From 2f056dbcffa3c0e6f86ccb3ad3cf1651b7ad91e1 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:04:12 +1000 Subject: [PATCH] UEVR: address the second review; recheck on the Frame frame_inject.py declares every Win32 signature, checks each result, frees its remote buffers and closes handles. Uninstall only accepts our folder and UEVR's own per-game settings folders, and keeps the receipt if anything can't be removed. The injector's reply is parsed defensively. Rechecked on a real Frame: install, three cold starts injected (33-35 s), clean uninstall. Part of #26. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/evidence/mods-2026-09-29.md | 19 ++++- docs/mods.md | 4 +- tests/test_mods.py | 21 ++++- ui/frame_mods.py | 140 +++++++++++++++++++------------ ui/server.py | 2 +- 5 files changed, 126 insertions(+), 60 deletions(-) diff --git a/docs/evidence/mods-2026-09-29.md b/docs/evidence/mods-2026-09-29.md index b94c8b8..605a6c2 100644 --- a/docs/evidence/mods-2026-09-29.md +++ b/docs/evidence/mods-2026-09-29.md @@ -4,8 +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, 11:33–11:49, 12:02–12:40 and -12:44–12:52. Battery 45 % → +`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40, +12:44–12:52 and 13:01–13:03. Battery 45 % → 93 %, on charge throughout. **Not observed:** the VR image, head tracking and controller input. SteamVR @@ -66,7 +66,7 @@ page's **VR mod** dialog, driven with a headless Chromium: | `uninstall` while running | Refused: "the game is running; quit it first" | | `start`, game not running | Launched the game, injected, `frame_submits` 45 (55 s) | | UI: Remove, Install | Worked; the prefix listing matched its pre-install state except an empty `UnrealVRMod`, which uninstall now also removes | -| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's; `start` also retries up to three times | +| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's (the relaunch-on-failure added next is gone with the GUI injector; see below) | | 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 | @@ -102,10 +102,21 @@ is 47 MB. Battery 98–100 % on charge. Lock released at 12:52. +## After the second review (13:01–13:03) + +`frame_inject.py` now declares every Win32 signature, checks each result, +frees its remote buffers and closes its handles; uninstall accepts only our +folder and UEVR's own settings folders. Rechecked: install; three cold starts +injected in 35, 34 and 33 s (`frame_submits` 42, 38, 47), UEVR logging +`Framework initialized` and `Requested runtime: openvr_api.dll`; uninstall +left `drive_c`, `AppData/Roaming` and `AppData/Local` as before, with no cache, +receipts or processes. Frame Control's injector total: 12 of 12 cold starts +through the API, plus the UI run. + ## 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 12:52. +- The test lock was released at 13:03. diff --git a/docs/mods.md b/docs/mods.md index d6e7f91..8fb7a9c 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -26,7 +26,7 @@ means an upstream source describes it; “inferred” means it still needs a tes | Game / build | Mod or content | Evidence and support status | Next check | |---|---|---|---| | Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. | -| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** 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. | +| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Twelve cold **Play in VR** runs (33–35 s each), and the full cycle 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. | @@ -95,7 +95,7 @@ Why not UEVR's own injector (verified 2026-09-29): 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. +- Frame Control's own script injected in 12 of 12 cold starts through the API, in 33–35 s, and again from the UI. - 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. diff --git a/tests/test_mods.py b/tests/test_mods.py index b0ad78f..3a656d9 100644 --- a/tests/test_mods.py +++ b/tests/test_mods.py @@ -159,12 +159,31 @@ class InstallUninstall(FakeLibrary): victim = self.steam / "steamapps/common/Gravitas" r["remove"].append(str(self.prefix / "../../../common/Gravitas")) frame_mods.receipt_path(APPID).write_text(json.dumps(r)) - with self.assertRaisesRegex(frame_mods.Fail, "outside"): + with self.assertRaisesRegex(frame_mods.Fail, "didn't create"): frame_mods.uninstall(APPID) self.assertTrue(victim.is_dir()) self.assertTrue((self.managed() / "uevr-1.05").is_dir(), "nothing removed when the receipt is bad") + def test_receipt_cannot_delete_other_things_in_the_prefix(self): + frame_mods.install(APPID) + r = frame_mods.read_receipt(APPID) + users = self.prefix / "drive_c/users" + r["remove"].append(str(users)) + frame_mods.receipt_path(APPID).write_text(json.dumps(r)) + with self.assertRaisesRegex(frame_mods.Fail, "didn't create"): + frame_mods.uninstall(APPID) + self.assertTrue(users.is_dir()) + + def test_failed_removal_keeps_the_receipt(self): + frame_mods.install(APPID) + with mock.patch.object(frame_mods.shutil, "rmtree"): # e.g. a permission error, swallowed + with self.assertRaisesRegex(frame_mods.Fail, "couldn't remove"): + frame_mods.uninstall(APPID) + self.assertIsNotNone(frame_mods.read_receipt(APPID), "Remove can be tried again") + frame_mods.uninstall(APPID) + self.assertFalse(self.managed().exists()) + def test_receipt_cannot_name_another_prefix(self): frame_mods.install(APPID) r = frame_mods.read_receipt(APPID) diff --git a/ui/frame_mods.py b/ui/frame_mods.py index 1aebbf7..482b2ea 100644 --- a/ui/frame_mods.py +++ b/ui/frame_mods.py @@ -69,18 +69,25 @@ 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 +H, P, SZ = W.HANDLE, ctypes.c_void_p, ctypes.c_size_t +for name, res, args in ( + ("OpenProcess", H, [W.DWORD, W.BOOL, W.DWORD]), + ("CloseHandle", W.BOOL, [H]), + ("VirtualAllocEx", P, [H, P, SZ, W.DWORD, W.DWORD]), + ("VirtualFreeEx", W.BOOL, [H, P, SZ, W.DWORD]), + ("WriteProcessMemory", W.BOOL, [H, P, P, SZ, P]), + ("CreateRemoteThread", H, [H, P, SZ, P, P, W.DWORD, P]), + ("WaitForSingleObject", W.DWORD, [H, W.DWORD]), + ("GetModuleHandleW", W.HMODULE, [W.LPCWSTR]), + ("LoadLibraryW", W.HMODULE, [W.LPCWSTR]), + ("FreeLibrary", W.BOOL, [W.HMODULE]), + ("GetProcAddress", P, [W.HMODULE, ctypes.c_char_p]), + ("CreateToolhelp32Snapshot", H, [W.DWORD, W.DWORD]), + ("Process32FirstW", W.BOOL, [H, P]), ("Process32NextW", W.BOOL, [H, P]), + ("Module32FirstW", W.BOOL, [H, P]), ("Module32NextW", W.BOOL, [H, P])): + fn = getattr(k32, name) + fn.restype, fn.argtypes = res, args +INVALID = ctypes.c_void_p(-1).value class Entry(ctypes.Structure): # PROCESSENTRY32W _fields_ = [("size", W.DWORD), ("usage", W.DWORD), ("pid", W.DWORD), ("heap", ctypes.c_void_p), @@ -93,56 +100,68 @@ class Module(ctypes.Structure): # MODULEENTRY32W ("name", W.WCHAR * 256), ("path", W.WCHAR * 260)] def fail(msg): - print(json.dumps({"error": msg})); sys.exit(1) + print(json.dumps({"error": f"{msg} (Windows error {ctypes.get_last_error()})"})); 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 snapshot(flags, pid, entry, first, next_, match): + snap = k32.CreateToolhelp32Snapshot(flags, pid) + if not snap or snap == INVALID: + fail("couldn't list processes") + try: + entry.size = ctypes.sizeof(entry) + ok = first(snap, ctypes.byref(entry)) + while ok: + if match(entry): + return entry + ok = next_(snap, ctypes.byref(entry)) + finally: + 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): +def remote_call(proc, fn, arg, wait_ms): 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) + fail("CreateRemoteThread failed") + try: + return k32.WaitForSingleObject(t, wait_ms) == 0 # WAIT_OBJECT_0 + finally: + 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: + if not mem: + fail(f"couldn't allocate in {exe}") + try: + if not k32.WriteProcessMemory(proc, mem, data, ctypes.sizeof(data), None): + fail(f"couldn't write into {exe}") + load = k32.GetProcAddress(k32.GetModuleHandleW("kernel32.dll"), b"LoadLibraryW") + if not remote_call(proc, load, mem, 30000): + fail(f"loading {name} into {exe} didn't finish in 30 s") + finally: + k32.VirtualFreeEx(proc, mem, 0, 0x8000) # MEM_RELEASE + m = snapshot(0x18, pid, Module(), k32.Module32FirstW, k32.Module32NextW, # TH32CS_SNAPMODULE | SNAPMODULE32 + lambda m: m.path.lower() == path.lower()) + if not m: fail(f"{name} didn't load into {exe}") - return path, base + return path, m.base -pid = find_pid() -if not pid: +e = snapshot(2, 0, Entry(), k32.Process32FirstW, k32.Process32NextW, # TH32CS_SNAPPROCESS + lambda e: e.name.lower() == exe.lower()) +if not e: fail(f"{exe} isn't running") +pid = e.pid proc = k32.OpenProcess(0x1F0FFF, False, pid) # PROCESS_ALL_ACCESS if not proc: - fail(f"couldn't open {exe} ({ctypes.get_last_error()})") + fail(f"couldn't open {exe}") 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) +fn = local and k32.GetProcAddress(local, b"nullify") +if not fn: + fail("couldn't find UEVRPluginNullifier.dll's nullify") +offset = fn - local # same DLL, same layout in both processes +k32.FreeLibrary(local) +# The frontend waits 2 s for nullify and carries on either way; so do we, but say so. +nullified = remote_call(proc, base + offset, None, 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) @@ -151,7 +170,8 @@ 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})) +k32.CloseHandle(proc) +print(json.dumps({"pid": pid, "nullified": nullified})) """ @@ -420,7 +440,15 @@ def start(appid): 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) + reply = None + for line in reversed(out.stdout.splitlines()): + try: + reply = json.loads(line) + break + except ValueError: + continue + if not isinstance(reply, dict): + reply = 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: @@ -446,15 +474,23 @@ def uninstall(appid): 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']}") + # Only ever our folder and UEVR's own settings folders for this game, even + # if the receipt says otherwise. + managed = os.path.join(prefix, "drive_c", MANAGED) + uevr_dirs = {os.path.realpath(str(d)) for d in user_dirs({"prefix": Path(prefix)}, Path(r["exe"])).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: real = os.path.realpath(t) - if not real.startswith(prefix + os.sep): - raise Fail(f"receipt lists a path outside the game's prefix: {t}") + if not (real.startswith(managed + os.sep) or real in uevr_dirs): + raise Fail(f"receipt lists a path Frame Control didn't create: {t}") for t in targets: shutil.rmtree(t, ignore_errors=True) + left = [t for t in targets if os.path.lexists(t)] + if left: # keep the receipt, so Remove can be tried again + raise Fail("couldn't remove " + ", ".join(left)) + parents = {os.path.realpath(os.path.join(prefix, "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod")), managed} for t in r.get("remove_if_empty", []): - if os.path.realpath(t).startswith(prefix + os.sep): + if os.path.realpath(t) in parents: try: os.rmdir(t) except OSError: diff --git a/ui/server.py b/ui/server.py index 711d390..057425e 100755 --- a/ui/server.py +++ b/ui/server.py @@ -435,7 +435,7 @@ def steam(body): return steam_frame(action, appid) -# Downloads are about 70 MB; starting waits for the game, the injector and SteamVR. +# Downloads are about 20 MB; starting waits for the game, the injection and SteamVR. MOD_TIMEOUT = {"status": 40, "install": 600, "start": 900, "uninstall": 60}