Files
saphidandClaude Opus 5.5 f10282294d Fix the cross-provider review's findings on VR APK support
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>
2026-09-28 17:47:35 +10:00
..

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 createApiLayerInstance hook handles xrCreateInstance. API requests >= 1.1 become XR_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_settings is advertised and accepted; xrSetAndroidApplicationThreadKHR logs and returns success without changing thread scheduling. Missing XR_OCULUS_android_session_state_enable is 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_extensions makes 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-Loader logs (loader_instance.cpp L149–176). If they reach this layer, it logs their names and preserves them for the downstream failure.
  • xrLocateSpaces dispatches to xrLocateSpacesKHR per 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/pose to /input/palm_ext/pose in xrStringToPath and in binding suggestions (including paths obtained before this layer rewrote them). Unknown paths are left alone.
  • xrGetInstanceProperties and 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 is assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json.
  • A bare library_path, as used here, is not explicitly concatenated with nativeLibraryDir. 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 against GetAndroidNativeLibraryDir(). loader_init_data.cpp L87–96 obtains the asset manager and ApplicationInfo.nativeLibraryDir via JNI. Wolvic's supplied manifest has android:extractNativeLibs="true".
  • android_utilities.cpp L267–322 handles runtime broker discovery, not APK layer discovery. Its native_lib_dir + so_filename and dlopen refer 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's XR_APILAYER_VALVE_fdm_injection discoverable. This layer advances nextInfo exactly once, uses next-layer GIPA, preserves the rest of the create-info chain and never opens vrclient.so directly. 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.