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) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-29 12:53:28 +10:00
1 parent 416f3181cc
commit e3176a2de8
7 files changed
+348 -201

No files matched your search

+35 -2
View File
@@ -4,7 +4,8 @@
[2026-09-28](mods-2026-09-28.md): aarch64, SteamOS `0.4.1`, BUILD_ID [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 `20260925.6191901`, Proton `11.0-2c` ARM64, SteamVR `2.18.1`. Times are the
Frame's AEST clock. Headset tests ran under the shared 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. 93 %, on charge throughout.
**Not observed:** the VR image, head tracking and controller input. SteamVR **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: 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 | | 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 ## Left on the Frame
- Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still - Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still
installed; nothing else from this work is left. installed; nothing else from this work is left.
- No `UEVRInjector.exe` or game processes were left running. Steam and - No `UEVRInjector.exe` or game processes were left running. Steam and
SteamVR were running at the end. SteamVR were running at the end.
- The test lock was released at 11:49. - The test lock was released at 12:52.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+38 -29
View File
@@ -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 | | Game / build | Mod or content | Evidence and support status | Next check |
|---|---|---|---| |---|---|---|---|
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. | | Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
| Gravitas, Steam 1067310, Windows, 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, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. | | Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2.** 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. | | 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 with Gravitas, and HL2 VR is correctly refused). The game needs a Proton
prefix, so it must have been played once. 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) - **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 and python.org's [Windows x64 embeddable Python 3.14.7](https://www.python.org/downloads/release/python-3147/).
injector is a .NET 6 WPF app, and Proton ships no .NET). Each file is Each is checked against a pinned SHA-256; Python's comes from python.org's
checked against its pinned SHA-256 (UEVR) or SHA-512 (.NET, from Microsoft's release-file API. Archives are unpacked into a staging folder, rejecting
release metadata) before use. Archives are unpacked into a staging folder, entries that escape it, symlinks and oversized contents, then moved into
rejecting entries that escape it, symlinks and oversized contents, and `<prefix>/drive_c/frame-control/` (about 47 MB). Nothing goes into the
then moved into `<prefix>/drive_c/frame-control/`. Nothing goes into the
game's own folder. A receipt in `~/.local/share/frame-control/mods/` lists 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 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 - **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 waits for its window. It then runs a short script under that Python, with
Proton, prefix and Wine settings, read from the game process, so both are in the game's Proton, prefix and Wine settings read from the game process, so
one Wine session. It presses **OpenVR** and **Inject**, confirms that both share one Wine session. The script repeats what UEVR's own injector does
`UEVRBackend.dll` is mapped into the game, and reads SteamVR's frame count when you press Inject with OpenVR chosen: load `UEVRPluginNullifier.dll` and
for the app (`vrcmd --stats`). 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 - **Remove.** Deletes what the receipt lists, then the download cache once
no game uses it. Verified: the prefix's file listing matched its no game uses it. Verified: the prefix's file listing matched its
pre-install state afterwards. 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 - It's a .NET 6 WPF app, so it needs Microsoft's .NET runtimes (about 70 MB
XTest clicks, raising or refocusing the injector window don't change that. of downloads) in the prefix.
So in the headset you couldn't click the injector yourself, and Frame - Under gamescope, the full-screen game keeps the pointer and focus. XTest
Control sends X `ButtonPress`/`ButtonRelease` events straight to the clicks, raising or refocusing the injector window don't change that, so in
injector's window instead. The positions are for UEVR 1.05's layout; the the headset you can't click it. Sending X `ButtonPress`/`ButtonRelease`
window first maps at 960 px wide and settles at 625 px. `start` refuses to events straight to its window does work.
click if it doesn't settle at that width. - `--attach=<process>` didn't inject under Wine, and it didn't restore its
- The injector's `--attach=<process>` didn't inject under Wine. It also saved OpenVR choice.
didn't restore the OpenVR choice it had saved, so Frame Control chooses - Driven that way, it injected in 6 of 8 cold starts. In a bad session every
OpenVR every time. new injector ignored input or never showed its window, even after
- With only the game's variables (no `HOME`), the injector hit an relaunching. Its process list reads each process's main window title, a
`AccessViolationException` in .NET at startup. With the normal environment cross-process window message; a window that isn't answering would block its
underneath, all four later starts worked. `start` also retries a UI thread (inferred from the frontend's source, not proven). Without a
crashed injector up to three times. `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 - 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. Gravitas, and some of its Unreal engine scans fail on this older UE4 game.
Neither stopped the injection. 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) - [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). 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), - [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), - [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
including per-game installation and the need to preserve original DLLs. 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) - [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
+90 -45
View File
@@ -64,17 +64,15 @@ class FakeLibrary(unittest.TestCase):
self.addCleanup(p.stop) self.addCleanup(p.stop)
self.downloads = {frame_mods.UEVR["url"]: zip_bytes({"UEVRInjector.exe": b"MZ", "UEVRBackend.dll": b"MZ", self.downloads = {frame_mods.UEVR["url"]: zip_bytes({"UEVRInjector.exe": b"MZ", "UEVRBackend.dll": b"MZ",
"openvr_api.dll": b"MZ"})} "openvr_api.dll": b"MZ"})}
for url, _ in frame_mods.DOTNET["files"]: self.downloads[frame_mods.PYTHON["url"]] = zip_bytes({"python.exe": b"MZ", "python314.zip": b"PK"})
self.downloads[url] = zip_bytes({"shared/Microsoft.NETCore.App/6.0.36/x.dll": b"MZ"})
self.pin(self.downloads) self.pin(self.downloads)
def pin(self, downloads): def pin(self, downloads):
"""Serve these bytes and pin their real hashes in place of the published ones.""" """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()) for name in ("UEVR", "PYTHON"):
dotnet = dict(frame_mods.DOTNET, files=[(u, hashlib.sha512(downloads[u]).hexdigest()) pinned = getattr(frame_mods, name)
for u, _ in frame_mods.DOTNET["files"]]) p = mock.patch.object(frame_mods, name,
for name, value in (("UEVR", uevr), ("DOTNET", dotnet)): dict(pinned, sha256=hashlib.sha256(downloads[pinned["url"]]).hexdigest()))
p = mock.patch.object(frame_mods, name, value)
p.start() p.start()
self.addCleanup(p.stop) self.addCleanup(p.stop)
p = mock.patch.object(frame_mods.urllib.request, "urlopen", 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("*")) before = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
res = frame_mods.install(APPID) res = frame_mods.install(APPID)
self.assertTrue(res["installed"]) self.assertTrue(res["installed"])
self.assertTrue((self.managed() / "uevr-1.05/UEVRInjector.exe").is_file()) self.assertTrue((self.managed() / "uevr-1.05/UEVRBackend.dll").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() / "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-*"))) self.assertFalse(list(self.managed().glob(".staging-*")))
# What UEVR writes on its first injection. # What UEVR writes on its first injection.
roaming = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping" 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") 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): class Downloads(FakeLibrary):
def test_hash_mismatch_deletes_and_installs_nothing(self): 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"): with self.assertRaisesRegex(frame_mods.Fail, "doesn't match"):
frame_mods.install(APPID) frame_mods.install(APPID)
self.assertFalse((frame_mods.CACHE / "UEVR.zip").exists()) self.assertFalse((frame_mods.CACHE / "UEVR.zip").exists())
@@ -176,8 +185,8 @@ class Downloads(FakeLibrary):
self.assertIsNone(frame_mods.read_receipt(APPID)) self.assertIsNone(frame_mods.read_receipt(APPID))
def test_archive_escaping_its_folder_is_refused(self): def test_archive_escaping_its_folder_is_refused(self):
for bad in (zip_bytes({"UEVRInjector.exe": b"MZ", "../../evil.dll": b"MZ"}), for bad in (zip_bytes({"UEVRBackend.dll": b"MZ", "../../evil.dll": b"MZ"}),
zip_bytes({"UEVRInjector.exe": b"MZ"}, links=["openvr_api.dll"])): zip_bytes({"UEVRBackend.dll": b"MZ"}, links=["openvr_api.dll"])):
downloads = dict(self.downloads, **{frame_mods.UEVR["url"]: bad}) downloads = dict(self.downloads, **{frame_mods.UEVR["url"]: bad})
self.pin(downloads) self.pin(downloads)
with self.subTest(), self.assertRaises(frame_mods.Fail): with self.subTest(), self.assertRaises(frame_mods.Fail):
@@ -193,52 +202,88 @@ class Downloads(FakeLibrary):
frame_mods.install(APPID) 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): class Start(FakeLibrary):
def environ(self, text):
"""The game's /proc/<pid>/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): def test_needs_install_first(self):
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"): with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
frame_mods.start(APPID) 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) frame_mods.install(APPID)
self.running = 4242 self.running = 4242
env = "WINEPREFIX=/x\0DISPLAY=:1\0WINESERVERSOCKET=17\0PATH=/p\0" env = "WINEPREFIX=/x\0DISPLAY=:1\0WINESERVERSOCKET=17\0PATH=/p\0"
windows = [[(1, "SkyArk", 1280)], [(1, "SkyArk", 1280)], [(1, "SkyArk", 1280), (2, "UEVR", 700)]] with self.environ(env), \
with mock.patch("builtins.open", mock.mock_open(read_data=env)), \ mock.patch.object(frame_mods, "loaded", side_effect=list(loaded) + [True] * 5), \
mock.patch.object(frame_mods, "loaded", return_value=False), \
mock.patch.object(frame_mods, "x11_windows", side_effect=lambda d: windows.pop(0) if len(windows) > 1 else windows[0]), \
mock.patch.object(frame_mods.subprocess, "Popen") as popen, \
mock.patch.object(frame_mods, "x_click") as click, \
mock.patch.object(frame_mods.os, "killpg") as kill, \
mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)), \
mock.patch.object(frame_mods.time, "sleep"):
popen.return_value.poll.return_value = None # the injector stays up
with self.assertRaisesRegex(frame_mods.Fail, "700 px"):
frame_mods.start(APPID)
click.assert_not_called()
kill.assert_called_once_with(popen.return_value.pid, frame_mods.signal.SIGKILL)
env_used = popen.call_args.kwargs["env"]
self.assertNotIn("WINESERVERSOCKET", env_used, "an inherited fd number means nothing to a new process")
self.assertEqual(env_used["DOTNET_ROOT"], "C:\\frame-control\\dotnet-6.0.36")
self.assertEqual(env_used["WINEPREFIX"], "/x")
self.assertIn("HOME", env_used, "fontconfig needs a HOME for its cache")
def test_injector_that_keeps_crashing_is_reported(self):
frame_mods.install(APPID)
self.running = 4242
with mock.patch("builtins.open", mock.mock_open(read_data="WINEPREFIX=/x\0DISPLAY=:1\0")), \
mock.patch.object(frame_mods, "loaded", return_value=False), \
mock.patch.object(frame_mods, "x11_windows", return_value=[(1, "SkyArk", 1280)]), \ mock.patch.object(frame_mods, "x11_windows", return_value=[(1, "SkyArk", 1280)]), \
mock.patch.object(frame_mods.subprocess, "Popen") as popen, \ mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=reply, stderr="")) as run, \
mock.patch.object(frame_mods, "x_click") as click, \ mock.patch.object(frame_mods, "frame_submits", return_value=7), \
mock.patch.object(frame_mods.time, "sleep"): mock.patch.object(frame_mods.time, "sleep"):
popen.return_value.poll.return_value = 134 # died before showing its window return frame_mods.start(APPID), run
with self.assertRaisesRegex(frame_mods.Fail, "crashed on start 3 times"):
frame_mods.start(APPID) def test_injects_with_the_games_wine_settings(self):
self.assertEqual(popen.call_count, 3) res, run = self.run_start('noise\n{"pid": 320}\n')
click.assert_not_called() 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): 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): def test_bad_usage_prints_json_error(self):
for args in ([], ["install"], ["install", "12x"], ["remove", "620"]): for args in ([], ["install"], ["install", "12x"], ["remove", "620"]):
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_mods.py"), *args], out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_mods.py"), *args],
+181 -122
View File
@@ -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. 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 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 ARM64 and SteamVR 2.18.1 with Gravitas (1067310): after injection the game has
the game's own Wine session, and after OpenVR + Inject the game loads UEVRBackend.dll loaded and SteamVR counts frame submits for it. Stereo image,
UEVRBackend.dll and SteamVR counts frame submits for it. Stereo image, head head tracking and controls were not seen: the headset was unworn (docs/mods.md).
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 UEVR's own injector (a .NET WPF app) is not used. Under Wine and FEX it hung
Windows .NET 6 runtime it needs from Microsoft's release metadata URLs, each or ignored input in about one session in four, and gamescope keeps real input
checked against a pinned hash. Files live in <prefix>/drive_c/frame-control, away from it. Instead the official Windows embeddable Python runs INJECT_PY in
never in the game's own folder, and a receipt records what to remove. 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
<prefix>/drive_c/frame-control, never in the game's own folder, and a receipt
records what to remove.
""" """
import fcntl
import hashlib import hashlib
import json import json
import os import os
import re import re
import shutil import shutil
import signal
import subprocess import subprocess
import sys import sys
import time import time
@@ -35,35 +39,122 @@ HOME = Path.home()
STEAM = HOME / ".local/share/Steam" STEAM = HOME / ".local/share/Steam"
CACHE = HOME / ".cache/frame-control/mods" CACHE = HOME / ".cache/frame-control/mods"
RECEIPTS = HOME / ".local/share/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" VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd"
MANAGED = "frame-control" # drive_c/<this> inside the game's prefix MANAGED = "frame-control" # drive_c/<this> inside the game's prefix
UEVR = {"version": "1.05", "dir": "uevr-1.05", UEVR = {"version": "1.05", "dir": "uevr-1.05",
"url": "https://github.com/praydog/UEVR/releases/download/1.05/UEVR.zip", "url": "https://github.com/praydog/UEVR/releases/download/1.05/UEVR.zip",
"sha256": "af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c"} "sha256": "af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c"}
# UEVRInjector.exe is a .NET 6 WPF app; Proton ships no .NET. These are the # Windows x64 embeddable Python, to run INJECT_PY. Hash from python.org's
# win-x64 ZIPs listed in builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json. # release-file API (sha256_sum).
DOTNET = {"version": "6.0.36", "dir": "dotnet-6.0.36", "files": [ PYTHON = {"version": "3.14.7", "dir": "python-3.14.7",
("https://builds.dotnet.microsoft.com/dotnet/Runtime/6.0.36/dotnet-runtime-6.0.36-win-x64.zip", "url": "https://www.python.org/ftp/python/3.14.7/python-3.14.7-embed-amd64.zip",
"935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2"), "sha256": "d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15"}
("https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/6.0.36/windowsdesktop-runtime-6.0.36-win-x64.zip", MAX_UNPACKED = 200 << 20 # the two archives unpack to about 50 MB
"cee88fef07643dceb3d34ea71b64eeae85b8e39e7042bc4d35bc3a1f1df29373681dc48e31c01c9f593ec412699f6762b6fc5817433283c89df35a27ce8885bf")]} MAX_DOWNLOAD = 50 << 20 # each archive is 7–13 MB
MAX_UNPACKED = 600 << 20 # the three archives unpack to about 190 MB
# UEVR 1.05's injector window (625 px wide), in window coordinates. Under # The game's environment a second program needs to join its Wine session. Not
# gamescope the full-screen game keeps the pointer and focus, so real clicks
# can't reach the injector; XSendEvent to its window does. It doesn't restore
# its saved runtime choice under Wine, and --attach didn't inject, so choose
# OpenVR (SteamVR's own API) and press Inject every time.
INJECTOR_WIDTH = 625
CLICK_OPENVR, CLICK_INJECT = (125, 137), (365, 113)
# The game's environment the injector needs to join its Wine session. Not
# WINESERVERSOCKET and friends: those are inherited file descriptors. # WINESERVERSOCKET and friends: those are inherited file descriptors.
GAME_ENV = re.compile(r"(WINEPREFIX|WINEDLLPATH|WINEDLLOVERRIDES|WINEFSYNC|WINEDEBUG|PATH|DISPLAY|" 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"XDG_RUNTIME_DIR|PROTON_VR_RUNTIME|FEX_APP_CONFIG|FEX_APP_CONFIG_LOCATION|"
r"SteamAppId|SteamGameId|STEAM_COMPAT_DATA_PATH|WINE_LARGE_ADDRESS_AWARE)=") 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): class Fail(Exception):
pass pass
@@ -173,8 +264,11 @@ def fetch(url, digest, algo):
part = path.with_suffix(path.suffix + ".part") part = path.with_suffix(path.suffix + ".part")
try: try:
with urllib.request.urlopen(url, timeout=60) as r, open(part, "wb") as f: with urllib.request.urlopen(url, timeout=60) as r, open(part, "wb") as f:
shutil.copyfileobj(r, f, 1 << 20) for block in iter(lambda: r.read(1 << 20), b""):
except OSError as e: 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) part.unlink(missing_ok=True)
raise Fail(f"download failed: {url}: {e}") raise Fail(f"download failed: {url}: {e}")
part.rename(path) part.rename(path)
@@ -217,7 +311,7 @@ def install(appid):
if read_receipt(appid): if read_receipt(appid):
return {"message": f"UEVR {UEVR['version']} is already installed for {g['name']}", **status(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(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) base = managed_dir(g)
dirs = user_dirs(g, exe) dirs = user_dirs(g, exe)
fresh = [str(d) for d in (dirs["profile"].parent, base) if not d.exists()] # remove on uninstall if empty 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 budget = MAX_UNPACKED
for archive, sub in archives: for archive, sub in archives:
budget = unpack(archive, stage / sub, budget) budget = unpack(archive, stage / sub, budget)
if not (stage / UEVR["dir"] / "UEVRInjector.exe").is_file(): for need in (stage / UEVR["dir"] / "UEVRBackend.dll", stage / PYTHON["dir"] / "python.exe"):
raise Fail("UEVR.zip has no UEVRInjector.exe") if not need.is_file():
for sub in (UEVR["dir"], DOTNET["dir"]): 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) shutil.rmtree(base / sub, ignore_errors=True)
(stage / sub).rename(base / sub) (stage / sub).rename(base / sub)
finally: finally:
@@ -241,13 +339,13 @@ def install(appid):
pass pass
receipt = {"appid": appid, "mod": "UEVR", "version": UEVR["version"], "exe": exe.name, receipt = {"appid": appid, "mod": "UEVR", "version": UEVR["version"], "exe": exe.name,
"installed": int(time.time()), "prefix": str(g["prefix"]), "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. # Parents that didn't exist yet (UEVR makes UnrealVRMod), deepest first.
"remove_if_empty": fresh, "remove_if_empty": fresh,
# UEVR creates these on first injection. Remove them on uninstall # UEVR creates these on first injection. Remove them on uninstall
# only if they weren't there before, so earlier settings survive. # 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()}, "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) RECEIPTS.mkdir(parents=True, exist_ok=True)
tmp = receipt_path(appid).with_suffix(".tmp") tmp = receipt_path(appid).with_suffix(".tmp")
tmp.write_text(json.dumps(receipt, indent=1)) 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)] 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): def frame_submits(appid):
"""SteamVR's count of frames the app has submitted, or None if it isn't a scene app.""" """SteamVR's count of frames the app has submitted, or None if it isn't a scene app."""
try: try:
stats = json.loads(subprocess.run([VRCMD, "--stats"], capture_output=True, text=True, timeout=15).stdout) stats = json.loads(subprocess.run([VRCMD, "--stats"], capture_output=True, text=True, timeout=15).stdout)
except (OSError, ValueError, subprocess.TimeoutExpired): except (OSError, ValueError, subprocess.TimeoutExpired):
return None 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): def wait(what, seconds, check, step=2):
end = time.time() + seconds end = time.time() + seconds
while time.time() < end: while time.time() < end:
v = check() v = check()
if v: if v is not None and v is not False: # 0 frames is still an answer
return v return v
time.sleep(step) time.sleep(step)
raise Fail(f"timed out waiting for {what}") 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, 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) 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)) 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: try:
# It maps at a default size, then WPF lays it out (seen: 960 px, then 625). environ = Path(f"/proc/{pid}/environ").read_text(errors="replace")
try: except OSError:
wait("the UEVR window's layout", 30, raise Fail(f"{exe} exited") from None
lambda: any(w[0] == win[0] and w[2] == INJECTOR_WIDTH for w in x11_windows(display)), step=1) game_env = dict(line.split("=", 1) for line in environ.split("\0") if GAME_ENV.match(line))
except Fail: display = game_env.get("DISPLAY")
width = next((w[2] for w in x11_windows(display) if w[0] == win[0]), "?") if not display or "WINEPREFIX" not in game_env:
raise Fail(f"the UEVR window is {width} px wide, not {INJECTOR_WIDTH}; " raise Fail(f"{exe} doesn't look like a Proton game process")
"its layout changed, so not clicking blind") from None # UEVR hooks the game's D3D11/12 device, so wait for its window. Proton's
time.sleep(5) # let it fill its process list # own helper windows ("Steam", "Default IME", …) are small.
x_click(display, win[0], [CLICK_OPENVR, CLICK_INJECT]) wait("the game window", 90, lambda: any(w[2] >= 640 for w in x11_windows(display)))
try: time.sleep(10)
wait("UEVRBackend.dll to load in the game", 45, lambda: loaded(pid, "UEVRBackend.dll")) # Our own environment (HOME and the rest) under the game's Wine settings.
except Fail: env = {**os.environ, **game_env}
raise Fail(f"UEVR didn't inject into {exe}. Its log is in ~/.cache/frame-control/mods/injector-{appid}.log") from None 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: except Fail:
os.killpg(injector.pid, signal.SIGKILL) # don't leave an injector window behind a failed start raise Fail(f"the injection reported success but {exe} has no UEVRBackend.dll") from None
raise
submits = None submits = None
try: try:
submits = wait("SteamVR frames", 30, lambda: frame_submits(appid)) submits = wait("SteamVR frames", 30, lambda: frame_submits(appid))
except Fail: except Fail:
pass pass
return {"message": f"UEVR injected into {g['name']}" + ("; SteamVR is receiving its frames" if submits else return {"message": f"UEVR injected into {g['name']}" + ("; SteamVR is receiving its frames" if submits is not None
"; SteamVR hasn't reported frames from it yet"), else "; SteamVR hasn't reported frames from it yet"),
"frame_submits": submits, **status(appid)} "frame_submits": submits, **status(appid)}
@@ -400,6 +444,8 @@ def uninstall(appid):
if game_pid(r["exe"]): if game_pid(r["exe"]):
raise Fail("the game is running; quit it first") raise Fail("the game is running; quit it first")
prefix = os.path.realpath(r["prefix"]) 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()) 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 for t in targets: # a receipt only ever points inside this game's prefix
real = os.path.realpath(t) real = os.path.realpath(t)
@@ -426,7 +472,17 @@ def uninstall(appid):
def main(argv): def main(argv):
if len(argv) != 2 or argv[0] not in ("status", "install", "start", "uninstall") or not argv[1].isdigit(): 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") 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__": if __name__ == "__main__":
@@ -435,3 +491,6 @@ if __name__ == "__main__":
except Fail as e: except Fail as e:
print(json.dumps({"error": str(e)})) print(json.dumps({"error": str(e)}))
sys.exit(1) 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)
+3 -2
View File
@@ -670,8 +670,9 @@
<h2 id="modTitle">Play in VR with UEVR</h2> <h2 id="modTitle">Play in VR with UEVR</h2>
<p id="modState" class="sub"></p> <p id="modState" class="sub"></p>
<p class="sub">UEVR by praydog turns many Unreal Engine games into VR games. Frame Control downloads <p class="sub">UEVR by praydog turns many Unreal Engine games into VR games. Frame Control downloads
the official UEVR 1.05 release and Microsoft's .NET 6 runtime it needs (about 70 MB), checks their hashes, the official UEVR 1.05 release and python.org's Windows Python, which it uses to load UEVR into the game
and keeps them in this game's Proton prefix, not the game's folder. Remove takes them out again.</p> (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.</p>
<p class="sub">Tested on the Frame with one game so far. Whether a game looks and plays right in VR varies; <p class="sub">Tested on the Frame with one game so far. Whether a game looks and plays right in VR varies;
UEVR's own menu in the headset has the per-game settings.</p> UEVR's own menu in the headset has the per-game settings.</p>
<div class="row rep-actions"><span class="sub" id="modMsg"></span><span class="spacer"></span> <div class="row rep-actions"><span class="sub" id="modMsg"></span><span class="spacer"></span>
+1 -1
View File
@@ -436,7 +436,7 @@ def steam(body):
# Downloads are about 70 MB; starting waits for the game, the injector and SteamVR. # 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): def mods(body):