diff --git a/app/build.gradle b/app/build.gradle
index 5b29d72f..99121073 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -150,6 +150,7 @@ android {
}
buildFeatures {
+ prefab true
compose true
buildConfig true
}
@@ -225,6 +226,7 @@ if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
dependencies {
+ implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
implementation 'org.apache.commons:commons-compress:1.27.1'
implementation 'org.tukaani:xz:1.10'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 705a261f..f04f1f2f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -22,6 +22,9 @@
+
+
+
+
+
+
+
+
+
+
+
10000) return 0;
+ struct timespec deadline;
+ clock_gettime(CLOCK_REALTIME, &deadline);
+ deadline.tv_sec += timeout_ms / 1000;
+ deadline.tv_nsec += (timeout_ms % 1000) * 1000000L;
+ if (deadline.tv_nsec >= 1000000000L) { deadline.tv_sec++; deadline.tv_nsec -= 1000000000L; }
+ pthread_mutex_lock(&g_req_lock);
+ const uint64_t generation = g_req_generation;
+ int error = 0;
+ while (g_req_applied < generation && error == 0)
+ error = pthread_cond_timedwait(&g_req_condition, &g_req_lock, &deadline);
+ int applied = g_req_applied >= generation;
+ pthread_mutex_unlock(&g_req_lock);
+ return applied;
+}
+
int vkp_apply_window_request(void) {
ANativeWindow *w;
pthread_mutex_lock(&g_req_lock);
if (!g_req_pending) { pthread_mutex_unlock(&g_req_lock); return 0; }
w = g_req_window;
const int detached = g_req_detached;
+ const uint64_t generation = g_req_generation;
g_req_window = NULL;
g_req_pending = 0;
g_req_detached = 0;
pthread_mutex_unlock(&g_req_lock);
if (w == g_window) {
if (w) ANativeWindow_release(w);
- if (!w || !detached) return 0;
+ if (!w || !detached) { window_request_applied(generation); return 0; }
sc_layer_window_gone();
destroy_swapchain();
+ window_request_applied(generation);
droiddeck_log("gpu", "screen surface rebound: rebuilding the swapchain at its new size");
return 1;
}
@@ -228,6 +260,7 @@ int vkp_apply_window_request(void) {
g_window = w;
g_swap_retry_at_ns = 0;
g_swap_fail_logged = 0;
+ window_request_applied(generation);
droiddeck_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused");
return 1;
}
@@ -1638,6 +1671,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
}
g_base_black = (g_plain_frame && n == 0 && !hf) ? 1 : 0;
+ if (fr == VK_SUCCESS) __atomic_add_fetch(&g_presented_frames, 1u, __ATOMIC_RELEASE);
if (drawn && !g_first_frame_done) {
g_first_frame_done = 1;
diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.h b/app/src/main/cpp/waylandcomp/src/vk_present.h
index 5b050d47..4f334a7c 100644
--- a/app/src/main/cpp/waylandcomp/src/vk_present.h
+++ b/app/src/main/cpp/waylandcomp/src/vk_present.h
@@ -48,6 +48,8 @@ const char *vkp_gpu_name(void);
int vkp_ready(void);
int vkp_has_window(void);
+int vkp_wait_window_request(int timeout_ms);
+uint64_t vkp_presented_frames(void);
int vkp_device_lost(void);
int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n);
diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
index b1125ce3..1a77ff42 100644
--- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
+++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
@@ -582,6 +582,17 @@ static char *dup_bytes(JNIEnv *env, jbyteArray arr, int *len) {
return buf;
}
+JNIEXPORT jboolean JNICALL
+Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeWaitSurface(
+ JNIEnv *env, jclass clazz, jint timeout_ms) {
+ return vkp_wait_window_request(timeout_ms) ? JNI_TRUE : JNI_FALSE;
+}
+
+JNIEXPORT jlong JNICALL
+Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativePresentedFrames(JNIEnv *env, jclass clazz) {
+ return (jlong)vkp_presented_frames();
+}
+
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetClipboardText(JNIEnv *env, jclass clazz, jbyteArray utf8) {
int len;
diff --git a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt
new file mode 100644
index 00000000..6e7b4e2e
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt
@@ -0,0 +1,53 @@
+package com.droiddeck.launcher
+
+import android.app.NativeActivity
+import android.os.Bundle
+import android.view.Surface
+import com.droiddeck.launcher.session.SessionService
+import com.droiddeck.launcher.wayland.CompositorHost
+import com.droiddeck.launcher.wayland.WaylandCompositor
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+class QuestXrActivity : NativeActivity() {
+ private var presentedBaseline = 0L
+ override fun onCreate(savedInstanceState: Bundle?) {
+ if (intent.getBooleanExtra("guest_surface", false)) {
+ require(CompositorHost.isStarted) { "Start a DroidDeck session before attaching XR" }
+ intent.putExtra("surface_test", true)
+ }
+ super.onCreate(savedInstanceState)
+ }
+
+ fun onXrSurface(surface: Surface?, width: Int, height: Int) {
+ val complete = CountDownLatch(1)
+ var failure: Throwable? = null
+ runOnUiThread {
+ try {
+ if (surface != null) {
+ presentedBaseline = WaylandCompositor.nativePresentedFrames()
+ WaylandCompositor.nativeSetCardboard(false, 1, 0.064f, 3f, 4f, 0f, 0f, 90f, 0f, 0f)
+ WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
+ WaylandCompositor.nativeSetZeroCopy(false)
+ WaylandCompositor.nativeSetOutputSize(width, height)
+ WaylandCompositor.nativeSetFpsLimit(0)
+ }
+ CompositorHost.attachXr(surface)
+ SessionService.setActivityVisible(this, surface != null)
+ WaylandCompositor.nativeVsync(System.nanoTime())
+ } catch (error: Throwable) {
+ failure = error
+ } finally {
+ complete.countDown()
+ }
+ }
+ check(complete.await(3, TimeUnit.SECONDS)) { "XR surface UI handshake timed out" }
+ failure?.let { throw it }
+ check(WaylandCompositor.nativeWaitSurface(3000)) { "XR surface GPU handshake timed out" }
+ }
+
+ fun onXrFrame(frameTimeNanos: Long): Long {
+ WaylandCompositor.nativeVsync(frameTimeNanos)
+ return (WaylandCompositor.nativePresentedFrames() - presentedBaseline).coerceAtLeast(0)
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt
index 9df4d4df..a68f804a 100644
--- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt
+++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt
@@ -71,6 +71,14 @@ object CompositorHost {
private var attached: Surface? = null
+ @Synchronized
+ fun attachXr(surface: Surface?) {
+ check(started) { "DroidDeck compositor is not running" }
+ pauseVsync()
+ attached = surface
+ WaylandCompositor.nativeSetSurface(surface)
+ }
+
@Synchronized
fun detach(surface: Surface?) {
if (!started) return
diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java
index 97cfcf3c..ba8d96d8 100644
--- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java
+++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java
@@ -148,6 +148,8 @@ public final class WaylandCompositor {
String nativeLibDir);
public static native void nativeSetSurface(Surface surface);
+ public static native boolean nativeWaitSurface(int timeoutMs);
+ public static native long nativePresentedFrames();
public static native void nativeSendPointer(int action, int x, int y);
diff --git a/docs/development/quest-openxr.md b/docs/development/quest-openxr.md
index 78b64172..cb35da1b 100644
--- a/docs/development/quest-openxr.md
+++ b/docs/development/quest-openxr.md
@@ -48,6 +48,35 @@ treating a successful `am start` as successful rendering.
## Remaining stages
+## Milestone 2: Android projection surface and guest integration
+
+The Android Surface producer test passed repeatedly, including 1,079 submitted
+stereo frames in a 15-second test at 72 Hz. The new stage-space test reports
+tracked floor-relative position, and the runtime supports monotonic-time
+conversion. A JNI abort during the first surface test was traced to treating
+the runtime's global Surface reference as a local reference and corrected.
+
+The same native frontend is now compiled into the main app behind the explicit
+`QuestXrActivity` entrypoint. With `guest_surface=true`, it hands its Android
+projection Surface to the existing compositor. Surface transitions wait for a
+compositor acknowledgement after the old Vulkan swapchain's GPU work drains.
+Successful guest GPU presentations are counted separately from XR submissions.
+The main app's Kotlin and native targets compile locally; an on-device main-app
+guest test is still required before considering this bridge functional.
+
+The frontend publishes a versioned 296-byte seqlock tracking record under the
+session runtime directory. It contains floor-relative head/controller poses,
+per-eye view geometry, predicted display time converted to CLOCK_MONOTONIC,
+display period, and Touch action values. The OpenVR driver consumes fresh
+records for real head/controller poses and asymmetric FOV; it marks stale
+tracking invalid. The modified ARM64 Linux driver links successfully in WSL.
+Driver timing, eye-to-head transforms, input completeness, render-pose/image
+association and return haptics still need implementation/validation.
+
+The companion's release build now targets Android 14 and passes release lint.
+
+## Remaining stages
+
- Android/Linux eye-image bridge and frame ownership/synchronization.
- OpenVR driver IPC for predicted 6DoF head, per-eye views and tracked hands.
- Real Touch input mapping and haptics returning through the bridge.
diff --git a/questxr/build.gradle b/questxr/build.gradle
index 3ec0e0e7..735f196e 100644
--- a/questxr/build.gradle
+++ b/questxr/build.gradle
@@ -9,7 +9,7 @@ android {
defaultConfig {
applicationId 'com.droiddeck.questxr'
minSdk 26
- targetSdk 32
+ targetSdk 34
versionCode 1
versionName '0.1.0'
ndk { abiFilters 'arm64-v8a' }
diff --git a/questxr/src/main/cpp/CMakeLists.txt b/questxr/src/main/cpp/CMakeLists.txt
index 1426d176..26f0685c 100644
--- a/questxr/src/main/cpp/CMakeLists.txt
+++ b/questxr/src/main/cpp/CMakeLists.txt
@@ -5,7 +5,7 @@ add_library(native_app_glue STATIC ${ANDROID_NDK}/sources/android/native_app_glu
target_include_directories(native_app_glue PUBLIC ${ANDROID_NDK}/sources/android/native_app_glue)
add_library(droiddeckxr SHARED quest_xr.cpp)
target_compile_features(droiddeckxr PRIVATE cxx_std_17)
-target_compile_definitions(droiddeckxr PRIVATE XR_USE_PLATFORM_ANDROID XR_USE_GRAPHICS_API_OPENGL_ES)
+target_compile_definitions(droiddeckxr PRIVATE XR_USE_PLATFORM_ANDROID XR_USE_GRAPHICS_API_OPENGL_ES XR_USE_TIMESPEC)
target_compile_options(droiddeckxr PRIVATE -Wall -Wextra -Wno-missing-field-initializers -Werror=return-type)
target_link_options(droiddeckxr PRIVATE -Wl,-z,max-page-size=16384 -uANativeActivity_onCreate)
target_link_libraries(droiddeckxr PRIVATE native_app_glue OpenXR::openxr_loader EGL GLESv3 android log)
diff --git a/questxr/src/main/cpp/quest_xr.cpp b/questxr/src/main/cpp/quest_xr.cpp
index e17364a6..de233178 100644
--- a/questxr/src/main/cpp/quest_xr.cpp
+++ b/questxr/src/main/cpp/quest_xr.cpp
@@ -2,7 +2,9 @@
#include
#include
#include
+#include
#include
+#include
#include
#include
#include
@@ -13,6 +15,14 @@
#include
#include
#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "../../../../tools/vr/src/xr_tracking.h"
static void check(XrResult result, const char *operation) {
if (XR_FAILED(result)) throw std::runtime_error(std::string(operation) + ": " + std::to_string(result));
@@ -103,12 +113,12 @@ public:
std::fprintf(file,
"{\"frames\":%llu,\"rendered\":%llu,\"sessionState\":%d,\"headFlags\":%llu,"
"\"headPosition\":[%.6f,%.6f,%.6f],\"controllersActive\":[%s,%s],"
- "\"periodNs\":%lld,\"surfaceSwapchain\":%s,\"error\":\"%s\"}\n",
+ "\"periodNs\":%lld,\"surfaceSwapchain\":%s,\"source\":\"%s\",\"guestFrames\":%lld,\"error\":\"%s\"}\n",
(unsigned long long)frames_, (unsigned long long)rendered_, state_,
(unsigned long long)head_.locationFlags,
head_.pose.position.x, head_.pose.position.y, head_.pose.position.z,
handActive_[0] ? "true" : "false", handActive_[1] ? "true" : "false",
- (long long)period_, surfaceSupported_ ? "true" : "false", error);
+ (long long)period_, surfaceSupported_ ? "true" : "false", guestSurface_ ? "guest" : surfaceTest_ ? "surface" : "native", (long long)guestFrames_, error);
std::fclose(file);
std::rename(temp.c_str(), path.c_str());
}
@@ -122,14 +132,28 @@ private:
XrSpace world_ = XR_NULL_HANDLE, view_ = XR_NULL_HANDLE;
XrActionSet actions_ = XR_NULL_HANDLE;
XrAction grip_ = XR_NULL_HANDLE, trigger_ = XR_NULL_HANDLE, vibration_ = XR_NULL_HANDLE;
+ XrAction squeeze_ = XR_NULL_HANDLE, stick_ = XR_NULL_HANDLE, stickClick_ = XR_NULL_HANDLE;
+ XrAction primary_ = XR_NULL_HANDLE, secondary_ = XR_NULL_HANDLE, menu_ = XR_NULL_HANDLE;
+ DroidDeckXrHand inputs_[2]{};
+ PFN_xrConvertTimeToTimespecTimeKHR convertTime_ = nullptr;
+ bool timespecSupported_ = false;
XrPath hands_[2]{};
XrSpace handSpaces_[2]{};
XrSpaceLocation head_{XR_TYPE_SPACE_LOCATION};
XrSpaceLocation handLocations_[2]{{XR_TYPE_SPACE_LOCATION}, {XR_TYPE_SPACE_LOCATION}};
bool handActive_[2]{}, pressed_[2]{};
bool running_ = false, exit_ = false, surfaceSupported_ = false;
+ bool surfaceTest_ = false;
+ bool guestSurface_ = false, guestAttached_ = false;
+ jmethodID surfaceCallback_ = nullptr, frameCallback_ = nullptr;
+ jobject androidJavaSurface_ = nullptr;
+ DroidDeckXrTracking *tracking_ = nullptr;
+ XrSwapchain androidSwapchain_ = XR_NULL_HANDLE;
+ ANativeWindow *androidWindow_ = nullptr;
+ EGLSurface androidSurface_ = EGL_NO_SURFACE;
XrSessionState state_ = XR_SESSION_STATE_UNKNOWN;
uint64_t frames_ = 0, rendered_ = 0;
+ int64_t guestFrames_ = 0;
XrDuration period_ = 0;
EGLDisplay display_ = EGL_NO_DISPLAY;
EGLContext context_ = EGL_NO_CONTEXT;
@@ -146,6 +170,24 @@ private:
void initialize() {
app_->activity->vm->AttachCurrentThread(&env_, nullptr);
+ jclass activityClass = env_->GetObjectClass(app_->activity->clazz);
+ jmethodID getIntent = env_->GetMethodID(activityClass, "getIntent", "()Landroid/content/Intent;");
+ jobject intent = env_->CallObjectMethod(app_->activity->clazz, getIntent);
+ jclass intentClass = env_->GetObjectClass(intent);
+ jmethodID getBoolean = env_->GetMethodID(intentClass, "getBooleanExtra", "(Ljava/lang/String;Z)Z");
+ jstring extra = env_->NewStringUTF("surface_test");
+ surfaceTest_ = env_->CallBooleanMethod(intent, getBoolean, extra, JNI_FALSE);
+ env_->DeleteLocalRef(extra);
+ extra = env_->NewStringUTF("guest_surface");
+ guestSurface_ = env_->CallBooleanMethod(intent, getBoolean, extra, JNI_FALSE);
+ if (guestSurface_) {
+ surfaceTest_ = true;
+ surfaceCallback_ = env_->GetMethodID(activityClass, "onXrSurface", "(Landroid/view/Surface;II)V");
+ frameCallback_ = env_->GetMethodID(activityClass, "onXrFrame", "(J)J");
+ if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("XR activity bridge methods unavailable"); }
+ }
+ env_->DeleteLocalRef(extra); env_->DeleteLocalRef(intentClass);
+ env_->DeleteLocalRef(intent); env_->DeleteLocalRef(activityClass);
PFN_xrInitializeLoaderKHR initializeLoader = nullptr;
check(xrGetInstanceProcAddr(XR_NULL_HANDLE, "xrInitializeLoaderKHR",
reinterpret_cast(&initializeLoader)), "loader init entrypoint");
@@ -160,8 +202,13 @@ private:
for (const auto &extension : extensions) {
if (!std::strcmp(extension.extensionName, XR_KHR_ANDROID_SURFACE_SWAPCHAIN_EXTENSION_NAME))
surfaceSupported_ = true;
+ if (!std::strcmp(extension.extensionName, XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME))
+ timespecSupported_ = true;
}
- const char *enabled[] = {XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME, XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME};
+ if (surfaceTest_ && !surfaceSupported_) throw std::runtime_error("Android surface swapchains unavailable");
+ std::vector enabled{XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME, XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME};
+ if (surfaceSupported_) enabled.push_back(XR_KHR_ANDROID_SURFACE_SWAPCHAIN_EXTENSION_NAME);
+ if (timespecSupported_) enabled.push_back(XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME);
XrInstanceCreateInfoAndroidKHR androidInfo{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
androidInfo.applicationVM = app_->activity->vm;
androidInfo.applicationActivity = app_->activity->clazz;
@@ -170,9 +217,11 @@ private:
std::strcpy(create.applicationInfo.applicationName, "DroidDeck XR");
create.applicationInfo.applicationVersion = 1;
create.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
- create.enabledExtensionCount = 2;
- create.enabledExtensionNames = enabled;
+ create.enabledExtensionCount = enabled.size();
+ create.enabledExtensionNames = enabled.data();
check(xrCreateInstance(&create, &instance_), "xrCreateInstance");
+ if (timespecSupported_) check(xrGetInstanceProcAddr(instance_, "xrConvertTimeToTimespecTimeKHR",
+ reinterpret_cast(&convertTime_)), "time conversion entrypoint");
XrSystemGetInfo systemInfo{XR_TYPE_SYSTEM_GET_INFO};
systemInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY;
check(xrGetSystem(instance_, &systemInfo, &system_), "xrGetSystem");
@@ -186,8 +235,8 @@ private:
throw std::runtime_error("Runtime does not support GLES 3.0");
display_ = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (!eglInitialize(display_, nullptr, nullptr)) throw std::runtime_error("eglInitialize failed");
- const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
- EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_NONE};
+ const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_SURFACE_TYPE, EGL_PBUFFER_BIT | EGL_WINDOW_BIT,
+ EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, 24, EGL_NONE};
EGLConfig config{};
EGLint found = 0;
if (!eglChooseConfig(display_, attributes, &config, 1, &found) || found != 1)
@@ -204,13 +253,21 @@ private:
check(xrCreateSession(instance_, &sessionInfo, &session_), "xrCreateSession");
XrReferenceSpaceCreateInfo reference{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
reference.poseInReferenceSpace.orientation.w = 1;
- reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
- check(xrCreateReferenceSpace(session_, &reference, &world_), "local space");
+ reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_STAGE;
+ XrResult referenceResult = xrCreateReferenceSpace(session_, &reference, &world_);
+ if (referenceResult == XR_ERROR_REFERENCE_SPACE_UNSUPPORTED) {
+ reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
+ reference.poseInReferenceSpace.position.y = -1.6f;
+ check(xrCreateReferenceSpace(session_, &reference, &world_), "local standing space");
+ } else check(referenceResult, "stage space");
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW;
+ reference.poseInReferenceSpace.position.y = 0;
check(xrCreateReferenceSpace(session_, &reference, &view_), "view space");
initializeActions();
initializeImages();
+ if (surfaceTest_) initializeAndroidSurface(config);
initializeGraphics();
+ initializeTracking();
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "initialized GLES; Android surface swapchain supported=%d", surfaceSupported_);
health();
}
@@ -228,14 +285,26 @@ private:
};
action("grip_pose", XR_ACTION_TYPE_POSE_INPUT, &grip_);
action("trigger", XR_ACTION_TYPE_FLOAT_INPUT, &trigger_);
+ action("squeeze", XR_ACTION_TYPE_FLOAT_INPUT, &squeeze_);
+ action("stick", XR_ACTION_TYPE_VECTOR2F_INPUT, &stick_);
+ action("stick_click", XR_ACTION_TYPE_BOOLEAN_INPUT, &stickClick_);
+ action("primary", XR_ACTION_TYPE_BOOLEAN_INPUT, &primary_);
+ action("secondary", XR_ACTION_TYPE_BOOLEAN_INPUT, &secondary_);
+ action("menu", XR_ACTION_TYPE_BOOLEAN_INPUT, &menu_);
action("vibration", XR_ACTION_TYPE_VIBRATION_OUTPUT, &vibration_);
std::vector bindings;
for (const char *hand : {"left", "right"}) {
std::string root = std::string("/user/hand/") + hand;
bindings.push_back({grip_, path((root + "/input/grip/pose").c_str())});
bindings.push_back({trigger_, path((root + "/input/trigger/value").c_str())});
+ bindings.push_back({squeeze_, path((root + "/input/squeeze/value").c_str())});
+ bindings.push_back({stick_, path((root + "/input/thumbstick").c_str())});
+ bindings.push_back({stickClick_, path((root + "/input/thumbstick/click").c_str())});
+ bindings.push_back({primary_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/x/click" : "/input/a/click")).c_str())});
+ bindings.push_back({secondary_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/y/click" : "/input/b/click")).c_str())});
bindings.push_back({vibration_, path((root + "/output/haptic").c_str())});
}
+ bindings.push_back({menu_, path("/user/hand/left/input/menu/click")});
XrInteractionProfileSuggestedBinding suggestion{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
suggestion.interactionProfile = path("/interaction_profiles/oculus/touch_controller");
suggestion.countSuggestedBindings = bindings.size(); suggestion.suggestedBindings = bindings.data();
@@ -268,6 +337,7 @@ private:
Eye &target = eyes_[eye];
target.width = views[eye].recommendedImageRectWidth;
target.height = views[eye].recommendedImageRectHeight;
+ if (surfaceTest_) continue;
XrSwapchainCreateInfo create{XR_TYPE_SWAPCHAIN_CREATE_INFO};
create.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT;
create.format = format; create.sampleCount = 1;
@@ -284,6 +354,94 @@ private:
}
}
+ void initializeAndroidSurface(EGLConfig config) {
+ PFN_xrCreateSwapchainAndroidSurfaceKHR createSurface = nullptr;
+ check(xrGetInstanceProcAddr(instance_, "xrCreateSwapchainAndroidSurfaceKHR",
+ reinterpret_cast(&createSurface)), "surface swapchain entrypoint");
+ if (eyes_[0].width != eyes_[1].width || eyes_[0].height != eyes_[1].height)
+ throw std::runtime_error("Surface path requires matching eye dimensions");
+ XrSwapchainCreateInfo info{XR_TYPE_SWAPCHAIN_CREATE_INFO};
+ info.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT;
+ info.width = eyes_[0].width * 2; info.height = eyes_[0].height;
+ jobject surface = nullptr;
+ check(createSurface(session_, &info, &androidSwapchain_, &surface), "Android surface swapchain");
+ androidWindow_ = ANativeWindow_fromSurface(env_, surface);
+ androidJavaSurface_ = env_->NewGlobalRef(surface);
+ if (env_->GetObjectRefType(surface) == JNILocalRefType) env_->DeleteLocalRef(surface);
+ if (!androidWindow_) throw std::runtime_error("Android swapchain has no native window");
+ if (!guestSurface_) {
+ androidSurface_ = eglCreateWindowSurface(display_, config, androidWindow_, nullptr);
+ if (androidSurface_ == EGL_NO_SURFACE) throw std::runtime_error("Android EGL window surface failed");
+ }
+ __android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "Android projection surface %ux%u", info.width, info.height);
+ }
+
+ void initializeTracking() {
+ std::string directory = std::string(app_->activity->internalDataPath) + "/.wayland-rt";
+ if (mkdir(directory.c_str(), 0700) != 0 && errno != EEXIST)
+ throw std::runtime_error("XR tracking directory unavailable");
+ std::string filename = directory + "/" DROIDDECK_XR_FILE;
+ int descriptor = open(filename.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0600);
+ if (descriptor < 0) throw std::runtime_error("XR tracking file unavailable");
+ if (ftruncate(descriptor, sizeof(DroidDeckXrTracking)) != 0) {
+ close(descriptor); throw std::runtime_error("XR tracking file size failed");
+ }
+ void *mapping = mmap(nullptr, sizeof(DroidDeckXrTracking), PROT_READ | PROT_WRITE, MAP_SHARED, descriptor, 0);
+ close(descriptor);
+ if (mapping == MAP_FAILED) throw std::runtime_error("XR tracking map failed");
+ tracking_ = static_cast(mapping);
+ }
+
+ static DroidDeckXrPose posePacket(const XrPosef &pose, XrSpaceLocationFlags flags) {
+ return {{pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w},
+ {pose.position.x, pose.position.y, pose.position.z}, (uint32_t)flags};
+ }
+
+ void publishTracking(const XrView views[2], XrTime predicted) {
+ if (!tracking_) return;
+ timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now);
+ DroidDeckXrTracking packet{};
+ packet.magic = DROIDDECK_XR_MAGIC; packet.version = DROIDDECK_XR_VERSION;
+ packet.active = state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED;
+ packet.sampled_ns = (int64_t)now.tv_sec * 1000000000LL + now.tv_nsec;
+ if (convertTime_) {
+ timespec displayTime{};
+ check(convertTime_(instance_, predicted, &displayTime), "display time conversion");
+ packet.predicted_ns = (int64_t)displayTime.tv_sec * 1000000000LL + displayTime.tv_nsec;
+ }
+ packet.period_ns = period_;
+ packet.head = posePacket(head_.pose, head_.locationFlags);
+ for (int eye = 0; eye < 2; eye++) {
+ packet.eyes[eye].pose = posePacket(views[eye].pose, head_.locationFlags);
+ packet.eyes[eye].fov[0] = views[eye].fov.angleLeft;
+ packet.eyes[eye].fov[1] = views[eye].fov.angleRight;
+ packet.eyes[eye].fov[2] = views[eye].fov.angleUp;
+ packet.eyes[eye].fov[3] = views[eye].fov.angleDown;
+ packet.eyes[eye].width = eyes_[eye].width; packet.eyes[eye].height = eyes_[eye].height;
+ packet.hands[eye] = inputs_[eye];
+ packet.hands[eye].pose = posePacket(handLocations_[eye].pose, handLocations_[eye].locationFlags);
+ packet.hands[eye].active = handActive_[eye];
+ }
+ uint32_t sequence = __atomic_load_n(&tracking_->seq, __ATOMIC_RELAXED);
+ __atomic_store_n(&tracking_->seq, sequence | 1u, __ATOMIC_SEQ_CST);
+ __atomic_thread_fence(__ATOMIC_SEQ_CST);
+ std::memcpy(tracking_, &packet, offsetof(DroidDeckXrTracking, seq));
+ std::memcpy(reinterpret_cast(tracking_) + offsetof(DroidDeckXrTracking, active),
+ reinterpret_cast(&packet) + offsetof(DroidDeckXrTracking, active),
+ sizeof(packet) - offsetof(DroidDeckXrTracking, active));
+ __atomic_store_n(&tracking_->seq, (sequence | 1u) + 1u, __ATOMIC_RELEASE);
+ }
+
+ void attachGuest(bool attach) {
+ if (!guestSurface_ || attach == guestAttached_) return;
+ env_->CallVoidMethod(app_->activity->clazz, surfaceCallback_, attach ? androidJavaSurface_ : nullptr,
+ eyes_[0].width * 2, eyes_[0].height);
+ if (env_->ExceptionCheck()) {
+ env_->ExceptionDescribe(); env_->ExceptionClear(); throw std::runtime_error("Guest surface handshake failed");
+ }
+ guestAttached_ = attach;
+ }
+
GLuint shader(GLenum type, const char *text) {
GLuint handle = glCreateShader(type);
glShaderSource(handle, 1, &text, nullptr); glCompileShader(handle);
@@ -331,23 +489,29 @@ private:
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, eye.depth);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
throw std::runtime_error("Incomplete eye framebuffer");
- glViewport(0, 0, eye.width, eye.height);
+ scene(view, eye.width, eye.height, 0);
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ if (glGetError() != GL_NO_ERROR) throw std::runtime_error("OpenGL render error");
+ glFlush();
+ }
+
+ void scene(const XrView &view, int width, int height, int offset) {
+ glViewport(offset, 0, width, height);
+ glEnable(GL_SCISSOR_TEST); glScissor(offset, 0, width, height);
glEnable(GL_DEPTH_TEST); glClearColor(0.015f, 0.025f, 0.06f, 1);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glUseProgram(program_); glBindVertexArray(vertexArray_);
Matrix camera = viewProjection(view);
XrPosef pose{}; pose.orientation.w = 1;
for (int column = -4; column <= 4; column++) {
- pose.position = {column * 0.6f, -1.2f, -3};
+ pose.position = {column * 0.6f, 0.2f, -3};
cube(camera, pose, 0.08f, 0.1f, 0.3f, 0.8f);
}
- pose.position = {0, 0, -2}; cube(camera, pose, 0.2f, 0.1f, 0.9f, 0.4f);
+ pose.position = {0, 1.4f, -2}; cube(camera, pose, 0.2f, 0.1f, 0.9f, 0.4f);
for (int hand = 0; hand < 2; hand++)
if (handActive_[hand] && (handLocations_[hand].locationFlags & XR_SPACE_LOCATION_POSITION_VALID_BIT))
cube(camera, handLocations_[hand].pose, 0.06f, hand == 0 ? 0.9f : 0.2f, 0.3f, hand == 1 ? 0.9f : 0.2f);
- glBindFramebuffer(GL_FRAMEBUFFER, 0);
- if (glGetError() != GL_NO_ERROR) throw std::runtime_error("OpenGL render error");
- glFlush();
+ glDisable(GL_SCISSOR_TEST);
}
void pollEvents() {
@@ -357,6 +521,7 @@ private:
if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
state_ = reinterpret_cast(&event)->state;
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "session state=%d", state_);
+ attachGuest(state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED);
if (state_ == XR_SESSION_STATE_READY) {
XrSessionBeginInfo begin{XR_TYPE_SESSION_BEGIN_INFO};
begin.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
@@ -388,6 +553,21 @@ private:
check(xrLocateSpace(handSpaces_[hand], world_, frameState.predictedDisplayTime, &handLocations_[hand]), "hand pose");
get.action = trigger_;
XrActionStateFloat trigger{XR_TYPE_ACTION_STATE_FLOAT}; check(xrGetActionStateFloat(session_, &get, &trigger), "trigger state");
+ inputs_[hand].trigger = trigger.isActive ? trigger.currentState : 0;
+ get.action = squeeze_;
+ XrActionStateFloat squeeze{XR_TYPE_ACTION_STATE_FLOAT}; check(xrGetActionStateFloat(session_, &get, &squeeze), "squeeze state");
+ inputs_[hand].squeeze = squeeze.isActive ? squeeze.currentState : 0;
+ get.action = stick_;
+ XrActionStateVector2f stick{XR_TYPE_ACTION_STATE_VECTOR2F}; check(xrGetActionStateVector2f(session_, &get, &stick), "stick state");
+ inputs_[hand].stick[0] = stick.isActive ? stick.currentState.x : 0;
+ inputs_[hand].stick[1] = stick.isActive ? stick.currentState.y : 0;
+ inputs_[hand].buttons = 0;
+ XrAction buttons[] = {stickClick_, primary_, secondary_, menu_};
+ for (int button = 0; button < 4; button++) {
+ get.action = buttons[button];
+ XrActionStateBoolean value{XR_TYPE_ACTION_STATE_BOOLEAN}; check(xrGetActionStateBoolean(session_, &get, &value), "button state");
+ if (value.isActive && value.currentState) inputs_[hand].buttons |= 1u << button;
+ }
bool pressed = trigger.isActive && trigger.currentState > 0.8f;
if (pressed && !pressed_[hand]) {
XrHapticActionInfo info{XR_TYPE_HAPTIC_ACTION_INFO}; info.action = vibration_; info.subactionPath = hands_[hand];
@@ -402,11 +582,30 @@ private:
XrViewState viewState{XR_TYPE_VIEW_STATE}; uint32_t count = 0;
XrView views[2]{{XR_TYPE_VIEW}, {XR_TYPE_VIEW}};
check(xrLocateViews(session_, &locate, &viewState, 2, &count, views), "xrLocateViews");
+ publishTracking(views, frameState.predictedDisplayTime);
+ if (guestSurface_ && guestAttached_) {
+ timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now);
+ guestFrames_ = env_->CallLongMethod(app_->activity->clazz, frameCallback_, (jlong)now.tv_sec * 1000000000LL + now.tv_nsec);
+ if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("Guest frame callback failed"); }
+ }
XrCompositionLayerProjectionView projection[2]{{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}, {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}};
- bool valid = frameState.shouldRender && count == 2 &&
+ bool valid = frameState.shouldRender && (!surfaceTest_ || state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED) && count == 2 &&
(viewState.viewStateFlags & (XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT))
== (XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT);
- if (valid) for (int eye = 0; eye < 2; eye++) {
+ if (valid && surfaceTest_) {
+ if (!guestSurface_ && !eglMakeCurrent(display_, androidSurface_, androidSurface_, context_))
+ throw std::runtime_error("Surface context binding failed");
+ glBindFramebuffer(GL_FRAMEBUFFER, 0);
+ for (int eye = 0; eye < 2; eye++) {
+ if (!guestSurface_) scene(views[eye], eyes_[eye].width, eyes_[eye].height, eye * eyes_[eye].width);
+ projection[eye].pose = views[eye].pose; projection[eye].fov = views[eye].fov;
+ projection[eye].subImage.swapchain = androidSwapchain_;
+ projection[eye].subImage.imageRect.offset = {eye * eyes_[eye].width, 0};
+ projection[eye].subImage.imageRect.extent = {eyes_[eye].width, eyes_[eye].height};
+ }
+ if (glGetError() != GL_NO_ERROR) throw std::runtime_error("Surface rendering error");
+ if (!guestSurface_ && !eglSwapBuffers(display_, androidSurface_)) throw std::runtime_error("Surface presentation failed");
+ } else if (valid) for (int eye = 0; eye < 2; eye++) {
uint32_t image = 0;
XrSwapchainImageAcquireInfo acquire{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
check(xrAcquireSwapchainImage(eyes_[eye].swapchain, &acquire, &image), "acquire image");
@@ -435,11 +634,25 @@ private:
}
void cleanup() {
+ try { attachGuest(false); } catch (const std::exception &error) {
+ __android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "cleanup: %s", error.what());
+ }
+ if (tracking_) {
+ __atomic_store_n(&tracking_->active, 0u, __ATOMIC_RELEASE);
+ munmap(tracking_, sizeof(DroidDeckXrTracking));
+ }
if (display_ != EGL_NO_DISPLAY && context_ != EGL_NO_CONTEXT) {
glDeleteFramebuffers(1, &framebuffer_); glDeleteProgram(program_);
glDeleteBuffers(1, &buffer_); glDeleteVertexArrays(1, &vertexArray_);
for (auto &eye : eyes_) glDeleteRenderbuffers(1, &eye.depth);
}
+ if (androidSurface_ != EGL_NO_SURFACE) {
+ eglMakeCurrent(display_, surface_, surface_, context_);
+ eglDestroySurface(display_, androidSurface_);
+ }
+ if (androidWindow_) ANativeWindow_release(androidWindow_);
+ if (androidSwapchain_) xrDestroySwapchain(androidSwapchain_);
+ if (androidJavaSurface_) env_->DeleteGlobalRef(androidJavaSurface_);
for (auto &eye : eyes_) if (eye.swapchain) xrDestroySwapchain(eye.swapchain);
for (auto space : handSpaces_) if (space) xrDestroySpace(space);
if (view_) xrDestroySpace(view_); if (world_) xrDestroySpace(world_);
diff --git a/tools/deploy_quest_xr.ps1 b/tools/deploy_quest_xr.ps1
index 28d8bca8..a69512c4 100644
--- a/tools/deploy_quest_xr.ps1
+++ b/tools/deploy_quest_xr.ps1
@@ -6,24 +6,24 @@ param(
[string]$BuildTools = '35.0.0'
)
$ErrorActionPreference = 'Stop'
+function Invoke-Checked([string]$Program, [string[]]$Arguments) {
+ $ErrorActionPreference = 'Continue'
+ & $Program @Arguments
+ if ($LASTEXITCODE -ne 0) { throw "$Program exited with $LASTEXITCODE" }
+}
$repo = Split-Path $PSScriptRoot -Parent
Push-Location $repo
try {
- & .\gradlew.bat :questxr:assembleDebug "-PndkVersion=$NdkVersion" '-PskipRust=true' --console=plain
- if ($LASTEXITCODE) { throw 'Quest XR build failed' }
+ Invoke-Checked .\gradlew.bat @(':questxr:assembleDebug', "-PndkVersion=$NdkVersion", '-PskipRust=true', '--console=plain')
$tools = Join-Path $Sdk "build-tools\$BuildTools"
$aligned = 'questxr\build\questxr-aligned.apk'
$signed = 'questxr\build\questxr-signed.apk'
- & "$tools\zipalign.exe" -f -p 4 questxr\build\outputs\apk\debug\questxr-debug.apk $aligned
- if ($LASTEXITCODE) { throw 'APK alignment failed' }
- & "$tools\apksigner.bat" sign --key "$KeyDirectory\droiddeck-release.pk8" --cert "$KeyDirectory\droiddeck-release.x509.pem" --out $signed $aligned
- if ($LASTEXITCODE) { throw 'APK signing failed' }
- $certificate = & "$tools\apksigner.bat" verify --print-certs $signed
- if ($LASTEXITCODE) { throw 'APK verification failed' }
+ Invoke-Checked "$tools\zipalign.exe" @('-f', '-p', '4', 'questxr\build\outputs\apk\debug\questxr-debug.apk', $aligned)
+ Invoke-Checked "$tools\apksigner.bat" @('sign', '--key', "$KeyDirectory\droiddeck-release.pk8", '--cert', "$KeyDirectory\droiddeck-release.x509.pem", '--out', $signed, $aligned)
+ $certificate = Invoke-Checked "$tools\apksigner.bat" @('verify', '--print-certs', $signed)
$expected = (Get-Content keystore\release-signer.sha256 -Raw).Trim()
if (-not ($certificate -match "certificate SHA-256 digest: $expected")) { throw 'Unexpected signing certificate' }
- & $Adb install --no-incremental -r $signed
- if ($LASTEXITCODE) { throw 'Quest XR deployment failed' }
+ Invoke-Checked $Adb @('install', '--no-incremental', '-r', $signed)
} finally {
Pop-Location
}
diff --git a/tools/test_quest_xr.py b/tools/test_quest_xr.py
index dbe42b10..a53afb66 100644
--- a/tools/test_quest_xr.py
+++ b/tools/test_quest_xr.py
@@ -15,6 +15,8 @@ def main():
parser.add_argument('--seconds', type=int, default=20)
parser.add_argument('--output', type=Path, default=Path('questxr-validation'))
parser.add_argument('--require-controllers', action='store_true')
+ parser.add_argument('--surface', action='store_true')
+ parser.add_argument('--guest', action='store_true')
args = parser.parse_args()
if args.seconds < 5 or args.seconds > 600:
parser.error('--seconds must be between 5 and 600')
@@ -25,12 +27,20 @@ def main():
return subprocess.run([args.adb, '-s', serial, *command], check=True,
capture_output=True, text=True).stdout
- health_path = '/sdcard/Android/data/com.droiddeck.questxr/files/xr-health.json'
- adb('shell', 'am', 'force-stop', 'com.droiddeck.questxr')
+ package = 'com.droiddeck.launcher' if args.guest else 'com.droiddeck.questxr'
+ health_path = f'/sdcard/Android/data/{package}/files/xr-health.json'
+ if not args.guest:
+ adb('shell', 'am', 'force-stop', package)
adb('shell', 'rm', '-f', health_path)
adb('shell', 'input', 'keyevent', 'KEYCODE_WAKEUP')
adb('shell', 'am', 'broadcast', '-a', 'com.oculus.vrpowermanager.prox_close')
- adb('shell', 'am', 'start', '-n', 'com.droiddeck.questxr/android.app.NativeActivity')
+ component = f'{package}/.QuestXrActivity' if args.guest else f'{package}/android.app.NativeActivity'
+ launch = ['shell', 'am', 'start', '-n', component]
+ if args.surface:
+ launch += ['--ez', 'surface_test', 'true']
+ if args.guest:
+ launch += ['--ez', 'guest_surface', 'true']
+ adb(*launch)
samples = []
failure = None
deadline = time.monotonic() + args.seconds
@@ -55,6 +65,10 @@ def main():
failure = failure or 'No focused session with valid position and orientation'
elif args.require_controllers and not any(all(sample['controllersActive']) for sample in samples):
failure = failure or 'Both controllers did not become active'
+ elif args.guest and samples[-1].get('guestFrames', 0) - samples[0].get('guestFrames', 0) < 72:
+ failure = failure or 'No advancing guest GPU presentation; submitting an XR layer alone is insufficient'
+ elif samples[-1].get('source') != ('guest' if args.guest else 'surface' if args.surface else 'native'):
+ failure = failure or 'Wrong projection source'
report = {'ok': failure is None, 'error': failure, 'samples': samples}
(args.output / 'report.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
print(json.dumps({'ok': report['ok'], 'error': failure, 'samples': len(samples),
diff --git a/tools/vr/src/driver_droiddeck.cpp b/tools/vr/src/driver_droiddeck.cpp
index 1397cc1c..09a9066a 100644
--- a/tools/vr/src/driver_droiddeck.cpp
+++ b/tools/vr/src/driver_droiddeck.cpp
@@ -17,6 +17,7 @@
#include "../../../app/src/main/cpp/waylandcomp/src/headpose_shm.h"
#include "presenter.h"
+#include "xr_tracking_file.h"
#define DD_EXPORT extern "C" __attribute__((visibility("default")))
@@ -372,6 +373,24 @@ public:
DriverPose_t GetPose() override { return pose_; }
+ void UpdateXr(const DroidDeckXrHand &hand) {
+ if (id_ == k_unTrackedDeviceIndexInvalid) return;
+ DriverPose_t pose{};
+ pose.qWorldFromDriverRotation.w = pose.qDriverFromHeadRotation.w = 1;
+ pose.qRotation = {hand.pose.orientation[3], hand.pose.orientation[0], hand.pose.orientation[1], hand.pose.orientation[2]};
+ for (int axis = 0; axis < 3; axis++) pose.vecPosition[axis] = hand.pose.position[axis];
+ pose.deviceIsConnected = hand.active;
+ pose.poseIsValid = hand.active && (hand.pose.flags & 3u) == 3u;
+ pose.result = pose.poseIsValid ? TrackingResult_Running_OK : TrackingResult_Running_OutOfRange;
+ pose_ = pose;
+ VRServerDriverHost()->TrackedDevicePoseUpdated(id_, pose, sizeof(pose));
+ VRDriverInput()->UpdateScalarComponent(trigger_, hand.active ? hand.trigger : 0, 0);
+ VRDriverInput()->UpdateBooleanComponent(triggerClick_, hand.active && hand.trigger > 0.6f, 0);
+ VRDriverInput()->UpdateBooleanComponent(grip_, hand.active && hand.squeeze > 0.6f, 0);
+ VRDriverInput()->UpdateScalarComponent(padX_, hand.active ? hand.stick[0] : 0, 0);
+ VRDriverInput()->UpdateScalarComponent(padY_, hand.active ? hand.stick[1] : 0, 0);
+ }
+
void Update(const HmdQuaternion_t &head, const PadState *pad) {
if (id_ == k_unTrackedDeviceIndexInvalid) return;
DriverPose_t p = {};
@@ -426,6 +445,12 @@ public:
if (height_ <= 0) height_ = 1080;
float hz = VRSettings()->GetFloat(kSettingsSection, "displayFrequency");
if (hz <= 0.0f) hz = 60.0f;
+ DroidDeckXrTracking xr;
+ xrMode_ = xr_.Read(&xr) && XrTrackingFile::Fresh(xr);
+ if (xrMode_) {
+ width_ = xr.eyes[0].width * 2; height_ = xr.eyes[0].height;
+ hz = 1000000000.f / xr.period_ns;
+ }
tanHalf_ = 1.0f;
eyeAspect_ = (width_ * 0.5f) / height_;
droiddeck_headpose hp;
@@ -433,7 +458,7 @@ public:
tanHalf_ = hp.tan_half_fov;
if (hp.eye_aspect > 0.0f) eyeAspect_ = hp.eye_aspect;
}
- VRProperties()->SetStringProperty(c, Prop_ModelNumber_String, "DroidDeck Cardboard");
+ VRProperties()->SetStringProperty(c, Prop_ModelNumber_String, xrMode_ ? "DroidDeck Quest OpenXR" : "DroidDeck Cardboard");
VRProperties()->SetStringProperty(c, Prop_ManufacturerName_String, "DroidDeck");
VRProperties()->SetStringProperty(c, Prop_TrackingSystemName_String, "droiddeck");
VRProperties()->SetFloatProperty(c, Prop_UserIpdMeters_Float, 0.064f);
@@ -488,6 +513,18 @@ public:
p.qWorldFromDriverRotation.w = 1;
p.qDriverFromHeadRotation.w = 1;
p.qRotation.w = 1;
+ DroidDeckXrTracking xr;
+ bool freshXr = xr_.Read(&xr) && XrTrackingFile::Fresh(xr);
+ if (freshXr || xrMode_) {
+ p.deviceIsConnected = true;
+ p.poseIsValid = freshXr && (xr.head.flags & 3u) == 3u;
+ p.result = p.poseIsValid ? TrackingResult_Running_OK : TrackingResult_Running_OutOfRange;
+ if (p.poseIsValid) {
+ p.qRotation = {xr.head.orientation[3], xr.head.orientation[0], xr.head.orientation[1], xr.head.orientation[2]};
+ for (int axis = 0; axis < 3; axis++) p.vecPosition[axis] = xr.head.position[axis];
+ }
+ return p;
+ }
p.vecPosition[1] = 1.6;
droiddeck_headpose hp;
bool ok = pose_.Read(&hp);
@@ -509,6 +546,7 @@ public:
bool IsDisplayOnDesktop() override { return !directMode_; }
bool IsDisplayRealDisplay() override { return false; }
void GetRecommendedRenderTargetSize(uint32_t *w, uint32_t *h) override {
+ if (xrMode_) { *w = width_ / 2; *h = height_; return; }
*w = (uint32_t)(width_ / 2 * 1.4f);
*h = (uint32_t)(height_ * 1.4f);
}
@@ -516,7 +554,14 @@ public:
*y = 0; *w = (uint32_t)width_ / 2; *h = (uint32_t)height_;
*x = eye == Eye_Left ? 0 : (uint32_t)width_ / 2;
}
- void GetProjectionRaw(EVREye, float *left, float *right, float *top, float *bottom) override {
+ void GetProjectionRaw(EVREye eye, float *left, float *right, float *top, float *bottom) override {
+ DroidDeckXrTracking xr;
+ if (xrMode_ && xr_.Read(&xr) && XrTrackingFile::Fresh(xr)) {
+ const float *fov = xr.eyes[eye == Eye_Right ? 1 : 0].fov;
+ *left = std::tan(fov[0]); *right = std::tan(fov[1]);
+ *top = -std::tan(fov[2]); *bottom = -std::tan(fov[3]);
+ return;
+ }
*left = -tanHalf_ * eyeAspect_; *right = tanHalf_ * eyeAspect_;
*top = -tanHalf_; *bottom = tanHalf_;
}
@@ -536,6 +581,19 @@ private:
uint32_t lastSeq = 0;
auto lastSent = std::chrono::steady_clock::now();
while (running_) {
+ DroidDeckXrTracking xr;
+ if (xrMode_) {
+ bool fresh = xr_.Read(&xr) && XrTrackingFile::Fresh(xr);
+ DriverPose_t head = GetPose();
+ VRServerDriverHost()->TrackedDevicePoseUpdated(id_, head, sizeof(head));
+ for (int hand = 0; hand < 2; hand++) if (hands_[hand]) {
+ DroidDeckXrHand state = fresh ? xr.hands[hand] : DroidDeckXrHand{};
+ hands_[hand]->UpdateXr(state);
+ }
+ VRDriverInput()->UpdateBooleanComponent(proximity_, fresh, 0);
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
+ continue;
+ }
droiddeck_headpose hp;
bool fresh = pose_.Read(&hp) && hp.seq != lastSeq;
auto now = std::chrono::steady_clock::now();
@@ -570,6 +628,8 @@ public:
private:
PoseFile pose_;
+ XrTrackingFile xr_;
+ bool xrMode_ = false;
PadRing pad_;
PadState lastPad_;
VRInputComponentHandle_t proximity_ = 0;
diff --git a/tools/vr/src/presenter.cpp b/tools/vr/src/presenter.cpp
index 2b0d446e..a5537940 100644
--- a/tools/vr/src/presenter.cpp
+++ b/tools/vr/src/presenter.cpp
@@ -9,6 +9,7 @@ typedef unsigned long VisualID;
#include "xdg-shell-client-protocol.h"
#include "presenter.h"
+#include "xr_tracking_file.h"
#include
#include
@@ -268,6 +269,11 @@ private:
if (pread(fd, eye, sizeof(eye), 72) == (ssize_t)sizeof(eye) && eye[0] && eye[1]) { width_ = eye[0] * 2; height_ = eye[1]; }
close(fd);
}
+ XrTrackingFile xrFile;
+ DroidDeckXrTracking tracking;
+ if (xrFile.Read(&tracking) && XrTrackingFile::Fresh(tracking)) {
+ width_ = tracking.eyes[0].width * 2; height_ = tracking.eyes[0].height;
+ }
if (!width_ && cfgW_ > 0 && cfgH_ > 0) { width_ = (uint32_t)cfgW_; height_ = (uint32_t)cfgH_; }
if (!width_) { width_ = 1920; height_ = 1080; }
Log("presenter: %ux%u fullscreen window on Wayland display %s (configured %s)", width_, height_, name, configured_ ? "yes" : "no");
diff --git a/tools/vr/src/xr_tracking.h b/tools/vr/src/xr_tracking.h
new file mode 100644
index 00000000..6258d32a
--- /dev/null
+++ b/tools/vr/src/xr_tracking.h
@@ -0,0 +1,34 @@
+#pragma once
+#include
+
+#define DROIDDECK_XR_MAGIC 0x52584444u
+#define DROIDDECK_XR_VERSION 1u
+#define DROIDDECK_XR_FILE "droiddeck-xr-tracking"
+
+struct DroidDeckXrPose {
+ float orientation[4];
+ float position[3];
+ uint32_t flags;
+};
+
+struct DroidDeckXrEye {
+ DroidDeckXrPose pose;
+ float fov[4];
+ uint32_t width, height;
+};
+
+struct DroidDeckXrHand {
+ DroidDeckXrPose pose;
+ float trigger, squeeze, stick[2];
+ uint32_t buttons, active;
+};
+
+struct DroidDeckXrTracking {
+ uint32_t magic, version, seq, active;
+ int64_t sampled_ns, predicted_ns, period_ns;
+ DroidDeckXrPose head;
+ DroidDeckXrEye eyes[2];
+ DroidDeckXrHand hands[2];
+};
+
+static_assert(sizeof(DroidDeckXrTracking) == 296, "XR tracking IPC layout");
diff --git a/tools/vr/src/xr_tracking_file.h b/tools/vr/src/xr_tracking_file.h
new file mode 100644
index 00000000..5295ee20
--- /dev/null
+++ b/tools/vr/src/xr_tracking_file.h
@@ -0,0 +1,51 @@
+#pragma once
+#include "xr_tracking.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+class XrTrackingFile {
+public:
+ ~XrTrackingFile() { if (mapping_) munmap(mapping_, sizeof(*mapping_)); }
+ bool Read(DroidDeckXrTracking *packet) {
+ std::lock_guard lock(mutex_);
+ if (!mapping_) {
+ int descriptor = open("/run/droiddeck/" DROIDDECK_XR_FILE, O_RDONLY | O_CLOEXEC);
+ if (descriptor < 0) return false;
+ struct stat status{};
+ if (fstat(descriptor, &status) != 0 || status.st_size != sizeof(*mapping_)) {
+ close(descriptor); return false;
+ }
+ void *mapping = mmap(nullptr, sizeof(*mapping_), PROT_READ, MAP_SHARED, descriptor, 0);
+ close(descriptor);
+ if (mapping == MAP_FAILED) return false;
+ mapping_ = static_cast(mapping);
+ }
+ for (int attempt = 0; attempt < 8; attempt++) {
+ uint32_t sequence = __atomic_load_n(&mapping_->seq, __ATOMIC_ACQUIRE);
+ if (sequence & 1u) continue;
+ std::memcpy(packet, mapping_, sizeof(*packet));
+ __atomic_thread_fence(__ATOMIC_ACQUIRE);
+ if (__atomic_load_n(&mapping_->seq, __ATOMIC_RELAXED) == sequence)
+ return packet->magic == DROIDDECK_XR_MAGIC && packet->version == DROIDDECK_XR_VERSION;
+ }
+ return false;
+ }
+ static int64_t Now() {
+ timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now);
+ return (int64_t)now.tv_sec * 1000000000LL + now.tv_nsec;
+ }
+ static bool Fresh(const DroidDeckXrTracking &packet) {
+ int64_t age = Now() - packet.sampled_ns;
+ return packet.active && age >= 0 && age < 500000000LL && packet.period_ns >= 1000000LL
+ && packet.period_ns <= 50000000LL && packet.eyes[0].width > 0 && packet.eyes[0].width <= 8192
+ && packet.eyes[0].height > 0 && packet.eyes[0].height <= 8192;
+ }
+private:
+ DroidDeckXrTracking *mapping_ = nullptr;
+ std::mutex mutex_;
+};