diff --git a/docs/evidence/mods-2026-09-29.md b/docs/evidence/mods-2026-09-29.md
index 0015ec1..b94c8b8 100644
--- a/docs/evidence/mods-2026-09-29.md
+++ b/docs/evidence/mods-2026-09-29.md
@@ -4,7 +4,8 @@
[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 % →
+`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40 and
+12:44–12:52. Battery 45 % →
93 %, on charge throughout.
**Not observed:** the VR image, head tracking and controller input. SteamVR
@@ -69,10 +70,42 @@ page's **VR mod** dialog, driven with a headless Chromium:
| 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 |
+## The GUI injector wasn't reliable (12:02–12:40)
+
+After review fixes, cold starts through the API with UEVR's own injector:
+
+- Batches of 4, 6 and 8 runs: 3/4, 4/6 and 6/8 injected. A retry that
+ relaunched the injector didn't rescue a bad session. In both failures of
+ the last batch, the first injector ignored the clicks and the next two
+ never showed a window within 60 s.
+- Captures of the ignoring injector (`xwd`) showed its first-drawn frame,
+ with OpenVR still unselected. The injector's log had no error.
+
+Lock released at 12:40 before redesigning.
+
+## Frame Control's own injector (12:44–12:52)
+
+`start` now runs `frame_inject.py` under python.org's embeddable Python
+3.14.7 (SHA-256 `d297e5ff…1f15`, matching python.org's release-file API), in
+the game's Wine session. The old install was removed and the new one
+installed without the headset lock, since neither launches anything. The
+prefix listing after removal matched the pre-install state; the new install
+is 47 MB.
+
+| Step | Result |
+|---|---|
+| API `start`, cold | Injected in 35 s, `frame_submits` 39. UEVR log: `Hooked DirectX 11`, `Framework initialized`, `Requested runtime: openvr_api.dll`; `config.txt` has `Frontend_RequestedRuntime=openvr_api.dll` |
+| API `start` ×8, cold | **8/8** injected, 33–34 s each; `frame_submits` 33–42 at the check |
+| UI: dialog while running | "UEVR is running in the game now." Remove disabled (screenshot in `docs/img/uevr-dialog.png`) |
+| UI: Remove → Install → Play in VR → Remove | Each worked; Play in VR injected (`frame_submits` 106 a few seconds later) |
+| After the last Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipts gone; no game or `python.exe` processes. The empty `/tmp/frame-control-mods.lock` was deleted by hand (tmpfs) |
+
+Battery 98–100 % on charge. Lock released at 12:52.
+
## 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.
+- The test lock was released at 12:52.
diff --git a/docs/img/uevr-dialog.png b/docs/img/uevr-dialog.png
index 4284922..18876d7 100644
Binary files a/docs/img/uevr-dialog.png and b/docs/img/uevr-dialog.png differ
diff --git a/docs/mods.md b/docs/mods.md
index c25aed6..d6e7f91 100644
--- a/docs/mods.md
+++ b/docs/mods.md
@@ -26,7 +26,7 @@ means an upstream source describes it; “inferred” means it still needs a tes
| 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. |
-| 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. |
+| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Nine cold **Play in VR** runs (33–35 s each), then the full cycle again from the app's UI, all loaded `UEVRBackend.dll` into the game, with UEVR logging `Hooked DirectX 11` and `Requested runtime: openvr_api.dll`. 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, 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.** 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. |
@@ -56,41 +56,46 @@ like `frame_steam.py`.
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 `/drive_c/frame-control/`. Nothing goes into the
+ and python.org's [Windows x64 embeddable Python 3.14.7](https://www.python.org/downloads/release/python-3147/).
+ Each is checked against a pinned SHA-256; Python's comes from python.org's
+ release-file API. Archives are unpacked into a staging folder, rejecting
+ entries that escape it, symlinks and oversized contents, then moved into
+ `/drive_c/frame-control/` (about 47 MB). 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.
+ exist before. Install and remove refuse while the game is running, and only
+ one mod action runs at a time.
- **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`).
+ waits for its window. It then runs a short script under that Python, with
+ the game's Proton, prefix and Wine settings read from the game process, so
+ both share one Wine session. The script repeats what UEVR's own injector does
+ when you press Inject with OpenVR chosen: load `UEVRPluginNullifier.dll` and
+ call its `nullify`, load `openvr_api.dll`, set `Frontend_RequestedRuntime` in
+ UEVR's per-game `config.txt`, then load `UEVRBackend.dll`. Each load is a
+ `LoadLibraryW` remote thread, as in the frontend's `Injector.cs`. `start`
+ then checks 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):
+Why not UEVR's own injector (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=` 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.
+- It's a .NET 6 WPF app, so it needs Microsoft's .NET runtimes (about 70 MB
+ of downloads) in the prefix.
+- Under gamescope, the full-screen game keeps the pointer and focus. XTest
+ clicks, raising or refocusing the injector window don't change that, so in
+ the headset you can't click it. Sending X `ButtonPress`/`ButtonRelease`
+ events straight to its window does work.
+- `--attach=` didn't inject under Wine, and it didn't restore its
+ saved OpenVR choice.
+- Driven that way, it injected in 6 of 8 cold starts. In a bad session every
+ new injector ignored input or never showed its window, even after
+ relaunching. Its process list reads each process's main window title, a
+ cross-process window message; a window that isn't answering would block its
+ UI thread (inferred from the frontend's source, not proven). Without a
+ `HOME` it also died in .NET at startup.
+- Frame Control's own script injected in 9 of 9 cold starts, in 33–35 s.
- 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.
@@ -152,7 +157,11 @@ same, and none of it is built for them:
- [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05)
and [author's usage instructions](https://github.com/praydog/UEVR#getting-started).
- [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json),
- including the SHA-512 hashes used for the test runtimes.
+ including the SHA-512 hashes used for the injector tests.
+- [UEVR frontend source](https://github.com/praydog/UEVR-Frontend): `UEVR/MainWindow.xaml.cs`
+ (`Inject_Clicked`) and `UEVR/Injector.cs`, the steps `frame_inject.py` follows.
+- [python.org release-file API](https://www.python.org/api/v2/downloads/release_file/) for the
+ embeddable Python's `sha256_sum`.
- [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
including per-game installation and the need to preserve original DLLs.
- [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
diff --git a/tests/test_mods.py b/tests/test_mods.py
index b4b5e41..b0ad78f 100644
--- a/tests/test_mods.py
+++ b/tests/test_mods.py
@@ -64,17 +64,15 @@ class FakeLibrary(unittest.TestCase):
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.downloads[frame_mods.PYTHON["url"]] = zip_bytes({"python.exe": b"MZ", "python314.zip": b"PK"})
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)
+ for name in ("UEVR", "PYTHON"):
+ pinned = getattr(frame_mods, name)
+ p = mock.patch.object(frame_mods, name,
+ dict(pinned, sha256=hashlib.sha256(downloads[pinned["url"]]).hexdigest()))
p.start()
self.addCleanup(p.stop)
p = mock.patch.object(frame_mods.urllib.request, "urlopen",
@@ -109,8 +107,9 @@ class InstallUninstall(FakeLibrary):
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.assertTrue((self.managed() / "uevr-1.05/UEVRBackend.dll").is_file())
+ self.assertTrue((self.managed() / "python-3.14.7/python.exe").is_file())
+ self.assertIn("UEVRBackend.dll", (self.managed() / "python-3.14.7/frame_inject.py").read_text())
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"
@@ -166,9 +165,19 @@ class InstallUninstall(FakeLibrary):
self.assertTrue((self.managed() / "uevr-1.05").is_dir(), "nothing removed when the receipt is bad")
+ def test_receipt_cannot_name_another_prefix(self):
+ frame_mods.install(APPID)
+ r = frame_mods.read_receipt(APPID)
+ r["prefix"] = "/"
+ frame_mods.receipt_path(APPID).write_text(json.dumps(r))
+ with self.assertRaisesRegex(frame_mods.Fail, "isn't app"):
+ frame_mods.uninstall(APPID)
+ self.assertTrue((self.managed() / "uevr-1.05").is_dir())
+
+
class Downloads(FakeLibrary):
def test_hash_mismatch_deletes_and_installs_nothing(self):
- self.downloads[frame_mods.UEVR["url"]] = zip_bytes({"UEVRInjector.exe": b"tampered"})
+ self.downloads[frame_mods.UEVR["url"]] = zip_bytes({"UEVRBackend.dll": b"tampered"})
with self.assertRaisesRegex(frame_mods.Fail, "doesn't match"):
frame_mods.install(APPID)
self.assertFalse((frame_mods.CACHE / "UEVR.zip").exists())
@@ -176,8 +185,8 @@ class Downloads(FakeLibrary):
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"])):
+ for bad in (zip_bytes({"UEVRBackend.dll": b"MZ", "../../evil.dll": b"MZ"}),
+ zip_bytes({"UEVRBackend.dll": 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):
@@ -193,52 +202,88 @@ class Downloads(FakeLibrary):
frame_mods.install(APPID)
+ def test_oversized_download_is_stopped(self):
+ with mock.patch.object(frame_mods, "MAX_DOWNLOAD", 10):
+ with self.assertRaisesRegex(frame_mods.Fail, "bigger than expected"):
+ frame_mods.install(APPID)
+ self.assertFalse(list(frame_mods.CACHE.glob("*")))
+
+
+class Actions(FakeLibrary):
+ def test_one_change_at_a_time(self):
+ lock = Path(tempfile.mkdtemp()) / "mods.lock"
+ with mock.patch.object(frame_mods, "LOCK", lock), open(lock, "w") as held:
+ frame_mods.fcntl.flock(held, frame_mods.fcntl.LOCK_EX)
+ with self.assertRaisesRegex(frame_mods.Fail, "another mod action"):
+ frame_mods.main(["install", str(APPID)])
+ self.assertFalse(frame_mods.status(APPID)["installed"])
+ self.assertEqual(frame_mods.main(["status", str(APPID)])["appid"], APPID, "status never waits")
+
+ def test_zero_frames_is_an_answer(self):
+ stats = json.dumps([{"key": f"steam.app.{APPID}", "frame_submits": 0}, {"operation": "status"}])
+ with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=stats)):
+ self.assertEqual(frame_mods.wait("frames", 5, lambda: frame_mods.frame_submits(APPID)), 0)
+ with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout='{"odd": 1}')):
+ self.assertIsNone(frame_mods.frame_submits(APPID))
+
+
class Start(FakeLibrary):
+ def environ(self, text):
+ """The game's /proc//environ; every other file reads normally."""
+ real = frame_mods.Path.read_text
+ return mock.patch.object(frame_mods.Path, "read_text", autospec=True, side_effect=lambda path, *a, **k:
+ text if str(path).startswith("/proc/") else real(path, *a, **k))
+
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):
+ def run_start(self, reply, loaded=(False, True, True, True)):
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), \
+ with self.environ(env), \
+ mock.patch.object(frame_mods, "loaded", side_effect=list(loaded) + [True] * 5), \
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.subprocess, "run", return_value=mock.Mock(stdout=reply, stderr="")) as run, \
+ mock.patch.object(frame_mods, "frame_submits", return_value=7), \
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()
+ return frame_mods.start(APPID), run
+
+ def test_injects_with_the_games_wine_settings(self):
+ res, run = self.run_start('noise\n{"pid": 320}\n')
+ self.assertEqual((res["frame_submits"], res["message"]), (7, "UEVR injected into Gravitas; SteamVR is receiving its frames"))
+ cmd, env = run.call_args.args[0], run.call_args.kwargs["env"]
+ self.assertEqual(cmd[:2], ["wine", "C:\\frame-control\\python-3.14.7\\python.exe"])
+ self.assertEqual(cmd[3:], ["Drop-Win64-Shipping.exe", "C:\\frame-control\\uevr-1.05",
+ "C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\Drop-Win64-Shipping"])
+ self.assertEqual(env["WINEPREFIX"], "/x")
+ self.assertNotIn("WINESERVERSOCKET", env, "an inherited fd number means nothing to a new process")
+ self.assertIn("HOME", env, "fontconfig needs a HOME for its cache")
+
+ def test_reports_the_injection_error(self):
+ with self.assertRaisesRegex(frame_mods.Fail, "couldn't open"):
+ self.run_start('{"error": "couldn\'t open Drop-Win64-Shipping.exe (5)"}\n')
+
+ def test_success_is_checked_in_the_game(self):
+ with self.assertRaisesRegex(frame_mods.Fail, "has no UEVRBackend.dll"):
+ with mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)):
+ self.run_start('{"pid": 320}\n', loaded=[False] * 40)
+
+ def test_inject_script_is_valid_python(self):
+ compile(frame_mods.INJECT_PY, "frame_inject.py", "exec")
class Helper(unittest.TestCase):
+ def test_unexpected_errors_are_still_json(self):
+ # A receipt from another version, without "exe": a KeyError, not a traceback.
+ script = (ROOT / "ui" / "frame_mods.py").read_text().replace(
+ "def read_receipt(appid):", "def read_receipt(appid):\n return {'prefix': '/x'}\n\n\ndef _unused(appid):")
+ out = subprocess.run([sys.executable, "-", "uninstall", "1"], input=script,
+ capture_output=True, text=True, timeout=30, env={"HOME": tempfile.mkdtemp()})
+ self.assertEqual(out.returncode, 1, out.stderr)
+ self.assertIn("KeyError", json.loads(out.stdout)["error"])
+
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],
diff --git a/ui/frame_mods.py b/ui/frame_mods.py
index f4db415..1aebbf7 100644
--- a/ui/frame_mods.py
+++ b/ui/frame_mods.py
@@ -8,22 +8,26 @@ Usage: python3 - status APPID # can UEVR run here, is it installed, is the g
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).
+ARM64 and SteamVR 2.18.1 with Gravitas (1067310): after injection the game has
+UEVRBackend.dll loaded 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 /drive_c/frame-control,
-never in the game's own folder, and a receipt records what to remove.
+UEVR's own injector (a .NET WPF app) is not used. Under Wine and FEX it hung
+or ignored input in about one session in four, and gamescope keeps real input
+away from it. Instead the official Windows embeddable Python runs INJECT_PY in
+the game's Wine session, doing what the injector does on Inject.
+
+Nothing here is bundled. UEVR comes from praydog's GitHub release and Python
+from python.org, each checked against a pinned hash. Files live in
+/drive_c/frame-control, never in the game's own folder, and a receipt
+records what to remove.
"""
+import fcntl
import hashlib
import json
import os
import re
import shutil
-import signal
import subprocess
import sys
import time
@@ -35,35 +39,122 @@ HOME = Path.home()
STEAM = HOME / ".local/share/Steam"
CACHE = HOME / ".cache/frame-control/mods"
RECEIPTS = HOME / ".local/share/frame-control/mods"
+LOCK = Path("/tmp/frame-control-mods.lock") # tmpfs: gone at reboot, never left in ~
VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd"
MANAGED = "frame-control" # drive_c/ 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
+# Windows x64 embeddable Python, to run INJECT_PY. Hash from python.org's
+# release-file API (sha256_sum).
+PYTHON = {"version": "3.14.7", "dir": "python-3.14.7",
+ "url": "https://www.python.org/ftp/python/3.14.7/python-3.14.7-embed-amd64.zip",
+ "sha256": "d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15"}
+MAX_UNPACKED = 200 << 20 # the two archives unpack to about 50 MB
+MAX_DOWNLOAD = 50 << 20 # each archive is 7–13 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
+# The game's environment a second program 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)=")
+# Runs under Windows Python in the game's Wine session: the steps of UEVR
+# 1.05's frontend (UEVR/MainWindow.xaml.cs Inject_Clicked, UEVR/Injector.cs)
+# with "Nullify VR plugins" on and the OpenVR runtime. Prints one JSON line.
+INJECT_PY = r"""
+import ctypes, json, os, sys
+from ctypes import wintypes as W
+exe, uevr, profile = sys.argv[1:4]
+k32 = ctypes.WinDLL("kernel32", use_last_error=True)
+k32.OpenProcess.restype = W.HANDLE
+k32.VirtualAllocEx.restype = ctypes.c_void_p
+k32.VirtualAllocEx.argtypes = [W.HANDLE, ctypes.c_void_p, ctypes.c_size_t, W.DWORD, W.DWORD]
+k32.WriteProcessMemory.argtypes = [W.HANDLE, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p]
+k32.CreateRemoteThread.restype = W.HANDLE
+k32.CreateRemoteThread.argtypes = [W.HANDLE, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p,
+ W.DWORD, ctypes.c_void_p]
+k32.GetModuleHandleW.restype = W.HMODULE
+k32.GetProcAddress.restype = ctypes.c_void_p
+k32.GetProcAddress.argtypes = [W.HMODULE, ctypes.c_char_p]
+k32.LoadLibraryW.restype = W.HMODULE
+k32.CreateToolhelp32Snapshot.restype = W.HANDLE
+
+class Entry(ctypes.Structure): # PROCESSENTRY32W
+ _fields_ = [("size", W.DWORD), ("usage", W.DWORD), ("pid", W.DWORD), ("heap", ctypes.c_void_p),
+ ("module", W.DWORD), ("threads", W.DWORD), ("parent", W.DWORD), ("prio", ctypes.c_long),
+ ("flags", W.DWORD), ("name", W.WCHAR * 260)]
+
+class Module(ctypes.Structure): # MODULEENTRY32W
+ _fields_ = [("size", W.DWORD), ("mid", W.DWORD), ("pid", W.DWORD), ("glbl", W.DWORD), ("proc", W.DWORD),
+ ("base", ctypes.c_void_p), ("bytes", W.DWORD), ("handle", W.HMODULE),
+ ("name", W.WCHAR * 256), ("path", W.WCHAR * 260)]
+
+def fail(msg):
+ print(json.dumps({"error": msg})); sys.exit(1)
+
+def find_pid():
+ snap = k32.CreateToolhelp32Snapshot(2, 0) # TH32CS_SNAPPROCESS
+ e = Entry(); e.size = ctypes.sizeof(e)
+ ok = k32.Process32FirstW(snap, ctypes.byref(e))
+ while ok:
+ if e.name.lower() == exe.lower():
+ k32.CloseHandle(snap); return e.pid
+ ok = k32.Process32NextW(snap, ctypes.byref(e))
+ k32.CloseHandle(snap)
+
+def module_base(pid, path):
+ snap = k32.CreateToolhelp32Snapshot(0x18, pid) # TH32CS_SNAPMODULE | SNAPMODULE32
+ m = Module(); m.size = ctypes.sizeof(m)
+ ok = k32.Module32FirstW(snap, ctypes.byref(m))
+ while ok:
+ if m.path.lower() == path.lower():
+ k32.CloseHandle(snap); return m.base
+ ok = k32.Module32NextW(snap, ctypes.byref(m))
+ k32.CloseHandle(snap)
+
+def remote_call(proc, fn, arg=None, wait_ms=10000):
+ t = k32.CreateRemoteThread(proc, None, 0, fn, arg, 0, None)
+ if not t:
+ fail(f"CreateRemoteThread failed ({ctypes.get_last_error()})")
+ k32.WaitForSingleObject(t, wait_ms); k32.CloseHandle(t)
+
+def inject(proc, pid, name):
+ path = os.path.join(uevr, name)
+ data = ctypes.create_unicode_buffer(path)
+ mem = k32.VirtualAllocEx(proc, None, ctypes.sizeof(data), 0x3000, 0x04) # commit|reserve, read/write
+ if not mem or not k32.WriteProcessMemory(proc, mem, data, ctypes.sizeof(data), None):
+ fail(f"couldn't write into {exe} ({ctypes.get_last_error()})")
+ remote_call(proc, k32.GetProcAddress(k32.GetModuleHandleW("kernel32.dll"), b"LoadLibraryW"), mem)
+ base = module_base(pid, path)
+ if not base:
+ fail(f"{name} didn't load into {exe}")
+ return path, base
+
+pid = find_pid()
+if not pid:
+ fail(f"{exe} isn't running")
+proc = k32.OpenProcess(0x1F0FFF, False, pid) # PROCESS_ALL_ACCESS
+if not proc:
+ fail(f"couldn't open {exe} ({ctypes.get_last_error()})")
+path, base = inject(proc, pid, "UEVRPluginNullifier.dll")
+local = k32.LoadLibraryW(path)
+offset = k32.GetProcAddress(local, b"nullify") - local # same DLL, same layout in both processes
+remote_call(proc, base + offset, wait_ms=2000)
+inject(proc, pid, "openvr_api.dll")
+# The frontend saves the runtime choice in the per-game config before UEVRBackend reads it.
+os.makedirs(profile, exist_ok=True)
+cfg = os.path.join(profile, "config.txt")
+lines = open(cfg).read().splitlines() if os.path.exists(cfg) else []
+lines = [l for l in lines if not l.startswith("Frontend_RequestedRuntime=")] + ["Frontend_RequestedRuntime=openvr_api.dll"]
+open(cfg, "w").write("\n".join(lines) + "\n")
+inject(proc, pid, "UEVRBackend.dll")
+print(json.dumps({"pid": pid}))
+"""
+
+
class Fail(Exception):
pass
@@ -173,8 +264,11 @@ def fetch(url, digest, algo):
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:
+ for block in iter(lambda: r.read(1 << 20), b""):
+ if f.tell() + len(block) > MAX_DOWNLOAD:
+ raise Fail(f"{url} is bigger than expected; stopped the download")
+ f.write(block)
+ except (OSError, Fail) as e:
part.unlink(missing_ok=True)
raise Fail(f"download failed: {url}: {e}")
part.rename(path)
@@ -217,7 +311,7 @@ def install(appid):
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"]]
+ archives.append((fetch(PYTHON["url"], PYTHON["sha256"], "sha256"), PYTHON["dir"]))
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
@@ -227,9 +321,13 @@ def install(appid):
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"]):
+ for need in (stage / UEVR["dir"] / "UEVRBackend.dll", stage / PYTHON["dir"] / "python.exe"):
+ if not need.is_file():
+ raise Fail(f"the download has no {need.name}")
+ if game_pid(exe.name): # it may have started during the download
+ raise Fail(f"{g['name']} started; quit it first")
+ (stage / PYTHON["dir"] / "frame_inject.py").write_text(INJECT_PY)
+ for sub in (UEVR["dir"], PYTHON["dir"]):
shutil.rmtree(base / sub, ignore_errors=True)
(stage / sub).rename(base / sub)
finally:
@@ -241,13 +339,13 @@ def install(appid):
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"])],
+ "remove": [str(base / UEVR["dir"]), str(base / PYTHON["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"]]}
+ "sources": [UEVR["url"], PYTHON["url"]]}
RECEIPTS.mkdir(parents=True, exist_ok=True)
tmp = receipt_path(appid).with_suffix(".tmp")
tmp.write_text(json.dumps(receipt, indent=1))
@@ -265,55 +363,22 @@ def x11_windows(display):
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)
+ if not isinstance(stats, list):
+ return None
+ return next((s.get("frame_submits") for s in stats if isinstance(s, dict) and 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:
+ if v is not None and v is not False: # 0 frames is still an answer
return v
time.sleep(step)
raise Fail(f"timed out waiting for {what}")
@@ -332,64 +397,43 @@ def start(appid):
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
+ environ = Path(f"/proc/{pid}/environ").read_text(errors="replace")
+ except OSError:
+ raise Fail(f"{exe} exited") from None
+ game_env = dict(line.split("=", 1) for line in environ.split("\0") if GAME_ENV.match(line))
+ display = game_env.get("DISPLAY")
+ if not display or "WINEPREFIX" not in game_env:
+ raise Fail(f"{exe} doesn't look like a Proton game process")
+ # UEVR hooks the game's D3D11/12 device, so wait for its window. Proton's
+ # own helper windows ("Steam", "Default IME", …) are small.
+ wait("the game window", 90, lambda: any(w[2] >= 640 for w in x11_windows(display)))
+ time.sleep(10)
+ # Our own environment (HOME and the rest) under the game's Wine settings.
+ env = {**os.environ, **game_env}
+ base = f"C:\\{MANAGED}"
+ profile = f"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\{Path(exe).stem}"
+ try:
+ out = subprocess.run(["wine", f"{base}\\{PYTHON['dir']}\\python.exe", f"{base}\\{PYTHON['dir']}\\frame_inject.py",
+ exe, f"{base}\\{UEVR['dir']}", profile], env=env, capture_output=True, text=True,
+ timeout=120, stdin=subprocess.DEVNULL,
+ cwd=str(Path(r["prefix"]) / "drive_c" / MANAGED))
+ except subprocess.TimeoutExpired:
+ raise Fail("the injection step didn't finish in 2 minutes") from None
+ reply = next((json.loads(l) for l in reversed(out.stdout.splitlines()) if l.startswith("{")), None)
+ if not reply or "error" in reply:
+ raise Fail("UEVR injection failed: " + (reply or {}).get("error", (out.stderr.strip().splitlines() or ["no output"])[-1]))
+ try:
+ wait("UEVRBackend.dll to load in the game", 20, lambda: loaded(pid, "UEVRBackend.dll"))
except Fail:
- os.killpg(injector.pid, signal.SIGKILL) # don't leave an injector window behind a failed start
- raise
+ raise Fail(f"the injection reported success but {exe} has no UEVRBackend.dll") from None
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"),
+ return {"message": f"UEVR injected into {g['name']}" + ("; SteamVR is receiving its frames" if submits is not None
+ else "; SteamVR hasn't reported frames from it yet"),
"frame_submits": submits, **status(appid)}
@@ -400,6 +444,8 @@ def uninstall(appid):
if game_pid(r["exe"]):
raise Fail("the game is running; quit it first")
prefix = os.path.realpath(r["prefix"])
+ if not prefix.endswith(f"/steamapps/compatdata/{appid}/pfx"):
+ raise Fail(f"receipt names a prefix that isn't app {appid}'s: {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)
@@ -426,7 +472,17 @@ def uninstall(appid):
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]))
+ action = {"status": status, "install": install, "start": start, "uninstall": uninstall}[argv[0]]
+ if action is status:
+ return status(int(argv[1]))
+ # One change at a time, or an uninstall could delete what an install is moving in.
+ LOCK.parent.mkdir(parents=True, exist_ok=True)
+ with open(LOCK, "w") as lock:
+ try:
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError:
+ raise Fail("another mod action is still running on the Frame; try again when it finishes") from None
+ return action(int(argv[1]))
if __name__ == "__main__":
@@ -435,3 +491,6 @@ if __name__ == "__main__":
except Fail as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
+ except Exception as e: # still one JSON object, for the server to show
+ print(json.dumps({"error": f"{type(e).__name__}: {e}"}))
+ sys.exit(1)
diff --git a/ui/index.html b/ui/index.html
index 3cacd4e..4990255 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -670,8 +670,9 @@
Play in VR with UEVR
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.
+ the official UEVR 1.05 release and python.org's Windows Python, which it uses to load UEVR into the game
+ (about 20 MB), checks their hashes, and keeps them in this game's Proton prefix, not the game's folder.
+ Remove takes them out again.
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.
diff --git a/ui/server.py b/ui/server.py
index 052ac31..711d390 100755
--- a/ui/server.py
+++ b/ui/server.py
@@ -436,7 +436,7 @@ def steam(body):
# Downloads are about 70 MB; starting waits for the game, the injector and SteamVR.
-MOD_TIMEOUT = {"status": 40, "install": 600, "start": 360, "uninstall": 60}
+MOD_TIMEOUT = {"status": 40, "install": 600, "start": 900, "uninstall": 60}
def mods(body):