Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Steamix

Steamix turns a NixOS machine into a Steam machine: it boots straight into Steam’s Gaming Mode (the gamescope-driven Deck UI), and the “Switch to Desktop” button in Steam’s power menu drops you into a regular desktop session — with an icon there to return to Gaming Mode, just like SteamOS.

It is deliberately not Jovian-NixOS. Jovian ships Valve’s full stack (powerbuttond, the vendor gamescope-session, Deck hardware support) and is the right choice for an actual Steam Deck. Steamix instead builds the parts a PC needs: a gamescope session modelled on Valve’s, a session switcher, and a login loop on greetd or SDDM. It is aimed at ordinary PCs used as living-room machines, and leans on upstream nixpkgs’ gamescope and Steam rather than Valve’s patched builds.

Design

Steamix is mechanism only:

  • the module touches plain pkgs, plain lib and upstream NixOS options, so it evaluates against a bare nixpkgs;
  • all policy (which host, which user, which desktop session) stays with the consumer’s configuration;
  • the packages nixpkgs does not have (SteamOS Manager, Decky Loader and the deckyPlugins scope) come from the flake’s overlays.default, and the module’s package options find them there.

Usage

Add the flake and import the module and its overlay:

{
  inputs.steamix = {
    url = "github:arunoruto/steamix";
    inputs.nixpkgs.follows = "nixpkgs";
  };

  # in a NixOS configuration:
  imports = [ inputs.steamix.nixosModules.default ];

  config = {
    nixpkgs.overlays = [ inputs.steamix.overlays.default ];

    steamix = {
      enable = true;
      user = "alice";
      desktopSession = "plasma";
    };
    # the desktop session itself still comes from your own config:
    services.desktopManager.plasma6.enable = true;
  };
}

Requirements and expectations:

  • A GPU with working Vulkan (gamescope requires it). AMD is the best-tested family for gamescope — it is what SteamOS runs on.
  • The desktop session must be a Wayland session; X11 sessions are out of scope.
  • steamix.autoStart (the default) logs steamix.user in without a password prompt, console-style — treat the machine like a game console, not a multi-user workstation. Screen lockers of the desktop session still work once switched.
  • Steam itself is unfree; nixpkgs.config.allowUnfree (or an equivalent predicate) must permit it.

HDR, VRR and the performance overlay are on by default and need no configuration — see Options. Resolution, refresh rate and anything else gamescope takes go through steamix.gamescope.args (one list element per argv entry):

steamix.gamescope.args = [
  "--output-width"
  "3840"
  "--output-height"
  "2160"
];

See How it works for the mechanism and its failure modes, Options for the reference, and Hardware setup & tuning for the checklist that makes the machine actually good — nixos-facter, nixos-hardware, gamescope display tuning, controllers. Roadmap is where the project is headed and why.

How it works

Eight pieces, eight files:

FilePiece
gaming-mode.nixThe Gaming Mode session: the gamescope session script, and the system-level bits it depends on.
session-select.nixThe steamos-session-select switcher Steam calls, plus the “Return to Gaming Mode” desktop entry.
autostart.nixThe default login path: a greetd session loop running the steamix-session launcher.
sddm.nixThe SteamOS-shaped login path: SDDM autologin plus SteamOS Manager, behind steamix.loginManager = "sddm".
tweaks.nixSteamOS’ system tuning — zram, earlyoom, a couple of network sysctls — behind steamix.tweaks.enable.
decky-loader.nixThe Decky plugin loader service, behind steamix.decky-loader.enable.
manager.nixThe SteamOS Manager daemon, behind steamix.manager.enable.
default.nixOptions, assertions, warnings.

The Gaming Mode session

nixpkgs has programs.steam.gamescopeSession, and this module used to just turn it on. It boils Valve’s session down to:

gamescope --steam -- steam -tenfoot

That is enough to see Gaming Mode, but not enough for it to behave, because Steam decides at startup what Gaming Mode can do by reading its environment, and expects a compositor set up a particular way. So the module ships its own session script — steamix-gamescope-session, registered as steam.desktop, the same session name — modelled on Valve’s gamescope-session with the Steam Deck hardware parts removed.

The parts that matter:

Two Xwayland servers (--xwayland-count 2 + STEAM_MULTIPLE_XWAYLANDS=1). SteamOS gives the Deck UI one X server and isolates games onto a second one. With a single server the Steam UI and the game share one focus stack, and the Steam-button overlay strands focus on the way out: the game keeps running but stops being drawn and stops receiving input, until something forces Steam to re-assert focus (opening a full-screen view, or “Resume” on the game’s page).

The WSI layer (ENABLE_GAMESCOPE_WSI=1 plus gamescope-wsi in hardware.graphics.extraPackages{,32}). VK_LAYER_FROG_gamescope_wsi is how a game’s Vulkan swapchain is handed to gamescope directly. It carries frame pacing, the framerate limiter, and HDR — without it HDR content is tonemapped down to SDR no matter what else is enabled.

Capability flags — the STEAM_GAMESCOPE_* and STEAM_* variables. These are not gamescope settings; Steam reads them once and believes them, and they are what makes toggles (HDR, VRR, tearing, scaling filters, the performance overlay) appear in Gaming Mode’s menus. The module only advertises what this session actually provides, which is why the Deck-specific ones (fan control, backlight, status LED, CEC, drive adoption) are absent, along with the ones that depend on Valve’s patched Mesa (dynamic VRS, the fifo fps limiter).

Session type. XDG_SESSION_TYPE=x11: Steam and its games are X11 clients of gamescope’s Xwayland servers, whatever the login path called the session.

Runtime state. The script creates gamescope’s mode-save file, the limiter fifo, and seeds the mangoapp config — the reason a plain env-and-args option pair could not express this session.

The performance overlay is one place this module knowingly diverges from Valve, in two halves.

The config file: Steam selects a level 0–4 by writing preset=N into ~/.local/share/Steam/config/mangohud.conf and then running mangohudctl toggle reload_config, which makes mangoapp re-parse its config. Valve’s script points MANGOHUD_CONFIGFILE at a fresh mktemp instead, so mangoapp ends up watching a file Steam never writes and every level looks the same. This module points it at the file Steam actually writes, and seeds it only when absent so an existing level survives.

The presets: the module defines what each level contains itself, through MANGOHUD_PRESETSFILE — MangoHud’s built-ins minus the Deck’s battery readouts, sized for the connected display at session start (steamix.mangoapp.fontScale). The presets file is the only place sizing can live: Steam rewrites the config file on every level change, and MANGOHUD_CONFIG makes MangoHud apply the preset twice — every element drawn double. The dense levels get their own, narrower scale, computed so the widest table row still fits the display.

The login loop

With steamix.autoStart on the default greetd path, there is no display manager and no greeter (loginManager = "sddm" swaps this whole section for SDDM autologin — see sddm.nix and the switching notes below). greetd’s default_session is used kiosk-style: it runs the steamix-session launcher directly as steamix.user, and whenever the session ends — Steam shut down, desktop logged out, compositor crashed — greetd simply runs it again.

The launcher does the part a display manager would normally do:

  1. Read (and delete) the one-shot selection file ~/.local/state/steamos-session-select; default to the steam session when it is absent.
  2. Resolve <session>.desktop in the collected services.displayManager.sessionData.desktops output — the same session registry GDM/SDDM would consult, so anything that registers a Wayland session (GNOME, Plasma, the gamescope Steam session, …) is launchable.
  3. Export the session identity (XDG_SESSION_TYPE, XDG_SESSION_DESKTOP, XDG_CURRENT_DESKTOP from the file’s DesktopNames=), push it into the systemd user manager and D-Bus activation environment, and exec the file’s Exec= line.

Where the session’s output goes

greetd hands its child the console, so a session started this way prints to whichever display owns fbcon — which is not necessarily the GPU the session renders on, and is unreachable over SSH. A Gaming Mode that fails to start then leaves a black screen and no way to ask why. The launcher runs the session under systemd-cat instead, so it is all in the journal:

journalctl -t steamix-session

systemd-cat execs the session rather than forking it, so greetd still sees one child and its bookkeeping is unchanged.

The Gaming Mode script also wraps itself the same way when nothing else has (guarded by STEAMIX_SESSION_JOURNAL, which the greetd launcher sets to prevent double-wrapping) — SDDM runs the .desktop file directly, and without this its sessions logged nowhere at all.

Making the session look like a session

Occupying greetd’s greeter slot is what buys the respawn loop, and it is also what nearly makes Desktop Mode impossible. greetd never sets XDG_SESSION_TYPE at all and marks its sessions XDG_SESSION_CLASS=greeter, so logind registers every one of them as class=greeter type=tty. Gaming Mode does not care — gamescope, like every wlroots compositor, promotes its own session type once it holds the DRM device — but a desktop does:

  • GNOME’s shell unit carries AssertEnvironment=XDG_SESSION_TYPE=wayland;
  • a type=tty session is never eligible to be the user’s logind display session, and mutter refuses to start without a logind session it can resolve.

So the module fixes it at both ends. A pam_env rule on the greetd PAM service — ordered after the base one and well before pam_systemd — sets XDG_SESSION_TYPE=wayland and XDG_SESSION_CLASS=user, so logind creates the session correctly instead of it being corrected afterwards. And step 3 above pushes the identity into the systemd user manager, because gnome-shell runs as a user service (org.gnome.Shell@.service) and would otherwise inherit none of it.

Because the selection file is consumed on every cycle, Gaming Mode is always the fallback: rebooting, logging out of the desktop, or a crashed session all land back in Steam. This mirrors SteamOS.

Session switching

The mechanism depends on steamix.loginManager. What follows describes the default greetd path; on the SDDM path the work is SteamOS Manager’s — Steam calls its SessionManagement1 D-Bus interface, the manager writes an SDDM autologin drop-in naming the target session and stops graphical-session.target, and SDDM’s Relogin starts whatever the drop-in named. Since gamescope is a plain child of sddm-helper rather than a session unit, the Gaming Mode script parks a stand-in gamescope-session.service in that target whose stop shuts Steam down cleanly — without it, stopping the target would do nothing and “Switch to Desktop” hung forever. The steamos-session-select script still exists on that path, delegating to steamosctl, so the desktop’s “Return to Gaming Mode” entry works the same either way.

SteamOS exposes switching through a steamos-session-select executable, and the Steam client hardcodes calls to it (historically with Valve’s KDE session names, e.g. steamos-session-select plasma). The module ships a script with that name and contract:

  • gamescope / steam → clear the selection (next login: Gaming Mode);
  • desktop / plasma* → write steamix.desktopSession into the selection file — whatever KDE flavor Steam asks for, you get your desktop;
  • anything else is taken as a literal session name, for scripting.

It then ends the current session: inside Gaming Mode via a clean steam -shutdown (gamescope exits with Steam), from a desktop via loginctl terminate-session. greetd’s loop does the rest.

On the desktop, a Return to Gaming Mode launcher entry (the same script, gamescope argument) completes the round trip.

   Gaming Mode (gamescope + steam)
        │  "Switch to Desktop"
        │  steamos-session-select plasma
        │    → writes ~/.local/state/steamos-session-select
        │    → steam -shutdown
        ▼
   greetd respawns steamix-session
        │  reads + deletes the selection file
        ▼
   Desktop session (steamix.desktopSession)
        │  "Return to Gaming Mode" (or plain logout / reboot)
        ▼
   greetd respawns steamix-session → Gaming Mode

Known limitations

  • Wayland only. X11 sessions would need the startx dance; not implemented.
  • No crash-loop brake. If the session dies instantly (e.g. a broken GPU driver), greetd respawns it in a tight loop; the launcher only sleeps before its own fallback path. Failsafe is the same as SteamOS: switch to another VT and log in there.
  • No greeter, no password in autoStart mode — by design, see the security note in the README.
  • gamescope runs Steam as its child, where Valve starts gamescope and Steam as separate systemd user units wired together by a ready socket. The simpler shape costs the session’s D-Bus/systemd integration (gamescope-session.target, the stats socket other tools consume) but keeps the module to one script. The SDDM path papers over the one consumer that matters — SteamOS Manager reads and stops gamescope-session.service — with a stand-in unit, not a real split.
  • Valve’s deeper integration (steamos-manager D-Bus API, updater, power button daemon, per-device quirks) is out of scope; use Jovian if you need it.

Options

All options live under steamix.*. Anything not listed here is configured through upstream nixpkgs options (programs.steam.*, programs.gamescope.*).

This page is the curated tour; the option reference is the complete listing, generated from the module so it cannot drift.

Every package Steamix installs has a package option (steamix.heroic.package, steamix.losslessScaling.package, steamix.library.boilr.package, …), so any of them can come from another nixpkgs, an overlay or an override. Steam and gamescope themselves are set through the upstream programs.steam.package and programs.gamescope.package. For example, lsfg-vk 2.x on a NixOS 26.05 machine:

steamix.losslessScaling = {
  package = unstable.lsfg-vk;       # `unstable` being a nixos-unstable package set
  ui.package = unstable.lsfg-vk-ui; # same major version as the layer
};

Core

steamix.enable

Type bool, default false. Master switch. Enables programs.steam and programs.gamescope, and registers this module’s own Gaming Mode session.

It also sets programs.steam.gamescopeSession.enable = false — nixpkgs’ session and this one both register steam.desktop, and this one is a superset (see How it works).

steamix.user

Type null or str, default null. The user the Gaming Mode session runs as. Required when autoStart is on — that user is logged in without a password prompt.

steamix.autoStart

Type bool, default true. Boot straight into Gaming Mode by owning the login path with a greetd session loop. Cannot be combined with a regular display manager (greetd aliases display-manager.service), so turn off the one your desktop configuration enables, for example services.displayManager.gdm.enable = false.

With autoStart = false the module only registers the “Steam” session: pick it from your own display manager’s session chooser; session switching (which relies on the loop) is not available.

steamix.desktopSession

Type null or str, default null, example "gnome". The Wayland session started by “Switch to Desktop” — a name from services.displayManager.sessionData.sessionNames (the basename of a wayland-sessions/*.desktop file). An unknown name is an eval-time error listing the valid ones; null keeps you in Gaming Mode (with a warning).

The session itself must be enabled through your normal configuration (services.desktopManager.gnome.enable, plasma6.enable, …) — this option only selects it.

steamix.steamArgs

Type list of str, default [ "-gamepadui" "-steamos3" "-steampal" "-steamdeck" "-pipewire-dmabuf" ]. Arguments Steam is launched with inside Gaming Mode — the set Valve’s own steam-launcher uses, plus -pipewire-dmabuf for Remote Play.

Leave -steamos3 in place: it is what ties Steam into gamescope’s focus handling and puts “Switch to Desktop” in the power menu.

Display features

Each of these does two things: passes the gamescope flag, and sets the STEAM_GAMESCOPE_* variable that makes the matching toggle appear in Gaming Mode’s settings. Steam reads those once at startup and simply believes them, so they are tied to the flag rather than exposed separately.

OptionDefaultgamescope flagSteam capability
steamix.hdr.enabletrue--hdr-enabledSTEAM_GAMESCOPE_HDR_SUPPORTED
steamix.vrr.enabletrue--adaptive-syncSTEAM_GAMESCOPE_VRR_SUPPORTED
steamix.tearing.enablefalse--immediate-flipsSTEAM_GAMESCOPE_TEARING_SUPPORTED
steamix.mangoapp.enabletrue--mangoappSTEAM_USE_MANGOAPP
  • HDR is safe to leave on for SDR displays — gamescope only drives HDR when the connected output advertises it. It needs the WSI layer below for games to hand HDR swapchains through; without it, HDR content is tonemapped to SDR. Turn it on per game in Gaming Mode’s display settings.
  • VRR is likewise ignored when the output does not support FreeSync.
  • Tearing is off by default: it trades visible tearing for latency, and VRR is the better answer on displays that have it.
  • mangoapp is what backs Steam’s built-in “Performance Overlay” levels; it is not the same thing as running MangoHud on a game yourself.

steamix.mangoapp.fontScale

Type null or float/int, default null. Multiplier for the performance overlay’s size — MangoHud’s font_scale, which scales the panel as well as the text.

MangoHud draws the overlay at a fixed size chosen for the Steam Deck’s 1280x800 panel, so it shrinks to an unreadable speck on a big television. The default scales it automatically against the tallest connected output (about 1.8x at 1440p, 2.7x at 2160p), which keeps it roughly Deck-sized. Set a number to pin it, or 1 for MangoHud’s own sizing.

The sizing is applied through MANGOHUD_PRESETSFILE — the module defines all five overlay levels itself and bakes the scale into them — because the two obvious routes both fail: Steam rewrites the config file on every level change, and MANGOHUD_CONFIG makes MangoHud apply the preset twice, drawing every element double. Levels 2–4 additionally get a narrower scale than level 1 where needed, computed so the widest table row still fits the display; at full scale on a 4K screen the dense levels would run off both edges.

steamix.mangoapp.pciDev

Type null or str, default null, example "0000:03:00.0". PCI address of the GPU the overlay reports on (MangoHud’s pci_dev, written as lspci -D prints it).

Only meaningful on a machine with more than one GPU: MangoHud enumerates every GPU and picks one itself, which need not be the one gamescope renders with — a desktop with an idle iGPU can get an overlay reporting that chip’s load while the games run on the discrete card. null leaves the choice to MangoHud.

steamix.mangoapp.package

The mangohud package that supplies mangoapp. It talks to gamescope over X atoms and a message queue, so when programs.gamescope.package is overridden to a much newer gamescope, override this to match.

steamix.mangoapp.backgroundAlpha

Type null or float/int, default 0.8. Opacity of the performance overlay’s backdrop, from 0 (invisible) to 1 (solid). MangoHud defaults to 0.5, which is legible on a monitor an arm’s length away and washes out into the game from across a living room. null leaves MangoHud’s own default alone.

steamix.realtime.enable

Type bool, default false. Adds --rt and gives gamescope cap_sys_nice through programs.gamescope.capSysNice, letting it renice itself for smoother frame pacing.

Off by default because the capability puts gamescope in secure-execution mode, where the loader ignores LD_LIBRARY_PATH/LD_PRELOAD — which has historically broken launching it out of Steam’s FHS environment. Turn it on, reboot, and confirm Gaming Mode still starts before keeping it.

gamescope

steamix.gamescope.args

Type list of str, default [ ]. Extra arguments appended to the gamescope command line, after the ones derived from the options above. One list element per argv entry:

steamix.gamescope.args = [
  "--output-width"
  "3840"
  "--output-height"
  "2160"
  "--nested-refresh"
  "120"
];

steamix.gamescope.env

Type attrs of str, default { }. Extra environment for the session, exported last so it overrides the module’s own defaults.

steamix.gamescope.wsi.enable

Type bool, default true. Installs the gamescope WSI Vulkan layer (VK_LAYER_FROG_gamescope_wsi) into hardware.graphics.extraPackages and extraPackages32, and sets ENABLE_GAMESCOPE_WSI=1 for the session.

The layer is how games present through gamescope instead of through plain Xwayland WSI; it carries frame pacing, the framerate limiter, and HDR swapchains. Requires hardware.graphics.enable32Bit (asserted).

steamix.gamescope.wsi.package / .package32

Defaulting to pkgs.gamescope-wsi and pkgs.pkgsi686Linux.gamescope-wsi. The layer speaks a versioned protocol to gamescope, so override these to keep it in step with a non-default programs.gamescope.package:

# `newer` being a newer nixpkgs, for example an unstable input
programs.gamescope.package = newer.gamescope;
steamix.gamescope.wsi = {
  package = newer.gamescope-wsi;
  package32 = newer.pkgsi686Linux.gamescope-wsi;
};

steamix.binaryCache.enable

Type bool, default true. Downloads SteamOS Manager, Decky Loader and the 32-bit gamescope WSI layer from steamix.cachix.org instead of compiling them, by adding the cache and its key to nix.settings next to cache.nixos.org. No trust beyond what importing the module already asks for: the module runs as root and the key is pinned. It helps only when the machine’s nixpkgs matches a revision CI built against; see Binary cache.

steamix.tweaks.enable

Type bool, default true. SteamOS’ opinionated system tuning, minus everything specific to Valve’s hardware. Every setting below is a mkDefault, so an individual one can be overridden without turning the group off.

SettingWhy
zramSwap — zstd, 50% of RAM, priority 100A console has no swap partition to fall back on, and shader compilation, Proton and a browser-based UI will happily use everything.
services.earlyoom — 5% free memory and swapUnder pressure the kernel’s own OOM killer arrives long after the machine stopped responding, which on a controller-driven box means reaching for the power button.
net.ipv4.tcp_mtu_probing = 1Some storefronts and matchmaking services sit behind PMTU black holes.
net.ipv4.tcp_fin_timeout = 5A game killed and relaunched cannot rebind its port while the old socket lingers, and the default timeout is far longer than a relaunch takes.

What else the module sets

Beyond the session itself:

SettingWhy
security.pam.loginLimits — hard nice of -8 for steamix.userProton runs some threads at negative niceness. Scoped to the account the session logs in as, not system-wide.
A polkit rule for NetworkManager, when steamix.user is set and networking.networkmanager.enable is onGaming Mode’s Wi-Fi settings write system connections, which normally needs the networkmanager group — impossible to grant mid-setup with only a controller in hand. Scoped to that one user’s local, active session; Jovian grants it to everyone in users.

Controller access is deliberately not handled here. programs.steam turns on hardware.steam-hardware, which installs Valve’s steam-devices rules — those already tag /dev/uinput for the active session and carry uaccess rules for every controller Steam supports. A blanket KERNEL=="hidraw*", TAG+="uaccess" would hand the session every HID device on the machine, which is more than Valve grants on a Deck. If you have a pad that Valve’s list misses, add a rule for that device.

steamix.heroic

Heroic Games Launcher for Epic, GOG and Amazon Prime Gaming libraries.

OptionTypeDefaultPurpose
.enableboolfalseInstall Heroic.
.packagepackagepkgs.heroicWhat to install.

This installs Heroic as a normal application, so it is there in Desktop Mode. Gaming Mode only shows what is in Steam’s library: use Heroic’s own per-game “Add to Steam” action, or add Heroic itself as a non-Steam game. Adding library entries declaratively is on the roadmap.

nixpkgs’ heroic runs the launcher in an FHS environment with the libraries its games and Wine builds expect; pkgs.heroic.override { extraPkgs = pkgs: [ ... ]; } adds more.

steamix.emulation

RetroArch with a set of libretro cores. It does not need the rest of Steamix (steamix.enable): it also serves machines Steam does not run on, such as a Raspberry Pi 4.

OptionTypeDefaultPurpose
.enableboolfalseInstall RetroArch with the cores.
.retroarch.packagepackagepkgs.retroarch-bareThe frontend the cores and settings are wrapped around.
.retroarch.coreslist of packageNestopia, bsnes, mGBA, PCSX ReARMedThe cores, from pkgs.libretro.
.retroarch.settingsattrs of string{ }retroarch.cfg settings applied over the user’s own on every start.
.retroarch.finalPackagepackageread-onlyThe RetroArch that is installed.
.kiosk.enableboolfalseBoot straight into RetroArch, full screen in cage, started again when it quits. Not together with Gaming Mode’s autoStart.
.kiosk.usernull or stringsteamix.userWho RetroArch runs as in the kiosk.

The default cores are free software and cover NES, SNES, Game Boy / Color / Advance and PlayStation. The fastest SNES and Mega Drive cores, Snes9x, Genesis Plus GX and PicoDrive, are unfree (non-commercial use only), so adding them needs unfree packages allowed; on small ARM boards they are the ones to use:

steamix.emulation.retroarch.cores = with pkgs.libretro; [
  nestopia snes9x genesis-plus-gx mgba pcsx-rearmed
];

Games go in ~/ROMs, where RetroArch’s file browser starts. With Gaming Mode, RetroArch is an application in Desktop Mode; add it as a non-Steam game to reach it from Gaming Mode.

steamix.library.boilr

BoilR puts games from other launchers in Steam’s library, so Gaming Mode can start them: Heroic (Epic and GOG), Legendary, Lutris, itch.io, Bottles, Flatpaks, MiniGalaxy and more. It runs on every login, inside the Gaming Mode session right before Steam starts, because it needs Steam closed. A game installed in Desktop Mode appears the next time Gaming Mode starts.

OptionTypeDefaultPurpose
.enableboolfalseSync on every login.
.packagepackagepkgs.boilrWhat to run.
.settingsTOML attrs{ }BoilR’s configuration, written to ~/.config/boilr/config.toml on every login.
.steamGridDbKeyFilenull or strnullA file with a SteamGridDB API key, read at login, for artwork.
.timeoutpositive int120Seconds a sync may take before Steam starts anyway.
steamix.library.boilr = {
  enable = true;
  steamGridDbKeyFile = "/run/secrets/steamgriddb"; # e.g. from sops-nix
  settings.steam.create_collections = true;
};

Repeating the sync is safe: BoilR removes only the shortcuts it added itself, and shortcuts you added by hand stay. Heroic’s games launch through Heroic by default, which keeps Epic sign-in and cloud saves working.

settings is the source of truth. Anything left out keeps BoilR’s default, which detects most launchers by itself; Steamix only sets steam.optimize_for_big_picture = true and enables SteamGridDB when a key file is given. Changes made in BoilR’s own window do not survive the next login. The key never enters the Nix store: the config is written with a placeholder and the key is filled in at login, into a file only the user can read.

steamix.losslessScaling

Lossless Scaling’s frame generation for Steam’s games, through lsfg-vk, a Vulkan layer that runs it with the DLL from your own copy of Lossless Scaling.

You need Lossless Scaling installed from Steam. lsfg-vk 2.x additionally needs its “lsfg-vk” beta branch: in Steam, Lossless Scaling > Properties > Game Versions & Betas. lsfg-vk finds the DLL in the usual Steam libraries; dll points elsewhere.

OptionTypeDefaultPurpose
.enableboolfalseInstall the layer for Steam’s games.
.packagepackagepkgs.lsfg-vkWhat to install.
.support32Bit.enableboolfalseAlso install the 32-bit layer, for 32-bit games.
.dllnull or strnullWhere Lossless Scaling’s DLL is, if lsfg-vk does not find it.
.allowHalfPrecisionbooltrueFP16 frame generation, 2 to 3 times faster where supported (2.x).
.profilesattrs of profiles{ }Declarative profiles; see below.
.ui.enableboolfalselsfg-vk’s configuration UI, for editing profiles in Desktop Mode.
.ui.packagepackagepkgs.lsfg-vk-uiWhich UI; keep it on the layer’s major version (Steamix warns otherwise).
.deckyPlugin.enableboolsee belowManage lsfg-vk from Gaming Mode with the Decky LSFG-VK plugin.
.deckyPlugin.packagenull or packagepkgs.deckyPlugins.decky-lsfg-vk or nullWhich plugin.

Three ways to run it.

  • From Gaming Mode, with Decky. When Decky Loader is on and profiles is empty, Steamix adds the Decky LSFG-VK plugin instead of installing the layer itself: per-game frame generation settings in the Quick Access menu, the way it works on SteamOS. Press its “Install lsfg-vk” button once; it installs its own lsfg-vk into your home. The plugin needs Decky to run plugins as the Steam user, as SteamOS does, because that is whose home it installs into:

    steamix.decky-loader.user = config.steamix.user;
    

    Without that, Steamix installs its own layer instead and warns why the plugin was left out. The plugin’s own lsfg-vk build is made for ordinary distributions and does not run on NixOS, so Steamix’s package repairs it: the plugin validates settings with a NixOS build of its lsfg-vk-cli, and its UI launcher starts a NixOS build of its UI. The layers games load stay as upstream built them. It carries lsfg-vk’s build, so it is unfree. After a NixOS update that changed the plugin, press “Reinstall” in it, so the copies in your home match.

  • Declaratively, with profiles set:

steamix.losslessScaling = {
  enable = true;
  profiles.elden-ring = {
    activeIn = [ "1245620" ]; # Steam App ID, executable or process name
    multiplier = 2;
    flowScale = 0.75;
  };
};

Steamix installs the layer, writes the whole config to /etc/lsfg-vk/conf.toml and points every session at it, read-only; the Decky plugin is left out, since it could not change it.

  • Interactively without Decky: with profiles empty and no Decky, the config is ~/.config/lsfg-vk/conf.toml, created with defaults the first time a game loads the layer and edited with lsfg-vk’s UI (ui.enable).

A profile has activeIn (Steam App IDs, executables, Windows .exe names or process names; lsfg-vk 1.x matches only executables and process names, and Steamix warns about App IDs there), multiplier (2 doubles the frame rate), flowScale (0.25 to 1, the resolution of the motion estimation), performanceMode, pacingMode (2.x) and hdrMode (1.x).

Versions and licence. nixpkgs has shipped two major versions with different config formats. NixOS 26.05 has the MIT-licensed 1.0; nixos-unstable has 2.0, licensed CC BY-NC-ND 4.0, which nixpkgs treats as unfree. Steamix writes whichever format the installed version reads, and the variable it reads the path from (LSFG_CONFIG or LSFGVK_CONFIG). For 2.0, nixpkgs.config.allowUnfree (or a predicate allowing lsfg-vk) has to permit it. Its licence also forbids redistribution, so no binary cache carries it, Steamix’s included, and it builds on your machine.

steamix.proton

GE-Proton, and AMD FSR 4 through it.

OptionTypeDefaultPurpose
.ge.enableboolfalseInstall GE-Proton as a compatibility tool.
.ge.packagepackagepkgs.proton-ge-binWhich GE-Proton; also what the FSR 4 variant is built from.
.fsr4.enableboolfalseAdd a “GE-Proton (FSR 4)” compatibility tool.
.fsr4.versionnull or strnullThe FSR 4 DLL version to ask for, e.g. "4.1.1"; null takes GE-Proton’s default.
.fsr4.indicatorboolfalseDraw AMD’s FSR watermark, to see whether FSR 4 is active.

FSR 4 is AMD’s machine-learning upscaler. GE-Proton can swap it in for the FSR 3.1 a game ships: with PROTON_FSR4_UPGRADE set, it downloads AMD’s FSR 4 DLL when the game starts and uses it instead. It needs an AMD RDNA 3 or RDNA 4 GPU and a game with FSR 3.1, and a network connection the first time. The DLL is GE-Proton’s download, not something Nix manages.

fsr4.enable adds GE-Proton with that flag in front of it, as its own compatibility tool: “GE-Proton (FSR 4)”, internal name GE-Proton-FSR4. It sits next to a plain GE-Proton instead of replacing it, so the two never clash. Pick it per game in Steam (Properties > Compatibility), or make it Steam’s default compatibility tool to use it everywhere. A game’s launch options still win: PROTON_FSR4_UPGRADE=0 %command% turns it off for that game, and PROTON_FSR4_UPGRADE=4.1.1 %command% pins a version.

steamix.proton.fsr4 = {
  enable = true;
  indicator = true; # until you have seen it work
};

FSR 1 is a different thing and needs nothing: gamescope’s FSR 1 upscaler is the “Scaling Filter” in Gaming Mode’s Quick Access menu, under Performance, for games that render below the display’s resolution.

steamix.decky-loader

Decky Loader injects a plugin menu into Steam’s Gaming Mode UI.

OptionTypeDefaultPurpose
.enableboolfalseRun the loader.
.packagenull or packagepkgs.decky-loader or nullWhat to run.
.userstr"decky"Unprivileged user plugins run as; created automatically when left at the default.
.stateDirpath/var/lib/decky-loaderInstalled plugins and their data.
.extraPackageslist of package[ ]Extra tools on the loader’s PATH, for plugins that shell out.
.extraPythonPackagesfunction_: [ ]Extra Python modules importable by plugins, as a function of the interpreter’s package set.

nixpkgs does not package Decky Loader. package therefore defaults to pkgs.decky-loader only when something has provided it, and to null otherwise — in which case the module configures nothing and emits a warning rather than failing to evaluate. That keeps the module usable against a bare nixpkgs, which is the same constraint the rest of it follows. Steamix builds one in packages/decky-loader, and its overlays.default provides it as pkgs.decky-loader; without the overlay, supply your own:

steamix.decky-loader = {
  enable = true;
  package = inputs.somewhere.packages.${pkgs.system}.decky-loader;
  extraPythonPackages = ps: [ ps.hid ];
};

The service runs as root and drops to user for plugins. That is upstream’s design, not an oversight — running the loader unprivileged is unsupported and breaks.

What the module does for Decky beyond running it

Decky does not draw its own window — it injects into Steam’s UI over the CEF debugger that steamwebhelper opens on 127.0.0.1:8080, and Steam only opens that when .cef-enable-remote-debugging exists in its data directory at startup. Without it the loader starts, serves happily on port 1337, and is simply never visible in Gaming Mode.

So the module creates that file in steamix.user’s Steam directory, through that user’s own tmpfiles instance at login, so every directory it creates on the way belongs to the user. Steam has to be restarted afterwards — switching the configuration is not enough, since Steam only reads the flag when it launches.

Be aware of what that implies: an unauthenticated debugger into the Steam client, bound to loopback, for as long as Decky is enabled. That is inherent to how Decky works, not something this module adds on top.

The service also gets lsof and systemctl on its PATH — the loader uses the first to find that CEF socket and the second to manage its own unit.

steamix.decky-loader.plugins

Type list of package, default [ ]. Plugins installed declaratively:

steamix.decky-loader.plugins = with pkgs.deckyPlugins; [
  hltb-for-deck
  protondb-decky
];

Each is symlinked into the plugin directory. Decky scans that directory for subdirectories containing a plugin.json and resolves symlinks on the way, so store paths load exactly like installed ones.

This composes with the in-game store rather than replacing it. Plugins installed through Decky’s UI are ordinary directories sitting alongside these symlinks, and keep working and updating as normal — so you can declare the ones you rely on and still try something from the store without touching your configuration.

What a declared plugin gives up is Decky’s own updater: its version is whatever the package pins, so updating means bumping the package. Uninstalling one from the UI removes only the symlink, which the next boot restores — declarative wins, which is the point, but it is a behaviour change worth knowing.

Per-plugin settings and data are unaffected either way. Decky keeps those in settings/<plugin> and data/<plugin> beside the plugin directory, never inside it, which is exactly what makes read-only store paths workable here.

Plugins are packaged in packages/deckyPlugins/, built by buildDeckyPlugin from the prebuilt tarball each plugin publishes per release. Adding one is a handful of lines — pname, version, owner, hash.

steamix.manager

SteamOS Manager is the system daemon Steam queries for things it cannot reach itself. Steam probes the interface at startup and shows only the settings the daemon can actually back, so on hardware that is not a Steam Deck most of it simply does not appear.

OptionTypeDefaultPurpose
.enableboolfalseRun the daemon.
.packagenull or packagepkgs.steamos-manager or nullWhat to run.

Like Decky’s, the package is not in nixpkgs; Steamix builds one in packages/steamos-manager and provides it through overlays.default. Without the overlay the option resolves to null and the module stays inert with a warning.

Why it is off by default

Enabling it on a greetd login path breaks “Switch to Desktop.” The daemon advertises SessionManagement1, which Steam prefers over the steamos-session-select script this module ships — and upstream implements session switching by writing an SDDM autologin drop-in:

#![allow(unused)]
fn main() {
const CONFIG_PREFIX: &str = "/etc/sddm.conf.d";
write(paths.temp_config(), format!("[Autologin]\nSession={session}\n"))
}

Nothing reads that under greetd, and it asks for gamescope-wayland.desktop by name where this module registers steam. The result is a stop of graphical-session.target followed by greetd putting you straight back into Gaming Mode. The module warns when it sees both options on.

That assumption is not unreasonable of Valve — SteamOS ships KDE and SDDM — but it is baked into the daemon rather than abstracted behind the display manager.

What you actually get on a desktop

Plausibly working: TdpLimit1, GpuPerformanceLevel1, GpuPowerProfile1, CpuScaling1, CpuBoost1, PerformanceProfile1 (generic amdgpu and cpufreq sysfs) and Storage1 (external drive formatting).

Not available: FanControl1, BatteryChargeLimit1, AmbientLightSensor1, UpdateBios1 and UpdateDock1 are Steam Deck hardware; HdmiCec1/HdmiCec2 need Valve’s cecd, which this tree does not ship.

It does not fix Gaming Mode’s resolution list. That was the original reason to reach for it, and the interface specification has no display or resolution interface at all — Steam sets the game resolution directly through gamescope’s GAMESCOPE_XWAYLAND_MODE_CONTROL atom, from a list it builds itself.

steamix.loginManager

Type enum [ "greetd" "sddm" ], default "greetd". Which display manager owns the login path when steamix.autoStart is on. The difference is not cosmetic.

greetdsddm
Needs anything outside nixpkgsnoyes — steamos-manager
logind sessionclass greeter, type tty, corrected by a pam_env ruleclass user, type wayland natively
Respawn loopgreetd’s greeter slot, used kiosk-stylesddm.autoLogin.relogin
Session switchingsteamos-session-select writes a state file the launcher consumesSteamOS Manager’s SessionManagement1, which Steam drives directly
ClosuresmallQt, mitigated by sddm.wayland.enable

greetd is the default because it is self-contained: nothing here needs a package nixpkgs does not have. The price is that greetd registers its sessions as class greeter, type tty, which desktop compositors refuse to start on, so the module corrects it with a pam_env rule — a fix that works but that upstream never intended.

sddm is what SteamOS itself runs, so Valve’s software finds the shape it expects and nothing needs correcting. It also unlocks steamix.manager, which this path enables for you: switching goes through the interface Steam prefers, rather than through a script emulating it.

Two things the module arranges on the SDDM path so it actually works:

  • Gaming Mode is registered under gamescope-wayland as well as steam. SteamOS Manager asks for gamescope-wayland.desktop by name when Steam requests game mode and that string is not configurable, so without the alias you could leave Gaming Mode but never return to it.
  • steamos-session-select keeps its name but delegates to steamosctl switch-to-game-mode/switch-to-desktop-mode, so the desktop’s “Return to Gaming Mode” entry works the same on both paths.

Picking sddm without a steamos-manager package is an assertion, not a silent no-op — nothing else can write SDDM’s autologin drop-in, so the module refuses rather than leaving you with a switch that does nothing.

Option reference

Every steamix.* option, generated from the declarations in steamix/modules/nixos. The Options page is the curated tour — what to reach for and why; this page is the complete, cannot-drift listing.

steamix.enable

Whether to enable the SteamOS-like Gaming Mode experience.

Type: boolean

Default:

false

Example:

true

Declared by:

steamix.autoStart

Boot straight into Gaming Mode. This owns the login path — see steamix.loginManager for which display manager does it — so it cannot be combined with another one. Disable it to merely register the “Steam” session with whatever display manager you already run.

Type: boolean

Default:

true

Declared by:

steamix.binaryCache.enable

Download Steamix’s own packages from its binary cache, steamix.cachix.org, instead of compiling them: SteamOS Manager, Decky Loader and the 32-bit gamescope WSI layer, which cache.nixos.org does not carry. Adds the cache and its public key to nix.settings, next to whatever is already there.

On by default because it asks for no trust beyond what importing this module already does: the module runs as root, and the cache is signed with a key pinned here. It only helps when the machine’s nixpkgs is a revision Steamix’s CI built against; otherwise Nix builds locally, as it would without the cache. Turn it off to build everything yourself.

Type: boolean

Default:

true

Declared by:

steamix.decky-loader.enable

Whether to enable Decky Loader, the Steam Deck plugin loader.

Type: boolean

Default:

false

Example:

true

Declared by:

steamix.decky-loader.package

The Decky Loader package to run, or null when none is available.

nixpkgs does not package Decky Loader, so this defaults to null unless an overlay provides pkgs.decky-loader. With null the module does nothing and warns instead of breaking evaluation.

Type: null or package

Default:

pkgs.decky-loader or null

Declared by:

steamix.decky-loader.extraPackages

Extra packages on the loader’s PATH. Some plugins shell out to tools that are not part of a minimal system.

Type: list of package

Default:

[ ]

Example:

[ pkgs.curl pkgs.unzip ]

Declared by:

steamix.decky-loader.extraPythonPackages

Extra Python packages importable by plugins, as a function of the interpreter’s package set.

Type: function that evaluates to a(n) list of package

Default:

pythonPackages: [ ]

Example:

pythonPackages: with pythonPackages; [ hid ]

Declared by:

steamix.decky-loader.plugins

Plugins to install declaratively. Each is symlinked into the plugin directory, which Decky scans for directories containing a plugin.json; it resolves symlinks, so store paths load normally.

This composes with the in-game store rather than replacing it: plugins installed through Decky’s UI are ordinary directories alongside these symlinks and keep working. What a plugin declared here gives up is Decky’s own updater — the version is whatever the package pins — and uninstalling it from the UI only removes the symlink until the next boot restores it.

Per-plugin settings and data are unaffected either way: Decky keeps those in settings/ and data/ beside the plugin directory, never inside it, which is what lets these be read-only.

Type: list of package

Default:

[ ]

Example:

with pkgs.deckyPlugins; [ hltb-for-deck protondb-decky ]

Declared by:

steamix.decky-loader.stateDir

Directory holding installed plugins and their data.

Type: absolute path

Default:

"/var/lib/decky-loader"

Declared by:

steamix.decky-loader.user

The unprivileged user plugins are run as. The loader itself runs as root and drops to this user; see the note in the service definition.

Type: string

Default:

"decky"

Declared by:

steamix.desktopSession

Name of the Wayland session started by “Switch to Desktop” in Gaming Mode (a session name from services.displayManager.sessionData.sessionNames, i.e. the basename of a wayland-sessions/*.desktop file). When null, switching to the desktop relaunches Gaming Mode.

Type: null or string

Default:

null

Example:

"gnome"

Declared by:

steamix.emulation.enable

Whether to enable emulation: RetroArch with a set of libretro cores.

Type: boolean

Default:

false

Example:

true

Declared by:

steamix.emulation.kiosk.enable

Whether to enable booting straight into RetroArch, full screen, for machines that run nothing else on their display (no Steam, no desktop). It runs as steamix.emulation.kiosk.user in the cage compositor, and is started again if it exits .

Type: boolean

Default:

false

Example:

true

Declared by:

steamix.emulation.kiosk.user

The user RetroArch runs as in kiosk mode.

Type: null or string

Default:

config.steamix.user

Declared by:

steamix.emulation.retroarch.package

The retroarch-bare package to use. The frontend alone; steamix.emulation.retroarch.cores and steamix.emulation.retroarch.settings are wrapped around it.

Type: package

Default:

pkgs.retroarch-bare

Declared by:

steamix.emulation.retroarch.cores

The libretro cores RetroArch can run, from pkgs.libretro. The default ones are free software.

Type: list of package

Default:

with pkgs.libretro; [ nestopia bsnes mgba pcsx-rearmed ]

Example:

# unfree, but much faster on small ARM boards
with pkgs.libretro; [ nestopia snes9x genesis-plus-gx mgba pcsx-rearmed ]

Declared by:

steamix.emulation.retroarch.finalPackage

The RetroArch that is installed.

Type: package (read only)

Default: RetroArch with the cores and settings above

Declared by:

steamix.emulation.retroarch.settings

retroarch.cfg settings, applied over the user’s own configuration on every start, so these always win over what is changed in RetroArch’s menus.

Type: attribute set of string

Default:

{ }

Example:

{
  menu_driver = "ozone";
  video_fullscreen = "true";
}

Declared by:

steamix.gamescope.args

Extra arguments appended to the gamescope command line, after the ones this module derives from the options above.

Type: list of string

Default:

[ ]

Example:

[
  "--output-width"
  "3840"
  "--output-height"
  "2160"
]

Declared by:

steamix.gamescope.env

Extra environment variables for the Gaming Mode session. These are exported last, so they override the module’s own defaults.

Type: attribute set of string

Default:

{ }

Example:

{
  GAMESCOPE_MODE_SAVE_FILE = "/tmp/modes.cfg";
}

Declared by:

steamix.gamescope.wsi.enable

Install the gamescope WSI Vulkan layer (VK_LAYER_FROG_gamescope_wsi) and enable it for the session. The layer is how games present through gamescope directly — it carries frame pacing, the fps limiter, and HDR swapchains. Without it HDR content is tonemapped down to SDR.

Type: boolean

Default:

true

Declared by:

steamix.gamescope.wsi.package

The gamescope-wsi package to use. Added to hardware.graphics.extraPackages. The layer speaks a versioned protocol to gamescope, so override this to keep it in step with a non-default programs.gamescope.package.

Type: package

Default:

pkgs.gamescope-wsi

Declared by:

steamix.gamescope.wsi.package32

The gamescope-wsi package to use. Added to hardware.graphics.extraPackages32. Needed by 32-bit games and by Proton’s 32-bit halves.

Type: package

Default:

pkgs.pkgsi686Linux.gamescope-wsi

Declared by:

steamix.hdr.enable

Allow HDR output in Gaming Mode: starts gamescope with --hdr-enabled and tells Steam the session supports HDR, which is what puts the HDR toggle in Gaming Mode’s display settings.

Harmless on SDR displays — gamescope only drives HDR when the connected output actually advertises it. Requires the gamescope WSI Vulkan layer (steamix.gamescope.wsi.enable) for games to hand HDR swapchains through.

Type: boolean

Default:

true

Declared by:

steamix.heroic.enable

Whether to enable Heroic Games Launcher, for Epic, GOG and Amazon Prime Gaming games.

Type: boolean

Default:

false

Example:

true

Declared by:

steamix.heroic.package

The heroic package to use. nixpkgs’ heroic runs the launcher in an FHS environment with the libraries its games and Wine builds expect. Use pkgs.heroic.override { extraPkgs = pkgs: [ ... ]; } to add more.

Type: package

Default:

pkgs.heroic

Declared by:

steamix.library.boilr.enable

Whether to enable syncing games from other launchers (Heroic, Legendary, Lutris, itch.io, Bottles, Flatpak, …) into Steam’s library with BoilR, on every login before Steam starts, so Gaming Mode can launch them .

Type: boolean

Default:

false

Example:

true

Declared by:

steamix.library.boilr.package

The boilr package to use.

Type: package

Default:

pkgs.boilr

Declared by:

steamix.library.boilr.settings

BoilR’s configuration, written to ~/.config/boilr/config.toml on every login (see BoilR’s configuration reference). Anything not set keeps BoilR’s default, which detects most launchers on its own. Steamix sets steam.optimize_for_big_picture, and enables SteamGridDB only when steamix.library.boilr.steamGridDbKeyFile is set; both can be overridden here.

Because the file is rewritten on every login, this is the source of truth: changes made in BoilR’s own window do not last.

Type: TOML value

Default:

{ }

Example:

{
  lutris.enabled = false;
  steam.create_collections = true;
  # launch these Heroic games directly instead of through Heroic
  heroic.launch_games_through_heroic = [ "Fortnite" ];
  heroic.default_launch_through_heroic = true;
}

Declared by:

steamix.library.boilr.steamGridDbKeyFile

A file holding a SteamGridDB API key, read at login, so the key never enters the Nix store. With it, BoilR downloads covers, banners and logos for every game it adds. It must be readable by steamix.user. null syncs without artwork.

Type: null or string

Default:

null

Example:

"/run/secrets/steamgriddb"

Declared by:

steamix.library.boilr.timeout

Seconds a sync may take before Steam is started anyway. The first sync with artwork downloads images for every game; later ones only for new games.

Type: positive integer, meaning >0

Default:

120

Declared by:

steamix.loginManager

Which display manager owns the login path when steamix.autoStart is on. Two shapes, and the difference is not cosmetic:

greetd is self-contained. It needs nothing outside nixpkgs, and session switching goes through the steamos-session-select script this module ships. The cost is that greetd registers its sessions as logind class greeter, type tty, so the module corrects that with a pam_env rule for desktop compositors to be willing to start.

sddm is what SteamOS itself runs, and matches what Valve’s software expects. Sessions come out as class user, type wayland with no correction needed, and switching goes through SteamOS Manager’s SessionManagement1 interface — the path the Steam client prefers. It requires steamix.manager and therefore a steamos-manager package, which nixpkgs does not have; an assertion will tell you if it is missing.

Type: one of “greetd”, “sddm”

Default:

"greetd"

Declared by:

steamix.losslessScaling.enable

Whether to enable Lossless Scaling frame generation for Steam’s games, through the lsfg-vk Vulkan layer. Needs Lossless Scaling installed from Steam; for lsfg-vk 2.x, on its “lsfg-vk” beta branch .

Type: boolean

Default:

false

Example:

true

Declared by:

steamix.losslessScaling.package

The lsfg-vk package to use. From 2.0, lsfg-vk is licensed CC BY-NC-ND 4.0, which nixpkgs treats as unfree, so nixpkgs.config.allowUnfree (or a predicate allowing lsfg-vk) has to permit it; the licence also forbids redistribution, so no binary cache carries it and it builds locally. NixOS 26.05 ships the MIT-licensed 1.0, whose config format differs; both work.

Type: package

Default:

pkgs.lsfg-vk

Declared by:

steamix.losslessScaling.allowHalfPrecision

Use half-precision (FP16) arithmetic where the GPU supports it, for 2 to 3 times faster frame generation. lsfg-vk 2.x only.

Type: boolean

Default:

true

Declared by:

steamix.losslessScaling.deckyPlugin.enable

Manage lsfg-vk from Gaming Mode with the Decky LSFG-VK plugin: its Quick Access menu has per-game frame generation settings, the way it works on SteamOS. Press its “Install lsfg-vk” button once.

The plugin installs its own lsfg-vk into the user’s home, so Steamix does not install the layer, config or UI while it is on. It cannot be combined with profiles, which would be a config the plugin cannot change. It needs Decky to run plugins as the Steam user (steamix.decky-loader.user set to steamix.user), as SteamOS does, because that is whose home it installs into.

Type: boolean

Default: on when Decky Loader is enabled, profiles is empty, and Decky runs plugins as steamix.user

Declared by:

steamix.losslessScaling.deckyPlugin.package

The plugin, from Steamix’s overlay by default. It carries lsfg-vk’s own build (CC BY-NC-ND 4.0), so it is unfree, like lsfg-vk 2.x.

Type: null or package

Default:

pkgs.deckyPlugins.decky-lsfg-vk or null

Declared by:

steamix.losslessScaling.dll

Where Lossless Scaling’s DLL is, when it is not in a Steam library lsfg-vk searches by itself (the default ones under ~/.local/share and ~/.steam). A directory or the DLL itself. Only written in the declarative mode; otherwise set it in the config yourself.

Type: null or string

Default:

null

Example:

"/mnt/games/SteamLibrary/steamapps/common/Lossless Scaling"

Declared by:

steamix.losslessScaling.profiles

Frame generation profiles, by name. When any are set, Steamix writes the whole config to /etc/lsfg-vk/conf.toml and points every session at it, so it is read-only: lsfg-vk’s UI and the Decky plugin cannot change it. Leave this empty to configure lsfg-vk interactively instead, in ~/.config/lsfg-vk/conf.toml.

Type: attribute set of (submodule)

Default:

{ }

Example:

{
  elden-ring = {
    activeIn = [ "1245620" ];
    multiplier = 2;
    flowScale = 0.75;
  };
}

Declared by:

steamix.losslessScaling.profiles.<name>.activeIn

Games this profile applies to: Linux executables (matched as a suffix of the path), Windows .exe names for Proton games, process names, or Steam App IDs. lsfg-vk 1.x matches executables and process names only, not Steam App IDs.

Type: non-empty (list of string)

Example:

[
  "1245620"
  "eldenring.exe"
]

Declared by:

steamix.losslessScaling.profiles.<name>.flowScale

Resolution of the motion estimation, as a fraction of the game’s. Lower is faster and less accurate; on a large display 0.5 to 0.75 often looks the same.

Type: integer or floating point number between 0.25 and 1 (both inclusive)

Default:

1.0

Declared by:

steamix.losslessScaling.profiles.<name>.hdrMode

Treat the game’s output as HDR. lsfg-vk 1.x only; 2.x detects it.

Type: boolean

Default:

false

Declared by:

steamix.losslessScaling.profiles.<name>.multiplier

Frames shown per rendered frame: 2 doubles the frame rate.

Type: integer between 2 and 20 (both inclusive)

Default:

2

Declared by:

steamix.losslessScaling.profiles.<name>.pacingMode

Frame pacing. lsfg-vk 2.x only; ignored by 1.x.

Type: one of “vsync”, “none”

Default:

"vsync"

Declared by:

steamix.losslessScaling.profiles.<name>.performanceMode

Use the lighter frame generation model.

Type: boolean

Default:

false

Declared by:

steamix.losslessScaling.support32Bit.enable

Whether to enable the 32-bit layer as well, for 32-bit games. It is a second local build of lsfg-vk .

Type: boolean

Default:

false

Example:

true

Declared by:

steamix.losslessScaling.support32Bit.package

The lsfg-vk package to use.

Type: package

Default:

pkgs.pkgsi686Linux.lsfg-vk

Declared by:

steamix.losslessScaling.ui.enable

Whether to enable lsfg-vk’s configuration UI, for editing profiles from Desktop Mode when profiles is empty .

Type: boolean

Default:

false

Example:

true

Declared by:

steamix.losslessScaling.ui.package

The lsfg-vk-ui package to use. The UI writes the config format of its own major version, so keep it on the same major version as steamix.losslessScaling.package; Steamix warns when they differ. When overriding the layer, for example to a newer nixpkgs’ lsfg-vk, override this from the same nixpkgs.

Type: package

Default:

pkgs.lsfg-vk-ui

Declared by:

steamix.manager.enable

Whether to enable SteamOS Manager, the OS-integration daemon Steam talks to.

Type: boolean

Default:

false

Example:

true

Declared by:

steamix.manager.package

The SteamOS Manager package, or null when none is available.

nixpkgs does not package it, so this is null unless an overlay provides pkgs.steamos-manager; the module then does nothing and warns rather than failing to evaluate.

Type: null or package

Default:

pkgs.steamos-manager or null

Declared by:

steamix.mangoapp.enable

Run gamescope’s mangoapp overlay, which is what backs Steam’s built-in “Performance Overlay” levels in Gaming Mode.

Type: boolean

Default:

true

Declared by:

steamix.mangoapp.package

The mangohud package to use. Supplies the mangoapp binary gamescope spawns for the overlay. mangoapp talks to gamescope over X atoms and a message queue, so when programs.gamescope.package is overridden to a much newer gamescope, override this to match.

Type: package

Default:

pkgs.mangohud

Declared by:

steamix.mangoapp.backgroundAlpha

Opacity of the performance overlay’s backdrop, from 0 (invisible) to 1 (solid).

MangoHud defaults to 0.5, which is legible on a monitor an arm’s length away and washes out into the game from across a living room. null leaves MangoHud’s own default alone.

Type: null or integer or floating point number between 0 and 1 (both inclusive)

Default:

0.8

Example:

0.5

Declared by:

steamix.mangoapp.fontScale

Multiplier for the performance overlay’s size (MangoHud’s font_scale, which scales the panel as well as the text).

MangoHud draws the overlay at a fixed size that was picked for the Steam Deck’s 1280x800 panel, so it shrinks to an unreadable speck on a 4K television. The default null scales it automatically against the tallest connected output — roughly 1.8x at 1440p and 2.7x at 2160p — which keeps it about the size it is on a Deck. Set a number to pin it, or 1 for MangoHud’s own sizing.

Type: null or (positive integer or floating point number, meaning >0)

Default:

null

Example:

2.7

Declared by:

steamix.mangoapp.pciDev

PCI address of the GPU the overlay should report on, written domain:bus:device.function as lspci -D prints it (MangoHud’s pci_dev).

Only meaningful on a machine with more than one GPU. MangoHud enumerates every GPU it finds and picks one itself, which need not be the one gamescope is rendering with — a desktop with an unused integrated GPU can end up with an overlay reporting that chip’s load and temperature while the games run on the discrete card. null leaves the choice to MangoHud.

Type: null or string

Default:

null

Example:

"0000:03:00.0"

Declared by:

steamix.proton.fsr4.enable

Whether to enable a “GE-Proton (FSR 4)” compatibility tool: GE-Proton with PROTON_FSR4_UPGRADE set, so it downloads and uses AMD’s FSR 4 DLL in games that ship FSR 3.1. Needs an AMD RDNA 3 or RDNA 4 GPU. Choose it per game in Steam, or as Steam’s default compatibility tool. Built from steamix.proton.ge.package, and independent of steamix.proton.ge.enable .

Type: boolean

Default:

false

Example:

true

Declared by:

steamix.proton.fsr4.indicator

Draw AMD’s FSR watermark in games, which shows whether FSR 4 upscaling and frame generation are actually active.

Type: boolean

Default:

false

Declared by:

steamix.proton.fsr4.version

The FSR 4 DLL version to ask GE-Proton for. null takes the one GE-Proton currently considers the default.

Type: null or string

Default:

null

Example:

"4.1.1"

Declared by:

steamix.proton.ge.enable

Whether to enable GE-Proton, a Proton build with extra game fixes and media codecs, as a Steam compatibility tool to pick per game .

Type: boolean

Default:

false

Example:

true

Declared by:

steamix.proton.ge.package

The proton-ge-bin package to use. Anything with a steamcompattool output, as programs.steam.extraCompatPackages expects. Also what the FSR 4 variant is built from.

Type: package

Default:

pkgs.proton-ge-bin

Declared by:

steamix.realtime.enable

Let gamescope renice itself into realtime scheduling (--rt), by giving the binary cap_sys_nice via programs.gamescope.capSysNice.

Off by default: the capability puts gamescope in secure-execution mode, where the loader ignores LD_LIBRARY_PATH/LD_PRELOAD, which has historically broken launching it from Steam’s FHS environment. Turn it on and verify Gaming Mode still starts before keeping it.

Type: boolean

Default:

false

Declared by:

steamix.steamArgs

Arguments Steam is launched with inside Gaming Mode. The default is the set Valve’s own steam-launcher uses, plus -pipewire-dmabuf for Remote Play. -steamos3 is what ties Steam into gamescope’s focus handling and puts “Switch to Desktop” in the power menu.

Type: list of string

Default:

[
  "-gamepadui"
  "-steamos3"
  "-steampal"
  "-steamdeck"
  "-pipewire-dmabuf"
]

Declared by:

steamix.tearing.enable

Allow tearing presentation (--immediate-flips) and expose Steam’s per-game tearing toggle. Trades visible tearing for latency; off by default because VRR is the better answer on displays that have it.

Type: boolean

Default:

false

Declared by:

steamix.tweaks.enable

Apply the parts of SteamOS’ opinionated system tuning that are not specific to Valve’s hardware: compressed swap, an out-of-memory killer that acts before the kernel’s does, and a handful of network sysctls games trip over.

Every setting is a lib.mkDefault, so an individual one can still be overridden without turning the whole group off.

Type: boolean

Default:

true

Declared by:

steamix.user

The user the Gaming Mode session runs as. Required when steamix.autoStart is enabled — the machine logs this user in without a password prompt, console-style.

Type: null or string

Default:

null

Example:

"alice"

Declared by:

steamix.vrr.enable

Allow variable refresh rate (FreeSync/G-Sync): starts gamescope with --adaptive-sync and exposes the VRR toggle in Gaming Mode’s display settings. Ignored by gamescope when the output does not support it.

Type: boolean

Default:

true

Declared by:

Hardware setup & tuning

The module gets you into Gaming Mode; how well it runs is decided by the hardware configuration around it. Nothing here is Steamix-specific machinery — it is the checklist of things worth setting up by hand until someone figures out how to automate them. Like the rest of these pages it is written split-ready: when Steamix becomes its own repository, this guide moves with it.

nixos-facter: describe the hardware as data

nixos-facter is the modern take on nixos-generate-config’s hardware detection. Instead of generating a Nix file once and letting it rot, it writes a JSON report of everything it probes — CPU, GPU, buses, network interfaces, bluetooth, disks — and the facter modules (formerly the separate nixos-facter-modules, now upstreamed into nixpkgs as hardware.facter.*) interpret that report at evaluation time.

Why bother, when hardware-configuration.nix exists?

  • The report is data, the interpretation is code. When the modules learn a better way to configure some device class, every host with a report picks it up on the next rebuild — a generated hardware-configuration.nix never improves after the day it was written.
  • It sees more: firmware/microcode, graphics stacks (Mesa, 32-bit support), bluetooth, virtualisation guests — not just kernel modules and filesystems.
  • It is reviewable: a hardware swap shows up as a readable diff of the report, checked in next to the host.

Generate (or refresh) a report on the machine itself:

sudo nix run \
  --option experimental-features "nix-command flakes" \
  nixpkgs#nixos-facter -- -o facter.json

Then wire it in:

# the module ships with nixpkgs, nothing to import:
hardware.facter.reportPath = ./facter.json;

Re-run it after every hardware change. A stale report is silently wrong: when a GTX 1060 made way for an RX 9060 XT, the old report kept describing the NVIDIA card, its driver ended up in the initrd, and AMD-specific extras gated on detection (like ROCm’s OpenCL) stayed off until the report was regenerated.

nixos-hardware: quirks other people already debugged

nixos-hardware is a community collection of per-device and per-component modules — the accumulated “this machine needs that kernel parameter” knowledge, packaged. If facter describes what hardware you have, nixos-hardware fixes how it behaves.

Add the input, then import either a named machine profile (best case: someone with your exact machine already did the work):

inputs.nixos-hardware.url = "github:nixos/nixos-hardware";

imports = [ inputs.nixos-hardware.nixosModules.framework-13-7040-amd ];

…or compose from the generic building blocks under common/ when there is no profile for your machine, for example a Coffee Lake desktop with a Radeon card:

imports = [
  (inputs.nixos-hardware.outPath + "/common/cpu/intel/coffee-lake")
  (inputs.nixos-hardware.outPath + "/common/gpu/amd")
];

Blocks worth knowing for a Steam machine:

ImportWhat it buys you
common/gpu/amdamdgpu driver stack incl. early KMS — flicker-free boot into gamescope
common/cpu/amd/pstate, common/cpu/intelmodern CPU frequency scaling / microcode
common/pc/ssdperiodic TRIM
common/pc/laptoppower tuning, if the “console” is a laptop

Browse the repo’s common/ tree — the modules are tiny and readable, so when in doubt, read what an import actually sets before adopting it.

Getting the most out of Gaming Mode

Everything below is upstream nixpkgs options — listed here because finding them is the hard part.

Match gamescope to your display. By default gamescope picks something sensible; being explicit avoids surprises on TVs. One list element per argv entry:

steamix.gamescope.args = [
  "--output-width"
  "3840"
  "--output-height"
  "2160"
  "--nested-refresh"
  "120"
];

HDR and VRR are not in that list because steamix.hdr.enable and steamix.vrr.enable are on by default — they pass the gamescope flag and tell Steam the session supports the feature, which is what puts the toggles in Gaming Mode’s display settings.

A new GPU wants a new kernel. Release-branch LTS kernels lag GPU support by months (RDNA 4 is the current example):

boot.kernelPackages = pkgs.linuxPackages_latest;

Proton-GE fixes titles that stock Proton struggles with:

programs.steam.extraCompatPackages = [ pkgs.proton-ge-bin ];

Controllers. programs.steam enables hardware.steam-hardware (udev rules for every controller Steam supports) by itself. On top of that:

hardware.bluetooth.enable = true;   # BT gamepads
hardware.xpadneo.enable = true;     # better Xbox-controller-over-BT driver

Feels-faster knobs:

programs.gamemode.enable = true;    # CPU governor/niceness while a game runs
zramSwap.enable = true;             # compressed-RAM swap; no disk partition needed

MangoHud is available too (MANGOHUD=1 in a game’s launch options after adding pkgs.mangohud), though Gaming Mode’s built-in performance overlay covers most of it.

Good to know

  • Audio must be configured by you (the module does not assume a sound stack): services.pipewire.enable plus its alsa/pulse sub-options is the standard choice.
  • First boot lands in Steam’s login screen — initial setup (account, network if NetworkManager is present) happens inside the Deck UI itself.
  • Firewall: programs.steam.remotePlay.openFirewall and localNetworkGameTransfers.openFirewall open what Remote Play / local game transfers need.
  • Failsafe: if a driver or session change ever leaves the machine crash-looping into a black screen, switch to another VT (Ctrl+Alt+F2), log in, and roll back — the loop never blocks a text console. See known limitations.
  • Updates: Gaming Mode’s own “check for updates” updates games, not the OS — system updates stay nixos-rebuild / your deploy tool. SteamOS-style atomic OS updates are exactly what NixOS generations already are.

Machines with two GPUs

A desktop with a discrete card and an active integrated one (any Intel or AMD CPU with video output enabled in firmware) has a trap waiting: nothing pins which GPU gamescope composites on. It selects a Vulkan device, then opens the DRM node that matches it — and which card that turns out to be depends on kernel enumeration order, which is not stable across boots. The same machine can come up on the discrete card for weeks and then, one boot, land on the iGPU, open a DRM node with no connected output, fail to create a backend, and leave you at a black screen with greetd looping until it hits its restart limit.

Pin it by PCI ID:

steamix.gamescope.args = [
  "--prefer-vk-device"
  "1002:7590"   # `lspci -nn | grep -i vga`, or /sys/class/drm/card*/device/{vendor,device}
];

gamescope --steam ... 2>&1 | grep 'selecting physical device' confirms the choice; it names the card it settled on.

Handhelds

Steamix carries NixOS support for Snapdragon handhelds in hardware/, built on Armada OS’s work: its kernel patches, device trees, kernel config and firmware, and the ROCKNIX ABL bootloader. It lives here while it takes shape, and is meant for nixos-hardware once it boots. The plan behind it is ARM port: Retroid Pocket 6.

Status: builds, and boots in an emulated ARM machine (QEMU, with the image’s own /KERNEL); untested on hardware. The first device has not booted it yet.

Devices

DeviceModuleSoC
Retroid Pocket 6nixosModules.retroid-pocket-6QCS8550 (Snapdragon 8 Gen 2)

Armada supports about two dozen handhelds across four Snapdragon generations, and the kernel here already builds all their device trees, so adding one of them is mostly a hardware/<vendor>/<model>/default.nix that names its device trees.

Layout

hardware/
├── common/                       parts, not products
│   ├── qualcomm/sm8550/          Snapdragon 8 Gen 2: kernel, firmware, boot
│   ├── rocknix-abl/              boot.loader.rocknix-abl, and the SD image
│   └── bring-up.nix              the console system the example images use
└── retroid/pocket-6/             the product: its device trees and quirks
packages/
├── armada-source.nix     the pinned Armada revision everything comes from
├── linux-armada/         the kernel
├── armada-firmware/      device firmware (unfree)
└── rocknix-abl/          the bootloader and its flashing scripts

Every product, hardware/<vendor>/<model> with a default.nix, becomes a NixOS module named <vendor>-<model>; nothing to register. Chips and other shared parts live under common/ and are imported by the products (the SoC is also exported, as qualcomm-sm8550).

How it boots

ROCKNIX ABL replaces the device’s Android bootloader. It loads /KERNEL from the first FAT partition of the SD card (or internal storage): an Android boot image with the kernel, the device trees appended to it, the initrd and the command line. boot.loader.rocknix-abl writes that file on every nixos-rebuild switch or boot, keeping the previous one as KERNEL.BAK. The bootloader has no menu of kernels, so that file is the only way back to an older generation: if a new one does not boot, rename KERNEL.BAK to KERNEL from another computer.

The kernel

linux-armada is Linux 7.2.6 with Armada’s 209 patches, built with nixpkgs’ buildLinux: arm64’s defconfig plus Armada’s config fragment, then nixpkgs’ common config on top, so NixOS’s kernel requirements still hold. Its tests.armada-config compares the result with Armada’s fragment and fails if anything Armada builds in became a module or was turned off. CI runs that check, cross-compiled, on every change, which also catches a patch that stops applying.

Building it natively under emulation takes hours. The x86_64 image (below) cross-compiles it instead; to do the same in your own configuration:

{ inputs, pkgs, ... }:
{
  boot.kernelPackages = pkgs.linuxPackagesFor
    (import "${inputs.steamix}/packages" {
      pkgs = inputs.nixpkgs.legacyPackages.x86_64-linux.pkgsCross.aarch64-multiplatform;
    }).linux_armada;
}

A bring-up image

nix build github:arunoruto/steamix#sd-image-retroid-pocket-6

builds an SD card image with a console system: the nixos user logs in on the screen, nmtui gets it on Wi-Fi, and SSH works once passwd set a password. The image’s FAT partition also carries rocknix_abl/, the bootloader and its flashing scripts.

Either machine works:

  • aarch64 (packages.aarch64-linux): built natively; the kernel comes from the binary cache, where CI puts it from GitHub’s ARM runners.
  • x86_64 (packages.x86_64-linux): the kernel is cross-compiled, about an hour on a desktop. Everything else is still aarch64, mostly from cache.nixos.org; the rest builds under emulation, so the machine needs boot.binfmt.emulatedSystems = [ "aarch64-linux" ];.

The image contains armada-firmware, which has no licence: build it yourself, and do not push it to a public cache.

Write it to a card (check the device name with lsblk first: dd overwrites whatever it is given):

zstdcat result/sd-image/*.img.zst | sudo dd of=/dev/sdX bs=4M conv=fsync status=progress

Steam

nixpkgs’ steam wraps Valve’s launcher, which only has an x86 bootstrap, and does not evaluate on aarch64. steam-arm packages Valve’s native ARM64 client instead, the one Valve’s own ARM devices run (a public beta; Valve has not released Steam for ARM Linux officially):

{ lib, pkgs, ... }:
{
  # steam-arm and its bootstrap are unfree.
  nixpkgs.config.allowUnfreePredicate =
    p: builtins.elem (lib.getName p) [ "steam-arm" "steam-arm-bootstrap" ];
  environment.systemPackages = [ pkgs.steam-arm ];
}

It works like nixpkgs’ Steam. The store holds a bootstrap (the client’s updater from Valve’s ARM64 manifest and Valve’s ARM64 Steam Runtime, both pinned by the SHA-256 Valve publishes, packages/steam-arm/update.py to bump). The first launch copies it to ~/.local/share/Steam, and the client installs the rest of itself from Valve’s servers (670 MB) and keeps itself updated there. It runs in an FHS environment with the libraries the ARM64 client links against, started the way Armada starts it. It needs an ARMv8.1 CPU with LSE atomics.

Status: verified on real ARM hardware (GitHub’s ARM runners, the manual steam-arm GUI workflow, software rendering): the first launch installs the client, which restarts itself and brings up its UI, up to the sign-in screen. Signing in, the UI on the device’s GPU, and games (ARM64 Proton, FEX) are not verified yet. It is never cached: Valve publishes the client for download, nothing grants redistributing it.

Started from a systemd service with PAMName= (cage, for example), the session keeps capabilities that make bubblewrap refuse to start (nixpkgs#533140); drop them first: setpriv --inh-caps=-all --ambient-caps=-all steam-arm.

When Valve releases the ARM64 client officially, this is meant for nixpkgs’ steam.

Size

The bring-up system is 1.9 GiB, the compressed image 690 MiB. Of linux-firmware, only the SoC’s part is installed (53 MB of 1.9 GB); hardware.enableRedistributableFirmware brings back the rest, for a USB Wi-Fi adapter say. With Plasma and gamescope on top, about 7 GiB.

For comparison, Armada’s 2026-09-26 release uses 12.9 GB of its root filesystem, compressed (btrfs, zstd), with Steam, Plasma, Waydroid and the x86 graphics stack for FEX installed.

Flashing the bootloader

This is the one step that can brick the device: there is no documented recovery from a bad bootloader. Do it once, carefully, from the device’s Android:

  1. Root Android, and copy rocknix_abl/ from the SD card to the root of the internal storage.
  2. Run rocknix_abl/SM8550/backup_abl.sh as root, and copy abl_a.img and abl_b.img somewhere off the device. They are the way back to stock.
  3. Check you are in the folder for your SoC (rocknix_abl/README lists which device has which), then run flash_abl.sh as root.
  4. Reboot holding Vol-. In the bootloader’s menu, pick the device model, switch the boot mode to Linux, and start.

The bootloader is the same one Armada and ROCKNIX install, so a device that already runs either of them can boot this image as it is.

When the device arrives

  1. Charge it and boot Android once, to know it works as shipped.
  2. Build the image and write it to an SD card (above).
  3. Back up and flash the bootloader (above). The backup is the step not to skip.
  4. Reboot holding Vol-, pick Retroid Pocket 6 and Linux, boot from SD. There are two RP6 device trees, identical except that the “TOP-DPAD” one inverts both stick axes; if evtest shows the sticks inverted, pick the other model in the menu.
  5. If the screen stays dark, try Armada’s image on the same card first: if that boots, the difference is ours, not the hardware’s.
  6. Once NixOS is up: nmtui for Wi-Fi, passwd, then everything else over SSH. Collect dmesg, lsmod and ls /sys/firmware/devicetree/base for the next round.

Testing

Steamix is tested with the NixOS test driver: each test boots a QEMU virtual machine built from a NixOS configuration that imports the module, then drives it from a Python test script. The tests live in tests/, one file per test.

TestWhat it covers
greetdThe default login path end to end. Boot lands in Gaming Mode; the gamescope command line and Steam’s environment match the options; the session logs to the journal; the performance-overlay presets are generated; “Switch to Desktop” lands in the configured desktop session as a logind class=user, type=wayland session with its identity in the systemd user manager; “Return to Gaming Mode” goes back; a killed gamescope falls back to Gaming Mode.
sddmThe SteamOS-shaped login path. SDDM autologin lands in Gaming Mode; both SteamOS Manager daemons run and publish SessionManagement1; the session’s stand-in unit is active and the configured desktop is the manager’s default; “Switch to Desktop” and “Return to Gaming Mode” go through the manager (temporary autologin drop-in, graphical-session.target stopped, Steam shut down cleanly); a killed gamescope falls back to Gaming Mode and stays there; a temporary session left behind does not survive a reboot.
steamos-managerSteamOS Manager finds the data it ships. A VM presenting a ROG Ally’s DMI identity is recognised as one from the device configs; a VM with QEMU’s own identity is unknown without an error, like a desktop PC; none of the Deck-only interfaces (factory reset, fan control, BIOS and dock updates, storage) appear on a PC; and nothing fails to read under /usr/share/steamos-manager.
decky-loaderThe loader serves on port 1337; declared plugins are linked from the store and load (frontend-only ones as passive); backend plugins run as the unprivileged user; the CEF flag lands in the Steam user’s home with every directory on the way owned by that user; a store-installed plugin coexists with declared ones; a declared plugin removed through the UI is back after a reboot.
libraryBoilR syncs a fake Heroic install’s game into Steam’s library inside the session, before Steam starts; the game launches through Heroic; a shortcut added by hand is kept; the next login does not duplicate anything; the SteamGridDB key is read from its file into a config only the user can read.
emulationThe emulation kiosk boots into RetroArch, full screen in cage, as the configured user, with the configured cores and settings (--appendconfig), the user can read controllers, and RetroArch comes back after quitting. Takes a screenshot of the menu.
decky-lsfg-vkNo VM: the Decky LSFG-VK plugin’s backend driven directly, as Decky would, with a scratch home. “Install lsfg-vk” puts every file in place, the plugin then sees lsfg-vk as installed, settings validate through the NixOS build of its CLI, and the CLI it installed starts. Guards the package’s NixOS repairs against plugin updates.
rocknix-ablNo VM: the handheld boot image writer run against a stand-in system. The image unpacks to Armada’s geometry, the kernel is gzip(Image) with the device trees appended in order, the command line points at the system’s init, a new system moves the old image to KERNEL.BAK, and an over-long command line or a missing device tree is refused.
linux-armada-configNo VM: the handheld kernel’s config, cross-compiled from x86_64, compared with Armada’s: nothing Armada builds in may end up a module or off. Generating it applies Armada’s whole patch series, so a patch that stops applying fails here too.
boot-retroid-pocket-6No device: the Retroid Pocket 6 bring-up image booted in QEMU’s generic ARM machine, emulated. The kernel, initrd and command line come out of the image’s own /KERNEL; it passes when the system reaches its login prompt with no failed units. Covers the kernel, the initrd finding the root partition and NixOS’s userland on this kernel’s config, not the Qualcomm hardware. On x86_64 with the kernel cross-compiled, and on aarch64 (CI’s ARM runners) natively.
optionsNo VM: options simple enough that evaluating a configuration with them proves everything, such as steamix.heroic installing Heroic only when both it and steamix.enable are on, steamix.losslessScaling installing the layer and writing the config each lsfg-vk version reads, and steamix.proton adding GE-Proton and its FSR 4 variant, whose wrapped launcher the check also runs against a stand-in to see the flag arrive. Runs in seconds; a failing case names itself in the evaluation error.

Running them

The tests are the Steamix flake’s own checks. From steamix/, against its nixos-unstable input:

nix build -L .#checks.x86_64-linux.greetd

Against the current NixOS 26.05 release instead, which is what CI does for the stable channel:

nix build -L --no-write-lock-file \
  --override-input nixpkgs github:nixos/nixpkgs/nixos-26.05 \
  .#checks.x86_64-linux.greetd

All of them need /dev/kvm; without it the driver falls back to emulation and is many times slower. A test VM boots in about 15 seconds and each test script runs in 20 to 45, so the cost of a run is almost entirely building the VM’s closure the first time.

To poke at a test machine by hand, build .driverInteractive and run the driver; start_all() boots the VMs and every machine.* call from the test script works at the prompt:

nix build .#checks.x86_64-linux.greetd.driverInteractive
./result/bin/nixos-test-driver

The CI workflow runs every test against both channels on every push to main and every pull request: unstable at the revision flake.lock pins, and stable at the current nixos-26.05 channel head, so a release-branch change that breaks Steamix shows up without waiting for a lock bump. The weekly lock update runs all tests against the new lock and only commits it when they pass.

What is real and what is not

Three things are replaced by stand-ins, defined in tests/lib.nix:

  • Steam is unfree, downloads its client on first start and needs an account. The stub accepts Valve’s arguments, records its environment, runs steamos-session-select the way the power menu does, and exits on steam -shutdown.
  • gamescope needs a Vulkan device with a DRM render node for every backend, headless included. The only Vulkan driver in a sandboxed VM is lavapipe, which has none, so the real binary starts, selects llvmpipe and dies with “Failed to create backend”. The stub records the command line the session script built and runs Steam as its child.
  • The desktop is a script registered as a Wayland session, which records the identity and logind session it was given.

Everything between them is the real thing: greetd, the PAM stack, logind, the launcher, the session script and the switcher. That is the part of Gaming Mode this module owns. What the tests cannot see is gamescope’s own behaviour: mode selection, HDR, VRR and the overlay rendering need real hardware.

What they have caught

Every bug below was found by these tests, and none of them showed up on the host that uses the module:

  • greetd’s PAM stack changed shape on nixpkgs-unstable. From 26.11 it is a single include login, and the identity rule was ordered after an inline env rule that no longer exists, so the greetd path failed to evaluate. The host uses the SDDM path and runs 26.05, so nothing evaluated it.
  • The CEF flag was never created on a fresh install. The system tmpfiles instance created the missing ~/.local as root, then refused to write through it, so Decky never appeared, and Steam’s own first-run install into ~/.local/share/Steam had nowhere to write. The host’s Steam had created those directories long before Decky was enabled.
  • SteamOS Manager’s user daemon crash-looped on a fresh install. The module seeds its state file with the desktop session, but the seed lacked default_login_mode, which the 26.4.1 daemon requires, so the daemon exited at startup and took SessionManagement1, and with it “Switch to Desktop”, along. The host’s daemon had written its own state file before the seed existed, and the seed never overwrites one.
  • SteamOS Manager never recognised a handheld. It reads its platform file and device configs from /usr/share/steamos-manager, and the package installed them into its store path, so every device lookup failed and the TDP, GPU and performance-profile controls the device configs describe never appeared. The package now points the daemon at its own data.
  • A crash in Gaming Mode on the SDDM path became a restart loop. The next session replaced the stand-in unit the dead one had left. Stopping the old unit left graphical-session.target unneeded, systemd stopped it, and that stop took the new stand-in, and so the new Steam, down within a fraction of a second. SDDM relogged and the race repeated about twice a second, 117 times in the minute CI watched, until a reboot. It is timing dependent and only one CI run in several hit it, which is why both login tests now also check that the recovered session stays up.

Binary cache

Steamix has its own binary cache at steamix.cachix.org, so a Steamix machine downloads what it would otherwise compile:

PackageWhy it is not on cache.nixos.org
SteamOS Managernot in nixpkgs
Decky Loadernot in nixpkgs
32-bit gamescope WSI layerin nixpkgs, but Hydra does not build it; the module installs it by default (steamix.gamescope.wsi.package32)
linux-armada (aarch64)the handheld kernel, not in nixpkgs; built natively on GitHub’s ARM runners, so an x86_64 machine building a handheld image does not compile it under emulation. It is GPL-2.0, so its corresponding source is served with it: Armada’s kernel directory (the patches, device trees and config) from this cache, Linux’s tarball from cache.nixos.org, which already has it, and the build recipe is this repository.

The Decky plugins are not cached: they are release downloads, so a cache would save nothing. Nor is anything that may not be redistributed: the handheld firmware (armada-firmware) has no licence at all, which is also why the kernel’s source in the cache is Armada’s kernel directory rather than the whole repository, and the Decky LSFG-VK plugin carries lsfg-vk under CC BY-NC-ND. The handheld SD images contain the firmware, so they are never cached either: build them yourself.

Using it

Machines that import the module get it from steamix.binaryCache.enable, which is on by default and adds the cache and its key to nix.settings, next to cache.nixos.org:

steamix.binaryCache.enable = true; # the default

It asks for no trust beyond what importing the module already does: the module runs as root, and the cache is signed with a key pinned in it. Set it to false to build everything yourself.

Building Steamix’s flake directly (nix build, the VM tests) uses the same cache through the flake’s nixConfig, if accept-flake-config is on. Nix ignores that setting when Steamix is a flake input, which is why the module option exists. The URL and key, if you want to add them by hand:

https://steamix.cachix.org
steamix.cachix.org-1:RDiQCw/nTZL8BuFm4uZrKEnkCU0xJ+w7zwQ+IQBXan0=

When it helps

A cache only serves the exact store path a machine asks for, and that path depends on the whole nixpkgs it was built against. CI builds the cached set against three nixpkgs revisions:

NameRevision
lockednixos-unstable at the revision Steamix’s flake.lock pins
unstablethe current nixos-unstable channel head
stablethe current nixos-26.05 channel head

It rebuilds them daily, so a machine whose own lock follows either channel and was bumped recently asks for paths that are already there. A machine on an older revision builds locally, exactly as it would without the cache.

How it is filled

The Cache workflow runs on main after CI passes, and daily. For each revision it builds the flake’s cache set, pushes those outputs and their runtime closures (Cachix skips anything cache.nixos.org has, so in practice one path per package), and pins each as <package>-<revision>, keeping three revisions, so the free tier’s garbage collection never evicts what machines are using. Build-only paths, such as fetched sources and Decky’s pnpm dependencies, are never pushed.

It pushes with an auth token stored as the repository secret STEAMIX_CACHIX_AUTH_TOKEN. Without the secret the job still builds, and skips the push with a notice.

To see what would be pushed:

nix eval .#cache --apply builtins.attrNames

Roadmap

Where this project is going, and why. The module today gets a PC into Gaming Mode and back out again; the pages before this one describe that mechanism. This page is the list of things that would make it a console rather than a desktop that happens to boot into Steam — written down so the order is deliberate and the reasoning survives.

Positioning

The comparison that matters is not Jovian-NixOS, it is Bazzite. Jovian answers “run Valve’s stack on a Steam Deck”. Bazzite answers “a console OS for any PC and any handheld”, and its feature list — Decky, Heroic, emulators, a settings UI, handheld daemons — is the one this project is reaching for.

What Bazzite cannot offer is what NixOS gives for free:

  • the user owns the configuration, and a change survives the next update (install Tailscale on SteamOS and it is gone after the next reboot);
  • every update is a generation, so rolling back is picking a different one;
  • features compose from data, so “enable Decky” is one line and the plugins it loads are a list.

Everything below is chosen to sharpen that argument. The target is a project feature-rich enough to present at NixCon 2027.

Make it a console

These are the pieces that turn “NixOS with a Steam session” into something a non-Nix user can live with. They are also the strongest demo material.

OS updates from Gaming Mode

Steam’s “Check for updates” button in Gaming Mode runs an executable named steamos-update. The contract is small: steamos-update check exits 0 when an update is available and 7 when the system is up to date, and the real run prints progress percentages that the client (and KDE Discover’s SteamOS backend) renders. Jovian stubs it to “nothing to do”.

Implement it for real:

  1. fetch the configured flake ref;
  2. build the toplevel and compare it against the running system — identical store path means exit 7;
  3. nixos-rebuild switch with progress lines on stdout, so the Deck UI shows a bar instead of a spinner.

SteamOS branches map onto flake refs, so steamos-select-branch becomes stable / unstable / your fork. This is the single feature that demonstrates the whole pitch: an update applied from the Deck UI, then a rollback.

Automatic rollback on a broken boot

How it works lists “no crash-loop brake” as a known limitation. NixOS has boot counting through boot.uki.tries: systemd-boot marks an entry bad after N failed attempts and falls back to the previous generation. Pair it with a session watchdog — the greetd loop or SDDM relogin failing a few times in a row counts the boot as failed — and a bad gamescope or driver update heals itself. This is SteamOS’ A/B slot fallback, built from generations.

A binary cache

Done (2026-10-04): steamix.cachix.org, filled by CI after the VM tests pass and enabled on every Steamix machine by steamix.binaryCache.enable. See Binary cache. Hits are limited to machines on the nixpkgs revisions CI builds against until Steamix has its own repository and builds against the channel heads.

An installer that creates the user’s repository

The audience this is for never writes Nix. The installer image should generate a template flake with a settings.json, commit it locally, and from then on the GUI (below) edits that file. Nobody opens a .nix file unless they want to. Per-host installer images already exist in the author’s configuration repository; this is the standalone, user-facing version of them.

Modules worth adding

Each of these fits the current shape: plain pkgs, plain lib, upstream NixOS options, policy left to the consumer.

Hardware from the facter report

The two-GPU trap is the strongest case. A facter report lists every graphics card with PCI vendor and device IDs, so the module can derive --prefer-vk-device and steamix.mangoapp.pciDev for the discrete card by default, and only ask the user when the report is absent or ambiguous. The same idea covers picking linuxPackages_latest for a GPU the LTS kernel does not know yet. This is how the project beats Jovian on generic hardware without writing per-device modules.

Handhelds

nixpkgs has services.handheld-daemon with its TDP adjustor and UI. A steamix.handheld.enable switch that turns it on, adds the hhd Decky plugin to steamix.decky-loader.plugins, and handles the power button covers the ROG Ally, Legion Go and Ayaneo crowd — Bazzite’s core audience.

The groundwork is in: SteamOS Manager recognises the handhelds it ships device configs for (ROG Ally, Legion Go, MSI Claw, AYANEO, GPD and others, matched by DMI), which is what unlocks its TDP, GPU and performance-profile controls. The steamos-manager VM test checks it with a ROG Ally’s DMI identity.

Mods as Steam compatibility tools

The clean home for modding software such as ModEngine3 for FromSoftware titles. Steam’s compatibility-tool dropdown is a first-class extension point, and programs.steam.extraCompatPackages already feeds it. A wrapper tool — “Proton-GE + ModEngine3” — means the user picks it per game in Gaming Mode, and the mod list for a game is a generated ModEngine TOML. No writing into Steam’s own VDF files, which is where every other approach gets fragile.

Launchers and emulators as library entries

Heroic, Lutris and ES-DE are only useful in Gaming Mode if they appear in the library. A small shortcuts.vdf writer that adds declared entries and leaves user-added ones alone gives steamix.launchers.heroic.enable that feels native. Emulation is where Nix shines: retroarch.withCores plus declared BIOS and ROM paths is EmuDeck without the installer script.

Started (2026-10-04): steamix.heroic.enable installs Heroic, and steamix.library.boilr puts the games Heroic, Lutris, itch.io and other launchers installed into Steam’s library on every login, through BoilR. Emulators are the remaining part, where Steam ROM Manager fits the same hook: it also needs Steam closed and has a command-line mode.

Lossless Scaling frame generation

Done (2026-10-04): steamix.losslessScaling installs the lsfg-vk layer and writes its per-game profiles, in the format of whichever lsfg-vk version is installed. With Decky Loader, the Decky LSFG-VK plugin manages it from the Quick Access menu instead, with Steamix repairing the plugin’s own lsfg-vk build to run on NixOS.

Upscaling

Done (2026-10-04): steamix.proton.fsr4 adds a “GE-Proton (FSR 4)” compatibility tool, which upgrades games that ship FSR 3.1 to AMD’s FSR 4 on RDNA 3 and 4. FSR 1 is gamescope’s scaling filter and already works. OptiScaler (FSR or XeSS in games that only offer DLSS) is left out: most current games ship both, and it is a per-game DLL drop that resists being declared.

Streaming and scheduler

Cheap wins on top of upstream options:

  • services.sunshine needs only session wiring and the capture capability to stream Gaming Mode to Moonlight clients;
  • services.scx with scx_lavd is the gaming scheduler Bazzite and CachyOS advertise, and here it is one option.

Upstream the session

The gamescope session in gaming-mode.nix fixes real bugs in nixpkgs’ programs.steam.gamescopeSession: the second Xwayland server, the WSI layer, the capability flags, the mangoapp config path. Send that upstream, together with the decky-loader and steamos-manager packages and the buildDeckyPlugin helper. It removes the pkgs.decky-loader or null dance from the module and gives everyone else the fixes.

VM tests

Started (2026-10-04): the greetd, sddm, steamos-manager and decky-loader tests boot the module in a VM and drive it end to end, against both nixpkgs channels, in CI. See Testing. They found three bugs on their first runs that the host using the module never showed.

Still to cover: the updater and rollback once they exist. A test that runs the real gamescope would need a Vulkan device with a DRM render node inside the VM, which today means virtio-gpu with Venus and a host GPU — out of reach for sandboxed builds.

The GUI

Many people are afraid of editing a config file, and a typo in one is a bad first experience. Most settings are a checkbox (enable a module), some are a pick from a list (an enum), and only rarely does anyone need to type a value.

The module is the GUI schema. The option reference is already generated from the module; the same options JSON carries type, default, description and enum values, so a form can be rendered from it:

Option typeWidget
boolcheckbox
enumdropdown
str, int, floattext field
listOf packagemulti-select from a known set (e.g. deckyPlugins)

Mark which options are user-facing with a small allowlist rather than exposing all of them.

The GUI never writes Nix. It writes settings.json, commits it to the local repository, and triggers the same update path as the Gaming Mode button. A bad change is a generation to roll back from, not a broken file to debug. The module reads the file with lib.importJSON and treats it as ordinary configuration.

A Decky plugin is the right first frontend. It renders inside the Deck UI, so it feels native, and the loader already runs as root, so it can trigger the rebuild. A later steamos-manager-style D-Bus service with a polkit rule is the cleaner shape once the flow works.

nixos-hardware and facter

An idea discussed with the facter maintainer at NixCon: expose common nixos-hardware tuning (CPU, GPU) as options instead of imports, so facter could switch them on. Their answer was that facter’s own modules already derive most of what is present.

What nothing derives is the tuning in nixos-hardware’s common/ tree: amd-pstate, per-generation Intel media stacks, fstrim, laptop power. The author’s configuration repository solves that in systems/hardware-profiles.nix, which maps CPU model and GPU driver from the report to profile directories. The obstacle it documents is real: those profiles carry nested imports, so the choice cannot depend on config, which is why “expose them as options” is the wrong PR.

The PR that would land is a facter entrypoint in nixos-hardware itself: a function that takes a report path and returns the profile list, built from that mapping table. Propose it as a function first; the option form can follow if the profiles are ever flattened.

ARM devices

The concrete plan, for a Retroid Pocket 6, is in ARM port: Retroid Pocket 6.

NixOS runs on aarch64 as well as it runs on x86_64, and the hardware is arriving: Snapdragon handhelds (AYN Odin and Thor, Retroid Pocket, AYANEO Pocket), and Valve’s own ARM64 Steam client, built for the Steam Frame. Steamix assumes x86_64 today only because Steam does.

Prior art: Armada OS

Armada OS gets Gaming Mode running on Snapdragon handhelds. It is a Fedora bootc image with device support from ROCKNIX, created in mid-2026 and calling itself prototype software. Its approach is the one to copy:

  • Steam is native, not emulated. It runs Valve’s ARM64 client, the steamdeck_publicbeta linuxarm64 channel, with Valve’s ARM64 Steam runtime. The image pre-bootstraps both offline and launches the ARM64 binary directly; there is no 32-bit client anywhere (steam-bootstrap).
  • Windows games use Valve’s ARM64 Proton, served through Steam, with Wine ARM64EC and FEX inside it. A CachyOS ARM64 Proton is the fallback.
  • FEX only runs x86 Linux code: native x86 games and helpers that only exist as x86 binaries. It runs on the official FEX Arch root filesystem, with thunks for Vulkan, GL and Wayland, and per-game FEX profiles. An x86 build of Mesa’s Turnip driver is overlaid into that root filesystem. Box64 is not used.
  • The session is the shape Steamix’s SDDM path already has: SDDM autologin with relogin, the holo.conf marker so SteamOS Manager owns switching, steamosctl behind steamos-session-select, and the gamescope-wayland alias. Steam runs with the same -gamepadui -steamos3 -steampal -steamdeck flags.
  • The hard part is the hardware: a mainline kernel with Snapdragon patches and 4K pages, Mesa with Turnip patches for the Adreno GPUs, about 25 gamescope patches for EDID-less panels and the msm display driver, and Valve’s Steam Frame patch series for SteamOS Manager, which teaches it devfreq GPUs and devices without DMI data.

What Steamix would need

  1. A FEX module. nixpkgs packages fex for aarch64 but has no NixOS module: no binfmt registration for x86 and x86_64, no FEXServer, no root filesystem. A programs.fex with boot.binfmt.registrations, a pinned root filesystem image and thunk configuration is useful far beyond Steamix, so it belongs upstream in nixpkgs, and it is the right first step.
  2. An aarch64 Steam. nixpkgs’ steam is x86-only. An ARM64 path would fetch Valve’s linuxarm64 client manifest and the ARM64 runtime and run them in an FHS environment, the way the x86 package runs its bootstrap.
  3. Device support stays outside Steamix. Kernel, Mesa and gamescope patches for a given SoC are hardware enablement, which belongs in nixos-hardware profiles or the consumer’s configuration. Steamix only needs its package options to accept the patched builds, which they already do.
  4. SteamOS Manager with the Steam Frame patches, so TDP and GPU controls work on devices that are not x86 PCs.
  5. Decky runs natively. Armada runs Decky’s x86 PyInstaller binary under FEX. Steamix builds Decky from source as a Python package, so it should build for aarch64 as it is, with no emulation in the loop.
  6. VM tests on aarch64. The tests are architecture-neutral apart from their x86 assumptions about Steam; with the stubs they could run on an aarch64 builder, which needs a runner with KVM.

Blockers

  • Nothing stable to pin. The ARM64 client lives on a public-beta channel with no stable URL or version. Armada pins a manifest and pre-bootstraps it; a Nix package would have to do the same and accept that Valve can withdraw a version.
  • Valve’s FEX tooling assumes an FHS layout. Steam’s FEX compatibility tool hardcodes paths such as /usr/share/guestos/fex-mesa, which an FHS environment or a bind mount has to provide.
  • Page size. FEX needs 4K pages, which rules out Asahi’s default 16K kernel without a micro-VM such as muvm.

This is a direction for after the console pieces, not before. The FEX module can start any time and stands on its own. “The same module on an ARM handheld” would make a strong closing slide.

Order of work

  1. The update button with rollback, the facter-driven GPU pin, and the compat-tool mod wrapper. Each is a weekend of work, each is demoable on stage, and together they make the “your machine, still a console” argument.
  2. The settings file, because it is the contract the GUI needs.
  3. The GUI, as a Decky plugin.
  4. Handhelds, launchers, emulation, streaming — the breadth that makes the project worth switching to.
  5. Upstreaming and the VM tests, throughout, whenever a piece stabilises.
  6. ARM, starting with a FEX module for nixpkgs, once the console pieces stand.

ARM port: Retroid Pocket 6

The plan for running NixOS with Steamix on a Retroid Pocket 6 (12 GB / 256 GB): a Steam machine on a Snapdragon handheld, the way Armada OS does it on Fedora, as the centrepiece of a NixCon 2027 talk. Written 2026-10-04 from reading Armada (main, f8f59dd), ROCKNIX (next, a7a96a5) and nixpkgs (nixos-unstable, c59305ba). Nobody has run NixOS on these handhelds yet.

The device

SoCQCS8550 (Snapdragon 8 Gen 2, SM8550 family), Adreno 740
Device treeqcs8550-retroidpocket-rp6.dts (and -rp6-top-dpad), out of tree, an Odin 2 derivative (qcs8550-ayn-common.dtsi)
Display1080x1920 portrait AMOLED, rotated (rotation = <270>): gamescope --force-orientation left
ControllerRSInput MCU on uart15 (rsinput driver), shows up as “AYN Odin2 Gamepad”
Audiosound card “AYN-Odin2”, UCM AYN/Odin2
Fanpwm-fan, kernel thermal trips
Suspendbroken on ROCKNIX (“fake suspend”); Armada defaults to s2idle

How it boots

The stock Qualcomm bootloader (ABL) only boots Android. Armada and ROCKNIX replace it with ROCKNIX ABL, flashed with dd from rooted Android onto abl_a and abl_b. Its source is private; what it does is known from how both distributions use it:

  • it boots from SD, internal storage or USB, chosen in a menu (hold Vol- at power-on), where the device model is also picked;
  • it loads /KERNEL from the first FAT partition: an Android boot image, header v0, holding gzip(Image) with every DTB appended, an initramfs and a command line of at most 512 bytes. Armada’s mkbootimg arguments: --base 0x10000000 --pagesize 2048 --kernel_offset 0x8000 --ramdisk_offset 0x06000000 --tags_offset 0x100 --header_version 0;
  • there is no boot menu of kernels, so rollback is a kept KERNEL.BAK;
  • it also has an EFI path (Armada disables /EFI so it falls through to /KERNEL). Whether systemd-boot would run there is unknown, and worth trying once the basic path works, because it would give NixOS its generation menu back.

Architecture

Two layers, kept apart so each can go upstream on its own:

  • Device support (kernel, device tree, firmware, boot image, SD image): not Steamix-specific. In Steamix’s hardware/ to start with (see Handhelds), aimed at nixos-hardware later.
  • Steam on ARM (Valve’s ARM64 client, FEX for x86 games, the Gaming Mode session on aarch64): Steamix, with the generic parts (FEX module, client package) aimed at nixpkgs.

Builds are native aarch64, not cross-compiled, so everything Hydra builds comes from cache.nixos.org. The kernel, the one big uncached build, is built on an aarch64 machine (a GitHub ubuntu-24.04-arm runner, free for public repositories, pushing to Cachix) or cross-compiled for speed; small uncached derivations build locally through boot.binfmt.emulatedSystems. GitHub’s ARM runners have no KVM, so aarch64 CI evaluates and builds but does not boot VM tests; the device itself is the test bench.

Phase 0: before the device arrives

Done (2026-10-05), see Handhelds: the kernel, the boot image writer, the SD image and the firmware are in hardware/ and packages/, with the kernel config and the boot image checked in CI. The image was compared with Armada’s 2026-09-26 release: same partition layout and flags, same boot image header (all but the patch-level date), and the RP6 device tree decompiles identical. Left: booting it.

  1. Kernel. buildLinux with Linux 7.2.6 and Armada’s patch series (209 patches: the RP6 panel driver, RSInput gamepad, LEDs, touchscreen, haptics, Adreno 740 fixes). The RP6 device trees copied into arch/arm64/boot/dts/qcom in postPatch. Config: arm64 defconfig plus Armada’s overrides as structuredExtraConfig; built in: DRM_MSM (as a module the screen stays black after the bootloader hands it over), the panel, LEDS_QCOM_LPG, QCOM_PMIC_GLINK, compressed firmware loading; 4K pages (FEX needs them).
  2. Boot image. A boot.loader.external installer that writes /boot/KERNEL on every nixos-rebuild: mkbootimg (nixpkgs android-tools) with the arguments above, gzip(Image) with both RP6 DTBs appended, the NixOS initrd, and init=<toplevel>/init plus boot.kernelParams under 512 bytes. The previous one is kept as KERNEL.BAK. mobile-nixos’s boot image builder is prior art.
  3. SD image. nixpkgs’ sd-image module: MBR (Android refuses GPT cards), a FAT partition first with KERNEL and rocknix_abl/, then the ext4 root.
  4. Verify the format without the device. Download an Armada release, unpack its KERNEL with unpackbootimg, and compare header, offsets, DTB order and command line with ours.
  5. Firmware, provisionally. linux-firmware carries the redistributable parts (qcom/a740_sqe.fw, qcom/gmu_gen70200.bin, qcom/sm8550/a740_zap.mbn, Wi-Fi and Bluetooth). The ADSP and CDSP blobs (battery, charging, audio) are OEM files without a licence: an unfree derivation, never pushed to a public cache, eventually extracted from your own device rather than taken from Armada.

Phase 1: bring-up

  1. Back up first. Root Android, run Armada’s backup_abl.sh, and keep abl_a.img and abl_b.img off the device. Flashing the wrong SoC’s ABL, or an interrupted dd, leaves the device unbootable, and no recovery procedure (EDL / firehose) is documented.
  2. Flash ROCKNIX ABL for SM8550, then boot Armada from SD: proves the bootloader and hardware, and gives a reference for dmesg, loaded modules and firmware.
  3. Boot NixOS from SD: serial-free bring-up means the panel has to work first; then input, Wi-Fi and SSH, after which everything else is remote.
  4. Extract the firmware from Android’s partitions and package it.

Phase 2: a handheld

Gamescope on Turnip (Mesa 26.2 has Adreno 740), audio through the AYN/Odin2 UCM profile, the controller through rsinput and InputPlumber, the orientation, fan and power handling, suspend disabled until s2idle is tested. Steamix’s session, switching, Decky (built from source, so native on aarch64, where Armada runs Decky’s x86 binary under FEX) and BoilR carry over.

Phase 3: Steam

Started (2026-10-05): steam-arm (Handhelds) pins the client’s updater and Valve’s ARM64 runtime; on ARM hardware it installs the client and brings up Steam’s UI to the sign-in screen. Left: the client on the device’s GPU, and Steamix’s Gaming Mode starting it on aarch64.

nixpkgs’ Steam is x86-only (programs.steam needs the 32-bit package set), so Steam on ARM is a separate package, modelled on Armada’s bootstrap:

  • Valve’s ARM64 client: the steam_client_steamdeck_publicbeta_linuxarm64 manifest from client-update.steamstatic.com lists every package with a SHA-256, so each becomes a fetchurl, pinned by manifest version, with an update script. Plus Valve’s steam-runtime-steamrt-arm64 tarball, whose hash Valve publishes. Unfree, never in a public cache.
  • A seed, not an install: Steam updates its own tree in ~/.local/share/Steam, so the store holds the seeded client and the first launch copies it into place; launched with Armada’s environment (steamrtarm64/steam, its LD_LIBRARY_PATH) and Steamix’s Gaming Mode flags.
  • Steamix’s Gaming Mode session on aarch64 starts that instead of programs.steam.

Phase 4: x86 games

  • Windows games run on Valve’s own ARM64 Proton (proton_11-arm64, proton-experimental-arm64), served by Steam, with Wine ARM64EC and Valve’s FEX inside. It expects an x86 graphics stack at the hardcoded path /usr/share/guestos/fex-mesa: Armada overlays FEX’s Arch Linux root filesystem with an x86_64 and i686 Turnip build there.
  • Native x86 Linux games run through the system FEX: nixpkgs has fex (2608, with thunks) but no NixOS module yet; the draft PR nixpkgs#379544 adds one with binfmt registration. Needed on top: FEX’s config, FEXServer with erofs-fuse, the pinned root filesystem, and the guestos overlay as a systemd mount.
  • Armada also carries kernel patches emulating unaligned atomics, which some x86 games need under FEX.

Phase 5: the Steamix experience

Lossless Scaling (lsfg-vk has community ARM ports), SteamOS Manager with Valve’s Steam Frame patches (it identifies devices by DMI, which ARM boards do not have), handheld controls, the settings GUI. Then the demo: the same Steamix module on a PC and on a Snapdragon handheld.

Timeline

WhenWhat
Oct - Nov 2026Phase 0, and bring-up as soon as the device is there
Dec 2026 - Jan 2027A handheld: graphics, input, audio, Steamix’s session
Feb - Mar 2027Steam’s ARM64 client
Spring 2027NixCon call for talks: submit with what runs then
Apr - Jun 2027x86 games through FEX and ARM64 Proton
Summer 2027Polish, Steamix features, the talk

Risks

  • Bricking while flashing the bootloader, with no documented recovery.
  • A closed bootloader: ROCKNIX ABL cannot be rebuilt or audited; its behaviour is inferred.
  • Firmware without a licence: the OEM ADSP and CDSP blobs.
  • Patch maintenance: 209 out-of-tree kernel patches, few upstreamed.
  • Valve’s ARM64 client is a public beta: no stable URL, versions can be withdrawn.
  • Undocumented pieces of Valve’s ARM stack: what its FEX tooling and runtime expect from the host beyond the guestos path.