steamac — Valve's official ARM64 SteamOS (the Steam Frame image) in a VM on Apple Silicon
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
actoolbuilds the app icon from an Icon Composer document), Homebrew, rustup. - KosmicKrisp (optional, alternative Vulkan driver): built only on macOS 26+;
host/kosmickrisp/build.sh
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
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/, 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/, 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 ), 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.
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 .
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, 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_, 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 ;
pending means Steam is running with a different applied set and the launcher offers Restart Steam
or Later. game-gfx 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 (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) →
- 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, 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)