From e3176a2de8999c3bfaf1dec1cfdcbd04bcbc4f9c Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:53:28 +1000 Subject: [PATCH] UEVR: inject with Frame Control's own script instead of UEVR's GUI injector UEVR's .NET injector, driven with XSendEvent, injected in only 6 of 8 cold starts on the Frame: in bad sessions every new injector ignored input or never showed a window. start() now runs frame_inject.py under python.org's embeddable Python (pinned SHA-256) in the game's Wine session, repeating the frontend's own Inject steps: nullify VR plugins, load openvr_api.dll, set Frontend_RequestedRuntime, load UEVRBackend.dll. Verified 9/9 cold starts in 33-35 s and a clean UI install/play/remove cycle. No .NET download any more (47 MB installed instead of about 190 MB). Also from the SWE-2 Max review: one mod action at a time (flock), a download size cap, errors always reported as JSON, a receipt can't name another app's prefix, 0 frames counts as an answer, and a longer server timeout for start. Part of #26. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/evidence/mods-2026-09-29.md | 37 +++- docs/img/uevr-dialog.png | Bin 34646 -> 33395 bytes docs/mods.md | 67 ++++--- tests/test_mods.py | 135 +++++++++----- ui/frame_mods.py | 303 ++++++++++++++++++------------- ui/index.html | 5 +- ui/server.py | 2 +- 7 files changed, 348 insertions(+), 201 deletions(-) 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 42849222ea671aab47b33cdeaf780e33f5e4e8ed..18876d717156cbb5e53308ff9ed40c38ecc936a2 100644 GIT binary patch literal 33395 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%0Ah4M@c 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 literal 34646 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 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):