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):