Files
saphid--frame-control/docs/sideloading.md
T
saphidandClaude Opus 5.5 6c4d387ef3 Sideload Linux and Windows builds as Steam Devkit Games
Dropping a game's .zip, folder or .exe on Send to Frame now adds it to the
headset's Steam library through Valve's SteamOS Devkit title path, with the
runtime picked from the program's header: Windows PE -> Proton Experimental
(steam_play=1), aarch64 ELF -> SteamLinuxRuntime_4-arm64, x86-64 ELF ->
SteamLinuxRuntime_4 (through FEX). Other architectures are refused.

- frame/devkit-utils: Valve's devkit-utils vendored unmodified (MIT,
  steamos-devkit v0.20260925.1), synced to ~/devkit-utils by stamp, bundled in
  the app and compiled in CI.
- ui/frame_titles.py: inspect (safe unzip, ELF/PE classification, launch
  target ranking), install(path, name=None, exe=None, runtime=None,
  progress=None), list, launch, remove, plus a CLI.
- ui/server.py: /api/titles (inspect/install/discard/launch/remove),
  /api/titles/job progress, and an upload mode 'title'.
- ui/index.html: confirm dialog (name, launch target, runtime), install
  progress, and a Sideloaded titles list with Launch and Remove. The app's
  preload passes a dropped folder's path.
- tests and docs/sideloading.md. Device-side behaviour is inferred from
  Valve's source; the headset was offline, so none of it has been checked on
  a Frame yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:38:14 +10:00

8.1 KiB
Raw 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 -; 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 Inferred: native
Linux ELF, x86-64 (0x3E) SteamLinuxRuntime_4 0 Inferred: runs through FEX; the least certain row
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 [A-Za-z0-9_-], at most 64 characters. Valve's scripts pass it 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).

To check on a headset

  • create-shortcut registers a title and it shows in the library under its id.
  • An aarch64 build launches in SteamLinuxRuntime_4-arm64.
  • An x86-64 Windows .exe launches under Proton Experimental through FEX.
  • An x86-64 Linux build launches in SteamLinuxRuntime_4 through FEX.
  • steam-devkit-rpc run-game starts it, and steamos-delete removes the shortcut.
  • How Steam splits a start command with a quoted path.
  • Whether these titles open as flat panels or need anything VR-specific.