mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 03:00:18 +02:00
Tested on a Frame (BUILD_ID 20260922.6101926): - Devkit pairing: the service runs and answers properties.json, but /register refuses with "please put the Steam client in pairing mode" unless Steam is on Settings > Developer > Pair new host. Both setup paths now say so and keep asking for 2 minutes before falling back to the password. - Sideloading: registering, launching, quoted start paths and Remove work; a Windows exe runs under Proton 11 through FEX. An aarch64 build starts but outside the runtime container, and an x86-64 Linux build doesn't start because the x86-64 Steam Linux Runtime 4.0 isn't installed. The inspect note for x86-64 Linux builds now says so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
172 lines
9.3 KiB
Markdown
172 lines
9.3 KiB
Markdown
# 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](https://gitlab.steamos.cloud/devkit/steamos-devkit):
|
||
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](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:
|
||
|
||
```sh
|
||
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 `[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](frame-control.md#how-it-works)).
|
||
|
||
## 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):
|
||
|
||
- [x] `create-shortcut` registers a title; it shows in the Steam library and in
|
||
**Sideloaded titles**, and Steam maps it to the chosen compat tool.
|
||
- [x] `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.
|
||
- [x] A quoted path in the start command is fine: Steam runs
|
||
`proton waitforexitandrun "/home/steamos/devkit-game/<id>/<exe>"`.
|
||
- [x] 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.
|