Add a UEVR mod manager: install, play in VR and remove from Frame Control

Installed Unreal Engine games get a "VR mod" button. ui/frame_mods.py runs on
the Frame: it downloads the pinned official UEVR 1.05 release and Microsoft's
.NET 6 runtimes (hash-checked, safely unpacked) into the game's Proton prefix
with a receipt; starts the game, runs the injector in the game's own Wine
session and presses OpenVR + Inject with XSendEvent (gamescope keeps real
input on the game); confirms UEVRBackend.dll is loaded and SteamVR counts
frames; and removes exactly what it added.

Verified on a real Frame 2026-09-29 with Gravitas, through the UI: install,
four injections (three from a cold start), remove back to the pre-install
prefix listing. The headset was unworn, so the VR image, tracking and controls
are not verified. Part of #26.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-29 11:52:02 +10:00
1 parent beccfec307
commit 416f3181cc
10 files changed
+943 -27

No files matched your search

+1 -1
View File
@@ -201,7 +201,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection | | [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste | | [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build | | [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet | | [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes | | [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing | | [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset | | [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
+78
View File
@@ -0,0 +1,78 @@
# UEVR from Frame Control, 2026-09-29
**Verified observations**, with limits below. Same device and software as
[2026-09-28](mods-2026-09-28.md): aarch64, SteamOS `0.4.1`, BUILD_ID
`20260925.6191901`, Proton `11.0-2c` ARM64, SteamVR `2.18.1`. Times are the
Frame's AEST clock. Headset tests ran under the shared
`/tmp/frame-test.lock`, taken 11:13–11:28 and 11:33–11:49. Battery 45 % →
93 %, on charge throughout.
**Not observed:** the VR image, head tracking and controller input. SteamVR
reported `activity_level` 3 (user interaction timeout; the headset was not
being worn) with `average_target_fps` 1. Every `frame_vrshot.py` stereo
capture was a uniform dark frame. Frame submits prove the app is presenting
to SteamVR, not that the image is correct.
## Manual injection (11:13–11:28)
Game launched with `steam steam://rungameid/1067310`. `UEVRInjector.exe` was
started with the game process's Wine variables, and its WPF window drew
through DXVK's D3D9. It listed `Drop-Win64-Shipping (pid: 320) (SkyArk
(64-bit, PCD3D_SM5))`, a Wine-side process id.
- `--attach=Drop-Win64-Shipping` didn't inject. The button still read
**Inject**, and the saved `OpenVRRadio=True` in `AppData/Local/praydog/…/user.config`
was not restored on the next start.
- XTest clicks landed on the game: `XQueryPointer` reported child
`0x2000001` (the game) at the injector's coordinates, and
`XLowerWindow`/`XRaiseWindow`/`XSetInputFocus` didn't change that.
- `XSendEvent` button events to the injector window worked. After OpenVR and
Inject, `/proc/<game>/maps` contained
`drive_c/frame26/uevr/UEVRBackend.dll` and `openvr_api.dll`, next to
Proton's `vrclient_x64.dll`. UEVR's log:
```text
[11:17:32.948] Hooking D3D11
[11:17:33.020] Hooked DirectX 11
[11:17:34.263] Framework initialized
[11:17:34.475] [VR] Requested runtime: openvr_api.dll
[11:17:39.791] UGameViewportClient::Draw called for the first time.
[11:17:39.792] is stereo enabled called!
```
It also logged repeated `Failed to initialize Framework on DirectX 12` (probing
before it chose D3D11) and failed Unreal scans, for example `Failed to locate
r.EnableStereoEmulation cvar` and `Failed to find FSceneView constructor`.
SteamVR loaded UEVR's action manifest and `bindings_oculus_touch.json` for
`steam.app.1067310`. `vrcmd --stats` then showed `"key" :
"steam.app.1067310"` with `frame_submits` 4439, then 5506 a few seconds later.
A second session's injector exited within about 20 seconds. The third,
started fresh, injected again (`frame_submits` 1412). After the game was
stopped, a plain launch had no UEVR modules and no SteamVR scene app. All test
files were removed.
## Through Frame Control (11:33–11:49)
Local `ui/server.py` against the `frame` alias. First the API, then the
page's **VR mod** dialog, driven with a headless Chromium:
| Step | Result |
|---|---|
| `status` for 2177750 (HL2 VR) | `eligible: false`; `install` refused ("doesn't look like an Unreal Engine game") |
| `install` for 1067310 | 3 s. Three downloads, hashes matched, receipt written |
| `start`, game running | First run refused: `the UEVR window is 960 px wide, not 625`. It was measured before WPF laid it out; `start` now waits for the width to settle. Rerun: injected, `frame_submits` 27 (36 s) |
| `uninstall` while running | Refused: "the game is running; quit it first" |
| `start`, game not running | Launched the game, injected, `frame_submits` 45 (55 s) |
| UI: Remove, Install | Worked; the prefix listing matched its pre-install state except an empty `UnrealVRMod`, which uninstall now also removes |
| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's; `start` also retries up to three times |
| UI: Play in VR ×4 | 4 of 4 injected. The three cold starts took 54, 58 and 58 s; frame submits 56, 71, 80 |
| UI: Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipt gone |
## Left on the Frame
- Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still
installed; nothing else from this work is left.
- No `UEVRInjector.exe` or game processes were left running. Steam and
SteamVR were running at the end.
- The test lock was released at 11:49.
+1
View File
@@ -47,6 +47,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` | | The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) | | Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) | | **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
| **A second Windows program can join a running Proton game.** Copy `WINEPREFIX`, `WINEDLLPATH`, `WINEDLLOVERRIDES`, `PATH` (Proton's `files/bin-arm64` comes first) and `DISPLAY` from `/proc/<game pid>/environ`, keeping your own `HOME` and the rest, and run `wine prog.exe` from an ordinary SSH shell. It shares the game's wineserver (outside the pressure-vessel container). Don't copy `WINESERVERSOCKET`, which is an inherited fd. A game's windows live on gamescope's X display for that app (`:1` for Gravitas), where the full-screen game keeps the pointer and focus: XTest clicks and restacking don't reach other windows, but `XSendEvent` button events sent to a window do. `xwd` captures WPF/DXVK windows but shows Vulkan game windows as black. **Verified 2026-09-29**, BUILD_ID 20260925.6191901, Proton 11.0-2c ARM64. | [mods.md](mods.md) |
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) | | **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` | | Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame | | **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+75 -14
View File
@@ -1,11 +1,14 @@
# Flat-to-VR mods and Beat Saber songs # Flat-to-VR mods and Beat Saber songs
**Status: feasibility work, not an installer.** Frame Control does not yet **Status: UEVR works from Frame Control for Unreal Engine games; Beat Saber
manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26) songs are blocked.** Installed games have a **VR mod** button. It installs
stays open: neither UEVR injection nor Beat Saber custom-song playback has UEVR, starts the game with UEVR injected, and removes UEVR again. That whole
been verified on this Frame. Alex has an owned copy on a Quest 2; that copy loop was run on a real Frame on 2026-09-29 with Gravitas, from the app's own
has not been inspected. There is no Mods button until the underlying UI ([evidence](evidence/mods-2026-09-29.md)). The headset was unworn, so
install, playback and removal have been checked. the VR image, head tracking and controls haven't been seen yet. Beat Saber
custom songs need an owned Beat Saber on the Frame, which there isn't
([below](#beat-saber-songs-first)).
[Issue #26](https://github.com/saphid/frame-control/issues/26) stays open for those.
The mod manager must be Frame Control's own implementation. Mods and songs The mod manager must be Frame Control's own implementation. Mods and songs
are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other
@@ -15,27 +18,82 @@ unofficial mod mirrors are part of this work.
## Per-game support ## Per-game support
Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64, Checked 2026-09-28 and 2026-09-29 on SteamOS **0.4.1**, BUILD_ID
with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only **20260925.6191901**, aarch64, with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
the observation stated, not a promise that the game is playable. “Documented” the observation stated, not a promise that the game is playable. “Documented”
means an upstream source describes it; “inferred” means it still needs a test. means an upstream source describes it; “inferred” means it still needs a test.
| Game / build | Mod or content | Evidence and support status | Next check | | Game / build | Mod or content | Evidence and support status | Next check |
|---|---|---|---| |---|---|---|---|
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. | | Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
| Gravitas, Steam 1067310, Windows | UEVR 1.05 | **Verified: prerequisites and windows only.** Free Steam install completed. The game produced a `SkyArk (64-bit, PCD3D_SM5)` window. UEVR needed .NET; with official .NET 6.0.36 libraries it produced a `UEVR` window. A combined run exited 1 with X11 errors before injection was verified. **Inferred: compatibility remains unknown**, not proven broken. | Retry during a stable headset session; verify injection, stereo scene output, controls and removal. | | Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Through the app's UI: install, then three cold **Play in VR** runs (54–58 s each) all loaded `UEVRBackend.dll` into the game, and SteamVR's compositor counted frame submits for `steam.app.1067310`. Remove restored the prefix to its pre-install file listing. **Not seen:** the stereo image, head tracking and controls (headset unworn). | Wear the headset: check the image, tracking, UEVR's in-headset menu and controller input. |
| Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. | | Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. | | Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2**, currently charging. No APK, version or installed mods inspected; no Frame playback verified. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. | | Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2.** Not reachable on 2026-09-29 (no device on `adb` from the Mac). No APK, version or installed mods inspected; no Frame playback verified. A Quest copy is a Meta store purchase; copying it to another headset would need its entitlement check to pass there, which this work won't work around. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
| Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. | | Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. |
| Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. | | Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. |
| Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. | | Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. |
| Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. | | Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. |
| Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. | | Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. |
The [test record](evidence/mods-2026-09-28.md) distinguishes process startup, The test records ([2026-09-28](evidence/mods-2026-09-28.md),
visible output and failures. It also records a SteamVR restart during the [2026-09-29](evidence/mods-2026-09-29.md)) distinguish process startup,
shared session, which prevents attributing the failed UEVR attempt to FEX. visible output, injection and failures.
## UEVR from Frame Control
![The VR mod dialog while UEVR runs in Gravitas](img/uevr-dialog.png)
**Get games → an installed game → VR mod.** The dialog says whether the game
can take UEVR, then offers **Install UEVR**, **▶ Play in VR** and **Remove
UEVR**. The server route is `POST /api/mods {"action": "status" | "install" |
"start" | "uninstall", "appid": …}`, and the work happens on the Frame in
[`ui/frame_mods.py`](../ui/frame_mods.py). It uses only the standard library,
like `frame_steam.py`.
- **Which games.** Unreal Engine games, recognised by their
`…/Binaries/Win64/*-Win64-Shipping.exe` (inferred as the general rule; checked
with Gravitas, and HL2 VR is correctly refused). The game needs a Proton
prefix, so it must have been played once.
- **Install.** Downloads praydog's [UEVR 1.05 release](https://github.com/praydog/UEVR/releases/tag/1.05)
and Microsoft's Windows x64 .NET 6.0.36 and Windows Desktop runtimes (the
injector is a .NET 6 WPF app, and Proton ships no .NET). Each file is
checked against its pinned SHA-256 (UEVR) or SHA-512 (.NET, from Microsoft's
release metadata) before use. Archives are unpacked into a staging folder,
rejecting entries that escape it, symlinks and oversized contents, and
then moved into `<prefix>/drive_c/frame-control/`. Nothing goes into the
game's own folder. A receipt in `~/.local/share/frame-control/mods/` lists
what to remove, including UEVR's own settings folders only if they didn't
exist before. Install and remove refuse while the game is running.
- **Play in VR.** Launches the game through Steam if it isn't running and
waits for its window. It then starts `UEVRInjector.exe` with the game's own
Proton, prefix and Wine settings, read from the game process, so both are in
one Wine session. It presses **OpenVR** and **Inject**, confirms that
`UEVRBackend.dll` is mapped into the game, and reads SteamVR's frame count
for the app (`vrcmd --stats`).
- **Remove.** Deletes what the receipt lists, then the download cache once
no game uses it. Verified: the prefix's file listing matched its
pre-install state afterwards.
Things found on the way (verified 2026-09-29):
- Under gamescope, the full-screen game keeps the pointer and focus, and
XTest clicks, raising or refocusing the injector window don't change that.
So in the headset you couldn't click the injector yourself, and Frame
Control sends X `ButtonPress`/`ButtonRelease` events straight to the
injector's window instead. The positions are for UEVR 1.05's layout; the
window first maps at 960 px wide and settles at 625 px. `start` refuses to
click if it doesn't settle at that width.
- The injector's `--attach=<process>` didn't inject under Wine. It also
didn't restore the OpenVR choice it had saved, so Frame Control chooses
OpenVR every time.
- With only the game's variables (no `HOME`), the injector hit an
`AccessViolationException` in .NET at startup. With the normal environment
underneath, all four later starts worked. `start` also retries a
crashed injector up to three times.
- UEVR's DirectX 12 probing logs errors before it settles on DirectX 11 for
Gravitas, and some of its Unreal engine scans fail on this older UE4 game.
Neither stopped the injection.
## Beat Saber: songs first ## Beat Saber: songs first
@@ -62,7 +120,10 @@ or dependency for Frame Control.
## Requirements for our manager ## Requirements for our manager
These are **planned**, not implemented or verified: For UEVR, items 1–5 are implemented in `frame_mods.py` and covered by
fake-library tests (`tests/test_mods.py`), apart from checking ownership
through Steam; item 6 is met for Gravitas. Beat Saber songs would need the
same, and none of it is built for them:
1. Resolve the selected Steam game's real library, installed build, executable 1. Resolve the selected Steam game's real library, installed build, executable
architecture and Proton prefix. Confirm ownership through Steam; a directory architecture and Proton prefix. Confirm ownership through Steam; a directory
+3 -3
View File
@@ -4,9 +4,9 @@ Frame Control's **Get games** section lists the games you own with each one's
Steam Frame rating, installs them on the Frame, and searches the Steam store. Steam Frame rating, installs them on the Frame, and searches the Steam store.
This page covers how it works underneath, so you can do the same from a shell. This page covers how it works underneath, so you can do the same from a shell.
For flat-to-VR mods and Beat Saber custom songs, see the Installed Unreal Engine games also have a **VR mod** button that installs,
[per-game feasibility table](mods.md). Mod support is separate from Steam's starts and removes UEVR; see [VR mods](mods.md) for how it works and the
Frame rating; there is no mod installer yet. per-game table. Mod support is separate from Steam's Frame rating.
## How it works ## How it works
+270
View File
@@ -0,0 +1,270 @@
"""VR mod (UEVR) checks that need no headset or network: a fake Steam library on disk.
Run: python3 -m unittest discover -s tests
"""
import hashlib
import io
import itertools
import json
import stat
import subprocess
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_mods # noqa: E402
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
APPID = 1067310
def zip_bytes(files, links=()):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
for name, data in files.items():
z.writestr(name, data)
for name in links:
info = zipfile.ZipInfo(name)
info.external_attr = (stat.S_IFLNK | 0o777) << 16
z.writestr(info, "/etc/passwd")
return buf.getvalue()
class FakeLibrary(unittest.TestCase):
"""A Steam library with Gravitas's real layout, and pinned downloads served from memory."""
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
home = Path(tmp.name)
self.steam = home / ".local/share/Steam"
apps = self.steam / "steamapps"
(apps / "common/Gravitas/SkyArk/Binaries/Win64").mkdir(parents=True)
(apps / "common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").write_bytes(b"MZ" * 100)
(apps / "common/Gravitas/Drop.exe").write_bytes(b"MZ")
self.prefix = apps / f"compatdata/{APPID}/pfx"
for sub in ("Roaming", "Local"):
(self.prefix / "drive_c/users/steamuser/AppData" / sub).mkdir(parents=True)
(apps / f"appmanifest_{APPID}.acf").write_text(
f'"AppState"\n{{\n\t"appid"\t\t"{APPID}"\n\t"name"\t\t"Gravitas"\n\t"installdir"\t\t"Gravitas"\n}}\n')
for name, value in (("STEAM", self.steam), ("CACHE", home / ".cache/frame-control/mods"),
("RECEIPTS", home / ".local/share/frame-control/mods")):
p = mock.patch.object(frame_mods, name, value)
p.start()
self.addCleanup(p.stop)
self.running = None
p = mock.patch.object(frame_mods, "game_pid", side_effect=lambda exe: self.running)
p.start()
self.addCleanup(p.stop)
self.downloads = {frame_mods.UEVR["url"]: zip_bytes({"UEVRInjector.exe": b"MZ", "UEVRBackend.dll": b"MZ",
"openvr_api.dll": b"MZ"})}
for url, _ in frame_mods.DOTNET["files"]:
self.downloads[url] = zip_bytes({"shared/Microsoft.NETCore.App/6.0.36/x.dll": b"MZ"})
self.pin(self.downloads)
def pin(self, downloads):
"""Serve these bytes and pin their real hashes in place of the published ones."""
uevr = dict(frame_mods.UEVR, sha256=hashlib.sha256(downloads[frame_mods.UEVR["url"]]).hexdigest())
dotnet = dict(frame_mods.DOTNET, files=[(u, hashlib.sha512(downloads[u]).hexdigest())
for u, _ in frame_mods.DOTNET["files"]])
for name, value in (("UEVR", uevr), ("DOTNET", dotnet)):
p = mock.patch.object(frame_mods, name, value)
p.start()
self.addCleanup(p.stop)
p = mock.patch.object(frame_mods.urllib.request, "urlopen",
side_effect=lambda url, timeout: io.BytesIO(downloads[url]))
self.urlopen = p.start()
self.addCleanup(p.stop)
def managed(self):
return self.prefix / "drive_c/frame-control"
class Status(FakeLibrary):
def test_unreal_game_is_eligible_and_not_installed(self):
st = frame_mods.status(APPID)
self.assertEqual((st["eligible"], st["exe"], st["installed"], st["running"], st["prefix"]),
(True, "Drop-Win64-Shipping.exe", False, False, True))
def test_game_without_unreal_binary_is_refused(self):
(self.steam / "steamapps/common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").unlink()
self.assertFalse(frame_mods.status(APPID)["eligible"])
with self.assertRaisesRegex(frame_mods.Fail, "Unreal Engine"):
frame_mods.install(APPID)
self.urlopen.assert_not_called()
def test_uninstalled_game(self):
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
frame_mods.status(620)
class InstallUninstall(FakeLibrary):
def test_install_then_uninstall_leaves_prefix_as_found(self):
before = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
res = frame_mods.install(APPID)
self.assertTrue(res["installed"])
self.assertTrue((self.managed() / "uevr-1.05/UEVRInjector.exe").is_file())
self.assertTrue((self.managed() / "dotnet-6.0.36/shared/Microsoft.NETCore.App/6.0.36/x.dll").is_file())
self.assertFalse(list(self.managed().glob(".staging-*")))
# What UEVR writes on its first injection.
roaming = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
roaming.mkdir(parents=True)
(roaming / "log.txt").write_text("x")
(self.prefix / "drive_c/users/steamuser/AppData/Local/praydog/x").mkdir(parents=True)
frame_mods.uninstall(APPID)
after = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
self.assertEqual(after, before)
self.assertFalse(frame_mods.CACHE.exists(), "the downloads go once no game uses them")
self.assertIsNone(frame_mods.read_receipt(APPID))
def test_existing_uevr_settings_survive_uninstall(self):
profile = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
profile.mkdir(parents=True)
(profile / "config.txt").write_text("mine")
frame_mods.install(APPID)
frame_mods.uninstall(APPID)
self.assertEqual((profile / "config.txt").read_text(), "mine")
def test_second_install_is_a_no_op(self):
frame_mods.install(APPID)
calls = self.urlopen.call_count
self.assertIn("already installed", frame_mods.install(APPID)["message"])
self.assertEqual(self.urlopen.call_count, calls)
def test_refuses_while_game_runs(self):
self.running = 4242
with self.assertRaisesRegex(frame_mods.Fail, "running"):
frame_mods.install(APPID)
self.running = None
frame_mods.install(APPID)
self.running = 4242
with self.assertRaisesRegex(frame_mods.Fail, "running"):
frame_mods.uninstall(APPID)
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
def test_needs_a_prefix(self):
import shutil
shutil.rmtree(self.prefix.parent)
with self.assertRaisesRegex(frame_mods.Fail, "play it once"):
frame_mods.install(APPID)
def test_receipt_cannot_point_outside_the_prefix(self):
frame_mods.install(APPID)
r = frame_mods.read_receipt(APPID)
victim = self.steam / "steamapps/common/Gravitas"
r["remove"].append(str(self.prefix / "../../../common/Gravitas"))
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
with self.assertRaisesRegex(frame_mods.Fail, "outside"):
frame_mods.uninstall(APPID)
self.assertTrue(victim.is_dir())
self.assertTrue((self.managed() / "uevr-1.05").is_dir(), "nothing removed when the receipt is bad")
class Downloads(FakeLibrary):
def test_hash_mismatch_deletes_and_installs_nothing(self):
self.downloads[frame_mods.UEVR["url"]] = zip_bytes({"UEVRInjector.exe": b"tampered"})
with self.assertRaisesRegex(frame_mods.Fail, "doesn't match"):
frame_mods.install(APPID)
self.assertFalse((frame_mods.CACHE / "UEVR.zip").exists())
self.assertFalse(self.managed().exists())
self.assertIsNone(frame_mods.read_receipt(APPID))
def test_archive_escaping_its_folder_is_refused(self):
for bad in (zip_bytes({"UEVRInjector.exe": b"MZ", "../../evil.dll": b"MZ"}),
zip_bytes({"UEVRInjector.exe": b"MZ"}, links=["openvr_api.dll"])):
downloads = dict(self.downloads, **{frame_mods.UEVR["url"]: bad})
self.pin(downloads)
with self.subTest(), self.assertRaises(frame_mods.Fail):
frame_mods.install(APPID)
self.assertFalse((self.prefix / "drive_c/evil.dll").exists())
self.assertFalse(list(self.managed().glob("*")) if self.managed().exists() else [])
import shutil
shutil.rmtree(frame_mods.CACHE, ignore_errors=True)
def test_oversized_archive_is_refused(self):
with mock.patch.object(frame_mods, "MAX_UNPACKED", 3):
with self.assertRaisesRegex(frame_mods.Fail, "more than expected"):
frame_mods.install(APPID)
class Start(FakeLibrary):
def test_needs_install_first(self):
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
frame_mods.start(APPID)
def test_refuses_to_click_an_unexpected_layout(self):
frame_mods.install(APPID)
self.running = 4242
env = "WINEPREFIX=/x\0DISPLAY=:1\0WINESERVERSOCKET=17\0PATH=/p\0"
windows = [[(1, "SkyArk", 1280)], [(1, "SkyArk", 1280)], [(1, "SkyArk", 1280), (2, "UEVR", 700)]]
with mock.patch("builtins.open", mock.mock_open(read_data=env)), \
mock.patch.object(frame_mods, "loaded", return_value=False), \
mock.patch.object(frame_mods, "x11_windows", side_effect=lambda d: windows.pop(0) if len(windows) > 1 else windows[0]), \
mock.patch.object(frame_mods.subprocess, "Popen") as popen, \
mock.patch.object(frame_mods, "x_click") as click, \
mock.patch.object(frame_mods.os, "killpg") as kill, \
mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)), \
mock.patch.object(frame_mods.time, "sleep"):
popen.return_value.poll.return_value = None # the injector stays up
with self.assertRaisesRegex(frame_mods.Fail, "700 px"):
frame_mods.start(APPID)
click.assert_not_called()
kill.assert_called_once_with(popen.return_value.pid, frame_mods.signal.SIGKILL)
env_used = popen.call_args.kwargs["env"]
self.assertNotIn("WINESERVERSOCKET", env_used, "an inherited fd number means nothing to a new process")
self.assertEqual(env_used["DOTNET_ROOT"], "C:\\frame-control\\dotnet-6.0.36")
self.assertEqual(env_used["WINEPREFIX"], "/x")
self.assertIn("HOME", env_used, "fontconfig needs a HOME for its cache")
def test_injector_that_keeps_crashing_is_reported(self):
frame_mods.install(APPID)
self.running = 4242
with mock.patch("builtins.open", mock.mock_open(read_data="WINEPREFIX=/x\0DISPLAY=:1\0")), \
mock.patch.object(frame_mods, "loaded", return_value=False), \
mock.patch.object(frame_mods, "x11_windows", return_value=[(1, "SkyArk", 1280)]), \
mock.patch.object(frame_mods.subprocess, "Popen") as popen, \
mock.patch.object(frame_mods, "x_click") as click, \
mock.patch.object(frame_mods.time, "sleep"):
popen.return_value.poll.return_value = 134 # died before showing its window
with self.assertRaisesRegex(frame_mods.Fail, "crashed on start 3 times"):
frame_mods.start(APPID)
self.assertEqual(popen.call_count, 3)
click.assert_not_called()
class Helper(unittest.TestCase):
def test_bad_usage_prints_json_error(self):
for args in ([], ["install"], ["install", "12x"], ["remove", "620"]):
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_mods.py"), *args],
capture_output=True, text=True, timeout=30)
self.assertEqual(out.returncode, 1, args)
self.assertIn("error", json.loads(out.stdout), args)
class ModRoutes(test_server.ServerGuards):
"""Reuses ServerGuards' server (unresolvable SSH alias); validation runs before any SSH."""
def test_mod_input_validation(self):
for body in ({"action": "install", "appid": "1; reboot"}, {"action": "install", "appid": ""},
{"action": "delete", "appid": "1067310"}, {"appid": "1067310"}):
status, payload = self.post("/api/mods", body)
self.assertEqual(status, 400, f"{body} -> {payload}")
def test_needs_custom_header(self):
self.assertEqual(self.request("POST", "/api/mods", {"action": "status", "appid": "1"})[0], 403)
def test_runs_the_mods_helper_with_its_timeout(self):
import server
with mock.patch.object(server, "steam_frame", return_value={"ok": 1}) as run:
self.assertEqual(server.mods({"action": "install", "appid": "1067310"}), {"ok": 1})
run.assert_called_once_with("install", "1067310", timeout=600, script="frame_mods.py")
for name in [n for n in dir(test_server.ServerGuards) if n.startswith("test_")]:
setattr(ModRoutes, name, None)
+437
View File
@@ -0,0 +1,437 @@
#!/usr/bin/env python3
"""Runs ON the Frame (piped over SSH): UEVR, a flat-to-VR mod for Unreal games.
Usage: python3 - status APPID # can UEVR run here, is it installed, is the game running
python3 - install APPID # download the pinned official release into the game's prefix
python3 - start APPID # launch the game if needed, inject UEVR, confirm SteamVR frames
python3 - uninstall APPID # remove exactly what install (and UEVR's first run) created
Prints one JSON object. Errors are {"error": "..."} with exit status 1.
Verified 2026-09-29 on SteamOS 0.4.1 (build 20260925.6191901), Proton 11.0
ARM64 and SteamVR 2.18.1 with Gravitas (1067310): UEVR 1.05's injector runs in
the game's own Wine session, and after OpenVR + Inject the game loads
UEVRBackend.dll and SteamVR counts frame submits for it. Stereo image, head
tracking and controls were not seen: the headset was unworn (docs/mods.md).
Nothing here is bundled. UEVR comes from praydog's GitHub release, and the
Windows .NET 6 runtime it needs from Microsoft's release metadata URLs, each
checked against a pinned hash. Files live in <prefix>/drive_c/frame-control,
never in the game's own folder, and a receipt records what to remove.
"""
import hashlib
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import time
import urllib.request
import zipfile
from pathlib import Path
HOME = Path.home()
STEAM = HOME / ".local/share/Steam"
CACHE = HOME / ".cache/frame-control/mods"
RECEIPTS = HOME / ".local/share/frame-control/mods"
VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd"
MANAGED = "frame-control" # drive_c/<this> inside the game's prefix
UEVR = {"version": "1.05", "dir": "uevr-1.05",
"url": "https://github.com/praydog/UEVR/releases/download/1.05/UEVR.zip",
"sha256": "af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c"}
# UEVRInjector.exe is a .NET 6 WPF app; Proton ships no .NET. These are the
# win-x64 ZIPs listed in builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json.
DOTNET = {"version": "6.0.36", "dir": "dotnet-6.0.36", "files": [
("https://builds.dotnet.microsoft.com/dotnet/Runtime/6.0.36/dotnet-runtime-6.0.36-win-x64.zip",
"935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2"),
("https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/6.0.36/windowsdesktop-runtime-6.0.36-win-x64.zip",
"cee88fef07643dceb3d34ea71b64eeae85b8e39e7042bc4d35bc3a1f1df29373681dc48e31c01c9f593ec412699f6762b6fc5817433283c89df35a27ce8885bf")]}
MAX_UNPACKED = 600 << 20 # the three archives unpack to about 190 MB
# UEVR 1.05's injector window (625 px wide), in window coordinates. Under
# gamescope the full-screen game keeps the pointer and focus, so real clicks
# can't reach the injector; XSendEvent to its window does. It doesn't restore
# its saved runtime choice under Wine, and --attach didn't inject, so choose
# OpenVR (SteamVR's own API) and press Inject every time.
INJECTOR_WIDTH = 625
CLICK_OPENVR, CLICK_INJECT = (125, 137), (365, 113)
# The game's environment the injector needs to join its Wine session. Not
# WINESERVERSOCKET and friends: those are inherited file descriptors.
GAME_ENV = re.compile(r"(WINEPREFIX|WINEDLLPATH|WINEDLLOVERRIDES|WINEFSYNC|WINEDEBUG|PATH|DISPLAY|"
r"XDG_RUNTIME_DIR|PROTON_VR_RUNTIME|FEX_APP_CONFIG|FEX_APP_CONFIG_LOCATION|"
r"SteamAppId|SteamGameId|STEAM_COMPAT_DATA_PATH|WINE_LARGE_ADDRESS_AWARE)=")
class Fail(Exception):
pass
def libraries():
"""Steam library folders: the default one plus any in libraryfolders.vdf."""
libs = [STEAM]
try:
text = (STEAM / "steamapps/libraryfolders.vdf").read_text(errors="replace")
libs += [Path(p.replace("\\\\", "\\")) for p in re.findall(r'"path"\s+"([^"]+)"', text)]
except OSError:
pass
seen, out = set(), []
for lib in libs:
key = os.path.realpath(lib)
if key not in seen:
seen.add(key)
out.append(lib)
return out
def game(appid):
"""Where the game is installed and its Proton prefix, from its app manifest."""
for lib in libraries():
manifest = lib / f"steamapps/appmanifest_{appid}.acf"
try:
text = manifest.read_text(errors="replace")
except OSError:
continue
m = re.search(r'"installdir"\s+"([^"]+)"', text)
if not m:
continue
name = re.search(r'"name"\s+"([^"]*)"', text)
return {"name": name.group(1) if name else str(appid),
"dir": lib / "steamapps/common" / m.group(1),
"prefix": lib / f"steamapps/compatdata/{appid}/pfx"}
raise Fail(f"app {appid} isn't installed on this Frame")
def shipping_exe(game_dir):
"""The Unreal Engine game binary (…/Binaries/Win64/*-Win64-Shipping.exe), or None."""
found = [p for p in game_dir.glob("*/Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
found += [p for p in game_dir.glob("Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
return max(found, key=lambda p: p.stat().st_size) if found else None
def game_pid(exe_name):
"""The running Wine process for this .exe (its cmdline is the Windows path)."""
want = "\\" + exe_name.lower()
for d in Path("/proc").iterdir():
if not d.name.isdigit():
continue
try:
cmd = (d / "cmdline").read_bytes().split(b"\0")[0].decode(errors="replace").lower()
except OSError:
continue
if cmd.endswith(want):
return int(d.name)
return None
def loaded(pid, name):
try:
return any(line.rstrip().endswith("/" + name) for line in open(f"/proc/{pid}/maps"))
except OSError:
return False
def receipt_path(appid):
return RECEIPTS / f"{appid}-uevr.json"
def read_receipt(appid):
try:
return json.loads(receipt_path(appid).read_text())
except (OSError, ValueError):
return None
def managed_dir(g):
return g["prefix"] / "drive_c" / MANAGED
def user_dirs(g, exe):
"""What UEVR itself writes into the prefix when it first runs."""
user = g["prefix"] / "drive_c/users/steamuser/AppData"
return {"profile": user / "Roaming/UnrealVRMod" / exe.stem, # per-game settings and log.txt
"injector": user / "Local/praydog"} # the injector's own settings
def status(appid):
g = game(appid)
exe = shipping_exe(g["dir"])
r = read_receipt(appid)
pid = game_pid(exe.name) if exe else None
return {"appid": appid, "name": g["name"], "mod": "UEVR", "version": UEVR["version"],
"eligible": bool(exe), "exe": exe.name if exe else None,
"prefix": g["prefix"].is_dir(), "installed": bool(r),
"running": bool(pid), "injected": bool(pid and loaded(pid, "UEVRBackend.dll"))}
def fetch(url, digest, algo):
"""Download into the cache once, and check its hash every time it's used."""
CACHE.mkdir(parents=True, exist_ok=True)
path = CACHE / url.rsplit("/", 1)[1]
if not path.exists():
part = path.with_suffix(path.suffix + ".part")
try:
with urllib.request.urlopen(url, timeout=60) as r, open(part, "wb") as f:
shutil.copyfileobj(r, f, 1 << 20)
except OSError as e:
part.unlink(missing_ok=True)
raise Fail(f"download failed: {url}: {e}")
part.rename(path)
h = hashlib.new(algo)
with open(path, "rb") as f:
for block in iter(lambda: f.read(1 << 20), b""):
h.update(block)
if h.hexdigest() != digest:
path.unlink()
raise Fail(f"{path.name} doesn't match its published {algo}; deleted it, try again")
return path
def unpack(archive, dest, budget):
"""Extract a ZIP into dest, refusing anything that would land outside it."""
root = os.path.realpath(dest)
with zipfile.ZipFile(archive) as z:
for info in z.infolist():
target = os.path.realpath(os.path.join(dest, info.filename))
if target != root and not target.startswith(root + os.sep):
raise Fail(f"{archive.name} has an entry outside its folder: {info.filename}")
if (info.external_attr >> 16) & 0o170000 == 0o120000:
raise Fail(f"{archive.name} contains a symlink: {info.filename}")
budget -= info.file_size
if budget < 0:
raise Fail(f"{archive.name} unpacks to more than expected")
z.extractall(dest)
return budget
def install(appid):
g = game(appid)
exe = shipping_exe(g["dir"])
if not exe:
raise Fail(f"{g['name']} doesn't look like an Unreal Engine game (no *-Win64-Shipping.exe), so UEVR can't hook it")
if not g["prefix"].is_dir():
raise Fail(f"{g['name']} has no Proton prefix yet; play it once, then add the mod")
if game_pid(exe.name):
raise Fail(f"{g['name']} is running; quit it first")
if read_receipt(appid):
return {"message": f"UEVR {UEVR['version']} is already installed for {g['name']}", **status(appid)}
archives = [(fetch(UEVR["url"], UEVR["sha256"], "sha256"), UEVR["dir"])]
archives += [(fetch(url, digest, "sha512"), DOTNET["dir"]) for url, digest in DOTNET["files"]]
base = managed_dir(g)
dirs = user_dirs(g, exe)
fresh = [str(d) for d in (dirs["profile"].parent, base) if not d.exists()] # remove on uninstall if empty
stage = base / f".staging-{os.getpid()}"
shutil.rmtree(stage, ignore_errors=True)
try:
budget = MAX_UNPACKED
for archive, sub in archives:
budget = unpack(archive, stage / sub, budget)
if not (stage / UEVR["dir"] / "UEVRInjector.exe").is_file():
raise Fail("UEVR.zip has no UEVRInjector.exe")
for sub in (UEVR["dir"], DOTNET["dir"]):
shutil.rmtree(base / sub, ignore_errors=True)
(stage / sub).rename(base / sub)
finally:
shutil.rmtree(stage, ignore_errors=True)
if str(base) in fresh:
try:
base.rmdir() # only succeeds if the install failed and left it empty
except OSError:
pass
receipt = {"appid": appid, "mod": "UEVR", "version": UEVR["version"], "exe": exe.name,
"installed": int(time.time()), "prefix": str(g["prefix"]),
"remove": [str(base / UEVR["dir"]), str(base / DOTNET["dir"])],
# Parents that didn't exist yet (UEVR makes UnrealVRMod), deepest first.
"remove_if_empty": fresh,
# UEVR creates these on first injection. Remove them on uninstall
# only if they weren't there before, so earlier settings survive.
"remove_if_created": {k: str(v) for k, v in dirs.items() if not v.exists()},
"sources": [UEVR["url"]] + [u for u, _ in DOTNET["files"]]}
RECEIPTS.mkdir(parents=True, exist_ok=True)
tmp = receipt_path(appid).with_suffix(".tmp")
tmp.write_text(json.dumps(receipt, indent=1))
tmp.rename(receipt_path(appid))
return {"message": f"Installed UEVR {UEVR['version']} for {g['name']}", **status(appid)}
def x11_windows(display):
"""(id, name, width) for each named top-level window, via xwininfo."""
try:
out = subprocess.run(["xwininfo", "-root", "-tree"], env={**os.environ, "DISPLAY": display},
capture_output=True, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return []
return [(int(m[1], 16), m[2], int(m[3])) for m in re.finditer(r'(0x[0-9a-f]+) "([^"]*)":.*?\)\s+(\d+)x\d+', out)]
def x_click(display, window, points):
"""Send ButtonPress/Release straight to a window (gamescope blocks real input to it)."""
import ctypes as C
x11 = C.CDLL("libX11.so.6")
x11.XOpenDisplay.restype = C.c_void_p
class Button(C.Structure):
_fields_ = [("type", C.c_int), ("serial", C.c_ulong), ("send_event", C.c_int), ("display", C.c_void_p),
("window", C.c_ulong), ("root", C.c_ulong), ("subwindow", C.c_ulong), ("time", C.c_ulong),
("x", C.c_int), ("y", C.c_int), ("x_root", C.c_int), ("y_root", C.c_int),
("state", C.c_uint), ("button", C.c_uint), ("same_screen", C.c_int)]
class Event(C.Union):
_fields_ = [("xbutton", Button), ("pad", C.c_long * 24)]
d = x11.XOpenDisplay(display.encode())
if not d:
raise Fail(f"can't open X display {display}")
d = C.c_void_p(d)
root = x11.XDefaultRootWindow(d)
ox, oy, child = C.c_int(), C.c_int(), C.c_ulong()
x11.XTranslateCoordinates(d, C.c_ulong(window), C.c_ulong(root), 0, 0, C.byref(ox), C.byref(oy), C.byref(child))
for x, y in points:
for kind, mask, state in ((4, 1 << 2, 0), (5, 1 << 3, 1 << 8)): # press, then release with Button1 held
e = Event()
b = e.xbutton
b.type, b.send_event, b.window, b.root, b.x, b.y = kind, 1, window, root, x, y
b.x_root, b.y_root, b.state, b.button, b.same_screen = x + ox.value, y + oy.value, state, 1, 1
x11.XSendEvent(d, C.c_ulong(window), 1, C.c_long(mask), C.byref(e))
x11.XFlush(d)
time.sleep(0.15)
time.sleep(1.5)
x11.XCloseDisplay(d)
def frame_submits(appid):
"""SteamVR's count of frames the app has submitted, or None if it isn't a scene app."""
try:
stats = json.loads(subprocess.run([VRCMD, "--stats"], capture_output=True, text=True, timeout=15).stdout)
except (OSError, ValueError, subprocess.TimeoutExpired):
return None
return next((s.get("frame_submits") for s in stats if s.get("key") == f"steam.app.{appid}"), None)
def wait(what, seconds, check, step=2):
end = time.time() + seconds
while time.time() < end:
v = check()
if v:
return v
time.sleep(step)
raise Fail(f"timed out waiting for {what}")
def start(appid):
g = game(appid)
r = read_receipt(appid)
if not r:
raise Fail(f"UEVR isn't installed for {g['name']}")
exe = r["exe"]
pid = game_pid(exe)
if pid and loaded(pid, "UEVRBackend.dll"):
return {"message": f"UEVR is already running in {g['name']}", "frame_submits": frame_submits(appid), **status(appid)}
if not pid:
subprocess.Popen(["steam", f"steam://rungameid/{appid}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env={**os.environ, "DISPLAY": os.environ.get("DISPLAY", ":0")}, start_new_session=True)
pid = wait(f"{g['name']} to start", 120, lambda: game_pid(exe))
env = dict(line.split("=", 1) for line in open(f"/proc/{pid}/environ").read().split("\0")
if GAME_ENV.match(line))
display = env.get("DISPLAY")
if not display or "WINEPREFIX" not in env:
raise Fail(f"{exe} doesn't look like a Proton game process")
# The game needs its D3D11 window before UEVR can hook it. Proton's own
# helper windows ("Steam", "Default IME", …) are small.
wait("the game window", 90, lambda: any(w[2] >= 640 and w[1] != "UEVR" for w in x11_windows(display)))
time.sleep(10)
root = f"C:\\{MANAGED}\\{DOTNET['dir']}"
# Our own environment (HOME and the rest) under the game's Wine settings:
# with only the game's variables, fontconfig had no cache directory.
env = {**os.environ, **env, "DOTNET_ROOT": root, "DOTNET_ROOT_X64": root}
# The .NET injector sometimes dies at startup under Wine and FEX (an
# AccessViolationException, 2026-09-29), so give it a few tries.
for attempt in range(3):
before = {w[0] for w in x11_windows(display) if w[1] == "UEVR"}
log = open(CACHE / f"injector-{appid}.log", "w") if CACHE.is_dir() else subprocess.DEVNULL
injector = subprocess.Popen(["wine", f"C:\\{MANAGED}\\{UEVR['dir']}\\UEVRInjector.exe"], env=env,
stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True,
cwd=str(Path(r["prefix"]) / "drive_c" / MANAGED / UEVR["dir"]))
try:
win = wait("the UEVR injector window", 90,
lambda: injector.poll() is not None or next(
(w for w in x11_windows(display) if w[1] == "UEVR" and w[0] not in before), None))
except Fail:
os.killpg(injector.pid, signal.SIGKILL)
raise
if win is not True:
break
else:
raise Fail(f"the UEVR injector crashed on start {attempt + 1} times; "
f"its log is in ~/.cache/frame-control/mods/injector-{appid}.log")
try:
# It maps at a default size, then WPF lays it out (seen: 960 px, then 625).
try:
wait("the UEVR window's layout", 30,
lambda: any(w[0] == win[0] and w[2] == INJECTOR_WIDTH for w in x11_windows(display)), step=1)
except Fail:
width = next((w[2] for w in x11_windows(display) if w[0] == win[0]), "?")
raise Fail(f"the UEVR window is {width} px wide, not {INJECTOR_WIDTH}; "
"its layout changed, so not clicking blind") from None
time.sleep(5) # let it fill its process list
x_click(display, win[0], [CLICK_OPENVR, CLICK_INJECT])
try:
wait("UEVRBackend.dll to load in the game", 45, lambda: loaded(pid, "UEVRBackend.dll"))
except Fail:
raise Fail(f"UEVR didn't inject into {exe}. Its log is in ~/.cache/frame-control/mods/injector-{appid}.log") from None
except Fail:
os.killpg(injector.pid, signal.SIGKILL) # don't leave an injector window behind a failed start
raise
submits = None
try:
submits = wait("SteamVR frames", 30, lambda: frame_submits(appid))
except Fail:
pass
return {"message": f"UEVR injected into {g['name']}" + ("; SteamVR is receiving its frames" if submits else
"; SteamVR hasn't reported frames from it yet"),
"frame_submits": submits, **status(appid)}
def uninstall(appid):
r = read_receipt(appid)
if not r:
raise Fail(f"UEVR isn't installed for app {appid}")
if game_pid(r["exe"]):
raise Fail("the game is running; quit it first")
prefix = os.path.realpath(r["prefix"])
targets = list(r.get("remove", [])) + list(r.get("remove_if_created", {}).values())
for t in targets: # a receipt only ever points inside this game's prefix
real = os.path.realpath(t)
if not real.startswith(prefix + os.sep):
raise Fail(f"receipt lists a path outside the game's prefix: {t}")
for t in targets:
shutil.rmtree(t, ignore_errors=True)
for t in r.get("remove_if_empty", []):
if os.path.realpath(t).startswith(prefix + os.sep):
try:
os.rmdir(t)
except OSError:
pass # something else lives there now
receipt_path(appid).unlink()
if not any(RECEIPTS.glob("*-uevr.json")): # no other game uses the downloads
shutil.rmtree(CACHE, ignore_errors=True)
try:
RECEIPTS.rmdir()
except OSError:
pass
return {"message": "Removed UEVR", "appid": appid, "removed": targets}
def main(argv):
if len(argv) != 2 or argv[0] not in ("status", "install", "start", "uninstall") or not argv[1].isdigit():
raise Fail("usage: status|install|start|uninstall APPID")
return {"status": status, "install": install, "start": start, "uninstall": uninstall}[argv[0]](int(argv[1]))
if __name__ == "__main__":
try:
print(json.dumps(main(sys.argv[1:])))
except Fail as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
+58 -4
View File
@@ -254,10 +254,10 @@
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; } .and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
.and-col { display: grid; gap: 22px; align-content: start; } .and-col { display: grid; gap: 22px; align-content: start; }
.rep-item .s { white-space: normal; } .rep-item .s { white-space: normal; }
#repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #modDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); } padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop { background: rgba(0,0,0,.55); } #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #modDlg::backdrop { background: rgba(0,0,0,.55); }
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); } #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #modDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; } #repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; } #repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; #titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
@@ -666,6 +666,20 @@
<div class="actions"><button id="apkAltClose">Close</button></div> <div class="actions"><button id="apkAltClose">Close</button></div>
</dialog> </dialog>
<dialog id="modDlg" aria-labelledby="modTitle">
<h2 id="modTitle">Play in VR with UEVR</h2>
<p id="modState" class="sub"></p>
<p class="sub">UEVR by praydog turns many Unreal Engine games into VR games. Frame Control downloads
the official UEVR 1.05 release and Microsoft's .NET 6 runtime it needs (about 70 MB), checks their hashes,
and keeps them in this game's Proton prefix, not the game's folder. Remove takes them out again.</p>
<p class="sub">Tested on the Frame with one game so far. Whether a game looks and plays right in VR varies;
UEVR's own menu in the headset has the per-game settings.</p>
<div class="row rep-actions"><span class="sub" id="modMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="modClose">Close</button>
<button type="button" class="small" id="modRemove" hidden>Remove UEVR</button>
<button type="button" class="action small" id="modInstall" hidden>Install UEVR</button>
<button type="button" class="play small" id="modStart" hidden>▶ PLAY IN VR</button></div>
</dialog>
<dialog id="repDlg" aria-labelledby="repTitle"> <dialog id="repDlg" aria-labelledby="repTitle">
<form method="dialog" id="repForm"> <form method="dialog" id="repForm">
<h2 id="repTitle">Report an APK</h2> <h2 id="repTitle">Report an APK</h2>
@@ -1701,7 +1715,8 @@ function gameButtons(id, name, free) {
const open = `<button class="small" data-gm="store" data-id="${id}" data-name="${esc(name)}" title="Open the store page in the headset">Store on Frame</button>`; const open = `<button class="small" data-gm="store" data-id="${id}" data-name="${esc(name)}" title="Open the store page in the headset">Store on Frame</button>`;
if (d && d.appid === id) return `<span class="tag">${d.paused ? "Paused" : "Downloading"} ${d.percent ?? 0}%</span>`; if (d && d.appid === id) return `<span class="tag">${d.paused ? "Paused" : "Downloading"} ${d.percent ?? 0}%</span>`;
if (g && g.installed) return `<span class="tag">Installed</span>` + if (g && g.installed) return `<span class="tag">Installed</span>` +
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>`; `<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>` +
`<button class="small" data-mod="${id}" data-name="${esc(name)}" title="Flat-to-VR mod for Unreal Engine games">VR mod</button>`;
if (g) return `<button class="action small" data-gm="install" data-id="${id}" data-name="${esc(name)}">Install on Frame</button>`; if (g) return `<button class="action small" data-gm="install" data-id="${id}" data-name="${esc(name)}">Install on Frame</button>`;
return `<a href="${storeUrl}" target="_blank" rel="noopener" title="Opens in your browser">${free ? "Get" : "Buy"} on Steam ↗</a>${open}`; return `<a href="${storeUrl}" target="_blank" rel="noopener" title="Opens in your browser">${free ? "Get" : "Buy"} on Steam ↗</a>${open}`;
} }
@@ -1773,6 +1788,45 @@ document.body.addEventListener("click", async e => {
}); });
loadOwned(); loadOwned();
// ---- VR mod (UEVR) for one installed game; frame_mods.py does the work ----
const mod = { appid: null, name: "" };
function showMod(st, note) {
const busy = note === null;
$("modTitle").textContent = `${mod.name} in VR`;
$("modState").textContent = !st ? "Checking the game on the Frame…"
: !st.eligible ? "This doesn't look like an Unreal Engine game, so UEVR can't run it."
: !st.prefix ? "Play the game once first, so Steam sets up its Proton prefix."
: st.injected ? "UEVR is running in the game now. Put on the headset."
: st.installed ? `UEVR ${st.version} is installed for this game.${st.running ? " The game is running; Play in VR adds UEVR to it." : ""}`
: `UEVR ${st.version} isn't installed for this game yet.`;
$("modMsg").textContent = busy ? "Working… this can take a few minutes" : note || "";
const ok = st && st.eligible && st.prefix;
$("modInstall").hidden = !ok || st.installed; $("modStart").hidden = !ok || !st.installed || st.injected;
$("modRemove").hidden = !st || !st.installed;
for (const b of ["modInstall", "modStart", "modRemove"]) $(b).disabled = busy || (b === "modRemove" && st && st.running);
}
async function modAction(action, label) {
const appid = mod.appid;
showMod(mod.st, null);
const res = await act(`${label} for ${mod.name}`, () => api("/api/mods", { action, appid }));
if (appid !== mod.appid) return;
try { mod.st = await api("/api/mods", { action: "status", appid }); } catch {}
showMod(mod.st, res ? res.message : "Didn't work; the log has the reason");
}
document.body.addEventListener("click", async e => {
const b = e.target.closest("[data-mod]"); if (!b) return;
mod.appid = b.dataset.mod; mod.name = b.dataset.name; mod.st = null;
showMod(null, "");
$("modDlg").showModal();
const appid = mod.appid;
try { const st = await api("/api/mods", { action: "status", appid }); if (appid === mod.appid) { mod.st = st; showMod(st, ""); } }
catch (err) { if (appid === mod.appid) $("modState").textContent = err.message; }
});
$("modInstall").onclick = () => modAction("install", "Install UEVR");
$("modStart").onclick = () => modAction("start", "Start UEVR");
$("modRemove").onclick = () => modAction("uninstall", "Remove UEVR");
$("modClose").onclick = () => $("modDlg").close();
// ---- Android apps (own Lepton instance each) + catalogue ---- // ---- Android apps (own Lepton instance each) + catalogue ----
const VLABEL = { works: "Works on Frame", likely: "Should work", maybe: "Might work", unlikely: "Probably crashes", no: "Won't work" }; const VLABEL = { works: "Works on Frame", likely: "Should work", maybe: "Might work", unlikely: "Probably crashes", no: "Won't work" };
const cat = { apps: null, q: "", cat: "", v: new Set(["works", "likely"]), shown: 0, results: [] }; const cat = { apps: null, q: "", cat: "", v: new Set(["works", "likely"]), shown: 0, results: [] };
+20 -5
View File
@@ -407,13 +407,14 @@ def launch(body):
return {"message": f"Launching {appid}"} return {"message": f"Launching {appid}"}
def steam_frame(*args, timeout=40): def steam_frame(*args, timeout=40, script="frame_steam.py"):
"""Run frame_steam.py on the Frame (it drives the Steam client) and return its JSON.""" """Run frame_steam.py (it drives the Steam client), or another on-Frame
helper with the same JSON contract, on the Frame and return its JSON."""
try: try:
out = ssh("python3 - " + " ".join(map(shlex.quote, args)), out = ssh("python3 - " + " ".join(map(shlex.quote, args)),
stdin=(HERE / "frame_steam.py").read_text(), timeout=timeout) stdin=(HERE / script).read_text(), timeout=timeout)
except Failure as e: except Failure as e:
# frame_steam.py prints {"error": ...} on stdout when it fails, but ssh() # The helper prints {"error": ...} on stdout when it fails, but ssh()
# reports stderr instead if there was any, so look in both. # reports stderr instead if there was any, so look in both.
for line in [*reversed(getattr(e, "stdout", "").splitlines()), *reversed(str(e).splitlines())]: for line in [*reversed(getattr(e, "stdout", "").splitlines()), *reversed(str(e).splitlines())]:
try: try:
@@ -434,6 +435,20 @@ def steam(body):
return steam_frame(action, appid) return steam_frame(action, appid)
# Downloads are about 70 MB; starting waits for the game, the injector and SteamVR.
MOD_TIMEOUT = {"status": 40, "install": 600, "start": 360, "uninstall": 60}
def mods(body):
"""UEVR for one installed Unreal game: status, install, start (inject) or uninstall."""
appid, action = str(body.get("appid", "")), body.get("action")
if not APPID.match(appid):
raise Failure("bad appid", 400)
if action not in MOD_TIMEOUT:
raise Failure("action must be " + ", ".join(MOD_TIMEOUT), 400)
return steam_frame(action, appid, timeout=MOD_TIMEOUT[action], script="frame_mods.py")
def steam_search(query): def steam_search(query):
q = parse_qs(query) q = parse_qs(query)
cc = (q.get("cc") or [""])[0].upper() cc = (q.get("cc") or [""])[0].upper()
@@ -1227,7 +1242,7 @@ def _sweep_one(prefix, d):
pass pass
POST = {"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, POST = {"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/mods": mods, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots, "/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start, "/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel} "/api/webinstall/cancel": webinstall_cancel}