Files
saphidandClaude Opus 5.5 ca2a991f03 Sideloaded titles: use ids Steam's create-shortcut accepts
On the Frame, Steam refused to register titles whose id had a hyphen
(fc-smoke-exe) with "missing/invalid arguments", and registered the same
program as FCSmokeProbe (headset smoke test, 2026-09-27, BUILD_ID
20260922.6101926). Valve's client only allows ^[A-Za-z_][A-Za-z0-9_.]+$.

title_id now makes ids of letters, digits and _, not starting with a
digit, 2 to 64 long; new installs are checked against that, while titles
already on the Frame are still listed, launched and removed. Steam's error
text is trimmed before it's quoted, and the "install it again with Steam
running" hint only follows a Steam-not-running error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:40:09 +10:00

9.7 KiB
Raw Permalink Blame History

Sideloading Linux and Windows games

A game you have as files (an itch.io download, your own build, a DRM-free release) can go into the Frame's Steam library without a Steam store page. Frame Control uses the same path as Valve's SteamOS Devkit Client: the title becomes a Steam Devkit Game, with a runtime (Proton or a Steam Linux Runtime) chosen from the program itself.

For Android APKs, see apks.md instead.

Status: nothing here has run on a headset yet. Every device-side step is inferred from Valve's steamos-devkit source (release v0.20260925.1). The local steps (reading the zip, picking the program and runtime, building the request) are covered by tests/test_frame_titles.py.

Using it

Drop a game's .zip, folder or .exe on Send to Frame. (Folders need the desktop app, which knows where a dropped folder lives; in a plain browser, zip it.) A dialog shows:

  • Name: what Steam shows. Steam uses the title id as the name, so it's limited to letters, digits and _, and can't start with a digit; the dialog shows the result.
  • Launches: the program picked to start the game, with the other candidates in the list.
  • Runtime: picked from the program, see below. Windows programs can switch between Proton Experimental and Proton (stable).

Install copies it to the Frame and registers it with Steam; progress shows in the bar and the activity log. Sideloaded titles lists what's installed, with Launch and Remove. Copy to ~/Downloads instead keeps the old behaviour for a zip that isn't a game.

From a terminal:

python3 ui/frame_titles.py inspect Game.zip          # what would be installed, no headset needed
python3 ui/frame_titles.py install Game.zip [--name N] [--exe REL] [--runtime R]
python3 ui/frame_titles.py list | launch ID | remove ID

Choosing the runtime

The program's header decides, not its file name:

Program Runtime (Steam compat tool) steam_play Confidence
Windows .exe, x86-64 (PE machine 0x8664) proton-experimental 1 Inferred: ARM64 Proton runs x86-64 code through FEX
Windows .exe, 32-bit x86 (0x14c) or ARM64 (0xaa64) proton-experimental 1 Inferred
Linux ELF, aarch64 (e_machine 0xB7) SteamLinuxRuntime_4-arm64 0 Verified: starts, but natively (see below)
Linux ELF, x86-64 (0x3E) SteamLinuxRuntime_4 0 Verified not to start: the runtime isn't installed (see below)
Shell script the runtime of the Linux binary beside it, else SteamLinuxRuntime_4-arm64 0 Guess
Anything else (32-bit Linux, other CPUs, DLLs, data) refused with a message

Proton Experimental is the default rather than stable because the Frame's ARM64 Proton and FEX stack is new and Proton fixes reach Experimental first. If a game misbehaves, reinstall it with Proton (stable).

The aliases and settings are the ones Valve's client sends: RUNTIME_ALIASES in devkit_client/__init__.py, and gui2._update_game, which sets steam_play=1, steam_play_debug=0, steam_play_debug_version=2019 for Proton and steam_play=0 otherwise, plus compat_tool=<alias>. Valve's client only offers SteamLinuxRuntime_4-arm64 and Lepton when the device reports itself as Deckard (the Frame).

Picking the program

ui/frame_titles.py reads every file's header: ELF executables (PIE ones are told from shared libraries by their PT_INTERP segment), PE executables (not DLLs) and scripts with #!. A zip with a single top-level folder is treated as that folder. Candidates are ranked by:

  1. Not a helper: names like UnityCrashHandler64, CrashReportClient, *setup*, unins*, vc_redist*, dxsetup, *prereq*, and anything under _CommonRedist, Redist, DirectX or Engine go last.
  2. Platform: native ARM64 Linux, then Windows x86-64, then x86-64 Linux, then other Windows builds.
  3. Name: a program named like the zip or folder (build words such as -linux-arm64 or _v1.2 are dropped from the name).
  4. Depth, then size: Unreal's top-level Game.exe beats Game/Binaries/Win64/Game-Win64-Shipping.exe.

A top-level shell script beats a Linux binary one folder down (run.sh + bin/game); a binary next to a script wins. The list in the dialog lets you pick another.

What happens on the Frame (inferred)

  1. Tools. frame/devkit-utils/ (Valve's scripts, vendored unmodified, MIT) is copied to ~/devkit-utils, where Valve's client puts it, unless the stamp file there already matches. Files are merged, not replaced, so a newer copy from Valve's client keeps its extra files.
  2. Folder. python3 ~/devkit-utils/steamos-prepare-upload --gameid ID makes ~/devkit-game/ID and prints {user, directory}.
  3. Copy. The files go there with rsync -a --delete on macOS and Linux, or scp -r into a fresh folder that then replaces it on Windows. Then chmod -R 755, the modes Valve's client gives an upload.
  4. Register. python3 ~/devkit-utils/steam-client-create-shortcut --parms JSON with {gameid, directory, argv: [target], env: {}, settings, clear_settings, force_appid: "", lepton_args: ""}. It writes ID-argv.json, ID-env.json and ID-settings.json next to the folder, then sends create-shortcut to the running Steam client over ~/.steam/steam.pipe (authenticated by ~/.steam/steam.token) and waits up to 5 s for Steam's answer file. Its error, for example "The Steam client is not running", is shown as the install error. The files stay, so installing again with Steam running finishes the job.
  5. Launch is steam-devkit-rpc run-game gameid=ID. Remove is steamos-delete --delete-title ID, which deletes the folder and has Steam drop shortcuts with no folder. Frame Control then removes the ID-*.json files that Valve's script leaves behind.

Frame Control also writes ~/devkit-game/ID-framecontrol.json (name, source file, target, runtime, size). Sideloaded titles lists every folder in ~/devkit-game, including titles uploaded with Valve's client.

argv is one string, as in Valve's client (the start command may carry arguments), so a program path with spaces is sent in double quotes. How Steam splits that string is not checked.

Safety

  • Zips are unpacked on your computer first. Entries with absolute paths, .., drive letters or : anywhere in the path, or links that point outside the zip (or at a folder they're in) are refused. So are zips over 64 GB unpacked, over 200,000 entries, more than 200× compressed past 1 GB, or bigger than the free space.
  • No symlink is created while unpacking, so no write can be redirected through one. A link to a file inside the zip (libfoo.so.1 → libfoo.so.1.2) becomes a copy of that file, which also works on Windows. Links to folders, loops and dangling links are left out.
  • A dropped folder that contains symlinks (or Windows junctions) is copied on your computer first, with the same rule, because scp -r would follow a link out of the folder and upload whatever it points at.
  • Installs run one at a time, and Remove is refused while one runs.
  • The title id is limited to letters, digits and _, doesn't start with a digit (one that would gets _ in front), and is 2 to 64 characters. That's what Steam's create-shortcut accepts: on the Frame it refused fc-smoke-exe with missing/invalid arguments and registered the same program as FCSmokeProbe (2026-09-27, BUILD_ID 20260922.6101926), and Valve's client only allows ^[A-Za-z_][A-Za-z0-9_.]+$. Valve's scripts also pass the id to a shell (steamos-delete runs rm -r on it). Valve's reserved sideload names (steam, steamvr, and their deckard forms, which would replace the Steam client itself) get _game added.
  • Nothing needs sudo; everything goes to your home folder on the Frame.
  • In the app, a dropped folder is read from its local path by the app's own server, which only accepts requests from its own page (see frame-control.md).

Checked on a headset

Tested 2026-09-26 on a Frame (BUILD_ID 20260922.6101926) with small static test programs and PuTTY's official 64-bit putty.exe, through both the command line and the app (inspect, install job, ▶, Remove, and install links):

  • create-shortcut registers a title; it shows in the Steam library and in Sideloaded titles, and Steam maps it to the chosen compat tool.
  • steam-devkit-rpc run-game starts it (Steam logs devkit run-game: started devkit game "<id>"), and Remove (steamos-delete) deletes the files, the shortcut and the Proton prefix.
  • A quoted path in the start command is fine: Steam runs proton waitforexitandrun "/home/steamos/devkit-game/<id>/<exe>".
  • An x86-64 Windows .exe runs under Proton 11 (stable) through FEX (ARM64EC) inside the Steam Linux Runtime 4.0 ARM64 container; PuTTY stayed up. Proton Experimental wasn't installed at the time (it was downloading), so it's untested. A Go-built x86-64 test program crashed in libarm64ecfex.dll (a FEX limitation with that program, not the sideloading).
  • An aarch64 build runs natively, not in SteamLinuxRuntime_4-arm64: Steam records the mapping (CompatToolMapping, compat_log.txt) but launches the devkit title without the runtime's _v2-entry-point prefix. Fine for a self-contained build; a build that needs the runtime's libraries may not start.
  • An x86-64 Linux build doesn't start: Steam logs Tool 4183110 "Steam Linux Runtime 4.0" is found for appID …, but is not installed, and the Frame doesn't install that x86-64 runtime for a devkit title (a steam://install/4183110 request did nothing).
  • Whether these titles open as flat panels or need anything VR-specific.