On macOS 15 (Sequoia), Valve's actual SteamOS for Steam Frame runs in a lightweight VM on Hypervisor.framework (libkrun) with GPU acceleration via Venus.
game (DX9/10/11) ─ DXVK (Proton 11, x86 via FEX) ─ Vulkan
└─ guest Mesa Venus ─ virtio-gpu (blob, 16K alignment)
└─ libkrun ─ virglrenderer (Venus) ─ MoltenVK | KosmicKrisp ─ Metal
Vulkan on Metal — MoltenVK from the UTM fork (geometry shaders, robustness2) +
VK_EXT_depth_clip_enable (PR #2712) + our fixes, by default. KosmicKrisp (Mesa's Vulkan driver on
Metal 4) is an experimental alternative on macOS 26+ (see “Vulkan driver”).
- Apple Silicon Mac, macOS 15+, ~150 GB of free space (the disk image is sparse).
- Xcode 26+ (full installation: its
actoolbuilds the app icon from an Icon Composer document), Homebrew, rustup. - KosmicKrisp (optional, alternative Vulkan driver): built only on macOS 26+;
host/kosmickrisp/build.shinstalls its Homebrew dependencies (llvm spirv-llvm-translator spirv-tools vulkan-loader glslang). - OrbStack (or Docker with arm64 and
--privileged): the kernel, Mesa, and disk image are built in Linux containers. - Homebrew packages:
meson ninja pkg-config dtc xz lld sshpass go(go: license inventory of the bundled gvproxy/desync). - libepoxy 1.5.10 is built from pinned sources by
host/libepoxy/build.sh, before virglrenderer and libkrun; all three useMACOSX_DEPLOYMENT_TARGET=15.0, including on macOS 26/27 build hosts. Bundling rejects Mach-O deployment targets above macOS 15.0 (KosmicKrisp alone may require 26.0).
./build.sh # everything: MoltenVK, virglrenderer, libkrun, launcher, kernel, Mesa, disk
./run.sh # a window with SteamOSBuild parts separately: ./build.sh host, ./build.sh guest, or the scripts in the table below.
The SteamOS image is downloaded from Valve's servers (a signed RAUC bundle); its signature and
sha256 are verified.
Guest release artifacts have adjacent .inputs.json build receipts binding their selected Git
working-tree inputs to their output bytes. Relevant dirty edits invalidate them; unrelated commits
do not. host/launcher/bundle.sh refuses missing/stale receipts before modifying the app;
dist.sh also validates the receipts and resources inside an existing app before packaging it.
The error names the step to rerun. To refresh guest artifacts without touching an existing SteamOS
disk: guest/kernel/build.sh (only when its inputs changed or its receipt is missing),
scripts/build-image.sh builder rootfs, guest/mesa/build.sh, then
scripts/build-image.sh initramfs layer; finally host/launcher/build.sh. An unstamped artifact
needs its normal build once; kernel builds reuse the build volume without clean.
ALLOW_NO_VENUS=1 layers are test-only and cannot be released.
VM options: ./run.sh --display 1920x1080 --cpus 10 --mem 24576 (full list:
work/out/steamac-vm --help).
Frame pacing: ./run.sh --perf-stats (or STEAMAC_PERF_STATS=1) prints guest-frame and on-screen
frame intervals to the terminal every 5 s (p50/p95/p99/max, number of intervals > 25 and > 50 ms).
Stutters when a new effect first appears come from Metal shader compilation (~50–100 ms per
pipeline); Metal caches the result on disk, so the effect does not stutter again, even after
restarting the game or VM (measured via Venus: 102 ms → 0.95 ms per pipeline in a new process).
Steam Shader Pre-Caching and “Allow background processing of Vulkan shaders” (Settings → Downloads)
are on by default in the VM. Pre-caching is what brings Proton the transcoded cutscene videos: Steam
downloads them per game (steamapps/shadercache/<appid>/transcoded_video.foz, e.g. 836 MB for
Heroes of Might and Magic: Olden Era, 3.8 GB for Diplomacy is Not an Option) and passes
STEAM_COMPAT_TRANSCODED_MEDIA_PATH to Proton, which plays them for videos it cannot decode itself;
with pre-caching off those videos show a placeholder. DXVK's own cache (DXVK 2.7) lives in the game's
Wine prefix and works either way. The cost: every pipeline Steam's fossilize_replay processes is
compiled by Metal on the Mac (~70–100 ms), and Steam processes again after its shader updates for a
game and, for every game, after a launcher update that changes MoltenVK (the Venus driver identity is
a hash of MoltenVK's pipelineCacheUUID). Background processing does most of that while Steam is
idle; what is left shows as “Processing Vulkan shaders” when a game starts and can be skipped with
Skip. To turn it off: Steam → Settings → Downloads → Enable Shader Pre-caching (and/or Allow
background processing of Vulkan shaders).
Stock Steam keeps background processing off (EnableShaderBackgroundProcessing absent from
~/.local/share/Steam/config/config.vdf reads as 0). Before Steam starts,
/usr/lib/steamac/steam-shader-defaults (ExecStartPre of steam.service) writes
"EnableShaderBackgroundProcessing" "1" to the ShaderCacheManager block if there is no value yet,
once per Steam installation (marker config/steamac-shader-defaults), so a choice made in Steam's
settings stays. Launcher 1.4 shipped both settings off ("DisableShaderCache" "1",
"EnableShaderBackgroundProcessing" "0"); on the first Steam start after updating, the script turns
both back on once, but only if both are still exactly those values.
SteamOS takes the Mac's time zone and 12/24-hour clock format (Settings > General
Use the Mac's time zone and clock format, on by default, applies on the next start).
On every boot the launcher adds steamac.tz=<IANA zone of the Mac> (TimeZone.current) and
steamac.clock24=0|1 (the localized j hour template, honoring macOS's 24-hour time switch).
The initramfs points /etc/localtime at the zone (timedatectl, Steam's Time zone setting via
steamos-set-timezone, and Steam's clock). /etc/steamac/mac-timezone remembers the last applied
zone; a different zone chosen inside SteamOS stays (until it is the Mac's zone again).
Before Steam starts, /usr/lib/steamac/mac-clock-format updates b24HourClock in the account's
userdata/<account>/config/localconfig.vdf, at
UserLocalConfigStore/Software/Valve/Steam/FriendsUI/FriendsUIJSON. This is Steam's
Settings → Time and date → 24-hour clock toggle, not a client-wide setting: on a new disk the file
does not exist before sign-in, so it is applied on the first Steam start after sign-in.
Desktop Mode gets [Formats] LC_TIME in ~/.config/plasma-localerc (en_GB.UTF-8 for 24-hour,
en_US.UTF-8 for 12-hour; this also selects the time/date locale). Last applied values and
independent user overrides are kept in ~/.config/steamac/mac-clock24.json: changing the format
in Steam or Plasma stops following the Mac for that setting, without changing the other.
With the launcher setting off, no zone or format is touched. A test can explicitly supply
--cmdline "console=hvc0 rootwait steamac.clock24=0" (or 1) without changing macOS settings.
Boot and shutdown progress does not disappear before ready: the first click or keypress in the
window collapses the full-screen overlay into a progress pill at the bottom center (stage,
percentage, bar, detail line such as 378 / 564 MB · 1.9 MB/s; input reaches the guest). Clicking
the pill or View → Show Boot Progress expands it again; with the overlay disabled (Settings >
General), the pill appears immediately. While a new disk is being prepared or the Steam client is
being downloaded / installed, clicks and keypresses do not collapse the overlay, and an overlay
previously collapsed by clicking expands automatically (overlay: expanded for steam-download); one collapsed via View → Show Boot Progress remains a pill until ready. Before
ready, the window title repeats the stage: “FX Steam Launcher — Downloading Steam update 70%”,
“— Starting Steam…”, “— Shutting down…”. The log records overlay: collapsed to pill (click) /
expanded from pill. After ready, if the window has had no picture for ≥ 3 s (scanout is off, or
no frame has arrived after scanout was set or resized), or the displayed frame has been black for
≥ 5 s (sparse sampling of 64 × 40 points, brightness < 8/255 for ≥ 99.5%, no more than 4 times a
second, ~3 µs), and the focus is not in a game and the guest is not asleep / paused / suspended,
the pill says “Waiting for SteamOS to draw…” with the reason, agent heartbeat, and VM CPU usage;
it disappears on the first non-black frame (no-picture: shown after 5.0 s (black picture …) /
hidden after … (first non-black frame)). Test:
work/out/steamac-vm --selftest-pill --selftest-out DIR.
While a game has focus (focus game <appid>), if the guest sends no GPU commands for 2 s (the
virtio-gpu control queue and Venus rings — krun_gpu_get_activity counters), a card saying “Still
working — loading or compiling shaders…” appears over the last frame with VM CPU usage; if the
guest agent has also stopped sending heartbeats (> 5 s), it says “SteamOS is not responding…”
(this applies regardless of focus). An idle Steam interface sends no GPU commands for minutes and
does not trigger the indicator. It disappears on the very next GPU command or when focus leaves the
game; every occurrence is logged (stall: gpu idle 3.1 s (guest alive, …)). With --perf-stats, a line
perf: gpu ctrl/s=… ring/s=… longest-idle=… is added every 5 s. Disable it in Settings > General. The agent runs in each
gamescope session (gaming and Desktop Mode): when a session ends (Switch to Desktop, Return to Gaming
Mode), no heartbeat is expected until the new session's agent sends one; while the VM is suspended or
asleep the indicator is off, and after resume, guest wake, and Mac sleep, idle and heartbeat timers
start over.
| Keys in the window | |
|---|---|
| Ctrl+Cmd+F | full screen (macOS turns on Game Mode: Info.plist declares a game — LSApplicationCategoryType public.app-category.games, GCSupportsGameMode, LSSupportsGameMode; gamepolicyd logs “Game mode status is now on”) |
| Ctrl+Cmd+G | manually capture / release the mouse |
| Ctrl+Cmd+P | toggle Apple's Metal Performance HUD (FPS, frame interval, GPU time, memory); also View → Show Metal Performance HUD and Settings > Display |
| Ctrl+Option | release the captured mouse |
| close window | shut down the guest (power button) |
Mouse (--mouse auto, by default): the SteamOS cursor follows the Mac cursor precisely. gamescope
(gaming mode) does not accept absolute coordinates, so the launcher moves it with relative deltas
without acceleration, in pixels of the surface gamescope shows: the agent sends its size with each
focus line (focus steam 1920x1200 when Steam's Settings > Display > Resolution is not the native
one, so its UI is smaller than the display and scaled up), and the launcher maps the window onto that
surface as gamescope scales it into the display (--selftest-pointer). When a game has focus in the
guest (the agent sends focus game <appid> <w>x<h>), the
first click captures the mouse (relative movement for mouse-look); Ctrl+Option releases it. On
returning to Steam, capture is released automatically. Mouse menu:
- Capture Mouse in This Game — auto-capture for the current game (saved by appid);
- Auto-Capture Mouse in Games — default for all games;
- Capture / Release Mouse Now — same as Ctrl+Cmd+G.
These and all other settings are in the Settings window (see below), domain
es.fxgam.steamac (defaults read es.fxgam.steamac); on first launch they are copied once from
the previous domain dev.steamac.vm. Metal stores the shader cache by app identifier, so after
changing the identifier, the first launch of games compiles shaders again (one “cold” start).
--auto-capture on|off overrides the default for one launch.
--mouse tablet — absolute tablet (gamescope ignores it, so only for other guest compositors),
--mouse capture — always capture on click.
Guest access: ssh -p 2222 steamos@127.0.0.1, password steamos (change via
STEAMOS_PASSWORD=... scripts/build-image.sh disk). The hvc0 console is in the terminal running
run.sh.
SSH is toggled with one switch: Settings → Advanced → Enable SSH (or --ssh-port N /
--no-ssh). On each boot the launcher passes steamac.ssh=0|1: with 0, the Mac port is not opened
at all (gvproxy without forwarding), and initramfs masks sshd in SteamOS. By default SSH is enabled
in the dev launcher (work/out/steamac-vm, ./run.sh, port 2222) and disabled in
FX Steam Launcher.app (the SteamacReleaseDefaults key in Info.plist, set by bundle.sh). When
enabled, the launcher generates a password for user steamos (20 characters, SecRandomCopyBytes),
stores it in the Keychain separately for each disk (by its GPT GUID), and on the next boot passes
only its SHA-512 crypt hash to the guest (“config payload” disk, steamac.config=1; the guest
responds config applied). Settings shows the user, password (Show/Copy), ready-to-use command
ssh -p … steamos@127.0.0.1, status “applied / will apply on next start”, and Regenerate Password;
in the dev launcher, the password is generated only via the button (Docker-built disks keep
steamos). Disks created in the app receive a password only this way — they have no default
password. From the terminal, steamac-vm --ssh-password <disk> prints the user, password, and
status.
Ordinary networking uses gvproxy user-mode NAT (192.168.127.2 in SteamOS); LAN broadcasts
do not cross that NAT. Settings → Advanced → LAN Remote Play (next start), or
--lan-remote-play, enables a launcher-side discovery relay and same-port forwards:
UDP 27031–27036, TCP 27036–27037, from the Mac to the guest. It is off by default:
enabling it exposes Steam's Remote Play services to other machines, independently of SSH.
--no-lan-remote-play disables it for one boot; Network off disables it too.
Allow macOS's Local Network permission and incoming traffic in the Mac/SteamOS firewall.
Steam Link and the Mac must be on the same IPv4 subnet (Wi-Fi client isolation, guest
networks, VLANs, routed discovery and IPv6-only LANs are not supported). Enable Remote Play
in guest Steam; use the Mac's LAN address for manual pairing, not 192.168.127.2.
Quit the Mac's own Steam client if it owns these ports: the launcher never shares or steals
UDP 27036, logs conflicts as remote-play: disabled for this boot, and rolls back its forwards.
The Mac Steam client's own UDP 27036 broadcasts are not relayed back into the guest.
The relay preserves Steam client identity and unknown protobuf fields, replaces status
address hints with the Mac's LAN IPv4 address, and retains ports because the forwards use
the same port numbers. Guest status announcements are refreshed by real discovery queries
every five seconds (gvproxy does not export unsolicited guest-subnet broadcasts); no status
is invented when Steam does not answer. Steam may not advertise a signed-out host.
--selftest-remote-play checks packet parsing and address rewriting without booting a VM.
For a real LAN probe (on the Mac or a second machine), run
python3 scripts/test/remote-play-discovery.py --bind <LAN-IP> --broadcast <subnet-broadcast> --expect-host <Mac-LAN-IP>.
It uses an ephemeral port, prints the actual Steam reply and rewritten address, and fails if
no host answers; it does not claim pairing or streaming success.
Protocol references: Valve's Remote Play network settings,
Steam remote-client protobufs,
and discovery envelope framing.
FX Steam Launcher → Settings… (Cmd+, — also works when the guest has the keyboard). Each field is labeled “applies now” (takes effect immediately) or “applies on next start” (on the next VM start). If anything in the second group changes, Restart VM to apply appears at the bottom: the guest shuts down normally via the power button, and the supervisor starts the VM again with the new values (also available through the Restart VM menu item). Command-line flags take precedence over saved values, but only for that launch: the field displays “overridden by command line (--cpus 6)”.
| Tab | Applies now | On next start |
|---|---|---|
| General | boot/shutdown overlay; “Still working…” indicator on GPU idle; “When FX Steam Launcher is in the background”: Mute sound (on by default: krun_snd_set_volume(…, mute) with a gradual ~150 ms fade-out; volume is restored when returning to the window) and Pause the game (off by default: the guest agent freezes only the game in focus — systemctl --user freeze app-steam-app<appid>-*.scope, cgroup v2; Steam, downloads, and updates continue running; online games may disconnect). While the agent confirms the freeze (game-frozen/game-thawed), the window is dimmed, with a “Game paused · Click to resume” card and “— paused” in the title; clicking the window resumes the game and is not passed to the guest; crash reports (--no-crash-reports, see below); Check for updates at startup (on by default, see “Update check”); frame statistics logging (--perf-stats) |
full screen at startup; Use the Mac's time zone and clock format (on by default, see above) |
| Display | guest follows window size; Apple's Metal Performance HUD in the upper-right corner of the window (Ctrl+Cmd+P, View → Show Metal Performance HUD); MetalFX super resolution (off by default): Apple's MetalFX spatial upscaler scales the guest picture to the window's pixel size whenever the window has more pixels than the guest (2× on Retina screens, scaled or fullscreen windows) instead of linear / nearest scaling; it runs on the guest frame in the launcher, so it works for every game and the Steam UI | physical size source (auto from display / DPI / mm — --dpi, --display-mm), refresh rate (--refresh), window size (--display): standard resolutions from 1280 × 800 (Steam Deck) to 3840 × 2160 (those that do not fit on the display are marked “larger than this screen”; the window is shrunk as before), “Fit to screen” (largest size for the display, recalculated on every launch), or “Custom…” (W × H fields); Retina resolution (optional, off by default): the guest display gets the screen's pixel density (window points × the screen's backing scale, taken at boot; the scale is lowered so no side exceeds 4094 px) at the same EDID physical size, so SteamOS scales its UI up to the same size with sharp text — but games draw 4× the pixels and each frame copy is 4× larger; recommended instead: Retina resolution off + MetalFX super resolution (a 2× upscale on Retina screens) |
| Mouse | auto-capture in games; game list (name from appmanifest_<appid>.acf, Default/Auto/Off, remove) |
— |
| Controller | which physical controller (GameController) drives the virtual pad (first connected or selected), whether SteamOS gets a pad and what it appears as (--no-gamepad, --pad, see “Controller”), whether a DualSense is passed through as itself, whether further controllers are players 2–4, swap A/B and X/Y, stick dead zone, live input test |
— |
| Sound | output device (System default follows macOS, or a specific CoreAudio device), volume/mute, Low/Normal/Safe buffer — via krun_snd_set_* (looked up with dlsym; with an older libkrun the fields are disabled with an explanation) |
sound (--no-sound) |
| Advanced | — | vCPU (--cpus), RAM (--mem), SSH enable/disable + port (--ssh-port, --no-ssh) and generated password, network (--no-net), disk image (--disk), Create New Disk…, Steam client (--steam-client, see “Steam client”), Vulkan driver (--vulkan-driver, see “Vulkan driver”) |
VM RAM and graphics share the Mac's memory. Automatic VM RAM is half of physical RAM
(4–16 GiB). On every boot the launcher also reserves at least 3 GiB, or a quarter of host RAM,
for macOS, other apps and driver overhead; the remainder is the GPU budget (256 MiB–16 GiB,
rounded down to 256 MiB). For a 16 GiB Mac this is 8 GiB VM + 4 GiB GPU + 4 GiB reserve;
a custom 9 GiB VM leaves 3 GiB for graphics. Settings → Advanced shows both allowances
and warns when custom VM memory leaves less than 2 GiB for graphics or exceeds the total.
Both KosmicKrisp and MoltenVK advertise this budget through Venus's device-local heap and,
when enabled, VK_EXT_memory_budget; zink's GL memory queries and DXVK/vkd3d therefore see
the same smaller heap instead of the entire Mac's unified RAM (STEAMAC-S).
steamac.gpu_mib= also updates Steam's VRAM-report layer. This guides games' texture budgets;
it is not a hard allocation cap and cannot prevent every OOM if a game ignores it or other
Mac apps consume the reserve. Lower VM RAM or texture settings in that case.
For a throwaway-VM check, --cmdline '… steamac.gpu_mib=3072' overrides both host and guest
reporting; host/virglrenderer/test/memory_budget.c queries heaps/budgets and fills real GPU
buffers up to the advertised heap (run with VN_DEBUG=mem_budget to expose the budget extension;
Venus leaves it disabled by default). Never run the allocation test on the developer's disk.
More room for games: free space on the Mac is not automatically free space inside SteamOS.
The home capacity is fixed when a disk is created. Settings → Advanced → Grow Disk… increases
it without recreating the disk or deleting games (grow only, up to 4096 GiB). For the running disk,
Grow and Restart shuts SteamOS down normally, takes the disk's exclusive lock while the VM is
stopped, enlarges the image, then boots again. SteamOS grows the last home partition with
systemd-repart, then its ext4 filesystem with x-systemd.growfs, before using it. Other launchers
must be stopped too; suspended VMs still own their disks. APFS / Mac OS Extended consume added space
only as SteamOS writes; exFAT allocates the full added capacity immediately, so growth checks its
free space first. Terminal, with SteamOS stopped:
work/out/steamac-vm --grow-disk /path/to/steamos.img --home-gib 128.
For tests: STEAMAC_DEFAULTS_DOMAIN=<domain> substitutes the settings domain; --selftest-settings --selftest-out DIR opens the window without a VM and writes a PNG of each tab; --control-fifo has
settings TAB, settings-dump PNG, set KEY VALUE (as from the window), restart.
How: Settings → General → “When closing the window” → Suspend — closing the window then
suspends the VM instead of shutting it down; or use FX Steam Launcher → Suspend (Ctrl+Cmd+S,
works even when the guest has the keyboard). krun_pause (libkrun patch 0016) stops all vCPUs and
guest audio, the window is hidden, and a ⏸ icon appears in the menu bar: “SteamOS suspended”,
since when and how much memory is in use, Resume, Shut Down SteamOS. A suspended VM uses
no CPU (~0%); the Mac can sleep.
Resume: click the Dock icon, relaunch the app (Finder, open, open -a), use the menu bar
icon, or choose Resume from the menu. The window returns (including full-screen mode, if it was
active), mouse capture is restored; the “Resuming…” progress pill stays until the first new guest
frame (if the guest GPU is idle, as with a static Steam interface, 0.5 s; at most 2.5 s).
Clocks: the guest's monotonic clock does not see the pause (libkrun shifts the virtual timer,
as QEMU does) — the sched_ext scheduler and watchdogs do not fire. Immediately after resume,
fx-clock-sync.service sets the wall clock (a root service in the layer, using the same
fx-progress-agent clock-sync binary, started by udev when the port appears): the launcher writes
its time as time <unix_ns> to the virtio port fx.clock; the service adjusts only
CLOCK_REALTIME (clock_adjtime(ADJ_SETOFFSET), only forward and only if it lags by more than 1 s).
After that adjustment, timesyncd synchronizes on its own.
Quit: Cmd+Q / Dock → Quit while suspended asks “SteamOS is suspended”: Shut Down SteamOS (the guest resumes and shuts down normally) or Cancel (it remains suspended). Logging out, restarting, and shutting down the Mac do not prompt.
Limitations: the state lives only in memory while FX Steam Launcher is running — it is not saved to disk (guest memory and host GPU state — virglrenderer, MoltenVK, Metal — are not serialized). Quitting the app, an app crash, logging out, or shutting down the Mac is an ordinary SteamOS shutdown; unsaved game progress is lost. All guest memory remains occupied while the VM is suspended. Guest network connections (online games, downloads) may drop and reconnect after a long pause.
Steam → Power → Sleep, Steam's idle auto-sleep (Settings → Power → “Sleep after”, 1 hour by
default), and systemctl suspend in the guest do not put the guest kernel to sleep (there is
nothing to wake s2idle in a VM — SteamOS previously hung this way until the app exited). The layer
replaces ExecStart in systemd-suspend.service (and systemd-suspend-then-hibernate /
systemd-hybrid-sleep likewise; hibernation is disabled in sleep.conf.d) with
fx-progress-agent sleep: it runs the system-sleep hooks (pre), writes
sleep <action> <token> to the virtio port fx.sleep, and waits for a response. The launcher
suspends the VM (krun_pause, as with Suspend — CPU ~0%, the Mac can sleep), but the window stays
open: a “SteamOS is sleeping” card overlays the frame. A click, keypress, gamepad button, or Dock
icon wakes it: krun_resume, wake <token> <unix_ns> is sent to the guest, the command adjusts
the wall clock (as with clock-sync), runs the post hooks, and exits — logind sends
PrepareForSleep(false), Steam wakes; the “Waking up…” progress pill stays until the first frame.
The click or keypress that woke the guest is not passed to the guest.
Closing the window during sleep follows “When closing the window”: Suspend hides the window
(Resume subsequently wakes it too); Shut Down / Cmd+Q wakes the guest and presses the power
button once the guest's sleep task has finished (awake <token>; logind ignores the button while
it is running). Without the port (--headless, old launcher), guest sleep fails instead of
sleeping.
For tests with --control-fifo: close, suspend, resume (also wakes a sleeping guest),
reopen, quit, wake (like waking the Mac), quit-prompt shutdown|cancel|dump PNG,
status open|close|dump PNG|item TITLE.
Steam → Power → Switch to Desktop starts KDE Plasma; the desktop's Return to Gaming Mode icon
goes back. On the Steam Frame image this mode is a VR desktop (plasma-session.target wants
SteamVR, which the VM masks), so the layer replaces it: plasma-session.target and
steamac-nested-desktop.service run Plasma as one KWin window inside the same gamescope
(/usr/lib/steamac/nested-desktop, like Valve's steamos-nested-desktop), sized to the display, so
gamescope shows it 1:1. gamescope-onready waits for that service: when Plasma exits, the session
ends and SDDM logs back in. Plasma gets its own runtime directory and D-Bus session bus; the layer's
steamosctl shim (/usr/lib/steamac/desktop-bin, first in its PATH) sends SteamOS commands such as
Return to Gaming Mode to the outer session bus, where steamos-manager runs. The desktop's Steam
autostart (/usr/lib/steamac/desktop-xdg/autostart/steam.desktop) keeps the client chosen in the
launcher instead of the stock -deckard (Frame client), which would download the other client on
every switch. The progress agent reports focus desktop <w>x<h> (the Plasma window): the launcher
keeps the relative pointer and maps it onto that window as gamescope scales it, and a boot straight
into Desktop Mode (steamos-session-select plasma-persistent) reports ready when the desktop is up.
Flatpak apps (Discover) run in bubblewrap, which mounts its own procfs in a user namespace. The kernel
allows that only while some procfs in the mount namespace is fully visible, and the initramfs binds
the synthesized /proc/cmdline over the real one; so it also mounts an untouched procfs at
/run/steamac/proc (nosuid,nodev,noexec). Without it every Flatpak app exits with bwrap: Can't mount proc on /newroot/proc: Operation not permitted.
Settings → General → Share clipboard with SteamOS (on by default, applies now): text (UTF-8)
and PNG images copied on the Mac can be pasted in SteamOS (Ctrl+V — in Steam's text fields, games
and Desktop Mode apps) and the other way round; limits 1 MiB of text and 16 MiB per image (larger
items are skipped with a log line). Items that password managers mark as concealed or transient
(org.nspasteboard.ConcealedType / TransientType) stay on the Mac unless Include concealed
(password manager) items is on. The launcher checks the pasteboard's changeCount on a 0.5 s
timer only while the app is active and the VM runs, and once on every activation — never in the
background, while suspended or asleep; images from SteamOS land on the Mac as PNG + TIFF.
Transport: the virtio-console port fx.clipboard, framed binary messages (HELLO / STATE /
SET with sequence numbers / ACK; host/launcher/Sources/steamac-vm/Clipboard.swift,
guest/progress-agent/src/clipboard.rs). In the guest the user service
fx-clipboard-agent.service (fx-progress-agent clipboard, wanted by the gaming and the Desktop
Mode session) owns and watches CLIPBOARD (XFixes; TARGETS, UTF8_STRING, text/plain;charset=utf-8,
TEXT, STRING, image/png, INCR above 256 KiB) on both gamescope Xwayland servers (:0 Steam,
:1 games): gamescope syncs plain text between them itself by taking the selection over, but not
images or INCR-sized text. In Desktop Mode it also uses the Plasma session's Wayland clipboard
through zwlr_data_control_manager_v1 (ext_data_control_manager_v1 if present); KWin bridges it
to X11 apps there while an X11 window is active. Echo suppression is by content: each side
remembers the content it last sent or took over, so one copy is one transfer however often
gamescope, KWin or Klipper re-announce it. The selection present when a display appears (e.g.
Klipper's restored history) is not sent to the Mac; the shared content is offered there instead.
--control-fifo has chord KEYCODE ctrl (e.g. chord 9 ctrl = Ctrl+V in the guest) and
set shareClipboard on|off.
Any controller macOS's GameController framework supports (Xbox, DualSense, DualShock 4, MFi, …)
drives a gamepad in SteamOS: the first connected one, or the one picked in Settings → Controller,
is player 1, and further ones are players 2–4 (“Several controllers” below). The pad is not a virtio-input
device: the launcher sends it over the virtio-console port fx.pad to the guest's root service
fx-pad.service (fx-progress-agent pad, started by udev when the port appears), which creates it
with uinput. So it follows the Mac while the VM runs: it appears when a controller connects (Steam
shows “Controller Connected”), disappears when its controller goes, and changes kind with it.
Settings → Controller → Appears in SteamOS as (--pad auto|xbox360|dualsense|dualshock4 for
one run):
- Automatic (default): the same kind as the controller that drives it — DualSense (or Edge) → DualSense, DualShock 4 → DualShock 4, anything else → Xbox 360 controller;
- Xbox 360 controller: what the kernel's
xpaddriver exposes (045e:028e); - DualSense / DualShock 4: what
hid-playstation/hid-sonyexpose for a USB pad (054c:0ce6/054c:09cc, version0x8111, face buttons by position, digital L2/R2 besides the analog triggers). Steam's SDL maps it as a PS5 / PS4 controller and shows PlayStation glyphs.
Rumble. The pad has FF_RUMBLE, like the real drivers, so SDL and Steam rumble it — games
through Steam Input reach it via Steam's virtual Xbox pad. uinput leaves playback to its user-space
driver: fx-pad implements the kernel's ff-memless rules (delay, length, repetitions, re-upload,
effects adding up) and sends the combined level, rumble <strong> <weak>, to the launcher, which
plays it with GameController haptics: the strong motor on the left handle, the weak one on the
right (one level everywhere on controllers without separate handles); nothing while the VM is
paused. Port protocol: guest/progress-agent/src/pad.rs. Touchpad, gyro, lightbar and adaptive
triggers are HID features of the real controller that this evdev device does not carry.
DualSense passthrough. When a DualSense (or Edge) drives the pad and it appears as a
DualSense, Settings → Controller → Pass a DualSense through (on by default, applies now) gives
SteamOS the controller itself instead of the uinput pad. The launcher opens it as a raw HID device
(IOHIDManager, without seizing it: GameController still selects it and wakes a sleeping guest) and
fx-pad recreates it with /dev/uhid: same report descriptor, vendor/product, and USB or Bluetooth
bus. The guest's hid-playstation driver binds to it as to a plugged-in controller (gamepad,
touchpad, motion sensors, lightbar and player LEDs, mute LED) and Steam uses its own HIDAPI
DualSense driver on /dev/hidraw*, so Steam Input gets touchpad, gyro and the mute button, and
drives rumble, lightbar and adaptive triggers itself. Input reports go to the guest as they are
(hid-input); output reports, GET_REPORT and SET_REPORT go back to the controller (hid-output,
hid-get / hid-get-reply, hid-set / hid-set-reply, hex with the report ID first). While the
guest falls behind, older input reports are dropped instead of queued: each carries the whole
state. Swap A/B and the stick dead zone do not apply to a passed-through controller. GameController
does not say which HID device a controller is: with several DualSenses connected, the first one
found is passed through. Older guest layers without caps hid keep getting the uinput pad.
Several controllers. With Settings → Controller → Additional controllers (on by default,
applies now) further connected controllers are players 2–4, each with a gamepad of its own in
SteamOS, for local co-op. Every player has a virtio-console port: fx.pad for player 1, as above,
and fx.pad2 … fx.pad4, each served by an instance of fx-pad@.service named after its port
(the same fx-progress-agent pad with FX_PAD_PORT, started by udev), so the protocol on a port
is the same for every player, and a pad's rumble plays on the controller that drives it. A player
gets a controller once the guest's service of that port said hello: a guest layer without
fx-pad@.service keeps the one pad. The controller chosen in Settings is player 1; a player keeps
their controller until it disconnects (the others do not move up), and a reconnected controller
takes the first free player. Appears in SteamOS as, swap A/B and dead zone apply to every pad (with
Automatic each pad is the kind of its own controller). Only player 1's
DualSense is passed through, and not while another player's controller is a DualSense too
(GameController does not say which HID device is whose): both get uinput pads then. While the VM
runs, the live input test in Settings → Controller badges each controller with its player
(Player 2). Off: one controller drives the one pad, as before.
--control-fifo test commands: pad on (a pad without a controller, as --input-selftest uses),
pad off, pad test (A + left stick) — each takes a player number (pad on 2; 1 if not given) —
and pad state (the guest's pads, their last rumble level, whether the guest takes HID devices,
connected DualSenses and input reports passed through).
host/launcher/build.sh (and ./build.sh host) builds work/out/FX Steam Launcher.app in
addition to work/out/steamac-vm (host/launcher/bundle.sh): es.fxgam.steamac, libraries
(libkrun, libvirglrenderer, libMoltenVK, libepoxy) in Contents/Frameworks via @rpath, and in
Contents/Resources — gvproxy, the Image kernel, initramfs.cpio.gz, steamac-layer.img,
desync, and Valve's CA for disk creation (licenses are in Resources/licenses); the icon:
host/launcher/AppIcon.icon (an Icon Composer document) is compiled by actool into Assets.car
(Liquid Glass on macOS 26, ready-made renders for macOS 15), with a fallback AppIcon.icns;
ad-hoc signing with hypervisor + disable-library-validation entitlements. The app can be moved to
/Applications.
Launching from Finder (without arguments) uses the kernel, initramfs, and layer from the bundle,
and the SteamOS disk from Settings → Advanced → Disk image. By default:
~/Library/Application Support/es.fxgam.steamac/steamos.img; otherwise, the repository's
work/out/steamos.img (next to the bundle or where it was built). If there is no disk, a
first-launch window offers Create New Disk… (see the next section) or Use Existing Disk…
(the image is used in place and never copied; scripts/build-image.sh also builds one). In this
mode, the guest console and launcher log are written to
~/Library/Logs/es.fxgam.steamac/steamac-vm.log; SIGUSR1 frame dumps go there too. ./run.sh
and work/out/steamac-vm work as before (window settings also apply to them unless overridden by
flags).
If the image is on an external disk, on the first launch from Finder macOS asks “FX Steam Launcher would like to access files on a removable volume” — allow it (the VM waits for the disk to open until you respond). Signing is ad-hoc, so macOS may ask again after the bundle is rebuilt.
One disk image can only be used by one VM at a time: the VM process holds an exclusive lock
(flock) on the writable disk until it exits, and a second launcher (another copy of the app, e.g.
a source build next to /Applications, or steamac-vm) refuses to start with “SteamOS is already
running” instead of mounting the same file systems twice (that corrupts /home and /var). The lock
outlives a killed launcher while the guest is still shutting down. Launchers built before the lock do
not check it.
If /home still has errors at boot (a VM killed while writing, or a disk used by two launchers that
predate the lock), SteamOS repairs it instead of stopping at “Starting SteamOS services…”: the launcher
adds fsck.repair=yes to the kernel command line, so systemd-fsck runs e2fsck answering yes rather than
only the safe preen fixes. Files e2fsck cannot place again end up in /home/lost+found.
The launcher's interface is in English, Russian and Simplified Chinese and follows the macOS language
(System Settings → General → Language & Region, also per app under Applications). Logs, command-line
output, crash reports and problem reports stay in English. The fixed Steam stage texts of the guest
agent ("Checking for Steam updates", …) are shown translated (BootProgress.localizedGuestText); other
guest text (systemd lines, SteamOS itself) is not translated by the launcher.
Strings live in String Catalogs: host/launcher/Localizable.xcstrings (keys are the English text) and
host/launcher/InfoPlist.xcstrings (permission prompts). In code, SwiftUI literals (Text("…"),
Button("…")) are localized automatically; every other UI string is written as
String(localized: "… \(value) …") — one sentence per key, arguments interpolated, never assembled
from fragments — and text for logs or the CLI stays a plain English string. host/launcher/build.sh
compiles with -emit-localized-strings, syncs the extracted keys into the catalog (new keys are
added, removed ones marked stale — commit the catalog with the code), prints untranslated keys and
format-argument mismatches (host/launcher/l10n.py check), and compiles the catalogs into
<lang>.lproj next to work/out/steamac-vm and in the app's Contents/Resources. dist.sh refuses
a release with an incomplete language. Translating outside Xcode: host/launcher/l10n.py export ru todo.json lists what is missing, host/launcher/l10n.py import ru done.json adds
{"key": "text"} or {"key": {"plural": {"one": …, "few": …, "many": …, "other": …}}}; Xcode can
also open the catalogs directly. To check a language without changing the system one:
work/out/steamac-vm -AppleLanguages '(ru)' ….
The app user does not need Docker: the launcher creates the disk itself — through the first-launch
window's Create New Disk… or Settings → Advanced → Create New Disk… (stable/rc
branch, home size, location, password for user steamos; progress, Stop, and Resume). Only stable
and rc are offered: beta/preview/main may use Valve's development signing key, which is not trusted
by the launcher. A saved unsupported branch falls back to stable and is logged. The same
without a window:
work/out/steamac-vm --create-disk ~/steamos.img [--branch stable] [--home-gib 64] [--password PW] [--keep-cache] [--accept-eula]Nothing is downloaded until the user accepts Valve's terms: “End User License Agreement for
SteamOS and Steam Client Back-Up Image” (the same text as on the Steam Frame image page,
https://store.steampowered.com/steamos/download/?ver=steamframe: personal use only, no
redistribution) and the Steam Subscriber Agreement. In the window this is a checkbox with links
to both texts — Create is unavailable without it; on the command line it is --accept-eula,
without which --create-disk prints the links and exits with code 2. Acceptance (date and
agreement URL) is stored in the settings domain and remains valid until the agreement URL in the
code (SteamOSLicense.eulaURL) changes.
External APFS, Mac OS Extended, and exFAT volumes can hold the disk. FAT32/MS-DOS is rejected
before downloading because of its 4 GiB per-file limit (the temporary rootfs alone is 10 GiB).
Read-only volumes and folders you can't write to are also rejected. Unlike APFS, exFAT has no sparse files: it needs space for the
full selected disk size plus the temporary rootfs, even before games are installed. There the download cache also costs far
more than its ~4.4 GB of data: macOS keeps each chunk file's provenance attribute in an extra ._ file, and every file and
folder occupies whole clusters (128 KiB on a 2 TB drive), about 28 GB in all. The launcher checks this space before
reconstructing the rootfs, and on exFAT deletes the chunk cache before allocating the disk when the volume would otherwise
be too full (--keep-cache keeps it and needs room for both).
These expected rejections (including an existing destination file, a busy download cache, a volume that fills up during
creation, and downloads that still fail after desync's retries, which the next attempt resumes from the chunk cache) are
logged, not sent as errors to Sentry. Unexpected creation failures still report: Foundation errors group by domain and code,
a failed desync by its own Error: line (chunk ids left out),
with the original technical diagnostic in the event details rather than pointers/task IDs in the title.
If a download cannot reach Valve securely, the window explains that a VPN, proxy, or network filter may be interfering: try disabling it or using another network. Update checks give the same advice for GitHub. Technical details stay in the launcher log. HTTPS uses macOS's standard certificate validation and TLS settings; the pinned Valve CA below verifies the downloaded bundle, not HTTPS.
rc stays available, but Valve sometimes signs its latest build with the development key
steamos-dev-images instead of the production CA. That build is not accepted: the launcher explains
that it cannot verify this development signature and asks you to choose stable or try again later.
This expected rejection is logged only; all other signature failures still report to Sentry.
https://steamdeck-atomupd.steamos.cloud/meta/holo/steamos/aarch64/vr/<branch>.json→ the latest candidate (update_path,chunks_store_path).- The
.raucb(~2 MB) is downloaded; Security.framework verifies its CMS signature only against Valve's pinned CACN=steamdeck-images(scripts/keys/steamdeck-images.pem, SHA-256 fingerprint embedded in the code); the system trust store is not used. A custom squashfs reader (using zstd from the pinned zstd release,fetch-zstd.sh) extractsmanifest.raucmandrootfs.img.caibx;compatible=steamos-aarch64, the version, and the slot size are checked. - Official desync (
fetch-desync.sh, pinned version and sha256) assembles the 10 GBrootfs.imgfrom Valve's chunk stores (~4.4 GB of data); the chunk cache isdesync/in~/Library/Caches/es.fxgam.steamacfor a disk on the home volume, otherwise in<disk>.cachenext to the disk (an external drive then needs no internal space for it; the folder is removed after success), so Stop/Resume (or rerunning the command after Ctrl+C) continues where it left off; a complete<disk>.rootfs-tmpleft by an attempt that failed later is deleted first (desync never reuses it, and it would take its space twice). One creation per cache at a time: a second one (another window or--create-disk) stops with “another SteamOS disk is being created” (flockoncreation.lockin the cache folder) instead of sharing the chunk cache and temporary files. - A sparse disk file: protective MBR + GPT (primary and backup, CRC32) with exactly the names,
order, types, sizes, and alignment of
scripts/steps/40-disk.sh, and random PARTUUIDs. In one pass,rootfs.imgis hashed (sha256 must match the signed manifest), and nonzero blocks of 16 KiB are written to rootfs-A and rootfs-B; the other partitions are zeros. The disk appears under its final name only after all checks. Existing files are not overwritten; on exFAT, which lacks atomic exclusive rename, the launcher checks the destination while holding its creation lock, then renames it. Do not create or move another file to that same destination with a non-launcher program during creation: that check and rename are not atomic against it. <disk without .img>.provision.imgis placed alongside it — cpio newc containingprovision.env(build, PARTUUIDs, SHA-512 crypt password hash, machine-id) androotfs.caibx(format: “Payload v1” in the provisioning contract). While this file exists, the launcher attaches it read-only (vdc) and addssteamac.provision=1: initramfs formats esp/efi-X/var-X/home, makes the rootfs-B fsid unique, writes partsets/bootconf/bootenv/var, and reportsprovision done— the launcher then removes the payload; subsequent boots do not use it.
Space: ~14 GB on the disk volume during creation (~9 GB afterward), ~6 GB of cache (deleted after
success unless --keep-cache is specified). Checks: work/out/steamac-vm --selftest-provision —
GPT against the Docker-built disk (work/out/steamos.img is opened read-only;
--reference-disk IMG), CMS/squashfs against the work/cache/rootfs cache, cpio, SHA-512 crypt.
The self-test also checks network messages, stable error fingerprints and log-only location
rejections. To exercise an actual TLS failure without sending any events, point
STEAMAC_PROVISION_TEST_TLS_URL=https://localhost:PORT/ at a local server with an untrusted
certificate when running --selftest-provision; it prints the message, title and fingerprint.
Which Steam client SteamOS launches is selected in the launcher: the first-launch window, the
Create SteamOS Disk window, and Settings → Advanced → Steam client (applies on next start,
“Restart VM to apply”); for a single launch, use --steam-client frame|deck|deckbeta. The launcher
passes the selection on every boot in the kernel cmdline as steamac.steam_client=…;
/usr/lib/steamac/steam-client in the layer reads it each time Steam starts. The Steam service
remains the stock SteamOS service (steam.service); the layer adds only a
steam.service.d/50-steamac.conf drop-in to it. Before startup, steam-client copies the stock
/usr/share/deckard/RUNSTEAM.sh to ~/.local/share/Steam/ — unchanged for the Frame client with
a remembered account, while in deck/branch/sign-in modes it removes only the lines containing
the -deckard and -vrgamepadui arguments from the copy. Valve's files are not included in the
layer.
| Option | What it is | Pros and cons |
|---|---|---|
Steam Deck client (deck, by default) |
public ARM64 Steam Deck client, steamdeck_stable branch (the same build as the public steam_client_linuxarm64; not officially announced for ARM) |
normal sign-in with an on-screen QR code; a public client branch rather than an internal beta |
Steam Deck client (beta) (deckbeta) |
steamdeck_publicbeta branch |
like deck, but beta |
Steam Frame client (frame) |
Valve's beta client for Steam Frame (linux_arm64_beta_<hash>, flags -deckard -vrgamepadui) — as in the image |
the client the image ships; an internal beta for a device not yet released; signing in uses sign-in mode (below) |
Changing the option makes Steam download a different client on the next start (up to ~1 GB,
progress in the boot overlay); when switching back to Frame, the Steam bootstrapper switches
automatically based on the -deckard flag. A manual /etc/steamac/steam-client-branch inside
SteamOS (any client branch) still works when frame is selected (or when the launcher passes no
parameter — older versions, a custom --cmdline); the launcher's deck / deckbeta selection
takes precedence over the file.
The host Vulkan driver behind Venus is chosen in Settings → Advanced → Vulkan driver (applies on
next start) or for one launch with --vulkan-driver moltenvk|kosmickrisp. The default is KosmicKrisp
where the Mac and the build have it (macOS 26+, a build made on macOS 26+), MoltenVK otherwise; a
driver chosen in Settings stays. virglrenderer opens the
driver at runtime (no Vulkan loader): the launcher sets VKR_VULKAN_DRIVER to
@rpath/libMoltenVK.dylib or @rpath/libvulkan_kosmickrisp.dylib before the VM starts. The boot
overlay shows the driver (“Venus → KosmicKrisp”); crash reports carry vulkan_driver and the
driver's patch revision.
Before Metal initialises, the launcher checks that its runtime compiler's module cache
(DARWIN_USER_CACHE_DIR/<bundle id>/com.apple.metalfe, including existing hash directories and
.pcm files) is writable. If permissions, ACLs or immutable file flags block it, the launcher logs
the failing path and redirects Metal to ~/Library/Caches/es.fxgam.steamac/metal-compiler.
It does not delete the old cache or change its permissions. This uses Metal's optional cache-path
SPI; if the override is unavailable or the replacement is also unwritable, that is logged and
shader compilation errors remain reportable. work/out/steamac-vm --selftest-metal-cache reproduces
the monolithic_metal.pcm: Operation not permitted failure in a private immutable cache, then
verifies actual Metal source compilation with the replacement (no VM or Sentry events).
| Option | What it is | Pros and cons |
|---|---|---|
KosmicKrisp — Mesa on Metal 4 · macOS 26+ (kosmickrisp, default where available) |
host/kosmickrisp/: Mesa main + open MRs (geometry shaders !44786, transform feedback !44928, tiled images in host-pointer memory !44929, device-local memory type !44221, linear render targets !44782/!44222) + steamac's patches (explicit LINEAR row pitch, LINEAR input attachments, fillModeNonSolid, which DXVK requires, 8-sample requests as 4, single texel alignment for texel buffers and timestamp pools over several Metal counter heaps, which vkd3d-proton requires; R32G32B32 uniform texel buffers, as which vkd3d-proton views 12-byte structured buffers (such a view crashed the VM in The Witcher 3, STEAMAC-2Z); one Metal compiler shared by all guest devices, also on macOS 27 (on its betas 1–4 a compiler per device corrupted the heap when a guest session started, STEAMAC-31); occlusion queries past one 32768-entry visibility buffer and timestamp pools on counter heaps shared by every guest device of the process (Metal allows 32 per process), so query pools of Dota 2 / Counter-Strike 2 no longer fail to create; sparse binding/residency on Metal 4 placement sparse resources and sampler min/max reduction emulated in shaders before Apple10, which give vkd3d-proton Tiled Resources Tier 2 and so D3D12 feature level 12_0) |
faster: Stellar Blade Demo ~29 FPS against ~18 on MoltenVK on an M1 Max (split-screen video: docs/media/stellar-blade-moltenvk-vs-kosmickrisp.mp4); Steam UI, DXVK games and Stellar Blade Demo (D3D12, vkd3d-proton) run, its first run spends ~28 min compiling shaders on an M1 Max. macOS 26+ only; built only when the build host runs macOS 26+, otherwise MoltenVK is used. Known gaps (host repros): transform feedback with strip geometry shaders and its overflow counter (draft MR); within one render pass, depth writes to unmapped tiles stay in tile memory and later draws of the pass test against them (Tiled Resources Tier 2 allows this cache; vkd3d-proton's test_sparse_depth_stencil_rendering expects them dropped); no sparse 3D textures (Metal's 3D tiles are not Vulkan's standard 3D blocks), hence no Tiled Resources Tier 3 |
MoltenVK — Metal 3 · macOS 15+ (moltenvk) |
host/moltenvk/: the UTM fork + steamac's patches |
every supported Mac; the default on macOS 15 and in builds without KosmicKrisp (source builds on macOS 15; the release DMG includes KosmicKrisp since 1.7) |
Switching changes the Venus driver identity (pipeline cache UUID), so Steam and games rebuild their
shader caches. A pipeline the host driver cannot build is a placeholder in virglrenderer: its draws
and dispatches are dropped, the driver never sees VK_NULL_HANDLE.
Every host-visible guest allocation is a POSIX shm whose file descriptors stay open in the VM
process (about four per mapped allocation), and a Finder launch starts with a soft limit of 256
descriptors: past a few dozen such allocations games lost their GPU context (STEAMAC-G, Left 4 Dead
2). The VM process raises its soft limit to kern.maxfilesperproc at start (logged as “file
descriptors: soft limit 256 → N”); virglrenderer logs the errno of a failed shm or descriptor
operation (“… failed: Too many open files (RLIMIT_NOFILE 256)”).
Checks without a VM: host/kosmickrisp/build.sh runs host/moltenvk/probe and the repros
(REPRO_DRIVER=kosmickrisp host/moltenvk/repro/run.sh <dylib>, through the Khronos loader, test
only) on the staged driver; host/virglrenderer/build.sh runs venus_check with each installed driver.
The Steam Frame client's sign-in screen is designed for a headset: “Tap to confirm” pairs with a
phone over Bluetooth LE, and “Scan QR code” opens a VR window — neither works in the VM (only the
password remains). Therefore, with the Steam Frame client, while config/loginusers.vdf has no
remembered account (new disk, signing out, signing in without “Remember me”), steam-client
starts Steam without -deckard/-vrgamepadui: the bootstrapper switches itself to the public
ARM64 Steam Deck client (steamdeck_stable), and sign-in shows an on-screen QR code (Steam Mobile
App → Steam Guard → scan) alongside the password form. After signing in with “Remember me”, Steam
restarts once and returns to the Steam Frame client (each client switch downloads up to ~1 GB;
progress is visible in the boot overlay). Without access to client-update.steamstatic.com,
sign-in mode is not enabled.
RecvMsgClientLogOnResponse() : 'Try another CM' lines in connection_log.txt on the sign-in
screen are normal: the CM server drops a connection without an account sign-in after ~60 s, and
the client reconnects.
host/launcher/dist.sh turns the built work/out/FX Steam Launcher.app into the downloadable
work/out/dist/FX-Steam-Launcher-<version>.dmg (the app + a link to /Applications). A copy of the
bundle without the SteamacBuildOut key (the path to this build tree) is re-signed with Developer ID,
hardened runtime, and a secure timestamp: first all nested Mach-O files (Frameworks/*.dylib, helper
programs in Resources), then the bundle with steamac-vm.entitlements (hypervisor,
disable-library-validation, and audio-input — without the latter, hardened runtime silently blocks the
microphone). The app is notarized and stapled, then the DMG is signed, notarized, and stapled —
Gatekeeper allows it through even offline (the usual “downloaded from the Internet” prompt still
appears on first launch).
host/launcher/build.sh # fresh bundle
host/launcher/dist.sh # signing, notarization, DMGA notarytool profile must be stored in Keychain once:
xcrun notarytool store-credentials steamac-notary --apple-id <Apple ID> --team-id V25VKGTW55 --password <app-specific password>. Variables: STEAMAC_SIGN_IDENTITY (by default, the only
“Developer ID Application” in Keychain), NOTARY_PROFILE (by default, steamac-notary);
--no-notarize — signing only, for local checks (Gatekeeper will reject a downloaded copy).
Licenses: bundle.sh puts all third-party license texts for bundled components and the
THIRD-PARTY-NOTICES.txt index (component, version, SPDX, location in the bundle, sources; generated
by host/launcher/licenses.sh in work/out/licenses, including libkrun crates and Go modules from
gvproxy/desync) in Contents/Resources/licenses, plus the project's LICENSE and NOTICE in
licenses/steamac/. dist.sh calls scripts/gpl-sources.sh and places
work/out/dist/FX-Steam-Launcher-<version>-gpl-sources.tar alongside the DMG — the complete source
code of GPL components (the kernel with patches and configuration, busybox from the Debian snapshot,
dosfstools, e2fsprogs, btrfs-progs, build scripts, README.txt); attach it to the GitHub release
alongside the DMG. For an older release: scripts/gpl-sources.sh v1.2.
The launcher sends crash reports and a few errors to the developers' own Sentry server
(sentry.fxgam.es, sentry-cocoa 9.30.0 SDK via SwiftPM). Enabled by default; disable it with the
Send crash reports and diagnostics checkbox in Settings → General, in the first-launch window,
or in the Create SteamOS Disk window (the “What is sent” link displays the list below). When disabled,
the SDK does not start at all and makes no network connections (reports already saved on disk remain
there and are not sent). For one launch: --no-crash-reports or STEAMAC_SENTRY=0.
What is sent:
- Crashes of the supervisor and VM processes (signal/abort, unhandled exceptions): cause, thread stacks, list of loaded libraries. This includes Metal/MoltenVK asserts, libkrun/virglrenderer aborts, and Rust panics propagated through the libkrun C API. A VM process crash report is sent on the next VM launch;
- A few errors (no more than once per fingerprint per process, subject to a global limit; the same
fingerprint is not sent again for at least a day, or 30 days for shader compilation errors): the
guest GPU context becomes fatal/device lost (vkr “fatal decoder state”, “device lost”), MoltenVK
pipeline compilation errors (
[mvk-error] … compile failed; if MoltenVK printed MSL source ([mvk-msl] …: the first 40 lines and lines around the error), it is sent in extramsl_source, not in breadcrumbs; the vkr line “pipeline … creation failed on host” is a breadcrumb only), a Rust panic in libkrun (thread … panicked at), failure of initial disk provisioning (provision failed), failure to create a disk, unexpected VM exit (nonzero exit code or signal when shutdown was not requested by the user), and the idle indicator's “SteamOS is not responding”. For a VM process crash, the supervisor's report adds macOS's own crash report of it (~/Library/Logs/DiagnosticReports/steamac-vm-*.ips, matched by pid): the crash site in the title and thecrash_sitetag (reports group by it), and in extramacos_crash_reportthe exception, the Application Specific Information lines (libmalloc's message on a heap corruption trap) and the crashed thread's first 16 frames (library and symbol names); - VM exit due to SIGTERM/SIGINT/SIGHUP (logging out,
kill, ^C before handlers are installed) is not a crash: only a log line, with no event and no Report a Problem window. Neither is SIGPIPE (both processes ignore it: a closed output pipe — the terminal, or the supervisor's stderr channel after the supervisor was killed — only fails the write; the VM process then logs to~/Library/Logs/es.fxgam.steamac/steamac-vm.logor drops the lines, and lets the guest finish shutting down; STEAMAC-10). SIGKILL generates a warning-levelvm-killedevent, “VM process killed (SIGKILL — memory pressure or force quit)”: whether the kernel killed it for memory (jetsam,NOTE_EXIT_DETAILfrom the supervisor's kqueue), VM memory and the VM process footprint at exit/peak, hostvm_statnumbers (free/compressed/wired, swap),kern.memorystatus_level, and the history of memory pressure levels since launch (transitions are also written to the log:memory pressure: …). Force Quit from the app menu (or a second shutdown request) is a requested exit, not a report: the VM process exits with status 3, which the supervisor recognizes without theuser-exitmarker file (with the Mac's disk full the marker could not be written, STEAMAC-15); - Every event includes the last ~200 lines of launcher stderr as breadcrumbs (
[steamac-vm],[mvk-*], libkrun/virglrenderer warnings, startup stages) and tags: version (es.fxgam.steamac@<CFBundleShortVersionString>+<git sha>), environment, andbuild_kindtag:release— only adist.shbuild with notarization (Info.plistSteamacDistTeamID, and the running code's signature is Developer ID for that team, checked withSecCodeCheckValidity),source-build— any other.app(bundle.sh, ad-hoc/re-signed copies,dist.sh --no-notarize),development—work/out/steamac-vmoutside the bundle; macOS, Mac model, GPU, vCPU/RAM, display mode, build UUIDs of libkrun / virglrenderer / MoltenVK,MVK_PATCH_REVISION, kernel version, SteamOS BUILD_ID and layer release (from initramfs lines), theappidof the game in guest focus (while it is in focus), and a random installation ID.
Not sent: guest console (hvc0), user and computer names (/Users/<name> → ~, name and hostname are
redacted), IP address (sendDefaultPii=false, the server does not expose IP addresses), locale/time
zone, Steam account, game titles (App ID only), or files. The supervisor routes its stderr through a
channel (everything still appears in the terminal/log), so it also sees the last lines from a crashed
VM process.
Testing: --sentry-test-event (a test event from the supervisor and VM process; the VM process also
sends a pending crash report and exits), --sentry-test-crash abort|segv|metal|panic|kill|term|shader
(the VM process crashes: abort() inside a C call, EXC_BAD_ACCESS in memset, Metal assert, Rust
panic in krun_start_enter due to an overly long kernel command line — panic requires --kernel
and, if necessary, --initrd; kill/term — the VM process kills itself with SIGKILL (vm-killed
report) or SIGTERM (no report or window); shader — prints an example MoltenVK compilation error
with [mvk-msl] and a vkr line, then exits). These events have environment=development and the
test=true tag. STEAMAC_SENTRY_DEBUG=1 prints the SDK debug log (server responses).
Symbols: build.sh puts dSYMs for steamac-vm and bundled libraries in work/out/dSYMs (libkrun,
virglrenderer, and MoltenVK are built without DWARF — they contain only symbol tables). dist.sh
uploads them and every Mach-O the app ships (executable, Frameworks, helpers in Resources; each must pass
sentry-cli debug-files check) with sentry-cli --url https://sentry.fxgam.es debug-files upload --wait,
and keeps the dSYMs as work/out/dist/FX-Steam-Launcher-<version>-dSYMs. A notarized build requires
SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT (checked before signing; --no-sentry-upload opts
out): 1.9 shipped without an upload, and crashes in steamac-vm and libkrun stayed unsymbolicated.
When the app starts (once per launch, not on every VM reboot, and no more than once every 6 hours —
even across launches), the launcher requests the latest release in the background without delaying
startup from https://api.github.com/repos/fxgl/steamac/releases/latest (unauthenticated, 10 s
timeout; drafts and prereleases are excluded) and compares its vX.Y[.Z] tag with its own version
(CFBundleShortVersionString, numerically: 1.3.10 > 1.3.9). If a new version is available, a window
appears next to the VM window (not over it if there is room on screen; without taking focus — the
keyboard and captured mouse remain with the VM): “FX Steam Launcher X.Y is available — you have …”,
with the release description and buttons Download (opens the release's .dmg in a browser, or
the release page otherwise), Skip This Version (this version is no longer offered at startup),
and Remind Me Later (offers it again at the next check). The app menu's Check for Updates…
item (while the available version has not been skipped, “Update Available: X.Y…” with a New badge,
opens this window) checks immediately — without the 6-hour limit and including skipped versions —
and reports “You're up to date” or an error; network and HTTP errors at startup are only logged
(update: …). Disable it with the Check for updates at startup checkbox in Settings → General
(effective immediately). The dev launcher work/out/steamac-vm does not check at startup (the menu
works); source builds (bundle.sh) and releases do.
Privacy: the request goes only to api.github.com (GitHub) and includes only User-Agent: FXSteamLauncher/<version> (plus standard HTTP headers and the previous response's ETag, so GitHub can
reply “not modified”); no Mac or user identifiers, cookies, or Sentry. The check time, ETag, response,
and skipped version are stored in settings (updateLastCheck, updateETag, updateCachedBody,
updateCachedURL, updateSkippedVersion).
Tests: STEAMAC_UPDATE_URL overrides the URL (release JSON or a /releases array, http(s):// or
file://; it also enables the startup check for the dev launcher), STEAMAC_FAKE_VERSION overrides
the launcher version; FIFO --control-fifo: update check|startup|state, update press download|skip|later|ok|releases, update dump PNG (the window and -with-vm.png — together with the
VM window, as shown on screen).
If something does not work, send a report to the developers directly from the launcher: Help → Report a Problem… (or from the app menu), the Report a Problem… button in Settings → General, the Report… link on the “SteamOS is not responding…” card, and the Report… button in the “FX Steam Launcher stopped unexpectedly” window that appears after the VM exits unexpectedly (crash, error). The dialog has: email (required, remembered on this Mac so the developers can reply), a description (what you did, what you expected, what happened), and checkboxes for attachments:
- Include launcher logs (enabled) — messages from the launcher, libkrun, virglrenderer, and MoltenVK
for this session (the last ~2 MB, both processes) and
perf:/stall:lines; paths/Users/<name>→~, the user and computer names, email addresses, and IP addresses are redacted; - Include SteamOS logs (system journal, Steam/Proton logs) (enabled) — the guest console (hvc0) for
the session and a
steamos-logs.tar.gzarchive collected by the guest agent: the systemd journal for the current boot (journalctl -b, the last 5000 lines, plus the user journal),coredumpctl list/info,dmesg,systemctl --failed,os-release,layer-release,/proc/cmdline,df/free, tails of Steam client logs (console_log,stderr,bootstrap_log,compat_log,connection_log,webhelper,cef_log,shader_log,steamui_*, Steam Input'scontroller,controller_uiandcloud_log) and Proton logs (~/steam-*.logfromPROTON_LOG=1,versionandconfig_infoof prefixes incompatdata), FEX tool build ID / Steam manifest / configuration, an opt-in FEX log, memory limits, and the last 512 KiB of American Truck Simulator'sgame.log.txt. For an ATS emulator crash, set its Steam launch options toFEX_SILENTLOG=0 FEX_OUTPUTLOG=/home/steamos/fex-amtrucks.log %command%, reproduce, and report; the collector includes the last 512 KiB of that log. Remove the launch options afterwards. FEX re-raises a crash of the emulated game from its JIT code, so the core's stack shows only an anonymous AArch64 address. To record where the x86_64 code faulted, set the game's launch options toLD_PRELOAD=/usr/lib/steamac/x86_64/fault-report.so:$LD_PRELOAD %command%, reproduce, and report. On SIGSEGV/SIGBUS/SIGILL/SIGFPE/SIGABRT the library writes the x86_64 RIP, the fault address, the registers, a backtrace with the module and offset of every frame, and the memory map to~/.local/state/steamac/fault-report.txt. The file stays under 256 KiB and the report includes it asfex/fault-report.txt. Then the game's own handler or the default action runs, so the crash and its core dump are unchanged. Without the launch option the library is never loaded. Completed crash metadata is exported by a root hook forsteamosonly (root-owned, mode 0640); raw cores remain private and are never attached.coredump-pending.txtidentifies dumps still running: reports do not wait for them; send another report once they finish. Namespace-aware stack extraction resolves pressure-vessel libraries where symbols are available (FEX JIT code may still be unsymbolized). Core processing limits are not lowered: oversized cores would lose their backtraces, andStorage=nonestill writes a full temporary core. Steam IDs ([U:1:…], 7656119…, account IDs as inuserdata/<n>), Steam account/persona names and email addresses are replaced with placeholders before packaging;collect-notes.txtlists what could be read. Over SSH, the same scrubbed archive is available with/usr/lib/steamac/fx-progress-agent collect > /tmp/steamos-logs.tar.gz; - Include a screenshot of the VM window (disabled by default: the image may show your Steam account name and friends).
system-info.txt (app and macOS versions, Mac model, GPU, build UUIDs of libkrun/virglrenderer/MoltenVK,
kernel, SteamOS BUILD_ID, layer release, disk sizes, VM settings, collection notes) and settings.txt
(saved settings and command-line overrides; the SSH password is stored in Keychain and never included
in the report, nor are game titles) are always attached. Show What Will Be Sent assembles the report
and opens its folder in Finder — exactly its contents are sent.
The report goes to Sentry (sentry.fxgam.es, the same project) as User Feedback: email, description, a
link to the latest error event in this session (if any), and files as attachments, in one envelope sent
directly to the envelope endpoint so the server's response is visible. It also works with crash reports
disabled (an explicit user action: the SDK and crash handler do not start in that case). Attachments are
limited to 20 MB (older portions of logs are trimmed first, then the screenshot and SteamOS archive are
dropped); on an HTTP 413 response, the limit is halved and the report is sent again. After sending, a
short Report ID is shown (the first 8 characters of the event ID). If sending fails, the folder remains
at ~/Library/Logs/es.fxgam.steamac/reports/<date>-<ID>/ (report.json with the email and description
plus files); the dialog offers Retry and Reveal in Finder, and the folder can be emailed.
How it works: the supervisor always pipes stderr from both processes and writes it to
/tmp/steamac-<pid>/launcher.log (timestamped, rotated at 4 MB); the VM process writes the hvc0
console to console.log alongside it; the directory is removed when the launcher exits. Guest logs
are requested over the same fx.progress port in the reverse direction: the launcher writes
collect-logs <id>, the agent (running as the session user, with access only to what that user can read)
responds with logs-begin <id> <size>, lines of logs <id> <base64>, and
logs-end <id> <sha256> (or logs-failed <id> <reason>); the launcher assembles the archive and
verifies its size and SHA-256. If there is no response within 20 s or the agent is not running (no
heartbeat), the report is sent without guest logs, with a note in system-info.txt.
Testing: --control-fifo PATH, commands report open, report fill EMAIL TEXT… (event tagged
test=true), report include launcher|steamos|screenshot on|off, report preview, report send,
report retry, report dsn DSN|default, report dump PNG, report close; STEAMAC_REPORT_DSN
overrides the DSN (failure path), STEAMAC_SENTRY_DEBUG=1 prints the server's response. Post-crash
window: --sentry-test-crash abort with STEAMAC_REPORT_DUMP=<dir> (PNGs of the window and dialog,
then it closes on its own; STEAMAC_REPORT_TEST_SEND=1 also sends a test report).
| Directory | Contents |
|---|---|
host/moltenvk/ |
MoltenVK utmapp geometry-shaders @05604465 + patches: depth_clip_enable, YCbCr arrays, null descriptors, geometry shader emulation for zink/DXVK (vertex stride, instancing, adjacency, fans, SCALED formats, gl_in), transform feedback (DXVK stream output) and its queries (SO statistics), query result availability on copy (DXVK occlusion queries via Venus), atomics on vector components at buffer addresses (BDA, vkd3d-proton), texel buffers with offsets at any texel (vkd3d-proton), writes to small push-descriptor buffers with robustness2, variable-count descriptor arrays as runtime arrays (Metal kept 32 MB per vkd3d-proton heap array and program), allocation of auxiliary buffers, deferred release of Metal resources, patch hash in the pipeline cache UUID, VK_NULL_HANDLE descriptor sets in binds, fragment outputs converted to their color attachment's numeric type, zeros for vertex shader inputs without a vertex attribute; tests in repro/ run under Metal validation (also on KosmicKrisp: REPRO_DRIVER=kosmickrisp); bench/run.sh <libdir>… compares performance of changes between builds; bench/shaders.sh <dump or pack> measures a game's shader compilation (SPIR-V → MSL, MSL → Metal library, pipeline states; cold/warm, threads) from a MoltenVK shader dump or a 10% sample made with bench/pack.py |
host/kosmickrisp/ |
KosmicKrisp (Mesa main @ce576c29) + open Mesa MRs and steamac patches (see “Vulkan driver”), built without LLVM at runtime (-Dllvm=disabled, mesa_clc from a first build), -Db_ndebug=true; macOS 26+ only |
host/libepoxy/ |
libepoxy 1.5.10, upstream macOS Meson options, built for macOS 15.0 instead of copying a Homebrew bottle |
host/virglrenderer/ |
virglrenderer UTM macos-next + merge with upstream main (venus-protocol 1.1.3) + LINEAR modifier, shm import as host memory, stubs for failed pipelines (draws dropped in virglrenderer), recreation of rejected cache, deferred shm unmap, thread QoS, Vulkan driver opened at runtime (VKR_VULKAN_DRIVER) |
host/libkrun/ |
libkrun v1.19.6 + patches: VIRTIO_GPU_F_BLOB_ALIGNMENT (16K), SME mask for M4, 2D resources without virgl, SET_SCANOUT_BLOB, SHM blob mapping, Venus fence signaling, virglrenderer logs, krun_display_resize (resolution changes on the fly), vCPU/GPU thread QoS |
host/launcher/ |
steamac-vm (Swift/AppKit): Metal window (optional MetalFX super resolution), “FX STEAM LAUNCHER” overlay with boot/shutdown progress, guest resolution = window size (× the screen's backing scale with Retina resolution) at constant DPI (EDID from the physical screen size), keyboard/mouse/tablet, the guest's Xbox 360 / DualSense / DualShock 4 pad from GameController.framework with rumble or a DualSense passed through as raw HID (fx.pad), network via gvproxy, VM restart on guest reboot, --perf-stats |
guest/kernel/ |
Linux 7.2.9, everything built in, 4K pages, 16K blob-node alignment, Apple TSO for FEX; uhid, hidraw and hid-playstation (with the LED classes it needs) for the passed-through DualSense |
guest/mesa/ |
Venus ICD for aarch64 (Proton, gamescope, zink) and x86_64/i386 (FEX graphics provider); x86_64 fault reporter for emulated games (/usr/lib/steamac/x86_64/fault-report.so) |
guest/initramfs/ |
boot stage = “bootloader”: A/B slot selection with attempt counter, partsets, overlays for /etc and /usr; initial provisioning of the launcher-created disk (steamac.provision=1: static mkfs.fat, mke2fs, btrfstune in initramfs); steamac.ssh=0 — no SSH server; launcher config payload (steamac.config=1) — new steamos password; steamac.tz= — the Mac's time zone in /etc/localtime; untouched procfs at /run/steamac/proc for Flatpak sandboxes |
guest/layer/ |
VM layer over /usr (read-only erofs): file-based splctl, safe post-install for RAUC, VARIANT_ID=steamdeck, gamescope session on DRM, Desktop Mode (Plasma nested in gamescope), masks for Frame hardware services, fx-progress-agent progress agent (Rust, guest/progress-agent/, fx.progress virtio-console port) and its root services (fx.clock, fx.sleep, the uinput or uhid gamepad on fx.pad), short shutdown timeouts, QR-code Steam sign-in mode (Steam Deck client, while there is no remembered account), optional Steam client branch (/etc/steamac/steam-client-branch), Steam Shader Pre-Caching disabled by default (steam-shader-defaults) |
scripts/ |
build of work/out/steamos.img: GPT with Valve's partition layout (esp, efi-A/B, rootfs-A/B, var-A/B, home); scripts/test/provision-test-disk.sh — dev test of provisioning against a disk from Docker; scripts/test/vkd3d-tiled.sh — dev end-to-end check of D3D12 tiled resources and the feature level (vkd3d-proton tests of Proton 11.0, d3d12-caps, vk-minmax) in a throwaway VM |
MoltenVK also fixes fragment helpers that discard from an otherwise empty SPIR-V block
(STEAMAC-1Q). host/moltenvk/repro/msl_helpers.c checks both direct and nested helpers:
discarded pixels stay clear and do not write storage buffers; surviving pixels render normally.
It renames user-defined log10(float) helpers to avoid Metal's builtin overload (STEAMAC-1R);
the same repro reads back the compute helper's results, not just successful pipeline creation.
vkCmdBindDescriptorSets with a VK_NULL_HANDLE among the sets (legal with graphics pipeline
libraries) no longer crashes the VM when the command buffer is submitted (Counter-Strike 2 binds
five sets with the fourth null, STEAMAC-25): as on RADV, a null set binds nothing and takes no
dynamic offsets (repro/invalid_usage.c).
A fragment output of another numeric type than its color attachment (a Left 4 Dead 2 pipeline from
DXVK writes an unsigned output to an R32_SINT attachment, STEAMAC-2C; Vulkan leaves the values
undefined) failed in Metal, and the pipeline's draws were skipped; it is now declared with the
attachment's type and its bits are written (repro/frag_output.c).
A vertex shader input without a vertex attribute in its pipeline (an SCS Truck Simulator shader reads
in_var_NORMAL at location 1, which the pipeline does not describe, STEAMAC-2Y; Vulkan leaves the
values undefined) failed in Metal ("missing from the vertex descriptor"), and the pipeline's draws were
skipped; such inputs now read zeros (repro/vertex_input.c).
The SteamOS root filesystem is not modified: all changes come from initramfs and the layer. Thus official Valve updates (RAUC + atomupd) install into the other slot and roll back normally — verified with the 20260922 → 20260928 update and rollback.
Verified:
- SteamOS boots to
graphical.target, autologin, gamescope session; networking (DHCP via gvproxy, downloading a 583 MB Steam client update), SSH; - Venus in the guest:
Virtio-GPU Venus (Apple M4 Max), Vulkan 1.4; render test (compute + clear/copy) and display output through KMS match the reference pixel for pixel; - all required DXVK features from Proton 11 / DXVK 3.x are visible in the guest (geometryShader, shaderCullDistance, depthClipEnable, robustness2 + nullDescriptor, maintenance5/6, …);
- keyboard, tablet, mouse, and the virtual pad are visible in SteamOS; as a DualSense, Steam's SDL
maps it as
PS5 Controller(type PS5) and shows PlayStation glyphs; the pad appears, disappears and changes kind while the VM runs; rumble from SDL and from Steam's virtual pad reaches the launcher (rumble 49152 16384for 1.5 s, then 0); playing it on a physical controller is not verified yet; - DualSense passthrough, guest side: a uhid DualSense with the real USB report descriptor, fed by a
stand-in for the launcher, binds
hid-playstation(gamepad, touchpad, motion sensors, headset jack, RGB and player LEDs); touch position and touchpad click reach the touchpad device, the mute button toggles the mute LED through an output report back to the Mac side, and Steam opens/dev/hidraw*with its HIDAPI driver (Controller using HIDAPI driver, vid=0x054c, pid=0x0ce6). The Mac side (IOHIDManager, a physical DualSense over USB or Bluetooth) is not verified yet; - Desktop Mode: Switch to Desktop, the Plasma desktop with mouse input (also letterboxed), Return to Gaming Mode, booting straight into the desktop; Flatpak sandboxes start;
- A→B update via official OTA and rollback;
- GL via zink (glamor in Xwayland, glxgears ~60 FPS), Steam UI (gamepad UI, CEF with GPU) renders in the VM window;
- Steam sign-in, installation of Proton 11.0-2 (ARM64) and FEX, running a DX11 game (Death's Door) through DXVK → Venus → MoltenVK;
- guest resolution follows the window size at constant DPI; fast shutdown (2–4 s);
- Heroes of Might and Magic: Olden Era (Unity, DX11) — 7 minutes without errors (offline test).
Stutters on the first pass are Metal compilation (~50–100 ms per new pipeline); subsequent passes take ~1 ms. On reboot after an abrupt shutdown, initramfs checks and repairs FAT on esp/efi.
- DirectX 12 (vkd3d-proton): feature level 12_0 on KosmicKrisp (tiled resources tier 2: sparse binding and residency on Metal 4 placement sparse resources), 11_0 on MoltenVK; SM 6.0 on both (no SM 6.2+ on Apple GPUs). Within one render pass, depth written to unbound tiles of a sparse depth attachment stays in Apple's tile memory. Stellar Blade Demo (UE4) runs at 1280×800, 60 FPS on an M4 Max; the first run spends minutes compiling shaders. The x86 emulator in Proton ARM64 (FEX) stopped it once after 20 minutes (DEP check in its protected .exe).
- Audio: virtio-snd → CoreAudio (default device or one selected in settings), latency ≈65 ms on built-in
speakers. At boot, playback exposes up to 12 PCM channels from the output device's configured speaker
layout, including height speakers (e.g. 5.1.2 or 7.1.4) when CoreAudio identifies them explicitly. An
unclassified/discrete layout, or a device with more than 32 channels, gets mono/stereo; 8 channels alone
do not imply 5.1.2, and there is no Dolby Atmos encoding. For a multichannel layout,
audio-layoutreads the VirtIO ALSA playback map before WirePlumber starts and replaces SteamOS's stereo policy with the matching Pro Audio output (timer scheduling, separate playback/capture groups: the host devices have independent clocks); a stereo host keeps the stock SteamOS profile. Playback follows a new default or unplugged output at once and rechecks the layout once a second, routing to the physical speakers and downmixing missing ones with headroom. Guest channel maps stay fixed for a boot (virtio-snd/ALSA cache them): restart the VM to expose more channels or a new layout to games.STEAMAC_SND_DUMPrecords guest PCM before speaker mapping. 5.1.2 was tested on hardware, 7.1.4 only by automated mapping tests; the microphone stays mono/stereo and has not been tested. - Anti-cheat systems that block VMs will not work.
logicOpis unavailable (the private Metal API in the MoltenVK fork does not build); zink emits a warning.
The project code is licensed under Apache License 2.0 (LICENSE), © 2026 FX GAMES FZ LLC. Exceptions
are listed in NOTICE: Linux kernel patches and configuration — GPL-2.0-only, virglrenderer and Mesa
patches — MIT (as in those projects), five gamescope session files derived from Valve's
deckard-steamvr-session package — MIT © Valve Corporation; the Valve CA certificate and screenshots
in docs/media are not covered by the project license. License texts are in LICENSES/.
SteamOS is not part of the project and is not distributed with it: the app downloads a signed image from Valve's servers after the user accepts Valve's license (see “Creating the SteamOS disk without Docker”).
Steam, the Steam logo, SteamOS, Steam Deck, and Steam Frame are trademarks and/or registered trademarks of Valve Corporation in the United States and/or other countries. The project is not affiliated with or endorsed by Valve Corporation.