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, plainliband 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
deckyPluginsscope) come from the flake’soverlays.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) logssteamix.userin 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:
| File | Piece |
|---|---|
gaming-mode.nix | The Gaming Mode session: the gamescope session script, and the system-level bits it depends on. |
session-select.nix | The steamos-session-select switcher Steam calls, plus the “Return to Gaming Mode” desktop entry. |
autostart.nix | The default login path: a greetd session loop running the steamix-session launcher. |
sddm.nix | The SteamOS-shaped login path: SDDM autologin plus SteamOS Manager, behind steamix.loginManager = "sddm". |
tweaks.nix | SteamOS’ system tuning — zram, earlyoom, a couple of network sysctls — behind steamix.tweaks.enable. |
decky-loader.nix | The Decky plugin loader service, behind steamix.decky-loader.enable. |
manager.nix | The SteamOS Manager daemon, behind steamix.manager.enable. |
default.nix | Options, 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:
- Read (and delete) the one-shot selection file
~/.local/state/steamos-session-select; default to thesteamsession when it is absent. - Resolve
<session>.desktopin the collectedservices.displayManager.sessionData.desktopsoutput — the same session registry GDM/SDDM would consult, so anything that registers a Wayland session (GNOME, Plasma, the gamescope Steam session, …) is launchable. - Export the session identity (
XDG_SESSION_TYPE,XDG_SESSION_DESKTOP,XDG_CURRENT_DESKTOPfrom the file’sDesktopNames=), push it into the systemd user manager and D-Bus activation environment, andexecthe file’sExec=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=ttysession 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*→ writesteamix.desktopSessioninto 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
startxdance; 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
autoStartmode — 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 stopsgamescope-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.
| Option | Default | gamescope flag | Steam capability |
|---|---|---|---|
steamix.hdr.enable | true | --hdr-enabled | STEAM_GAMESCOPE_HDR_SUPPORTED |
steamix.vrr.enable | true | --adaptive-sync | STEAM_GAMESCOPE_VRR_SUPPORTED |
steamix.tearing.enable | false | --immediate-flips | STEAM_GAMESCOPE_TEARING_SUPPORTED |
steamix.mangoapp.enable | true | --mangoapp | STEAM_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.
| Setting | Why |
|---|---|
zramSwap — zstd, 50% of RAM, priority 100 | A 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 swap | Under 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 = 1 | Some storefronts and matchmaking services sit behind PMTU black holes. |
net.ipv4.tcp_fin_timeout = 5 | A 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:
| Setting | Why |
|---|---|
security.pam.loginLimits — hard nice of -8 for steamix.user | Proton 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 on | Gaming 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.
| Option | Type | Default | Purpose |
|---|---|---|---|
.enable | bool | false | Install Heroic. |
.package | package | pkgs.heroic | What 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.
| Option | Type | Default | Purpose |
|---|---|---|---|
.enable | bool | false | Install RetroArch with the cores. |
.retroarch.package | package | pkgs.retroarch-bare | The frontend the cores and settings are wrapped around. |
.retroarch.cores | list of package | Nestopia, bsnes, mGBA, PCSX ReARMed | The cores, from pkgs.libretro. |
.retroarch.settings | attrs of string | { } | retroarch.cfg settings applied over the user’s own on every start. |
.retroarch.finalPackage | package | read-only | The RetroArch that is installed. |
.kiosk.enable | bool | false | Boot straight into RetroArch, full screen in cage, started again when it quits. Not together with Gaming Mode’s autoStart. |
.kiosk.user | null or string | steamix.user | Who 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.
| Option | Type | Default | Purpose |
|---|---|---|---|
.enable | bool | false | Sync on every login. |
.package | package | pkgs.boilr | What to run. |
.settings | TOML attrs | { } | BoilR’s configuration, written to ~/.config/boilr/config.toml on every login. |
.steamGridDbKeyFile | null or str | null | A file with a SteamGridDB API key, read at login, for artwork. |
.timeout | positive int | 120 | Seconds 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.
| Option | Type | Default | Purpose |
|---|---|---|---|
.enable | bool | false | Install the layer for Steam’s games. |
.package | package | pkgs.lsfg-vk | What to install. |
.support32Bit.enable | bool | false | Also install the 32-bit layer, for 32-bit games. |
.dll | null or str | null | Where Lossless Scaling’s DLL is, if lsfg-vk does not find it. |
.allowHalfPrecision | bool | true | FP16 frame generation, 2 to 3 times faster where supported (2.x). |
.profiles | attrs of profiles | { } | Declarative profiles; see below. |
.ui.enable | bool | false | lsfg-vk’s configuration UI, for editing profiles in Desktop Mode. |
.ui.package | package | pkgs.lsfg-vk-ui | Which UI; keep it on the layer’s major version (Steamix warns otherwise). |
.deckyPlugin.enable | bool | see below | Manage lsfg-vk from Gaming Mode with the Decky LSFG-VK plugin. |
.deckyPlugin.package | null or package | pkgs.deckyPlugins.decky-lsfg-vk or null | Which plugin. |
Three ways to run it.
-
From Gaming Mode, with Decky. When Decky Loader is on and
profilesis 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
profilesset:
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
profilesempty 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.
| Option | Type | Default | Purpose |
|---|---|---|---|
.ge.enable | bool | false | Install GE-Proton as a compatibility tool. |
.ge.package | package | pkgs.proton-ge-bin | Which GE-Proton; also what the FSR 4 variant is built from. |
.fsr4.enable | bool | false | Add a “GE-Proton (FSR 4)” compatibility tool. |
.fsr4.version | null or str | null | The FSR 4 DLL version to ask for, e.g. "4.1.1"; null takes GE-Proton’s default. |
.fsr4.indicator | bool | false | Draw 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.
| Option | Type | Default | Purpose |
|---|---|---|---|
.enable | bool | false | Run the loader. |
.package | null or package | pkgs.decky-loader or null | What to run. |
.user | str | "decky" | Unprivileged user plugins run as; created automatically when left at the default. |
.stateDir | path | /var/lib/decky-loader | Installed plugins and their data. |
.extraPackages | list of package | [ ] | Extra tools on the loader’s PATH, for plugins that shell out. |
.extraPythonPackages | function | _: [ ] | 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.
| Option | Type | Default | Purpose |
|---|---|---|---|
.enable | bool | false | Run the daemon. |
.package | null or package | pkgs.steamos-manager or null | What 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.
greetd | sddm | |
|---|---|---|
| Needs anything outside nixpkgs | no | yes — steamos-manager |
| logind session | class greeter, type tty, corrected by a pam_env rule | class user, type wayland natively |
| Respawn loop | greetd’s greeter slot, used kiosk-style | sddm.autoLogin.relogin |
| Session switching | steamos-session-select writes a state file the launcher consumes | SteamOS Manager’s SessionManagement1, which Steam drives directly |
| Closure | small | Qt, 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-waylandas well assteam. SteamOS Manager asks forgamescope-wayland.desktopby 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-selectkeeps its name but delegates tosteamosctl 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.nixnever 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:
| Import | What it buys you |
|---|---|
common/gpu/amd | amdgpu driver stack incl. early KMS — flicker-free boot into gamescope |
common/cpu/amd/pstate, common/cpu/intel | modern CPU frequency scaling / microcode |
common/pc/ssd | periodic TRIM |
common/pc/laptop | power 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.enableplus itsalsa/pulsesub-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.openFirewallandlocalNetworkGameTransfers.openFirewallopen 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
| Device | Module | SoC |
|---|---|---|
| Retroid Pocket 6 | nixosModules.retroid-pocket-6 | QCS8550 (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 needsboot.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:
- Root Android, and copy
rocknix_abl/from the SD card to the root of the internal storage. - Run
rocknix_abl/SM8550/backup_abl.shas root, and copyabl_a.imgandabl_b.imgsomewhere off the device. They are the way back to stock. - Check you are in the folder for your SoC (
rocknix_abl/READMElists which device has which), then runflash_abl.shas root. - 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
- Charge it and boot Android once, to know it works as shipped.
- Build the image and write it to an SD card (above).
- Back up and flash the bootloader (above). The backup is the step not to skip.
- 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
evtestshows the sticks inverted, pick the other model in the menu. - 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.
- Once NixOS is up:
nmtuifor Wi-Fi,passwd, then everything else over SSH. Collectdmesg,lsmodandls /sys/firmware/devicetree/basefor 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.
| Test | What it covers |
|---|---|
greetd | The 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. |
sddm | The 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-manager | SteamOS 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-loader | The 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. |
library | BoilR 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. |
emulation | The 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-vk | No 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-abl | No 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-config | No 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-6 | No 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. |
options | No 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-selectthe way the power menu does, and exits onsteam -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 inlineenvrule 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
~/.localas root, then refused to write through it, so Decky never appeared, and Steam’s own first-run install into~/.local/share/Steamhad 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 tookSessionManagement1, 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.targetunneeded, 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:
| Package | Why it is not on cache.nixos.org |
|---|---|
| SteamOS Manager | not in nixpkgs |
| Decky Loader | not in nixpkgs |
| 32-bit gamescope WSI layer | in 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:
| Name | Revision |
|---|---|
locked | nixos-unstable at the revision Steamix’s flake.lock pins |
unstable | the current nixos-unstable channel head |
stable | the 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:
- fetch the configured flake ref;
- build the toplevel and compare it against the running system — identical
store path means exit
7; nixos-rebuild switchwith 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.sunshineneeds only session wiring and the capture capability to stream Gaming Mode to Moonlight clients;services.scxwithscx_lavdis 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 type | Widget |
|---|---|
bool | checkbox |
enum | dropdown |
str, int, float | text field |
listOf package | multi-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_publicbetalinuxarm64channel, 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.confmarker so SteamOS Manager owns switching,steamosctlbehindsteamos-session-select, and thegamescope-waylandalias. Steam runs with the same-gamepadui -steamos3 -steampal -steamdeckflags. - 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
- A FEX module. nixpkgs packages
fexfor aarch64 but has no NixOS module: no binfmt registration for x86 and x86_64, no FEXServer, no root filesystem. Aprograms.fexwithboot.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. - An aarch64 Steam. nixpkgs’
steamis x86-only. An ARM64 path would fetch Valve’slinuxarm64client manifest and the ARM64 runtime and run them in an FHS environment, the way the x86 package runs its bootstrap. - 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.
- SteamOS Manager with the Steam Frame patches, so TDP and GPU controls work on devices that are not x86 PCs.
- 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.
- 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
- 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.
- The settings file, because it is the contract the GUI needs.
- The GUI, as a Decky plugin.
- Handhelds, launchers, emulation, streaming — the breadth that makes the project worth switching to.
- Upstreaming and the VM tests, throughout, whenever a piece stabilises.
- 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
| SoC | QCS8550 (Snapdragon 8 Gen 2, SM8550 family), Adreno 740 |
| Device tree | qcs8550-retroidpocket-rp6.dts (and -rp6-top-dpad), out of tree, an Odin 2 derivative (qcs8550-ayn-common.dtsi) |
| Display | 1080x1920 portrait AMOLED, rotated (rotation = <270>): gamescope --force-orientation left |
| Controller | RSInput MCU on uart15 (rsinput driver), shows up as “AYN Odin2 Gamepad” |
| Audio | sound card “AYN-Odin2”, UCM AYN/Odin2 |
| Fan | pwm-fan, kernel thermal trips |
| Suspend | broken 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
/KERNELfrom 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
/EFIso 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.
- Kernel.
buildLinuxwith 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 intoarch/arm64/boot/dts/qcominpostPatch. Config: arm64 defconfig plus Armada’s overrides asstructuredExtraConfig; 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). - Boot image. A
boot.loader.externalinstaller that writes/boot/KERNELon everynixos-rebuild: mkbootimg (nixpkgsandroid-tools) with the arguments above, gzip(Image) with both RP6 DTBs appended, the NixOS initrd, andinit=<toplevel>/initplusboot.kernelParamsunder 512 bytes. The previous one is kept asKERNEL.BAK. mobile-nixos’s boot image builder is prior art. - SD image. nixpkgs’
sd-imagemodule: MBR (Android refuses GPT cards), a FAT partition first withKERNELandrocknix_abl/, then the ext4 root. - Verify the format without the device. Download an Armada release,
unpack its
KERNELwithunpackbootimg, and compare header, offsets, DTB order and command line with ours. - 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
- Back up first. Root Android, run Armada’s
backup_abl.sh, and keepabl_a.imgandabl_b.imgoff the device. Flashing the wrong SoC’s ABL, or an interrupteddd, leaves the device unbootable, and no recovery procedure (EDL / firehose) is documented. - 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. - 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.
- 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_linuxarm64manifest fromclient-update.steamstatic.comlists every package with a SHA-256, so each becomes afetchurl, pinned by manifest version, with an update script. Plus Valve’ssteam-runtime-steamrt-arm64tarball, 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, itsLD_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
| When | What |
|---|---|
| Oct - Nov 2026 | Phase 0, and bring-up as soon as the device is there |
| Dec 2026 - Jan 2027 | A handheld: graphics, input, audio, Steamix’s session |
| Feb - Mar 2027 | Steam’s ARM64 client |
| Spring 2027 | NixCon call for talks: submit with what runs then |
| Apr - Jun 2027 | x86 games through FEX and ARM64 Proton |
| Summer 2027 | Polish, 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.