From 50d5e1453963565ff4b71ee0458d2bcdd7315e44 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:33:14 +1000 Subject: [PATCH] Inject the OpenXR compatibility layer into VR APKs on install VR apps with an arm64 OpenXR loader get frame/openxr-compat's layer unless --no-xr-compat; the app bundle ships the layer. Verified on the Frame: Wolvic's Quest build gets through OpenXR start-up, Open Brush still reaches FOCUSED. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/package.json | 8 ++++++++ docs/vr-apks.md | 24 ++++++++++++++++++++++++ tests/test_frame_apk_vr.py | 37 ++++++++++++++++++++++++++++++++++++- ui/frame_android.py | 38 +++++++++++++++++++++++++++++++------- 4 files changed, 99 insertions(+), 8 deletions(-) diff --git a/app/package.json b/app/package.json index bd5701a..c9b80b3 100644 --- a/app/package.json +++ b/app/package.json @@ -64,6 +64,14 @@ "*.py" ] }, + { + "from": "../frame/openxr-compat", + "to": "frame/openxr-compat", + "filter": [ + "XrApiLayer_FRAME_compat.json", + "prebuilt/**/*.so" + ] + }, { "from": "../frame/devkit-utils", "to": "frame/devkit-utils", diff --git a/docs/vr-apks.md b/docs/vr-apks.md index aeced96..8d31cc3 100644 --- a/docs/vr-apks.md +++ b/docs/vr-apks.md @@ -109,6 +109,30 @@ in Frame Control's per-user app-data directory as `apk-signing-key.json` updates. A re-signed APK cannot update an installation signed by its original publisher; Android also treats it as a different signer for signature checks. +VR apps with an arm64 OpenXR loader also get the OpenXR compatibility layer +([frame/openxr-compat](../frame/openxr-compat/README.md)): an implicit API +layer in the APK's `assets/openxr/1/api_layers/implicit.d/`, which the app's +own loader picks up next to Valve's layer. It asks SteamVR for OpenXR 1.0 when +the app wants 1.1 and enables the extensions that became 1.1 core; maps +`xrLocateSpaces` to `xrLocateSpacesKHR` and `grip_surface` to `palm_ext`; drops +1.1 controller profiles SteamVR doesn't know; stubs +`XR_KHR_android_thread_settings` and `XR_OCULUS_android_session_state_enable`; +and keeps the current refresh rate when SteamVR refuses a requested one. +`meta.json` records `"patched": ["openxr-compat"]`. Skip it with +`install … --no-xr-compat`. Its decisions go to logcat under `FrameXrCompat`. + +Verified on the headset (2026-09-28): + +- **Wolvic 1.9, Quest build**, installed as downloaded: Frame Control added + `LAUNCHER` and the layer. The layer turned OpenXR 1.1.48 into 1.0.63, the + instance and session were created, and a 144 Hz refresh request that SteamVR + refused was kept at the current rate. The session reached `SYNCHRONIZED`; + then Wolvic's Gecko engine crashed (null SIGSEGV on its Gecko thread, the + same crash its Lynx build has), which is Wolvic's, not OpenXR's. +- **Open Brush, Quest build**, with the layer: 1.1.54 → 1.0.63, the thread + settings stub in use, `bytedance/pico4_controller` bindings dropped, and the + session reached `FOCUSED`, the same as without the layer. + Inspect or prepare an APK without contacting the headset: ```sh diff --git a/tests/test_frame_apk_vr.py b/tests/test_frame_apk_vr.py index c13e856..186ae58 100644 --- a/tests/test_frame_apk_vr.py +++ b/tests/test_frame_apk_vr.py @@ -174,7 +174,10 @@ class VRTests(unittest.TestCase): def test_install_auto_and_override(self): base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None, 'vr': True, 'vr_activity': True, 'launchable': False} - with patch.object(frame_android, 'apk_info', return_value=base), patch.object(frame_android, 'patch') as repair, patch.object(frame_android, '_install', return_value={}) as install: + with patch.object(frame_android, 'apk_info', return_value=base), \ + patch.object(frame_android, 'xr_compat_files', return_value={}), \ + patch.object(frame_android, 'patch', return_value={'patched': ['launcher']}) as repair, \ + patch.object(frame_android, '_install', return_value={}) as install: frame_android.install('original.apk') repair.assert_called_once() self.assertFalse(install.call_args.args[3]) @@ -182,6 +185,38 @@ class VRTests(unittest.TestCase): frame_android.install('original.apk', flatscreen=True) self.assertTrue(install.call_args.args[3]) + def test_xr_compat_layer(self): + with tempfile.TemporaryDirectory() as d: + apk = Path(d) / 'a.apk' + with zipfile.ZipFile(apk, 'w') as z: + z.writestr('lib/arm64-v8a/libopenxr_loader.so', b'') + add = frame_android.xr_compat_files(str(apk)) + self.assertEqual(set(add), set(frame_android.XR_COMPAT_FILES)) + self.assertIn(b'XR_APILAYER_FRAME_compat', add['assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json']) + self.assertTrue(add['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so'].startswith(b'\x7fELF')) + with zipfile.ZipFile(apk, 'a') as z: # already injected: nothing more to add + z.writestr('lib/arm64-v8a/libXrApiLayer_FRAME_compat.so', b'') + self.assertEqual(frame_android.xr_compat_files(str(apk)), {}) + flat = Path(d) / 'flat.apk' + with zipfile.ZipFile(flat, 'w') as z: + z.writestr('classes.dex', b'') + self.assertEqual(frame_android.xr_compat_files(str(flat)), {}) + + def test_install_adds_layer_to_vr_apps(self): + base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None, + 'vr': True, 'vr_activity': True, 'launchable': True} + layer = {'x': b''} + with patch.object(frame_android, 'apk_info', return_value=dict(base)), \ + patch.object(frame_android, 'xr_compat_files', return_value=layer), \ + patch.object(frame_android, 'patch', return_value={'patched': ['openxr-compat']}) as repair, \ + patch.object(frame_android, '_install', return_value={}) as install: + frame_android.install('game.apk') + self.assertIs(repair.call_args.args[2], layer) + self.assertEqual(install.call_args.args[1]['patched'], ['openxr-compat']) + repair.reset_mock() + frame_android.install('game.apk', xr_compat=False) # launchable, no layer: install as is + repair.assert_not_called() + if __name__ == '__main__': unittest.main() diff --git a/ui/frame_android.py b/ui/frame_android.py index 39e9161..cec2e07 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -7,7 +7,7 @@ in the Steam library and gets its own SteamVR panel. Nothing goes through Lepton Development, which wipes its apps on exit. See docs/apks.md. Python stdlib only. CLI: python3 ui/frame_android.py - install APK [--vr|--flat] | info APK | patch SRC DST [--add NAME=PATH ...] + install APK [--vr|--flat] [--no-xr-compat] | info APK | patch SRC DST [--add NAME=PATH ...] list | launch PKG | stop PKG | remove PKG | probe PKG """ import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib @@ -26,6 +26,13 @@ COMPAT = '.local/share/Steam/steamapps/compatdata' SHADERS = '.local/share/Steam/steamapps/shadercache' LAUNCHER = os.path.join(ROOT, 'frame', 'android', 'lepton-app.sh') SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py') +# OpenXR API layer that lets OpenXR 1.1 apps run on SteamVR's 1.0-only Android +# runtime (frame/openxr-compat, docs/vr-apks.md). Injected into VR APKs. +XR_COMPAT = os.path.join(ROOT, 'frame', 'openxr-compat') +XR_COMPAT_FILES = { + 'assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json': 'XrApiLayer_FRAME_compat.json', + 'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so', +} PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$') SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8'] @@ -71,6 +78,19 @@ def apk_info(path): raise FrameError(f'{os.path.basename(path)}: {e}') +def xr_compat_files(apk_path): + """The layer's files to add, or {} if the APK has no OpenXR loader or already has the layer.""" + with zipfile.ZipFile(apk_path) as z: + names = set(z.namelist()) + if 'lib/arm64-v8a/libopenxr_loader.so' not in names or names & set(XR_COMPAT_FILES): + return {} + add = {} + for entry, rel in XR_COMPAT_FILES.items(): + with open(os.path.join(XR_COMPAT, rel), 'rb') as f: + add[entry] = f.read() + return add + + def check_installable(info): if info['min_sdk'] and info['min_sdk'] > 30: raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)") @@ -112,7 +132,7 @@ def _write_meta(d, meta): ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1)) -def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None): +def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None): info = apk_info(apk_path) if icon_png: info['icon_png'] = icon_png @@ -122,12 +142,14 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None): raise FrameError(f'unexpected package name {pkg!r}') if flatscreen is None: flatscreen = not info['vr'] + # VR apps get the OpenXR compatibility layer unless told otherwise; it only + # changes calls SteamVR would otherwise reject. + add = xr_compat_files(apk_path) if (info['vr'] if xr_compat is None else xr_compat) else {} with _install_lock: - if not info['launchable'] and info['vr_activity']: + if add or (not info['launchable'] and info['vr_activity']): with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp: patched = os.path.join(tmp, 'app.apk') - patch(apk_path, patched) - info['patched'] = ['launcher'] + info['patched'] = patch(apk_path, patched, add)['patched'] info['launchable'] = True return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path)) return _install(apk_path, info, pkg, flatscreen, name, source) @@ -301,7 +323,8 @@ def patch(src, dst, add=None): repack(src, dst, replace={'AndroidManifest.xml': manifest}, add=add) result = apk_info(dst) result.pop('icon_png', None) - result['patched'] = ['launcher'] if manifest != original else [] + result['patched'] = (['launcher'] if manifest != original else []) + \ + (['openxr-compat'] if add and set(XR_COMPAT_FILES) <= set(add) else []) return result except (OSError, ValueError, zipfile.BadZipFile, frame_apk.ApkError) as e: raise FrameError(str(e)) from e @@ -311,7 +334,8 @@ def main(): cmd, *args = sys.argv[1:] or ['help'] try: if cmd == 'install': - r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None) + r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None, + xr_compat=False if '--no-xr-compat' in args else None) elif cmd in ('info', 'describe'): r = apk_info(args[0]) r.pop('icon_png', None)