patch() turns corrupt-manifest struct/index errors into FrameError; a missing layer build says so; the signing key falls back to a rename where hard links aren't supported and explains how to recover from a bad cached key; the layer nulls an instance it can't destroy and logs xrLocateSpaces once. Not changed: the 1.1 Meta profile names match xr.xml's promoted names (meta/touch_pro_controller, meta/touch_plus_controller), and grip_surface is palm_ext renamed, so the rewrite stays (now commented). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
15 KiB
Frame OpenXR compatibility API layer
XR_APILAYER_FRAME_compat is an APK-local implicit API layer for Steam Frame's
OpenXR 1.0 Android runtime. It translates selected 1.1 functionality; it is not
a conformant replacement for a complete 1.1 runtime. Device facts are in
../../docs/vr-apks.md. No headset was contacted during
implementation. Loading, Valve-layer coexistence, controller usability, and
Wolvic startup/rendering on Lepton remain unverified on device.
Behavior
- The negotiated
createApiLayerInstancehook handlesxrCreateInstance. API requests >= 1.1 becomeXR_API_VERSION_1_0(1.0.63 with these headers), rather than carrying an arbitrary application's patch into the 1.0 request. Requests below 1.1 are unchanged. The loader may reject future API versions before any layer runs. - Enumerate downstream extensions through the next layer's GIPA before
creation. Only advertised promoted extensions are added, with duplicates
removed. All other application extensions remain, except the two stubs below.
Log added, stubbed and missing extensions with tag
FrameXrCompat. - Missing
XR_KHR_android_thread_settingsis advertised and accepted;xrSetAndroidApplicationThreadKHRlogs and returns success without changing thread scheduling. MissingXR_OCULUS_android_session_state_enableis advertised and accepted, with no commands or additional session behavior. If downstream supports either extension, preserve it and its real commands. Stubbing these capabilities is intentionally limited to accepting the app's request; it does not provide Meta services. - The manifest's
instance_extensionsmakes both stubs visible during the loader's pre-instance enumeration and validation. The layer also exposes an enumeration implementation with count/capacity handling. Before a next dispatch is available it can enumerate only its own extensions; normal app global enumeration is performed by the loader, merging runtime and manifest entries. See loader_core.cpp L106–145 and manifest parsing L549–558. - Unknown missing extensions are never silently removed. Khronos may reject
them before entering the layer and name them in
OpenXR-Loaderlogs (loader_instance.cpp L149–176). If they reach this layer, it logs their names and preserves them for the downstream failure. xrLocateSpacesdispatches toxrLocateSpacesKHRper session/instance. This is the only command added in 1.1. The three relevant structure types (SPACES_LOCATE_INFO,SPACE_LOCATIONS,SPACE_VELOCITIES) and their KHR versions are numeric aliases, and the structs are typedef aliases in these headers. Explicit type conversion of local input/output copies is therefore an identity conversion; the velocity chain passes through without mutating its types or linkage. Output arrays remain shared and receive the runtime's results, including on error; caller struct headers remain intact.- With palm pose enabled, rewrite both hand paths ending in
/input/grip_surface/poseto/input/palm_ext/poseinxrStringToPathand in binding suggestions (including paths obtained before this layer rewrote them). Unknown paths are left alone. xrGetInstancePropertiesand unrelated commands pass through. The real runtime's identity is not spoofed. Dispatch tables and session ownership are locked, and removed after successful destruction. Downstream calls occur outside the lock.
Spec basis and interaction profiles
The pinned 1.1 promotions appendix, versions.adoc L16–156
includes a generated promotion list. Its source is the
1.1.63 XML registry,
selecting extension[@promotedto='XR_VERSION_1_1']. The full list implemented:
| Promoted extension | Handling |
|---|---|
XR_KHR_locate_spaces |
Enable if advertised; core command alias |
XR_EXT_local_floor |
Enable if advertised; reference-space enum is an alias |
XR_EXT_uuid |
Enable if advertised; type alias, no commands |
XR_EXT_palm_pose |
Enable if advertised; rewrite grip-surface paths |
XR_VARJO_quad_views |
Enable if advertised; view-configuration enum alias, support remains optional |
XR_EXT_samsung_odyssey_controller |
Enable if advertised |
XR_EXT_hp_mixed_reality_controller |
Enable if advertised |
XR_HTC_vive_cosmos_controller_interaction |
Enable if advertised |
XR_HTC_vive_focus3_controller_interaction |
Enable if advertised |
XR_ML_ml2_controller_interaction |
Enable if advertised |
XR_FB_touch_controller_pro |
Enable if advertised; old profile still usable |
XR_META_touch_controller_plus |
Enable if advertised; old profile still usable |
XR_BD_controller_interaction |
Enable if advertised |
XR_KHR_maintenance1 |
Enable if advertised (included in the pinned registry's promotion list) |
XR_EXT_hand_interaction is not promoted to 1.1. Preserve it if requested;
it is not auto-enabled merely because the runtime advertises it. The parent
provided its availability; the device notes list extensions non-exhaustively.
The registry's XR_VERSION_1_1 feature introduces 13 profile paths. For
Samsung Odyssey, HP mixed reality, HTC Cosmos/Focus3, ML2 and the three
ByteDance Pico profiles, drop a whole suggestion call with success when the
corresponding extension is absent. Keep it when the extension is enabled.
Drop the five new Meta paths: touch_pro_controller, touch_plus_controller,
touch_controller_rift_cv1, touch_controller_quest_1_rift_s, and
touch_controller_quest_2. The verified runtime notes do not list these;
Pro/Plus promotion also renames several components, so the old extension's
presence alone does not prove support for the new profile. This layer does
not implement those component conversions. Existing old Pro/Plus profile
paths are passed through if the app uses them.
Preserve the documented oculus/touch_controller, khr/simple_controller,
valve/frame_controller and original PC profile paths. The notes' "usual PC
controllers" is not a complete enumerated list: availability of promoted PC
profiles is inferred from the runtime's extension advertisement, not invented
from that phrase. OpenXR has no general profile enumeration API. Dropping
suggestions prevents an unsupported 1.1 profile from aborting initialization;
it does not create bindings, so the app must also suggest a supported fallback.
Headers and licensing
The required public headers (openxr.h, openxr_platform.h,
openxr_platform_defines.h) are unmodified from
OpenXR-SDK release-1.1.63,
commit f2448a8.
The requested filename loader_interfaces.h no longer exists in 1.1 SDK
releases: negotiation was ratified and moved to openxr_loader_negotiation.h
in 1.0.33 (spec history L166–186).
To retain that requested interface filename, the layer uses the unmodified,
ABI-compatible last legacy header from SDK-Source release-1.0.32.
Only these four headers are vendored. They offer Apache-2.0 OR MIT; this
vendoring uses Apache-2.0, reproduced in vendor/LICENSE.
Android discovery and coexistence
Minimum asset-discovery loader release: 1.0.25 (2022-09-02). Its release commit, 15c3d8e explicitly announces APK-packaged API layers; manifest_file.cpp L665–723 implements discovery, and L934–940 adds asset manifests after filesystem manifests. This is not a 1.1-only feature.
For an app requesting API 1.1, use a 1.1 loader (first release 1.1.36) or newer. A 1.0 loader rejects the request before the layer can downgrade it; see loader_core.cpp L217–230. The runtime can remain 1.0.
Source inspection at release-1.1.63 confirms:
- manifest_file.cpp L720–783
uses the initialized Android asset manager and scans
openxr/1/api_layers/implicit.d/for JSON. The application must initialize the loader with its Android context (xrInitializeLoaderKHR). In the APK, the entry isassets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json. - A bare
library_path, as used here, is not explicitly concatenated withnativeLibraryDir. L878–900 leaves a bare name for normal dynamic-linker search in the application's namespace (including its native library directory). Relative paths with a slash use LocateLibraryInAssets L943–952, which resolves againstGetAndroidNativeLibraryDir(). loader_init_data.cpp L87–96 obtains the asset manager andApplicationInfo.nativeLibraryDirvia JNI. Wolvic's supplied manifest hasandroid:extractNativeLibs="true". - android_utilities.cpp L267–322
handles runtime broker discovery, not APK layer discovery. Its
native_lib_dir + so_filenameanddlopenrefer to the runtime package. They must not be confused with the app's layer library resolution. - manifest_file.cpp L1008–1023
retains filesystem manifests and then appends asset manifests. This leaves
/vendor'sXR_APILAYER_VALVE_fdm_injectiondiscoverable. This layer advancesnextInfoexactly once, uses next-layer GIPA, preserves the rest of the create-info chain and never opensvrclient.sodirectly. No environment layer-list override or assumed ordering is needed.
DISABLE_FRAME_XR_COMPAT is the manifest's disable environment variable.
Leave it unset to activate the implicit layer. No enable_environment is
required.
Rebuild and verify
NDK r30 / 30.0.16248370, installed at
~/Library/Android/ndk/30.0.16248370, was the latest stable/LTS package shown
by the Android download page
on 2026-09-28. Downloaded
android-ndk-r30-darwin.dmg
(1,072,970,961 bytes). The page publishes SHA-1, not SHA-256:
expected: 48591224b6657f46eebbc9d95d4af09bbed8d107
actual: 48591224b6657f46eebbc9d95d4af09bbed8d107 (PASS)
Mounted read-only with hdiutil; copied
AndroidNDK16248370.app/Contents/NDK into that version directory. Despite the
darwin-x86_64 toolchain directory name, clang is universal arm64/x86_64 and
ran on this Apple Silicon Mac.
frame/openxr-compat/build.sh
# Or set ANDROID_NDK_HOME to an installed NDK.
cmake -S frame/openxr-compat -B frame/openxr-compat/build-host -G Ninja
cmake --build frame/openxr-compat/build-host
ctest --test-dir frame/openxr-compat/build-host --output-on-failure
The script produces arm64-v8a / android-24, C++17, -O2, static libc++, hidden
internal symbols, stripped output, and -Wl,-z,max-page-size=16384.
The committed prebuilt is prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.
Its SHA-256 is recorded below with the APK checks.
Host test output: two tests passed, covering pure logic
and the actual layer with a fake next layer and host-only log/JNI shims.
Full llvm-readelf -d -s -l output records three LOAD
segments aligned 0x4000, only xrNegotiateLoaderApiLayerInterface exported,
and only libc.so, libm.so, libdl.so, liblog.so as NEEDED libraries.
All 46 undefined imports are versioned @LIBC except __android_log_print.
There is no libc++_shared.so dependency and no unstripped .symtab.
No independent model review was run: the task explicitly prohibits delegation.
No CODING_STANDARDS.md exists in this worktree or the primary worktree.
Wolvic injection artifact (Mac only)
Copied /tmp/vrapk/wq-dec to /tmp/vrapk/wq-layer-dec; retained its existing
LAUNCHER fix and native-library extraction setting. Added only:
assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json
lib/arm64-v8a/libXrApiLayer_FRAME_compat.so
Built using /tmp/vrapk/jdk/Contents/Home/bin/java and
/tmp/vrapk/apktool_3.0.3.jar, then signed in place using
/tmp/vrapk/uber-apk-signer-1.3.0.jar --overwrite (embedded debug certificate):
/tmp/vrapk/jdk/Contents/Home/bin/java -jar /tmp/vrapk/apktool_3.0.3.jar \
b /tmp/vrapk/wq-layer-dec -o /tmp/vrapk/wolvic-quest-compat.apk
/tmp/vrapk/jdk/Contents/Home/bin/java -jar /tmp/vrapk/uber-apk-signer-1.3.0.jar \
-a /tmp/vrapk/wolvic-quest-compat.apk --overwrite
/tmp/vrapk/jdk/Contents/Home/bin/java -jar /tmp/vrapk/uber-apk-signer-1.3.0.jar \
-a /tmp/vrapk/wolvic-quest-compat.apk --onlyVerify
Final APK: /tmp/vrapk/wolvic-quest-compat.apk (not committed).
The bundled lib/arm64-v8a/libopenxr_loader.so is byte-for-byte unchanged.
Its strings include both openxr/1/api_layers/implicit.d/ and
openxr/1/api_layers/explicit.d/, AddManifestFilesAndroid error messages,
and xrLocateSpaces. Thus it contains APK asset discovery and evidence of
1.1 command support. Its exact upstream release number was not established
from the binary; runtime execution of those paths remains unverified.
Prebuilt library SHA-256:
479d31c374f137906e03f73209b581d65d7e6a41b8476e6425fa55a6aa40bd68
APK SHA-256:
355a681625e38b71563dcffecb031799eff27894d6ab910a659bace057e82c1f
Final --onlyVerify exited 0: zip alignment verified, v2/v3 signatures
verified, one APK processed and zero errors. ZIP readback confirmed that the
injected library and manifest match the committed inputs, the loader is
unchanged, and the binary Android manifest retains the LAUNCHER category.
See APK verification evidence.