Profile
Back to NewsBack
GitHub Trending 37 min
Reader Mode
fxgl/steamac: Valve's official ARM64 SteamOS in a lightweight VM on Apple Silicon: libkrun + Venus + MoltenVK, controllers, CoreAudio sound

fxgl/steamac: Valve's official ARM64 SteamOS in a lightweight VM on Apple Silicon: libkrun + Venus + MoltenVK, controllers, CoreAudio sound

13 hours ago

steamac — Valve's official ARM64 SteamOS (the Steam Frame image) in a VM on Apple Silicon

English · Русский · 简体中文

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”).

Requirements

  • Apple Silicon Mac, macOS 15+, ~150 GB of free space (the disk image is sparse).
  • Xcode 26+ (full installation: its actool builds the app icon from an Icon Composer document), Homebrew, rustup.
  • KosmicKrisp (optional, alternative Vulkan driver): built only on macOS 26+; host/kosmickrisp/build.sh
installs 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 use MACOSX_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).

Building and running

./build.sh          # Vulkan stack: MoltenVK, virglrenderer, libkrun, launcher, kernel, Mesa, disk
./build.sh dxmt     # DXMT + wmtr host executor and guest payload; then rebuild layer and launcher (below)
./run.sh            # a window with SteamOS

Build 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//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.

Steam's idle screensaver (Settings → Display → Screensaver in Game Mode) is off in the VM. After a few minutes without input Steam puts its Screensaver_uid window in front of a running game, gamescope moves focus to it and the next clicks land in Steam until Escape. The launcher's mouse, tablet and keyboard reset Steam's timer, but a game left alone on a long loading screen or played in VR (headset input never reaches Steam) is idle to Steam; macOS keeps its own screensaver and display sleep. Before Steam starts, /usr/lib/steamac/steam-idle-screensaver (ExecStartPre of steam.service) writes "IdleScreensaverACSeconds" "0" and "IdleScreensaverBatterySeconds" "0" (InstallConfigStore/Software/Valve/Steam/System in config.vdf; 0 = never) where they are missing, once per Steam installation (marker config/steamac-idle-screensaver), so a screensaver timeout chosen later in Steam's settings stays.

Per-title environment that a game needs from the driver it runs on is a default, not code: /usr/lib/steamac/steam-title-defaults (also an ExecStartPre of steam.service) puts the required VAR=value words in front of a title's Steam launch options (%command%). It ships one entry: Dragon's Dogma 2 (2054970) asks for D3D feature level 12_1, which vkd3d-proton derives from ROVs and conservative rasterization — on Metal neither exists (conservative rasterization has no Metal API; ROVs would need Metal's raster order groups mapped to VK_EXT_fragment_shader_interlock), and the title only checks the level, so it is reported as VKD3D_FEATURE_LEVEL=12_1 VKD3D_SHADER_MODEL=6_6, together with VKD3D_DISABLE_EXTENSIONS= (empty). KosmicKrisp's 64-bit atomics cover unsigned min/max only, so steam.service keeps VK_KHR_shader_atomic_int64 and VK_EXT_shader_image_atomic_int64 from every D3D12 title by default (Environment=VKD3D_DISABLE_EXTENSIONS=… in its drop-in; a title whose shaders use another 64-bit atomic would silently lose those pipelines, and vkd3d-proton reports shader model 6.6 from them), and a title that fits gets them back in its own launch options. The default is Steam's inherited environment, not a gfx.txt line: steam-launch-wrapper sets every variable of $XDG_RUNTIME_DIR/steam/env/gfx.txt and then ~/.config/steam/env/gfx.txt over the game's environment, launch options included, so a VKD3D_DISABLE_EXTENSIONS written there wins for every title. An entry applies only where the Venus device advertises the features it needs (DD2's: the 64-bit atomics, Apple8/M2 and later) and is taken back, restoring the previous launch options, when a later Steam start finds them gone (driver switched to MoltenVK, disk moved to an M1); a failed probe changes nothing. An existing value is only prefixed, never replaced, a value without %command% is left alone, and an edit made in Steam after ours wins (marker ~/.config/steamac/title-defaults.json, which also keeps the previous value). Profile env words override the built-in entry; without a profile override, DD2 keeps the feature-gated default.

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= (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//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 ), 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, and the Mac cursor is hidden over the picture so only gamescope's cursor shows (it reappears over the black bars, the clickable progress pill and "Report…" link, menus and other windows, while booting, paused or asleep, and outside the window). 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 x), 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 [email protected], 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 … [email protected], 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 prints the user, password, and status.

LAN networking and Steam Remote Play

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 --broadcast --expect-host . 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.

Settings window

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-*.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); Use the Mac's keyboard layouts in Desktop Mode (on by default, see “Desktop Mode”) | 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_.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) | | Games | Share game statistics (off until you agree, see “Game statistics”); known games and games with recorded sessions: per-game profiles (graphics, Vulkan driver, launch options; see below), sessions, play time, last session's FPS / 1% low / game memory / Vulkan driver, your optional rating (Runs well / Playable with issues / Starts, but unplayable / Does not start) and a per-game Share switch | profiles may require restarting Steam or the VM (see below) | | 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”), Allow DXMT (D3D11 on the Mac's Metal), experimental (--wmtr-lib, --no-wmtr, 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= 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.

Per-game profiles

Settings → Games stores a profile per Steam app id, separately from mouse auto-capture: Graphics — Default (= Vulkan) / Vulkan / DXMT; Vulkan driver — Default (the global driver) / KosmicKrisp / MoltenVK (only available drivers are offered); Launch options — space-separated NAME=value words placed before %command%, not arbitrary shell commands. The whole string is limited to 256 characters. Names use the prefixes VKD3D_, DXVK_, PROTON_, WINE_, MESA_, RADV_, VK_, DXMT_, MTL_, STEAMAC_, followed by zero or more [A-Z0-9_] characters; values match [A-Za-z0-9_.,:;+-]* (empty is allowed). Assignments are sorted by name; the last duplicate name wins. Invalid input stays visible with an error and is not saved or sent.

Recommended: … — Apply fetches a profile from the statistics service using this Mac's My Mac limits (GPU cores, RAM and performance cores no greater than this Mac's). Configurations are ranked by (runs well + 0.5 × playable with issues) / all ratings, then higher launch success and fewer early exits; at least two distinct installs are required (details in “Game statistics”). Recommendations are never automatic: only Apply changes the profile.

DXMT requires Settings → Advanced → Allow DXMT (D3D11 on the Mac's Metal) and a VM boot with that permission. While it is off, a DXMT profile is shown as not in effect and offers Allow DXMT… with the existing security warning; it cannot map a game to Proton 10.0 on a Vulkan-only boot. The Vulkan driver is selected per VM boot, not per process. When a game starts or gains focus with another profile driver, the launcher offers to restart the VM with that driver and relaunch the game once Steam is ready. This one-shot override does not change the global driver setting.

Profiles travel over the bidirectional fx.progress port (one line per message). Host → guest: profiles replaces the full set on the first agent heartbeat/ready of a boot and after changes; launch starts steam steam://rungameid/ as the session user; restart-steam restarts steam.service only after the user agrees. The profile payload is {"v":1,"games":{"570":{"gfx":"dxmt","env":"PROTON_LOG=1"}}}; default/empty fields are omitted, and driver stays host-side. The guest atomically persists it in ~/.config/steamac/game-profiles.json. Before graphics/title preparation, the steam-profiles-wait ExecStartPre waits up to 5 s for this boot's synchronization, then snapshots the latest available set (using the persisted file on timeout). Guest → host: profiles-ok applied|pending; pending means Steam is running with a different applied set and the launcher offers Restart Steam or Later. game-gfx dxmt|vulkan follows game-proton and describes the actual Proton graphics path, detected from its .steamac-dxmt marker rather than the boot permission. Unknown verbs remain ignored so old/new host/guest combinations work.

On Steam start, profile launch words override steam-title-defaults' built-in entry; with no profile env, Dragon's Dogma 2 retains its Venus-feature-gated default. Ownership is unchanged: a later user edit in Steam wins; replacing or removing steamac-owned words does not accumulate old assignments, and removal restores the previous launch options.

Suspend

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 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.

SteamOS sleep

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 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 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 ; 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.

Desktop Mode

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 x (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.

Keyboard layouts. With Settings > General Use the Mac's keyboard layouts in Desktop Mode (on by default, applies now), Desktop Mode has the keyboard layouts enabled on the Mac, in the Input menu's order, and switches with the Mac's input source (Globe key, Control-Space). The launcher maps each to an XKB layout (com.apple.keylayout.Russian → ru(mac), RussianWin → ru(winkeys), German → de(mac), …; otherwise only a closed list of unambiguous first languages; unknown sources are skipped; an input method counts as the layout it types with). At most four layouts are sent (XKB's limit), always including the active one, as keyboard-layout … over fx.progress on every change and whenever Desktop Mode gets the focus, again 1.5, 4 and 8 s later (Plasma's startup selects its first layout after KWin took ours). The agent runs /usr/lib/steamac/desktop-keyboard (latest request only, never blocking): it checks each layout and variant against the guest's XKB symbols, drops unknown ones and recomputes the active index (if the active layout was dropped, it does not switch). Like the Mac clock format, it follows the Mac until changed in SteamOS: the first sync replaces even existing custom layouts; after that an observed user edit of Plasma's layouts wins permanently. The marker is ~/.config/steamac/mac-keyboard.json; switching off the setting stops sync, without restoring the old list. While following, it writes kxkbrc (including empty DisplayNames), asks KWin to reload when the file changes or KWin's layout codes differ (reloadConfig on org.kde.keyboard /Layouts), and selects the active layout (org.kde.KeyboardLayouts.setLayout) on the live nested session bus (nested_plasma/steamac-session-bus; absent or stale bus means no-op). macOS often posts its input-source notification 0.55–0.85 s after a Globe-key switch, so only while this setting is on, Desktop Mode has focus and the launcher is active, it polls the current layout every 30 ms. The keys stay the Mac's physical keys (evdev codes); the layout decides what they type. Gaming mode keeps its layout.

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.

Clipboard

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.

Controller

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 xpad driver exposes (045e:028e);
  • DualSense / DualShock 4: what hid-playstation / hid-sony expose for a USB pad
(054c:0ce6 / 054c:09cc, version 0x8111, 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.

Home / PS button diagnostics. The launcher requests that macOS leave this button to SteamOS, but macOS can override that preference. If it works over USB but not Bluetooth, compare Settings → Controller's live Home indicator and send a report after pressing and releasing the button on each connection. The launcher log records whether GameController exposes Home, its system-gesture binding, button and profile callbacks, and whether the Home event was queued for the guest pad port; pad state includes the current Home state. This distinguishes host input handling from guest delivery without assuming that a Bluetooth failure is a DualSense passthrough problem (DualShock 4 uses the virtual pad).

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 , 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 [email protected] 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 [email protected] 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).

FX Steam Launcher.app

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.

Languages

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

... (README truncated for length)

NeshDevTech

NeshDevTech is a professional technology firm passionate about creating innovative, high-performance web and mobile solutions.

Core Services
  • Custom Laravel Apps
  • SPA (React & Vue.js)
  • Flutter Mobile Apps
  • SEO & Performance Opt
Get in Touch
Available for Projects

Need a high-performing system? Let's discuss your project.


© 2026 NeshDevTech. All rights reserved.

Built with

Chat with me