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) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 16:33:14 +10:00
1 parent fd15f1de4c
commit 50d5e14539
4 files changed
+99 -8

No files matched your search

+8
View File
@@ -64,6 +64,14 @@
"*.py" "*.py"
] ]
}, },
{
"from": "../frame/openxr-compat",
"to": "frame/openxr-compat",
"filter": [
"XrApiLayer_FRAME_compat.json",
"prebuilt/**/*.so"
]
},
{ {
"from": "../frame/devkit-utils", "from": "../frame/devkit-utils",
"to": "frame/devkit-utils", "to": "frame/devkit-utils",
+24
View File
@@ -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 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. 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: Inspect or prepare an APK without contacting the headset:
```sh ```sh
+36 -1
View File
@@ -174,7 +174,10 @@ class VRTests(unittest.TestCase):
def test_install_auto_and_override(self): def test_install_auto_and_override(self):
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None, base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None,
'vr': True, 'vr_activity': True, 'launchable': False} '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') frame_android.install('original.apk')
repair.assert_called_once() repair.assert_called_once()
self.assertFalse(install.call_args.args[3]) self.assertFalse(install.call_args.args[3])
@@ -182,6 +185,38 @@ class VRTests(unittest.TestCase):
frame_android.install('original.apk', flatscreen=True) frame_android.install('original.apk', flatscreen=True)
self.assertTrue(install.call_args.args[3]) 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__': if __name__ == '__main__':
unittest.main() unittest.main()
+31 -7
View File
@@ -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. Lepton Development, which wipes its apps on exit. See docs/apks.md.
Python stdlib only. CLI: python3 ui/frame_android.py 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 list | launch PKG | stop PKG | remove PKG | probe PKG
""" """
import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib 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' SHADERS = '.local/share/Steam/steamapps/shadercache'
LAUNCHER = os.path.join(ROOT, 'frame', 'android', 'lepton-app.sh') LAUNCHER = os.path.join(ROOT, 'frame', 'android', 'lepton-app.sh')
SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py') 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]*)+$') PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8'] SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
@@ -71,6 +78,19 @@ def apk_info(path):
raise FrameError(f'{os.path.basename(path)}: {e}') 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): def check_installable(info):
if info['min_sdk'] and info['min_sdk'] > 30: 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)") 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)) 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) info = apk_info(apk_path)
if icon_png: if icon_png:
info['icon_png'] = 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}') raise FrameError(f'unexpected package name {pkg!r}')
if flatscreen is None: if flatscreen is None:
flatscreen = not info['vr'] 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: 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: with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp:
patched = os.path.join(tmp, 'app.apk') patched = os.path.join(tmp, 'app.apk')
patch(apk_path, patched) info['patched'] = patch(apk_path, patched, add)['patched']
info['patched'] = ['launcher']
info['launchable'] = True info['launchable'] = True
return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path)) return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path))
return _install(apk_path, info, pkg, flatscreen, name, source) 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) repack(src, dst, replace={'AndroidManifest.xml': manifest}, add=add)
result = apk_info(dst) result = apk_info(dst)
result.pop('icon_png', None) 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 return result
except (OSError, ValueError, zipfile.BadZipFile, frame_apk.ApkError) as e: except (OSError, ValueError, zipfile.BadZipFile, frame_apk.ApkError) as e:
raise FrameError(str(e)) from e raise FrameError(str(e)) from e
@@ -311,7 +334,8 @@ def main():
cmd, *args = sys.argv[1:] or ['help'] cmd, *args = sys.argv[1:] or ['help']
try: try:
if cmd == 'install': 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'): elif cmd in ('info', 'describe'):
r = apk_info(args[0]) r = apk_info(args[0])
r.pop('icon_png', None) r.pop('icon_png', None)