mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
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:
1 parent
416f3181cc
commit
e3176a2de8
7 files changed
+348
-201
No files matched your search
@@ -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.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 33 KiB |
+38
-29
@@ -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 `<prefix>/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
|
||||
`<prefix>/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=<process>` didn't inject under Wine. It also
|
||||
didn't restore the OpenVR choice it had saved, so Frame Control chooses
|
||||
OpenVR every time.
|
||||
- With only the game's variables (no `HOME`), the injector hit an
|
||||
`AccessViolationException` in .NET at startup. With the normal environment
|
||||
underneath, all four later starts worked. `start` also retries a
|
||||
crashed injector up to three times.
|
||||
- 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=<process>` 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)
|
||||
|
||||
+90
-45
@@ -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/<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):
|
||||
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],
|
||||
|
||||
+181
-122
@@ -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 <prefix>/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
|
||||
<prefix>/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/<this> inside the game's prefix
|
||||
|
||||
UEVR = {"version": "1.05", "dir": "uevr-1.05",
|
||||
"url": "https://github.com/praydog/UEVR/releases/download/1.05/UEVR.zip",
|
||||
"sha256": "af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c"}
|
||||
# UEVRInjector.exe is a .NET 6 WPF app; Proton ships no .NET. These are the
|
||||
# win-x64 ZIPs listed in builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json.
|
||||
DOTNET = {"version": "6.0.36", "dir": "dotnet-6.0.36", "files": [
|
||||
("https://builds.dotnet.microsoft.com/dotnet/Runtime/6.0.36/dotnet-runtime-6.0.36-win-x64.zip",
|
||||
"935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2"),
|
||||
("https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/6.0.36/windowsdesktop-runtime-6.0.36-win-x64.zip",
|
||||
"cee88fef07643dceb3d34ea71b64eeae85b8e39e7042bc4d35bc3a1f1df29373681dc48e31c01c9f593ec412699f6762b6fc5817433283c89df35a27ce8885bf")]}
|
||||
MAX_UNPACKED = 600 << 20 # the three archives unpack to about 190 MB
|
||||
# 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)
|
||||
+3
-2
@@ -670,8 +670,9 @@
|
||||
<h2 id="modTitle">Play in VR with UEVR</h2>
|
||||
<p id="modState" class="sub"></p>
|
||||
<p class="sub">UEVR by praydog turns many Unreal Engine games into VR games. Frame Control downloads
|
||||
the official UEVR 1.05 release and Microsoft's .NET 6 runtime it needs (about 70 MB), checks their hashes,
|
||||
and keeps them in this game's Proton prefix, not the game's folder. Remove takes them out again.</p>
|
||||
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.</p>
|
||||
<p class="sub">Tested on the Frame with one game so far. Whether a game looks and plays right in VR varies;
|
||||
UEVR's own menu in the headset has the per-game settings.</p>
|
||||
<div class="row rep-actions"><span class="sub" id="modMsg"></span><span class="spacer"></span>
|
||||
|
||||
+1
-1
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user