mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 05:02:50 +02:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae7f733b90 | ||
|
|
b00db2484d | ||
|
|
363497fdd5 | ||
|
|
ff52f50c0c | ||
|
|
b0d6039eec | ||
|
|
86b8ab1d82 | ||
|
|
e288946b81 | ||
|
|
020e3386e1 | ||
|
|
f81f70ed5d | ||
|
|
4d4e45f622 | ||
|
|
6ecd0cea39 | ||
|
|
c335cd5130 | ||
|
|
0d448ab510 | ||
|
|
038dcd48cd | ||
|
|
9e9e5950d0 | ||
|
|
268afccf05 | ||
|
|
e1c0ac983c | ||
|
|
077eab2b79 | ||
|
|
35ef3af54b | ||
|
|
f10282294d | ||
|
|
50d5e14539 | ||
|
|
fd15f1de4c | ||
|
|
064cf95f6d | ||
|
|
8d75ede0ab | ||
|
|
0dbe18d824 | ||
|
|
a1bc6522c3 | ||
|
|
724b020e93 |
No files matched your search
@@ -66,6 +66,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",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 409 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 901 KiB |
@@ -102,10 +102,11 @@ Still open: 4, 6, 7, 12–15, 16 (off-LAN and after a reboot), 17–21.
|
|||||||
20. **F-Droid 2.0** (Compose 1.12): does it run? If so, the catalogue can
|
20. **F-Droid 2.0** (Compose 1.12): does it run? If so, the catalogue can
|
||||||
install it instead of 1.17.2.
|
install it instead of 1.17.2.
|
||||||
|
|
||||||
21. **DeoVR local files:** does DeoVR's file browser show `Videos → VR`
|
21. **Owned media player:** worn-headset comfort, audio quality/lip sync, long
|
||||||
(the symlink from `push-vr-video.sh`) or `Z:\home\steamos\Videos\VR`, and do
|
movies and 4K/8K decoding remain to check. Native spatial-photo container
|
||||||
the colour-coded test clips play in 3D (red left eye, cyan right) for both
|
extraction, large/immersive splats and existing-panel theatre docking need
|
||||||
H.264 and H.265? Does the DLNA browser find a server on the Mac?
|
further implementation. Remote eye isolation, short hardware decode and
|
||||||
|
owned-overlay cleanup are verified; see [vr-video.md](vr-video.md).
|
||||||
|
|
||||||
## Verified 2026-09-27
|
## Verified 2026-09-27
|
||||||
|
|
||||||
|
|||||||
@@ -123,3 +123,12 @@ a limit on the number of floating panels.
|
|||||||
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
||||||
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
|
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
|
||||||
PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
|
PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
|
||||||
|
|
||||||
|
## Frame Control's media theatre
|
||||||
|
|
||||||
|
[The owned media player](vr-video.md) can show its video or stereo image on a
|
||||||
|
larger, head-relative screen with its own dark surround. **Verified remotely
|
||||||
|
2026-09-28**, SteamOS 0.4.1 / BUILD_ID 20260925.6191901: screen, eye isolation,
|
||||||
|
surround and cleanup. It does not alter panel docking or global settings.
|
||||||
|
Its `Overlay` RGBA rendering hook is available to stream producers; applying
|
||||||
|
SteamVR theatre docking to existing Mac/PC panels is still unverified here.
|
||||||
+1
-1
@@ -99,7 +99,7 @@ controls to place each panel. See [docs/panels.md](panels.md).
|
|||||||
| `scripts/frame-ui.sh` | Mac | Start the Frame Control web UI (`ui/server.py`) and open it (**verified**) |
|
| `scripts/frame-ui.sh` | Mac | Start the Frame Control web UI (`ui/server.py`) and open it (**verified**) |
|
||||||
| `scripts/apk-catalog.sh` | Mac | Refresh the rated F-Droid catalogue that Frame Control's Android section shows (**verified**) |
|
| `scripts/apk-catalog.sh` | Mac | Refresh the rated F-Droid catalogue that Frame Control's Android section shows (**verified**) |
|
||||||
| `scripts/compat-db-backup.sh` | Mac | Maintainer-only: back up the shared compatibility database locally and to Google Drive (**verified**) |
|
| `scripts/compat-db-backup.sh` | Mac | Maintainer-only: back up the shared compatibility database locally and to Google Drive (**verified**) |
|
||||||
| `scripts/push-vr-video.sh` | Mac → Frame | Upload VR180/360 videos to `~/Videos/VR`, linked into DeoVR's Proton prefix; `--launch` starts DeoVR (**verified**: upload and link; in-headset playback of local files not yet checked). See [docs/vr-video.md](vr-video.md) |
|
| `scripts/push-vr-video.sh` | Computer → Frame | Upload movies, stereo PNG/JPEG or small `.splat` files to `~/Videos/FrameControl`; `--launch` starts our OpenVR player, `--theatre` adds a larger screen and dark surround. No separate viewer. **Verified remotely**: decode, stereo output and cleanup; worn-headset checks remain. See [docs/vr-video.md](vr-video.md) |
|
||||||
| `scripts/push.sh` | Mac → Frame | `rsync` files to `~/Downloads` (or a given path) on the Frame (**verified**) |
|
| `scripts/push.sh` | Mac → Frame | `rsync` files to `~/Downloads` (or a given path) on the Frame (**verified**) |
|
||||||
| `scripts/serve-bootstrap.sh` | Mac | Fallback: serve `bootstrap-on-frame.sh` with your public key embedded |
|
| `scripts/serve-bootstrap.sh` | Mac | Fallback: serve `bootstrap-on-frame.sh` with your public key embedded |
|
||||||
| `scripts/bootstrap-on-frame.sh` | Frame | Fallback: install the key and enable `sshd` |
|
| `scripts/bootstrap-on-frame.sh` | Frame | Fallback: install the key and enable `sshd` |
|
||||||
|
|||||||
+4
-2
@@ -45,8 +45,10 @@ flat 2D desktop streaming into a window on the Frame's Linux desktop.
|
|||||||
| Immersed / Virtual Desktop | Vendor apps | Immersed has a Mac agent but no known Frame client. Virtual Desktop's developer said he'd "try" to port it ([NewsBreak](https://www.newsbreak.com/news/4892834783961-virtual-desktop-dev-says-he-ll-try-to-bring-the-app-to-steam-frame)). | Not available as of 2026-09-25. Check again later. |
|
| Immersed / Virtual Desktop | Vendor apps | Immersed has a Mac agent but no known Frame client. Virtual Desktop's developer said he'd "try" to port it ([NewsBreak](https://www.newsbreak.com/news/4892834783961-virtual-desktop-dev-says-he-ll-try-to-bring-the-app-to-steam-frame)). | Not available as of 2026-09-25. Check again later. |
|
||||||
| WiVRn / ALVR | VR streaming from a Linux or Windows PC | Irrelevant for a Mac host (no SteamVR/OpenXR runtime on macOS) | N/A |
|
| WiVRn / ALVR | VR streaming from a Linux or Windows PC | Irrelevant for a Mac host (no SteamVR/OpenXR runtime on macOS) | N/A |
|
||||||
|
|
||||||
For **VR video files** (180°/360° stereo), don't stream the Mac's screen. Play
|
For **local movies and stereo photos**, Frame Control's own OpenVR player
|
||||||
them on the Frame in DeoVR instead: see [vr-video.md](vr-video.md).
|
runs on the Frame; see [vr-video.md](vr-video.md). It currently renders a flat
|
||||||
|
stereo screen. VR180/360 projection is not implemented; the same page records
|
||||||
|
DeoVR only as an optional, independently installed alternative.
|
||||||
|
|
||||||
### Pre-seeding the Remmina profile (no typing in the headset)
|
### Pre-seeding the Remmina profile (no typing in the headset)
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,20 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
|
|||||||
accepted. The fake now refuses them the same way, and Frame Control makes ids
|
accepted. The fake now refuses them the same way, and Frame Control makes ids
|
||||||
Steam accepts.
|
Steam accepts.
|
||||||
|
|
||||||
|
## Owned media player
|
||||||
|
|
||||||
|
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
|
||||||
|
hardware-decoder command construction, malformed splats, stereo parallax and
|
||||||
|
fake-Frame library/process ownership. `tests/e2e/test_media_transfer.py` checks the real
|
||||||
|
HTTP/SSH upload and library listing without pretending the fake renders VR.
|
||||||
|
Real decode timings and captured stereo output are recorded in
|
||||||
|
[vr-video.md](vr-video.md). Generated media only; no external player required.
|
||||||
|
|
||||||
|
**Verified 2026-09-28**, real Frame BUILD_ID 20260925.6191901: `~/.local`
|
||||||
|
and `~/.local/share` are `steamos:steamos`, mode 0755. The fake supervisor
|
||||||
|
sets those parent owners too; previously its root-created Steam manifests
|
||||||
|
left the parents root-owned and incorrectly prevented user runtime installs.
|
||||||
|
|
||||||
## Agent interfaces
|
## Agent interfaces
|
||||||
|
|
||||||
`tests/test_agent.py` exercises MCP stdio, exact-action human approvals and the
|
`tests/test_agent.py` exercises MCP stdio, exact-action human approvals and the
|
||||||
|
|||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
# VR APKs and Quest games in Lepton
|
||||||
|
|
||||||
|
What it takes to run an immersive (OpenXR) Android app, including Meta Quest
|
||||||
|
builds, on the Frame. Checked on SteamOS BUILD_ID 20260925.6191901, Lepton
|
||||||
|
v2.8.14 (rootfs v2.8.11), SteamVR 2.18.1, on 2026-09-28, unless marked
|
||||||
|
**inferred**.
|
||||||
|
|
||||||
|
## How a VR APK reaches SteamVR (verified)
|
||||||
|
|
||||||
|
- Lepton ships a standard Khronos system runtime manifest,
|
||||||
|
`/vendor/etc/openxr/1/active_runtime.json`, pointing at SteamVR's Android
|
||||||
|
client, `/data/steamvr/runtime/bin/androidarm64/vrclient.so`. That is the
|
||||||
|
host's `/opt/steamvr/bin/androidarm64/`, bind-mounted in.
|
||||||
|
- An APK's own Khronos-style `libopenxr_loader.so` tries the runtime brokers
|
||||||
|
(`org.khronos.openxr.runtime_broker`, `…system_runtime_broker`), finds
|
||||||
|
neither, then falls back to that manifest. Nothing in the APK has to change
|
||||||
|
for discovery.
|
||||||
|
- Install the APK without the flatscreen marker
|
||||||
|
(`python3 ui/frame_android.py install app.apk --vr`). The marker only
|
||||||
|
controls Lepton's 2D Android surface; the app itself has to start an
|
||||||
|
OpenXR session.
|
||||||
|
- Lepton also loads Valve's `XR_APILAYER_VALVE_fdm_injection` layer from
|
||||||
|
`/vendor/etc/openxr/1/api_layers/implicit.d/`. Only layers in Valve's own
|
||||||
|
directories are picked up (`liblepton/vulkan_layers.sh`), so a third-party
|
||||||
|
layer has to ship inside the APK.
|
||||||
|
|
||||||
|
**Open Brush 2.32.29, the Quest APK from its GitHub release (Unity OpenXR,
|
||||||
|
Vulkan), works unmodified.** Its manifest already has `LAUNCHER` next to
|
||||||
|
`com.oculus.intent.category.VR`. Unity asked for OpenXR 1.1, got
|
||||||
|
`XR_ERROR_API_VERSION_UNSUPPORTED`, retried with 1.0 and succeeded. SteamVR
|
||||||
|
took it as the scene app, created Touch, simple-controller and Frame-controller
|
||||||
|
bindings, and the session reached `XR_SESSION_STATE_FOCUSED`. A headset capture
|
||||||
|
(`ui/frame_vrshot.py`, after waking the compositor and closing the dashboard)
|
||||||
|
showed a dark sky over a mountain horizon; nobody wore the headset to confirm
|
||||||
|
it was Open Brush's scene or to try drawing.
|
||||||
|
|
||||||
|
**Khronos `hello_xr` (Vulkan, 1.1.63 release APK) works unmodified:**
|
||||||
|
`Instance RuntimeName=SteamVR/OpenXR RuntimeVersion=2.18.1`, 1728×1728
|
||||||
|
swapchains per eye, session `IDLE → READY → SYNCHRONIZED` (the headset was
|
||||||
|
not being worn, so it did not reach `FOCUSED`).
|
||||||
|
|
||||||
|
## What SteamVR's Android runtime supports (verified, from `vrclient.so`)
|
||||||
|
|
||||||
|
- **OpenXR 1.0 only.** An app requesting `XR_API_VERSION_1_0` works; one
|
||||||
|
requesting 1.1 (`XR_CURRENT_API_VERSION` in a 1.1 SDK) gets
|
||||||
|
`XR_ERROR_API_VERSION_UNSUPPORTED` from the runtime.
|
||||||
|
- Extensions include `XR_KHR_opengl_es_enable`, `XR_KHR_vulkan_enable{,2}`,
|
||||||
|
`XR_KHR_composition_layer_depth`, `XR_KHR_locate_spaces`,
|
||||||
|
`XR_EXT_local_floor`, `XR_EXT_uuid`, `XR_EXT_palm_pose`,
|
||||||
|
`XR_EXT_hand_tracking`, `XR_EXT_eye_gaze_interaction`, and these Meta ones:
|
||||||
|
`XR_FB_display_refresh_rate`, `XR_FB_foveation{,_configuration,_vulkan}`,
|
||||||
|
`XR_FB_space_warp`, `XR_FB_swapchain_update_state`,
|
||||||
|
`XR_META_foveation_eye_tracked`, `XR_META_recommended_layer_resolution`,
|
||||||
|
`XR_META_vulkan_swapchain_create_info`, `XR_META_performance_metrics`.
|
||||||
|
- Not present: `XR_FB_passthrough`, `XR_FB_hand_tracking_*`,
|
||||||
|
`XR_FB_spatial_entity*`, `XR_FB_color_space`,
|
||||||
|
`XR_KHR_android_thread_settings`, `XR_OCULUS_*`.
|
||||||
|
- Interaction profiles include `oculus/touch_controller`, `khr/simple_controller`,
|
||||||
|
`valve/frame_controller` and the usual PC controllers. Valve documents Touch
|
||||||
|
bindings as a working fallback on the Frame controllers.
|
||||||
|
|
||||||
|
## What stops a Quest APK (verified with Wolvic 1.9, `oculusvr` build)
|
||||||
|
|
||||||
|
1. **Lepton won't start it.** Lepton's `apk-info-extractor` only accepts an
|
||||||
|
activity whose intent filter has `android.intent.action.MAIN` and
|
||||||
|
`android.intent.category.LAUNCHER`. Quest apps use
|
||||||
|
`com.oculus.intent.category.VR` instead, so Lepton logs `APP_ACTIVITY is
|
||||||
|
empty` and exits. There is no override. **Fix:** add the `LAUNCHER`
|
||||||
|
category to that intent filter and re-sign. After that, Wolvic started.
|
||||||
|
2. **OpenXR 1.1.** Wolvic's Quest build then requested OpenXR 1.1 and aborted
|
||||||
|
on `XR_ERROR_API_VERSION_UNSUPPORTED`. Unity's OpenXR plugin retries with
|
||||||
|
1.0 (Open Brush, above), so this mostly bites native and non-Unity apps. **Fix (inferred):** an API layer
|
||||||
|
inside the APK that asks the runtime for 1.0 and maps the 1.1 core
|
||||||
|
functions to the extensions the runtime does have (`XR_KHR_locate_spaces`,
|
||||||
|
`XR_EXT_local_floor`, `XR_EXT_uuid`, `XR_EXT_palm_pose`).
|
||||||
|
3. **Lepton's missing clipboard service** still applies to VR apps. The Godot
|
||||||
|
XR Tools demo's Quest build (itch.io) dies in `Godot.<init>` casting the
|
||||||
|
null clipboard service to `ClipboardManager`, before any OpenXR call. See
|
||||||
|
the clipboard table in [apks.md](apks.md).
|
||||||
|
4. **Not yet reached:** required Meta-only extensions (each app differs),
|
||||||
|
swapchain formats (the Lynx Wolvic build needed `GL_SRGB8_ALPHA8`), and
|
||||||
|
Meta platform services.
|
||||||
|
|
||||||
|
The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a
|
||||||
|
Khronos-style loader and found SteamVR through `/vendor`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Meta entitlement.** Apps that call the Oculus Platform SDK
|
||||||
|
(`libovrplatformloader.so`) to check the Quest store licence need Meta's
|
||||||
|
services. Frame Control won't work around that.
|
||||||
|
- **VrApi-era apps** (`libvrapi.so`, before OpenXR) need an API translator,
|
||||||
|
not a patch.
|
||||||
|
|
||||||
|
## Frame Control does this for you
|
||||||
|
|
||||||
|
APK uploads and `python3 ui/frame_android.py install app.apk` detect VR
|
||||||
|
manifest categories, Samsung's `vr_only` flag and the arm64 OpenXR loader.
|
||||||
|
VR apps default to immersive mode without the flatscreen marker. The upload
|
||||||
|
selector or CLI `--flat` / `--vr` overrides that choice. Compatibility notes
|
||||||
|
identify legacy VrApi, Meta platform SDK and OpenXR libraries.
|
||||||
|
|
||||||
|
Lepton only starts an `<activity>` whose MAIN intent filter has LAUNCHER; it
|
||||||
|
ignores `<activity-alias>`, which is where Godot 4 exports put LAUNCHER. When no
|
||||||
|
real activity qualifies, Frame Control adds LAUNCHER to the VR activity's MAIN
|
||||||
|
filter, or to the activity the launcher alias targets, then repacks and v2-signs
|
||||||
|
the APK locally before copying it; `meta.json` records
|
||||||
|
`"patched": ["launcher"]`. Unchanged ZIP members retain their compressed
|
||||||
|
bytes; stored libraries are aligned to 16 KiB. The RSA signing identity lives
|
||||||
|
in Frame Control's per-user app-data directory as `apk-signing-key.json`
|
||||||
|
(mode 0600). Keep this key to preserve the signer on subsequent patched
|
||||||
|
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
|
||||||
|
python3 ui/frame_android.py info app.apk
|
||||||
|
python3 ui/frame_android.py patch app.apk patched.apk
|
||||||
|
python3 ui/frame_android.py patch app.apk patched.apk --add assets/openxr/1/api_layers/implicit.d/X.json=X.json --add lib/arm64-v8a/libX.so=libX.so
|
||||||
|
```
|
||||||
|
|
||||||
|
The patch fixes Lepton's launch-category requirement. It does not supply an
|
||||||
|
OpenXR 1.1 translation layer, Meta services or a VrApi implementation.
|
||||||
+139
-72
@@ -1,81 +1,148 @@
|
|||||||
# Watching VR video (180°/360°) on the Frame
|
# Movies, stereo photos and splats
|
||||||
|
|
||||||
The confidence labels are the same as in [ssh.md](ssh.md). Everything here
|
Frame Control has its own Frame-side OpenVR player. It uses SteamOS's ffmpeg
|
||||||
was checked on SteamOS 0.3.0, build 20260922.6101926.
|
and V4L2 hardware decoder, Python and SteamVR. **No separate player or viewer
|
||||||
|
is required.** Chromium and immersive WebXR are not in this playback path.
|
||||||
|
|
||||||
## The short version
|
## Use it
|
||||||
|
|
||||||
1. Install **DeoVR Video Player** from Steam (free, app 837380) and start it once
|
In **Tools → Media in the headset**, send a file, choose its layout and press
|
||||||
in the headset. That creates its Proton prefix.
|
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
|
||||||
2. On the Mac: `scripts/push-vr-video.sh --launch ~/Movies/beach_180_LR.mp4`
|
**Stop** removes both. Refresh reads the library and the player's state.
|
||||||
3. In the headset, open DeoVR's local file browser → **Videos → VR** and
|
The screen follows your head; it isn't a saved world-space panel.
|
||||||
pick the file.
|
|
||||||
|
|
||||||
## Why DeoVR
|
The command-line route uses the same player:
|
||||||
|
|
||||||
The Frame's Chromium can't play VR video in 3D. It has no immersive WebXR
|
|
||||||
([how-the-frame-works.md](how-the-frame-works.md)). DeoVR's Windows build
|
|
||||||
runs under Proton ARM64 + FEX as a real SteamVR app. **Verified 2026-09-25:**
|
|
||||||
it found the Steam Frame headset and controller over OpenVR, and decoded
|
|
||||||
7680×3840 and 8192×4096 H.265 VR180 side-by-side streams through AVPro's
|
|
||||||
hardware Media Foundation path, mapped onto a 180° dome or fisheye mesh.
|
|
||||||
|
|
||||||
Known quirks (verified):
|
|
||||||
|
|
||||||
- The first launch takes about 45 s while it compiles shaders.
|
|
||||||
- Grid thumbnails stay blank. Unity's own video player, which DeoVR uses for
|
|
||||||
previews, fails with `0xc00d36bb` under Proton. Full playback uses AVPro
|
|
||||||
and isn't affected.
|
|
||||||
- The in-app store and web content are separate from local files. You don't
|
|
||||||
need an account to play your own files.
|
|
||||||
|
|
||||||
## Getting files onto the headset
|
|
||||||
|
|
||||||
`scripts/push-vr-video.sh` copies files with `rsync --partial`, so an
|
|
||||||
interrupted upload resumes. They go to `~/Videos/VR` on the Frame (`/home`
|
|
||||||
has about 860 GB free). The script also links that folder into DeoVR's prefix
|
|
||||||
as `C:\users\steamuser\Videos\VR`. It's reachable at
|
|
||||||
`Z:\home\steamos\Videos\VR` as well. **Verified** that the upload and link
|
|
||||||
work. **Not yet checked** whether DeoVR's file browser lands there
|
|
||||||
(open question 21).
|
|
||||||
|
|
||||||
Speed: a test upload over Wi-Fi ran at about 3–5 MB/s (verified 2026-09-25,
|
|
||||||
one sample). At that rate an 8K file of several GB takes tens of minutes, so
|
|
||||||
start big uploads before you put the headset on.
|
|
||||||
|
|
||||||
## Naming files so they play correctly
|
|
||||||
|
|
||||||
DeoVR guesses the projection from the file name. Its binary contains the tags
|
|
||||||
`_180`, `_360`, `_fisheye`, `_fisheye190`, `_mkx200`, `_vrca220` and `_rf52`
|
|
||||||
(verified). For stereo layout, the common DeoVR convention is `_LR`/`_SBS`
|
|
||||||
(side by side) and `_TB` (top/bottom) (inferred). If a video looks wrong
|
|
||||||
(doubled, warped, or flat), change the projection and stereo mode in DeoVR's
|
|
||||||
player menu.
|
|
||||||
|
|
||||||
Examples: `trip_180_LR.mp4`, `concert_360_TB.mp4`, `hike_fisheye190_LR.mp4`.
|
|
||||||
|
|
||||||
Codecs: H.265 at 8K played (verified, streamed). Local H.264 and H.265
|
|
||||||
files haven't been played yet. The test clips below cover that.
|
|
||||||
|
|
||||||
## Test clips
|
|
||||||
|
|
||||||
The script doesn't include these. To check a setup, make two 20 s clips:
|
|
||||||
3840×1920, 180° side by side, with the left eye tinted red and the right eye
|
|
||||||
cyan. In the headset each eye should see only its own colour. A single mixed
|
|
||||||
colour means the stereo split is wrong.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ffmpeg -f lavfi -i testsrc2=size=1920x1920:rate=30:duration=20 \
|
scripts/push-vr-video.sh --launch --theatre ~/Movies/film_SBS.mp4
|
||||||
-filter_complex "[0:v]split[a][b];[a]colorchannelmixer=rr=1:gg=0.3:bb=0.3[l];[b]colorchannelmixer=rr=0.3:gg=1:bb=1[r];[l][r]hstack" \
|
scripts/push-vr-video.sh --launch --layout ou ~/Pictures/stereo.png
|
||||||
-c:v libx264 -pix_fmt yuv420p -b:v 20M frame-test_180_LR_h264.mp4
|
scripts/push-vr-video.sh --launch capture.splat
|
||||||
scripts/push-vr-video.sh frame-test_180_LR_h264.mp4
|
scripts/push-vr-video.sh --list
|
||||||
|
scripts/push-vr-video.sh --stop
|
||||||
```
|
```
|
||||||
|
|
||||||
## Streaming from the Mac instead of copying (untested)
|
Uploads live in `~/Videos/FrameControl/<id>/` on the Frame. Each upload gets
|
||||||
|
its own directory, so sending another file with the same name doesn't replace
|
||||||
|
it. The UI's existing upload limit is 8 GiB. Transfers use the app's rsync/scp
|
||||||
|
path; resumable large uploads are not implemented here yet. Existing
|
||||||
|
`~/Videos/VR` files and Proton prefixes are left alone. The script no longer
|
||||||
|
launches DeoVR, links a Proton prefix, or accepts directories.
|
||||||
|
|
||||||
DeoVR has a DLNA browser (the binary contains `Searching for DLNA
|
| Media | Supported preview |
|
||||||
devices...` and a UPnP ContentDirectory client). A DLNA server on the Mac
|
|---|---|
|
||||||
should therefore appear in DeoVR without copying anything, for example
|
| Movies | H.264 / H.265, mono or left-first SBS / top-first OU; audio through the Frame's PulseAudio-compatible server |
|
||||||
`brew install rclone` then `rclone serve dlna ~/Movies/VR`. This is **inferred**, not
|
| Stereo photos | PNG / JPEG containing both eyes, SBS or OU |
|
||||||
tried. 8K VR video needs roughly 50–100 Mbit/s sustained, and the Wi-Fi
|
| Gaussian splats | Common 32-byte `.splat` records, 1–20,000 Gaussians; a stationary stereo preview |
|
||||||
sample above (about 30–40 Mbit/s) suggests copying first is the safer default.
|
|
||||||
|
Auto layout reads delimited filename tags: `_SBS`, `_HSBS`, `_LR`, `_OU`,
|
||||||
|
`_TB`, `_HOU`, `_HTB`, `_FSBS`, `_FOU`, `_FTB`. SBS/OU without `F` means
|
||||||
|
half-resolution packing. Full packing preserves each eye's original aspect.
|
||||||
|
It also accepts FFmpeg's `stereo_mode` metadata (`left_right`, `top_bottom`,
|
||||||
|
`mono`); left/right and top/bottom metadata are treated as full packing.
|
||||||
|
Choose an explicit layout if that assumption doesn't match the file.
|
||||||
|
Unknown or conflicting tags ask for a choice, rather than silently flattening
|
||||||
|
stereo. The explicit selector always wins. Layout is not guessed from resolution.
|
||||||
|
|
||||||
|
## What was verified
|
||||||
|
|
||||||
|
**Verified remotely, 2026-09-28:** SteamOS 0.4.1, BUILD_ID
|
||||||
|
`20260925.6191901`, SteamVR 2.18.1. Nobody wore the headset for these checks.
|
||||||
|
All test media were generated by us. No paid content or DRM was involved.
|
||||||
|
|
||||||
|
- Stock `ffmpeg` `h264_v4l2m2m` decoded 150 frames of our 1920×1080 H.264
|
||||||
|
test in 0.128 s (decode only); converting all frames to RGBA took 0.242 s.
|
||||||
|
- Our ffmpeg → Python → OpenVR path submitted all 150 frames and exited 0.
|
||||||
|
The 1280×720 prototype took 4.775 s; the full 1920×1080 player took
|
||||||
|
4.618 s. These are wall times, not in-headset frame-rate measurements.
|
||||||
|
Finishing a 5 s clip early showed those probes weren't paced, so the final
|
||||||
|
player paces output at 30 fps: all 150 frames then completed in **5.025 s**.
|
||||||
|
- The H.265 hardware decoder also completed the generated 1080p clip
|
||||||
|
(60 frames, exit 0); this is a short compatibility check, not a 4K/8K benchmark.
|
||||||
|
- Our actual player, launched through Frame Control's media API, displayed
|
||||||
|
SBS and OU photos with red only in the left-eye capture and cyan only in
|
||||||
|
the right. OpenVR's `SideBySide_Parallel` flag separates the eyes; we
|
||||||
|
rearrange OU rows ourselves.
|
||||||
|
- A generated 48-Gaussian `.splat` rendered in our own CPU renderer and appeared
|
||||||
|
in both eyes with separate perspective projections.
|
||||||
|
- Theatre's owned dark surround and screen appeared in captures. Steam's
|
||||||
|
dashboard remained available over them. Stop removed the owned overlays
|
||||||
|
and ended the dedicated user service. No global SteamVR setting was changed.
|
||||||
|
- A generated H.264/AAC clip reached the Pulse audio output and completed
|
||||||
|
all 100 video frames with exit 0. This proves the output path, not audible
|
||||||
|
quality or lip sync.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Verified failed route:** GStreamer 1.24.2's `playbin` selected
|
||||||
|
`v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139)
|
||||||
|
in the basic appsink probe and the OpenVR probe. We do not ship that route.
|
||||||
|
The ffmpeg path above completed instead.
|
||||||
|
|
||||||
|
## Limits and blockers
|
||||||
|
|
||||||
|
- **Not verified:** worn-headset comfort, sound quality/lip sync, long movies,
|
||||||
|
4K/8K decode, HDR, controller interaction or behaviour on other OS builds.
|
||||||
|
Output is bounded to 1920×1080 packed pixels before OU rearrangement.
|
||||||
|
- **Not implemented:** pause, seeking, subtitles, playlists, right-eye-first
|
||||||
|
layouts, non-square pixel correction, VR180/360 projection and fisheye.
|
||||||
|
This preview is a flat stereo screen, not a dome player.
|
||||||
|
- **Native spatial-photo blocker:** HEIC/HEIF/AVIF/MPO stereo-container
|
||||||
|
extraction isn't implemented in our viewer. We reject these rather than
|
||||||
|
displaying one image and calling it spatial. Export both eyes to PNG/JPEG
|
||||||
|
first. This is a current implementation gap, not a claim that the Frame
|
||||||
|
cannot support these containers.
|
||||||
|
- **Large/immersive splat blocker:** the owned CPU rasterizer projects 3D
|
||||||
|
covariance into each eye and alpha-composites Gaussians, but renders a
|
||||||
|
fixed view at 320×240 per eye. It caps each Gaussian footprint at 32 pixels
|
||||||
|
and normalizes the scene to a two-metre box. Large scenes, PLY/SPZ, live
|
||||||
|
head-position parallax and navigation need a GPU scene renderer; this
|
||||||
|
version rejects files above 20,000 records. It is a stereo preview, not
|
||||||
|
an immersive walk-through.
|
||||||
|
- A single player can run at a time. It stops at EOF, on **Stop**, or after
|
||||||
|
four hours in any case, so longer movies are cut off. Still images remain
|
||||||
|
until Stop or that timeout. There is no delete action yet: remove old
|
||||||
|
uploads from `~/Videos/FrameControl/` over SSH. The log is
|
||||||
|
`~/.local/share/frame-control/media/player.log` on the Frame.
|
||||||
|
- **Panel/stream theatre remains outside this media slice:** SteamVR's
|
||||||
|
`vrcmd --dock-overlay` accepts `theater`, but docking an existing panel
|
||||||
|
and its dimming behaviour were not verified here. The shared headset had
|
||||||
|
workspace and stream tests active. We did not reposition their panels.
|
||||||
|
Integration with [#22](https://github.com/saphid/frame-control/issues/22)
|
||||||
|
and [#31](https://github.com/saphid/frame-control/issues/31) can use the
|
||||||
|
owned rendering hook below; no second stream/window manager is introduced.
|
||||||
|
|
||||||
|
## Integration hook
|
||||||
|
|
||||||
|
`POST /api/upload` with `X-Mode: media` and `X-Filename` sends a file and
|
||||||
|
returns its `id`. `POST /api/media` accepts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action":"play","id":"<32 hex characters>/film_SBS.mp4","layout":"auto","theatre":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Other actions are `list`, `status`, `stop`. These use the normal `X-Frame-UI`
|
||||||
|
guard. Play reports **starting**, not a claim that frames reached the headset;
|
||||||
|
read status for `playing`, `ended`, `stopped` or `error`.
|
||||||
|
The own-code files are copied to `~/.local/share/frame-control/media/` and
|
||||||
|
run in the `frame-control-media.service` systemd user unit. Stop affects only
|
||||||
|
that unit, including its decoder child.
|
||||||
|
|
||||||
|
For a Frame-side stream producer, `frame_media_player.Overlay` exposes
|
||||||
|
`create(key, width, distance, stereo=False, aspect=1, order=1)`,
|
||||||
|
`pixels(handle, rgba, width, height)` and `close()`. Use an owned unique key,
|
||||||
|
pass interleaved RGBA bytes (left/right halves for stereo) and always close in
|
||||||
|
`finally`. `create` uses a head-relative transform; it does not move another
|
||||||
|
app's panel. The player demonstrates a separate black surround overlay.
|
||||||
|
`frame_media.stereo_pixels` converts OU to SBS. This is the narrow rendering
|
||||||
|
hook for stream/workspace work; it doesn't capture or manage a Mac/PC stream.
|
||||||
|
|
||||||
|
## Optional separate app
|
||||||
|
|
||||||
|
You can independently install **DeoVR Video Player** (free Steam app 837380)
|
||||||
|
if you prefer its VR180/360 features. Earlier tests on SteamOS 0.3.0,
|
||||||
|
build `20260922.6101926` (2026-09-25), verified its OpenVR initialization and
|
||||||
|
8K H.265 streamed VR180 decoding under Proton. Frame Control's media features
|
||||||
|
neither install nor launch it, and do not depend on it. Its behaviour and
|
||||||
|
file-naming conventions are not evidence about our player.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Preserve upstream headers byte-for-byte, including their existing whitespace.
|
||||||
|
vendor/** -whitespace
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build-*/
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.22)
|
||||||
|
project(FrameXrCompat LANGUAGES CXX)
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
add_library(compat_logic INTERFACE)
|
||||||
|
target_include_directories(compat_logic INTERFACE . vendor)
|
||||||
|
if(ANDROID)
|
||||||
|
add_library(XrApiLayer_FRAME_compat SHARED layer.cpp)
|
||||||
|
target_link_libraries(XrApiLayer_FRAME_compat PRIVATE compat_logic log)
|
||||||
|
target_compile_definitions(XrApiLayer_FRAME_compat PRIVATE XR_USE_PLATFORM_ANDROID XR_NO_PROTOTYPES)
|
||||||
|
target_compile_options(XrApiLayer_FRAME_compat PRIVATE -O2 -fvisibility=hidden -Wall -Wextra -Werror)
|
||||||
|
target_link_options(XrApiLayer_FRAME_compat PRIVATE -Wl,-z,max-page-size=16384 -Wl,--no-undefined -Wl,--exclude-libs,ALL -Wl,-s)
|
||||||
|
else()
|
||||||
|
enable_testing()
|
||||||
|
add_executable(compat_tests tests/compat_tests.cpp)
|
||||||
|
target_link_libraries(compat_tests PRIVATE compat_logic)
|
||||||
|
target_compile_options(compat_tests PRIVATE -Wall -Wextra -Werror)
|
||||||
|
add_test(NAME compat_logic COMMAND compat_tests)
|
||||||
|
add_executable(dispatch_tests tests/dispatch_tests.cpp)
|
||||||
|
target_link_libraries(dispatch_tests PRIVATE compat_logic)
|
||||||
|
target_include_directories(dispatch_tests PRIVATE tests/shims)
|
||||||
|
target_compile_definitions(dispatch_tests PRIVATE XR_USE_PLATFORM_ANDROID XR_NO_PROTOTYPES)
|
||||||
|
target_compile_options(dispatch_tests PRIVATE -Wall -Wextra -Werror)
|
||||||
|
add_test(NAME layer_dispatch COMMAND dispatch_tests)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# 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](../../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](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/loader_core.cpp#L106)
|
||||||
|
and [manifest parsing L549–558](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/manifest_file.cpp#L549).
|
||||||
|
- 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](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/loader_instance.cpp#L149)).
|
||||||
|
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](https://github.com/KhronosGroup/OpenXR-Docs/blob/release-1.1.63/specification/sources/chapters/versions.adoc#L16)
|
||||||
|
includes a generated promotion list. Its source is the
|
||||||
|
[1.1.63 XML registry](https://github.com/KhronosGroup/OpenXR-Docs/blob/release-1.1.63/specification/registry/xr.xml),
|
||||||
|
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](https://github.com/KhronosGroup/OpenXR-SDK/tree/release-1.1.63/include/openxr),
|
||||||
|
commit [f2448a8](https://github.com/KhronosGroup/OpenXR-SDK/commit/f2448a8797c85814aa892efc1ab8707900fbcc78).
|
||||||
|
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](https://github.com/KhronosGroup/OpenXR-Docs/blob/release-1.1.63/specification/sources/chapters/versions.adoc#L166)).
|
||||||
|
To retain that requested interface filename, the layer uses the unmodified,
|
||||||
|
ABI-compatible [last legacy header from SDK-Source release-1.0.32](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.0.32/src/common/loader_interfaces.h).
|
||||||
|
Only these four headers are vendored. They offer Apache-2.0 OR MIT; this
|
||||||
|
vendoring uses Apache-2.0, reproduced in [vendor/LICENSE](vendor/LICENSE).
|
||||||
|
|
||||||
|
## Android discovery and coexistence
|
||||||
|
|
||||||
|
**Minimum asset-discovery loader release: 1.0.25 (2022-09-02).** Its
|
||||||
|
[release commit, 15c3d8e](https://github.com/KhronosGroup/OpenXR-SDK-Source/commit/15c3d8eb99994e5365d6b6f96eefaf4c51a65a9d)
|
||||||
|
explicitly announces APK-packaged API layers;
|
||||||
|
[manifest_file.cpp L665–723](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.0.25/src/loader/manifest_file.cpp#L665)
|
||||||
|
implements discovery, and [L934–940](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.0.25/src/loader/manifest_file.cpp#L934)
|
||||||
|
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](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/loader_core.cpp#L217).
|
||||||
|
The runtime can remain 1.0.
|
||||||
|
|
||||||
|
Source inspection at release-1.1.63 confirms:
|
||||||
|
|
||||||
|
- [manifest_file.cpp L720–783](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/manifest_file.cpp#L720)
|
||||||
|
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](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/manifest_file.cpp#L878)
|
||||||
|
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](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/manifest_file.cpp#L943),
|
||||||
|
which resolves against `GetAndroidNativeLibraryDir()`.
|
||||||
|
[loader_init_data.cpp L87–96](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/loader_init_data.cpp#L87)
|
||||||
|
obtains the asset manager and `ApplicationInfo.nativeLibraryDir` via JNI.
|
||||||
|
Wolvic's supplied manifest has `android:extractNativeLibs="true"`.
|
||||||
|
- [android_utilities.cpp L267–322](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/android_utilities.cpp#L267)
|
||||||
|
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](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/manifest_file.cpp#L1008)
|
||||||
|
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](https://developer.android.com/ndk/downloads)
|
||||||
|
on 2026-09-28. Downloaded
|
||||||
|
[android-ndk-r30-darwin.dmg](https://dl.google.com/android/repository/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.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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](evidence/ctest.txt): 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](evidence/readelf.txt) 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):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/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](evidence/apk-verification.txt).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"file_format_version": "1.0.0",
|
||||||
|
"api_layer": {
|
||||||
|
"name": "XR_APILAYER_FRAME_compat",
|
||||||
|
"library_path": "libXrApiLayer_FRAME_compat.so",
|
||||||
|
"api_version": "1.1",
|
||||||
|
"implementation_version": "1",
|
||||||
|
"description": "Steam Frame OpenXR 1.1 to 1.0 compatibility",
|
||||||
|
"disable_environment": "DISABLE_FRAME_XR_COMPAT",
|
||||||
|
"instance_extensions": [
|
||||||
|
{
|
||||||
|
"name": "XR_KHR_android_thread_settings",
|
||||||
|
"extension_version": "6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "XR_OCULUS_android_session_state_enable",
|
||||||
|
"extension_version": "1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
ndk=${ANDROID_NDK_HOME:-"$HOME/Library/Android/ndk/30.0.16248370"}
|
||||||
|
cmake -S "$here" -B "$here/build-android" -G Ninja \
|
||||||
|
-DCMAKE_TOOLCHAIN_FILE="$ndk/build/cmake/android.toolchain.cmake" \
|
||||||
|
-DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-24 \
|
||||||
|
-DANDROID_STL=c++_static -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build "$here/build-android"
|
||||||
|
mkdir -p "$here/prebuilt/arm64-v8a"
|
||||||
|
cp "$here/build-android/libXrApiLayer_FRAME_compat.so" "$here/prebuilt/arm64-v8a/"
|
||||||
|
shasum -a 256 "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <openxr/openxr.h>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace frame {
|
||||||
|
using Names = std::vector<std::string>;
|
||||||
|
inline constexpr const char* layerName = "XR_APILAYER_FRAME_compat";
|
||||||
|
inline constexpr const char* stubs[] = {"XR_KHR_android_thread_settings", "XR_OCULUS_android_session_state_enable"};
|
||||||
|
// OpenXR-Docs release-1.1.63 registry: promotedto="XR_VERSION_1_1".
|
||||||
|
inline constexpr const char* promotions[] = {
|
||||||
|
"XR_KHR_locate_spaces", "XR_EXT_local_floor", "XR_EXT_uuid", "XR_EXT_palm_pose",
|
||||||
|
"XR_VARJO_quad_views", "XR_EXT_samsung_odyssey_controller", "XR_EXT_hp_mixed_reality_controller",
|
||||||
|
"XR_HTC_vive_cosmos_controller_interaction", "XR_HTC_vive_focus3_controller_interaction",
|
||||||
|
"XR_ML_ml2_controller_interaction", "XR_FB_touch_controller_pro", "XR_META_touch_controller_plus",
|
||||||
|
"XR_BD_controller_interaction", "XR_KHR_maintenance1"};
|
||||||
|
inline bool has(const Names& names, const std::string& n) {
|
||||||
|
return std::find(names.begin(), names.end(), n) != names.end();
|
||||||
|
}
|
||||||
|
inline bool downgrade(XrVersion v) { return v >= XR_MAKE_VERSION(1, 1, 0); }
|
||||||
|
inline XrVersion runtimeVersion(XrVersion v) { return downgrade(v) ? XR_API_VERSION_1_0 : v; }
|
||||||
|
struct ExtensionPlan { Names downstream, stubbed, missing, added; };
|
||||||
|
inline ExtensionPlan extensions(const Names& requested, const Names& available, bool promote) {
|
||||||
|
ExtensionPlan p;
|
||||||
|
for (const auto& n : requested) {
|
||||||
|
if (!has(available, n) && (n == stubs[0] || n == stubs[1])) p.stubbed.push_back(n);
|
||||||
|
else {
|
||||||
|
if (!has(p.downstream, n)) p.downstream.push_back(n);
|
||||||
|
if (!has(available, n)) p.missing.push_back(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (promote) for (auto n : promotions) if (has(available, n) && !has(p.downstream, n)) {
|
||||||
|
p.downstream.push_back(n); p.added.push_back(n);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
// 1.1's grip_surface is XR_EXT_palm_pose's palm_ext renamed (same pose), not the grip pose.
|
||||||
|
inline std::string rewritePath(std::string p, bool palm) {
|
||||||
|
const std::string suffix = "/input/grip_surface/pose";
|
||||||
|
if (palm && (p == "/user/hand/left" + suffix || p == "/user/hand/right" + suffix))
|
||||||
|
p.replace(p.size() - suffix.size(), suffix.size(), "/input/palm_ext/pose");
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
// Profiles added to 1.1. The three legacy Meta splits have no 1.0 extension.
|
||||||
|
// The renamed Pro/Plus profiles have different component semantics; do not
|
||||||
|
// claim the old extension implements the new profile merely because it exists.
|
||||||
|
inline bool dropProfile(const std::string& p, const Names& enabled) {
|
||||||
|
for (auto name : {"touch_pro_controller", "touch_plus_controller", "touch_controller_rift_cv1",
|
||||||
|
"touch_controller_quest_1_rift_s", "touch_controller_quest_2"})
|
||||||
|
if (p == std::string("/interaction_profiles/meta/") + name) return true;
|
||||||
|
const char* profiles[][2] = {
|
||||||
|
{"samsung/odyssey_controller", "XR_EXT_samsung_odyssey_controller"},
|
||||||
|
{"hp/mixed_reality_controller", "XR_EXT_hp_mixed_reality_controller"},
|
||||||
|
{"htc/vive_cosmos_controller", "XR_HTC_vive_cosmos_controller_interaction"},
|
||||||
|
{"htc/vive_focus3_controller", "XR_HTC_vive_focus3_controller_interaction"},
|
||||||
|
{"ml/ml2_controller", "XR_ML_ml2_controller_interaction"},
|
||||||
|
{"bytedance/pico_neo3_controller", "XR_BD_controller_interaction"},
|
||||||
|
{"bytedance/pico4_controller", "XR_BD_controller_interaction"},
|
||||||
|
{"bytedance/pico_g3_controller", "XR_BD_controller_interaction"}};
|
||||||
|
for (auto& entry : profiles) if (p == std::string("/interaction_profiles/") + entry[0])
|
||||||
|
return !has(enabled, entry[1]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
inline XrStructureType extensionType(XrStructureType t) {
|
||||||
|
switch (t) {
|
||||||
|
case XR_TYPE_SPACES_LOCATE_INFO: return XR_TYPE_SPACES_LOCATE_INFO_KHR;
|
||||||
|
case XR_TYPE_SPACE_LOCATIONS: return XR_TYPE_SPACE_LOCATIONS_KHR;
|
||||||
|
case XR_TYPE_SPACE_VELOCITIES: return XR_TYPE_SPACE_VELOCITIES_KHR;
|
||||||
|
default: return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inline XrResult locate(PFN_xrLocateSpacesKHR next, XrSession session,
|
||||||
|
const XrSpacesLocateInfo* info, XrSpaceLocations* locations) {
|
||||||
|
if (!info || !locations) return XR_ERROR_VALIDATION_FAILURE;
|
||||||
|
static_assert(XR_TYPE_SPACE_VELOCITIES == XR_TYPE_SPACE_VELOCITIES_KHR);
|
||||||
|
static_assert(XR_TYPE_SPACE_LOCATIONS == XR_TYPE_SPACE_LOCATIONS_KHR);
|
||||||
|
static_assert(XR_TYPE_SPACES_LOCATE_INFO == XR_TYPE_SPACES_LOCATE_INFO_KHR);
|
||||||
|
auto in = *info;
|
||||||
|
auto out = *locations;
|
||||||
|
in.type = extensionType(in.type); out.type = extensionType(out.type);
|
||||||
|
// The enums and typedefs are exact aliases. All chained velocities,
|
||||||
|
// including unknown next structures, can be forwarded without mutation.
|
||||||
|
const auto result = next(session, &in, &out);
|
||||||
|
locations->locationCount = out.locationCount;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
2026-09-28, Mac only; no device connection.
|
||||||
|
APK: /tmp/vrapk/wolvic-quest-compat.apk
|
||||||
|
SHA-256: 355a681625e38b71563dcffecb031799eff27894d6ab910a659bace057e82c1f
|
||||||
|
|
||||||
|
Command:
|
||||||
|
/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
|
||||||
|
Exit status: 0
|
||||||
|
Relevant output:
|
||||||
|
- zipalign verified
|
||||||
|
- signature verified [v2, v3]
|
||||||
|
Successfully processed 1 APKs and 0 errors in 0.63 seconds.
|
||||||
|
|
||||||
|
ZIP readback checks: PASS
|
||||||
|
- Injected JSON identical to frame/openxr-compat/XrApiLayer_FRAME_compat.json.
|
||||||
|
- Injected SO identical to prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.
|
||||||
|
- Existing libopenxr_loader.so identical to /tmp/vrapk/wq-dec version.
|
||||||
|
- Binary AndroidManifest.xml contains android.intent.category.LAUNCHER.
|
||||||
|
|
||||||
|
Loader strings present:
|
||||||
|
openxr/1/api_layers/implicit.d/
|
||||||
|
openxr/1/api_layers/explicit.d/
|
||||||
|
ApiLayerManifestFile::AddManifestFilesAndroid unable to open asset
|
||||||
|
xrLocateSpaces
|
||||||
|
|
||||||
|
NDK: 30.0.16248370 (r30)
|
||||||
|
DMG size: 1072970961 bytes
|
||||||
|
Download-page SHA-1: 48591224b6657f46eebbc9d95d4af09bbed8d107
|
||||||
|
Measured SHA-1: 48591224b6657f46eebbc9d95d4af09bbed8d107
|
||||||
|
SHA check: PASS
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Test project /Users/saphid/projects/steam-frame-xrlayer/frame/openxr-compat/build-host
|
||||||
|
Start 1: compat_logic
|
||||||
|
1/2 Test #1: compat_logic ..................... Passed 0.01 sec
|
||||||
|
Start 2: layer_dispatch
|
||||||
|
2/2 Test #2: layer_dispatch ................... Passed 0.42 sec
|
||||||
|
|
||||||
|
100% tests passed out of 2
|
||||||
|
|
||||||
|
Total Test time (real) = 0.43 sec
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
|
||||||
|
Elf file type is DYN (Shared object file)
|
||||||
|
Entry point 0x0
|
||||||
|
There are 9 program headers, starting at offset 64
|
||||||
|
|
||||||
|
Program Headers:
|
||||||
|
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
|
||||||
|
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0001f8 0x0001f8 R 0x8
|
||||||
|
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x0169e0 0x0169e0 R E 0x4000
|
||||||
|
LOAD 0x0169e0 0x000000000001a9e0 0x000000000001a9e0 0x000a90 0x001620 RW 0x4000
|
||||||
|
LOAD 0x017470 0x000000000001f470 0x000000000001f470 0x000068 0x000bc0 RW 0x4000
|
||||||
|
DYNAMIC 0x017048 0x000000000001b048 0x000000000001b048 0x0001c0 0x0001c0 RW 0x8
|
||||||
|
GNU_RELRO 0x0169e0 0x000000000001a9e0 0x000000000001a9e0 0x000a90 0x001620 R 0x1
|
||||||
|
GNU_EH_FRAME 0x004fb4 0x0000000000004fb4 0x0000000000004fb4 0x00070c 0x00070c R 0x4
|
||||||
|
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x0
|
||||||
|
NOTE 0x000238 0x0000000000000238 0x0000000000000238 0x0000bc 0x0000bc R 0x4
|
||||||
|
|
||||||
|
Section to Segment mapping:
|
||||||
|
Segment Sections...
|
||||||
|
00
|
||||||
|
01 .note.android.ident .note.gnu.build-id .dynsym .gnu.version .gnu.version_r .gnu.hash .dynstr .rela.dyn .rela.plt .gcc_except_table .rodata .eh_frame_hdr .eh_frame .text __lcxx_override .plt
|
||||||
|
02 .data.rel.ro .fini_array .init_array .dynamic .got .got.plt .relro_padding
|
||||||
|
03 .data .bss
|
||||||
|
04 .dynamic
|
||||||
|
05 .data.rel.ro .fini_array .init_array .dynamic .got .got.plt .relro_padding
|
||||||
|
06 .eh_frame_hdr
|
||||||
|
07
|
||||||
|
08 .note.android.ident .note.gnu.build-id
|
||||||
|
None .comment .shstrtab
|
||||||
|
Dynamic section at offset 0x17048 contains 28 entries:
|
||||||
|
Tag Type Name/Value
|
||||||
|
0x0000000000000001 (NEEDED) Shared library: [liblog.so]
|
||||||
|
0x0000000000000001 (NEEDED) Shared library: [libm.so]
|
||||||
|
0x0000000000000001 (NEEDED) Shared library: [libdl.so]
|
||||||
|
0x0000000000000001 (NEEDED) Shared library: [libc.so]
|
||||||
|
0x000000000000000e (SONAME) Library soname: [libXrApiLayer_FRAME_compat.so]
|
||||||
|
0x000000000000001e (FLAGS) BIND_NOW
|
||||||
|
0x000000006ffffffb (FLAGS_1) NOW
|
||||||
|
0x0000000000000007 (RELA) 0xae8
|
||||||
|
0x0000000000000008 (RELASZ) 5424 (bytes)
|
||||||
|
0x0000000000000009 (RELAENT) 24 (bytes)
|
||||||
|
0x000000006ffffff9 (RELACOUNT) 225
|
||||||
|
0x0000000000000017 (JMPREL) 0x2018
|
||||||
|
0x0000000000000002 (PLTRELSZ) 1080 (bytes)
|
||||||
|
0x0000000000000003 (PLTGOT) 0x1b2f0
|
||||||
|
0x0000000000000014 (PLTREL) RELA
|
||||||
|
0x0000000000000006 (SYMTAB) 0x2f8
|
||||||
|
0x000000000000000b (SYMENT) 24 (bytes)
|
||||||
|
0x0000000000000005 (STRTAB) 0x838
|
||||||
|
0x000000000000000a (STRSZ) 681 (bytes)
|
||||||
|
0x000000006ffffef5 (GNU_HASH) 0x818
|
||||||
|
0x0000000000000019 (INIT_ARRAY) 0x1b030
|
||||||
|
0x000000000000001b (INIT_ARRAYSZ) 24 (bytes)
|
||||||
|
0x000000000000001a (FINI_ARRAY) 0x1b020
|
||||||
|
0x000000000000001c (FINI_ARRAYSZ) 16 (bytes)
|
||||||
|
0x000000006ffffff0 (VERSYM) 0x778
|
||||||
|
0x000000006ffffffe (VERNEED) 0x7d8
|
||||||
|
0x000000006fffffff (VERNEEDNUM) 2
|
||||||
|
0x0000000000000000 (NULL) 0x0
|
||||||
|
|
||||||
|
Symbol table '.dynsym' contains 48 entries:
|
||||||
|
Num: Value Size Type Bind Vis Ndx Name
|
||||||
|
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
|
||||||
|
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __cxa_finalize@LIBC
|
||||||
|
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __cxa_atexit@LIBC
|
||||||
|
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strcmp@LIBC
|
||||||
|
4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __android_log_print
|
||||||
|
5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __stack_chk_fail@LIBC
|
||||||
|
6: 0000000000000000 0 FUNC GLOBAL DEFAULT UND memmove@LIBC
|
||||||
|
7: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strlen@LIBC
|
||||||
|
8: 0000000000000000 0 FUNC GLOBAL DEFAULT UND memcpy@LIBC
|
||||||
|
9: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __strcpy_chk@LIBC
|
||||||
|
10: 0000000000000000 0 FUNC GLOBAL DEFAULT UND memset@LIBC
|
||||||
|
11: 0000000000000000 0 FUNC GLOBAL DEFAULT UND memcmp@LIBC
|
||||||
|
12: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_mutex_lock@LIBC
|
||||||
|
13: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_mutex_unlock@LIBC
|
||||||
|
14: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __errno@LIBC
|
||||||
|
15: 0000000000000000 0 FUNC GLOBAL DEFAULT UND snprintf@LIBC
|
||||||
|
16: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_mutex_destroy@LIBC
|
||||||
|
17: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strerror_r@LIBC
|
||||||
|
18: 0000000000000000 0 FUNC GLOBAL DEFAULT UND abort@LIBC
|
||||||
|
19: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_getspecific@LIBC
|
||||||
|
20: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_setspecific@LIBC
|
||||||
|
21: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_key_create@LIBC
|
||||||
|
22: 0000000000000000 0 FUNC GLOBAL DEFAULT UND syscall@LIBC
|
||||||
|
23: 0000000000000000 0 OBJECT GLOBAL DEFAULT UND __sF@LIBC
|
||||||
|
24: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fwrite@LIBC
|
||||||
|
25: 0000000000000000 0 FUNC GLOBAL DEFAULT UND vfprintf@LIBC
|
||||||
|
26: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fputc@LIBC
|
||||||
|
27: 0000000000000000 0 FUNC GLOBAL DEFAULT UND vasprintf@LIBC
|
||||||
|
28: 0000000000000000 0 FUNC GLOBAL DEFAULT UND android_set_abort_message@LIBC
|
||||||
|
29: 0000000000000000 0 FUNC GLOBAL DEFAULT UND openlog@LIBC
|
||||||
|
30: 0000000000000000 0 FUNC GLOBAL DEFAULT UND syslog@LIBC
|
||||||
|
31: 0000000000000000 0 FUNC GLOBAL DEFAULT UND closelog@LIBC
|
||||||
|
32: 0000000000000000 0 FUNC GLOBAL DEFAULT UND malloc@LIBC
|
||||||
|
33: 0000000000000000 0 FUNC GLOBAL DEFAULT UND free@LIBC
|
||||||
|
34: 0000000000000000 0 FUNC GLOBAL DEFAULT UND posix_memalign@LIBC
|
||||||
|
35: 0000000000000000 0 FUNC GLOBAL DEFAULT UND realloc@LIBC
|
||||||
|
36: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_once@LIBC
|
||||||
|
37: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_key_delete@LIBC
|
||||||
|
38: 0000000000000000 0 FUNC GLOBAL DEFAULT UND getauxval@LIBC
|
||||||
|
39: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __system_property_get@LIBC
|
||||||
|
40: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strncmp@LIBC
|
||||||
|
41: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fprintf@LIBC
|
||||||
|
42: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fflush@LIBC
|
||||||
|
43: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_rwlock_wrlock@LIBC
|
||||||
|
44: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_rwlock_unlock@LIBC
|
||||||
|
45: 0000000000000000 0 FUNC GLOBAL DEFAULT UND dl_iterate_phdr@LIBC
|
||||||
|
46: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_rwlock_rdlock@LIBC
|
||||||
|
47: 00000000000081a0 284 FUNC GLOBAL DEFAULT 14 xrNegotiateLoaderApiLayerInterface
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
#include "compat_logic.h"
|
||||||
|
#include <loader_interfaces.h>
|
||||||
|
#include <jni.h>
|
||||||
|
#include <openxr/openxr_platform.h>
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, "FrameXrCompat", __VA_ARGS__)
|
||||||
|
#define GUARD_END catch (const std::bad_alloc&) { return XR_ERROR_OUT_OF_MEMORY; } catch (...) { return XR_ERROR_RUNTIME_FAILURE; }
|
||||||
|
namespace {
|
||||||
|
struct Dispatch {
|
||||||
|
PFN_xrGetInstanceProcAddr gipa{};
|
||||||
|
PFN_xrDestroyInstance destroy{};
|
||||||
|
PFN_xrCreateSession createSession{};
|
||||||
|
PFN_xrDestroySession destroySession{};
|
||||||
|
PFN_xrLocateSpacesKHR locate{};
|
||||||
|
PFN_xrStringToPath stringToPath{};
|
||||||
|
PFN_xrPathToString pathToString{};
|
||||||
|
PFN_xrSuggestInteractionProfileBindings suggest{};
|
||||||
|
PFN_xrSetAndroidApplicationThreadKHR thread{};
|
||||||
|
PFN_xrRequestDisplayRefreshRateFB refresh{};
|
||||||
|
frame::Names enabled, stubbed;
|
||||||
|
XrInstance instance{};
|
||||||
|
bool promote{}, palm{};
|
||||||
|
};
|
||||||
|
std::mutex mutex;
|
||||||
|
std::unordered_map<XrInstance, std::shared_ptr<Dispatch>> instances;
|
||||||
|
std::unordered_map<XrSession, std::shared_ptr<Dispatch>> sessions;
|
||||||
|
// No runtime library is opened here: every call goes through the next layer.
|
||||||
|
PFN_xrEnumerateInstanceExtensionProperties enumerateNext{};
|
||||||
|
template<class T> T proc(PFN_xrGetInstanceProcAddr gipa, XrInstance i, const char* name) {
|
||||||
|
PFN_xrVoidFunction f{};
|
||||||
|
if (XR_FAILED(gipa(i, name, &f))) return nullptr;
|
||||||
|
return reinterpret_cast<T>(f);
|
||||||
|
}
|
||||||
|
std::shared_ptr<Dispatch> get(XrInstance i) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex);
|
||||||
|
auto it = instances.find(i); return it == instances.end() ? nullptr : it->second;
|
||||||
|
}
|
||||||
|
std::shared_ptr<Dispatch> get(XrSession s) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex);
|
||||||
|
auto it = sessions.find(s); return it == sessions.end() ? nullptr : it->second;
|
||||||
|
}
|
||||||
|
XrResult properties(PFN_xrEnumerateInstanceExtensionProperties fn,
|
||||||
|
std::vector<XrExtensionProperties>& out) {
|
||||||
|
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||||
|
for (int attempt = 0; attempt < 4; ++attempt) {
|
||||||
|
uint32_t n{};
|
||||||
|
auto r = fn(nullptr, 0, &n, nullptr);
|
||||||
|
if (XR_FAILED(r)) return r;
|
||||||
|
out.assign(n, {XR_TYPE_EXTENSION_PROPERTIES, nullptr, {}, 0});
|
||||||
|
if (!n) return XR_SUCCESS;
|
||||||
|
r = fn(nullptr, n, &n, out.data());
|
||||||
|
if (r == XR_ERROR_SIZE_INSUFFICIENT) continue;
|
||||||
|
if (XR_FAILED(r)) return r;
|
||||||
|
out.resize(n); return XR_SUCCESS;
|
||||||
|
}
|
||||||
|
return XR_ERROR_RUNTIME_FAILURE;
|
||||||
|
}
|
||||||
|
XrResult XRAPI_CALL enumerate(const char* layer, uint32_t capacity, uint32_t* count,
|
||||||
|
XrExtensionProperties* output) try {
|
||||||
|
if (!count || (capacity && !output)) return XR_ERROR_VALIDATION_FAILURE;
|
||||||
|
const bool own = layer && std::strcmp(layer, frame::layerName) == 0;
|
||||||
|
PFN_xrEnumerateInstanceExtensionProperties next;
|
||||||
|
{ std::lock_guard<std::mutex> lock(mutex); next = enumerateNext; }
|
||||||
|
if (layer && *layer && !own) return next ? next(layer, capacity, count, output) : XR_ERROR_API_LAYER_NOT_PRESENT;
|
||||||
|
std::vector<XrExtensionProperties> all;
|
||||||
|
if (!own && next) {
|
||||||
|
auto r = properties(next, all); if (XR_FAILED(r)) return r;
|
||||||
|
}
|
||||||
|
for (unsigned i = 0; i < 2; ++i) {
|
||||||
|
bool found = false;
|
||||||
|
for (auto& e : all) if (!std::strcmp(e.extensionName, frame::stubs[i])) found = true;
|
||||||
|
if (!found) {
|
||||||
|
XrExtensionProperties p{XR_TYPE_EXTENSION_PROPERTIES, nullptr, {}, i == 0 ? XR_KHR_android_thread_settings_SPEC_VERSION : 1u};
|
||||||
|
std::strcpy(p.extensionName, frame::stubs[i]); all.push_back(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*count = static_cast<uint32_t>(all.size());
|
||||||
|
LOG("enumerate extensions: %u (includes stubs)", *count);
|
||||||
|
if (!capacity) return XR_SUCCESS;
|
||||||
|
if (capacity < all.size()) return XR_ERROR_SIZE_INSUFFICIENT;
|
||||||
|
for (size_t i = 0; i < all.size(); ++i) {
|
||||||
|
if (output[i].type != XR_TYPE_EXTENSION_PROPERTIES) return XR_ERROR_VALIDATION_FAILURE;
|
||||||
|
std::strcpy(output[i].extensionName, all[i].extensionName);
|
||||||
|
output[i].extensionVersion = all[i].extensionVersion;
|
||||||
|
}
|
||||||
|
return XR_SUCCESS;
|
||||||
|
} GUARD_END
|
||||||
|
XrResult XRAPI_CALL destroyInstance(XrInstance i) try {
|
||||||
|
auto d = get(i); if (!d) return XR_ERROR_HANDLE_INVALID;
|
||||||
|
auto r = d->destroy(i);
|
||||||
|
if (XR_SUCCEEDED(r)) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex);
|
||||||
|
for (auto it = sessions.begin(); it != sessions.end();) {
|
||||||
|
if (it->second == d) it = sessions.erase(it); else ++it;
|
||||||
|
}
|
||||||
|
instances.erase(i);
|
||||||
|
if (instances.empty()) enumerateNext = nullptr;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
} GUARD_END
|
||||||
|
XrResult XRAPI_CALL createSession(XrInstance i, const XrSessionCreateInfo* info, XrSession* s) try {
|
||||||
|
auto d = get(i); if (!d) return XR_ERROR_HANDLE_INVALID;
|
||||||
|
auto r = d->createSession(i, info, s);
|
||||||
|
if (XR_SUCCEEDED(r)) {
|
||||||
|
try { std::lock_guard<std::mutex> lock(mutex); sessions.emplace(*s, d); }
|
||||||
|
catch (...) { d->destroySession(*s); *s = XR_NULL_HANDLE; throw; }
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
} GUARD_END
|
||||||
|
XrResult XRAPI_CALL destroySession(XrSession s) try {
|
||||||
|
auto d = get(s); if (!d) return XR_ERROR_HANDLE_INVALID;
|
||||||
|
auto r = d->destroySession(s);
|
||||||
|
if (XR_SUCCEEDED(r)) { std::lock_guard<std::mutex> lock(mutex); sessions.erase(s); }
|
||||||
|
return r;
|
||||||
|
} GUARD_END
|
||||||
|
XrResult XRAPI_CALL locateSpaces(XrSession s, const XrSpacesLocateInfo* in, XrSpaceLocations* out) try {
|
||||||
|
auto d = get(s); if (!d) return XR_ERROR_HANDLE_INVALID;
|
||||||
|
if (!d->locate) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||||
|
static std::once_flag logged; // called every frame
|
||||||
|
std::call_once(logged, [] { LOG("xrLocateSpaces -> xrLocateSpacesKHR (core/KHR type aliases)"); });
|
||||||
|
return frame::locate(d->locate, s, in, out);
|
||||||
|
} GUARD_END
|
||||||
|
XrResult XRAPI_CALL threadSettings(XrSession s, XrAndroidThreadTypeKHR type, uint32_t tid) try {
|
||||||
|
auto d = get(s); if (!d) return XR_ERROR_HANDLE_INVALID;
|
||||||
|
if (frame::has(d->stubbed, frame::stubs[0])) {
|
||||||
|
LOG("stub xrSetAndroidApplicationThreadKHR type=%d tid=%u -> XR_SUCCESS (no scheduling change)", type, tid);
|
||||||
|
return XR_SUCCESS;
|
||||||
|
}
|
||||||
|
return d->thread ? d->thread(s, type, tid) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||||
|
} GUARD_END
|
||||||
|
// SteamVR offers only the current refresh rate; Quest apps ask for 72/90/120 Hz
|
||||||
|
// and abort on the error, so keep the current rate and report success.
|
||||||
|
XrResult XRAPI_CALL requestRefreshRate(XrSession s, float hz) try {
|
||||||
|
auto d = get(s); if (!d) return XR_ERROR_HANDLE_INVALID;
|
||||||
|
auto r = d->refresh(s, hz);
|
||||||
|
if (r == XR_ERROR_DISPLAY_REFRESH_RATE_UNSUPPORTED_FB) {
|
||||||
|
LOG("xrRequestDisplayRefreshRateFB %.1f Hz unsupported; keeping the current rate", hz);
|
||||||
|
return XR_SUCCESS;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
} GUARD_END
|
||||||
|
XrResult XRAPI_CALL stringToPath(XrInstance i, const char* path, XrPath* out) try {
|
||||||
|
auto d = get(i); if (!d) return XR_ERROR_HANDLE_INVALID;
|
||||||
|
if (!path) return XR_ERROR_VALIDATION_FAILURE;
|
||||||
|
auto rewritten = frame::rewritePath(path, d->palm);
|
||||||
|
if (rewritten != path) LOG("path %s -> %s", path, rewritten.c_str());
|
||||||
|
return d->stringToPath(i, rewritten.c_str(), out);
|
||||||
|
} GUARD_END
|
||||||
|
XrResult pathString(const Dispatch& d, XrPath path, std::string& out) {
|
||||||
|
uint32_t n{};
|
||||||
|
auto r = d.pathToString(d.instance, path, 0, &n, nullptr);
|
||||||
|
if (XR_FAILED(r)) return r;
|
||||||
|
std::vector<char> text(n);
|
||||||
|
r = d.pathToString(d.instance, path, n, &n, text.data());
|
||||||
|
if (XR_SUCCEEDED(r)) out.assign(text.data());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
XrResult XRAPI_CALL suggest(XrInstance i, const XrInteractionProfileSuggestedBinding* info) try {
|
||||||
|
auto d = get(i); if (!d) return XR_ERROR_HANDLE_INVALID;
|
||||||
|
if (!info || (info->countSuggestedBindings && !info->suggestedBindings)) return XR_ERROR_VALIDATION_FAILURE;
|
||||||
|
std::string profile;
|
||||||
|
auto r = pathString(*d, info->interactionProfile, profile);
|
||||||
|
if (XR_FAILED(r)) return r;
|
||||||
|
if (d->promote && frame::dropProfile(profile, d->enabled)) {
|
||||||
|
LOG("drop unsupported 1.1 interaction profile %s (%u suggestions)", profile.c_str(), info->countSuggestedBindings);
|
||||||
|
return XR_SUCCESS;
|
||||||
|
}
|
||||||
|
auto copy = *info;
|
||||||
|
std::vector<XrActionSuggestedBinding> bindings;
|
||||||
|
for (uint32_t index = 0; index < info->countSuggestedBindings; ++index) {
|
||||||
|
auto b = info->suggestedBindings[index];
|
||||||
|
std::string path;
|
||||||
|
r = pathString(*d, b.binding, path); if (XR_FAILED(r)) return r;
|
||||||
|
auto rewritten = frame::rewritePath(path, d->palm);
|
||||||
|
if (rewritten != path) {
|
||||||
|
LOG("binding %s -> %s", path.c_str(), rewritten.c_str());
|
||||||
|
r = d->stringToPath(i, rewritten.c_str(), &b.binding); if (XR_FAILED(r)) return r;
|
||||||
|
}
|
||||||
|
bindings.push_back(b);
|
||||||
|
}
|
||||||
|
copy.suggestedBindings = bindings.data();
|
||||||
|
return d->suggest(i, ©);
|
||||||
|
} GUARD_END
|
||||||
|
XrResult XRAPI_CALL gipa(XrInstance, const char*, PFN_xrVoidFunction*);
|
||||||
|
XrResult XRAPI_CALL createLayer(const XrInstanceCreateInfo* info, const XrApiLayerCreateInfo* layer,
|
||||||
|
XrInstance* instance) try {
|
||||||
|
if (!info || !instance || !layer || layer->structType != XR_LOADER_INTERFACE_STRUCT_API_LAYER_CREATE_INFO ||
|
||||||
|
layer->structVersion != XR_API_LAYER_CREATE_INFO_STRUCT_VERSION || layer->structSize != sizeof(*layer) ||
|
||||||
|
!layer->nextInfo || layer->nextInfo->structType != XR_LOADER_INTERFACE_STRUCT_API_LAYER_NEXT_INFO ||
|
||||||
|
layer->nextInfo->structVersion != XR_API_LAYER_NEXT_INFO_STRUCT_VERSION ||
|
||||||
|
layer->nextInfo->structSize != sizeof(XrApiLayerNextInfo) ||
|
||||||
|
!layer->nextInfo->nextGetInstanceProcAddr || !layer->nextInfo->nextCreateApiLayerInstance ||
|
||||||
|
(info->enabledExtensionCount && !info->enabledExtensionNames)) return XR_ERROR_INITIALIZATION_FAILED;
|
||||||
|
*instance = XR_NULL_HANDLE;
|
||||||
|
auto next = layer->nextInfo->nextGetInstanceProcAddr;
|
||||||
|
auto enumFn = proc<PFN_xrEnumerateInstanceExtensionProperties>(next, XR_NULL_HANDLE, "xrEnumerateInstanceExtensionProperties");
|
||||||
|
std::vector<XrExtensionProperties> available;
|
||||||
|
auto r = properties(enumFn, available); if (XR_FAILED(r)) { LOG("runtime extension enumeration failed: %d", r); return r; }
|
||||||
|
frame::Names requested, supported;
|
||||||
|
for (auto& p : available) supported.emplace_back(p.extensionName);
|
||||||
|
for (uint32_t n = 0; n < info->enabledExtensionCount; ++n) {
|
||||||
|
if (!info->enabledExtensionNames[n]) return XR_ERROR_VALIDATION_FAILURE;
|
||||||
|
requested.emplace_back(info->enabledExtensionNames[n]);
|
||||||
|
}
|
||||||
|
auto d = std::make_shared<Dispatch>();
|
||||||
|
d->promote = frame::downgrade(info->applicationInfo.apiVersion);
|
||||||
|
auto plan = frame::extensions(requested, supported, d->promote);
|
||||||
|
for (auto& n : plan.added) LOG("enable promoted extension %s", n.c_str());
|
||||||
|
for (auto& n : plan.stubbed) LOG("emulate missing extension %s; remove downstream", n.c_str());
|
||||||
|
for (auto& n : plan.missing) LOG("unsupported extension %s retained; runtime must reject", n.c_str());
|
||||||
|
auto copy = *info;
|
||||||
|
copy.applicationInfo.apiVersion = frame::runtimeVersion(info->applicationInfo.apiVersion);
|
||||||
|
if (d->promote) LOG("API %u.%u.%u -> 1.0.%u", unsigned(XR_VERSION_MAJOR(info->applicationInfo.apiVersion)),
|
||||||
|
unsigned(XR_VERSION_MINOR(info->applicationInfo.apiVersion)), unsigned(XR_VERSION_PATCH(info->applicationInfo.apiVersion)),
|
||||||
|
unsigned(XR_VERSION_PATCH(copy.applicationInfo.apiVersion)));
|
||||||
|
std::vector<const char*> names;
|
||||||
|
for (auto& n : plan.downstream) names.push_back(n.c_str());
|
||||||
|
copy.enabledExtensionCount = static_cast<uint32_t>(names.size()); copy.enabledExtensionNames = names.data();
|
||||||
|
auto chain = *layer; chain.nextInfo = layer->nextInfo->next;
|
||||||
|
r = layer->nextInfo->nextCreateApiLayerInstance(©, &chain, instance);
|
||||||
|
if (XR_FAILED(r)) { LOG("downstream xrCreateInstance -> %d", r); return r; }
|
||||||
|
d->gipa = next; d->instance = *instance;
|
||||||
|
d->destroy = proc<PFN_xrDestroyInstance>(next, *instance, "xrDestroyInstance");
|
||||||
|
// A successful runtime instance must expose xrDestroyInstance.
|
||||||
|
if (!d->destroy) { // nothing could ever destroy it, so don't hand it out
|
||||||
|
LOG("invalid downstream: missing xrDestroyInstance"); *instance = XR_NULL_HANDLE; return XR_ERROR_INITIALIZATION_FAILED;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
d->enabled = std::move(plan.downstream); d->stubbed = std::move(plan.stubbed);
|
||||||
|
d->palm = frame::has(d->enabled, "XR_EXT_palm_pose");
|
||||||
|
#define LOAD(field, name) d->field = proc<PFN_##name>(next, *instance, #name)
|
||||||
|
LOAD(createSession, xrCreateSession); LOAD(destroySession, xrDestroySession);
|
||||||
|
LOAD(stringToPath, xrStringToPath); LOAD(pathToString, xrPathToString);
|
||||||
|
LOAD(suggest, xrSuggestInteractionProfileBindings);
|
||||||
|
if (frame::has(d->enabled, "XR_KHR_locate_spaces")) LOAD(locate, xrLocateSpacesKHR);
|
||||||
|
if (frame::has(d->enabled, frame::stubs[0])) LOAD(thread, xrSetAndroidApplicationThreadKHR);
|
||||||
|
if (frame::has(d->enabled, "XR_FB_display_refresh_rate")) LOAD(refresh, xrRequestDisplayRefreshRateFB);
|
||||||
|
#undef LOAD
|
||||||
|
if (!d->createSession || !d->destroySession || !d->stringToPath || !d->pathToString || !d->suggest) {
|
||||||
|
d->destroy(*instance); *instance = XR_NULL_HANDLE; return XR_ERROR_INITIALIZATION_FAILED;
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(mutex);
|
||||||
|
instances.emplace(*instance, d); enumerateNext = enumFn;
|
||||||
|
} catch (...) { d->destroy(*instance); *instance = XR_NULL_HANDLE; throw; }
|
||||||
|
LOG("created instance; next-layer dispatch retained (Valve layer coexists)");
|
||||||
|
return r;
|
||||||
|
} GUARD_END
|
||||||
|
XrResult XRAPI_CALL gipa(XrInstance i, const char* name, PFN_xrVoidFunction* out) try {
|
||||||
|
if (!name || !out) return XR_ERROR_VALIDATION_FAILURE;
|
||||||
|
*out = nullptr;
|
||||||
|
#define RETURN_PROC(n, fn) if (!std::strcmp(name, n)) { *out = reinterpret_cast<PFN_xrVoidFunction>(fn); return XR_SUCCESS; }
|
||||||
|
RETURN_PROC("xrGetInstanceProcAddr", gipa)
|
||||||
|
RETURN_PROC("xrCreateApiLayerInstance", createLayer)
|
||||||
|
RETURN_PROC("xrEnumerateInstanceExtensionProperties", enumerate)
|
||||||
|
auto d = get(i); if (!d) return XR_ERROR_HANDLE_INVALID;
|
||||||
|
RETURN_PROC("xrDestroyInstance", destroyInstance)
|
||||||
|
RETURN_PROC("xrCreateSession", createSession)
|
||||||
|
RETURN_PROC("xrDestroySession", destroySession)
|
||||||
|
RETURN_PROC("xrStringToPath", stringToPath)
|
||||||
|
RETURN_PROC("xrSuggestInteractionProfileBindings", suggest)
|
||||||
|
if (d->locate && d->promote) { RETURN_PROC("xrLocateSpaces", locateSpaces) }
|
||||||
|
if (frame::has(d->stubbed, frame::stubs[0])) { RETURN_PROC("xrSetAndroidApplicationThreadKHR", threadSettings) }
|
||||||
|
if (d->refresh) { RETURN_PROC("xrRequestDisplayRefreshRateFB", requestRefreshRate) }
|
||||||
|
#undef RETURN_PROC
|
||||||
|
// Includes xrGetInstanceProperties: report the actual runtime's identity.
|
||||||
|
return d->gipa(i, name, out);
|
||||||
|
} GUARD_END
|
||||||
|
} // namespace
|
||||||
|
extern "C" __attribute__((visibility("default"))) XrResult XRAPI_CALL xrNegotiateLoaderApiLayerInterface(
|
||||||
|
const XrNegotiateLoaderInfo* loader, const char* name, XrNegotiateApiLayerRequest* request) {
|
||||||
|
if (!loader || !name || !request || std::strcmp(name, frame::layerName) ||
|
||||||
|
loader->structType != XR_LOADER_INTERFACE_STRUCT_LOADER_INFO ||
|
||||||
|
loader->structVersion != XR_LOADER_INFO_STRUCT_VERSION || loader->structSize != sizeof(*loader) ||
|
||||||
|
request->structType != XR_LOADER_INTERFACE_STRUCT_API_LAYER_REQUEST ||
|
||||||
|
request->structVersion != XR_API_LAYER_INFO_STRUCT_VERSION || request->structSize != sizeof(*request) ||
|
||||||
|
loader->minInterfaceVersion > 1 || loader->maxInterfaceVersion < 1 ||
|
||||||
|
loader->minApiVersion > XR_CURRENT_API_VERSION || loader->maxApiVersion < XR_MAKE_VERSION(1, 1, 0))
|
||||||
|
return XR_ERROR_INITIALIZATION_FAILED;
|
||||||
|
request->layerInterfaceVersion = 1;
|
||||||
|
request->layerApiVersion = std::min<XrVersion>(XR_CURRENT_API_VERSION, loader->maxApiVersion);
|
||||||
|
request->getInstanceProcAddr = gipa; request->createApiLayerInstance = createLayer;
|
||||||
|
LOG("negotiated interface 1, API 1.1");
|
||||||
|
return XR_SUCCESS;
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,70 @@
|
|||||||
|
#include "compat_logic.h"
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
#include <type_traits>
|
||||||
|
#define CHECK(expr) do { if (!(expr)) { std::cerr << __LINE__ << ": " #expr "\n"; std::exit(1); } } while (false)
|
||||||
|
namespace {
|
||||||
|
void* sentinel = reinterpret_cast<void*>(0x1234);
|
||||||
|
XrResult XRAPI_CALL fakeLocate(XrSession, const XrSpacesLocateInfo* in, XrSpaceLocations* out) {
|
||||||
|
CHECK(in->type == XR_TYPE_SPACES_LOCATE_INFO_KHR);
|
||||||
|
CHECK(out->type == XR_TYPE_SPACE_LOCATIONS_KHR);
|
||||||
|
CHECK(in->next == sentinel);
|
||||||
|
auto* velocities = static_cast<XrSpaceVelocitiesKHR*>(out->next);
|
||||||
|
CHECK(velocities->type == XR_TYPE_SPACE_VELOCITIES_KHR);
|
||||||
|
CHECK(velocities->next == sentinel);
|
||||||
|
CHECK(in->spaceCount == 1 && in->time == 123);
|
||||||
|
out->locations[0].pose.position.x = 42;
|
||||||
|
velocities->velocities[0].linearVelocity.y = 7;
|
||||||
|
return XR_SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int main() {
|
||||||
|
using namespace frame;
|
||||||
|
CHECK(!downgrade(XR_MAKE_VERSION(1, 0, 99)));
|
||||||
|
CHECK(runtimeVersion(XR_MAKE_VERSION(1, 0, 42)) == XR_MAKE_VERSION(1, 0, 42));
|
||||||
|
CHECK(runtimeVersion(XR_MAKE_VERSION(1, 1, 0)) == XR_API_VERSION_1_0);
|
||||||
|
CHECK(runtimeVersion(XR_MAKE_VERSION(1, 1, 999)) == XR_API_VERSION_1_0);
|
||||||
|
CHECK(runtimeVersion(XR_MAKE_VERSION(2, 0, 0)) == XR_API_VERSION_1_0);
|
||||||
|
Names available{"XR_KHR_locate_spaces", "XR_EXT_palm_pose", "XR_EXT_hand_interaction", "XR_VARJO_quad_views"};
|
||||||
|
Names requested{stubs[0], stubs[1], "XR_MISSING_test", "XR_EXT_palm_pose"};
|
||||||
|
auto p = extensions(requested, available, true);
|
||||||
|
CHECK(p.stubbed.size() == 2 && p.missing == Names{"XR_MISSING_test"});
|
||||||
|
CHECK(has(p.downstream, "XR_MISSING_test"));
|
||||||
|
CHECK(has(p.downstream, "XR_KHR_locate_spaces") && has(p.downstream, "XR_VARJO_quad_views"));
|
||||||
|
CHECK(!has(p.downstream, "XR_EXT_hand_interaction"));
|
||||||
|
CHECK(!has(p.downstream, "XR_EXT_local_floor"));
|
||||||
|
CHECK(std::count(p.downstream.begin(), p.downstream.end(), "XR_EXT_palm_pose") == 1);
|
||||||
|
available.push_back(stubs[0]);
|
||||||
|
CHECK(extensions(requested, available, true).stubbed == Names{stubs[1]});
|
||||||
|
CHECK(extensions({}, available, false).downstream.empty());
|
||||||
|
CHECK(rewritePath("/user/hand/left/input/grip_surface/pose", true) == "/user/hand/left/input/palm_ext/pose");
|
||||||
|
CHECK(rewritePath("/user/hand/right/input/grip_surface/pose", true) == "/user/hand/right/input/palm_ext/pose");
|
||||||
|
CHECK(rewritePath("/user/hand/left/input/grip_surface/pose", false) == "/user/hand/left/input/grip_surface/pose");
|
||||||
|
CHECK(rewritePath("/user/hand/left/input/grip_surface/pose/extra", true) == "/user/hand/left/input/grip_surface/pose/extra");
|
||||||
|
CHECK(rewritePath("/user/hand/left/input/grip/pose", true) == "/user/hand/left/input/grip/pose");
|
||||||
|
CHECK(dropProfile("/interaction_profiles/meta/touch_pro_controller", {}));
|
||||||
|
CHECK(dropProfile("/interaction_profiles/meta/touch_controller_quest_2", {}));
|
||||||
|
CHECK(dropProfile("/interaction_profiles/hp/mixed_reality_controller", {}));
|
||||||
|
CHECK(!dropProfile("/interaction_profiles/hp/mixed_reality_controller", {"XR_EXT_hp_mixed_reality_controller"}));
|
||||||
|
CHECK(!dropProfile("/interaction_profiles/oculus/touch_controller", {}));
|
||||||
|
CHECK(!dropProfile("/interaction_profiles/khr/simple_controller", {}));
|
||||||
|
CHECK(!dropProfile("/interaction_profiles/valve/frame_controller", {}));
|
||||||
|
CHECK(!dropProfile("/interaction_profiles/ext/hand_interaction_ext", {}));
|
||||||
|
CHECK(extensionType(XR_TYPE_SPACES_LOCATE_INFO) == XR_TYPE_SPACES_LOCATE_INFO_KHR);
|
||||||
|
CHECK(extensionType(XR_TYPE_SPACE_LOCATIONS) == XR_TYPE_SPACE_LOCATIONS_KHR);
|
||||||
|
CHECK(extensionType(XR_TYPE_SPACE_VELOCITIES) == XR_TYPE_SPACE_VELOCITIES_KHR);
|
||||||
|
CHECK(extensionType(XR_TYPE_INSTANCE_CREATE_INFO) == XR_TYPE_INSTANCE_CREATE_INFO);
|
||||||
|
static_assert(std::is_same_v<XrSpaceVelocities, XrSpaceVelocitiesKHR>);
|
||||||
|
XrSpace space{};
|
||||||
|
XrSpaceLocationData data{};
|
||||||
|
XrSpaceVelocityData velocity{};
|
||||||
|
XrSpaceVelocities velocities{XR_TYPE_SPACE_VELOCITIES, sentinel, 1, &velocity};
|
||||||
|
XrSpaceLocations locations{XR_TYPE_SPACE_LOCATIONS, &velocities, 1, &data};
|
||||||
|
XrSpacesLocateInfo info{XR_TYPE_SPACES_LOCATE_INFO, sentinel, XR_NULL_HANDLE, 123, 1, &space};
|
||||||
|
CHECK(locate(fakeLocate, XR_NULL_HANDLE, &info, &locations) == XR_SUCCESS);
|
||||||
|
CHECK(data.pose.position.x == 42 && velocity.linearVelocity.y == 7);
|
||||||
|
CHECK(info.next == sentinel && locations.next == &velocities && velocities.next == sentinel);
|
||||||
|
CHECK(info.type == XR_TYPE_SPACES_LOCATE_INFO && locations.type == XR_TYPE_SPACE_LOCATIONS && velocities.type == XR_TYPE_SPACE_VELOCITIES);
|
||||||
|
CHECK(locate(fakeLocate, XR_NULL_HANDLE, nullptr, &locations) == XR_ERROR_VALIDATION_FAILURE);
|
||||||
|
std::cout << "All version, extension, path/profile and locate-chain checks passed\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Compile the actual layer with host-only JNI/logging shims, and a fake next
|
||||||
|
// layer. This exercises ABI routing without a runtime or a headset.
|
||||||
|
#include "../layer.cpp"
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
#define CHECK(expr) do { if (!(expr)) { std::cerr << __LINE__ << ": " #expr "\n"; std::exit(1); } } while (false)
|
||||||
|
namespace {
|
||||||
|
uintptr_t serial = 100;
|
||||||
|
frame::Names seenExtensions;
|
||||||
|
XrVersion seenVersion{};
|
||||||
|
XrApiLayerNextInfo* seenNext{};
|
||||||
|
int suggestionCalls{}, locateCalls{}, realThreadCalls{};
|
||||||
|
bool nativeThread{};
|
||||||
|
std::unordered_map<XrPath, std::string> paths;
|
||||||
|
XrResult XRAPI_CALL fakeEnumerate(const char*, uint32_t capacity, uint32_t* count, XrExtensionProperties* out) {
|
||||||
|
const char* extensions[] = {"XR_KHR_locate_spaces", "XR_EXT_palm_pose", frame::stubs[0]};
|
||||||
|
*count = nativeThread ? 3 : 2;
|
||||||
|
if (!capacity) return XR_SUCCESS;
|
||||||
|
if (capacity < *count) return XR_ERROR_SIZE_INSUFFICIENT;
|
||||||
|
for (uint32_t j = 0; j < *count; ++j) { std::strcpy(out[j].extensionName, extensions[j]); out[j].extensionVersion = 1; }
|
||||||
|
return XR_SUCCESS;
|
||||||
|
}
|
||||||
|
XrResult XRAPI_CALL fakeCreate(const XrInstanceCreateInfo* in, const XrApiLayerCreateInfo* layer, XrInstance* i) {
|
||||||
|
seenVersion = in->applicationInfo.apiVersion; seenNext = layer->nextInfo;
|
||||||
|
seenExtensions.clear();
|
||||||
|
for (uint32_t j = 0; j < in->enabledExtensionCount; ++j) seenExtensions.emplace_back(in->enabledExtensionNames[j]);
|
||||||
|
if (frame::has(seenExtensions, "XR_MISSING_test")) return XR_ERROR_EXTENSION_NOT_PRESENT;
|
||||||
|
*i = reinterpret_cast<XrInstance>(++serial); return XR_SUCCESS;
|
||||||
|
}
|
||||||
|
XrResult XRAPI_CALL fakeDestroy(XrInstance) { return XR_SUCCESS; }
|
||||||
|
XrResult XRAPI_CALL fakeCreateSession(XrInstance, const XrSessionCreateInfo*, XrSession* s) {
|
||||||
|
*s = reinterpret_cast<XrSession>(++serial); return XR_SUCCESS;
|
||||||
|
}
|
||||||
|
XrResult XRAPI_CALL fakeDestroySession(XrSession) { return XR_SUCCESS; }
|
||||||
|
XrResult XRAPI_CALL fakeString(XrInstance, const char* text, XrPath* p) {
|
||||||
|
*p = ++serial; paths[*p] = text; return XR_SUCCESS;
|
||||||
|
}
|
||||||
|
XrResult XRAPI_CALL fakePath(XrInstance, XrPath p, uint32_t cap, uint32_t* count, char* out) {
|
||||||
|
if (!paths.count(p)) return XR_ERROR_PATH_INVALID;
|
||||||
|
*count = static_cast<uint32_t>(paths[p].size() + 1);
|
||||||
|
if (!cap) return XR_SUCCESS;
|
||||||
|
if (cap < *count) return XR_ERROR_SIZE_INSUFFICIENT;
|
||||||
|
std::strcpy(out, paths[p].c_str()); return XR_SUCCESS;
|
||||||
|
}
|
||||||
|
XrResult XRAPI_CALL fakeSuggest(XrInstance, const XrInteractionProfileSuggestedBinding* info) {
|
||||||
|
++suggestionCalls;
|
||||||
|
if (info->countSuggestedBindings) CHECK(paths[info->suggestedBindings[0].binding] == "/user/hand/left/input/palm_ext/pose");
|
||||||
|
return XR_SUCCESS;
|
||||||
|
}
|
||||||
|
XrResult XRAPI_CALL fakeLocate(XrSession, const XrSpacesLocateInfo*, XrSpaceLocations*) { ++locateCalls; return XR_SUCCESS; }
|
||||||
|
XrResult XRAPI_CALL fakeThread(XrSession, XrAndroidThreadTypeKHR, uint32_t) { ++realThreadCalls; return XR_TIMEOUT_EXPIRED; }
|
||||||
|
XrResult XRAPI_CALL fakeProperties(XrInstance, XrInstanceProperties*) { return XR_SUCCESS; }
|
||||||
|
XrResult XRAPI_CALL fakeGipa(XrInstance, const char* n, PFN_xrVoidFunction* out) {
|
||||||
|
#define MAP(name, f) if (!std::strcmp(n, name)) { *out = reinterpret_cast<PFN_xrVoidFunction>(f); return XR_SUCCESS; }
|
||||||
|
MAP("xrEnumerateInstanceExtensionProperties", fakeEnumerate)
|
||||||
|
MAP("xrDestroyInstance", fakeDestroy)
|
||||||
|
MAP("xrCreateSession", fakeCreateSession) MAP("xrDestroySession", fakeDestroySession)
|
||||||
|
MAP("xrStringToPath", fakeString) MAP("xrPathToString", fakePath)
|
||||||
|
MAP("xrSuggestInteractionProfileBindings", fakeSuggest) MAP("xrLocateSpacesKHR", fakeLocate)
|
||||||
|
MAP("xrSetAndroidApplicationThreadKHR", fakeThread) MAP("xrGetInstanceProperties", fakeProperties)
|
||||||
|
#undef MAP
|
||||||
|
*out = nullptr; return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int main() {
|
||||||
|
XrNegotiateLoaderInfo loader{XR_LOADER_INTERFACE_STRUCT_LOADER_INFO, 1, sizeof(XrNegotiateLoaderInfo), 1, 1,
|
||||||
|
XR_MAKE_VERSION(1,0,0), XR_MAKE_VERSION(1,1023,4095)};
|
||||||
|
XrNegotiateApiLayerRequest request{};
|
||||||
|
request.structType = XR_LOADER_INTERFACE_STRUCT_API_LAYER_REQUEST; request.structVersion = 1; request.structSize = sizeof(request);
|
||||||
|
CHECK(xrNegotiateLoaderApiLayerInterface(&loader, frame::layerName, &request) == XR_SUCCESS);
|
||||||
|
CHECK(request.getInstanceProcAddr && request.createApiLayerInstance);
|
||||||
|
loader.maxApiVersion = XR_MAKE_VERSION(1,1,0);
|
||||||
|
CHECK(xrNegotiateLoaderApiLayerInterface(&loader, frame::layerName, &request) == XR_SUCCESS);
|
||||||
|
CHECK(request.layerApiVersion == loader.maxApiVersion);
|
||||||
|
loader.maxApiVersion = XR_MAKE_VERSION(1,0,99);
|
||||||
|
CHECK(xrNegotiateLoaderApiLayerInterface(&loader, frame::layerName, &request) == XR_ERROR_INITIALIZATION_FAILED);
|
||||||
|
loader.maxApiVersion = XR_MAKE_VERSION(1,1023,4095);
|
||||||
|
CHECK(xrNegotiateLoaderApiLayerInterface(&loader, "wrong", &request) == XR_ERROR_INITIALIZATION_FAILED);
|
||||||
|
XrApiLayerNextInfo tail{};
|
||||||
|
XrApiLayerNextInfo next{XR_LOADER_INTERFACE_STRUCT_API_LAYER_NEXT_INFO, 1, sizeof(XrApiLayerNextInfo), {}, fakeGipa, fakeCreate, &tail};
|
||||||
|
XrApiLayerCreateInfo layer{XR_LOADER_INTERFACE_STRUCT_API_LAYER_CREATE_INFO, 1, sizeof(XrApiLayerCreateInfo), nullptr, {}, &next};
|
||||||
|
XrInstanceCreateInfo info{XR_TYPE_INSTANCE_CREATE_INFO, nullptr, 0, {}, 0, nullptr, 2, frame::stubs};
|
||||||
|
info.applicationInfo.apiVersion = XR_MAKE_VERSION(1,1,999);
|
||||||
|
XrInstance a{}, b{};
|
||||||
|
CHECK(createLayer(&info, &layer, &a) == XR_SUCCESS);
|
||||||
|
CHECK(seenVersion == XR_API_VERSION_1_0 && info.applicationInfo.apiVersion == XR_MAKE_VERSION(1,1,999));
|
||||||
|
CHECK(seenNext == &tail && layer.nextInfo == &next);
|
||||||
|
CHECK(!frame::has(seenExtensions, frame::stubs[0]) && frame::has(seenExtensions, "XR_KHR_locate_spaces"));
|
||||||
|
XrSession sa{}, sb{};
|
||||||
|
CHECK(createSession(a, nullptr, &sa) == XR_SUCCESS);
|
||||||
|
CHECK(threadSettings(sa, XR_ANDROID_THREAD_TYPE_APPLICATION_MAIN_KHR, 1) == XR_SUCCESS);
|
||||||
|
nativeThread = true;
|
||||||
|
CHECK(createLayer(&info, &layer, &b) == XR_SUCCESS);
|
||||||
|
CHECK(frame::has(seenExtensions, frame::stubs[0]));
|
||||||
|
CHECK(createSession(b, nullptr, &sb) == XR_SUCCESS);
|
||||||
|
PFN_xrVoidFunction fn{};
|
||||||
|
CHECK(gipa(b, "xrSetAndroidApplicationThreadKHR", &fn) == XR_SUCCESS);
|
||||||
|
CHECK(reinterpret_cast<PFN_xrSetAndroidApplicationThreadKHR>(fn)(sb, XR_ANDROID_THREAD_TYPE_APPLICATION_MAIN_KHR, 1) == XR_TIMEOUT_EXPIRED);
|
||||||
|
CHECK(realThreadCalls == 1);
|
||||||
|
CHECK(threadSettings(sa, XR_ANDROID_THREAD_TYPE_APPLICATION_MAIN_KHR, 1) == XR_SUCCESS);
|
||||||
|
CHECK(gipa(a, "xrGetInstanceProperties", &fn) == XR_SUCCESS && fn == reinterpret_cast<PFN_xrVoidFunction>(fakeProperties));
|
||||||
|
CHECK(gipa(a, "xrLocateSpaces", &fn) == XR_SUCCESS);
|
||||||
|
XrSpacesLocateInfo locateInfo{XR_TYPE_SPACES_LOCATE_INFO, nullptr, XR_NULL_HANDLE, 1, 0, nullptr};
|
||||||
|
XrSpaceLocations locations{XR_TYPE_SPACE_LOCATIONS, nullptr, 0, nullptr};
|
||||||
|
CHECK(reinterpret_cast<PFN_xrLocateSpaces>(fn)(sa, &locateInfo, &locations) == XR_SUCCESS && locateCalls == 1);
|
||||||
|
XrPath binding{}, profile{};
|
||||||
|
CHECK(stringToPath(a, "/user/hand/left/input/grip_surface/pose", &binding) == XR_SUCCESS);
|
||||||
|
CHECK(paths[binding] == "/user/hand/left/input/palm_ext/pose");
|
||||||
|
fakeString(a, "/user/hand/left/input/grip_surface/pose", &binding); // preexisting path: rewrite in suggest too
|
||||||
|
fakeString(a, "/interaction_profiles/oculus/touch_controller", &profile);
|
||||||
|
XrActionSuggestedBinding action{XR_NULL_HANDLE, binding};
|
||||||
|
XrInteractionProfileSuggestedBinding suggested{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING, nullptr, profile, 1, &action};
|
||||||
|
CHECK(suggest(a, &suggested) == XR_SUCCESS && suggestionCalls == 1);
|
||||||
|
CHECK(paths[action.binding] == "/user/hand/left/input/grip_surface/pose");
|
||||||
|
fakeString(a, "/interaction_profiles/meta/touch_pro_controller", &suggested.interactionProfile);
|
||||||
|
CHECK(suggest(a, &suggested) == XR_SUCCESS && suggestionCalls == 1);
|
||||||
|
uint32_t count{};
|
||||||
|
CHECK(enumerate(frame::layerName, 0, &count, nullptr) == XR_SUCCESS && count == 2);
|
||||||
|
XrExtensionProperties props[2]{{XR_TYPE_EXTENSION_PROPERTIES, nullptr, {}, 0}, {XR_TYPE_EXTENSION_PROPERTIES, nullptr, {}, 0}};
|
||||||
|
CHECK(enumerate(frame::layerName, 1, &count, props) == XR_ERROR_SIZE_INSUFFICIENT);
|
||||||
|
CHECK(enumerate(frame::layerName, 2, &count, props) == XR_SUCCESS && count == 2);
|
||||||
|
CHECK(enumerate(nullptr, 0, &count, nullptr) == XR_SUCCESS && count == 4);
|
||||||
|
const char* missing = "XR_MISSING_test";
|
||||||
|
info.enabledExtensionCount = 1; info.enabledExtensionNames = &missing;
|
||||||
|
XrInstance failed{};
|
||||||
|
CHECK(createLayer(&info, &layer, &failed) == XR_ERROR_EXTENSION_NOT_PRESENT && failed == XR_NULL_HANDLE);
|
||||||
|
CHECK(destroySession(sa) == XR_SUCCESS);
|
||||||
|
CHECK(threadSettings(sa, XR_ANDROID_THREAD_TYPE_APPLICATION_MAIN_KHR, 1) == XR_ERROR_HANDLE_INVALID);
|
||||||
|
CHECK(destroyInstance(a) == XR_SUCCESS);
|
||||||
|
CHECK(get(sb) && !get(a));
|
||||||
|
CHECK(destroyInstance(b) == XR_SUCCESS && !get(sb));
|
||||||
|
CHECK(instances.empty() && sessions.empty() && enumerateNext == nullptr);
|
||||||
|
std::cout << "Negotiation, next-layer chaining, multi-instance dispatch, stubs, forwarding and cleanup passed\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#pragma once
|
||||||
|
// Host-only logcat replacement. Android builds use the NDK header and liblog.
|
||||||
|
#include <cstdarg>
|
||||||
|
#include <cstdio>
|
||||||
|
#define ANDROID_LOG_INFO 4
|
||||||
|
inline int __android_log_print(int, const char*, const char* fmt, ...) {
|
||||||
|
va_list args; va_start(args, fmt); auto r = std::vfprintf(stderr, fmt, args);
|
||||||
|
va_end(args); std::fputc('\n', stderr); return r;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
// Only the opaque jobject declaration is needed by openxr_platform.h.
|
||||||
|
typedef void* jobject;
|
||||||
Vendored
+208
@@ -0,0 +1,208 @@
|
|||||||
|
Apache License
|
||||||
|
|
||||||
|
Version 2.0, January 2004
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION,
|
||||||
|
AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction, and distribution
|
||||||
|
as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by the copyright
|
||||||
|
owner that is granting the License.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all other entities
|
||||||
|
that control, are controlled by, or are under common control with that entity.
|
||||||
|
For the purposes of this definition, "control" means (i) the power, direct
|
||||||
|
or indirect, to cause the direction or management of such entity, whether
|
||||||
|
by contract or otherwise, or (ii) ownership of fifty percent (50%) or more
|
||||||
|
of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions
|
||||||
|
granted by this License.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications, including
|
||||||
|
but not limited to software source code, documentation source, and configuration
|
||||||
|
files.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical transformation
|
||||||
|
or translation of a Source form, including but not limited to compiled object
|
||||||
|
code, generated documentation, and conversions to other media types.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or Object form,
|
||||||
|
made available under the License, as indicated by a copyright notice that
|
||||||
|
is included in or attached to the work (an example is provided in the Appendix
|
||||||
|
below).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object form,
|
||||||
|
that is based on (or derived from) the Work and for which the editorial revisions,
|
||||||
|
annotations, elaborations, or other modifications represent, as a whole, an
|
||||||
|
original work of authorship. For the purposes of this License, Derivative
|
||||||
|
Works shall not include works that remain separable from, or merely link (or
|
||||||
|
bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including the original version
|
||||||
|
of the Work and any modifications or additions to that Work or Derivative
|
||||||
|
Works thereof, that is intentionally submitted to Licensor for inclusion in
|
||||||
|
the Work by the copyright owner or by an individual or Legal Entity authorized
|
||||||
|
to submit on behalf of the copyright owner. For the purposes of this definition,
|
||||||
|
"submitted" means any form of electronic, verbal, or written communication
|
||||||
|
sent to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems, and
|
||||||
|
issue tracking systems that are managed by, or on behalf of, the Licensor
|
||||||
|
for the purpose of discussing and improving the Work, but excluding communication
|
||||||
|
that is conspicuously marked or otherwise designated in writing by the copyright
|
||||||
|
owner as "Not a Contribution."
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
|
||||||
|
of whom a Contribution has been received by Licensor and subsequently incorporated
|
||||||
|
within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of this
|
||||||
|
License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
|
||||||
|
no-charge, royalty-free, irrevocable copyright license to reproduce, prepare
|
||||||
|
Derivative Works of, publicly display, publicly perform, sublicense, and distribute
|
||||||
|
the Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of this License,
|
||||||
|
each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
|
||||||
|
no-charge, royalty-free, irrevocable (except as stated in this section) patent
|
||||||
|
license to make, have made, use, offer to sell, sell, import, and otherwise
|
||||||
|
transfer the Work, where such license applies only to those patent claims
|
||||||
|
licensable by such Contributor that are necessarily infringed by their Contribution(s)
|
||||||
|
alone or by combination of their Contribution(s) with the Work to which such
|
||||||
|
Contribution(s) was submitted. If You institute patent litigation against
|
||||||
|
any entity (including a cross-claim or counterclaim in a lawsuit) alleging
|
||||||
|
that the Work or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses granted to You
|
||||||
|
under this License for that Work shall terminate as of the date such litigation
|
||||||
|
is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the Work or
|
||||||
|
Derivative Works thereof in any medium, with or without modifications, and
|
||||||
|
in Source or Object form, provided that You meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or Derivative Works a copy
|
||||||
|
of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices stating that
|
||||||
|
You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works that You distribute,
|
||||||
|
all copyright, patent, trademark, and attribution notices from the Source
|
||||||
|
form of the Work, excluding those notices that do not pertain to any part
|
||||||
|
of the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its distribution,
|
||||||
|
then any Derivative Works that You distribute must include a readable copy
|
||||||
|
of the attribution notices contained within such NOTICE file, excluding those
|
||||||
|
notices that do not pertain to any part of the Derivative Works, in at least
|
||||||
|
one of the following places: within a NOTICE text file distributed as part
|
||||||
|
of the Derivative Works; within the Source form or documentation, if provided
|
||||||
|
along with the Derivative Works; or, within a display generated by the Derivative
|
||||||
|
Works, if and wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and do not modify the
|
||||||
|
License. You may add Your own attribution notices within Derivative Works
|
||||||
|
that You distribute, alongside or as an addendum to the NOTICE text from the
|
||||||
|
Work, provided that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and may provide
|
||||||
|
additional or different license terms and conditions for use, reproduction,
|
||||||
|
or distribution of Your modifications, or for any such Derivative Works as
|
||||||
|
a whole, provided Your use, reproduction, and distribution of the Work otherwise
|
||||||
|
complies with the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise, any
|
||||||
|
Contribution intentionally submitted for inclusion in the Work by You to the
|
||||||
|
Licensor shall be under the terms and conditions of this License, without
|
||||||
|
any additional terms or conditions. Notwithstanding the above, nothing herein
|
||||||
|
shall supersede or modify the terms of any separate license agreement you
|
||||||
|
may have executed with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade names,
|
||||||
|
trademarks, service marks, or product names of the Licensor, except as required
|
||||||
|
for reasonable and customary use in describing the origin of the Work and
|
||||||
|
reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or agreed to
|
||||||
|
in writing, Licensor provides the Work (and each Contributor provides its
|
||||||
|
Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied, including, without limitation, any warranties
|
||||||
|
or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness
|
||||||
|
of using or redistributing the Work and assume any risks associated with Your
|
||||||
|
exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory, whether
|
||||||
|
in tort (including negligence), contract, or otherwise, unless required by
|
||||||
|
applicable law (such as deliberate and grossly negligent acts) or agreed to
|
||||||
|
in writing, shall any Contributor be liable to You for damages, including
|
||||||
|
any direct, indirect, special, incidental, or consequential damages of any
|
||||||
|
character arising as a result of this License or out of the use or inability
|
||||||
|
to use the Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all other commercial
|
||||||
|
damages or losses), even if such Contributor has been advised of the possibility
|
||||||
|
of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing the Work
|
||||||
|
or Derivative Works thereof, You may choose to offer, and charge a fee for,
|
||||||
|
acceptance of support, warranty, indemnity, or other liability obligations
|
||||||
|
and/or rights consistent with this License. However, in accepting such obligations,
|
||||||
|
You may act only on Your own behalf and on Your sole responsibility, not on
|
||||||
|
behalf of any other Contributor, and only if You agree to indemnify, defend,
|
||||||
|
and hold each Contributor harmless for any liability incurred by, or claims
|
||||||
|
asserted against, such Contributor by reason of your accepting any such warranty
|
||||||
|
or additional liability. END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following boilerplate
|
||||||
|
notice, with the fields enclosed by brackets "[]" replaced with your own identifying
|
||||||
|
information. (Don't include the brackets!) The text should be enclosed in
|
||||||
|
the appropriate comment syntax for the file format. We also recommend that
|
||||||
|
a file or class name and description of purpose be included on the same "printed
|
||||||
|
page" as the copyright notice for easier identification within third-party
|
||||||
|
archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
|
||||||
|
limitations under the License.
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
// Copyright (c) 2017-2023, The Khronos Group Inc.
|
||||||
|
// Copyright (c) 2017 Valve Corporation
|
||||||
|
// Copyright (c) 2017 LunarG, Inc.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||||
|
//
|
||||||
|
// Initial Author: Mark Young <marky@lunarg.com>
|
||||||
|
//
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <openxr/openxr.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Forward declare.
|
||||||
|
typedef struct XrApiLayerCreateInfo XrApiLayerCreateInfo;
|
||||||
|
|
||||||
|
// Function pointer prototype for the xrCreateApiLayerInstance function used in place of xrCreateInstance.
|
||||||
|
// This function allows us to pass special API layer information to each layer during the process of creating an Instance.
|
||||||
|
typedef XrResult(XRAPI_PTR *PFN_xrCreateApiLayerInstance)(const XrInstanceCreateInfo *info,
|
||||||
|
const XrApiLayerCreateInfo *apiLayerInfo, XrInstance *instance);
|
||||||
|
|
||||||
|
// Loader/API Layer Interface versions
|
||||||
|
// 1 - First version, introduces negotiation structure and functions
|
||||||
|
#define XR_CURRENT_LOADER_API_LAYER_VERSION 1
|
||||||
|
|
||||||
|
// Loader/Runtime Interface versions
|
||||||
|
// 1 - First version, introduces negotiation structure and functions
|
||||||
|
#define XR_CURRENT_LOADER_RUNTIME_VERSION 1
|
||||||
|
|
||||||
|
// Version negotiation values
|
||||||
|
typedef enum XrLoaderInterfaceStructs {
|
||||||
|
XR_LOADER_INTERFACE_STRUCT_UNINTIALIZED = 0,
|
||||||
|
XR_LOADER_INTERFACE_STRUCT_LOADER_INFO,
|
||||||
|
XR_LOADER_INTERFACE_STRUCT_API_LAYER_REQUEST,
|
||||||
|
XR_LOADER_INTERFACE_STRUCT_RUNTIME_REQUEST,
|
||||||
|
XR_LOADER_INTERFACE_STRUCT_API_LAYER_CREATE_INFO,
|
||||||
|
XR_LOADER_INTERFACE_STRUCT_API_LAYER_NEXT_INFO,
|
||||||
|
} XrLoaderInterfaceStructs;
|
||||||
|
|
||||||
|
#define XR_LOADER_INFO_STRUCT_VERSION 1
|
||||||
|
typedef struct XrNegotiateLoaderInfo {
|
||||||
|
XrLoaderInterfaceStructs structType; // XR_LOADER_INTERFACE_STRUCT_LOADER_INFO
|
||||||
|
uint32_t structVersion; // XR_LOADER_INFO_STRUCT_VERSION
|
||||||
|
size_t structSize; // sizeof(XrNegotiateLoaderInfo)
|
||||||
|
uint32_t minInterfaceVersion;
|
||||||
|
uint32_t maxInterfaceVersion;
|
||||||
|
XrVersion minApiVersion;
|
||||||
|
XrVersion maxApiVersion;
|
||||||
|
} XrNegotiateLoaderInfo;
|
||||||
|
|
||||||
|
#define XR_API_LAYER_INFO_STRUCT_VERSION 1
|
||||||
|
typedef struct XrNegotiateApiLayerRequest {
|
||||||
|
XrLoaderInterfaceStructs structType; // XR_LOADER_INTERFACE_STRUCT_API_LAYER_REQUEST
|
||||||
|
uint32_t structVersion; // XR_API_LAYER_INFO_STRUCT_VERSION
|
||||||
|
size_t structSize; // sizeof(XrNegotiateApiLayerRequest)
|
||||||
|
uint32_t layerInterfaceVersion; // CURRENT_LOADER_API_LAYER_VERSION
|
||||||
|
XrVersion layerApiVersion;
|
||||||
|
PFN_xrGetInstanceProcAddr getInstanceProcAddr;
|
||||||
|
PFN_xrCreateApiLayerInstance createApiLayerInstance;
|
||||||
|
} XrNegotiateApiLayerRequest;
|
||||||
|
|
||||||
|
#define XR_RUNTIME_INFO_STRUCT_VERSION 1
|
||||||
|
typedef struct XrNegotiateRuntimeRequest {
|
||||||
|
XrLoaderInterfaceStructs structType; // XR_LOADER_INTERFACE_STRUCT_RUNTIME_REQUEST
|
||||||
|
uint32_t structVersion; // XR_RUNTIME_INFO_STRUCT_VERSION
|
||||||
|
size_t structSize; // sizeof(XrNegotiateRuntimeRequest)
|
||||||
|
uint32_t runtimeInterfaceVersion; // CURRENT_LOADER_RUNTIME_VERSION
|
||||||
|
XrVersion runtimeApiVersion;
|
||||||
|
PFN_xrGetInstanceProcAddr getInstanceProcAddr;
|
||||||
|
} XrNegotiateRuntimeRequest;
|
||||||
|
|
||||||
|
// Function used to negotiate an interface betewen the loader and an API layer. Each library exposing one or
|
||||||
|
// more API layers needs to expose at least this function.
|
||||||
|
typedef XrResult(XRAPI_PTR *PFN_xrNegotiateLoaderApiLayerInterface)(const XrNegotiateLoaderInfo *loaderInfo,
|
||||||
|
const char *apiLayerName,
|
||||||
|
XrNegotiateApiLayerRequest *apiLayerRequest);
|
||||||
|
|
||||||
|
// Function used to negotiate an interface betewen the loader and a runtime. Each runtime should expose
|
||||||
|
// at least this function.
|
||||||
|
typedef XrResult(XRAPI_PTR *PFN_xrNegotiateLoaderRuntimeInterface)(const XrNegotiateLoaderInfo *loaderInfo,
|
||||||
|
XrNegotiateRuntimeRequest *runtimeRequest);
|
||||||
|
|
||||||
|
// Forward declare.
|
||||||
|
typedef struct XrApiLayerNextInfo XrApiLayerNextInfo;
|
||||||
|
|
||||||
|
#define XR_API_LAYER_NEXT_INFO_STRUCT_VERSION 1
|
||||||
|
struct XrApiLayerNextInfo {
|
||||||
|
XrLoaderInterfaceStructs structType; // XR_LOADER_INTERFACE_STRUCT_API_LAYER_NEXT_INFO
|
||||||
|
uint32_t structVersion; // XR_API_LAYER_NEXT_INFO_STRUCT_VERSION
|
||||||
|
size_t structSize; // sizeof(XrApiLayerNextInfo)
|
||||||
|
char layerName[XR_MAX_API_LAYER_NAME_SIZE]; // Name of API layer which should receive this info
|
||||||
|
PFN_xrGetInstanceProcAddr nextGetInstanceProcAddr; // Pointer to next API layer's xrGetInstanceProcAddr
|
||||||
|
PFN_xrCreateApiLayerInstance nextCreateApiLayerInstance; // Pointer to next API layer's xrCreateApiLayerInstance
|
||||||
|
XrApiLayerNextInfo *next; // Pointer to the next API layer info in the sequence
|
||||||
|
};
|
||||||
|
|
||||||
|
#define XR_API_LAYER_MAX_SETTINGS_PATH_SIZE 512
|
||||||
|
#define XR_API_LAYER_CREATE_INFO_STRUCT_VERSION 1
|
||||||
|
typedef struct XrApiLayerCreateInfo {
|
||||||
|
XrLoaderInterfaceStructs structType; // XR_LOADER_INTERFACE_STRUCT_API_LAYER_CREATE_INFO
|
||||||
|
uint32_t structVersion; // XR_API_LAYER_CREATE_INFO_STRUCT_VERSION
|
||||||
|
size_t structSize; // sizeof(XrApiLayerCreateInfo)
|
||||||
|
void *loaderInstance; // Pointer to the LoaderInstance class
|
||||||
|
char settings_file_location[XR_API_LAYER_MAX_SETTINGS_PATH_SIZE]; // Location to the found settings file (or empty '\0')
|
||||||
|
XrApiLayerNextInfo *nextInfo; // Pointer to the next API layer's Info
|
||||||
|
} XrApiLayerCreateInfo;
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} // extern "C"
|
||||||
|
#endif
|
||||||
+14911
File diff suppressed because it is too large.
Load diff
+819
@@ -0,0 +1,819 @@
|
|||||||
|
#ifndef OPENXR_PLATFORM_H_
|
||||||
|
#define OPENXR_PLATFORM_H_ 1
|
||||||
|
|
||||||
|
/*
|
||||||
|
** Copyright 2017-2026 The Khronos Group Inc.
|
||||||
|
**
|
||||||
|
** SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
** This header is generated from the Khronos OpenXR XML API Registry.
|
||||||
|
**
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "openxr.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_ANDROID
|
||||||
|
|
||||||
|
// XR_KHR_android_thread_settings is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_android_thread_settings 1
|
||||||
|
#define XR_KHR_android_thread_settings_SPEC_VERSION 6
|
||||||
|
#define XR_KHR_ANDROID_THREAD_SETTINGS_EXTENSION_NAME "XR_KHR_android_thread_settings"
|
||||||
|
|
||||||
|
typedef enum XrAndroidThreadTypeKHR {
|
||||||
|
XR_ANDROID_THREAD_TYPE_APPLICATION_MAIN_KHR = 1,
|
||||||
|
XR_ANDROID_THREAD_TYPE_APPLICATION_WORKER_KHR = 2,
|
||||||
|
XR_ANDROID_THREAD_TYPE_RENDERER_MAIN_KHR = 3,
|
||||||
|
XR_ANDROID_THREAD_TYPE_RENDERER_WORKER_KHR = 4,
|
||||||
|
XR_ANDROID_THREAD_TYPE_MAX_ENUM_KHR = 0x7FFFFFFF
|
||||||
|
} XrAndroidThreadTypeKHR;
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrSetAndroidApplicationThreadKHR)(XrSession session, XrAndroidThreadTypeKHR threadType, uint32_t threadId);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrSetAndroidApplicationThreadKHR(
|
||||||
|
XrSession session,
|
||||||
|
XrAndroidThreadTypeKHR threadType,
|
||||||
|
uint32_t threadId);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_PLATFORM_ANDROID */
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_ANDROID
|
||||||
|
|
||||||
|
// XR_KHR_android_surface_swapchain is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_android_surface_swapchain 1
|
||||||
|
#define XR_KHR_android_surface_swapchain_SPEC_VERSION 4
|
||||||
|
#define XR_KHR_ANDROID_SURFACE_SWAPCHAIN_EXTENSION_NAME "XR_KHR_android_surface_swapchain"
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrCreateSwapchainAndroidSurfaceKHR)(XrSession session, const XrSwapchainCreateInfo* info, XrSwapchain* swapchain, jobject* surface);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchainAndroidSurfaceKHR(
|
||||||
|
XrSession session,
|
||||||
|
const XrSwapchainCreateInfo* info,
|
||||||
|
XrSwapchain* swapchain,
|
||||||
|
jobject* surface);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_PLATFORM_ANDROID */
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_ANDROID
|
||||||
|
|
||||||
|
// XR_KHR_android_create_instance is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_android_create_instance 1
|
||||||
|
#define XR_KHR_android_create_instance_SPEC_VERSION 3
|
||||||
|
#define XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME "XR_KHR_android_create_instance"
|
||||||
|
// XrInstanceCreateInfoAndroidKHR extends XrInstanceCreateInfo
|
||||||
|
typedef struct XrInstanceCreateInfoAndroidKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
void* XR_MAY_ALIAS applicationVM;
|
||||||
|
void* XR_MAY_ALIAS applicationActivity;
|
||||||
|
} XrInstanceCreateInfoAndroidKHR;
|
||||||
|
|
||||||
|
#endif /* XR_USE_PLATFORM_ANDROID */
|
||||||
|
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_VULKAN
|
||||||
|
|
||||||
|
// XR_KHR_vulkan_swapchain_format_list is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_vulkan_swapchain_format_list 1
|
||||||
|
#define XR_KHR_vulkan_swapchain_format_list_SPEC_VERSION 5
|
||||||
|
#define XR_KHR_VULKAN_SWAPCHAIN_FORMAT_LIST_EXTENSION_NAME "XR_KHR_vulkan_swapchain_format_list"
|
||||||
|
// XrVulkanSwapchainFormatListCreateInfoKHR extends XrSwapchainCreateInfo
|
||||||
|
typedef struct XrVulkanSwapchainFormatListCreateInfoKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
uint32_t viewFormatCount;
|
||||||
|
const VkFormat* viewFormats;
|
||||||
|
} XrVulkanSwapchainFormatListCreateInfoKHR;
|
||||||
|
|
||||||
|
#endif /* XR_USE_GRAPHICS_API_VULKAN */
|
||||||
|
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_OPENGL
|
||||||
|
|
||||||
|
// XR_KHR_opengl_enable is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_opengl_enable 1
|
||||||
|
#define XR_KHR_opengl_enable_SPEC_VERSION 12
|
||||||
|
#define XR_KHR_OPENGL_ENABLE_EXTENSION_NAME "XR_KHR_opengl_enable"
|
||||||
|
#ifdef XR_USE_PLATFORM_WIN32
|
||||||
|
// XrGraphicsBindingOpenGLWin32KHR extends XrSessionCreateInfo
|
||||||
|
typedef struct XrGraphicsBindingOpenGLWin32KHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
HDC hDC;
|
||||||
|
HGLRC hGLRC;
|
||||||
|
} XrGraphicsBindingOpenGLWin32KHR;
|
||||||
|
#endif // XR_USE_PLATFORM_WIN32
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_XLIB
|
||||||
|
// XrGraphicsBindingOpenGLXlibKHR extends XrSessionCreateInfo
|
||||||
|
typedef struct XrGraphicsBindingOpenGLXlibKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
Display* xDisplay;
|
||||||
|
uint32_t visualid;
|
||||||
|
GLXFBConfig glxFBConfig;
|
||||||
|
GLXDrawable glxDrawable;
|
||||||
|
GLXContext glxContext;
|
||||||
|
} XrGraphicsBindingOpenGLXlibKHR;
|
||||||
|
#endif // XR_USE_PLATFORM_XLIB
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_XCB
|
||||||
|
// XrGraphicsBindingOpenGLXcbKHR extends XrSessionCreateInfo
|
||||||
|
typedef struct XrGraphicsBindingOpenGLXcbKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
xcb_connection_t* connection;
|
||||||
|
uint32_t screenNumber;
|
||||||
|
xcb_glx_fbconfig_t fbconfigid;
|
||||||
|
xcb_visualid_t visualid;
|
||||||
|
xcb_glx_drawable_t glxDrawable;
|
||||||
|
xcb_glx_context_t glxContext;
|
||||||
|
} XrGraphicsBindingOpenGLXcbKHR;
|
||||||
|
#endif // XR_USE_PLATFORM_XCB
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_WAYLAND
|
||||||
|
// XrGraphicsBindingOpenGLWaylandKHR extends XrSessionCreateInfo
|
||||||
|
typedef struct XrGraphicsBindingOpenGLWaylandKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
struct wl_display* display;
|
||||||
|
} XrGraphicsBindingOpenGLWaylandKHR;
|
||||||
|
#endif // XR_USE_PLATFORM_WAYLAND
|
||||||
|
|
||||||
|
typedef struct XrSwapchainImageOpenGLKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
uint32_t image;
|
||||||
|
} XrSwapchainImageOpenGLKHR;
|
||||||
|
|
||||||
|
typedef struct XrGraphicsRequirementsOpenGLKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
XrVersion minApiVersionSupported;
|
||||||
|
XrVersion maxApiVersionSupported;
|
||||||
|
} XrGraphicsRequirementsOpenGLKHR;
|
||||||
|
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrGetOpenGLGraphicsRequirementsKHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsOpenGLKHR* graphicsRequirements);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrGetOpenGLGraphicsRequirementsKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
XrSystemId systemId,
|
||||||
|
XrGraphicsRequirementsOpenGLKHR* graphicsRequirements);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_GRAPHICS_API_OPENGL */
|
||||||
|
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_OPENGL_ES
|
||||||
|
|
||||||
|
// XR_KHR_opengl_es_enable is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_opengl_es_enable 1
|
||||||
|
#define XR_KHR_opengl_es_enable_SPEC_VERSION 10
|
||||||
|
#define XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME "XR_KHR_opengl_es_enable"
|
||||||
|
#ifdef XR_USE_PLATFORM_ANDROID
|
||||||
|
// XrGraphicsBindingOpenGLESAndroidKHR extends XrSessionCreateInfo
|
||||||
|
typedef struct XrGraphicsBindingOpenGLESAndroidKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
EGLDisplay display;
|
||||||
|
EGLConfig config;
|
||||||
|
EGLContext context;
|
||||||
|
} XrGraphicsBindingOpenGLESAndroidKHR;
|
||||||
|
#endif // XR_USE_PLATFORM_ANDROID
|
||||||
|
|
||||||
|
typedef struct XrSwapchainImageOpenGLESKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
uint32_t image;
|
||||||
|
} XrSwapchainImageOpenGLESKHR;
|
||||||
|
|
||||||
|
typedef struct XrGraphicsRequirementsOpenGLESKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
XrVersion minApiVersionSupported;
|
||||||
|
XrVersion maxApiVersionSupported;
|
||||||
|
} XrGraphicsRequirementsOpenGLESKHR;
|
||||||
|
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrGetOpenGLESGraphicsRequirementsKHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsOpenGLESKHR* graphicsRequirements);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrGetOpenGLESGraphicsRequirementsKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
XrSystemId systemId,
|
||||||
|
XrGraphicsRequirementsOpenGLESKHR* graphicsRequirements);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_GRAPHICS_API_OPENGL_ES */
|
||||||
|
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_VULKAN
|
||||||
|
|
||||||
|
// XR_KHR_vulkan_enable is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_vulkan_enable 1
|
||||||
|
#define XR_KHR_vulkan_enable_SPEC_VERSION 10
|
||||||
|
#define XR_KHR_VULKAN_ENABLE_EXTENSION_NAME "XR_KHR_vulkan_enable"
|
||||||
|
// XrGraphicsBindingVulkanKHR extends XrSessionCreateInfo
|
||||||
|
typedef struct XrGraphicsBindingVulkanKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
VkInstance instance;
|
||||||
|
VkPhysicalDevice physicalDevice;
|
||||||
|
VkDevice device;
|
||||||
|
uint32_t queueFamilyIndex;
|
||||||
|
uint32_t queueIndex;
|
||||||
|
} XrGraphicsBindingVulkanKHR;
|
||||||
|
|
||||||
|
typedef struct XrSwapchainImageVulkanKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
VkImage image;
|
||||||
|
} XrSwapchainImageVulkanKHR;
|
||||||
|
|
||||||
|
typedef struct XrGraphicsRequirementsVulkanKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
XrVersion minApiVersionSupported;
|
||||||
|
XrVersion maxApiVersionSupported;
|
||||||
|
} XrGraphicsRequirementsVulkanKHR;
|
||||||
|
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrGetVulkanInstanceExtensionsKHR)(XrInstance instance, XrSystemId systemId, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, char* buffer);
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrGetVulkanDeviceExtensionsKHR)(XrInstance instance, XrSystemId systemId, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, char* buffer);
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrGetVulkanGraphicsDeviceKHR)(XrInstance instance, XrSystemId systemId, VkInstance vkInstance, VkPhysicalDevice* vkPhysicalDevice);
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrGetVulkanGraphicsRequirementsKHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsVulkanKHR* graphicsRequirements);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrGetVulkanInstanceExtensionsKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
XrSystemId systemId,
|
||||||
|
uint32_t bufferCapacityInput,
|
||||||
|
uint32_t* bufferCountOutput,
|
||||||
|
char* buffer);
|
||||||
|
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrGetVulkanDeviceExtensionsKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
XrSystemId systemId,
|
||||||
|
uint32_t bufferCapacityInput,
|
||||||
|
uint32_t* bufferCountOutput,
|
||||||
|
char* buffer);
|
||||||
|
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrGetVulkanGraphicsDeviceKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
XrSystemId systemId,
|
||||||
|
VkInstance vkInstance,
|
||||||
|
VkPhysicalDevice* vkPhysicalDevice);
|
||||||
|
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrGetVulkanGraphicsRequirementsKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
XrSystemId systemId,
|
||||||
|
XrGraphicsRequirementsVulkanKHR* graphicsRequirements);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_GRAPHICS_API_VULKAN */
|
||||||
|
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_D3D11
|
||||||
|
|
||||||
|
// XR_KHR_D3D11_enable is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_D3D11_enable 1
|
||||||
|
#define XR_KHR_D3D11_enable_SPEC_VERSION 11
|
||||||
|
#define XR_KHR_D3D11_ENABLE_EXTENSION_NAME "XR_KHR_D3D11_enable"
|
||||||
|
// XrGraphicsBindingD3D11KHR extends XrSessionCreateInfo
|
||||||
|
typedef struct XrGraphicsBindingD3D11KHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
ID3D11Device* device;
|
||||||
|
} XrGraphicsBindingD3D11KHR;
|
||||||
|
|
||||||
|
typedef struct XrSwapchainImageD3D11KHR {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
ID3D11Texture2D* texture;
|
||||||
|
} XrSwapchainImageD3D11KHR;
|
||||||
|
|
||||||
|
typedef struct XrGraphicsRequirementsD3D11KHR {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
LUID adapterLuid;
|
||||||
|
D3D_FEATURE_LEVEL minFeatureLevel;
|
||||||
|
} XrGraphicsRequirementsD3D11KHR;
|
||||||
|
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrGetD3D11GraphicsRequirementsKHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsD3D11KHR* graphicsRequirements);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrGetD3D11GraphicsRequirementsKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
XrSystemId systemId,
|
||||||
|
XrGraphicsRequirementsD3D11KHR* graphicsRequirements);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_GRAPHICS_API_D3D11 */
|
||||||
|
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_D3D12
|
||||||
|
|
||||||
|
// XR_KHR_D3D12_enable is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_D3D12_enable 1
|
||||||
|
#define XR_KHR_D3D12_enable_SPEC_VERSION 11
|
||||||
|
#define XR_KHR_D3D12_ENABLE_EXTENSION_NAME "XR_KHR_D3D12_enable"
|
||||||
|
// XrGraphicsBindingD3D12KHR extends XrSessionCreateInfo
|
||||||
|
typedef struct XrGraphicsBindingD3D12KHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
ID3D12Device* device;
|
||||||
|
ID3D12CommandQueue* queue;
|
||||||
|
} XrGraphicsBindingD3D12KHR;
|
||||||
|
|
||||||
|
typedef struct XrSwapchainImageD3D12KHR {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
ID3D12Resource* texture;
|
||||||
|
} XrSwapchainImageD3D12KHR;
|
||||||
|
|
||||||
|
typedef struct XrGraphicsRequirementsD3D12KHR {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
LUID adapterLuid;
|
||||||
|
D3D_FEATURE_LEVEL minFeatureLevel;
|
||||||
|
} XrGraphicsRequirementsD3D12KHR;
|
||||||
|
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrGetD3D12GraphicsRequirementsKHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsD3D12KHR* graphicsRequirements);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrGetD3D12GraphicsRequirementsKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
XrSystemId systemId,
|
||||||
|
XrGraphicsRequirementsD3D12KHR* graphicsRequirements);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_GRAPHICS_API_D3D12 */
|
||||||
|
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_METAL
|
||||||
|
|
||||||
|
// XR_KHR_metal_enable is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_metal_enable 1
|
||||||
|
#define XR_KHR_metal_enable_SPEC_VERSION 3
|
||||||
|
#define XR_KHR_METAL_ENABLE_EXTENSION_NAME "XR_KHR_metal_enable"
|
||||||
|
// XrGraphicsBindingMetalKHR extends XrSessionCreateInfo
|
||||||
|
typedef struct XrGraphicsBindingMetalKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
void* XR_MAY_ALIAS commandQueue;
|
||||||
|
} XrGraphicsBindingMetalKHR;
|
||||||
|
|
||||||
|
typedef struct XrSwapchainImageMetalKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
void* XR_MAY_ALIAS texture;
|
||||||
|
} XrSwapchainImageMetalKHR;
|
||||||
|
|
||||||
|
typedef struct XrGraphicsRequirementsMetalKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
void* XR_MAY_ALIAS metalDevice;
|
||||||
|
} XrGraphicsRequirementsMetalKHR;
|
||||||
|
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrGetMetalGraphicsRequirementsKHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsMetalKHR* graphicsRequirements);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrGetMetalGraphicsRequirementsKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
XrSystemId systemId,
|
||||||
|
XrGraphicsRequirementsMetalKHR* graphicsRequirements);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_GRAPHICS_API_METAL */
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_WIN32
|
||||||
|
|
||||||
|
// XR_KHR_win32_convert_performance_counter_time is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_win32_convert_performance_counter_time 1
|
||||||
|
#define XR_KHR_win32_convert_performance_counter_time_SPEC_VERSION 1
|
||||||
|
#define XR_KHR_WIN32_CONVERT_PERFORMANCE_COUNTER_TIME_EXTENSION_NAME "XR_KHR_win32_convert_performance_counter_time"
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrConvertWin32PerformanceCounterToTimeKHR)(XrInstance instance, const LARGE_INTEGER* performanceCounter, XrTime* time);
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrConvertTimeToWin32PerformanceCounterKHR)(XrInstance instance, XrTime time, LARGE_INTEGER* performanceCounter);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrConvertWin32PerformanceCounterToTimeKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
const LARGE_INTEGER* performanceCounter,
|
||||||
|
XrTime* time);
|
||||||
|
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrConvertTimeToWin32PerformanceCounterKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
XrTime time,
|
||||||
|
LARGE_INTEGER* performanceCounter);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_PLATFORM_WIN32 */
|
||||||
|
|
||||||
|
#ifdef XR_USE_TIMESPEC
|
||||||
|
|
||||||
|
// XR_KHR_convert_timespec_time is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_convert_timespec_time 1
|
||||||
|
#define XR_KHR_convert_timespec_time_SPEC_VERSION 1
|
||||||
|
#define XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME "XR_KHR_convert_timespec_time"
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrConvertTimespecTimeToTimeKHR)(XrInstance instance, const struct timespec* timespecTime, XrTime* time);
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrConvertTimeToTimespecTimeKHR)(XrInstance instance, XrTime time, struct timespec* timespecTime);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrConvertTimespecTimeToTimeKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
const struct timespec* timespecTime,
|
||||||
|
XrTime* time);
|
||||||
|
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrConvertTimeToTimespecTimeKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
XrTime time,
|
||||||
|
struct timespec* timespecTime);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_TIMESPEC */
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_ANDROID
|
||||||
|
|
||||||
|
// XR_KHR_loader_init_android is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_loader_init_android 1
|
||||||
|
#define XR_KHR_loader_init_android_SPEC_VERSION 1
|
||||||
|
#define XR_KHR_LOADER_INIT_ANDROID_EXTENSION_NAME "XR_KHR_loader_init_android"
|
||||||
|
typedef struct XrLoaderInitInfoAndroidKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
void* XR_MAY_ALIAS applicationVM;
|
||||||
|
void* XR_MAY_ALIAS applicationContext;
|
||||||
|
} XrLoaderInitInfoAndroidKHR;
|
||||||
|
|
||||||
|
#endif /* XR_USE_PLATFORM_ANDROID */
|
||||||
|
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_VULKAN
|
||||||
|
|
||||||
|
// XR_KHR_vulkan_enable2 is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_KHR_vulkan_enable2 1
|
||||||
|
#define XR_KHR_vulkan_enable2_SPEC_VERSION 4
|
||||||
|
#define XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME "XR_KHR_vulkan_enable2"
|
||||||
|
typedef XrFlags64 XrVulkanInstanceCreateFlagsKHR;
|
||||||
|
|
||||||
|
// Flag bits for XrVulkanInstanceCreateFlagsKHR
|
||||||
|
|
||||||
|
typedef XrFlags64 XrVulkanDeviceCreateFlagsKHR;
|
||||||
|
|
||||||
|
// Flag bits for XrVulkanDeviceCreateFlagsKHR
|
||||||
|
|
||||||
|
typedef struct XrVulkanInstanceCreateInfoKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
XrSystemId systemId;
|
||||||
|
XrVulkanInstanceCreateFlagsKHR createFlags;
|
||||||
|
PFN_vkGetInstanceProcAddr pfnGetInstanceProcAddr;
|
||||||
|
const VkInstanceCreateInfo* vulkanCreateInfo;
|
||||||
|
const VkAllocationCallbacks* vulkanAllocator;
|
||||||
|
} XrVulkanInstanceCreateInfoKHR;
|
||||||
|
|
||||||
|
typedef struct XrVulkanDeviceCreateInfoKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
XrSystemId systemId;
|
||||||
|
XrVulkanDeviceCreateFlagsKHR createFlags;
|
||||||
|
PFN_vkGetInstanceProcAddr pfnGetInstanceProcAddr;
|
||||||
|
VkPhysicalDevice vulkanPhysicalDevice;
|
||||||
|
const VkDeviceCreateInfo* vulkanCreateInfo;
|
||||||
|
const VkAllocationCallbacks* vulkanAllocator;
|
||||||
|
} XrVulkanDeviceCreateInfoKHR;
|
||||||
|
|
||||||
|
typedef XrGraphicsBindingVulkanKHR XrGraphicsBindingVulkan2KHR;
|
||||||
|
|
||||||
|
typedef struct XrVulkanGraphicsDeviceGetInfoKHR {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
XrSystemId systemId;
|
||||||
|
VkInstance vulkanInstance;
|
||||||
|
} XrVulkanGraphicsDeviceGetInfoKHR;
|
||||||
|
|
||||||
|
typedef XrSwapchainImageVulkanKHR XrSwapchainImageVulkan2KHR;
|
||||||
|
|
||||||
|
typedef XrGraphicsRequirementsVulkanKHR XrGraphicsRequirementsVulkan2KHR;
|
||||||
|
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrCreateVulkanInstanceKHR)(XrInstance instance, const XrVulkanInstanceCreateInfoKHR* createInfo, VkInstance* vulkanInstance, VkResult* vulkanResult);
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrCreateVulkanDeviceKHR)(XrInstance instance, const XrVulkanDeviceCreateInfoKHR* createInfo, VkDevice* vulkanDevice, VkResult* vulkanResult);
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrGetVulkanGraphicsDevice2KHR)(XrInstance instance, const XrVulkanGraphicsDeviceGetInfoKHR* getInfo, VkPhysicalDevice* vulkanPhysicalDevice);
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrGetVulkanGraphicsRequirements2KHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsVulkanKHR* graphicsRequirements);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrCreateVulkanInstanceKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
const XrVulkanInstanceCreateInfoKHR* createInfo,
|
||||||
|
VkInstance* vulkanInstance,
|
||||||
|
VkResult* vulkanResult);
|
||||||
|
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrCreateVulkanDeviceKHR(
|
||||||
|
XrInstance instance,
|
||||||
|
const XrVulkanDeviceCreateInfoKHR* createInfo,
|
||||||
|
VkDevice* vulkanDevice,
|
||||||
|
VkResult* vulkanResult);
|
||||||
|
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrGetVulkanGraphicsDevice2KHR(
|
||||||
|
XrInstance instance,
|
||||||
|
const XrVulkanGraphicsDeviceGetInfoKHR* getInfo,
|
||||||
|
VkPhysicalDevice* vulkanPhysicalDevice);
|
||||||
|
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrGetVulkanGraphicsRequirements2KHR(
|
||||||
|
XrInstance instance,
|
||||||
|
XrSystemId systemId,
|
||||||
|
XrGraphicsRequirementsVulkanKHR* graphicsRequirements);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_GRAPHICS_API_VULKAN */
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_EGL
|
||||||
|
|
||||||
|
// XR_MNDX_egl_enable is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_MNDX_egl_enable 1
|
||||||
|
#define XR_MNDX_egl_enable_SPEC_VERSION 2
|
||||||
|
#define XR_MNDX_EGL_ENABLE_EXTENSION_NAME "XR_MNDX_egl_enable"
|
||||||
|
typedef PFN_xrVoidFunction (*PFN_xrEglGetProcAddressMNDX)(const char *name);
|
||||||
|
// XrGraphicsBindingEGLMNDX extends XrSessionCreateInfo
|
||||||
|
typedef struct XrGraphicsBindingEGLMNDX {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
PFN_xrEglGetProcAddressMNDX getProcAddress;
|
||||||
|
EGLDisplay display;
|
||||||
|
EGLConfig config;
|
||||||
|
EGLContext context;
|
||||||
|
} XrGraphicsBindingEGLMNDX;
|
||||||
|
|
||||||
|
#endif /* XR_USE_PLATFORM_EGL */
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_WIN32
|
||||||
|
|
||||||
|
// XR_MSFT_perception_anchor_interop is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_MSFT_perception_anchor_interop 1
|
||||||
|
#define XR_MSFT_perception_anchor_interop_SPEC_VERSION 1
|
||||||
|
#define XR_MSFT_PERCEPTION_ANCHOR_INTEROP_EXTENSION_NAME "XR_MSFT_perception_anchor_interop"
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorFromPerceptionAnchorMSFT)(XrSession session, IUnknown* perceptionAnchor, XrSpatialAnchorMSFT* anchor);
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrTryGetPerceptionAnchorFromSpatialAnchorMSFT)(XrSession session, XrSpatialAnchorMSFT anchor, IUnknown** perceptionAnchor);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorFromPerceptionAnchorMSFT(
|
||||||
|
XrSession session,
|
||||||
|
IUnknown* perceptionAnchor,
|
||||||
|
XrSpatialAnchorMSFT* anchor);
|
||||||
|
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrTryGetPerceptionAnchorFromSpatialAnchorMSFT(
|
||||||
|
XrSession session,
|
||||||
|
XrSpatialAnchorMSFT anchor,
|
||||||
|
IUnknown** perceptionAnchor);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_PLATFORM_WIN32 */
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_WIN32
|
||||||
|
|
||||||
|
// XR_MSFT_holographic_window_attachment is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_MSFT_holographic_window_attachment 1
|
||||||
|
#define XR_MSFT_holographic_window_attachment_SPEC_VERSION 1
|
||||||
|
#define XR_MSFT_HOLOGRAPHIC_WINDOW_ATTACHMENT_EXTENSION_NAME "XR_MSFT_holographic_window_attachment"
|
||||||
|
#ifdef XR_USE_PLATFORM_WIN32
|
||||||
|
// XrHolographicWindowAttachmentMSFT extends XrSessionCreateInfo
|
||||||
|
typedef struct XrHolographicWindowAttachmentMSFT {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
IUnknown* holographicSpace;
|
||||||
|
IUnknown* coreWindow;
|
||||||
|
} XrHolographicWindowAttachmentMSFT;
|
||||||
|
#endif // XR_USE_PLATFORM_WIN32
|
||||||
|
|
||||||
|
#endif /* XR_USE_PLATFORM_WIN32 */
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_ANDROID
|
||||||
|
|
||||||
|
// XR_FB_android_surface_swapchain_create is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_FB_android_surface_swapchain_create 1
|
||||||
|
#define XR_FB_android_surface_swapchain_create_SPEC_VERSION 1
|
||||||
|
#define XR_FB_ANDROID_SURFACE_SWAPCHAIN_CREATE_EXTENSION_NAME "XR_FB_android_surface_swapchain_create"
|
||||||
|
typedef XrFlags64 XrAndroidSurfaceSwapchainFlagsFB;
|
||||||
|
|
||||||
|
// Flag bits for XrAndroidSurfaceSwapchainFlagsFB
|
||||||
|
static const XrAndroidSurfaceSwapchainFlagsFB XR_ANDROID_SURFACE_SWAPCHAIN_SYNCHRONOUS_BIT_FB = 0x00000001;
|
||||||
|
static const XrAndroidSurfaceSwapchainFlagsFB XR_ANDROID_SURFACE_SWAPCHAIN_USE_TIMESTAMPS_BIT_FB = 0x00000002;
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_ANDROID
|
||||||
|
// XrAndroidSurfaceSwapchainCreateInfoFB extends XrSwapchainCreateInfo
|
||||||
|
typedef struct XrAndroidSurfaceSwapchainCreateInfoFB {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
XrAndroidSurfaceSwapchainFlagsFB createFlags;
|
||||||
|
} XrAndroidSurfaceSwapchainCreateInfoFB;
|
||||||
|
#endif // XR_USE_PLATFORM_ANDROID
|
||||||
|
|
||||||
|
#endif /* XR_USE_PLATFORM_ANDROID */
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_ML
|
||||||
|
|
||||||
|
// XR_ML_compat is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_ML_compat 1
|
||||||
|
#define XR_ML_compat_SPEC_VERSION 1
|
||||||
|
#define XR_ML_COMPAT_EXTENSION_NAME "XR_ML_compat"
|
||||||
|
typedef struct XrCoordinateSpaceCreateInfoML {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
MLCoordinateFrameUID cfuid;
|
||||||
|
XrPosef poseInCoordinateSpace;
|
||||||
|
} XrCoordinateSpaceCreateInfoML;
|
||||||
|
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrCreateSpaceFromCoordinateFrameUIDML)(XrSession session, const XrCoordinateSpaceCreateInfoML *createInfo, XrSpace* space);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpaceFromCoordinateFrameUIDML(
|
||||||
|
XrSession session,
|
||||||
|
const XrCoordinateSpaceCreateInfoML * createInfo,
|
||||||
|
XrSpace* space);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_PLATFORM_ML */
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_WIN32
|
||||||
|
|
||||||
|
// XR_OCULUS_audio_device_guid is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_OCULUS_audio_device_guid 1
|
||||||
|
#define XR_OCULUS_audio_device_guid_SPEC_VERSION 1
|
||||||
|
#define XR_OCULUS_AUDIO_DEVICE_GUID_EXTENSION_NAME "XR_OCULUS_audio_device_guid"
|
||||||
|
#define XR_MAX_AUDIO_DEVICE_STR_SIZE_OCULUS 128
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrGetAudioOutputDeviceGuidOculus)(XrInstance instance, wchar_t buffer[XR_MAX_AUDIO_DEVICE_STR_SIZE_OCULUS]);
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrGetAudioInputDeviceGuidOculus)(XrInstance instance, wchar_t buffer[XR_MAX_AUDIO_DEVICE_STR_SIZE_OCULUS]);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrGetAudioOutputDeviceGuidOculus(
|
||||||
|
XrInstance instance,
|
||||||
|
wchar_t buffer[XR_MAX_AUDIO_DEVICE_STR_SIZE_OCULUS]);
|
||||||
|
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrGetAudioInputDeviceGuidOculus(
|
||||||
|
XrInstance instance,
|
||||||
|
wchar_t buffer[XR_MAX_AUDIO_DEVICE_STR_SIZE_OCULUS]);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_PLATFORM_WIN32 */
|
||||||
|
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_VULKAN
|
||||||
|
|
||||||
|
// XR_FB_foveation_vulkan is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_FB_foveation_vulkan 1
|
||||||
|
#define XR_FB_foveation_vulkan_SPEC_VERSION 1
|
||||||
|
#define XR_FB_FOVEATION_VULKAN_EXTENSION_NAME "XR_FB_foveation_vulkan"
|
||||||
|
// XrSwapchainImageFoveationVulkanFB extends XrSwapchainImageVulkanKHR
|
||||||
|
typedef struct XrSwapchainImageFoveationVulkanFB {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
VkImage image;
|
||||||
|
uint32_t width;
|
||||||
|
uint32_t height;
|
||||||
|
} XrSwapchainImageFoveationVulkanFB;
|
||||||
|
|
||||||
|
#endif /* XR_USE_GRAPHICS_API_VULKAN */
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_ANDROID
|
||||||
|
|
||||||
|
// XR_FB_swapchain_update_state_android_surface is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_FB_swapchain_update_state_android_surface 1
|
||||||
|
#define XR_FB_swapchain_update_state_android_surface_SPEC_VERSION 1
|
||||||
|
#define XR_FB_SWAPCHAIN_UPDATE_STATE_ANDROID_SURFACE_EXTENSION_NAME "XR_FB_swapchain_update_state_android_surface"
|
||||||
|
#ifdef XR_USE_PLATFORM_ANDROID
|
||||||
|
typedef struct XrSwapchainStateAndroidSurfaceDimensionsFB {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
uint32_t width;
|
||||||
|
uint32_t height;
|
||||||
|
} XrSwapchainStateAndroidSurfaceDimensionsFB;
|
||||||
|
#endif // XR_USE_PLATFORM_ANDROID
|
||||||
|
|
||||||
|
#endif /* XR_USE_PLATFORM_ANDROID */
|
||||||
|
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_OPENGL_ES
|
||||||
|
|
||||||
|
// XR_FB_swapchain_update_state_opengl_es is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_FB_swapchain_update_state_opengl_es 1
|
||||||
|
#define XR_FB_swapchain_update_state_opengl_es_SPEC_VERSION 1
|
||||||
|
#define XR_FB_SWAPCHAIN_UPDATE_STATE_OPENGL_ES_EXTENSION_NAME "XR_FB_swapchain_update_state_opengl_es"
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_OPENGL_ES
|
||||||
|
typedef struct XrSwapchainStateSamplerOpenGLESFB {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
EGLenum minFilter;
|
||||||
|
EGLenum magFilter;
|
||||||
|
EGLenum wrapModeS;
|
||||||
|
EGLenum wrapModeT;
|
||||||
|
EGLenum swizzleRed;
|
||||||
|
EGLenum swizzleGreen;
|
||||||
|
EGLenum swizzleBlue;
|
||||||
|
EGLenum swizzleAlpha;
|
||||||
|
float maxAnisotropy;
|
||||||
|
XrColor4f borderColor;
|
||||||
|
} XrSwapchainStateSamplerOpenGLESFB;
|
||||||
|
#endif // XR_USE_GRAPHICS_API_OPENGL_ES
|
||||||
|
|
||||||
|
#endif /* XR_USE_GRAPHICS_API_OPENGL_ES */
|
||||||
|
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_VULKAN
|
||||||
|
|
||||||
|
// XR_FB_swapchain_update_state_vulkan is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_FB_swapchain_update_state_vulkan 1
|
||||||
|
#define XR_FB_swapchain_update_state_vulkan_SPEC_VERSION 1
|
||||||
|
#define XR_FB_SWAPCHAIN_UPDATE_STATE_VULKAN_EXTENSION_NAME "XR_FB_swapchain_update_state_vulkan"
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_VULKAN
|
||||||
|
typedef struct XrSwapchainStateSamplerVulkanFB {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
VkFilter minFilter;
|
||||||
|
VkFilter magFilter;
|
||||||
|
VkSamplerMipmapMode mipmapMode;
|
||||||
|
VkSamplerAddressMode wrapModeS;
|
||||||
|
VkSamplerAddressMode wrapModeT;
|
||||||
|
VkComponentSwizzle swizzleRed;
|
||||||
|
VkComponentSwizzle swizzleGreen;
|
||||||
|
VkComponentSwizzle swizzleBlue;
|
||||||
|
VkComponentSwizzle swizzleAlpha;
|
||||||
|
float maxAnisotropy;
|
||||||
|
XrColor4f borderColor;
|
||||||
|
} XrSwapchainStateSamplerVulkanFB;
|
||||||
|
#endif // XR_USE_GRAPHICS_API_VULKAN
|
||||||
|
|
||||||
|
#endif /* XR_USE_GRAPHICS_API_VULKAN */
|
||||||
|
|
||||||
|
#ifdef XR_USE_GRAPHICS_API_VULKAN
|
||||||
|
|
||||||
|
// XR_META_vulkan_swapchain_create_info is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_META_vulkan_swapchain_create_info 1
|
||||||
|
#define XR_META_vulkan_swapchain_create_info_SPEC_VERSION 1
|
||||||
|
#define XR_META_VULKAN_SWAPCHAIN_CREATE_INFO_EXTENSION_NAME "XR_META_vulkan_swapchain_create_info"
|
||||||
|
// XrVulkanSwapchainCreateInfoMETA extends XrSwapchainCreateInfo
|
||||||
|
typedef struct XrVulkanSwapchainCreateInfoMETA {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
VkImageCreateFlags additionalCreateFlags;
|
||||||
|
VkImageUsageFlags additionalUsageFlags;
|
||||||
|
} XrVulkanSwapchainCreateInfoMETA;
|
||||||
|
|
||||||
|
#endif /* XR_USE_GRAPHICS_API_VULKAN */
|
||||||
|
|
||||||
|
#ifdef XR_USE_PLATFORM_ANDROID
|
||||||
|
|
||||||
|
// XR_ANDROID_anchor_sharing_export is a preprocessor guard. Do not pass it to API calls.
|
||||||
|
#define XR_ANDROID_anchor_sharing_export 1
|
||||||
|
#define XR_ANDROID_anchor_sharing_export_SPEC_VERSION 1
|
||||||
|
#define XR_ANDROID_ANCHOR_SHARING_EXPORT_EXTENSION_NAME "XR_ANDROID_anchor_sharing_export"
|
||||||
|
typedef struct XrAnchorSharingInfoANDROID {
|
||||||
|
XrStructureType type;
|
||||||
|
const void* XR_MAY_ALIAS next;
|
||||||
|
XrSpace anchor;
|
||||||
|
} XrAnchorSharingInfoANDROID;
|
||||||
|
|
||||||
|
typedef struct XrAnchorSharingTokenANDROID {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
struct AIBinder* token;
|
||||||
|
} XrAnchorSharingTokenANDROID;
|
||||||
|
|
||||||
|
// XrSystemAnchorSharingExportPropertiesANDROID extends XrSystemProperties
|
||||||
|
typedef struct XrSystemAnchorSharingExportPropertiesANDROID {
|
||||||
|
XrStructureType type;
|
||||||
|
void* XR_MAY_ALIAS next;
|
||||||
|
XrBool32 supportsAnchorSharingExport;
|
||||||
|
} XrSystemAnchorSharingExportPropertiesANDROID;
|
||||||
|
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrShareAnchorANDROID)(XrSession session, const XrAnchorSharingInfoANDROID* sharingInfo, XrAnchorSharingTokenANDROID* anchorToken);
|
||||||
|
typedef XrResult (XRAPI_PTR *PFN_xrUnshareAnchorANDROID)(XrSession session, XrSpace anchor);
|
||||||
|
|
||||||
|
#ifndef XR_NO_PROTOTYPES
|
||||||
|
#ifdef XR_EXTENSION_PROTOTYPES
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrShareAnchorANDROID(
|
||||||
|
XrSession session,
|
||||||
|
const XrAnchorSharingInfoANDROID* sharingInfo,
|
||||||
|
XrAnchorSharingTokenANDROID* anchorToken);
|
||||||
|
|
||||||
|
XRAPI_ATTR XrResult XRAPI_CALL xrUnshareAnchorANDROID(
|
||||||
|
XrSession session,
|
||||||
|
XrSpace anchor);
|
||||||
|
#endif /* XR_EXTENSION_PROTOTYPES */
|
||||||
|
#endif /* !XR_NO_PROTOTYPES */
|
||||||
|
#endif /* XR_USE_PLATFORM_ANDROID */
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
** Copyright (c) 2017-2026 The Khronos Group Inc.
|
||||||
|
**
|
||||||
|
** SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef OPENXR_PLATFORM_DEFINES_H_
|
||||||
|
#define OPENXR_PLATFORM_DEFINES_H_ 1
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Platform-specific calling convention macros.
|
||||||
|
*
|
||||||
|
* Platforms should define these so that OpenXR clients call OpenXR functions
|
||||||
|
* with the same calling conventions that the OpenXR implementation expects.
|
||||||
|
*
|
||||||
|
* XRAPI_ATTR - Placed before the return type in function declarations.
|
||||||
|
* Useful for C++11 and GCC/Clang-style function attribute syntax.
|
||||||
|
* XRAPI_CALL - Placed after the return type in function declarations.
|
||||||
|
* Useful for MSVC-style calling convention syntax.
|
||||||
|
* XRAPI_PTR - Placed between the '(' and '*' in function pointer types.
|
||||||
|
*
|
||||||
|
* Function declaration: XRAPI_ATTR void XRAPI_CALL xrFunction(void);
|
||||||
|
* Function pointer type: typedef void (XRAPI_PTR *PFN_xrFunction)(void);
|
||||||
|
*/
|
||||||
|
#if defined(_WIN32)
|
||||||
|
#define XRAPI_ATTR
|
||||||
|
// On Windows, functions use the stdcall convention
|
||||||
|
#define XRAPI_CALL __stdcall
|
||||||
|
#define XRAPI_PTR XRAPI_CALL
|
||||||
|
#elif defined(__ANDROID__) && defined(__ARM_ARCH) && __ARM_ARCH < 7
|
||||||
|
#error "API not supported for the 'armeabi' NDK ABI"
|
||||||
|
#elif defined(__ANDROID__) && defined(__ARM_ARCH) && __ARM_ARCH >= 7 && defined(__ARM_32BIT_STATE)
|
||||||
|
// On Android 32-bit ARM targets, functions use the "hardfloat"
|
||||||
|
// calling convention, i.e. float parameters are passed in registers. This
|
||||||
|
// is true even if the rest of the application passes floats on the stack,
|
||||||
|
// as it does by default when compiling for the armeabi-v7a NDK ABI.
|
||||||
|
#define XRAPI_ATTR __attribute__((pcs("aapcs-vfp")))
|
||||||
|
#define XRAPI_CALL
|
||||||
|
#define XRAPI_PTR XRAPI_ATTR
|
||||||
|
#else
|
||||||
|
// On other platforms, use the default calling convention
|
||||||
|
#define XRAPI_ATTR
|
||||||
|
#define XRAPI_CALL
|
||||||
|
#define XRAPI_PTR
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#if !defined(XR_NO_STDINT_H)
|
||||||
|
#if defined(_MSC_VER) && (_MSC_VER < 1600)
|
||||||
|
typedef signed __int8 int8_t;
|
||||||
|
typedef unsigned __int8 uint8_t;
|
||||||
|
typedef signed __int16 int16_t;
|
||||||
|
typedef unsigned __int16 uint16_t;
|
||||||
|
typedef signed __int32 int32_t;
|
||||||
|
typedef unsigned __int32 uint32_t;
|
||||||
|
typedef signed __int64 int64_t;
|
||||||
|
typedef unsigned __int64 uint64_t;
|
||||||
|
#else
|
||||||
|
#include <stdint.h>
|
||||||
|
#endif
|
||||||
|
#endif // !defined( XR_NO_STDINT_H )
|
||||||
|
|
||||||
|
// XR_PTR_SIZE (in bytes)
|
||||||
|
#if (defined(__LP64__) || defined(_WIN64) || (defined(__x86_64__) && !defined(__ILP32__) ) || defined(_M_X64) || defined(__ia64) || defined(_M_IA64) || defined(__aarch64__) || defined(__powerpc64__))
|
||||||
|
#define XR_PTR_SIZE 8
|
||||||
|
#else
|
||||||
|
#define XR_PTR_SIZE 4
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Needed so we can use clang __has_feature portably.
|
||||||
|
#if !defined(XR_COMPILER_HAS_FEATURE)
|
||||||
|
#if defined(__clang__)
|
||||||
|
#define XR_COMPILER_HAS_FEATURE(x) __has_feature(x)
|
||||||
|
#else
|
||||||
|
#define XR_COMPILER_HAS_FEATURE(x) 0
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Identifies if the current compiler has C++11 support enabled.
|
||||||
|
// Does not by itself identify if any given C++11 feature is present.
|
||||||
|
#if !defined(XR_CPP11_ENABLED) && defined(__cplusplus)
|
||||||
|
#if defined(__GNUC__) && defined(__GXX_EXPERIMENTAL_CXX0X__)
|
||||||
|
#define XR_CPP11_ENABLED 1
|
||||||
|
#elif defined(_MSC_VER) && (_MSC_VER >= 1600)
|
||||||
|
#define XR_CPP11_ENABLED 1
|
||||||
|
#elif (__cplusplus >= 201103L) // 201103 is the first C++11 version.
|
||||||
|
#define XR_CPP11_ENABLED 1
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Identifies if the current compiler supports C++11 nullptr.
|
||||||
|
#if !defined(XR_CPP_NULLPTR_SUPPORTED)
|
||||||
|
#if defined(XR_CPP11_ENABLED) && \
|
||||||
|
((defined(__clang__) && XR_COMPILER_HAS_FEATURE(cxx_nullptr)) || \
|
||||||
|
(defined(__GNUC__) && (((__GNUC__ * 1000) + __GNUC_MINOR__) >= 4006)) || \
|
||||||
|
(defined(_MSC_VER) && (_MSC_VER >= 1600)) || \
|
||||||
|
(defined(__EDG_VERSION__) && (__EDG_VERSION__ >= 403)))
|
||||||
|
#define XR_CPP_NULLPTR_SUPPORTED 1
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if !defined(XR_CPP_NULLPTR_SUPPORTED)
|
||||||
|
#define XR_CPP_NULLPTR_SUPPORTED 0
|
||||||
|
#endif // !defined(XR_CPP_NULLPTR_SUPPORTED)
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -1,65 +1,8 @@
|
|||||||
#!/usr/bin/env zsh
|
#!/usr/bin/env zsh
|
||||||
# Mac-side: upload VR videos to the Steam Frame so DeoVR can play them in 3D.
|
# Upload media for Frame Control's own OpenVR player (no external player).
|
||||||
#
|
# Usage: scripts/push-vr-video.sh [--launch] [--layout auto|mono|sbs|ou|full-sbs|full-ou] [--theatre] FILE...
|
||||||
# Files go to ~/Videos/VR on the Frame. The script links that folder into
|
# scripts/push-vr-video.sh --list | --stop
|
||||||
# DeoVR's Proton prefix as C:\users\steamuser\Videos\VR, so DeoVR's file
|
# Files go to ~/Videos/FrameControl/<id>/; --launch accepts exactly one file.
|
||||||
# browser finds it under Videos. (It's also reachable as Z:\home\steamos\Videos\VR.)
|
# Directories, automatic DeoVR launching and Proton-prefix links are no longer used.
|
||||||
# Uploads resume if interrupted.
|
|
||||||
#
|
|
||||||
# Name files so DeoVR picks the projection: include _180 or _360 (or _fisheye190,
|
|
||||||
# _mkx200, _rf52 ...) plus the stereo layout (_LR / _SBS side by side, _TB over-
|
|
||||||
# under), e.g. "beach_180_LR.mp4". You can also change it in DeoVR's player.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# scripts/push-vr-video.sh FILE_OR_DIR... # upload
|
|
||||||
# scripts/push-vr-video.sh --launch FILE... # upload, then start DeoVR
|
|
||||||
# scripts/push-vr-video.sh --launch # just start DeoVR
|
|
||||||
# scripts/push-vr-video.sh --list # what's on the Frame
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
exec python3 "${0:A:h}/../ui/frame_media_cli.py" "$@"
|
||||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
|
||||||
DEOVR_APPID=837380
|
|
||||||
REMOTE_DIR="Videos/VR"
|
|
||||||
PREFIX_VIDEOS=".local/share/Steam/steamapps/compatdata/$DEOVR_APPID/pfx/drive_c/users/steamuser/Videos"
|
|
||||||
|
|
||||||
launch=0 list=0
|
|
||||||
while (( $# )); do
|
|
||||||
case "$1" in
|
|
||||||
-h|--help) sed -n '2,17p' "$0"; exit 0 ;;
|
|
||||||
--launch) launch=1; shift ;;
|
|
||||||
--list) list=1; shift ;;
|
|
||||||
--) shift; break ;;
|
|
||||||
-*) print -u2 "push-vr-video: unknown option $1"; exit 2 ;;
|
|
||||||
*) break ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
(( $# || launch || list )) || { sed -n '2,17p' "$0" >&2; exit 2; }
|
|
||||||
|
|
||||||
for f in "$@"; do
|
|
||||||
[[ -e "$f" ]] || { print -u2 "push-vr-video: no such file: $f"; exit 2; }
|
|
||||||
done
|
|
||||||
|
|
||||||
# Create the folder and link it into DeoVR's prefix (the prefix exists once
|
|
||||||
# DeoVR has run). Refresh a stale link, but never replace a real directory.
|
|
||||||
if (( $# || launch )); then
|
|
||||||
ssh "$FRAME_ALIAS" "mkdir -p ~/$REMOTE_DIR
|
|
||||||
p=~/$PREFIX_VIDEOS
|
|
||||||
if [ -d \"\$p\" ] && { [ -L \"\$p/VR\" ] || [ ! -e \"\$p/VR\" ]; }; then ln -sfn ~/$REMOTE_DIR \"\$p/VR\"
|
|
||||||
elif [ -d \"\$p/VR\" ]; then echo \"warning: \$p/VR is a real folder, so uploads won't show under DeoVR's Videos; browse Z:\\\\home\\\\steamos\\\\Videos\\\\VR instead\" >&2; fi
|
|
||||||
[ -d \"\$p\" ] || echo 'note: DeoVR has not run yet; use Z:\\home\\steamos\\Videos\\VR or run this again after starting it once' >&2"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if (( $# )); then
|
|
||||||
# -L: send what a symlink points at; a Mac-side link would dangle on the Frame
|
|
||||||
rsync -aL --partial --progress -- "$@" "$FRAME_ALIAS:$REMOTE_DIR/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if (( list )); then
|
|
||||||
ssh "$FRAME_ALIAS" "cd ~/$REMOTE_DIR && ls -lhR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if (( launch )); then
|
|
||||||
ssh "$FRAME_ALIAS" "command -v steam >/dev/null || { echo 'steam not found on the Frame' >&2; exit 1; }
|
|
||||||
steam steam://rungameid/$DEOVR_APPID </dev/null >/dev/null 2>&1 &"
|
|
||||||
print "DeoVR starting on the Frame. Open Local files / the file browser → Videos → VR."
|
|
||||||
fi
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Media transfer through the real HTTP/SSH path; the fake has no VR renderer."""
|
||||||
|
import harness
|
||||||
|
from harness import ok, ssh
|
||||||
|
|
||||||
|
harness.require()
|
||||||
|
|
||||||
|
|
||||||
|
class Media(harness.FrameTestCase):
|
||||||
|
def test_upload_and_list_without_launching_a_viewer(self):
|
||||||
|
# The transfer endpoint doesn't decode. Rendering belongs to the real
|
||||||
|
# headset smoke checks documented in docs/vr-video.md.
|
||||||
|
self.assertEqual(ssh('stat -c "%U:%G %a" ~/.local/share').strip(), 'steamos:steamos 755')
|
||||||
|
result = ok('POST', '/api/upload', raw=b'fake-media', headers={
|
||||||
|
'X-Mode': 'media', 'X-Filename': 'test_SBS.png'})
|
||||||
|
identity = result['id']
|
||||||
|
try:
|
||||||
|
files = ok('POST', '/api/media', {'action': 'list'})['files']
|
||||||
|
self.assertIn(identity, [f['id'] for f in files])
|
||||||
|
self.assertEqual(ssh('cat ~/Videos/FrameControl/'+identity), 'fake-media')
|
||||||
|
finally:
|
||||||
|
ssh('rm -rf ~/Videos/FrameControl/'+identity.split('/')[0])
|
||||||
@@ -231,6 +231,13 @@ def sysfs(state):
|
|||||||
|
|
||||||
def runtimes(state):
|
def runtimes(state):
|
||||||
"""Installed compat tools as Steam app manifests, and the Lepton launcher itself."""
|
"""Installed compat tools as Steam app manifests, and the Lepton launcher itself."""
|
||||||
|
# Verified 2026-09-28, BUILD_ID 20260925.6191901: both parents are
|
||||||
|
# steamos:steamos 0755. The root supervisor must not leave them root-owned
|
||||||
|
# when creating Steam's fake manifests; user-account app installs need them.
|
||||||
|
for directory in (HOME + '/.local', HOME + '/.local/share'):
|
||||||
|
os.makedirs(directory, mode=0o755, exist_ok=True)
|
||||||
|
chown(directory)
|
||||||
|
os.chmod(directory, 0o755)
|
||||||
apps = fs.STEAM_ROOT + '/steamapps'
|
apps = fs.STEAM_ROOT + '/steamapps'
|
||||||
for alias, installed in state['runtimes'].items():
|
for alias, installed in state['runtimes'].items():
|
||||||
acf = f'{apps}/appmanifest_{fs.RUNTIME_APPIDS[alias]}.acf'
|
acf = f'{apps}/appmanifest_{fs.RUNTIME_APPIDS[alias]}.acf'
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
"""Local-only VR manifest, raw ZIP and independent signature checks."""
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||||
|
import frame_apk
|
||||||
|
import frame_apk_vr as vr
|
||||||
|
import frame_apk_sign as signing
|
||||||
|
import frame_android
|
||||||
|
from test_frame_apk import pool
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT = 'android.intent.category.DEFAULT'
|
||||||
|
|
||||||
|
|
||||||
|
def manifest(utf8=False, launcher=False, category=None, samsung=False, split=False, alias=False, splash=False):
|
||||||
|
strings = ['manifest', 'package', 'org.test.vr', 'application', 'activity', 'intent-filter',
|
||||||
|
'action', 'category', 'name', vr.MAIN, category or next(iter(sorted(vr.VR))),
|
||||||
|
vr.LAUNCHER, 'http://schemas.android.com/apk/res/android', 'meta-data', 'value',
|
||||||
|
'com.samsung.android.vr.application.mode', 'vr_only', 'activity-alias', DEFAULT, next(iter(sorted(vr.VR))), 'targetActivity', '.Splash', '.Game']
|
||||||
|
def start(tag, attrs=()):
|
||||||
|
body = struct.pack('<IIHHHHHH', 0xffffffff, strings.index(tag), 20, 20, len(attrs), 0, 0, 0)
|
||||||
|
for name, value in attrs:
|
||||||
|
ns = 0xffffffff if name == 'package' else 12
|
||||||
|
body += struct.pack('<IIIHBBI', ns, strings.index(name), strings.index(value), 8, 0, 3, strings.index(value))
|
||||||
|
return struct.pack('<HHIII', 0x102, 16, 16 + len(body), 1, 0xffffffff) + body
|
||||||
|
def end(tag):
|
||||||
|
return struct.pack('<HHIIIII', 0x103, 16, 24, 1, 0xffffffff, 0xffffffff, strings.index(tag))
|
||||||
|
def leaf(tag, attrs):
|
||||||
|
return start(tag, attrs) + end(tag)
|
||||||
|
b = pool(strings, utf8) + start('manifest', [('package', 'org.test.vr')]) + start('application')
|
||||||
|
if samsung:
|
||||||
|
b += leaf('meta-data', [('name', strings[15]), ('value', 'vr_only')])
|
||||||
|
if splash: # a helper activity with its own MAIN filter, ahead of the game's
|
||||||
|
b += start('activity', [('name', '.Splash')]) + start('intent-filter') + leaf('action', [('name', vr.MAIN)])
|
||||||
|
b += leaf('category', [('name', DEFAULT)]) + end('intent-filter') + end('activity')
|
||||||
|
b += start('activity', [('name', '.Game')] if splash else []) + start('intent-filter') + leaf('action', [('name', vr.MAIN)])
|
||||||
|
b += leaf('category', [('name', strings[10])])
|
||||||
|
if split:
|
||||||
|
b += end('intent-filter') + start('intent-filter')
|
||||||
|
if launcher:
|
||||||
|
b += leaf('category', [('name', vr.LAUNCHER)])
|
||||||
|
b += end('intent-filter') + end('activity')
|
||||||
|
if alias: # Godot 4: LAUNCHER only on an alias of the activity ('vr' or 'flat')
|
||||||
|
b += start('activity-alias', [('targetActivity', '.Game')] if splash else []) + start('intent-filter') + leaf('action', [('name', vr.MAIN)])
|
||||||
|
if alias == 'vr':
|
||||||
|
b += leaf('category', [('name', next(iter(sorted(vr.VR))))])
|
||||||
|
b += leaf('category', [('name', vr.LAUNCHER)])
|
||||||
|
b += end('intent-filter') + end('activity-alias')
|
||||||
|
b += end('application') + end('manifest')
|
||||||
|
return struct.pack('<HHI', 3, 8, len(b) + 8) + b
|
||||||
|
|
||||||
|
|
||||||
|
class VRTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.tmp = tempfile.TemporaryDirectory()
|
||||||
|
cls.keypath = Path(cls.tmp.name) / 'key.json'
|
||||||
|
cls.key = signing.signing_key(cls.keypath)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_manifest_patch(self):
|
||||||
|
for utf8 in (False, True):
|
||||||
|
for category in vr.VR:
|
||||||
|
original = manifest(utf8, category=category)
|
||||||
|
result = vr.add_launcher_category(original)
|
||||||
|
info, filters = vr.inspect(result)
|
||||||
|
self.assertTrue(info['launchable'])
|
||||||
|
self.assertTrue(info['vr'])
|
||||||
|
self.assertIn(vr.LAUNCHER, filters[0]['categories'])
|
||||||
|
self.assertEqual(struct.unpack_from('<I', result, 4)[0], len(result))
|
||||||
|
self.assertEqual(vr.add_launcher_category(result), result)
|
||||||
|
self.assertEqual(vr.add_launcher_category(manifest(utf8, True)), manifest(utf8, True))
|
||||||
|
|
||||||
|
def test_filter_boundaries(self):
|
||||||
|
self.assertFalse(vr.inspect(manifest(launcher=True, split=True))[0]['launchable'])
|
||||||
|
self.assertTrue(vr.inspect(vr.add_launcher_category(manifest(launcher=True, split=True)))[0]['launchable'])
|
||||||
|
|
||||||
|
def test_alias_launcher_is_not_enough(self):
|
||||||
|
# (manifest, still VR after patching)
|
||||||
|
cases = [(manifest(alias='vr'), True), # Godot 4 VR export
|
||||||
|
(manifest(alias='vr', category=DEFAULT), True), # VR category only on the alias
|
||||||
|
(manifest(alias='flat', category=DEFAULT), False)] # Godot 4 flat export
|
||||||
|
for original, is_vr in cases:
|
||||||
|
info, filters = vr.inspect(original)
|
||||||
|
self.assertFalse(info['launchable'])
|
||||||
|
self.assertTrue(info['repairable'])
|
||||||
|
self.assertEqual(len(filters), 1)
|
||||||
|
self.assertFalse(filters[0]['alias'])
|
||||||
|
result = vr.add_launcher_category(original)
|
||||||
|
info, _ = vr.inspect(result)
|
||||||
|
self.assertTrue(info['launchable'])
|
||||||
|
self.assertFalse(info['repairable'])
|
||||||
|
self.assertEqual(info['vr'], is_vr)
|
||||||
|
|
||||||
|
def test_alias_target_activity_is_patched(self):
|
||||||
|
original = manifest(alias='flat', category=DEFAULT, splash=True)
|
||||||
|
info, filters = vr.inspect(original)
|
||||||
|
self.assertTrue(info['repairable'])
|
||||||
|
self.assertEqual(filters[0]['activity'], 'org.test.vr.Game')
|
||||||
|
owner, launchers = None, []
|
||||||
|
for tag, attrs in frame_apk.manifest_elements(vr.add_launcher_category(original)):
|
||||||
|
if tag in ('activity', 'activity-alias'):
|
||||||
|
owner = (tag, attrs.get('name', (0, 0, None))[2])
|
||||||
|
if tag == 'category' and attrs['name'][2] == vr.LAUNCHER:
|
||||||
|
launchers.append(owner)
|
||||||
|
self.assertEqual(launchers, [('activity', '.Game'), ('activity-alias', None)])
|
||||||
|
|
||||||
|
def test_alias_with_launchable_activity_is_left_alone(self):
|
||||||
|
original = manifest(launcher=True, alias='vr')
|
||||||
|
info, _ = vr.inspect(original)
|
||||||
|
self.assertTrue(info['launchable'])
|
||||||
|
self.assertFalse(info['repairable'])
|
||||||
|
self.assertEqual(vr.add_launcher_category(original), original)
|
||||||
|
|
||||||
|
def test_patch_alias_apk(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
src, dst = Path(d) / 'in.apk', Path(d) / 'out.apk'
|
||||||
|
with zipfile.ZipFile(src, 'w') as z:
|
||||||
|
z.writestr('AndroidManifest.xml', manifest(alias='flat', category=DEFAULT))
|
||||||
|
with patch.object(signing, 'signing_key', return_value=self.key):
|
||||||
|
result = frame_android.patch(src, dst)
|
||||||
|
self.assertEqual(result['patched'], ['launcher'])
|
||||||
|
self.assertTrue(frame_apk.apk_info(dst)['launchable'])
|
||||||
|
self.assertTrue(signing.verify(dst))
|
||||||
|
|
||||||
|
def test_styled_pool(self):
|
||||||
|
for utf8 in (False, True):
|
||||||
|
p = bytearray(pool(['styled'], utf8))
|
||||||
|
old_start = struct.unpack_from('<I', p, 20)[0]
|
||||||
|
p[old_start:old_start] = struct.pack('<I', 0)
|
||||||
|
style_start = len(p)
|
||||||
|
p += struct.pack('<III', 0, 0, 2) + b'\xff' * 12
|
||||||
|
struct.pack_into('<I', p, 4, len(p))
|
||||||
|
struct.pack_into('<I', p, 16, (0x100 if utf8 else 0) | 1)
|
||||||
|
struct.pack_into('<I', p, 12, 1)
|
||||||
|
struct.pack_into('<II', p, 20, old_start + 4, style_start)
|
||||||
|
result, idx = vr._append_string(bytes(p), vr.LAUNCHER)
|
||||||
|
self.assertEqual(frame_apk._string_pool(result, 0), ['styled', vr.LAUNCHER])
|
||||||
|
new_style = struct.unpack_from('<I', result, 24)[0]
|
||||||
|
self.assertEqual(result[new_style:], p[style_start:])
|
||||||
|
self.assertEqual(len(result) % 4, 0)
|
||||||
|
|
||||||
|
def test_detection(self):
|
||||||
|
names = {'lib/arm64-v8a/' + n for n in ('libvrapi.so', 'libopenxr_loader.so', 'libovrplatformloader.so')}
|
||||||
|
info = vr.detection(manifest(category='android.intent.category.LAUNCHER'), names)
|
||||||
|
self.assertTrue(info['vr'])
|
||||||
|
self.assertTrue(info['launchable'])
|
||||||
|
self.assertEqual(len(info['vr_issues']), 3)
|
||||||
|
self.assertFalse(vr.detection(manifest(category=vr.LAUNCHER), set())['vr'])
|
||||||
|
self.assertTrue(vr.detection(manifest(category=vr.LAUNCHER, samsung=True), set())['vr'])
|
||||||
|
|
||||||
|
def test_key_cache(self):
|
||||||
|
with patch.object(signing, '_prime', side_effect=AssertionError('regenerated')):
|
||||||
|
self.assertEqual(signing.signing_key(self.keypath), self.key)
|
||||||
|
if os.name != 'nt':
|
||||||
|
self.assertEqual(self.keypath.stat().st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
def test_repack_sign_tamper(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
src, dst = Path(d) / 'in.apk', Path(d) / 'out.apk'
|
||||||
|
with zipfile.ZipFile(src, 'w') as z:
|
||||||
|
z.writestr('AndroidManifest.xml', manifest(), compress_type=8)
|
||||||
|
z.writestr('classes.dex', b'compress me' * 10000, compress_type=8)
|
||||||
|
z.writestr('resources.arsc', b'1234')
|
||||||
|
z.writestr('lib/arm64-v8a/libx.so', b'ELF' * 500000)
|
||||||
|
z.writestr('META-INF/OLD.RSA', b'old')
|
||||||
|
z.writestr('META-INF/MANIFEST.MF', b'old')
|
||||||
|
with patch.object(signing, 'signing_key', return_value=self.key):
|
||||||
|
result = frame_android.patch(src, dst, {'assets/layer.json': b'{}', 'lib/arm64-v8a/liblayer.so': b'layer'})
|
||||||
|
self.assertEqual(result['patched'], ['launcher'])
|
||||||
|
self.assertTrue(frame_apk.apk_info(dst)['launchable'])
|
||||||
|
self.assertTrue(signing.verify(dst))
|
||||||
|
def compressed(path, info):
|
||||||
|
data = path.read_bytes()
|
||||||
|
nl, el = struct.unpack_from('<HH', data, info.header_offset + 26)
|
||||||
|
start = info.header_offset + 30 + nl + el
|
||||||
|
return data[start:start + info.compress_size], start
|
||||||
|
with zipfile.ZipFile(src) as a, zipfile.ZipFile(dst) as b:
|
||||||
|
self.assertNotIn('META-INF/OLD.RSA', b.namelist())
|
||||||
|
self.assertNotIn('META-INF/MANIFEST.MF', b.namelist())
|
||||||
|
for i in b.infolist():
|
||||||
|
raw, start = compressed(dst, i)
|
||||||
|
if i.compress_type == 0:
|
||||||
|
self.assertEqual(start % (16384 if i.filename.endswith('.so') else 4), 0)
|
||||||
|
if i.filename in ('classes.dex', 'resources.arsc', 'lib/arm64-v8a/libx.so'):
|
||||||
|
self.assertEqual(raw, compressed(src, a.getinfo(i.filename))[0])
|
||||||
|
_, off = compressed(dst, b.getinfo('resources.arsc'))
|
||||||
|
original = dst.read_bytes()
|
||||||
|
data = bytearray(original)
|
||||||
|
eo, cd = signing._eocd(data)
|
||||||
|
size = struct.unpack_from('<Q', data, cd - 24)[0]
|
||||||
|
block_start = cd - size - 8
|
||||||
|
value = data[block_start + 20:cd - 24]
|
||||||
|
signer = signing._parts(signing._parts(value)[0])[0]
|
||||||
|
signed, signatures, pub = signing._parts(signer)
|
||||||
|
signature = signing._parts(signing._parts(signatures)[0][4:])[0]
|
||||||
|
sig_offset = data.index(signature, block_start)
|
||||||
|
data[sig_offset] ^= 1
|
||||||
|
dst.write_bytes(data)
|
||||||
|
with self.assertRaisesRegex(ValueError, 'RSA signature'):
|
||||||
|
signing.verify(dst)
|
||||||
|
data = bytearray(original)
|
||||||
|
data[off] ^= 1
|
||||||
|
dst.write_bytes(data)
|
||||||
|
with self.assertRaisesRegex(ValueError, 'digest'):
|
||||||
|
signing.verify(dst)
|
||||||
|
|
||||||
|
@unittest.skipUnless(Path('/usr/bin/openssl').exists(), 'openssl absent')
|
||||||
|
def test_openssl(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
d = Path(d)
|
||||||
|
(d / 'cert.der').write_bytes(signing.certificate(self.key))
|
||||||
|
(d / 'message').write_bytes(b'independent signature check')
|
||||||
|
(d / 'signature').write_bytes(signing.rsa_sign(b'independent signature check', self.key))
|
||||||
|
def run(*args):
|
||||||
|
return subprocess.run(['/usr/bin/openssl', *args], check=True, capture_output=True).stdout
|
||||||
|
run('x509', '-inform', 'DER', '-in', str(d / 'cert.der'), '-out', str(d / 'cert.pem'))
|
||||||
|
run('verify', '-check_ss_sig', '-CAfile', str(d / 'cert.pem'), str(d / 'cert.pem'))
|
||||||
|
(d / 'pub.pem').write_bytes(run('x509', '-in', str(d / 'cert.pem'), '-pubkey', '-noout'))
|
||||||
|
output = run('dgst', '-sha256', '-verify', str(d / 'pub.pem'), '-signature', str(d / 'signature'), str(d / 'message'))
|
||||||
|
self.assertIn(b'Verified OK', output)
|
||||||
|
|
||||||
|
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, 'repairable': True}
|
||||||
|
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])
|
||||||
|
self.assertEqual(install.call_args.args[1]['patched'], ['launcher'])
|
||||||
|
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_xr_compat_layer_missing(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'')
|
||||||
|
with patch.object(frame_android, 'XR_COMPAT', d):
|
||||||
|
with self.assertRaisesRegex(frame_android.FrameError, 'build.sh'):
|
||||||
|
frame_android.xr_compat_files(str(apk))
|
||||||
|
|
||||||
|
def test_patch_rejects_corrupt_manifest_cleanly(self):
|
||||||
|
with patch.object(frame_android, 'apk_info', side_effect=struct.error('bad')):
|
||||||
|
with self.assertRaises(frame_android.FrameError):
|
||||||
|
frame_android.patch('x.apk', 'y.apk')
|
||||||
|
|
||||||
|
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, 'repairable': False}
|
||||||
|
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()
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||||
|
import frame_media as media
|
||||||
|
import frame_media_player as player
|
||||||
|
import frame_media_remote as remote
|
||||||
|
import frame_splat as splat
|
||||||
|
import server
|
||||||
|
|
||||||
|
|
||||||
|
class Media(unittest.TestCase):
|
||||||
|
def test_layout_evidence_and_override(self):
|
||||||
|
for name, layout in [('film_SBS.mp4', 'sbs'), ('film.OU.mkv', 'ou'),
|
||||||
|
('film_FSBS.mp4', 'full-sbs'), ('photo_TB.png', 'ou')]:
|
||||||
|
self.assertEqual(media.plan(name)['layout'], layout)
|
||||||
|
self.assertEqual(media.plan('film_SBS_OU.mp4', 'mono')['source'], 'explicit')
|
||||||
|
self.assertEqual(media.plan('film.mkv', metadata={'stereo_mode': 'top_bottom'})['layout'], 'full-ou')
|
||||||
|
for name in ('film.mp4', 'film_SBS_OU.mp4', 'businessbs.mp4'):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
media.plan(name)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
media.plan('film.mkv', metadata={'stereo_mode': 'right_left'})
|
||||||
|
|
||||||
|
def test_unsupported_containers_do_not_flatten_spatial_photos(self):
|
||||||
|
for name in ('spatial.HEIC', 'stereo.mpo', 'cloud.ply', 'cloud.spz', 'app.exe'):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
media.plan(name, 'sbs')
|
||||||
|
|
||||||
|
def test_ou_pixels_keep_each_eye_and_row(self):
|
||||||
|
a, b, c, d = [bytes([n])*8 for n in (1, 2, 3, 4)]
|
||||||
|
data, width, height = media.stereo_pixels(a+b+c+d, 2, 4, 'ou')
|
||||||
|
self.assertEqual((data, width, height), (a+c+b+d, 4, 2))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
media.stereo_pixels(b'bad', 2, 4, 'sbs')
|
||||||
|
self.assertEqual(media.geometry(3840, 2160, 'sbs'), (1920, 1080, 2))
|
||||||
|
self.assertEqual(media.geometry(1920, 1080, 'ou'), (1920, 1080, .5))
|
||||||
|
|
||||||
|
def test_decode_is_hardware_and_one_clock_for_audio(self):
|
||||||
|
for codec, decoder in [('h264', 'h264_v4l2m2m'), ('hevc', 'hevc_v4l2m2m')]:
|
||||||
|
cmd = player.decoder_command(Path('/tmp/a file.mp4'), {'codec_name': codec}, 1280, 720, True)
|
||||||
|
self.assertIn(decoder, cmd)
|
||||||
|
self.assertIn('-re', cmd)
|
||||||
|
self.assertIn('pulse', cmd)
|
||||||
|
self.assertIn(str(Path('/tmp/a file.mp4')), cmd)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
player.decoder_command(Path('x.webm'), {'codec_name': 'vp9'}, 640, 480, False)
|
||||||
|
cmd = player.decoder_command(Path('x.png'), {'codec_name': 'png'}, 640, 480, False, True)
|
||||||
|
self.assertNotIn('-re', cmd)
|
||||||
|
self.assertIn('-frames:v', cmd)
|
||||||
|
|
||||||
|
def test_fake_frame_library_and_traversal(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)):
|
||||||
|
identity = 'a'*32+'/space and quote\'.png'
|
||||||
|
path = Path(d)/identity
|
||||||
|
path.parent.mkdir()
|
||||||
|
path.write_bytes(b'test')
|
||||||
|
self.assertEqual(remote.media_path(identity), path.resolve())
|
||||||
|
for bad in ('../../etc/passwd', '/etc/passwd', 'a'*32+'/..', 'a'*32+'/x/y', None):
|
||||||
|
with self.assertRaises((ValueError, FileNotFoundError)):
|
||||||
|
remote.media_path(bad)
|
||||||
|
link = path.parent/'link.png'
|
||||||
|
link.symlink_to(path)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
remote.media_path('a'*32+'/link.png')
|
||||||
|
with patch.object(remote, 'status', return_value={'state': 'idle'}):
|
||||||
|
files = remote.run({'action': 'list'})['files']
|
||||||
|
self.assertEqual([f['id'] for f in files], [identity])
|
||||||
|
|
||||||
|
def test_server_rejects_bad_actions_before_ssh(self):
|
||||||
|
with patch.object(server, 'ssh') as ssh:
|
||||||
|
for body in ({'action': 'delete'}, {'action': 'play', 'id': '../x'},
|
||||||
|
{'action': 'play', 'id': 'a'*32+'/x', 'layout': 'invalid'},
|
||||||
|
{'action': 'play', 'id': 'a'*32+'/x', 'theatre': 'false'}):
|
||||||
|
with self.assertRaises(server.Failure):
|
||||||
|
server.media(body)
|
||||||
|
ssh.assert_not_called()
|
||||||
|
|
||||||
|
def test_fake_frame_stop_only_owns_our_unit(self):
|
||||||
|
for rc in (0, 5): # 5: already collected ("not loaded"), a no-op
|
||||||
|
with patch.object(remote.subprocess, 'run') as run, patch.object(remote, 'status', return_value={'state': 'ended'}):
|
||||||
|
run.return_value.returncode = rc
|
||||||
|
self.assertEqual(remote.run({'action': 'stop'})['state'], 'ended')
|
||||||
|
self.assertEqual(run.call_args.args[0], ['systemctl', '--user', 'stop', 'frame-control-media.service'])
|
||||||
|
with patch.object(remote.subprocess, 'run') as run, patch.object(remote, 'status', return_value={}):
|
||||||
|
run.return_value.returncode, run.return_value.stderr = 1, 'Access denied'
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'Access denied'):
|
||||||
|
remote.run({'action': 'stop'})
|
||||||
|
|
||||||
|
def test_start_failure_reports_systemd_error(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)), \
|
||||||
|
patch.object(remote, 'STATUS', Path(d)/'status.json'), \
|
||||||
|
patch.object(remote, 'active', return_value=False), \
|
||||||
|
patch.object(remote.subprocess, 'run') as run:
|
||||||
|
identity = 'b'*32+'/still_SBS.png'
|
||||||
|
(Path(d)/identity).parent.mkdir()
|
||||||
|
(Path(d)/identity).write_bytes(b'x')
|
||||||
|
run.return_value.returncode, run.return_value.stderr = 1, 'Unit already exists'
|
||||||
|
with patch.object(remote, 'probe', return_value=({}, False)), \
|
||||||
|
self.assertRaisesRegex(RuntimeError, 'Unit already exists'):
|
||||||
|
remote.run({'action': 'play', 'id': identity})
|
||||||
|
self.assertEqual(run.call_args.args[0][0], 'systemd-run') # reset-failed's result is ignored
|
||||||
|
|
||||||
|
def test_upload_rejects_unplayable_names_and_keeps_copy_error(self):
|
||||||
|
with patch.object(server, 'ssh') as ssh, patch.object(server, 'push_file') as push:
|
||||||
|
# On Windows a backslash is a separator, so such a name can't reach here.
|
||||||
|
for name in ('.hidden.mp4',) + (('a\\b_SBS.mp4',) if os.sep == '/' else ()):
|
||||||
|
with self.assertRaises(server.Failure):
|
||||||
|
server.push_media(Path('/tmp')/name)
|
||||||
|
ssh.assert_not_called()
|
||||||
|
push.side_effect = server.Failure('copy failed')
|
||||||
|
ssh.side_effect = [None, server.Failure('link down')]
|
||||||
|
with self.assertRaisesRegex(server.Failure, 'copy failed'):
|
||||||
|
server.push_media(Path('/tmp/film_SBS.mp4'))
|
||||||
|
|
||||||
|
def test_splat_invalid_records_and_stereo_parallax(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
path = Path(d)/'small.splat'
|
||||||
|
path.write_bytes(struct.pack('<6f8B', 0, 0, 0, .001, .001, .001,
|
||||||
|
255, 0, 0, 255, 255, 128, 128, 128))
|
||||||
|
data, w, h = splat.render(path, 64, 48)
|
||||||
|
self.assertEqual((len(data), w, h), (128*48*4, 128, 48))
|
||||||
|
def centroid(eye):
|
||||||
|
weights = [(x, data[(y*w+x+eye*64)*4]) for y in range(h) for x in range(64)]
|
||||||
|
return sum(x*v for x,v in weights)/sum(v for x,v in weights)
|
||||||
|
self.assertGreater(centroid(0), centroid(1))
|
||||||
|
for bad in (b'', b'bad', struct.pack('<6f8B', float('nan'), 0, 0, 1, 1, 1, *([128]*8))):
|
||||||
|
path.write_bytes(bad)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
splat.read(path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""APK sources: every place Frame Control can find and download APKs.
|
||||||
|
|
||||||
|
One module per source kind in this package. Each module exposes the same small
|
||||||
|
interface so ``search.py`` can query them all and show where every result came
|
||||||
|
from. Python stdlib only; must run on Python 3.9.
|
||||||
|
|
||||||
|
Module interface
|
||||||
|
----------------
|
||||||
|
KIND = 'sidequest' # stable id of the source kind
|
||||||
|
def sources() -> list[dict] # configured sources of this kind (a kind can have
|
||||||
|
# several, e.g. one per F-Droid-format repo)
|
||||||
|
def search(source, query, limit=50) -> list[dict] # Entry dicts, best first
|
||||||
|
def details(source, entry_id) -> dict # Entry with 'versions'
|
||||||
|
def download(source, entry_id, version_code=None) -> dict
|
||||||
|
# {'apk': local path, 'obb': [paths], 'sha256': hex or None, 'verified': bool}
|
||||||
|
# Raise SourceError with a user-readable message on failure.
|
||||||
|
|
||||||
|
Source dict
|
||||||
|
-----------
|
||||||
|
{'id': 'sidequest', 'kind': KIND, 'name': 'SideQuest', 'url': 'https://...',
|
||||||
|
'builtin': True, 'enabled': True, 'trust': 'official' | 'community' | 'user'}
|
||||||
|
|
||||||
|
Entry dict (missing facts are None, never guessed)
|
||||||
|
----------
|
||||||
|
{'source': source id, 'id': source-local id, 'package': 'org.example.app' or None,
|
||||||
|
'name': str, 'summary': str, 'icon': url or None, 'page': url or None,
|
||||||
|
'version': '1.2', 'version_code': 12, 'min_sdk': 24, 'abis': ['arm64-v8a'],
|
||||||
|
'vr': True/False/None, 'size': bytes, 'free': True, 'license': 'GPL-3.0' or None,
|
||||||
|
'updated': 'YYYY-MM-DD', 'downloadable': bool, # False = open page only
|
||||||
|
'versions': [ {version, version_code, min_sdk, size, updated}, ... ]} # details() only
|
||||||
|
|
||||||
|
Rules
|
||||||
|
-----
|
||||||
|
- Only sources that distribute APKs with the developer's consent: free listings,
|
||||||
|
never paid apps re-hosted, no licence or entitlement workarounds.
|
||||||
|
- Honour each site's terms and robots rules; if automated download isn't allowed,
|
||||||
|
return entries with 'downloadable': False and a 'page' link instead.
|
||||||
|
- Cache indexes under frame_host.cache_dir('apk-sources'); send a clear
|
||||||
|
User-Agent ('FrameControl/<version>'); keep requests modest.
|
||||||
|
- Tests use recorded fixtures, never the network.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SourceError(Exception):
|
||||||
|
"""User-readable failure from a source (network, format, verification)."""
|
||||||
+90
-7
@@ -6,12 +6,18 @@ apps, the lepton-show-flatscreen marker; plus a non-Steam shortcut, so it shows
|
|||||||
in the Steam library and gets its own SteamVR panel. Nothing goes through
|
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 {info APK|versions APK-or-PKG|install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
|
Python stdlib only. CLI: python3 ui/frame_android.py
|
||||||
|
install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
|
||||||
|
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
|
import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||||
|
|
||||||
import frame_apk
|
import frame_apk
|
||||||
import frame_host
|
import frame_host
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from frame_apk_vr import add_launcher_category
|
||||||
|
from frame_apk_sign import repack
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
|
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
|
||||||
@@ -20,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']
|
||||||
|
|
||||||
@@ -65,6 +78,22 @@ 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():
|
||||||
|
try:
|
||||||
|
with open(os.path.join(XR_COMPAT, rel), 'rb') as f:
|
||||||
|
add[entry] = f.read()
|
||||||
|
except OSError:
|
||||||
|
raise FrameError("the OpenXR compatibility layer isn't built; run frame/openxr-compat/build.sh")
|
||||||
|
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)")
|
||||||
@@ -111,7 +140,7 @@ def _write_meta(d, meta):
|
|||||||
install_hooks = []
|
install_hooks = []
|
||||||
|
|
||||||
|
|
||||||
def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None):
|
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None):
|
||||||
start, info = time.time(), None
|
start, info = time.time(), None
|
||||||
try:
|
try:
|
||||||
info = apk_info(apk_path)
|
info = apk_info(apk_path)
|
||||||
@@ -121,8 +150,20 @@ def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None):
|
|||||||
pkg = info['package']
|
pkg = info['package']
|
||||||
if not PKG_RE.match(pkg):
|
if not PKG_RE.match(pkg):
|
||||||
raise FrameError(f'unexpected package name {pkg!r}')
|
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:
|
with _install_lock:
|
||||||
meta = _install(apk_path, info, pkg, flatscreen, name, source)
|
if add or info['repairable']:
|
||||||
|
with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp:
|
||||||
|
patched = os.path.join(tmp, 'app.apk')
|
||||||
|
info['patched'] = patch(apk_path, patched, add)['patched']
|
||||||
|
info['launchable'] = True
|
||||||
|
meta = _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path))
|
||||||
|
else:
|
||||||
|
meta = _install(apk_path, info, pkg, flatscreen, name, source)
|
||||||
except FrameError as e:
|
except FrameError as e:
|
||||||
_after_install(info, None, e, start)
|
_after_install(info, None, e, start)
|
||||||
raise
|
raise
|
||||||
@@ -163,6 +204,8 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
|
|||||||
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
|
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
|
||||||
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
|
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
|
||||||
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
|
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
|
||||||
|
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
|
||||||
|
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
|
||||||
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||||
'source': source or os.path.basename(apk_path)}
|
'source': source or os.path.basename(apk_path)}
|
||||||
_write_meta(d, meta)
|
_write_meta(d, meta)
|
||||||
@@ -293,19 +336,59 @@ def probe(pkg, wait=20):
|
|||||||
'container_up': ctr in running_instances()}
|
'container_up': ctr in running_instances()}
|
||||||
|
|
||||||
|
|
||||||
|
def patch(src, dst, add=None):
|
||||||
|
try:
|
||||||
|
info = apk_info(src)
|
||||||
|
with zipfile.ZipFile(src) as z:
|
||||||
|
original = frame_apk._read(z, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST)
|
||||||
|
manifest = add_launcher_category(original) if info['repairable'] else original
|
||||||
|
if not info['launchable'] and not info['repairable']:
|
||||||
|
raise FrameError('APK has no MAIN/LAUNCHER activity that Frame Control can patch')
|
||||||
|
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 []) + \
|
||||||
|
(['openxr-compat'] if add and set(XR_COMPAT_FILES) <= set(add) else [])
|
||||||
|
return result
|
||||||
|
except (OSError, ValueError, IndexError, struct.error, zipfile.BadZipFile, frame_apk.ApkError) as e:
|
||||||
|
raise FrameError(str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
cmd, *args = sys.argv[1:] or ['help']
|
cmd, *args = sys.argv[1:] or ['help']
|
||||||
try:
|
try:
|
||||||
if cmd in ('info', 'versions'):
|
if cmd in ('info', 'versions'):
|
||||||
import frame_apk_versions
|
import frame_apk_versions
|
||||||
if cmd == 'info':
|
if cmd == 'info':
|
||||||
print(frame_apk_versions.describe(apk_info(args[0])))
|
info = apk_info(args[0])
|
||||||
|
print(frame_apk_versions.describe(info))
|
||||||
|
fix = '; Frame Control adds the LAUNCHER entry Lepton needs' if info.get('repairable') else ''
|
||||||
|
if info.get('vr') or fix:
|
||||||
|
print(('VR app' if info.get('vr') else 'Android app') + fix)
|
||||||
|
for note in info.get('vr_issues', []):
|
||||||
|
print(note)
|
||||||
return
|
return
|
||||||
info = apk_info(args[0]) if os.path.isfile(args[0]) or args[0].lower().endswith('.apk') else None
|
info = apk_info(args[0]) if os.path.isfile(args[0]) or args[0].lower().endswith('.apk') else None
|
||||||
r = frame_apk_versions.alternatives(
|
r = frame_apk_versions.alternatives(
|
||||||
info['package'] if info else args[0], info.get('version_code') if info else None)
|
info['package'] if info else args[0], info.get('version_code') if info else None)
|
||||||
elif cmd == 'install':
|
elif cmd == 'install':
|
||||||
r = install(args[0], flatscreen='--vr' not in args)
|
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 == 'patch':
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
|
||||||
|
parser.add_argument('src')
|
||||||
|
parser.add_argument('dst')
|
||||||
|
parser.add_argument('--add', action='append', default=[], metavar='NAME=PATH')
|
||||||
|
opts = parser.parse_args(args)
|
||||||
|
additions = {}
|
||||||
|
for item in opts.add:
|
||||||
|
if '=' not in item:
|
||||||
|
raise FrameError('--add requires NAME=PATH')
|
||||||
|
entry, path = item.split('=', 1)
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
additions[entry] = f.read()
|
||||||
|
r = patch(opts.src, opts.dst, additions)
|
||||||
elif cmd == 'list':
|
elif cmd == 'list':
|
||||||
r = list_apps()
|
r = list_apps()
|
||||||
elif cmd in ('launch', 'stop', 'probe'):
|
elif cmd in ('launch', 'stop', 'probe'):
|
||||||
@@ -314,7 +397,7 @@ def main():
|
|||||||
r = remove(args[0], keep_data='--keep-data' in args)
|
r = remove(args[0], keep_data='--keep-data' in args)
|
||||||
else:
|
else:
|
||||||
sys.exit(__doc__)
|
sys.exit(__doc__)
|
||||||
except FrameError as e:
|
except (FrameError, OSError) as e:
|
||||||
sys.exit(f'error: {e}')
|
sys.exit(f'error: {e}')
|
||||||
print(json.dumps(r, indent=1))
|
print(json.dumps(r, indent=1))
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -10,7 +10,8 @@ import zipfile
|
|||||||
|
|
||||||
# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
|
# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
|
||||||
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
|
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
|
||||||
0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion'}
|
0x01010024: 'value', 0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion',
|
||||||
|
0x01010202: 'targetActivity'}
|
||||||
T_REF, T_STRING, T_INT_DEC, T_INT_HEX = 0x01, 0x03, 0x10, 0x11
|
T_REF, T_STRING, T_INT_DEC, T_INT_HEX = 0x01, 0x03, 0x10, 0x11
|
||||||
# APKs can come from websites (install links), so nothing read from one may be
|
# APKs can come from websites (install links), so nothing read from one may be
|
||||||
# unbounded. zipfile stops at a member's declared size, so checking it is enough.
|
# unbounded. zipfile stops at a member's declared size, so checking it is enough.
|
||||||
@@ -215,8 +216,11 @@ def apk_info(path):
|
|||||||
if 'AndroidManifest.xml' not in names:
|
if 'AndroidManifest.xml' not in names:
|
||||||
raise ApkError('not an APK: no AndroidManifest.xml')
|
raise ApkError('not an APK: no AndroidManifest.xml')
|
||||||
try:
|
try:
|
||||||
elements = manifest_elements(_read(z, 'AndroidManifest.xml', MAX_MANIFEST))
|
manifest_data = _read(z, 'AndroidManifest.xml', MAX_MANIFEST)
|
||||||
|
elements = manifest_elements(manifest_data)
|
||||||
res = Resources(_read(z, 'resources.arsc', MAX_ARSC) if 'resources.arsc' in names else b'')
|
res = Resources(_read(z, 'resources.arsc', MAX_ARSC) if 'resources.arsc' in names else b'')
|
||||||
|
from frame_apk_vr import detection
|
||||||
|
vr_info = detection(manifest_data, names)
|
||||||
except (struct.error, IndexError, zipfile.BadZipFile) as e:
|
except (struct.error, IndexError, zipfile.BadZipFile) as e:
|
||||||
raise ApkError(f'could not read the APK manifest: {e}')
|
raise ApkError(f'could not read the APK manifest: {e}')
|
||||||
tags = {}
|
tags = {}
|
||||||
@@ -236,6 +240,7 @@ def apk_info(path):
|
|||||||
'min_sdk': min_sdk[1] if min_sdk and min_sdk[0] in (T_INT_DEC, T_INT_HEX) else None,
|
'min_sdk': min_sdk[1] if min_sdk and min_sdk[0] in (T_INT_DEC, T_INT_HEX) else None,
|
||||||
'icon_png': None,
|
'icon_png': None,
|
||||||
}
|
}
|
||||||
|
info.update(vr_info)
|
||||||
try:
|
try:
|
||||||
info['icon_png'] = _icon_png(z, names, _icons(app.get('icon'), res))
|
info['icon_png'] = _icon_png(z, names, _icons(app.get('icon'), res))
|
||||||
except Exception: # noqa: BLE001 - any unreadable icon just means no icon
|
except Exception: # noqa: BLE001 - any unreadable icon just means no icon
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""Lossless ZIP repacking and APK v2 RSA/SHA-256 signing, Python 3.9 stdlib.
|
||||||
|
|
||||||
|
Spec: https://source.android.com/docs/security/features/apksigning/v2
|
||||||
|
Sections: APK Signing Block; APK Signature Scheme v2 Block; Integrity-protected
|
||||||
|
contents; Verification. No verity algorithm is used, so no verity padding.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
import frame_host
|
||||||
|
|
||||||
|
MAGIC = b'APK Sig Block 42'
|
||||||
|
V2 = 0x7109871a
|
||||||
|
ALG = 0x0103
|
||||||
|
SHA256_DER = bytes.fromhex('3031300d060960864801650304020105000420')
|
||||||
|
|
||||||
|
|
||||||
|
def u32(n):
|
||||||
|
return struct.pack('<I', n)
|
||||||
|
|
||||||
|
|
||||||
|
def lp(b):
|
||||||
|
return u32(len(b)) + b
|
||||||
|
|
||||||
|
|
||||||
|
def der(tag, b):
|
||||||
|
n = len(b)
|
||||||
|
length = bytes([n]) if n < 128 else bytes([128 + (n.bit_length() + 7) // 8]) + n.to_bytes((n.bit_length() + 7) // 8, 'big')
|
||||||
|
return bytes([tag]) + length + b
|
||||||
|
|
||||||
|
|
||||||
|
def integer(n):
|
||||||
|
b = n.to_bytes((n.bit_length() + 7) // 8 or 1, 'big')
|
||||||
|
return der(2, (b'\0' if b[0] & 128 else b'') + b)
|
||||||
|
|
||||||
|
|
||||||
|
def sequence(*items):
|
||||||
|
return der(0x30, b''.join(items))
|
||||||
|
|
||||||
|
|
||||||
|
RSA_ALG = bytes.fromhex('300d06092a864886f70d0101010500')
|
||||||
|
CERT_ALG = bytes.fromhex('300d06092a864886f70d01010b0500')
|
||||||
|
|
||||||
|
|
||||||
|
def public_key(key):
|
||||||
|
return sequence(RSA_ALG, der(3, b'\0' + sequence(integer(key['n']), integer(key['e']))))
|
||||||
|
|
||||||
|
|
||||||
|
def encoded_hash(data, size):
|
||||||
|
digest = SHA256_DER + hashlib.sha256(data).digest()
|
||||||
|
return b'\0\1' + b'\xff' * (size - len(digest) - 3) + b'\0' + digest
|
||||||
|
|
||||||
|
|
||||||
|
def rsa_sign(data, key):
|
||||||
|
size = (key['n'].bit_length() + 7) // 8
|
||||||
|
return pow(int.from_bytes(encoded_hash(data, size), 'big'), key['d'], key['n']).to_bytes(size, 'big')
|
||||||
|
|
||||||
|
|
||||||
|
def rsa_verify(data, sig, n, e):
|
||||||
|
size = (n.bit_length() + 7) // 8
|
||||||
|
if len(sig) != size or int.from_bytes(sig, 'big') >= n:
|
||||||
|
raise ValueError('invalid RSA signature size/value')
|
||||||
|
actual = pow(int.from_bytes(sig, 'big'), e, n).to_bytes(size, 'big')
|
||||||
|
if actual != encoded_hash(data, size):
|
||||||
|
raise ValueError('RSA signature mismatch')
|
||||||
|
|
||||||
|
|
||||||
|
def certificate(key):
|
||||||
|
name = sequence(der(0x31, sequence(bytes.fromhex('0603550403'), der(12, b'Frame Control APK signer'))))
|
||||||
|
validity = sequence(der(0x17, b'200101000000Z'), der(0x18, b'21200101000000Z'))
|
||||||
|
tbs = sequence(der(0xa0, integer(2)), integer(1), CERT_ALG, name, validity, name, public_key(key))
|
||||||
|
return sequence(tbs, CERT_ALG, der(3, b'\0' + rsa_sign(tbs, key)))
|
||||||
|
|
||||||
|
|
||||||
|
def _prime(bits):
|
||||||
|
small = (3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47)
|
||||||
|
while True:
|
||||||
|
n = secrets.randbits(bits) | (3 << (bits - 2)) | 1
|
||||||
|
if any(n % p == 0 for p in small) or (n - 1) % 65537 == 0:
|
||||||
|
continue
|
||||||
|
d, s = n - 1, 0
|
||||||
|
while d % 2 == 0:
|
||||||
|
d //= 2
|
||||||
|
s += 1
|
||||||
|
for _ in range(40): # Miller-Rabin error bound <= 2^-80
|
||||||
|
x = pow(secrets.randbelow(n - 3) + 2, d, n)
|
||||||
|
if x in (1, n - 1):
|
||||||
|
continue
|
||||||
|
for _ in range(s - 1):
|
||||||
|
x = pow(x, 2, n)
|
||||||
|
if x == n - 1:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def signing_key(path=None):
|
||||||
|
"""Persistent identity in app data, never an evictable cache. Atomic publication.
|
||||||
|
|
||||||
|
Hard-linking a fully written private temp file prevents concurrent first-use
|
||||||
|
callers from selecting different identities or reading a partial key.
|
||||||
|
"""
|
||||||
|
path = Path(path) if path is not None else frame_host.data_dir('apk-signing-key.json')
|
||||||
|
if not path.exists():
|
||||||
|
p, q = _prime(1024), _prime(1024)
|
||||||
|
while q == p:
|
||||||
|
q = _prime(1024)
|
||||||
|
key = {'n': p * q, 'e': 65537, 'd': pow(65537, -1, math.lcm(p - 1, q - 1))}
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix='.apk-key-', dir=str(path.parent))
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'w') as f:
|
||||||
|
json.dump(key, f)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
try:
|
||||||
|
os.link(tmp, path) # never replaces a key another process wrote first
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
except OSError: # no hard links (FAT/exFAT): plain rename
|
||||||
|
if not path.exists():
|
||||||
|
os.replace(tmp, path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
os.chmod(path, 0o600) # Windows ignores this; the per-user app-data folder is the protection there
|
||||||
|
key = json.loads(path.read_text())
|
||||||
|
if key['n'].bit_length() != 2048 or key['e'] != 65537:
|
||||||
|
raise ValueError(f'invalid cached APK signing key; delete {path} to make a new one '
|
||||||
|
'(re-signed apps then need reinstalling)')
|
||||||
|
rsa_verify(b'key check', rsa_sign(b'key check', key), key['n'], key['e'])
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _eocd(data):
|
||||||
|
# ZIP comments can contain the EOCD signature; accept only an exact EOF fit.
|
||||||
|
for at in range(len(data) - 22, max(-1, len(data) - 65558), -1):
|
||||||
|
if data[at:at + 4] == b'PK\5\6' and at + 22 + struct.unpack_from('<H', data, at + 20)[0] == len(data):
|
||||||
|
disk, cd_disk, count_disk, count, size, cd = struct.unpack_from('<HHHHII', data, at + 4)
|
||||||
|
if disk or cd_disk or count_disk != count or count == 65535 or cd + size != at:
|
||||||
|
raise ValueError('multi-disk/ZIP64 or invalid APK directory')
|
||||||
|
return at, cd
|
||||||
|
raise ValueError('missing ZIP end record')
|
||||||
|
|
||||||
|
|
||||||
|
def content_digest(sections):
|
||||||
|
chunks = []
|
||||||
|
for section in sections:
|
||||||
|
for off in range(0, len(section), 1024 * 1024):
|
||||||
|
part = section[off:off + 1024 * 1024]
|
||||||
|
chunks.append(hashlib.sha256(b'\xa5' + u32(len(part)) + part).digest())
|
||||||
|
return hashlib.sha256(b'\x5a' + u32(len(chunks)) + b''.join(chunks)).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def sign_apk(data, key):
|
||||||
|
eo, cd = _eocd(data)
|
||||||
|
digest = content_digest((memoryview(data)[:cd], memoryview(data)[cd:eo], data[eo:]))
|
||||||
|
signed = lp(lp(u32(ALG) + lp(digest))) + lp(lp(certificate(key))) + lp(b'')
|
||||||
|
signer = lp(signed) + lp(lp(u32(ALG) + lp(rsa_sign(signed, key)))) + lp(public_key(key))
|
||||||
|
value = lp(lp(signer))
|
||||||
|
pair = struct.pack('<Q', 4 + len(value)) + u32(V2) + value
|
||||||
|
size = len(pair) + 24
|
||||||
|
block = struct.pack('<Q', size) + pair + struct.pack('<Q', size) + MAGIC
|
||||||
|
end = bytearray(data[eo:])
|
||||||
|
struct.pack_into('<I', end, 16, cd + len(block))
|
||||||
|
return data[:cd] + block + data[cd:eo] + end
|
||||||
|
|
||||||
|
|
||||||
|
def _parts(data):
|
||||||
|
result, off = [], 0
|
||||||
|
while off < len(data):
|
||||||
|
if off + 4 > len(data):
|
||||||
|
raise ValueError('truncated length prefix')
|
||||||
|
size = struct.unpack_from('<I', data, off)[0]
|
||||||
|
off += 4
|
||||||
|
if off + size > len(data):
|
||||||
|
raise ValueError('length prefix outside block')
|
||||||
|
result.append(data[off:off + size])
|
||||||
|
off += size
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _der_parts(data):
|
||||||
|
result, off = [], 0
|
||||||
|
while off < len(data):
|
||||||
|
start = off
|
||||||
|
tag, size = data[off:off + 2]
|
||||||
|
off += 2
|
||||||
|
if size & 128:
|
||||||
|
count = size & 127
|
||||||
|
if not count or count > 4:
|
||||||
|
raise ValueError('invalid DER length')
|
||||||
|
size = int.from_bytes(data[off:off + count], 'big')
|
||||||
|
off += count
|
||||||
|
if off + size > len(data):
|
||||||
|
raise ValueError('truncated DER')
|
||||||
|
result.append((tag, data[off:off + size], data[start:off + size]))
|
||||||
|
off += size
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _cert_key(cert):
|
||||||
|
outer = _der_parts(cert)
|
||||||
|
if len(outer) != 1 or outer[0][0] != 0x30:
|
||||||
|
raise ValueError('invalid certificate')
|
||||||
|
fields = _der_parts(outer[0][1])
|
||||||
|
tbs = _der_parts(fields[0][1])
|
||||||
|
spki = tbs[6 if tbs[0][0] == 0xa0 else 5][2]
|
||||||
|
pub = _der_parts(_der_parts(spki)[0][1])
|
||||||
|
if pub[0][2] != RSA_ALG or pub[1][1][:1] != b'\0':
|
||||||
|
raise ValueError('certificate is not RSA')
|
||||||
|
numbers = _der_parts(_der_parts(pub[1][1][1:])[0][1])
|
||||||
|
n, e = [int.from_bytes(item[1], 'big') for item in numbers]
|
||||||
|
return n, e, spki
|
||||||
|
|
||||||
|
|
||||||
|
def verify(path):
|
||||||
|
"""Verify this v2-only format; return True or raise ValueError on corruption.
|
||||||
|
|
||||||
|
A valid signature establishes integrity, not trust in the APK publisher.
|
||||||
|
"""
|
||||||
|
data = Path(path).read_bytes()
|
||||||
|
try:
|
||||||
|
eo, cd = _eocd(data)
|
||||||
|
if data[cd - 16:cd] != MAGIC:
|
||||||
|
raise ValueError('no APK signing block')
|
||||||
|
size = struct.unpack_from('<Q', data, cd - 24)[0]
|
||||||
|
start = cd - size - 8
|
||||||
|
if start < 0 or struct.unpack_from('<Q', data, start)[0] != size:
|
||||||
|
raise ValueError('invalid signing block size')
|
||||||
|
off, values = start + 8, []
|
||||||
|
while off < cd - 24:
|
||||||
|
length = struct.unpack_from('<Q', data, off)[0]
|
||||||
|
if length < 4 or off + 8 + length > cd - 24:
|
||||||
|
raise ValueError('invalid signing pair')
|
||||||
|
ident = struct.unpack_from('<I', data, off + 8)[0]
|
||||||
|
if ident == V2:
|
||||||
|
values.append(data[off + 12:off + 8 + length])
|
||||||
|
off += 8 + length
|
||||||
|
if off != cd - 24 or len(values) != 1:
|
||||||
|
raise ValueError('missing or duplicate v2 signer block')
|
||||||
|
end = bytearray(data[eo:])
|
||||||
|
struct.pack_into('<I', end, 16, start)
|
||||||
|
digest = content_digest((memoryview(data)[:start], memoryview(data)[cd:eo], end))
|
||||||
|
wrappers = _parts(values[0])
|
||||||
|
if len(wrappers) != 1:
|
||||||
|
raise ValueError('invalid signers sequence')
|
||||||
|
signers = _parts(wrappers[0])
|
||||||
|
if not signers:
|
||||||
|
raise ValueError('no signers')
|
||||||
|
for signer in signers:
|
||||||
|
signed, signatures, pub = _parts(signer)
|
||||||
|
digests, certs, attrs = _parts(signed)
|
||||||
|
cert = _parts(certs)[0]
|
||||||
|
n, e, cert_pub = _cert_key(cert)
|
||||||
|
if cert_pub != pub:
|
||||||
|
raise ValueError('public key differs from certificate')
|
||||||
|
sigs, digs = _parts(signatures), _parts(digests)
|
||||||
|
if len(sigs) != 1 or len(digs) != 1 or sigs[0][:4] != u32(ALG) or digs[0][:4] != u32(ALG):
|
||||||
|
raise ValueError('unsupported signature algorithm')
|
||||||
|
if _parts(digs[0][4:]) != [digest]:
|
||||||
|
raise ValueError('APK content digest mismatch')
|
||||||
|
sig = _parts(sigs[0][4:])
|
||||||
|
if len(sig) != 1:
|
||||||
|
raise ValueError('invalid signature sequence')
|
||||||
|
rsa_verify(signed, sig[0], n, e)
|
||||||
|
return True
|
||||||
|
except (IndexError, struct.error, OverflowError) as exc:
|
||||||
|
raise ValueError('malformed APK signature: ' + str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def repack(src, dst, replace=None, add=None, sign=True):
|
||||||
|
"""Copy raw compressed members; reconstruct ZIP headers without descriptors.
|
||||||
|
|
||||||
|
Reject ZIP64/encrypted archives. Output is atomically replaced after signing.
|
||||||
|
"""
|
||||||
|
replace, add = dict(replace or {}), dict(add or {})
|
||||||
|
central, output = [], io.BytesIO()
|
||||||
|
with open(src, 'rb') as raw, zipfile.ZipFile(raw) as archive:
|
||||||
|
names = archive.namelist()
|
||||||
|
if len(set(names)) != len(names):
|
||||||
|
raise ValueError('duplicate ZIP member names')
|
||||||
|
if set(replace) - set(names) or set(add) & set(names) or set(add) & set(replace):
|
||||||
|
raise ValueError('replace must exist and add must be new')
|
||||||
|
items = archive.infolist() + [zipfile.ZipInfo(name) for name in add]
|
||||||
|
for info in items:
|
||||||
|
name = info.filename
|
||||||
|
if re.match(r'^META-INF/(?:[^/]+\.(?:SF|RSA|EC|DSA)|MANIFEST\.MF)$', name, re.I):
|
||||||
|
continue
|
||||||
|
if info.flag_bits & 1 or info.compress_type not in (0, 8):
|
||||||
|
raise ValueError('unsupported ZIP encryption/compression')
|
||||||
|
method = info.compress_type
|
||||||
|
content = add.get(name) if name in add else replace.get(name)
|
||||||
|
if content is None:
|
||||||
|
raw.seek(info.header_offset)
|
||||||
|
header = raw.read(30)
|
||||||
|
if header[:4] != b'PK\3\4':
|
||||||
|
raise ValueError('invalid local ZIP header')
|
||||||
|
nl, el = struct.unpack_from('<HH', header, 26)
|
||||||
|
raw.seek(nl + el, 1)
|
||||||
|
compressed = raw.read(info.compress_size)
|
||||||
|
crc, usize = info.CRC, info.file_size
|
||||||
|
if len(compressed) != info.compress_size:
|
||||||
|
raise ValueError('truncated ZIP member')
|
||||||
|
else:
|
||||||
|
crc, usize = zlib.crc32(content), len(content)
|
||||||
|
if method == 8:
|
||||||
|
compressor = zlib.compressobj(6, zlib.DEFLATED, -15)
|
||||||
|
compressed = compressor.compress(content) + compressor.flush()
|
||||||
|
else:
|
||||||
|
compressed = content
|
||||||
|
encoded = name.encode('utf-8')
|
||||||
|
offset = output.tell()
|
||||||
|
align = 16384 if name.endswith('.so') else 4
|
||||||
|
needs_alignment = method == 0 or name.endswith('.so')
|
||||||
|
padding = (-(offset + 30 + len(encoded) + 6) % align) if needs_alignment else 0
|
||||||
|
# Android zipalign extra: alignment (uint16), then padding bytes.
|
||||||
|
extra = struct.pack('<HHH', 0xd935, padding + 2, align) + bytes(padding) if needs_alignment else b''
|
||||||
|
dt = info.date_time
|
||||||
|
dos_time = (dt[3] << 11) | (dt[4] << 5) | (dt[5] // 2)
|
||||||
|
dos_date = ((dt[0] - 1980) << 9) | (dt[1] << 5) | dt[2]
|
||||||
|
csize = len(compressed)
|
||||||
|
if max(offset, csize, usize) >= 0xffffffff:
|
||||||
|
raise ValueError('ZIP64 APKs are unsupported')
|
||||||
|
output.write(struct.pack('<IHHHHHIIIHH', 0x04034b50, 20, 0x800, method, dos_time, dos_date,
|
||||||
|
crc, csize, usize, len(encoded), len(extra)) + encoded + extra + compressed)
|
||||||
|
central.append(struct.pack('<IHHHHHHIIIHHHHHII', 0x02014b50, 0x314, 20, 0x800, method,
|
||||||
|
dos_time, dos_date, crc, csize, usize, len(encoded), 0, len(info.comment),
|
||||||
|
0, info.internal_attr, info.external_attr, offset) + encoded + info.comment)
|
||||||
|
cd = output.tell()
|
||||||
|
directory = b''.join(central)
|
||||||
|
if len(central) >= 65535 or cd + len(directory) >= 0xffffffff:
|
||||||
|
raise ValueError('ZIP64 APKs are unsupported')
|
||||||
|
output.write(directory)
|
||||||
|
output.write(struct.pack('<IHHHHIIH', 0x06054b50, 0, 0, len(central), len(central), len(directory), cd, len(archive.comment)) + archive.comment)
|
||||||
|
data = output.getvalue()
|
||||||
|
if sign:
|
||||||
|
data = sign_apk(data, signing_key())
|
||||||
|
dst = Path(dst)
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix='.apk-', dir=str(dst.parent))
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
if sign:
|
||||||
|
verify(tmp)
|
||||||
|
os.replace(tmp, dst)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Binary-manifest VR inspection and minimal launcher repair (stdlib only)."""
|
||||||
|
import struct
|
||||||
|
import frame_apk
|
||||||
|
|
||||||
|
MAIN = 'android.intent.action.MAIN'
|
||||||
|
LAUNCHER = 'android.intent.category.LAUNCHER'
|
||||||
|
VR = {'com.oculus.intent.category.VR', 'org.khronos.openxr.intent.category.IMMERSIVE_HMD'}
|
||||||
|
|
||||||
|
|
||||||
|
def inspect(data):
|
||||||
|
elements = iter(frame_apk.manifest_elements(data))
|
||||||
|
stack, filters, samsung = [], [], False
|
||||||
|
current, owner, package = None, None, ''
|
||||||
|
for kind, hs, off, size in frame_apk._chunks(data, 8, len(data)):
|
||||||
|
if kind == 0x0102:
|
||||||
|
tag, attrs = next(elements)
|
||||||
|
value = attrs.get('name', (None, None, None))[2]
|
||||||
|
if tag == 'manifest':
|
||||||
|
package = attrs.get('package', (None, None, None))[2] or ''
|
||||||
|
if tag in ('activity', 'activity-alias'):
|
||||||
|
owner = attrs.get('targetActivity', (None, None, None))[2] if tag == 'activity-alias' else value
|
||||||
|
if owner and '.' not in owner: # PackageParser.buildClassName: bare names are relative too
|
||||||
|
owner = '.' + owner
|
||||||
|
owner = package + owner if owner and owner.startswith('.') else owner
|
||||||
|
if tag == 'intent-filter' and stack and stack[-1] in ('activity', 'activity-alias'):
|
||||||
|
current = {'actions': set(), 'categories': set(), 'templates': [], 'alias': stack[-1] == 'activity-alias', 'activity': owner}
|
||||||
|
if current is not None and stack and stack[-1] == 'intent-filter':
|
||||||
|
if tag == 'action':
|
||||||
|
current['actions'].add(value)
|
||||||
|
if tag == 'category':
|
||||||
|
current['categories'].add(value)
|
||||||
|
current['templates'].append((off, size, hs))
|
||||||
|
if tag == 'meta-data' and stack and stack[-1] == 'application':
|
||||||
|
samsung |= value == 'com.samsung.android.vr.application.mode' and attrs.get('value', (0, 0, None))[2] == 'vr_only'
|
||||||
|
stack.append(tag)
|
||||||
|
elif kind == 0x0103 and stack:
|
||||||
|
tag = stack.pop()
|
||||||
|
if tag == 'intent-filter' and current is not None:
|
||||||
|
current['end'] = off
|
||||||
|
filters.append(current)
|
||||||
|
current = None
|
||||||
|
mains = [f for f in filters if MAIN in f['actions']]
|
||||||
|
vr_filters = [f for f in mains if VR & f['categories']]
|
||||||
|
# Lepton's apk-info-extractor ignores <activity-alias>; Godot 4 puts LAUNCHER only there.
|
||||||
|
real = [f for f in mains if not f['alias']]
|
||||||
|
targets = [f for f in real if VR & f['categories']]
|
||||||
|
if not targets and any(LAUNCHER in f['categories'] or VR & f['categories'] for f in mains if f['alias']):
|
||||||
|
aimed = {f['activity'] for f in mains if f['alias'] and (LAUNCHER in f['categories'] or VR & f['categories'])}
|
||||||
|
targets = [f for f in real if f['templates']] # the patch copies an existing <category>
|
||||||
|
targets = [f for f in targets if f['activity'] in aimed] or targets
|
||||||
|
launchable = any(LAUNCHER in f['categories'] for f in real)
|
||||||
|
return {'launchable': launchable, 'repairable': not launchable and bool(targets),
|
||||||
|
'vr_activity': bool(vr_filters), 'vr': bool(vr_filters) or samsung}, targets
|
||||||
|
|
||||||
|
|
||||||
|
def _append_string(chunk, text):
|
||||||
|
_, hs, size, count, styles, flags, start, style_start = struct.unpack_from('<HHIIIIII', chunk)
|
||||||
|
strings_end = style_start or size
|
||||||
|
encoded = text.encode('utf-8' if flags & 0x100 else 'utf-16-le')
|
||||||
|
# LAUNCHER is short enough for both single-unit length encodings.
|
||||||
|
new = (bytes([len(text), len(encoded)]) + encoded + b'\0' if flags & 0x100
|
||||||
|
else struct.pack('<H', len(text)) + encoded + b'\0\0')
|
||||||
|
string_data = chunk[start:strings_end] + new
|
||||||
|
string_data += bytes(-len(string_data) % 4)
|
||||||
|
header = bytearray(chunk[:hs])
|
||||||
|
new_start = start + 4
|
||||||
|
new_styles = new_start + len(string_data) if style_start else 0
|
||||||
|
body = (chunk[hs:hs + count * 4] + struct.pack('<I', strings_end - start)
|
||||||
|
+ chunk[hs + count * 4:start] + string_data + (chunk[style_start:] if style_start else b''))
|
||||||
|
struct.pack_into('<IIIII', header, 8, count + 1, styles, flags & ~1, new_start, new_styles)
|
||||||
|
struct.pack_into('<I', header, 4, len(header) + len(body))
|
||||||
|
return bytes(header) + body, count
|
||||||
|
|
||||||
|
|
||||||
|
def add_launcher_category(axml_bytes):
|
||||||
|
info, filters = inspect(axml_bytes)
|
||||||
|
if info['launchable']:
|
||||||
|
return axml_bytes
|
||||||
|
if not filters:
|
||||||
|
raise frame_apk.ApkError('no activity with a MAIN intent filter that Frame Control can patch')
|
||||||
|
target = filters[0]
|
||||||
|
chunks = list(frame_apk._chunks(axml_bytes, 8, len(axml_bytes)))
|
||||||
|
pool = next(c for c in chunks if c[0] == 1)
|
||||||
|
_, _, po, ps = pool
|
||||||
|
new_pool, index = _append_string(axml_bytes[po:po + ps], LAUNCHER)
|
||||||
|
off, size, hs = target['templates'][0]
|
||||||
|
start = bytearray(axml_bytes[off:off + size])
|
||||||
|
attr_start, attr_size, count = struct.unpack_from('<HHH', start, hs + 8)
|
||||||
|
strings = frame_apk._string_pool(axml_bytes, po)
|
||||||
|
resmap = []
|
||||||
|
for kind, header_size, offset, chunk_size in chunks:
|
||||||
|
if kind == 0x180:
|
||||||
|
resmap = struct.unpack_from('<%dI' % ((chunk_size - header_size) // 4),
|
||||||
|
axml_bytes, offset + header_size)
|
||||||
|
for i in range(count):
|
||||||
|
a = hs + attr_start + i * attr_size
|
||||||
|
name = struct.unpack_from('<I', start, a + 4)[0]
|
||||||
|
if (name < len(resmap) and resmap[name] == 0x01010003) or strings[name] == 'name':
|
||||||
|
struct.pack_into('<I', start, a + 8, index)
|
||||||
|
struct.pack_into('<HBBI', start, a + 12, 8, 0, 3, index)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise frame_apk.ApkError('category has no name attribute')
|
||||||
|
end = struct.pack('<HHI', 0x0103, hs, hs + 8) + start[8:hs] + start[hs:hs + 8]
|
||||||
|
result = bytearray(axml_bytes[:8])
|
||||||
|
for _, _, off, size in chunks:
|
||||||
|
if off == target['end']:
|
||||||
|
result += start + end
|
||||||
|
result += new_pool if off == po else axml_bytes[off:off + size]
|
||||||
|
struct.pack_into('<I', result, 4, len(result))
|
||||||
|
result = bytes(result)
|
||||||
|
if not inspect(result)[0]['launchable']:
|
||||||
|
raise frame_apk.ApkError('launcher repair failed verification')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def detection(data, names):
|
||||||
|
info, _ = inspect(data)
|
||||||
|
issues = []
|
||||||
|
if 'lib/arm64-v8a/libvrapi.so' in names:
|
||||||
|
issues.append("Uses Meta's legacy VrApi, which the Frame doesn't have; it won't run.")
|
||||||
|
if any(n.endswith('/libovrplatformloader.so') for n in names):
|
||||||
|
issues.append("Uses Meta's platform SDK; if it checks your Quest store licence it will quit.")
|
||||||
|
if 'lib/arm64-v8a/libopenxr_loader.so' in names:
|
||||||
|
info['vr'] = True
|
||||||
|
issues.append('Uses OpenXR (good).')
|
||||||
|
info['vr_issues'] = issues
|
||||||
|
return info
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Media planning shared by Frame Control and its own Frame-side player.
|
||||||
|
|
||||||
|
No viewer dependencies. Filename hints are suggestions, never guesses from
|
||||||
|
resolution. Explicit layout wins; conflicting hints require a choice.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LAYOUTS = ('auto', 'mono', 'sbs', 'ou', 'full-sbs', 'full-ou')
|
||||||
|
VIDEO = {'.mp4', '.mkv', '.mov', '.webm', '.m4v'}
|
||||||
|
PHOTO = {'.png', '.jpg', '.jpeg'}
|
||||||
|
|
||||||
|
|
||||||
|
def plan(name, layout='auto', metadata=None):
|
||||||
|
if layout not in LAYOUTS:
|
||||||
|
raise ValueError('Choose auto, mono, sbs, ou, full-sbs or full-ou')
|
||||||
|
suffix = Path(name).suffix.lower()
|
||||||
|
if suffix in {'.heic', '.heif', '.avif', '.mpo'}:
|
||||||
|
raise ValueError('Native spatial-photo containers are not supported yet; export both eyes as SBS or OU PNG/JPEG')
|
||||||
|
if suffix == '.splat':
|
||||||
|
return {'kind': 'splat', 'layout': 'sbs', 'source': 'renderer'}
|
||||||
|
if suffix not in VIDEO | PHOTO:
|
||||||
|
raise ValueError('Use MP4/MKV/MOV/WebM video, PNG/JPEG stereo photos, or a .splat file')
|
||||||
|
source = 'explicit'
|
||||||
|
if layout == 'auto':
|
||||||
|
tokens = set(re.split(r'[^a-z0-9]+', Path(name).stem.lower()))
|
||||||
|
hints = set()
|
||||||
|
for value, tags in [('full-sbs', {'fsbs'}), ('full-ou', {'fou', 'ftb'}),
|
||||||
|
('sbs', {'sbs', 'hsbs', 'lr'}), ('ou', {'ou', 'hou', 'tb', 'htb'})]:
|
||||||
|
if tokens & tags:
|
||||||
|
hints.add(value)
|
||||||
|
if len(hints) > 1:
|
||||||
|
raise ValueError('Conflicting stereo filename tags; choose the layout explicitly')
|
||||||
|
layout = next(iter(hints), None)
|
||||||
|
source = 'filename'
|
||||||
|
if not layout:
|
||||||
|
# Matroska StereoMode/FFmpeg stereo_mode: only known left-first modes.
|
||||||
|
mode = (metadata or {}).get('stereo_mode')
|
||||||
|
layout = {'left_right': 'full-sbs', 'top_bottom': 'full-ou', 'mono': 'mono'}.get(mode)
|
||||||
|
source = 'metadata'
|
||||||
|
if mode and layout is None:
|
||||||
|
raise ValueError('Unsupported stereo metadata; choose the eye order/layout explicitly')
|
||||||
|
if not layout:
|
||||||
|
raise ValueError('No stereo layout found; choose mono, SBS or OU (left/top eye first)')
|
||||||
|
return {'kind': 'video' if suffix in VIDEO else 'photo', 'layout': layout, 'source': source}
|
||||||
|
|
||||||
|
|
||||||
|
def geometry(width, height, layout):
|
||||||
|
"""Bound transfer to 1920x1080; return packed dimensions and texel aspect."""
|
||||||
|
if not 0 < width <= 32768 or not 0 < height <= 32768:
|
||||||
|
raise ValueError('Invalid media dimensions')
|
||||||
|
if layout not in LAYOUTS[1:]:
|
||||||
|
raise ValueError('Resolve the layout before playback')
|
||||||
|
scale = min(1, 1920 / width, 1080 / height)
|
||||||
|
w, h = max(2, int(width * scale) // 2 * 2), max(2, int(height * scale) // 2 * 2)
|
||||||
|
return w, h, {'mono': 1, 'sbs': 2, 'ou': .5, 'full-sbs': 1, 'full-ou': 1}[layout]
|
||||||
|
|
||||||
|
|
||||||
|
def stereo_pixels(data, width, height, layout):
|
||||||
|
"""Normalize top/bottom to OpenVR's left/right texture; preserve eye order."""
|
||||||
|
if len(data) != width * height * 4:
|
||||||
|
raise ValueError('Incomplete RGBA frame')
|
||||||
|
if layout not in ('ou', 'full-ou'):
|
||||||
|
return data, width, height
|
||||||
|
stride, half = width * 4, height // 2
|
||||||
|
return b''.join(data[y*stride:(y+1)*stride] +
|
||||||
|
data[(y+half)*stride:(y+half+1)*stride] for y in range(half)), width*2, half
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Send local media to Frame Control's own OpenVR player."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import frame_media
|
||||||
|
import server
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument('files', nargs='*', type=Path)
|
||||||
|
ap.add_argument('--launch', action='store_true', help='play the one file being sent')
|
||||||
|
ap.add_argument('--layout', choices=frame_media.LAYOUTS, default='auto')
|
||||||
|
ap.add_argument('--theatre', action='store_true', help='bigger screen and dark surround')
|
||||||
|
ap.add_argument('--list', action='store_true')
|
||||||
|
ap.add_argument('--stop', action='store_true')
|
||||||
|
args = ap.parse_args()
|
||||||
|
if args.launch and len(args.files) != 1:
|
||||||
|
ap.error('--launch needs exactly one file')
|
||||||
|
if not args.files and not (args.list or args.stop):
|
||||||
|
ap.error('choose files, --list or --stop')
|
||||||
|
for path in args.files:
|
||||||
|
if not path.is_file():
|
||||||
|
ap.error('not a file: %s' % path)
|
||||||
|
frame_media.plan(path.name, 'mono')
|
||||||
|
if args.stop:
|
||||||
|
print(json.dumps(server.media({'action': 'stop'})))
|
||||||
|
for path in args.files:
|
||||||
|
result = server.push_media(path.resolve())
|
||||||
|
print(json.dumps(result))
|
||||||
|
if args.launch:
|
||||||
|
print(json.dumps(server.media({'action': 'play', 'id': result['id'],
|
||||||
|
'layout': args.layout, 'theatre': args.theatre})))
|
||||||
|
if args.list:
|
||||||
|
print(json.dumps(server.media({'action': 'list'})))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (ValueError, server.Failure) as e:
|
||||||
|
sys.exit(str(e))
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Frame Control's local-media OpenVR player. Runs on the Frame, no third-party app.
|
||||||
|
|
||||||
|
SteamOS ffmpeg does hardware video decoding, scaling and audio output. OpenVR
|
||||||
|
owns only our screen and optional black surround. Exiting destroys both.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import ctypes as C
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
import frame_media
|
||||||
|
import frame_splat
|
||||||
|
|
||||||
|
LIB = '/opt/steamvr/bin/linuxarm64/libopenvr_api.so'
|
||||||
|
H = C.c_uint64
|
||||||
|
# Slots from Valve's openvr_capi.h, IVROverlay_028. Fail closed on another ABI.
|
||||||
|
SLOTS = {
|
||||||
|
'CreateOverlay': (1, [C.c_char_p, C.c_char_p, C.POINTER(H)]),
|
||||||
|
'DestroyOverlay': (3, [H]),
|
||||||
|
'SetOverlayFlag': (11, [H, C.c_int, C.c_bool]),
|
||||||
|
'SetOverlayAlpha': (16, [H, C.c_float]),
|
||||||
|
'SetOverlayTexelAspect': (18, [H, C.c_float]),
|
||||||
|
'SetOverlaySortOrder': (20, [H, C.c_uint32]),
|
||||||
|
'SetOverlayWidthInMeters': (22, [H, C.c_float]),
|
||||||
|
'SetOverlayTransformTrackedDeviceRelative': (35, [H, C.c_uint32, C.c_void_p]),
|
||||||
|
'ShowOverlay': (43, [H]),
|
||||||
|
'SetOverlayRaw': (62, [H, C.c_void_p, C.c_uint32, C.c_uint32, C.c_uint32]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Overlay:
|
||||||
|
def __init__(self):
|
||||||
|
self.handles = []
|
||||||
|
self.vr = C.CDLL(LIB)
|
||||||
|
self.vr.VR_InitInternal2.argtypes = [C.POINTER(C.c_int), C.c_int, C.c_char_p]
|
||||||
|
self.vr.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(C.c_int)]
|
||||||
|
self.vr.VR_GetGenericInterface.restype = C.c_void_p
|
||||||
|
err = C.c_int()
|
||||||
|
self.vr.VR_InitInternal2(C.byref(err), 2, None)
|
||||||
|
if err.value:
|
||||||
|
raise RuntimeError('SteamVR init failed: %s' % err.value)
|
||||||
|
ptr = self.vr.VR_GetGenericInterface(b'FnTable:IVROverlay_028', C.byref(err))
|
||||||
|
if not ptr or err.value:
|
||||||
|
self.vr.VR_ShutdownInternal()
|
||||||
|
raise RuntimeError('SteamVR needs IVROverlay_028: %s' % err.value)
|
||||||
|
self.table = C.cast(ptr, C.POINTER(C.c_void_p))
|
||||||
|
|
||||||
|
def call(self, name, *values):
|
||||||
|
slot, args = SLOTS[name]
|
||||||
|
rc = C.CFUNCTYPE(C.c_int, *args)(self.table[slot])(*values)
|
||||||
|
if rc:
|
||||||
|
raise RuntimeError('OpenVR %s failed: %s' % (name, rc))
|
||||||
|
|
||||||
|
def create(self, key, width, distance, stereo=False, aspect=1, order=1):
|
||||||
|
handle = H()
|
||||||
|
self.call('CreateOverlay', key.encode(), b'Frame Control media', C.byref(handle))
|
||||||
|
self.handles.append(handle)
|
||||||
|
self.call('SetOverlayWidthInMeters', handle, width)
|
||||||
|
self.call('SetOverlaySortOrder', handle, order)
|
||||||
|
self.call('SetOverlayTexelAspect', handle, aspect)
|
||||||
|
if stereo:
|
||||||
|
self.call('SetOverlayFlag', handle, 1024, True) # SideBySide_Parallel
|
||||||
|
matrix = (C.c_float * 12)(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, -distance)
|
||||||
|
self.call('SetOverlayTransformTrackedDeviceRelative', handle, 0, matrix)
|
||||||
|
return handle
|
||||||
|
|
||||||
|
def pixels(self, handle, data, width, height):
|
||||||
|
buf = C.create_string_buffer(data)
|
||||||
|
self.call('SetOverlayRaw', handle, buf, width, height, 4)
|
||||||
|
self.call('ShowOverlay', handle)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
for h in reversed(self.handles):
|
||||||
|
self.call('DestroyOverlay', h)
|
||||||
|
finally:
|
||||||
|
self.vr.VR_ShutdownInternal()
|
||||||
|
|
||||||
|
|
||||||
|
def probe(path):
|
||||||
|
result = subprocess.run(['ffprobe', '-v', 'error', '-show_streams', '-of', 'json', str(path)],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
if result.returncode:
|
||||||
|
raise ValueError(result.stderr[-2000:] or 'Cannot read media')
|
||||||
|
streams = json.loads(result.stdout)['streams']
|
||||||
|
video = next((s for s in streams if s['codec_type'] == 'video'), None)
|
||||||
|
if not video:
|
||||||
|
raise ValueError('No image or video stream')
|
||||||
|
return video, any(s['codec_type'] == 'audio' for s in streams)
|
||||||
|
|
||||||
|
|
||||||
|
def decoder_command(path, info, width, height, audio, photo=False):
|
||||||
|
cmd = ['ffmpeg', '-nostdin', '-hide_banner', '-loglevel', 'error']
|
||||||
|
if not photo:
|
||||||
|
cmd += ['-re', '-readrate_initial_burst', '0']
|
||||||
|
codec = {'h264': 'h264_v4l2m2m', 'hevc': 'hevc_v4l2m2m'}.get(info['codec_name'])
|
||||||
|
if not codec:
|
||||||
|
raise ValueError('Hardware playback currently supports H.264 and H.265 only')
|
||||||
|
cmd += ['-c:v', codec]
|
||||||
|
cmd += ['-i', str(path), '-map', '0:v:0', '-vf', 'scale=%s:%s' % (width, height),
|
||||||
|
'-pix_fmt', 'rgba']
|
||||||
|
if photo:
|
||||||
|
cmd += ['-frames:v', '1']
|
||||||
|
else:
|
||||||
|
cmd += ['-r', '30']
|
||||||
|
cmd += ['-f', 'rawvideo', 'pipe:1']
|
||||||
|
if audio and not photo:
|
||||||
|
cmd += ['-map', '0:a:0', '-f', 'pulse', 'Frame Control Media']
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def write_status(path, **values):
|
||||||
|
tmp = path.with_suffix('.tmp')
|
||||||
|
tmp.write_text(json.dumps(values))
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def play(args):
|
||||||
|
path = Path(args.file).resolve(strict=True)
|
||||||
|
status = Path(args.status)
|
||||||
|
splat = path.suffix.lower() == '.splat'
|
||||||
|
if splat:
|
||||||
|
data, width, height = frame_splat.render(path)
|
||||||
|
plan = frame_media.plan(path.name)
|
||||||
|
aspect, photo, command = 1, True, None
|
||||||
|
else:
|
||||||
|
info, audio = probe(path)
|
||||||
|
plan = frame_media.plan(path.name, args.layout, info.get('tags'))
|
||||||
|
width, height, aspect = frame_media.geometry(info['width'], info['height'], plan['layout'])
|
||||||
|
photo = plan['kind'] == 'photo'
|
||||||
|
command = decoder_command(path, info, width, height, audio, photo)
|
||||||
|
vr, proc, frames, started = None, None, 0, time.monotonic()
|
||||||
|
# systemd sends SIGTERM to the whole unit, including ffmpeg. Python unwinds
|
||||||
|
# ownership; no unrelated Steam/SteamVR process or setting is touched.
|
||||||
|
def stop(signum, frame):
|
||||||
|
raise InterruptedError('Stopped')
|
||||||
|
signal.signal(signal.SIGTERM, stop)
|
||||||
|
signal.signal(signal.SIGINT, stop)
|
||||||
|
try:
|
||||||
|
vr = Overlay()
|
||||||
|
if args.theatre:
|
||||||
|
surround = vr.create('framecontrol.media.surround', 40, 4, order=0)
|
||||||
|
vr.call('SetOverlayAlpha', surround, .85)
|
||||||
|
vr.pixels(surround, b'\x00\x00\x00\xff', 1, 1)
|
||||||
|
screen = vr.create('framecontrol.media.screen', 3 if args.theatre else 1.6, 2,
|
||||||
|
plan['layout'] != 'mono', aspect)
|
||||||
|
if splat:
|
||||||
|
vr.pixels(screen, data, width, height)
|
||||||
|
write_status(status, state='playing', file=path.name, frames=1, **plan)
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
|
||||||
|
video_start = time.monotonic()
|
||||||
|
while True:
|
||||||
|
data = proc.stdout.read(width * height * 4)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
data, outw, outh = frame_media.stereo_pixels(data, width, height, plan['layout'])
|
||||||
|
if not photo:
|
||||||
|
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
|
||||||
|
vr.pixels(screen, data, outw, outh)
|
||||||
|
frames += 1
|
||||||
|
if frames == 1 or frames % 30 == 0:
|
||||||
|
write_status(status, state='playing', file=path.name, frames=frames,
|
||||||
|
seconds=time.monotonic()-started, **plan)
|
||||||
|
if not photo:
|
||||||
|
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
|
||||||
|
rc = proc.wait(timeout=10)
|
||||||
|
if rc:
|
||||||
|
raise RuntimeError('ffmpeg exited %s; see media log' % rc)
|
||||||
|
if not frames:
|
||||||
|
raise RuntimeError('Decoder produced no frames')
|
||||||
|
if photo:
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
write_status(status, state='ended', frames=frames, seconds=time.monotonic()-started)
|
||||||
|
except InterruptedError:
|
||||||
|
write_status(status, state='stopped', frames=frames)
|
||||||
|
finally:
|
||||||
|
if proc:
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
proc.stdout.close()
|
||||||
|
if vr:
|
||||||
|
vr.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument('file')
|
||||||
|
ap.add_argument('--layout', choices=frame_media.LAYOUTS, default='auto')
|
||||||
|
ap.add_argument('--theatre', action='store_true')
|
||||||
|
ap.add_argument('--status', required=True)
|
||||||
|
args = ap.parse_args()
|
||||||
|
try:
|
||||||
|
play(args)
|
||||||
|
except Exception as e:
|
||||||
|
write_status(Path(args.status), state='error', error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Frame-side library and process ownership for Frame Control media.
|
||||||
|
|
||||||
|
Only the dedicated systemd user unit is controlled. No SteamVR settings change.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import frame_media
|
||||||
|
from frame_media_player import probe
|
||||||
|
|
||||||
|
ROOT = Path.home() / 'Videos' / 'FrameControl'
|
||||||
|
RUNTIME = Path.home() / '.local' / 'share' / 'frame-control' / 'media'
|
||||||
|
UNIT = 'frame-control-media.service'
|
||||||
|
STATUS = RUNTIME / 'status.json'
|
||||||
|
|
||||||
|
|
||||||
|
def media_path(identity):
|
||||||
|
if not isinstance(identity, str) or '\\' in identity or '\x00' in identity:
|
||||||
|
raise ValueError('Invalid media id')
|
||||||
|
parts = Path(identity).parts
|
||||||
|
if len(parts) != 2 or not re.fullmatch('[0-9a-f]{32}', parts[0]) or parts[1].startswith('.'):
|
||||||
|
raise ValueError('Invalid media id')
|
||||||
|
candidate = ROOT / identity
|
||||||
|
if candidate.is_symlink() or candidate.parent.is_symlink():
|
||||||
|
raise ValueError('Media links are not supported')
|
||||||
|
path = candidate.resolve(strict=True)
|
||||||
|
if not path.is_file() or ROOT.resolve() not in path.parents:
|
||||||
|
raise ValueError('Media file is outside the library')
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def active():
|
||||||
|
return subprocess.run(['systemctl', '--user', 'is-active', '--quiet', UNIT]).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def status():
|
||||||
|
running = active()
|
||||||
|
try:
|
||||||
|
state = json.loads(STATUS.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
state = {'state': 'idle'}
|
||||||
|
if not running and state.get('state') in ('playing', 'starting', 'paused'):
|
||||||
|
state = {'state': 'stopped', 'message': 'Player exited; check the media log if this was unexpected'}
|
||||||
|
return dict(state, running=running)
|
||||||
|
|
||||||
|
|
||||||
|
def run(body):
|
||||||
|
action = body.get('action')
|
||||||
|
if action == 'list':
|
||||||
|
files = []
|
||||||
|
if ROOT.exists():
|
||||||
|
for folder in sorted(ROOT.iterdir()):
|
||||||
|
if not re.fullmatch('[0-9a-f]{32}', folder.name) or not folder.is_dir() or folder.is_symlink():
|
||||||
|
continue
|
||||||
|
for path in sorted(folder.iterdir()):
|
||||||
|
if path.is_file() and not path.is_symlink() and not path.name.startswith('.'):
|
||||||
|
files.append({'id': folder.name+'/'+path.name, 'name': path.name, 'bytes': path.stat().st_size})
|
||||||
|
return {'files': files, 'player': status()}
|
||||||
|
if action == 'status':
|
||||||
|
return status()
|
||||||
|
if action == 'stop':
|
||||||
|
# --collect unloads the unit after it exits; systemctl then exits 5
|
||||||
|
# ("not loaded", verified on the Frame). That's a finished player, not an error.
|
||||||
|
stopped = subprocess.run(['systemctl', '--user', 'stop', UNIT], capture_output=True, text=True, timeout=15)
|
||||||
|
if stopped.returncode not in (0, 5):
|
||||||
|
raise RuntimeError('Could not stop the media player: ' + (stopped.stderr.strip() or 'exit %s' % stopped.returncode))
|
||||||
|
return {'message': 'Media player stopped', **status()}
|
||||||
|
if action != 'play':
|
||||||
|
raise ValueError('Media action must be list, status, play or stop')
|
||||||
|
path = media_path(body.get('id'))
|
||||||
|
if type(body.get('theatre', False)) is not bool:
|
||||||
|
raise ValueError('theatre must be true or false')
|
||||||
|
info = {} if path.suffix.lower() == '.splat' else probe(path)[0]
|
||||||
|
plan = frame_media.plan(path.name, body.get('layout', 'auto'), info.get('tags'))
|
||||||
|
if active():
|
||||||
|
raise ValueError('Stop the current media before starting another file')
|
||||||
|
# systemd owns the process group and refuses a concurrent start of this name.
|
||||||
|
# The runtime cap also cleans up if the controlling computer disconnects.
|
||||||
|
subprocess.run(['systemctl', '--user', 'reset-failed', UNIT], stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL, timeout=10)
|
||||||
|
STATUS.write_text(json.dumps({'state': 'starting', 'file': path.name}))
|
||||||
|
command = ['systemd-run', '--user', '--quiet', '--collect', '--unit='+UNIT,
|
||||||
|
'--property=RuntimeMaxSec=14400', '--property=TimeoutStopSec=8',
|
||||||
|
'--property=StandardOutput=append:'+str(RUNTIME/'player.log'),
|
||||||
|
'--property=StandardError=append:'+str(RUNTIME/'player.log'),
|
||||||
|
'python3', str(RUNTIME/'frame_media_player.py'), str(path),
|
||||||
|
'--layout', plan['layout'], '--status', str(STATUS)]
|
||||||
|
if body.get('theatre'):
|
||||||
|
command.append('--theatre')
|
||||||
|
started = subprocess.run(command, capture_output=True, text=True, timeout=15)
|
||||||
|
if started.returncode:
|
||||||
|
raise RuntimeError('Could not start the media player: ' + (started.stderr.strip() or 'systemd-run exited %s' % started.returncode))
|
||||||
|
return {'message': 'Starting Frame Control media', 'plan': plan}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
print(json.dumps(run(json.load(sys.stdin))))
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({'error': str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Small, bounded CPU Gaussian-splat preview renderer (Frame Control-owned).
|
||||||
|
|
||||||
|
Reads the common 32-byte .splat record: position/scale float32 triplets,
|
||||||
|
RGBA bytes, then normalized quaternion bytes (wxyz). Two perspective cameras,
|
||||||
|
projected 3D covariance, back-to-front alpha compositing. This is a stationary
|
||||||
|
stereo preview, not a six-degree-of-freedom scene or a large-scene renderer.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import struct
|
||||||
|
|
||||||
|
MAX_SPLATS = 20000
|
||||||
|
RECORD = struct.Struct('<6f8B')
|
||||||
|
|
||||||
|
|
||||||
|
def read(path):
|
||||||
|
size = Path(path).stat().st_size
|
||||||
|
if not size or size % RECORD.size or size > MAX_SPLATS * RECORD.size:
|
||||||
|
raise ValueError('Use a 32-byte .splat file with 1–20,000 Gaussians; PLY/SPZ and larger scenes are not supported yet')
|
||||||
|
values = []
|
||||||
|
with open(path, 'rb') as stream:
|
||||||
|
for row in RECORD.iter_unpack(stream.read(MAX_SPLATS * RECORD.size + 1)):
|
||||||
|
xyz, scales = row[:3], row[3:6]
|
||||||
|
if not all(math.isfinite(v) and abs(v) <= 1e6 for v in row[:6]) or min(scales) <= 0:
|
||||||
|
raise ValueError('Invalid splat position or scale')
|
||||||
|
q = [(v - 128) / 128 for v in row[10:14]]
|
||||||
|
length = math.sqrt(sum(v*v for v in q))
|
||||||
|
if length < .01:
|
||||||
|
raise ValueError('Invalid splat quaternion')
|
||||||
|
w, x, y, z = [v / length for v in q]
|
||||||
|
rotation = ((1-2*(y*y+z*z), 2*(x*y-z*w), 2*(x*z+y*w)),
|
||||||
|
(2*(x*y+z*w), 1-2*(x*x+z*z), 2*(y*z-x*w)),
|
||||||
|
(2*(x*z-y*w), 2*(y*z+x*w), 1-2*(x*x+y*y)))
|
||||||
|
cov = [[sum(rotation[i][k]*rotation[j][k]*scales[k]**2 for k in range(3))
|
||||||
|
for j in range(3)] for i in range(3)]
|
||||||
|
values.append((xyz, cov, row[6:10]))
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def render(path, width=320, height=240):
|
||||||
|
values = read(path)
|
||||||
|
lo = [min(p[0][i] for p in values) for i in range(3)]
|
||||||
|
hi = [max(p[0][i] for p in values) for i in range(3)]
|
||||||
|
center = [(a+b)/2 for a, b in zip(lo, hi)]
|
||||||
|
radius = max(max(b-a for a, b in zip(lo, hi))/2, .01)
|
||||||
|
# Normalize captures to a two-metre box. Source units are not assumed metres.
|
||||||
|
normalized = [([(xyz[i]-center[i])/radius for i in range(3)],
|
||||||
|
[[v/radius**2 for v in row] for row in cov], color)
|
||||||
|
for xyz, cov, color in values]
|
||||||
|
normalized.sort(key=lambda p: p[0][2]) # camera is at z=3; farthest first
|
||||||
|
focal = width * .8
|
||||||
|
eyes = []
|
||||||
|
for eye in (-.032, .032):
|
||||||
|
pixels = bytearray(b'\x00\x00\x00\xff' * (width*height))
|
||||||
|
for (x, y, z), cov, color in normalized:
|
||||||
|
x -= eye
|
||||||
|
depth = 3-z
|
||||||
|
px, py = width/2+focal*x/depth, height/2-focal*y/depth
|
||||||
|
jac = ((focal/depth, 0, focal*x/depth**2),
|
||||||
|
(0, -focal/depth, -focal*y/depth**2))
|
||||||
|
screen = [[sum(jac[i][a]*cov[a][b]*jac[j][b] for a in range(3) for b in range(3))
|
||||||
|
for j in range(2)] for i in range(2)]
|
||||||
|
a, b, c = screen[0][0]+.3, screen[0][1], screen[1][1]+.3
|
||||||
|
det = a*c-b*b
|
||||||
|
if det <= 0 or not math.isfinite(det):
|
||||||
|
raise ValueError('Splat covariance is not renderable')
|
||||||
|
# A footprint cap bounds work on malformed or oversized Gaussians.
|
||||||
|
rx, ry = min(32, math.ceil(3*math.sqrt(a))), min(32, math.ceil(3*math.sqrt(c)))
|
||||||
|
for sy in range(max(0, int(py)-ry), min(height, int(py)+ry+1)):
|
||||||
|
dy = sy+.5-py
|
||||||
|
for sx in range(max(0, int(px)-rx), min(width, int(px)+rx+1)):
|
||||||
|
dx = sx+.5-px
|
||||||
|
power = (c*dx*dx-2*b*dx*dy+a*dy*dy)/det
|
||||||
|
if power > 9:
|
||||||
|
continue
|
||||||
|
alpha = color[3]/255 * math.exp(-.5*power)
|
||||||
|
offset = (sy*width+sx)*4
|
||||||
|
for k in range(3):
|
||||||
|
pixels[offset+k] = round(color[k]*alpha+pixels[offset+k]*(1-alpha))
|
||||||
|
eyes.append(pixels)
|
||||||
|
stride = width*4
|
||||||
|
return b''.join(eyes[0][y*stride:(y+1)*stride]+eyes[1][y*stride:(y+1)*stride]
|
||||||
|
for y in range(height)), width*2, height
|
||||||
@@ -87,6 +87,7 @@
|
|||||||
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
|
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
|
||||||
|
|
||||||
/* ---- drop anywhere ---- */
|
/* ---- drop anywhere ---- */
|
||||||
|
#media select { min-width: 0; max-width: 100%; flex: 1; }
|
||||||
.dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none;
|
.dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none;
|
||||||
background: rgba(14,20,27,.82); backdrop-filter: blur(3px); }
|
background: rgba(14,20,27,.82); backdrop-filter: blur(3px); }
|
||||||
.dropzone > div { padding: 42px 60px; border: 2px dashed var(--blue); border-radius: 8px; text-align: center;
|
.dropzone > div { padding: 42px 60px; border: 2px dashed var(--blue); border-radius: 8px; text-align: center;
|
||||||
@@ -647,6 +648,12 @@
|
|||||||
<span class="desk-only">You can also drop files anywhere in this window.</span>
|
<span class="desk-only">You can also drop files anywhere in this window.</span>
|
||||||
<input type="file" id="fileInput" multiple hidden>
|
<input type="file" id="fileInput" multiple hidden>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="sub" for="apkDisplay">Android display</label>
|
||||||
|
<select id="apkDisplay" aria-label="Android app display mode">
|
||||||
|
<option value="auto">Automatic (detect VR)</option>
|
||||||
|
<option value="flat">Flat screen</option>
|
||||||
|
<option value="vr">VR app</option>
|
||||||
|
</select>
|
||||||
<div class="progress" id="prog"><i></i></div>
|
<div class="progress" id="prog"><i></i></div>
|
||||||
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
|
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
|
||||||
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
||||||
@@ -657,6 +664,31 @@
|
|||||||
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="media">
|
||||||
|
<div class="shelf-head"><h2>Media in the headset</h2><span class="count">Preview</span></div>
|
||||||
|
<p class="sub">Play your movies and stereo photos with Frame Control's own player.</p>
|
||||||
|
<div class="row"><button class="small" id="mediaChoose">Send media…</button>
|
||||||
|
<button class="small" id="mediaRefresh">Refresh</button></div>
|
||||||
|
<input type="file" id="mediaInput" accept=".mp4,.mkv,.mov,.m4v,.webm,.png,.jpg,.jpeg,.splat" hidden>
|
||||||
|
<div class="row" style="margin-top:12px"><label for="mediaFile">On the Frame</label>
|
||||||
|
<select id="mediaFile"><option value="">Refresh to load your media</option></select></div>
|
||||||
|
<div class="row" style="margin-top:8px"><label for="mediaLayout">Layout</label>
|
||||||
|
<select id="mediaLayout">
|
||||||
|
<option value="auto">Detect from name or metadata</option><option value="mono">2D</option>
|
||||||
|
<option value="sbs">Half SBS · left / right</option><option value="ou">Half OU · top / bottom</option>
|
||||||
|
<option value="full-sbs">Full SBS · left / right</option><option value="full-ou">Full OU · top / bottom</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="row" style="margin-top:8px"><label><input type="checkbox" id="mediaTheatre" checked>
|
||||||
|
Theatre · bigger screen, dark surround</label></div>
|
||||||
|
<div class="row" style="margin-top:12px"><button class="action small" id="mediaPlay">Play</button>
|
||||||
|
<button class="small" id="mediaStop">Stop</button></div>
|
||||||
|
<p class="sub" id="mediaState" role="status" aria-live="polite">Choose a file to begin.</p>
|
||||||
|
<div class="hint">H.264/H.265 video; SBS/OU PNG or JPEG photos. Screens follow your head.
|
||||||
|
Small .splat files show a stationary stereo preview (up to 20,000 Gaussians).
|
||||||
|
Native HEIC/MPO photos, large splat scenes, seeking and subtitles aren't supported yet.
|
||||||
|
Stop works from here; playback also ends after four hours.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="apps">
|
<section class="panel" id="apps">
|
||||||
<div class="shelf-head"><h2>Linux apps</h2><span class="count">Flatpak</span></div>
|
<div class="shelf-head"><h2>Linux apps</h2><span class="count">Flatpak</span></div>
|
||||||
<div class="list" id="flatpaks"><div class="sub">Loading…</div></div>
|
<div class="list" id="flatpaks"><div class="sub">Loading…</div></div>
|
||||||
@@ -1522,6 +1554,7 @@ function upload(file, mode) {
|
|||||||
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
|
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
|
||||||
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
|
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
|
||||||
xhr.setRequestHeader("X-Mode", mode);
|
xhr.setRequestHeader("X-Mode", mode);
|
||||||
|
if (mode === "apk") xhr.setRequestHeader("X-APK-Display", $("apkDisplay").value);
|
||||||
const bar = $("prog").firstElementChild;
|
const bar = $("prog").firstElementChild;
|
||||||
$("prog").style.display = "block"; bar.style.width = "0";
|
$("prog").style.display = "block"; bar.style.width = "0";
|
||||||
xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (100 * e.loaded / e.total) + "%"; };
|
xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (100 * e.loaded / e.total) + "%"; };
|
||||||
@@ -1535,6 +1568,42 @@ function upload(file, mode) {
|
|||||||
xhr.send(file);
|
xhr.send(file);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async function refreshMedia(selectId) {
|
||||||
|
const data = await api("/api/media", { action: "list" });
|
||||||
|
const selected = selectId || $("mediaFile").value;
|
||||||
|
$("mediaFile").replaceChildren();
|
||||||
|
if (!data.files.length) $("mediaFile").add(new Option("Send media to begin", ""));
|
||||||
|
for (const file of data.files) $("mediaFile").add(new Option(file.name, file.id));
|
||||||
|
if (data.files.some(f => f.id === selected)) $("mediaFile").value = selected;
|
||||||
|
const p = data.player;
|
||||||
|
$("mediaState").textContent = p.error || `${p.state}${p.file ? ": " + p.file : ""}`;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
$("mediaChoose").onclick = () => $("mediaInput").click();
|
||||||
|
$("mediaInput").onchange = () => act("Send media", async () => {
|
||||||
|
const file = $("mediaInput").files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const result = await upload(file, "media");
|
||||||
|
await refreshMedia(result.id);
|
||||||
|
$("mediaInput").value = "";
|
||||||
|
return result;
|
||||||
|
}, $("mediaChoose"));
|
||||||
|
$("mediaRefresh").onclick = () => act("Refresh media", () => refreshMedia(), $("mediaRefresh"));
|
||||||
|
$("mediaPlay").onclick = () => act("Play media", async () => {
|
||||||
|
const id = $("mediaFile").value;
|
||||||
|
if (!id) throw new Error("Send or select a media file first");
|
||||||
|
const result = await api("/api/media", { action: "play", id,
|
||||||
|
layout: $("mediaLayout").value, theatre: $("mediaTheatre").checked });
|
||||||
|
$("mediaState").textContent = "Starting…";
|
||||||
|
setTimeout(() => refreshMedia().catch(e => { $("mediaState").textContent = e.message; }), 1500);
|
||||||
|
return result;
|
||||||
|
}, $("mediaPlay"));
|
||||||
|
$("mediaStop").onclick = () => act("Stop media", async () => {
|
||||||
|
const result = await api("/api/media", { action: "stop" });
|
||||||
|
await refreshMedia();
|
||||||
|
return result;
|
||||||
|
}, $("mediaStop"));
|
||||||
|
|
||||||
let apkLookup = 0;
|
let apkLookup = 0;
|
||||||
async function checkApkAlternatives(apk) {
|
async function checkApkAlternatives(apk) {
|
||||||
const lookup = ++apkLookup;
|
const lookup = ++apkLookup;
|
||||||
|
|||||||
+83
-3
@@ -44,6 +44,7 @@ import frame_apk_versions # noqa: E402
|
|||||||
import frame_catalog # noqa: E402
|
import frame_catalog # noqa: E402
|
||||||
import frame_host # noqa: E402
|
import frame_host # noqa: E402
|
||||||
import frame_macview # noqa: E402
|
import frame_macview # noqa: E402
|
||||||
|
import frame_media # noqa: E402
|
||||||
import frame_report # noqa: E402
|
import frame_report # noqa: E402
|
||||||
import frame_store # noqa: E402
|
import frame_store # noqa: E402
|
||||||
import frame_telemetry # noqa: E402
|
import frame_telemetry # noqa: E402
|
||||||
@@ -1289,6 +1290,75 @@ def _sweep_one(prefix, d):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Our Frame-side media player -----------------------------------------
|
||||||
|
|
||||||
|
_MEDIA_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def media(body):
|
||||||
|
action = body.get("action")
|
||||||
|
if action not in ("list", "status", "play", "stop"):
|
||||||
|
raise Failure("Media action must be list, status, play or stop", 400)
|
||||||
|
if action == "play":
|
||||||
|
identity = body.get("id")
|
||||||
|
if not isinstance(identity, str) or not re.fullmatch(r"[0-9a-f]{32}/[^/\\\x00]+", identity):
|
||||||
|
raise Failure("Invalid media id", 400)
|
||||||
|
if body.get("layout", "auto") not in frame_media.LAYOUTS:
|
||||||
|
raise Failure("Invalid media layout", 400)
|
||||||
|
if type(body.get("theatre", False)) is not bool:
|
||||||
|
raise Failure("theatre must be true or false", 400)
|
||||||
|
with _MEDIA_LOCK:
|
||||||
|
# Ship only our small stdlib modules, atomically, to the user account.
|
||||||
|
sources = {name: (HERE / name).read_text() for name in (
|
||||||
|
"frame_media.py", "frame_media_player.py", "frame_media_remote.py", "frame_splat.py")}
|
||||||
|
installer = """import json, os, pathlib, sys, tempfile
|
||||||
|
root = pathlib.Path.home()/'.local/share/frame-control/media'
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name, source in json.load(sys.stdin).items():
|
||||||
|
path = root/name
|
||||||
|
fd, temp = tempfile.mkstemp(dir=root, prefix=name+'.')
|
||||||
|
with os.fdopen(fd, 'w') as f:
|
||||||
|
f.write(source)
|
||||||
|
os.replace(temp, path)
|
||||||
|
"""
|
||||||
|
ssh("python3 -c " + shlex.quote(installer), stdin=json.dumps(sources))
|
||||||
|
try:
|
||||||
|
out = ssh("python3 ~/.local/share/frame-control/media/frame_media_remote.py",
|
||||||
|
stdin=json.dumps(body), timeout=75) # remote worst case: ffprobe 30 + reset-failed 10 + systemd-run 15 s
|
||||||
|
except Failure as e:
|
||||||
|
for line in reversed(getattr(e, "stdout", "").splitlines()):
|
||||||
|
try:
|
||||||
|
detail = json.loads(line).get("error")
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
continue
|
||||||
|
if detail:
|
||||||
|
raise Failure(detail) from None
|
||||||
|
raise
|
||||||
|
return json.loads(out)
|
||||||
|
|
||||||
|
|
||||||
|
def push_media(path):
|
||||||
|
# Validate the format, but leave layout selection until playback (ffprobe
|
||||||
|
# can then read metadata on the Frame, where it is installed).
|
||||||
|
name = Path(path).name
|
||||||
|
frame_media.plan(name, "mono")
|
||||||
|
if name.startswith(".") or "\\" in name:
|
||||||
|
raise Failure("Rename the file: media names can't start with a dot or contain a backslash", 400)
|
||||||
|
token = secrets.token_hex(16)
|
||||||
|
dest = "Videos/FrameControl/" + token + "/"
|
||||||
|
ssh("mkdir -p ~/" + dest)
|
||||||
|
try:
|
||||||
|
push_file(path, dest)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
ssh("rm -rf ~/" + dest)
|
||||||
|
except Exception:
|
||||||
|
pass # keep the copy error; an empty folder isn't listed as media
|
||||||
|
raise
|
||||||
|
return {"message": "Media sent. Choose its layout and press Play.",
|
||||||
|
"id": token + "/" + name}
|
||||||
|
|
||||||
|
|
||||||
# ---- Mac in the headset (frame_macview.py) ----------------------------------
|
# ---- Mac in the headset (frame_macview.py) ----------------------------------
|
||||||
|
|
||||||
# The tunnel gets its own connection: the shared master's options would win
|
# The tunnel gets its own connection: the shared master's options would win
|
||||||
@@ -1327,6 +1397,8 @@ def macview_action(body):
|
|||||||
except frame_macview.MacViewError as e:
|
except frame_macview.MacViewError as e:
|
||||||
raise Failure(str(e), 502)
|
raise Failure(str(e), 502)
|
||||||
raise Failure("unknown action", 400)
|
raise Failure("unknown action", 400)
|
||||||
|
|
||||||
|
|
||||||
# ---- Report a problem (frame_report.py) --------------------------------------
|
# ---- Report a problem (frame_report.py) --------------------------------------
|
||||||
|
|
||||||
def report_preview(body):
|
def report_preview(body):
|
||||||
@@ -1353,7 +1425,7 @@ def agent_approval(body):
|
|||||||
return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept"))
|
return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept"))
|
||||||
|
|
||||||
|
|
||||||
POST = {"/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
|
POST = {"/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
|
||||||
"/api/assistant/chat": assistant_chat, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
"/api/assistant/chat": assistant_chat, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||||
@@ -1645,6 +1717,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
raise Failure("upload interrupted", 400)
|
raise Failure("upload interrupted", 400)
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
remaining -= len(chunk)
|
remaining -= len(chunk)
|
||||||
|
if mode == "media":
|
||||||
|
return push_media(dest)
|
||||||
if mode == "apkinfo":
|
if mode == "apkinfo":
|
||||||
# Read an APK for a report without installing it.
|
# Read an APK for a report without installing it.
|
||||||
try:
|
try:
|
||||||
@@ -1677,10 +1751,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
raise Failure(str(e), 400, {"package": info["package"], "version_code": info.get("version_code"), "blocker": str(e)})
|
raise Failure(str(e), 400, {"package": info["package"], "version_code": info.get("version_code"), "blocker": str(e)})
|
||||||
ensure_master()
|
ensure_master()
|
||||||
try:
|
try:
|
||||||
m = frame_android.install(str(dest), source=name)
|
display = self.headers.get("X-APK-Display", "auto")
|
||||||
|
if display not in ("auto", "flat", "vr"):
|
||||||
|
raise frame_android.FrameError("invalid APK display mode")
|
||||||
|
m = frame_android.install(str(dest), source=name,
|
||||||
|
flatscreen=None if display == "auto" else display == "flat")
|
||||||
except frame_android.FrameError as e:
|
except frame_android.FrameError as e:
|
||||||
raise Failure(str(e), 400)
|
raise Failure(str(e), 400)
|
||||||
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m}
|
kind = "VR app" if not m['flatscreen'] else "app"
|
||||||
|
notes = " ".join(m.get("vr_issues", []))
|
||||||
|
return {"message": f"Installed {m['label']} as its own {kind} in the Steam library. {notes}".strip(), "app": m}
|
||||||
return {"message": push_file(dest)}
|
return {"message": push_file(dest)}
|
||||||
finally:
|
finally:
|
||||||
if not keep:
|
if not keep:
|
||||||
|
|||||||
Reference in new issue
Block a user