diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f9437987db..02d7265b05 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -271,7 +271,7 @@ android { srcDirs("src/modern/assets", "src/main/assets") } jniLibs { - srcDirs("src/modern/jniLibs") + setSrcDirs(listOf("src/modern/jniLibs", "src/modernXr/jniLibs")) } } getByName("debug") { @@ -283,6 +283,76 @@ android { ignoreFormatFailures = false } + val hostCanRunXrPayloadScripts = System.getProperty("os.name").startsWith("Windows") + + tasks.register("buildModernXrNative") { + enabled = hostCanRunXrPayloadScripts + commandLine( + "powershell", + "-ExecutionPolicy", + "Bypass", + "-File", + rootProject.file("tools/build-xr-native.ps1").absolutePath, + ) + } + + tasks.register("buildWindowsXrRuntime") { + enabled = hostCanRunXrPayloadScripts + commandLine( + "powershell", + "-ExecutionPolicy", + "Bypass", + "-File", + rootProject.file("tools/build-windows-xr-runtime.ps1").absolutePath, + ) + } + + tasks.register("stageWineXrBridge") { + enabled = hostCanRunXrPayloadScripts + dependsOn("buildModernXrNative") + val companion = providers.environmentVariable("GAMENATIVE_WINE_XR_BRIDGE") + doFirst { + check(companion.isPresent) { "GAMENATIVE_WINE_XR_BRIDGE must point to the ARM64X Wine builtin companion" } + } + commandLine( + "powershell", + "-ExecutionPolicy", + "Bypass", + "-File", + rootProject.file("tools/stage-wine-xr-bridge.ps1").absolutePath, + "-CompanionPath", + companion.getOrElse(""), + ) + } + + tasks.register("stageOpenComposite") { + enabled = hostCanRunXrPayloadScripts + commandLine( + "powershell", + "-ExecutionPolicy", + "Bypass", + "-File", + rootProject.file("tools/stage-opencomposite.ps1").absolutePath, + ) + } + + tasks.register("verifyModernXrPayload") { + enabled = hostCanRunXrPayloadScripts + dependsOn("buildModernXrNative", "buildWindowsXrRuntime", "stageWineXrBridge", "stageOpenComposite") + commandLine( + "powershell", + "-ExecutionPolicy", + "Bypass", + "-File", + rootProject.file("tools/verify-xr-payload.ps1").absolutePath, + ) + } + + tasks.register("prepareModernXrPayload") { + dependsOn("verifyModernXrPayload") + } + + // externalNativeBuild { // cmake { // path = file("src/main/cpp/asurfacerenderer/CMakeLists.txt") diff --git a/app/src/legacyXr/AndroidManifest.xml b/app/src/legacyXr/AndroidManifest.xml index ed1859cfb2..bd4f097b73 100644 --- a/app/src/legacyXr/AndroidManifest.xml +++ b/app/src/legacyXr/AndroidManifest.xml @@ -75,6 +75,9 @@ + diff --git a/app/src/legacyXr/assets/gamenative_openxr_runtime32.dll b/app/src/legacyXr/assets/gamenative_openxr_runtime32.dll new file mode 100755 index 0000000000..985e44faef Binary files /dev/null and b/app/src/legacyXr/assets/gamenative_openxr_runtime32.dll differ diff --git a/app/src/legacyXr/assets/gamenative_openxr_runtime64.dll b/app/src/legacyXr/assets/gamenative_openxr_runtime64.dll new file mode 100755 index 0000000000..6e0e3cfc26 Binary files /dev/null and b/app/src/legacyXr/assets/gamenative_openxr_runtime64.dll differ diff --git a/app/src/legacyXr/assets/gamenative_xr_unixbridge.dll b/app/src/legacyXr/assets/gamenative_xr_unixbridge.dll new file mode 100755 index 0000000000..b8da8b4f9c Binary files /dev/null and b/app/src/legacyXr/assets/gamenative_xr_unixbridge.dll differ diff --git a/app/src/legacyXr/assets/gamenative_xr_unixbridge.so b/app/src/legacyXr/assets/gamenative_xr_unixbridge.so new file mode 100755 index 0000000000..991a5dd1ba Binary files /dev/null and b/app/src/legacyXr/assets/gamenative_xr_unixbridge.so differ diff --git a/app/src/legacyXr/assets/gamenative_xr_unixbridge.wine-commit b/app/src/legacyXr/assets/gamenative_xr_unixbridge.wine-commit new file mode 100644 index 0000000000..0c13c2e787 --- /dev/null +++ b/app/src/legacyXr/assets/gamenative_xr_unixbridge.wine-commit @@ -0,0 +1 @@ +77eed5501e077f5f0422d063b171a3a4491f52a6 diff --git a/app/src/legacyXr/assets/gamenative_xr_unixbridge32.dll b/app/src/legacyXr/assets/gamenative_xr_unixbridge32.dll new file mode 100644 index 0000000000..54762881b3 Binary files /dev/null and b/app/src/legacyXr/assets/gamenative_xr_unixbridge32.dll differ diff --git a/app/src/legacyXr/assets/opencomposite_x64.dll b/app/src/legacyXr/assets/opencomposite_x64.dll new file mode 100644 index 0000000000..8ce58c88f9 Binary files /dev/null and b/app/src/legacyXr/assets/opencomposite_x64.dll differ diff --git a/app/src/legacyXr/assets/payload.version b/app/src/legacyXr/assets/payload.version new file mode 100644 index 0000000000..a3c2f6ec94 --- /dev/null +++ b/app/src/legacyXr/assets/payload.version @@ -0,0 +1 @@ +3 fb4792ecc21725bec26e3e1754af6c954b39e0f641192d257e578629dea9c92c eb77fcd88b7a93c1fd28e8aaa05b22d0d454a11ebf488743cb4008a058d93e8e \ No newline at end of file diff --git a/app/src/legacyXr/jniLibs/arm64-v8a/libxrimmersive.so b/app/src/legacyXr/jniLibs/arm64-v8a/libxrimmersive.so index f92c95c98e..50e74975df 100755 Binary files a/app/src/legacyXr/jniLibs/arm64-v8a/libxrimmersive.so and b/app/src/legacyXr/jniLibs/arm64-v8a/libxrimmersive.so differ diff --git a/app/src/main/cpp/xrimmersive/CMakeLists.txt b/app/src/main/cpp/xrimmersive/CMakeLists.txt index ec6e3b0ea8..973e8399ac 100644 --- a/app/src/main/cpp/xrimmersive/CMakeLists.txt +++ b/app/src/main/cpp/xrimmersive/CMakeLists.txt @@ -17,6 +17,8 @@ project(xrimmersive) add_library(xrimmersive SHARED xr_immersive.cpp jni_bridge.cpp + xr_windows_projection.cpp + xr_windows_transport.cpp ) find_package(OpenXR REQUIRED CONFIG) @@ -30,4 +32,11 @@ target_link_libraries(xrimmersive log ) +target_compile_definitions(xrimmersive PRIVATE + XR_USE_PLATFORM_ANDROID=1 + XR_USE_GRAPHICS_API_OPENGL_ES=1 + EGL_EGLEXT_PROTOTYPES=1 + GL_GLEXT_PROTOTYPES=1 +) + target_compile_options(xrimmersive PRIVATE -Wall -Wextra -Werror=return-type -Wno-missing-field-initializers) diff --git a/app/src/main/cpp/xrimmersive/jni_bridge.cpp b/app/src/main/cpp/xrimmersive/jni_bridge.cpp index 4834fe5234..e9e430c887 100644 --- a/app/src/main/cpp/xrimmersive/jni_bridge.cpp +++ b/app/src/main/cpp/xrimmersive/jni_bridge.cpp @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include @@ -13,13 +15,18 @@ namespace { struct NativeHandle { xrimmersive::XrImmersiveSession *session; jobject activityGlobalRef; + std::atomic waiters{0}; }; // Lock ordering: gHandleMutex is the outermost lock — it may be held while calling into // XrImmersiveSession (which takes its own inner mutexes), never the other way round, and it is // never held across XrImmersiveSession::join() or AndroidBitmap_lockPixels/unlockPixels. Every -// entry point must hold it for the WHOLE call into the session, not just the lookup. +// entry point must hold it for the WHOLE call into the session, not just the lookup. The one +// exception is nativeWaitWindowsFrame, which pins the handle with NativeHandle::waiters instead so +// the (up to 1 s) wait does not block every other entry point; nativeJoinAndDestroy waits for +// waiters to drain before freeing the session. std::mutex gHandleMutex; +std::condition_variable gHandleCondition; std::unordered_set gLiveHandles; NativeHandle *LiveHandle(jlong handlePtr) { @@ -68,8 +75,10 @@ Java_app_gamenative_ui_screen_xr_XrNative_nativeJoinAndDestroy(JNIEnv *env, jcla { // Retiring the handle under the lock means every in-flight caller has already left its // critical section, and no new one can enter, before anything is destroyed. - std::lock_guard lock(gHandleMutex); + std::unique_lock lock(gHandleMutex); if (gLiveHandles.erase(handle) == 0) return; + handle->session->requestStop(); + gHandleCondition.wait(lock, [handle] { return handle->waiters.load() == 0; }); } handle->session->join(); delete handle->session; @@ -127,6 +136,125 @@ Java_app_gamenative_ui_screen_xr_XrNative_nativePollSnapshot(JNIEnv *env, jclass return snapshot.quickMenuClicked ? JNI_TRUE : JNI_FALSE; } +JNIEXPORT jboolean JNICALL +Java_app_gamenative_ui_screen_xr_XrNative_nativeWaitWindowsFrame( + JNIEnv *env, jclass, jlong handlePtr, jlong afterSerial, jint timeoutMs, + jlongArray outTiming, jfloatArray outViews, jfloatArray outInput, jintArray outFlags) { + if (env->GetArrayLength(outTiming) < 12 || env->GetArrayLength(outViews) < 22 || + env->GetArrayLength(outInput) < 36 || env->GetArrayLength(outFlags) < 3) return JNI_FALSE; + xrimmersive::WindowsRuntimeSnapshot snapshot; + xrimmersive::XrImmersiveSession *session = nullptr; + NativeHandle *handle = nullptr; + { + std::lock_guard lock(gHandleMutex); + handle = LiveHandle(handlePtr); + if (handle == nullptr) return JNI_FALSE; + session = handle->session; + handle->waiters.fetch_add(1); + } + const bool got = session->waitWindowsRuntimeSnapshot( + static_cast(afterSerial), static_cast(timeoutMs), &snapshot); + { + std::lock_guard lock(gHandleMutex); + handle->waiters.fetch_sub(1); + } + gHandleCondition.notify_all(); + if (!got) return JNI_FALSE; + const jlong timing[12] = { + static_cast(snapshot.frameSerial), + static_cast(snapshot.predictedDisplayTime), + static_cast(snapshot.predictedDisplayPeriod), + static_cast(snapshot.sessionState), + static_cast(snapshot.shouldRender ? 1 : 0), + static_cast(snapshot.recommendedWidth), + static_cast(snapshot.recommendedHeight), + static_cast(snapshot.stageAvailable ? 1 : 0), + static_cast(snapshot.stageBounds.width * 1000000.0f), + static_cast(snapshot.stageBounds.height * 1000000.0f), + static_cast(snapshot.stageSpaceActive ? 1 : 0), + static_cast(snapshot.recenterSerial), + }; + env->SetLongArrayRegion(outTiming, 0, 12, timing); + jfloat views[22]{}; + for (size_t eye = 0; eye < 2; ++eye) { + const XrView &view = snapshot.views[eye]; + const size_t base = eye * 11; + views[base] = view.pose.orientation.x; + views[base + 1] = view.pose.orientation.y; + views[base + 2] = view.pose.orientation.z; + views[base + 3] = view.pose.orientation.w; + views[base + 4] = view.pose.position.x; + views[base + 5] = view.pose.position.y; + views[base + 6] = view.pose.position.z; + views[base + 7] = view.fov.angleLeft; + views[base + 8] = view.fov.angleRight; + views[base + 9] = view.fov.angleUp; + views[base + 10] = view.fov.angleDown; + } + env->SetFloatArrayRegion(outViews, 0, 22, views); + const auto &input = snapshot.input; + jfloat inputValues[36]{}; + for (size_t hand = 0; hand < 2; ++hand) { + const size_t base = hand * 18; + const XrPosef &grip = input.gripPoses[hand]; + const XrPosef &aim = input.aimPoses[hand]; + inputValues[base] = hand == 0 ? input.triggerL : input.triggerR; + inputValues[base + 1] = hand == 0 ? input.squeezeL : input.squeezeR; + inputValues[base + 2] = hand == 0 ? input.leftX : input.rightX; + inputValues[base + 3] = -(hand == 0 ? input.leftY : input.rightY); + inputValues[base + 4] = grip.orientation.x; + inputValues[base + 5] = grip.orientation.y; + inputValues[base + 6] = grip.orientation.z; + inputValues[base + 7] = grip.orientation.w; + inputValues[base + 8] = grip.position.x; + inputValues[base + 9] = grip.position.y; + inputValues[base + 10] = grip.position.z; + inputValues[base + 11] = aim.orientation.x; + inputValues[base + 12] = aim.orientation.y; + inputValues[base + 13] = aim.orientation.z; + inputValues[base + 14] = aim.orientation.w; + inputValues[base + 15] = aim.position.x; + inputValues[base + 16] = aim.position.y; + inputValues[base + 17] = aim.position.z; + } + env->SetFloatArrayRegion(outInput, 0, 36, inputValues); + const jint flags[3] = { + static_cast(snapshot.viewStateFlags), + static_cast(input.buttons), + static_cast((input.aimPoseValid[0] && input.gripPoseValid[0] ? 1 : 0) | + (input.aimPoseValid[1] && input.gripPoseValid[1] ? 2 : 0)), + }; + env->SetIntArrayRegion(outFlags, 0, 3, flags); + return JNI_TRUE; +} + +JNIEXPORT jboolean JNICALL +Java_app_gamenative_ui_screen_xr_XrNative_nativeIsWindowsStereoActive( + JNIEnv *, jclass, jlong handlePtr) { + std::lock_guard lock(gHandleMutex); + auto *handle = LiveHandle(handlePtr); + return handle != nullptr && handle->session->windowsStereoActive() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL +Java_app_gamenative_ui_screen_xr_XrNative_nativeApplyWindowsHaptic( + JNIEnv *, jclass, jlong handlePtr, jint hand, jfloat amplitude, jlong duration, + jfloat frequency) { + std::lock_guard lock(gHandleMutex); + auto *handle = LiveHandle(handlePtr); + return handle != nullptr && handle->session->applyWindowsHaptic( + static_cast(hand), amplitude, static_cast(duration), frequency) + ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT void JNICALL +Java_app_gamenative_ui_screen_xr_XrNative_nativeSetWindowsOverlayVisible( + JNIEnv *, jclass, jlong handlePtr, jboolean visible) { + std::lock_guard lock(gHandleMutex); + auto *handle = LiveHandle(handlePtr); + if (handle != nullptr) handle->session->setWindowsOverlayVisible(visible == JNI_TRUE); +} + // Called from ImmersiveXrActivity's PixelCopy capture loop with the game's actual rendered // frame (ARGB_8888 bitmap). Copies the pixels into the session's pending-frame buffer; the // render thread uploads them to the GPU and draws them into the quad layer on its own. diff --git a/app/src/main/cpp/xrimmersive/xr_immersive.cpp b/app/src/main/cpp/xrimmersive/xr_immersive.cpp index 0489704b5a..19c5e1a1fe 100644 --- a/app/src/main/cpp/xrimmersive/xr_immersive.cpp +++ b/app/src/main/cpp/xrimmersive/xr_immersive.cpp @@ -60,6 +60,7 @@ bool XrImmersiveSession::initialize(JavaVM *vm, jobject activityRef) { void XrImmersiveSession::requestStop() { stopRequested_.store(true); + windowsSnapshotCondition_.notify_all(); } void XrImmersiveSession::join() { @@ -77,6 +78,41 @@ InputSnapshot XrImmersiveSession::pollSnapshot() { return result; } +bool XrImmersiveSession::waitWindowsRuntimeSnapshot(uint64_t afterSerial, uint32_t timeoutMs, + WindowsRuntimeSnapshot *snapshot) { + if (snapshot == nullptr) return false; + std::unique_lock lock(windowsSnapshotMutex_); + windowsSnapshotCondition_.wait_for( + lock, std::chrono::milliseconds(timeoutMs), + [this, afterSerial] { return windowsSnapshot_.frameSerial > afterSerial || stopRequested_.load(); }); + if (windowsSnapshot_.frameSerial <= afterSerial) return false; + *snapshot = windowsSnapshot_; + return true; +} + +bool XrImmersiveSession::windowsStereoActive() const { + return stereoActive_.load(); +} + +bool XrImmersiveSession::applyWindowsHaptic(uint32_t hand, float amplitude, XrDuration duration, + float frequency) { + std::lock_guard lock(sessionMutex_); + if (hand > 1 || session_ == XR_NULL_HANDLE || hapticAction_ == XR_NULL_HANDLE) return false; + XrHapticActionInfo actionInfo{XR_TYPE_HAPTIC_ACTION_INFO}; + actionInfo.action = hapticAction_; + actionInfo.subactionPath = handPaths_[hand]; + XrHapticVibration vibration{XR_TYPE_HAPTIC_VIBRATION}; + vibration.amplitude = std::clamp(amplitude, 0.0f, 1.0f); + vibration.duration = duration; + vibration.frequency = frequency; + return XR_SUCCEEDED(xrApplyHapticFeedback( + session_, &actionInfo, reinterpret_cast(&vibration))); +} + +void XrImmersiveSession::setWindowsOverlayVisible(bool visible) { + windowsOverlayVisible_.store(visible); +} + void XrImmersiveSession::submitFrame(const uint8_t *rgbaPixels, int32_t width, int32_t height, int32_t strideBytes) { if (width <= 0 || height <= 0) return; @@ -212,10 +248,45 @@ void XrImmersiveSession::runLoop() { applyPendingPassthroughState(); syncControllerInputs(frameState.predictedDisplayTime); + WindowsRuntimeSnapshot runtimeSnapshot; + { + std::lock_guard lock(windowsSnapshotMutex_); + runtimeSnapshot = windowsSnapshot_; + } + runtimeSnapshot.frameSerial += 1; + runtimeSnapshot.predictedDisplayTime = frameState.predictedDisplayTime; + runtimeSnapshot.predictedDisplayPeriod = frameState.predictedDisplayPeriod; + runtimeSnapshot.sessionState = sessionState_; + runtimeSnapshot.shouldRender = frameState.shouldRender == XR_TRUE; + runtimeSnapshot.recenterSerial = recenterSerial_.load(); + XrViewLocateInfo viewLocateInfo{XR_TYPE_VIEW_LOCATE_INFO}; + viewLocateInfo.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + viewLocateInfo.displayTime = frameState.predictedDisplayTime; + viewLocateInfo.space = windowsTrackingSpace_; + XrViewState viewState{XR_TYPE_VIEW_STATE}; + uint32_t viewCount = 0; + if (XR_SUCCEEDED(xrLocateViews(session_, &viewLocateInfo, &viewState, 2, &viewCount, + runtimeSnapshot.views.data())) && viewCount == 2) { + runtimeSnapshot.viewStateFlags = viewState.viewStateFlags; + } else { + runtimeSnapshot.viewStateFlags = 0; + } + { + std::lock_guard lock(snapshotMutex_); + runtimeSnapshot.input = snapshot_; + } + syncWindowsTrackingPoses(&runtimeSnapshot.input, frameState.predictedDisplayTime); + { + std::lock_guard lock(windowsSnapshotMutex_); + windowsSnapshot_ = runtimeSnapshot; + } + windowsSnapshotCondition_.notify_all(); + // Every xrBeginFrame must be matched by an xrEndFrame, but a layer may only reference a // swapchain image that was actually acquired — so a failed renderFrame() still ends the // frame, just with no layers. - if (frameState.shouldRender && renderFrame()) { + if (frameState.shouldRender && submitWindowsProjection(frameState.predictedDisplayTime)) { + } else if (frameState.shouldRender && renderFrame()) { submitQuadLayer(frameState.predictedDisplayTime, localSpace_, swapchain_, swapchainWidth_, swapchainHeight_, true); } else { @@ -273,6 +344,11 @@ bool XrImmersiveSession::setupInstanceAndSession() { if (threadSettingsExtensionAvailable_) extensions.push_back(XR_KHR_ANDROID_THREAD_SETTINGS_EXTENSION_NAME); refreshRateExtensionAvailable_ = IsInstanceExtensionSupported(XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME); if (refreshRateExtensionAvailable_) extensions.push_back(XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME); + localFloorExtensionAvailable_ = IsInstanceExtensionSupported(XR_EXT_LOCAL_FLOOR_EXTENSION_NAME); + if (localFloorExtensionAvailable_) extensions.push_back(XR_EXT_LOCAL_FLOOR_EXTENSION_NAME); + const char *picoControllerExtension = "XR_BD_controller_interaction"; + const bool picoControllerExtensionAvailable = IsInstanceExtensionSupported(picoControllerExtension); + if (picoControllerExtensionAvailable) extensions.push_back(picoControllerExtension); XrInstanceCreateInfoAndroidKHR androidInfo{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR}; androidInfo.applicationVM = vm_; @@ -285,7 +361,7 @@ bool XrImmersiveSession::setupInstanceAndSession() { createInfo.applicationInfo.applicationVersion = 1; std::strncpy(createInfo.applicationInfo.engineName, "GameNative", XR_MAX_ENGINE_NAME_SIZE - 1); createInfo.applicationInfo.engineVersion = 1; - createInfo.applicationInfo.apiVersion = XR_CURRENT_API_VERSION; + createInfo.applicationInfo.apiVersion = XR_API_VERSION_1_0; createInfo.enabledExtensionCount = static_cast(extensions.size()); createInfo.enabledExtensionNames = extensions.data(); @@ -356,9 +432,14 @@ bool XrImmersiveSession::setupInstanceAndSession() { XrSessionCreateInfo sessionCreateInfo{XR_TYPE_SESSION_CREATE_INFO}; sessionCreateInfo.next = &graphicsBinding; sessionCreateInfo.systemId = systemId_; - if (!XrCheck(xrCreateSession(instance_, &sessionCreateInfo, &session_), "xrCreateSession")) { + XrSession createdSession = XR_NULL_HANDLE; + if (!XrCheck(xrCreateSession(instance_, &sessionCreateInfo, &createdSession), "xrCreateSession")) { return false; } + { + std::lock_guard lock(sessionMutex_); + session_ = createdSession; + } // Perf levels: request BOOST for both CPU and GPU domains — this game is running Box64 // (x86 emulation) + Wine + our own screen-scraping capture on top of everything else, so @@ -429,6 +510,28 @@ bool XrImmersiveSession::setupInstanceAndSession() { "xrCreateReferenceSpace")) { return false; } + windowsTrackingSpace_ = localSpace_; + spaceCreateInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_STAGE; + const XrResult stageSpaceResult = xrCreateReferenceSpace(session_, &spaceCreateInfo, &stageSpace_); + if (stageSpaceResult != XR_SUCCESS) stageSpace_ = XR_NULL_HANDLE; + if (localFloorExtensionAvailable_) { + spaceCreateInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL_FLOOR_EXT; + const XrResult floorResult = xrCreateReferenceSpace(session_, &spaceCreateInfo, &localFloorSpace_); + if (floorResult != XR_SUCCESS) localFloorSpace_ = XR_NULL_HANDLE; + } + windowsTrackingSpaceType_ = XR_REFERENCE_SPACE_TYPE_LOCAL; + if (localFloorSpace_ != XR_NULL_HANDLE) { + windowsTrackingSpace_ = localFloorSpace_; + windowsTrackingSpaceType_ = XR_REFERENCE_SPACE_TYPE_LOCAL_FLOOR_EXT; + LOGI("Windows VR tracking space: LOCAL_FLOOR (recenter follows the headset)"); + } else if (stageSpace_ != XR_NULL_HANDLE) { + windowsTrackingSpace_ = stageSpace_; + windowsTrackingSpaceType_ = XR_REFERENCE_SPACE_TYPE_STAGE; + LOGI("Windows VR tracking space: STAGE"); + } else { + LOGI("Windows VR tracking space: LOCAL fallback (xrCreateReferenceSpace STAGE=%d)", + static_cast(stageSpaceResult)); + } uint32_t formatCount = 0; xrEnumerateSwapchainFormats(session_, 0, &formatCount, nullptr); @@ -477,6 +580,41 @@ bool XrImmersiveSession::setupInstanceAndSession() { glGenFramebuffers(1, &framebuffer_); + uint32_t projectionViewCount = 0; + xrEnumerateViewConfigurationViews(instance_, systemId_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, + 0, &projectionViewCount, nullptr); + std::vector projectionViews( + projectionViewCount, {XR_TYPE_VIEW_CONFIGURATION_VIEW}); + if (projectionViewCount >= 2 && XR_SUCCEEDED(xrEnumerateViewConfigurationViews( + instance_, systemId_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, + projectionViewCount, &projectionViewCount, projectionViews.data()))) { + { + std::lock_guard lock(windowsSnapshotMutex_); + windowsSnapshot_.recommendedWidth = projectionViews[0].recommendedImageRectWidth; + windowsSnapshot_.recommendedHeight = projectionViews[0].recommendedImageRectHeight; + const XrResult boundsResult = xrGetReferenceSpaceBoundsRect( + session_, XR_REFERENCE_SPACE_TYPE_STAGE, &windowsSnapshot_.stageBounds); + windowsSnapshot_.stageSpaceActive = stageSpace_ != XR_NULL_HANDLE; + windowsSnapshot_.stageAvailable = stageSpace_ != XR_NULL_HANDLE && + boundsResult == XR_SUCCESS && + windowsSnapshot_.stageBounds.width > 0.0f && + windowsSnapshot_.stageBounds.height > 0.0f; + if (!windowsSnapshot_.stageAvailable) windowsSnapshot_.stageBounds = {0.0f, 0.0f}; + LOGI("Windows VR stage: active=%d bounds=%d %.3fx%.3f result=%d", + windowsSnapshot_.stageSpaceActive ? 1 : 0, + windowsSnapshot_.stageAvailable ? 1 : 0, + windowsSnapshot_.stageBounds.width, + windowsSnapshot_.stageBounds.height, + static_cast(boundsResult)); + } + windowsProjectionReady_ = windowsProjection_.initialize( + session_, chosenFormat, + projectionViews[0].recommendedImageRectWidth, + projectionViews[0].recommendedImageRectHeight, + eglDisplay_); + } + windowsTransport_.start("@gamenative-xr"); + // --- Input: action set + Meta Quest Touch controller bindings. --- XrActionSetCreateInfo actionSetInfo{XR_TYPE_ACTION_SET_CREATE_INFO}; std::strncpy(actionSetInfo.actionSetName, "gameplay", XR_MAX_ACTION_SET_NAME_SIZE - 1); @@ -510,6 +648,8 @@ bool XrImmersiveSession::setupInstanceAndSession() { createAction("menu_left", XR_ACTION_TYPE_BOOLEAN_INPUT, &menuLAction_); createAction("aim_pose_left", XR_ACTION_TYPE_POSE_INPUT, &aimPoseLeftAction_); createAction("aim_pose_right", XR_ACTION_TYPE_POSE_INPUT, &aimPoseRightAction_); + createAction("grip_pose_left", XR_ACTION_TYPE_POSE_INPUT, &gripPoseLeftAction_); + createAction("grip_pose_right", XR_ACTION_TYPE_POSE_INPUT, &gripPoseRightAction_); auto path = [this](const char *p) { XrPath result = XR_NULL_PATH; @@ -517,6 +657,21 @@ bool XrImmersiveSession::setupInstanceAndSession() { return result; }; + handPaths_[0] = path("/user/hand/left"); + handPaths_[1] = path("/user/hand/right"); + XrActionCreateInfo hapticInfo{XR_TYPE_ACTION_CREATE_INFO}; + std::strncpy(hapticInfo.actionName, "haptic", XR_MAX_ACTION_NAME_SIZE - 1); + std::strncpy(hapticInfo.localizedActionName, "Haptic", XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1); + hapticInfo.actionType = XR_ACTION_TYPE_VIBRATION_OUTPUT; + hapticInfo.countSubactionPaths = 2; + hapticInfo.subactionPaths = handPaths_; + XrAction hapticAction = XR_NULL_HANDLE; + XrCheck(xrCreateAction(actionSet_, &hapticInfo, &hapticAction), "haptic"); + { + std::lock_guard lock(sessionMutex_); + hapticAction_ = hapticAction; + } + std::vector bindings = { {buttonXAction_, path("/user/hand/left/input/x/click")}, {buttonYAction_, path("/user/hand/left/input/y/click")}, @@ -533,14 +688,53 @@ bool XrImmersiveSession::setupInstanceAndSession() { {menuLAction_, path("/user/hand/left/input/menu/click")}, {aimPoseLeftAction_, path("/user/hand/left/input/aim/pose")}, {aimPoseRightAction_, path("/user/hand/right/input/aim/pose")}, + {gripPoseLeftAction_, path("/user/hand/left/input/grip/pose")}, + {gripPoseRightAction_, path("/user/hand/right/input/grip/pose")}, + {hapticAction_, path("/user/hand/left/output/haptic")}, + {hapticAction_, path("/user/hand/right/output/haptic")}, + }; + + auto suggestBindings = [this, &path](const char *profile, + const std::vector &profileBindings) { + XrInteractionProfileSuggestedBinding suggestion{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING}; + suggestion.interactionProfile = path(profile); + suggestion.countSuggestedBindings = static_cast(profileBindings.size()); + suggestion.suggestedBindings = profileBindings.data(); + const XrResult result = xrSuggestInteractionProfileBindings(instance_, &suggestion); + if (XR_SUCCEEDED(result)) { + LOGI("OpenXR controller bindings enabled: %s", profile); + } else { + LOGI("OpenXR controller bindings unavailable: %s result=%d", profile, + static_cast(result)); + } }; - XrInteractionProfileSuggestedBinding suggestedBinding{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING}; - suggestedBinding.interactionProfile = path("/interaction_profiles/oculus/touch_controller"); - suggestedBinding.countSuggestedBindings = static_cast(bindings.size()); - suggestedBinding.suggestedBindings = bindings.data(); - XrCheck(xrSuggestInteractionProfileBindings(instance_, &suggestedBinding), - "xrSuggestInteractionProfileBindings"); + suggestBindings("/interaction_profiles/oculus/touch_controller", bindings); + + std::vector picoLegacyBindings = { + {buttonXAction_, path("/user/hand/left/input/x/click")}, + {buttonYAction_, path("/user/hand/left/input/y/click")}, + {buttonAAction_, path("/user/hand/right/input/a/click")}, + {buttonBAction_, path("/user/hand/right/input/b/click")}, + {squeezeLAction_, path("/user/hand/left/input/squeeze/value")}, + {squeezeRAction_, path("/user/hand/right/input/squeeze/value")}, + {triggerLAction_, path("/user/hand/left/input/trigger")}, + {triggerRAction_, path("/user/hand/right/input/trigger")}, + {thumbstickLAction_, path("/user/hand/left/input/thumbstick")}, + {thumbstickRAction_, path("/user/hand/right/input/thumbstick")}, + {thumbstickLClickAction_, path("/user/hand/left/input/thumbstick/click")}, + {thumbstickRClickAction_, path("/user/hand/right/input/thumbstick/click")}, + {menuLAction_, path("/user/hand/left/input/menu/click")}, + {aimPoseLeftAction_, path("/user/hand/left/input/aim/pose")}, + {aimPoseRightAction_, path("/user/hand/right/input/aim/pose")}, + {hapticAction_, path("/user/hand/left/output/haptic")}, + {hapticAction_, path("/user/hand/right/output/haptic")}, + }; + if (picoControllerExtensionAvailable) { + suggestBindings("/interaction_profiles/pico/neo3_controller", picoLegacyBindings); + suggestBindings("/interaction_profiles/bytedance/pico_neo3_controller", bindings); + suggestBindings("/interaction_profiles/bytedance/pico4_controller", bindings); + } XrSessionActionSetsAttachInfo attachInfo{XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO}; attachInfo.countActionSets = 1; @@ -553,6 +747,10 @@ bool XrImmersiveSession::setupInstanceAndSession() { XrCheck(xrCreateActionSpace(session_, &aimSpaceInfo, &aimSpaceLeft_), "xrCreateActionSpace(left aim)"); aimSpaceInfo.action = aimPoseRightAction_; XrCheck(xrCreateActionSpace(session_, &aimSpaceInfo, &aimSpaceRight_), "xrCreateActionSpace(right aim)"); + aimSpaceInfo.action = gripPoseLeftAction_; + XrCheck(xrCreateActionSpace(session_, &aimSpaceInfo, &gripSpaceLeft_), "xrCreateActionSpace(left grip)"); + aimSpaceInfo.action = gripPoseRightAction_; + XrCheck(xrCreateActionSpace(session_, &aimSpaceInfo, &gripSpaceRight_), "xrCreateActionSpace(right grip)"); if (passthroughExtensionAvailable_) { setupPassthrough(); @@ -589,6 +787,31 @@ void XrImmersiveSession::pollXrEvents() { default: break; } + } else if (event.type == XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING) { + const auto *change = reinterpret_cast(&event); + const bool tracked = change->referenceSpaceType == windowsTrackingSpaceType_ && change->poseValid; + const uint32_t serial = tracked ? recenterSerial_.fetch_add(1) + 1 : recenterSerial_.load(); + LOGI("OpenXR reference space change: type=%d poseValid=%d counted=%d serial=%u", + static_cast(change->referenceSpaceType), change->poseValid ? 1 : 0, tracked ? 1 : 0, serial); + } else if (event.type == XR_TYPE_EVENT_DATA_INTERACTION_PROFILE_CHANGED) { + auto logProfile = [this](const char *hand, XrPath handPath) { + XrInteractionProfileState profileState{XR_TYPE_INTERACTION_PROFILE_STATE}; + const XrResult result = xrGetCurrentInteractionProfile(session_, handPath, &profileState); + if (XR_FAILED(result) || profileState.interactionProfile == XR_NULL_PATH) { + LOGI("OpenXR active controller profile: %s unavailable result=%d", hand, + static_cast(result)); + return; + } + std::array profile{}; + uint32_t length = 0; + if (XR_SUCCEEDED(xrPathToString(instance_, profileState.interactionProfile, + static_cast(profile.size()), &length, + profile.data()))) { + LOGI("OpenXR active controller profile: %s %s", hand, profile.data()); + } + }; + logProfile("left", handPaths_[0]); + logProfile("right", handPaths_[1]); } event.type = XR_TYPE_EVENT_DATA_BUFFER; } @@ -854,6 +1077,62 @@ bool XrImmersiveSession::renderFrame() { return true; } +bool XrImmersiveSession::submitWindowsProjection(XrTime predictedDisplayTime) { + if (!windowsProjectionReady_ || !windowsTransport_.hasStereoContent()) { + stereoActive_.store(false); + stereoMisses_ = 0; + return false; + } + XrCompositionLayerProjection projection{XR_TYPE_COMPOSITION_LAYER_PROJECTION}; + if (!windowsProjection_.render(windowsTransport_, windowsTrackingSpace_, &projection)) { + if (++stereoMisses_ >= 8) stereoActive_.store(false); + return false; + } + stereoMisses_ = 0; + stereoActive_.store(true); + XrCompositionLayerQuad overlay{XR_TYPE_COMPOSITION_LAYER_QUAD}; + const bool overlayRendered = windowsOverlayVisible_.load() && renderFrame(); + if (overlayRendered) { + overlay.layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT; + overlay.space = localSpace_; + overlay.eyeVisibility = XR_EYE_VISIBILITY_BOTH; + overlay.subImage.swapchain = swapchain_; + overlay.subImage.imageRect = {{0, 0}, {swapchainWidth_, swapchainHeight_}}; + const float distance = -quadPosZ_.load(); + const float yaw = distance > 0.0001f ? std::atan2(quadPosX_.load(), distance) : 0.0f; + const float pitch = distance > 0.0001f ? std::atan2(quadPosY_.load(), distance) : 0.0f; + const float cy = std::cos(yaw * 0.5f); + const float sy = std::sin(yaw * 0.5f); + const float cx = std::cos(pitch * 0.5f); + const float sx = std::sin(pitch * 0.5f); + overlay.pose = IdentityPose(); + overlay.pose.orientation = {cy * sx, -sy * cx, sy * sx, cy * cx}; + overlay.pose.position = {distance * std::sin(yaw) * std::cos(pitch), distance * std::sin(pitch), + -distance * std::cos(yaw) * std::cos(pitch)}; + overlay.size = {quadWidth_.load(), quadHeight_.load()}; + } + XrCompositionLayerPassthroughFB passthroughLayer{XR_TYPE_COMPOSITION_LAYER_PASSTHROUGH_FB}; + passthroughLayer.space = XR_NULL_HANDLE; + passthroughLayer.layerHandle = passthroughLayer_; + std::array layers{}; + uint32_t layerCount = 0; + if (passthroughActive_ && passthroughLayer_ != XR_NULL_HANDLE) { + layers[layerCount++] = reinterpret_cast(&passthroughLayer); + } + layers[layerCount++] = reinterpret_cast(&projection); + if (overlayRendered) layers[layerCount++] = reinterpret_cast(&overlay); + XrFrameEndInfo endInfo{XR_TYPE_FRAME_END_INFO}; + endInfo.displayTime = predictedDisplayTime; + endInfo.environmentBlendMode = passthroughActive_ ? XR_ENVIRONMENT_BLEND_MODE_ALPHA_BLEND + : XR_ENVIRONMENT_BLEND_MODE_OPAQUE; + endInfo.layerCount = layerCount; + endInfo.layers = layers.data(); + if (!XrCheck(xrEndFrame(session_, &endInfo), "xrEndFrame(windows projection)")) { + stereoActive_.store(false); + } + return true; +} + void XrImmersiveSession::submitQuadLayer(XrTime predictedDisplayTime, XrSpace space, XrSwapchain swapchain, int32_t width, int32_t height, bool sessionActive) { @@ -1078,8 +1357,10 @@ void XrImmersiveSession::syncControllerInputs(XrTime predictedDisplayTime) { if (getBool(buttonYAction_)) next.buttons |= (1u << BUTTON_Y); if (getBool(buttonAAction_)) next.buttons |= (1u << BUTTON_A); if (getBool(buttonBAction_)) next.buttons |= (1u << BUTTON_B); - if (getFloat(squeezeLAction_) > 0.5f) next.buttons |= (1u << BUTTON_LB); - if (getFloat(squeezeRAction_) > 0.5f) next.buttons |= (1u << BUTTON_RB); + next.squeezeL = getFloat(squeezeLAction_); + next.squeezeR = getFloat(squeezeRAction_); + if (next.squeezeL > 0.5f) next.buttons |= (1u << BUTTON_LB); + if (next.squeezeR > 0.5f) next.buttons |= (1u << BUTTON_RB); // Menu/Start button: a short press still forwards a normal Start press to the game (for // the game's own pause/settings menu); holding it for 600ms instead opens the immersive @@ -1192,7 +1473,10 @@ void XrImmersiveSession::syncControllerInputs(XrTime predictedDisplayTime) { XrCheck(xrLocateSpace(aimSpaceRight_, localSpace_, predictedDisplayTime, &rightLoc), "xrLocateSpace(right)") && (rightLoc.locationFlags & kPoseValidBits) == kPoseValidBits; next.handPosesValid = haveLeft && haveRight; + next.aimPoseValid[0] = haveLeft; + next.aimPoseValid[1] = haveRight; if (haveLeft) { + next.aimPoses[0] = leftLoc.pose; next.leftHandPosX = leftLoc.pose.position.x; next.leftHandPosY = leftLoc.pose.position.y; next.leftHandPosZ = leftLoc.pose.position.z; @@ -1202,6 +1486,7 @@ void XrImmersiveSession::syncControllerInputs(XrTime predictedDisplayTime) { next.leftHandFwdZ = fwd[2]; } if (haveRight) { + next.aimPoses[1] = rightLoc.pose; next.rightHandPosX = rightLoc.pose.position.x; next.rightHandPosY = rightLoc.pose.position.y; next.rightHandPosZ = rightLoc.pose.position.z; @@ -1210,6 +1495,16 @@ void XrImmersiveSession::syncControllerInputs(XrTime predictedDisplayTime) { next.rightHandFwdY = fwd[1]; next.rightHandFwdZ = fwd[2]; } + XrSpaceLocation leftGrip{XR_TYPE_SPACE_LOCATION}; + XrSpaceLocation rightGrip{XR_TYPE_SPACE_LOCATION}; + next.gripPoseValid[0] = gripSpaceLeft_ != XR_NULL_HANDLE && + XrCheck(xrLocateSpace(gripSpaceLeft_, localSpace_, predictedDisplayTime, &leftGrip), "xrLocateSpace(left grip)") && + (leftGrip.locationFlags & kPoseValidBits) == kPoseValidBits; + next.gripPoseValid[1] = gripSpaceRight_ != XR_NULL_HANDLE && + XrCheck(xrLocateSpace(gripSpaceRight_, localSpace_, predictedDisplayTime, &rightGrip), "xrLocateSpace(right grip)") && + (rightGrip.locationFlags & kPoseValidBits) == kPoseValidBits; + if (next.gripPoseValid[0]) next.gripPoses[0] = leftGrip.pose; + if (next.gripPoseValid[1]) next.gripPoses[1] = rightGrip.pose; // Rate-limited raw-value dump (~every 0.5s at 90Hz) — temporary, for diagnosing controller // mapping reports; safe to remove once the analog stick mapping is confirmed solid. @@ -1225,7 +1520,36 @@ void XrImmersiveSession::syncControllerInputs(XrTime predictedDisplayTime) { snapshot_ = next; } +void XrImmersiveSession::syncWindowsTrackingPoses(InputSnapshot *snapshot, + XrTime predictedDisplayTime) { + if (snapshot == nullptr || windowsTrackingSpace_ == XR_NULL_HANDLE || + windowsTrackingSpace_ == localSpace_) return; + constexpr XrSpaceLocationFlags validBits = + XR_SPACE_LOCATION_POSITION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_VALID_BIT; + XrSpaceLocation aim[2]{{XR_TYPE_SPACE_LOCATION}, {XR_TYPE_SPACE_LOCATION}}; + XrSpaceLocation grip[2]{{XR_TYPE_SPACE_LOCATION}, {XR_TYPE_SPACE_LOCATION}}; + const XrSpace aimSpaces[2]{aimSpaceLeft_, aimSpaceRight_}; + const XrSpace gripSpaces[2]{gripSpaceLeft_, gripSpaceRight_}; + for (uint32_t hand = 0; hand < 2; ++hand) { + snapshot->aimPoseValid[hand] = aimSpaces[hand] != XR_NULL_HANDLE && + XR_SUCCEEDED(xrLocateSpace(aimSpaces[hand], windowsTrackingSpace_, + predictedDisplayTime, &aim[hand])) && + (aim[hand].locationFlags & validBits) == validBits; + snapshot->gripPoseValid[hand] = gripSpaces[hand] != XR_NULL_HANDLE && + XR_SUCCEEDED(xrLocateSpace(gripSpaces[hand], windowsTrackingSpace_, + predictedDisplayTime, &grip[hand])) && + (grip[hand].locationFlags & validBits) == validBits; + if (snapshot->aimPoseValid[hand]) snapshot->aimPoses[hand] = aim[hand].pose; + if (snapshot->gripPoseValid[hand]) snapshot->gripPoses[hand] = grip[hand].pose; + } + snapshot->handPosesValid = snapshot->aimPoseValid[0] && snapshot->aimPoseValid[1]; +} + void XrImmersiveSession::teardown() { + stereoActive_.store(false); + stereoMisses_ = 0; + windowsTransport_.stop(); + windowsProjection_.shutdown(); teardownPassthrough(); if (gameTexture_ != 0) { glDeleteTextures(1, &gameTexture_); @@ -1270,6 +1594,15 @@ void XrImmersiveSession::teardown() { xrDestroySpace(localSpace_); localSpace_ = XR_NULL_HANDLE; } + if (stageSpace_ != XR_NULL_HANDLE) { + xrDestroySpace(stageSpace_); + stageSpace_ = XR_NULL_HANDLE; + } + if (localFloorSpace_ != XR_NULL_HANDLE) { + xrDestroySpace(localFloorSpace_); + localFloorSpace_ = XR_NULL_HANDLE; + } + windowsTrackingSpace_ = XR_NULL_HANDLE; if (aimSpaceLeft_ != XR_NULL_HANDLE) { xrDestroySpace(aimSpaceLeft_); aimSpaceLeft_ = XR_NULL_HANDLE; @@ -1278,13 +1611,25 @@ void XrImmersiveSession::teardown() { xrDestroySpace(aimSpaceRight_); aimSpaceRight_ = XR_NULL_HANDLE; } - if (actionSet_ != XR_NULL_HANDLE) { - xrDestroyActionSet(actionSet_); - actionSet_ = XR_NULL_HANDLE; + if (gripSpaceLeft_ != XR_NULL_HANDLE) { + xrDestroySpace(gripSpaceLeft_); + gripSpaceLeft_ = XR_NULL_HANDLE; } - if (session_ != XR_NULL_HANDLE) { - xrDestroySession(session_); - session_ = XR_NULL_HANDLE; + if (gripSpaceRight_ != XR_NULL_HANDLE) { + xrDestroySpace(gripSpaceRight_); + gripSpaceRight_ = XR_NULL_HANDLE; + } + { + std::lock_guard lock(sessionMutex_); + if (actionSet_ != XR_NULL_HANDLE) { + xrDestroyActionSet(actionSet_); + actionSet_ = XR_NULL_HANDLE; + } + hapticAction_ = XR_NULL_HANDLE; + if (session_ != XR_NULL_HANDLE) { + xrDestroySession(session_); + session_ = XR_NULL_HANDLE; + } } if (instance_ != XR_NULL_HANDLE) { xrDestroyInstance(instance_); diff --git a/app/src/main/cpp/xrimmersive/xr_immersive.h b/app/src/main/cpp/xrimmersive/xr_immersive.h index c7240a0224..bb0e107ad2 100644 --- a/app/src/main/cpp/xrimmersive/xr_immersive.h +++ b/app/src/main/cpp/xrimmersive/xr_immersive.h @@ -2,22 +2,23 @@ #include #include +#include #include +#include #include #include #include #include -#define EGL_EGLEXT_PROTOTYPES #include #include -#define GL_GLEXT_PROTOTYPES #include #include #include -#define XR_USE_PLATFORM_ANDROID 1 -#define XR_USE_GRAPHICS_API_OPENGL_ES 1 +#include "xr_windows_projection.h" +#include "xr_windows_transport.h" + #include #include @@ -50,6 +51,8 @@ struct InputSnapshot { float rightY = 0.0f; float triggerL = 0.0f; float triggerR = 0.0f; + float squeezeL = 0.0f; + float squeezeR = 0.0f; // Rising-edge only: holding the Touch controllers' Menu button for 600ms summons // GameNative's own quick menu. A short press of the same button is still forwarded as a // normal gamepad Start press (games use it for their own pause/settings menu). @@ -73,6 +76,27 @@ struct InputSnapshot { float leftHandFwdX = 0.0f, leftHandFwdY = 0.0f, leftHandFwdZ = -1.0f; float rightHandPosX = 0.0f, rightHandPosY = 0.0f, rightHandPosZ = 0.0f; float rightHandFwdX = 0.0f, rightHandFwdY = 0.0f, rightHandFwdZ = -1.0f; + XrPosef aimPoses[2]{{{0, 0, 0, 1}, {0, 0, 0}}, {{0, 0, 0, 1}, {0, 0, 0}}}; + XrPosef gripPoses[2]{{{0, 0, 0, 1}, {0, 0, 0}}, {{0, 0, 0, 1}, {0, 0, 0}}}; + bool aimPoseValid[2]{false, false}; + bool gripPoseValid[2]{false, false}; +}; + +struct WindowsRuntimeSnapshot { + uint64_t frameSerial = 0; + XrTime predictedDisplayTime = 0; + XrDuration predictedDisplayPeriod = 0; + XrSessionState sessionState = XR_SESSION_STATE_UNKNOWN; + bool shouldRender = false; + XrViewStateFlags viewStateFlags = 0; + uint32_t recommendedWidth = 0; + uint32_t recommendedHeight = 0; + bool stageAvailable = false; + bool stageSpaceActive = false; + uint32_t recenterSerial = 0; + XrExtent2Df stageBounds{0.0f, 0.0f}; + std::array views{{{XR_TYPE_VIEW}, {XR_TYPE_VIEW}}}; + InputSnapshot input; }; // Owns the OpenXR instance/session and its dedicated frame-loop thread. @@ -84,6 +108,11 @@ class XrImmersiveSession { void join(); InputSnapshot pollSnapshot(); + bool waitWindowsRuntimeSnapshot(uint64_t afterSerial, uint32_t timeoutMs, + WindowsRuntimeSnapshot *snapshot); + bool windowsStereoActive() const; + bool applyWindowsHaptic(uint32_t hand, float amplitude, XrDuration duration, float frequency); + void setWindowsOverlayVisible(bool visible); // Called from the JNI bridge with a freshly PixelCopy'd RGBA_8888 frame of the game's // actual rendered output (see ImmersiveXrActivity's capture loop). Copies into a @@ -125,8 +154,10 @@ class XrImmersiveSession { // caller must NOT submit a layer referencing it. bool renderFrame(); void syncControllerInputs(XrTime predictedDisplayTime); + void syncWindowsTrackingPoses(InputSnapshot *snapshot, XrTime predictedDisplayTime); void submitQuadLayer(XrTime predictedDisplayTime, XrSpace space, XrSwapchain swapchain, int32_t width, int32_t height, bool sessionActive); + bool submitWindowsProjection(XrTime predictedDisplayTime); void ensureQuadGeometryAndShader(); void uploadPendingGameFrameLocked(); void setupPassthrough(); @@ -139,10 +170,23 @@ class XrImmersiveSession { XrInstance instance_ = XR_NULL_HANDLE; XrSystemId systemId_ = XR_NULL_SYSTEM_ID; XrSession session_ = XR_NULL_HANDLE; + std::mutex sessionMutex_; XrSpace localSpace_ = XR_NULL_HANDLE; + XrSpace stageSpace_ = XR_NULL_HANDLE; + XrSpace localFloorSpace_ = XR_NULL_HANDLE; + XrReferenceSpaceType windowsTrackingSpaceType_ = XR_REFERENCE_SPACE_TYPE_LOCAL; + bool localFloorExtensionAvailable_ = false; + XrSpace windowsTrackingSpace_ = XR_NULL_HANDLE; XrSwapchain swapchain_ = XR_NULL_HANDLE; XrSessionState sessionState_ = XR_SESSION_STATE_UNKNOWN; bool sessionRunning_ = false; + windowsvr::WindowsFrameTransport windowsTransport_; + windowsvr::WindowsProjectionPresenter windowsProjection_; + bool windowsProjectionReady_ = false; + std::atomic stereoActive_{false}; + std::atomic windowsOverlayVisible_{false}; + std::atomic recenterSerial_{0}; + uint32_t stereoMisses_ = 0; // The quad swapchain follows the container's screen size (min width 1280) and the // refresh rate is per-container, both set through configure() before the session starts. @@ -224,8 +268,14 @@ class XrImmersiveSession { // InputSnapshot::pointerModeToggled. XrAction aimPoseLeftAction_ = XR_NULL_HANDLE; XrAction aimPoseRightAction_ = XR_NULL_HANDLE; + XrAction gripPoseLeftAction_ = XR_NULL_HANDLE; + XrAction gripPoseRightAction_ = XR_NULL_HANDLE; + XrAction hapticAction_ = XR_NULL_HANDLE; + XrPath handPaths_[2]{XR_NULL_PATH, XR_NULL_PATH}; XrSpace aimSpaceLeft_ = XR_NULL_HANDLE; XrSpace aimSpaceRight_ = XR_NULL_HANDLE; + XrSpace gripSpaceLeft_ = XR_NULL_HANDLE; + XrSpace gripSpaceRight_ = XR_NULL_HANDLE; bool lastL3Pressed_ = false; std::chrono::steady_clock::time_point lastL3ClickTime_; int l3ClickCount_ = 0; @@ -251,6 +301,9 @@ class XrImmersiveSession { std::mutex snapshotMutex_; InputSnapshot snapshot_; + std::mutex windowsSnapshotMutex_; + std::condition_variable windowsSnapshotCondition_; + WindowsRuntimeSnapshot windowsSnapshot_; // Quad transform — defaults match the original hardcoded values (2m in front, 16:9, // 1.6m wide). Atomics: written from Kotlin/JNI callers, read from the render thread. diff --git a/app/src/main/cpp/xrimmersive/xr_windows_projection.cpp b/app/src/main/cpp/xrimmersive/xr_windows_projection.cpp new file mode 100644 index 0000000000..853ad030e1 --- /dev/null +++ b/app/src/main/cpp/xrimmersive/xr_windows_projection.cpp @@ -0,0 +1,506 @@ +#include "xr_windows_projection.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "xrimmersive", __VA_ARGS__) + +namespace xrimmersive::windowsvr { + +namespace { + +constexpr uint32_t kDrmFormatAbgr8888 = + static_cast('A') | (static_cast('B') << 8) | + (static_cast('2') << 16) | (static_cast('4') << 24); +constexpr uint32_t kDrmFormatArgb8888 = + static_cast('A') | (static_cast('R') << 8) | + (static_cast('2') << 16) | (static_cast('4') << 24); + +struct DmaBufSync { + uint64_t flags; +}; + +constexpr uint64_t kDmaBufSyncRead = 1u << 0; +constexpr uint64_t kDmaBufSyncEnd = 1u << 2; + +#ifndef DMA_BUF_IOCTL_SYNC +#define DMA_BUF_IOCTL_SYNC _IOW('b', 0, DmaBufSync) +#endif + +GLuint compileShader(GLenum type, const char *source) { + const GLuint shader = glCreateShader(type); + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint compiled = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled != GL_TRUE) { + glDeleteShader(shader); + return 0; + } + return shader; +} + +} + +bool WindowsProjectionPresenter::initialize(XrSession session, int64_t format, uint32_t width, + uint32_t height, EGLDisplay display) { + session_ = session; + width_ = width; + height_ = height; + display_ = display; + for (auto &eye : eglImages_) eye.fill(EGL_NO_IMAGE_KHR); + XrSwapchainCreateInfo info{XR_TYPE_SWAPCHAIN_CREATE_INFO}; + info.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT; + info.format = format; + info.sampleCount = 1; + info.width = width; + info.height = height; + info.faceCount = 1; + info.arraySize = 2; + info.mipCount = 1; + if (XR_FAILED(xrCreateSwapchain(session, &info, &swapchain_))) return false; + uint32_t count = 0; + if (XR_FAILED(xrEnumerateSwapchainImages(swapchain_, 0, &count, nullptr)) || count == 0) return false; + images_.assign(count, {XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR}); + if (XR_FAILED(xrEnumerateSwapchainImages( + swapchain_, count, &count, + reinterpret_cast(images_.data())))) return false; + glGenFramebuffers(1, &framebuffer_); + return ensureProgram(); +} + +bool WindowsProjectionPresenter::ensureProgram() { + static const char *vertexSource = + "#version 300 es\nlayout(location=0) in vec2 p;layout(location=1) in vec2 t;" + "out vec2 uv;uniform vec4 u;void main(){uv=u.xy+t*u.zw;gl_Position=vec4(p,0,1);}"; + static const char *fragmentSource = + "#version 300 es\nprecision mediump float;in vec2 uv;uniform sampler2D s;" + "out vec4 c;void main(){c=texture(s,uv);}"; + const GLuint vertex = compileShader(GL_VERTEX_SHADER, vertexSource); + const GLuint fragment = compileShader(GL_FRAGMENT_SHADER, fragmentSource); + if (vertex == 0 || fragment == 0) return false; + program_ = glCreateProgram(); + glAttachShader(program_, vertex); + glAttachShader(program_, fragment); + glLinkProgram(program_); + glDeleteShader(vertex); + glDeleteShader(fragment); + GLint linked = GL_FALSE; + glGetProgramiv(program_, GL_LINK_STATUS, &linked); + if (linked != GL_TRUE) return false; + uvTransformLocation_ = glGetUniformLocation(program_, "u"); + const float vertices[] = { + -1.0f, -1.0f, 0.0f, 1.0f, + 1.0f, -1.0f, 1.0f, 1.0f, + -1.0f, 1.0f, 0.0f, 0.0f, + 1.0f, 1.0f, 1.0f, 0.0f, + }; + glGenBuffers(1, &vertexBuffer_); + glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer_); + glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); + glGenVertexArrays(1, &vertexArray_); + glBindVertexArray(vertexArray_); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), reinterpret_cast(0)); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), + reinterpret_cast(2 * sizeof(float))); + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glUseProgram(program_); + glUniform1i(glGetUniformLocation(program_, "s"), 0); + glUseProgram(0); + return true; +} + +EGLImageKHR WindowsProjectionPresenter::createImageFromHardwareBuffer(AHardwareBuffer *buffer) { + EGLClientBuffer client = eglGetNativeClientBufferANDROID(buffer); + if (client == nullptr) return EGL_NO_IMAGE_KHR; + const EGLint attributes[] = {EGL_IMAGE_PRESERVED_KHR, EGL_TRUE, EGL_NONE}; + return eglCreateImageKHR(display_, EGL_NO_CONTEXT, EGL_NATIVE_BUFFER_ANDROID, client, attributes); +} + +EGLImageKHR WindowsProjectionPresenter::createImageFromDmabuf(const EyeFrame &frame) { + static constexpr EGLint planeFd[] = { + EGL_DMA_BUF_PLANE0_FD_EXT, EGL_DMA_BUF_PLANE1_FD_EXT, + EGL_DMA_BUF_PLANE2_FD_EXT, EGL_DMA_BUF_PLANE3_FD_EXT}; + static constexpr EGLint planeOffset[] = { + EGL_DMA_BUF_PLANE0_OFFSET_EXT, EGL_DMA_BUF_PLANE1_OFFSET_EXT, + EGL_DMA_BUF_PLANE2_OFFSET_EXT, EGL_DMA_BUF_PLANE3_OFFSET_EXT}; + static constexpr EGLint planePitch[] = { + EGL_DMA_BUF_PLANE0_PITCH_EXT, EGL_DMA_BUF_PLANE1_PITCH_EXT, + EGL_DMA_BUF_PLANE2_PITCH_EXT, EGL_DMA_BUF_PLANE3_PITCH_EXT}; + static constexpr EGLint modifierLow[] = { + EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE1_MODIFIER_LO_EXT, + EGL_DMA_BUF_PLANE2_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE3_MODIFIER_LO_EXT}; + static constexpr EGLint modifierHigh[] = { + EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE1_MODIFIER_HI_EXT, + EGL_DMA_BUF_PLANE2_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE3_MODIFIER_HI_EXT}; + EGLint attributes[64]; + int count = 0; + attributes[count++] = EGL_WIDTH; + attributes[count++] = frame.width; + attributes[count++] = EGL_HEIGHT; + attributes[count++] = frame.height; + attributes[count++] = EGL_LINUX_DRM_FOURCC_EXT; + attributes[count++] = static_cast(frame.fourcc); + for (int plane = 0; plane < frame.planeCount; ++plane) { + attributes[count++] = planeFd[plane]; + attributes[count++] = frame.dmabufFds[plane]; + attributes[count++] = planeOffset[plane]; + attributes[count++] = static_cast(frame.offsets[plane]); + attributes[count++] = planePitch[plane]; + attributes[count++] = static_cast(frame.strides[plane]); + if (frame.modifier != 0) { + attributes[count++] = modifierLow[plane]; + attributes[count++] = static_cast(frame.modifier & 0xffffffffu); + attributes[count++] = modifierHigh[plane]; + attributes[count++] = static_cast(frame.modifier >> 32); + } + } + attributes[count] = EGL_NONE; + return eglCreateImageKHR(display_, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, attributes); +} + +bool WindowsProjectionPresenter::waitForAcquireFence(int fenceFd) { + if (acquireSync_ != EGL_NO_SYNC_KHR) { + eglDestroySyncKHR(display_, acquireSync_); + acquireSync_ = EGL_NO_SYNC_KHR; + } + if (fenceFd < 0) return true; + const EGLint attributes[] = {EGL_SYNC_NATIVE_FENCE_FD_ANDROID, fenceFd, EGL_NONE}; + EGLSyncKHR sync = eglCreateSyncKHR(display_, EGL_SYNC_NATIVE_FENCE_ANDROID, attributes); + if (sync != EGL_NO_SYNC_KHR) { + const EGLBoolean result = eglWaitSyncKHR(display_, sync, 0); + if (result == EGL_TRUE) { + acquireSync_ = sync; + return true; + } + eglDestroySyncKHR(display_, sync); + return false; + } + pollfd descriptor{fenceFd, POLLIN, 0}; + int result; + do result = poll(&descriptor, 1, 5000); while (result < 0 && errno == EINTR); + close(fenceFd); + return result > 0; +} + +int WindowsProjectionPresenter::createReleaseFence() { + const auto duplicate = reinterpret_cast( + eglGetProcAddress("eglDupNativeFenceFDANDROID")); + if (duplicate == nullptr) { + glFinish(); + return -1; + } + const EGLint attributes[] = { + EGL_SYNC_NATIVE_FENCE_FD_ANDROID, EGL_NO_NATIVE_FENCE_FD_ANDROID, EGL_NONE}; + EGLSyncKHR sync = eglCreateSyncKHR(display_, EGL_SYNC_NATIVE_FENCE_ANDROID, attributes); + if (sync == EGL_NO_SYNC_KHR) { + glFinish(); + return -1; + } + glFlush(); + const int fd = duplicate(display_, sync); + eglDestroySyncKHR(display_, sync); + if (fd < 0) glFinish(); + return fd; +} + +bool WindowsProjectionPresenter::uploadLinearDmabufToTexture( + uint32_t eye, int imageIndex, const EyeFrame &frame, GLuint &texture, + uint64_t &cachedRegistration) { + if (frame.planeCount != 1 || frame.dmabufFds[0] < 0 || frame.modifier != 0 || + frame.width <= 0 || frame.height <= 0 || + frame.strides[0] < static_cast(frame.width) * 4u || + (frame.fourcc != kDrmFormatAbgr8888 && frame.fourcc != kDrmFormatArgb8888)) return false; + const size_t rowBytes = static_cast(frame.width) * 4u; + const size_t mapLength = static_cast(frame.offsets[0]) + + static_cast(frame.strides[0]) * static_cast(frame.height - 1) + rowBytes; + void *&mapping = cpuMappings_[eye][imageIndex]; + size_t &mappedLength = cpuMappingLengths_[eye][imageIndex]; + uint64_t &mappedRegistration = cpuMappingRegistrations_[eye][imageIndex]; + if (mapping != nullptr && + (mappedRegistration != frame.registrationSerial || mappedLength < mapLength)) { + munmap(mapping, mappedLength); + mapping = nullptr; + mappedLength = 0; + mappedRegistration = 0; + } + if (mapping == nullptr) { + mapping = mmap(nullptr, mapLength, PROT_READ, MAP_SHARED, frame.dmabufFds[0], 0); + if (mapping == MAP_FAILED) { + mapping = nullptr; + return false; + } + mappedLength = mapLength; + mappedRegistration = frame.registrationSerial; + } + if (texture == 0) glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + const bool allocate = cpuTextureWidths_[eye][imageIndex] != frame.width || + cpuTextureHeights_[eye][imageIndex] != frame.height || + cachedRegistration != frame.registrationSerial; + if (allocate) { + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + const bool swap = frame.fourcc == kDrmFormatArgb8888; + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_R, swap ? GL_BLUE : GL_RED); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_G, GL_GREEN); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_B, swap ? GL_RED : GL_BLUE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_A, GL_ALPHA); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, frame.width, frame.height, 0, + GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + } + if (acquireSync_ != EGL_NO_SYNC_KHR) { + eglClientWaitSyncKHR(display_, acquireSync_, EGL_SYNC_FLUSH_COMMANDS_BIT_KHR, EGL_FOREVER_KHR); + eglDestroySyncKHR(display_, acquireSync_); + acquireSync_ = EGL_NO_SYNC_KHR; + } + DmaBufSync sync{kDmaBufSyncRead}; + ioctl(frame.dmabufFds[0], DMA_BUF_IOCTL_SYNC, &sync); + const auto *source = static_cast(mapping) + frame.offsets[0]; + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glPixelStorei(GL_UNPACK_ROW_LENGTH, static_cast(frame.strides[0] / 4u)); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, frame.width, frame.height, + GL_RGBA, GL_UNSIGNED_BYTE, source); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + const GLenum error = glGetError(); + glBindTexture(GL_TEXTURE_2D, 0); + sync.flags = kDmaBufSyncRead | kDmaBufSyncEnd; + ioctl(frame.dmabufFds[0], DMA_BUF_IOCTL_SYNC, &sync); + if (error != GL_NO_ERROR) return false; + cpuTextureWidths_[eye][imageIndex] = frame.width; + cpuTextureHeights_[eye][imageIndex] = frame.height; + cachedRegistration = frame.registrationSerial; + return true; +} + +bool WindowsProjectionPresenter::importEyeBuffer(WindowsFrameTransport &transport, uint32_t eye, + EyeFrame &frame, bool &fresh) { + frame = transport.pollEye(static_cast(eye)); + if (frame.kind == BufferKind::None) return false; + fresh = frame.serial != renderedSerials_[eye]; + if (!waitForAcquireFence(frame.acquireFenceFd)) { + transport.discardFrame(static_cast(eye), frame.imageIndex, frame.serial); + if (fresh) renderedSerials_[eye] = frame.serial; + return false; + } + frame.acquireFenceFd = -1; + if (frame.imageIndex < 0 || frame.imageIndex >= WindowsFrameTransport::kMaxImages) { + transport.discardFrame(static_cast(eye), frame.imageIndex, frame.serial); + return false; + } + const int image = frame.imageIndex; + EGLImageKHR &cachedImage = eglImages_[eye][image]; + GLuint &cachedTexture = textures_[eye][image]; + uint64_t ®istration = registrations_[eye][image]; + if (cpuFallback_[eye][image]) { + if (!fresh && registration == frame.registrationSerial) return cachedTexture != 0; + if (uploadLinearDmabufToTexture(eye, image, frame, cachedTexture, registration)) return true; + } + if (cachedImage != EGL_NO_IMAGE_KHR && registration == frame.registrationSerial) return true; + if (cachedImage != EGL_NO_IMAGE_KHR) { + eglDestroyImageKHR(display_, cachedImage); + cachedImage = EGL_NO_IMAGE_KHR; + } + EGLImageKHR imported = EGL_NO_IMAGE_KHR; + if (frame.kind == BufferKind::HardwareBuffer && frame.buffer != nullptr) { + imported = createImageFromHardwareBuffer(frame.buffer); + } else if (frame.kind == BufferKind::DmaBuf && frame.planeCount > 0) { + imported = createImageFromDmabuf(frame); + if (imported == EGL_NO_IMAGE_KHR) { + cpuFallback_[eye][image] = true; + static bool details = false; + LOGI("windows vr eye %u image %d: EGL dma-buf import failed (0x%x) — CPU upload fallback engaged", + eye, image, eglGetError()); + if (!details) { + details = true; + const char *extensions = eglQueryString(display_, EGL_EXTENSIONS); + LOGI("EGL dma_buf_import=%d modifiers=%d", + extensions != nullptr && strstr(extensions, "EGL_EXT_image_dma_buf_import") != nullptr, + extensions != nullptr && strstr(extensions, "EGL_EXT_image_dma_buf_import_modifiers") != nullptr); + } + if (uploadLinearDmabufToTexture(eye, image, frame, cachedTexture, registration)) return true; + } else { + LOGI("windows vr eye %u image %d: zero-copy EGL dma-buf import active", eye, image); + } + } + if (imported == EGL_NO_IMAGE_KHR) { + transport.discardFrame(static_cast(eye), image, frame.serial); + return false; + } + if (cachedTexture == 0) glGenTextures(1, &cachedTexture); + glBindTexture(GL_TEXTURE_2D, cachedTexture); + glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, imported); + const GLenum error = glGetError(); + if (error != GL_NO_ERROR) { + glBindTexture(GL_TEXTURE_2D, 0); + eglDestroyImageKHR(display_, imported); + transport.discardFrame(static_cast(eye), image, frame.serial); + return false; + } + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_R, frame.swapRedBlue ? GL_BLUE : GL_RED); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_G, GL_GREEN); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_B, frame.swapRedBlue ? GL_RED : GL_BLUE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_A, GL_ALPHA); + glBindTexture(GL_TEXTURE_2D, 0); + cachedImage = imported; + registration = frame.registrationSerial; + return true; +} + +void WindowsProjectionPresenter::drawEye(uint32_t eye, const EyeFrame &source, + uint32_t imageIndex) { + glFramebufferTextureLayer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, images_[imageIndex].image, 0, eye); + glViewport(0, 0, static_cast(width_), static_cast(height_)); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, textures_[eye][source.imageIndex]); + const float sourceWidth = source.sourceWidth > 0 ? source.sourceWidth : source.width; + const float sourceHeight = source.sourceHeight > 0 ? source.sourceHeight : source.height; + const float v0 = (source.flipY ? source.sourceY + sourceHeight : source.sourceY) / + static_cast(source.height); + const float vScale = (source.flipY ? -sourceHeight : sourceHeight) / source.height; + glUniform4f(uvTransformLocation_, source.sourceX / static_cast(source.width), v0, + sourceWidth / source.width, vScale); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); +} + +void WindowsProjectionPresenter::discardFresh(WindowsFrameTransport &transport, + const std::array &frames, + const std::array &fresh) { + for (uint32_t eye = 0; eye < 2; ++eye) { + if (!fresh[eye]) continue; + transport.discardFrame(static_cast(eye), frames[eye].imageIndex, frames[eye].serial); + renderedSerials_[eye] = frames[eye].serial; + } +} + +bool WindowsProjectionPresenter::render(WindowsFrameTransport &transport, XrSpace space, + XrCompositionLayerProjection *layer) { + if (layer == nullptr || !transport.hasStereoContent()) return false; + std::array frames{}; + std::array fresh{false, false}; + for (uint32_t eye = 0; eye < 2; ++eye) { + if (!importEyeBuffer(transport, eye, frames[eye], fresh[eye])) { + discardFresh(transport, frames, fresh); + return false; + } + } + XrSwapchainImageAcquireInfo acquire{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; + uint32_t imageIndex = 0; + if (XR_FAILED(xrAcquireSwapchainImage(swapchain_, &acquire, &imageIndex))) { + discardFresh(transport, frames, fresh); + return false; + } + XrSwapchainImageWaitInfo wait{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO}; + wait.timeout = XR_INFINITE_DURATION; + if (XR_FAILED(xrWaitSwapchainImage(swapchain_, &wait))) { + XrSwapchainImageReleaseInfo release{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; + xrReleaseSwapchainImage(swapchain_, &release); + discardFresh(transport, frames, fresh); + return false; + } + glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_); + glDisable(GL_DEPTH_TEST); + glDisable(GL_BLEND); + glDisable(GL_SCISSOR_TEST); + glUseProgram(program_); + glBindVertexArray(vertexArray_); + for (uint32_t eye = 0; eye < 2; ++eye) { + drawEye(eye, frames[eye], imageIndex); + XrCompositionLayerProjectionView &view = views_[eye]; + view = {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}; + if (frames[eye].projectionValid) { + view.pose.orientation = { + frames[eye].projectionOrientation[0], frames[eye].projectionOrientation[1], + frames[eye].projectionOrientation[2], frames[eye].projectionOrientation[3]}; + view.pose.position = { + frames[eye].projectionPosition[0], frames[eye].projectionPosition[1], + frames[eye].projectionPosition[2]}; + view.fov = { + frames[eye].projectionFov[0], frames[eye].projectionFov[1], + frames[eye].projectionFov[2], frames[eye].projectionFov[3]}; + } + view.subImage.swapchain = swapchain_; + view.subImage.imageRect = {{0, 0}, {static_cast(width_), static_cast(height_)}}; + view.subImage.imageArrayIndex = eye; + } + glBindVertexArray(0); + glBindTexture(GL_TEXTURE_2D, 0); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + const uint32_t freshCount = static_cast(fresh[0]) + static_cast(fresh[1]); + int sharedFence = freshCount > 0 ? createReleaseFence() : -1; + if (freshCount == 0) glFlush(); + std::array releaseFences{-1, -1}; + if (sharedFence >= 0) { + if (fresh[0] && fresh[1]) { + releaseFences[0] = dup(sharedFence); + releaseFences[1] = sharedFence; + if (releaseFences[0] < 0) { + close(sharedFence); + releaseFences = {-1, -1}; + glFinish(); + } + } else { + releaseFences[fresh[0] ? 0 : 1] = sharedFence; + } + } + for (uint32_t eye = 0; eye < 2; ++eye) { + if (!fresh[eye]) continue; + transport.publishReleaseFence(static_cast(eye), frames[eye].imageIndex, + releaseFences[eye]); + renderedSerials_[eye] = frames[eye].serial; + } + XrSwapchainImageReleaseInfo release{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; + if (XR_FAILED(xrReleaseSwapchainImage(swapchain_, &release))) return false; + *layer = {XR_TYPE_COMPOSITION_LAYER_PROJECTION}; + layer->space = space; + layer->viewCount = 2; + layer->views = views_.data(); + return true; +} + +void WindowsProjectionPresenter::shutdown() { + if (acquireSync_ != EGL_NO_SYNC_KHR) { + eglDestroySyncKHR(display_, acquireSync_); + acquireSync_ = EGL_NO_SYNC_KHR; + } + for (uint32_t eye = 0; eye < 2; ++eye) { + for (int image = 0; image < WindowsFrameTransport::kMaxImages; ++image) { + if (eglImages_[eye][image] != EGL_NO_IMAGE_KHR) { + eglDestroyImageKHR(display_, eglImages_[eye][image]); + } + if (textures_[eye][image] != 0) glDeleteTextures(1, &textures_[eye][image]); + if (cpuMappings_[eye][image] != nullptr) { + munmap(cpuMappings_[eye][image], cpuMappingLengths_[eye][image]); + } + } + } + if (vertexArray_ != 0) glDeleteVertexArrays(1, &vertexArray_); + if (vertexBuffer_ != 0) glDeleteBuffers(1, &vertexBuffer_); + if (program_ != 0) glDeleteProgram(program_); + if (framebuffer_ != 0) glDeleteFramebuffers(1, &framebuffer_); + if (swapchain_ != XR_NULL_HANDLE) xrDestroySwapchain(swapchain_); + vertexArray_ = 0; + vertexBuffer_ = 0; + program_ = 0; + framebuffer_ = 0; + swapchain_ = XR_NULL_HANDLE; +} + +} diff --git a/app/src/main/cpp/xrimmersive/xr_windows_projection.h b/app/src/main/cpp/xrimmersive/xr_windows_projection.h new file mode 100644 index 0000000000..5e7f6679b1 --- /dev/null +++ b/app/src/main/cpp/xrimmersive/xr_windows_projection.h @@ -0,0 +1,62 @@ +#pragma once + +#include "xr_windows_transport.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrimmersive::windowsvr { + +class WindowsProjectionPresenter { +public: + bool initialize(XrSession session, int64_t format, uint32_t width, uint32_t height, EGLDisplay display); + bool render(WindowsFrameTransport &transport, XrSpace space, XrCompositionLayerProjection *layer); + void shutdown(); + +private: + bool ensureProgram(); + EGLImageKHR createImageFromHardwareBuffer(AHardwareBuffer *buffer); + EGLImageKHR createImageFromDmabuf(const EyeFrame &frame); + bool waitForAcquireFence(int fenceFd); + int createReleaseFence(); + bool uploadLinearDmabufToTexture(uint32_t eye, int imageIndex, const EyeFrame &frame, + GLuint &texture, uint64_t &cachedRegistration); + bool importEyeBuffer(WindowsFrameTransport &transport, uint32_t eye, EyeFrame &frame, bool &fresh); + void drawEye(uint32_t eye, const EyeFrame &source, uint32_t imageIndex); + void discardFresh(WindowsFrameTransport &transport, const std::array &frames, + const std::array &fresh); + + XrSession session_ = XR_NULL_HANDLE; + XrSwapchain swapchain_ = XR_NULL_HANDLE; + uint32_t width_ = 0; + uint32_t height_ = 0; + EGLDisplay display_ = EGL_NO_DISPLAY; + std::vector images_; + std::array views_{}; + std::array, 2> eglImages_{}; + std::array, 2> textures_{}; + std::array, 2> registrations_{}; + std::array, 2> cpuFallback_{}; + std::array, 2> cpuMappings_{}; + std::array, 2> cpuMappingLengths_{}; + std::array, 2> cpuMappingRegistrations_{}; + std::array, 2> cpuTextureWidths_{}; + std::array, 2> cpuTextureHeights_{}; + std::array renderedSerials_{0, 0}; + EGLSyncKHR acquireSync_ = EGL_NO_SYNC_KHR; + GLuint framebuffer_ = 0; + GLuint program_ = 0; + GLuint vertexBuffer_ = 0; + GLuint vertexArray_ = 0; + GLint uvTransformLocation_ = -1; +}; + +} diff --git a/app/src/main/cpp/xrimmersive/xr_windows_transport.cpp b/app/src/main/cpp/xrimmersive/xr_windows_transport.cpp new file mode 100644 index 0000000000..9ea339711f --- /dev/null +++ b/app/src/main/cpp/xrimmersive/xr_windows_transport.cpp @@ -0,0 +1,654 @@ +#include "xr_windows_transport.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOG_TAG "GameNativeVR" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +namespace xrimmersive::windowsvr { +namespace { + + + + + +bool readLine(int fd, std::string& out) { + out.clear(); + char buffer[512]; + while (true) { + ssize_t got = recv(fd, buffer, sizeof(buffer), MSG_PEEK); + if (got == 0) return false; + if (got < 0) { + if (errno == EINTR) continue; + return false; + } + const void* newlinePtr = std::memchr(buffer, '\n', static_cast(got)); + const size_t consume = newlinePtr != nullptr + ? static_cast(newlinePtr) - buffer + 1 + : static_cast(got); + size_t consumed = 0; + while (consumed < consume) { + const ssize_t read = recv(fd, buffer + consumed, consume - consumed, 0); + if (read == 0) return false; + if (read < 0) { + if (errno == EINTR) continue; + return false; + } + consumed += static_cast(read); + } + const size_t textBytes = newlinePtr != nullptr ? consume - 1 : consume; + if (out.size() < 512) { + const size_t available = 512 - out.size(); + out.append(buffer, std::min(textBytes, available)); + } + if (newlinePtr != nullptr) return true; + } +} + +bool writeAll(int fd, const void* data, size_t len) { + const char* p = static_cast(data); + size_t sent = 0; + while (sent < len) { + ssize_t n = send(fd, p + sent, len - sent, MSG_NOSIGNAL); + if (n <= 0) { + if (n < 0 && errno == EINTR) continue; + return false; + } + sent += static_cast(n); + } + return true; +} + +bool replyOk(int fd) { return writeAll(fd, "OK\n", 3); } + + + + + +socklen_t fillUnixAddr(sockaddr_un& addr, const std::string& path, bool& abstractOut) { + std::memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + if (!path.empty() && path[0] == '@') { + abstractOut = true; + size_t nameLen = path.size() - 1; + if (nameLen > sizeof(addr.sun_path) - 1) nameLen = sizeof(addr.sun_path) - 1; + addr.sun_path[0] = '\0'; + std::memcpy(addr.sun_path + 1, path.data() + 1, nameLen); + return static_cast(offsetof(sockaddr_un, sun_path) + 1 + nameLen); + } + abstractOut = false; + std::strncpy(addr.sun_path, path.c_str(), sizeof(addr.sun_path) - 1); + return static_cast(sizeof(addr)); +} + + + +long long parseKey(const std::string& line, const char* key, long long fallback) { + std::string needle = std::string(key) + "="; + size_t pos = 0; + while ((pos = line.find(needle, pos)) != std::string::npos) { + if (pos == 0 || line[pos - 1] == ' ') { + pos += needle.size(); + return std::strtoll(line.c_str() + pos, nullptr, 0); + } + pos += needle.size(); + } + return fallback; +} + + + +int recvFd(int sockFd) { + char payload = 0; + iovec iov{}; + iov.iov_base = &payload; + iov.iov_len = 1; + + union { + char buf[CMSG_SPACE(sizeof(int))]; + cmsghdr align; + } control{}; + + msghdr msg{}; + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = control.buf; + msg.msg_controllen = sizeof(control.buf); + + ssize_t n; + do { n = ::recvmsg(sockFd, &msg, 0); } while (n < 0 && errno == EINTR); + if (n <= 0) return -1; + + for (cmsghdr* c = CMSG_FIRSTHDR(&msg); c != nullptr; c = CMSG_NXTHDR(&msg, c)) { + if (c->cmsg_level == SOL_SOCKET && c->cmsg_type == SCM_RIGHTS && + c->cmsg_len == CMSG_LEN(sizeof(int))) { + int fd = -1; + std::memcpy(&fd, CMSG_DATA(c), sizeof(int)); + return fd; + } + } + return -1; +} + +bool sendFd(int sockFd, int fd) { + char payload = 'F'; + iovec iov{}; + iov.iov_base = &payload; + iov.iov_len = 1; + union { + char buf[CMSG_SPACE(sizeof(int))]; + cmsghdr align; + } control{}; + msghdr msg{}; + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = control.buf; + msg.msg_controllen = sizeof(control.buf); + cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + cmsg->cmsg_len = CMSG_LEN(sizeof(int)); + std::memcpy(CMSG_DATA(cmsg), &fd, sizeof(fd)); + ssize_t sent; + do { sent = ::sendmsg(sockFd, &msg, MSG_NOSIGNAL); } while (sent < 0 && errno == EINTR); + return sent == 1; +} + +} + +WindowsFrameTransport::WindowsFrameTransport() { + for (int eye = 0; eye < kEyeCount; ++eye) { + for (int image = 0; image < kMaxImages; ++image) { + releaseFenceFds_[eye][image] = -1; + } + } +} + +WindowsFrameTransport::~WindowsFrameTransport() { stop(); } + +void WindowsFrameTransport::start(const std::string& socketPath) { + bool expected = false; + if (!running_.compare_exchange_strong(expected, true)) return; + socketPath_ = socketPath; + acceptThread_ = std::thread([this] { acceptLoop(); }); +} + +void WindowsFrameTransport::stop() { + if (running_.exchange(false)) { + releaseCv_.notify_all(); + int client = clientFd_.exchange(-1); + if (client >= 0) ::shutdown(client, SHUT_RDWR); + int fd = listenFd_.exchange(-1); + if (fd >= 0) { + ::shutdown(fd, SHUT_RDWR); + ::close(fd); + } + } + if (acceptThread_.joinable()) acceptThread_.join(); + for (int eye = 0; eye < kEyeCount; ++eye) releaseEye(eye); + std::lock_guard lock(eyesMutex_); + for (int eye = 0; eye < kEyeCount; ++eye) dropRetainedLocked(eye); +} + +void WindowsFrameTransport::acceptLoop() { + int listenFd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (listenFd < 0) { + LOGE("xr transport: socket() failed: %s", strerror(errno)); + running_ = false; + return; + } + + sockaddr_un addr{}; + bool abstract = false; + socklen_t addrLen = fillUnixAddr(addr, socketPath_, abstract); + if (!abstract) ::unlink(socketPath_.c_str()); + + if (::bind(listenFd, reinterpret_cast(&addr), addrLen) != 0) { + LOGE("xr transport: bind(%s) failed: %s", socketPath_.c_str(), strerror(errno)); + ::close(listenFd); + running_ = false; + return; + } + if (::listen(listenFd, 1) != 0) { + LOGE("xr transport: listen() failed: %s", strerror(errno)); + ::close(listenFd); + running_ = false; + return; + } + listenFd_ = listenFd; + LOGI("xr transport listening on %s", socketPath_.c_str()); + + while (running_.load()) { + int clientFd = ::accept(listenFd, nullptr, nullptr); + if (clientFd < 0) { + if (errno == EINTR) continue; + break; + } + LOGI("xr transport: producer connected"); + clientFd_ = clientFd; + serviceClient(clientFd); + int expectedClient = clientFd; + clientFd_.compare_exchange_strong(expectedClient, -1); + ::close(clientFd); + for (int eye = 0; eye < kEyeCount; ++eye) resetEye(eye); + LOGI("xr transport: producer disconnected"); + } + + int fd = listenFd_.exchange(-1); + if (fd >= 0) ::close(fd); + if (!abstract) ::unlink(socketPath_.c_str()); +} + +void WindowsFrameTransport::serviceClient(int clientFd) { + std::string line; + while (running_.load() && readLine(clientFd, line)) { + if (line.rfind("HELLO", 0) == 0) { + writeAll(clientFd, "OK GameNativeVR 2\n", 18); + } else if (line.rfind("BUFFER", 0) == 0) { + if (!handleBufferLine(clientFd, line)) { + writeAll(clientFd, "ERR buffer\n", 11); + return; + } + } else if (line.rfind("DMABUF", 0) == 0) { + if (!handleDmabufLine(clientFd, line)) { + writeAll(clientFd, "ERR dmabuf\n", 11); + return; + } + } else if (line.rfind("FRAME", 0) == 0) { + if (!handleFrameLine(clientFd, line)) return; + } else if (line.rfind("ACQUIRE", 0) == 0) { + if (!handleAcquireLine(clientFd, line)) return; + } else if (line.rfind("BYE", 0) == 0) { + replyOk(clientFd); + return; + } else { + writeAll(clientFd, "ERR unknown\n", 12); + } + } +} + +bool WindowsFrameTransport::handleBufferLine(int clientFd, const std::string& line) { + long long eye = parseKey(line, "eye", -1); + long long index = parseKey(line, "index", 0); + long long w = parseKey(line, "w", 0); + long long h = parseKey(line, "h", 0); + const bool swapRedBlue = parseKey(line, "swizzle", 0) != 0; + if (eye < 0 || eye >= kEyeCount || index < 0 || index >= kMaxImages || + w <= 0 || h <= 0) { + LOGE("xr transport: bad BUFFER line: %s", line.c_str()); + return false; + } + + if (!replyOk(clientFd)) return false; + + AHardwareBuffer* ahb = nullptr; + if (AHardwareBuffer_recvHandleFromUnixSocket(clientFd, &ahb) != 0 || ahb == nullptr) { + LOGE("xr transport: recvHandleFromUnixSocket failed for eye %lld", eye); + return false; + } + + storeEyeBuffer(static_cast(eye), ahb, + static_cast(w), static_cast(h), + static_cast(index), swapRedBlue); + LOGI("xr transport: received eye %lld AHB buffer %lldx%lld index=%lld", eye, w, h, index); + return writeAll(clientFd, "OK stored\n", 10); +} + +bool WindowsFrameTransport::handleDmabufLine(int clientFd, const std::string& line) { + long long eye = parseKey(line, "eye", -1); + long long imageIndex = parseKey(line, "index", -1); + long long w = parseKey(line, "w", 0); + long long h = parseKey(line, "h", 0); + long long planeCount = parseKey(line, "planes", 1); + if (eye < 0 || eye >= kEyeCount || imageIndex < 0 || imageIndex >= kMaxImages || + w <= 0 || h <= 0 || planeCount < 1 || planeCount > EyeFrame::kMaxPlanes) { + LOGE("xr transport: bad DMABUF line: %s", line.c_str()); + return false; + } + + EyeFrame frame; + frame.kind = BufferKind::DmaBuf; + frame.width = static_cast(w); + frame.height = static_cast(h); + frame.imageIndex = static_cast(imageIndex); + frame.planeCount = static_cast(planeCount); + frame.fourcc = static_cast(parseKey(line, "fourcc", 0)); + frame.modifier = static_cast(parseKey(line, "modifier", 0)); + for (int plane = 0; plane < frame.planeCount; ++plane) { + char strideKey[16]; + char offsetKey[16]; + std::snprintf(strideKey, sizeof(strideKey), "stride%d", plane); + std::snprintf(offsetKey, sizeof(offsetKey), "offset%d", plane); + frame.strides[plane] = static_cast( + parseKey(line, strideKey, plane == 0 ? parseKey(line, "stride", w * 4) : 0)); + frame.offsets[plane] = static_cast( + parseKey(line, offsetKey, plane == 0 ? parseKey(line, "offset", 0) : 0)); + } + + if (!replyOk(clientFd)) return false; + for (int plane = 0; plane < frame.planeCount; ++plane) { + frame.dmabufFds[plane] = recvFd(clientFd); + if (frame.dmabufFds[plane] < 0) { + for (int received = 0; received < plane; ++received) ::close(frame.dmabufFds[received]); + LOGE("xr transport: recvFd (dma-buf plane %d) failed for eye %lld", plane, eye); + return false; + } + } + + storeEyeDmabuf(static_cast(eye), frame); + LOGI("xr transport: received eye %lld dma-buf %lldx%lld fourcc=0x%08x planes=%d mod=0x%llx", + eye, w, h, frame.fourcc, frame.planeCount, + static_cast(frame.modifier)); + return writeAll(clientFd, "OK stored\n", 10); +} + +bool WindowsFrameTransport::handleFrameLine(int clientFd, const std::string& line) { + const long long eye = parseKey(line, "eye", -1); + const long long index = parseKey(line, "index", -1); + const bool hasFence = parseKey(line, "fence", 0) != 0; + if (eye < 0 || eye >= kEyeCount || index < 0 || index >= kMaxImages) { + writeAll(clientFd, "ERR frame\n", 10); + return true; + } + + int acquireFenceFd = -1; + if (hasFence) { + if (!replyOk(clientFd)) return false; + acquireFenceFd = recvFd(clientFd); + if (acquireFenceFd < 0) return false; + } + + { + std::lock_guard lock(eyesMutex_); + EyeFrame& registered = buffers_[eye][index]; + if (registered.kind == BufferKind::None) { + if (acquireFenceFd >= 0) ::close(acquireFenceFd); + writeAll(clientFd, "ERR unregistered\n", 17); + return true; + } + const long long sourceX = parseKey(line, "x", 0); + const long long sourceY = parseKey(line, "y", 0); + const long long sourceWidth = parseKey(line, "w", registered.width); + const long long sourceHeight = parseKey(line, "h", registered.height); + if (sourceX < 0 || sourceY < 0 || sourceWidth <= 0 || sourceHeight <= 0 || + sourceX + sourceWidth > registered.width || + sourceY + sourceHeight > registered.height) { + if (acquireFenceFd >= 0) ::close(acquireFenceFd); + writeAll(clientFd, "ERR rect\n", 9); + return true; + } + if (latest_[eye].acquireFenceFd >= 0) { + ::close(latest_[eye].acquireFenceFd); + } + if (latest_[eye].kind != BufferKind::None && !latestClaimed_[eye]) { + const int dropped = latest_[eye].imageIndex; + if (dropped >= 0 && dropped < kMaxImages && + releasePending_[eye][dropped]) { + releasePending_[eye][dropped] = false; + if (releaseFenceFds_[eye][dropped] >= 0) { + ::close(releaseFenceFds_[eye][dropped]); + releaseFenceFds_[eye][dropped] = -1; + } + } + } + latest_[eye] = registered; + latest_[eye].sourceX = static_cast(sourceX); + latest_[eye].sourceY = static_cast(sourceY); + latest_[eye].sourceWidth = static_cast(sourceWidth); + latest_[eye].sourceHeight = static_cast(sourceHeight); + latest_[eye].flipY = parseKey(line, "flip", 0) != 0; + latest_[eye].projectionValid = + parseKey(line, "projection", 0) != 0; + if (latest_[eye].projectionValid) { + static constexpr const char* kOrientationKeys[4] = { + "qx", "qy", "qz", "qw"}; + static constexpr const char* kPositionKeys[3] = { + "px", "py", "pz"}; + static constexpr const char* kFovKeys[4] = { + "fl", "fr", "fu", "fd"}; + for (int component = 0; component < 4; ++component) { + latest_[eye].projectionOrientation[component] = + static_cast(parseKey( + line, kOrientationKeys[component], + component == 3 ? 1000000 : 0)) / 1000000.0f; + latest_[eye].projectionFov[component] = + static_cast(parseKey( + line, kFovKeys[component], + component == 0 || component == 3 + ? -750000 : 750000)) / 1000000.0f; + } + for (int component = 0; component < 3; ++component) { + latest_[eye].projectionPosition[component] = + static_cast(parseKey( + line, kPositionKeys[component], 0)) / 1000000.0f; + } + } + latest_[eye].acquireFenceFd = acquireFenceFd; + latest_[eye].serial = nextSerial_++; + latestClaimed_[eye] = false; + if (releaseFenceFds_[eye][index] >= 0) { + ::close(releaseFenceFds_[eye][index]); + releaseFenceFds_[eye][index] = -1; + } + releasePending_[eye][index] = true; + } + releaseCv_.notify_all(); + return writeAll(clientFd, hasFence ? "OK stored\n" : "OK\n", + hasFence ? 10 : 3); +} + +bool WindowsFrameTransport::handleAcquireLine(int clientFd, const std::string& line) { + const long long eye = parseKey(line, "eye", -1); + const long long index = parseKey(line, "index", -1); + const long long timeoutMs = parseKey(line, "timeout", 5000); + if (eye < 0 || eye >= kEyeCount || index < 0 || index >= kMaxImages) { + return writeAll(clientFd, "ERR acquire\n", 12); + } + + std::unique_lock lock(eyesMutex_); + const auto ready = [this, eye, index] { + return !running_.load() || + !releasePending_[eye][index] || + releaseFenceFds_[eye][index] >= 0; + }; + bool signaled; + if (timeoutMs < 0) { + releaseCv_.wait(lock, ready); + signaled = true; + } else { + signaled = releaseCv_.wait_for( + lock, std::chrono::milliseconds(timeoutMs), ready); + } + if (!signaled) { + LOGW("xr transport: timed out waiting for release eye=%lld index=%lld", eye, index); + return writeAll(clientFd, "ERR timeout\n", 12); + } + if (!running_.load()) return false; + + const int fenceFd = releaseFenceFds_[eye][index]; + releaseFenceFds_[eye][index] = -1; + releasePending_[eye][index] = false; + lock.unlock(); + + if (fenceFd < 0) return writeAll(clientFd, "OK fence=0\n", 11); + const bool sent = writeAll(clientFd, "OK fence=1\n", 11) && + sendFd(clientFd, fenceFd); + ::close(fenceFd); + return sent; +} + + +void WindowsFrameTransport::releaseSlotLocked(int eye, int imageIndex) { + EyeFrame& slot = buffers_[eye][imageIndex]; + if (slot.kind == BufferKind::HardwareBuffer && slot.buffer != nullptr) { + AHardwareBuffer_release(slot.buffer); + } else if (slot.kind == BufferKind::DmaBuf) { + for (int plane = 0; plane < slot.planeCount; ++plane) { + if (slot.dmabufFds[plane] >= 0) ::close(slot.dmabufFds[plane]); + } + } + if (slot.acquireFenceFd >= 0) ::close(slot.acquireFenceFd); + slot = EyeFrame{}; + if (releaseFenceFds_[eye][imageIndex] >= 0) { + ::close(releaseFenceFds_[eye][imageIndex]); + releaseFenceFds_[eye][imageIndex] = -1; + } + releasePending_[eye][imageIndex] = false; +} + +void WindowsFrameTransport::storeEyeBuffer(int eye, AHardwareBuffer* ahb, + int32_t w, int32_t h, int32_t index, + bool swapRedBlue) { + std::lock_guard lock(eyesMutex_); + if (latest_[eye].kind != BufferKind::None && + latest_[eye].imageIndex == index) { + if (latest_[eye].acquireFenceFd >= 0) + ::close(latest_[eye].acquireFenceFd); + latest_[eye] = EyeFrame{}; + latestClaimed_[eye] = false; + } + releaseSlotLocked(eye, index); + EyeFrame& slot = buffers_[eye][index]; + slot.kind = BufferKind::HardwareBuffer; + slot.buffer = ahb; + slot.swapRedBlue = swapRedBlue; + slot.width = w; + slot.height = h; + slot.imageIndex = index; + slot.registrationSerial = nextSerial_++; +} + +void WindowsFrameTransport::storeEyeDmabuf(int eye, const EyeFrame& incoming) { + std::lock_guard lock(eyesMutex_); + if (latest_[eye].kind != BufferKind::None && + latest_[eye].imageIndex == incoming.imageIndex) { + if (latest_[eye].acquireFenceFd >= 0) + ::close(latest_[eye].acquireFenceFd); + latest_[eye] = EyeFrame{}; + latestClaimed_[eye] = false; + } + releaseSlotLocked(eye, incoming.imageIndex); + buffers_[eye][incoming.imageIndex] = incoming; + buffers_[eye][incoming.imageIndex].registrationSerial = nextSerial_++; +} + +void WindowsFrameTransport::releaseEye(int eye) { + std::lock_guard lock(eyesMutex_); + if (latest_[eye].acquireFenceFd >= 0) { + ::close(latest_[eye].acquireFenceFd); + } + latest_[eye] = EyeFrame{}; + latestClaimed_[eye] = false; + for (int image = 0; image < kMaxImages; ++image) { + releaseSlotLocked(eye, image); + } + releaseCv_.notify_all(); +} + +void WindowsFrameTransport::resetEye(int eye) { + std::lock_guard lock(eyesMutex_); + if (latest_[eye].acquireFenceFd >= 0) { + ::close(latest_[eye].acquireFenceFd); + } + latest_[eye] = EyeFrame{}; + latestClaimed_[eye] = false; + for (int image = 0; image < kMaxImages; ++image) { + if (releaseFenceFds_[eye][image] >= 0) { + ::close(releaseFenceFds_[eye][image]); + releaseFenceFds_[eye][image] = -1; + } + releasePending_[eye][image] = false; + } + releaseCv_.notify_all(); +} + +void WindowsFrameTransport::dropRetainedLocked(int eye) { + EyeFrame& held = retained_[eye]; + if (held.kind == BufferKind::HardwareBuffer && held.buffer != nullptr) { + AHardwareBuffer_release(held.buffer); + } else if (held.kind == BufferKind::DmaBuf) { + for (int plane = 0; plane < held.planeCount; ++plane) { + if (held.dmabufFds[plane] >= 0) ::close(held.dmabufFds[plane]); + } + } + held = EyeFrame{}; +} + +EyeFrame WindowsFrameTransport::pollEye(int eye) { + if (eye < 0 || eye >= kEyeCount) return EyeFrame{}; + std::lock_guard lock(eyesMutex_); + EyeFrame snapshot = latest_[eye]; + latest_[eye].acquireFenceFd = -1; + if (snapshot.kind == BufferKind::None) return snapshot; + latestClaimed_[eye] = true; + dropRetainedLocked(eye); + if (snapshot.kind == BufferKind::HardwareBuffer && snapshot.buffer != nullptr) { + AHardwareBuffer_acquire(snapshot.buffer); + } else if (snapshot.kind == BufferKind::DmaBuf) { + for (int plane = 0; plane < snapshot.planeCount; ++plane) { + snapshot.dmabufFds[plane] = + snapshot.dmabufFds[plane] >= 0 ? ::dup(snapshot.dmabufFds[plane]) : -1; + } + } + retained_[eye] = snapshot; + retained_[eye].acquireFenceFd = -1; + return snapshot; +} + +void WindowsFrameTransport::publishReleaseFence(int eye, int imageIndex, int releaseFenceFd) { + if (eye < 0 || eye >= kEyeCount || + imageIndex < 0 || imageIndex >= kMaxImages) { + if (releaseFenceFd >= 0) ::close(releaseFenceFd); + return; + } + { + std::lock_guard lock(eyesMutex_); + if (releaseFenceFds_[eye][imageIndex] >= 0) { + ::close(releaseFenceFds_[eye][imageIndex]); + } + releaseFenceFds_[eye][imageIndex] = releaseFenceFd; + if (releaseFenceFd < 0) releasePending_[eye][imageIndex] = false; + } + releaseCv_.notify_all(); +} + +void WindowsFrameTransport::discardFrame(int eye, int imageIndex, uint64_t serial) { + if (eye < 0 || eye >= kEyeCount || + imageIndex < 0 || imageIndex >= kMaxImages) return; + { + std::lock_guard lock(eyesMutex_); + if (latest_[eye].serial == serial && + latest_[eye].imageIndex == imageIndex) { + if (latest_[eye].acquireFenceFd >= 0) + ::close(latest_[eye].acquireFenceFd); + latest_[eye] = EyeFrame{}; + latestClaimed_[eye] = true; + } + if (releaseFenceFds_[eye][imageIndex] >= 0) { + ::close(releaseFenceFds_[eye][imageIndex]); + releaseFenceFds_[eye][imageIndex] = -1; + } + releasePending_[eye][imageIndex] = false; + } + releaseCv_.notify_all(); +} + +bool WindowsFrameTransport::hasStereoContent() const { + std::lock_guard lock(const_cast(eyesMutex_)); + return latest_[0].kind != BufferKind::None && + latest_[1].kind != BufferKind::None; +} + +} diff --git a/app/src/main/cpp/xrimmersive/xr_windows_transport.h b/app/src/main/cpp/xrimmersive/xr_windows_transport.h new file mode 100644 index 0000000000..50a10210b6 --- /dev/null +++ b/app/src/main/cpp/xrimmersive/xr_windows_transport.h @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace xrimmersive::windowsvr { + + + +enum class BufferKind { None, HardwareBuffer, DmaBuf }; + + + + +struct EyeFrame { + static constexpr int kMaxPlanes = 4; + BufferKind kind{BufferKind::None}; + + AHardwareBuffer* buffer{nullptr}; + bool swapRedBlue{false}; + + int planeCount{0}; + int dmabufFds[kMaxPlanes]{-1, -1, -1, -1}; + uint32_t fourcc{0}; + uint32_t strides[kMaxPlanes]{0, 0, 0, 0}; + uint32_t offsets[kMaxPlanes]{0, 0, 0, 0}; + uint64_t modifier{0}; + + int32_t width{0}; + int32_t height{0}; + int32_t sourceX{0}; + int32_t sourceY{0}; + int32_t sourceWidth{0}; + int32_t sourceHeight{0}; + bool flipY{false}; + bool projectionValid{false}; + float projectionOrientation[4]{0.0f, 0.0f, 0.0f, 1.0f}; + float projectionPosition[3]{0.0f, 0.0f, 0.0f}; + float projectionFov[4]{-0.75f, 0.75f, 0.75f, -0.75f}; + int32_t imageIndex{0}; + int32_t acquireFenceFd{-1}; + uint64_t registrationSerial{0}; + uint64_t serial{0}; +}; + +class WindowsFrameTransport { +public: + static constexpr int kEyeCount = 2; + static constexpr int kMaxImages = 128; + + WindowsFrameTransport(); + ~WindowsFrameTransport(); + + WindowsFrameTransport(const WindowsFrameTransport&) = delete; + WindowsFrameTransport& operator=(const WindowsFrameTransport&) = delete; + + void start(const std::string& socketPath); + + void stop(); + + EyeFrame pollEye(int eye); + + void publishReleaseFence(int eye, int imageIndex, int releaseFenceFd); + + void discardFrame(int eye, int imageIndex, uint64_t serial); + + bool hasStereoContent() const; + +private: + void acceptLoop(); + void serviceClient(int clientFd); + bool handleBufferLine(int clientFd, const std::string& line); + bool handleDmabufLine(int clientFd, const std::string& line); + bool handleFrameLine(int clientFd, const std::string& line); + bool handleAcquireLine(int clientFd, const std::string& line); + void storeEyeBuffer(int eye, AHardwareBuffer* ahb, int32_t w, int32_t h, + int32_t index, bool swapRedBlue); + void storeEyeDmabuf(int eye, const EyeFrame& incoming); + void releaseSlotLocked(int eye, int imageIndex); + void releaseEye(int eye); + void resetEye(int eye); + void dropRetainedLocked(int eye); + + std::string socketPath_; + std::atomic running_{false}; + std::atomic listenFd_{-1}; + std::atomic clientFd_{-1}; + std::thread acceptThread_; + + std::mutex eyesMutex_; + std::condition_variable releaseCv_; + EyeFrame buffers_[kEyeCount][kMaxImages]; + EyeFrame latest_[kEyeCount]; + EyeFrame retained_[kEyeCount]; + bool latestClaimed_[kEyeCount]{false, false}; + int releaseFenceFds_[kEyeCount][kMaxImages]; + bool releasePending_[kEyeCount][kMaxImages]{}; + uint64_t nextSerial_{1}; +}; + +} diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index d47930645c..b3fdc6bfea 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -558,6 +558,11 @@ class MainActivity : ComponentActivity() { } override fun onPause() { + if (PluviaApp.isImmersiveActivityResumed) { + Timber.d("Launcher paused behind the immersive activity; game stays in the foreground") + super.onPause() + return + } PowerManager.pause() PluviaApp.isActivityInForeground = false if (hasReadyGameLifecycleState("pause")) { diff --git a/app/src/main/java/app/gamenative/PluviaApp.kt b/app/src/main/java/app/gamenative/PluviaApp.kt index 215c4d1e11..10a7ecf2c4 100644 --- a/app/src/main/java/app/gamenative/PluviaApp.kt +++ b/app/src/main/java/app/gamenative/PluviaApp.kt @@ -224,6 +224,7 @@ class PluviaApp : SplitCompatApplication() { var isOverlayPaused by mutableStateOf(false) @Volatile var isActivityInForeground: Boolean = true + var isImmersiveActivityResumed: Boolean = false // True while the booting splash covers the game screen (and its Resume overlay). @Volatile var isBootingSplashShowing: Boolean = false diff --git a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt index 81c91f5796..b44f1a2fea 100644 --- a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt +++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt @@ -586,6 +586,7 @@ fun QuickMenu( val powerItemFocusRequester = remember { FocusRequester() } val immersiveScrollState = rememberScrollState() val immersiveTabFocusRequester = remember { FocusRequester() } + val exitFocusRequester = remember { FocusRequester() } val immersiveItemFocusRequester = remember { FocusRequester() } val visibleState = remember { MutableTransitionState(false) } @@ -617,6 +618,9 @@ fun QuickMenu( } LaunchedEffect(Unit) { + immersiveHooks?.registerFocusExit?.invoke { + runCatching { exitFocusRequester.requestFocus() } + } immersiveHooks?.registerFocusTabRail?.invoke { val requester = when (selectedTab) { QuickMenuTab.HUD -> hudTabFocusRequester @@ -908,6 +912,7 @@ fun QuickMenu( } }, modifier = Modifier.width(56.dp), + focusRequester = exitFocusRequester, ) } @@ -1779,6 +1784,38 @@ private fun ImmersiveQuickMenuTab( verticalArrangement = Arrangement.spacedBy(4.dp), ) { // ── Passthrough ──────────────────────────────────────────────── + QuickMenuSectionHeader( + title = stringResource(R.string.immersive_windows_vr_title), + subtitle = "${controls.windowsVrStatus} · ${controls.windowsVrRuntimePath}", + ) + QuickMenuToggleRow( + title = stringResource(R.string.immersive_windows_vr_toggle), + enabled = controls.windowsVrEnabled, + onToggle = { controls.onWindowsVrToggle(!controls.windowsVrEnabled) }, + accentColor = accentColor, + focusRequester = focusRequester, + ) + QuickMenuToggleRow( + title = stringResource(R.string.immersive_openvr_compatibility_toggle), + enabled = controls.openCompositeEnabled, + onToggle = { controls.onOpenCompositeToggle(!controls.openCompositeEnabled) }, + accentColor = accentColor, + ) + Text( + text = stringResource(R.string.immersive_windows_vr_restart_required), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + QuickMenuDetailRow( + title = stringResource(R.string.immersive_windows_vr_export), + subtitle = stringResource(R.string.immersive_windows_vr_export_desc), + accentColor = accentColor, + onActivate = controls.onExportWindowsVrDiagnostics, + ) + + Spacer(modifier = Modifier.height(4.dp)) + QuickMenuSectionHeader( title = stringResource(R.string.immersive_passthrough), subtitle = stringResource(R.string.immersive_passthrough_desc), @@ -1788,7 +1825,6 @@ private fun ImmersiveQuickMenuTab( enabled = controls.passthroughEnabled, onToggle = { controls.onPassthroughToggle(!controls.passthroughEnabled) }, accentColor = accentColor, - focusRequester = focusRequester, ) Spacer(modifier = Modifier.height(4.dp)) @@ -2016,6 +2052,9 @@ private fun QuickMenuRailActionButton( // avoid a second focus target on the same element (see LocalImmersiveInputBypass). val inputBypass = LocalImmersiveInputBypass.current val isFocused by interactionSource.collectIsFocusedAsState() + LaunchedEffect(isFocused) { + inputBypass.reportActivate(interactionSource, if (isFocused) onClick else null) + } val accentColor = if (item.accentColor != Color.Unspecified) { item.accentColor } else { diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt index b30143fd4a..7baaa14b1e 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt @@ -172,7 +172,7 @@ fun GraphicsTabContent(state: ContainerConfigState, default: Boolean = false) { state.config.value = config.copy(graphicsDriverConfig = cfg.toString()) }, ) - if (app.gamenative.BuildConfig.XR_BUILD) { + if (app.gamenative.BuildConfig.XR_BUILD && !default) { val xrRates = listOf(72, 90, 120) SettingsListDropdown( colors = settingsTileColors(), @@ -183,6 +183,29 @@ fun GraphicsTabContent(state: ContainerConfigState, default: Boolean = false) { state.config.value = config.copy(xrRefreshRate = xrRates[idx]) }, ) + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text(text = stringResource(R.string.xr_render_scale)) + Slider( + value = config.xrRenderScale.toFloat(), + onValueChange = { newValue -> + val stepped = ((newValue.roundToInt() + 2) / 5 * 5).coerceIn(25, 100) + state.config.value = config.copy(xrRenderScale = stepped) + }, + valueRange = 25f..100f, + ) + Text(text = "${config.xrRenderScale}%") + } + if (config.windowsVrEnabled) { + SettingsSwitch( + colors = settingsTileColorsAlt(), + title = { Text(text = stringResource(R.string.xr_open_composite_toggle)) }, + subtitle = { Text(text = stringResource(R.string.xr_open_composite_toggle_desc)) }, + state = config.openCompositeEnabled, + onCheckedChange = { checked -> + state.config.value = config.copy(openCompositeEnabled = checked) + }, + ) + } } SettingsListDropdown( colors = settingsTileColors(), diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt index 8106d71ab2..57bdce2c16 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -654,7 +654,7 @@ class MainViewModel @Inject constructor( if (app.gamenative.BuildConfig.XR_BUILD && container.isLaunchImmersiveMode() && - app.gamenative.MainActivity.isMetaQuest() + app.gamenative.MainActivity.isHeadset(context) ) { bootingSplashTimeoutJob?.cancel() bootingSplashTimeoutJob = null diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 1d67adcce8..0668a26282 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -519,6 +519,8 @@ internal data class ImmersiveModeUiState( val isSupported: Boolean = false, val isEnabled: Boolean = false, val onChange: (Boolean) -> Unit = {}, + val isVrEnabled: Boolean = false, + val onVrChange: (Boolean) -> Unit = {}, ) @Composable @@ -976,6 +978,28 @@ internal fun AppScreenContent( color = Color.White, ) } + if (immersiveMode.isEnabled) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp) + .clickable { immersiveMode.onVrChange(!immersiveMode.isVrEnabled) }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = immersiveMode.isVrEnabled, + onCheckedChange = immersiveMode.onVrChange, + colors = CheckboxDefaults.colors( + uncheckedColor = Color.White.copy(alpha = 0.7f), + ), + ) + Text( + text = stringResource(R.string.xr_windows_vr_toggle), + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + ) + } + } } if (isDownloading && isPortrait) { diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index 4c07da73b5..37986994c8 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -67,6 +67,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import timber.log.Timber @@ -157,6 +158,7 @@ internal suspend fun installMissingComponentsForConfig( abstract class BaseAppScreen { companion object { + private const val WINDOWS_VR_ENABLED_EXTRA = "windowsVrEnabled" private val installDialogStates = mutableStateMapOf() private val exportConfigRequests = mutableStateMapOf() private val importConfigRequests = mutableStateMapOf() @@ -1251,27 +1253,50 @@ abstract class BaseAppScreen { mutableStateOf?>(null) } - // Immersive/VR launch mode is only offered on the modernXr build running on Meta Quest. val isImmersiveModeSupported = remember(libraryItem.appId) { - app.gamenative.BuildConfig.XR_BUILD && app.gamenative.MainActivity.isMetaQuest() + app.gamenative.BuildConfig.XR_BUILD && app.gamenative.MainActivity.isHeadset(context) } var isImmersiveModeEnabledState by remember(libraryItem.appId) { mutableStateOf(null) } + var isVrModeEnabledState by remember(libraryItem.appId) { mutableStateOf(false) } val immersiveModeSaveRequests = remember(libraryItem.appId) { Channel(Channel.CONFLATED) } + val vrModeSaveRequests = remember(libraryItem.appId) { Channel(Channel.CONFLATED) } + val containerSaveMutex = remember(libraryItem.appId) { kotlinx.coroutines.sync.Mutex() } if (isImmersiveModeSupported) { LaunchedEffect(libraryItem.appId) { val stored = withContext(Dispatchers.IO) { - runCatching { ContainerUtils.getContainer(context, libraryItem.appId).isLaunchImmersiveMode() } - .getOrDefault(true) + runCatching { + val container = ContainerUtils.getContainer(context, libraryItem.appId) + container.isLaunchImmersiveMode() to + container.getExtra(WINDOWS_VR_ENABLED_EXTRA, "false").toBoolean() + }.getOrDefault(true to false) } if (isImmersiveModeEnabledState == null) { - isImmersiveModeEnabledState = stored + isImmersiveModeEnabledState = stored.first + isVrModeEnabledState = stored.second } - for (enabled in immersiveModeSaveRequests) { - withContext(Dispatchers.IO) { - runCatching { - val container = ContainerUtils.getContainer(context, libraryItem.appId) - container.setLaunchImmersiveMode(enabled) - container.saveData() + launch { + for (enabled in immersiveModeSaveRequests) { + containerSaveMutex.withLock { + withContext(Dispatchers.IO) { + runCatching { + val container = ContainerUtils.getContainer(context, libraryItem.appId) + container.setLaunchImmersiveMode(enabled) + container.saveData() + } + } + } + } + } + launch { + for (enabled in vrModeSaveRequests) { + containerSaveMutex.withLock { + withContext(Dispatchers.IO) { + runCatching { + val container = ContainerUtils.getContainer(context, libraryItem.appId) + container.putExtra(WINDOWS_VR_ENABLED_EXTRA, enabled.toString()) + container.saveData() + } + } } } } @@ -1610,6 +1635,11 @@ abstract class BaseAppScreen { isImmersiveModeEnabledState = enabled immersiveModeSaveRequests.trySend(enabled) }, + isVrEnabled = isVrModeEnabledState, + onVrChange = { enabled -> + isVrModeEnabledState = enabled + vrModeSaveRequests.trySend(enabled) + }, ), onDownloadInstallClick = { if (app.gamenative.launch.LaunchReadiness.pending) { diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveControls.kt b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveControls.kt index 6b56dfa67c..cb24d00a42 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveControls.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveControls.kt @@ -12,6 +12,13 @@ data class ImmersiveControls( val onResetScreenEffects: () -> Unit = {}, val resizeModeEnabled: Boolean = false, val onResizeModeToggle: (Boolean) -> Unit = {}, + val windowsVrEnabled: Boolean = false, + val onWindowsVrToggle: (Boolean) -> Unit = {}, + val openCompositeEnabled: Boolean = false, + val onOpenCompositeToggle: (Boolean) -> Unit = {}, + val windowsVrStatus: String = "Waiting for runtime", + val windowsVrRuntimePath: String = "Native OpenXR", + val onExportWindowsVrDiagnostics: () -> Unit = {}, ) { companion object { const val MIN_DISTANCE = 1.0f diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveSessionHooks.kt b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveSessionHooks.kt index 9c3d5194c7..1bf459039c 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveSessionHooks.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveSessionHooks.kt @@ -13,11 +13,13 @@ import androidx.compose.ui.focus.FocusManager */ class ImmersiveSessionHooks( val controls: ImmersiveControls, + val windowsVr: app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeService? = null, val onQuickMenuVisibilityChanged: (Boolean) -> Unit = {}, val registerFocusManager: ((FocusManager) -> Unit)? = null, val registerCycleTab: (((Boolean) -> Unit) -> Unit)? = null, val registerAdjustmentControl: ((() -> Pair<() -> Unit, () -> Unit>?) -> Unit)? = null, val registerFocusTabRail: ((() -> Unit) -> Unit)? = null, + val registerFocusExit: ((() -> Unit) -> Unit)? = null, val registerFocusedActivate: ((() -> (() -> Unit)?) -> Unit)? = null, val registerToggle: ((() -> Unit) -> Unit)? = null, val registerStartHeld: (((Boolean) -> Unit) -> Unit)? = null, diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt index 34b4a347f3..af6de717d0 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt @@ -77,6 +77,7 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { private const val POLL_INTERVAL_MS = 11L private const val CAPTURE_RETRY_DELAY_MS = 200L private const val OVERLAY_REFRESH_INTERVAL_MS = 33L // ~30fps — plenty for a menu/HUD, + private const val OVERLAY_IDLE_REFRESH_INTERVAL_MS = 250L private const val OVERLAY_CONTENT_GRACE_MS = 2500L private const val IMMERSIVE_UI_DENSITY = 2.5f @@ -99,6 +100,8 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { private const val EXTRA_QUAD_DISTANCE = "immersiveQuadDistance" private const val EXTRA_QUAD_SCALE = "immersiveQuadScale" private const val EXTRA_PASSTHROUGH_ENABLED = "immersivePassthroughEnabled" + private const val EXTRA_WINDOWS_VR_ENABLED = "windowsVrEnabled" + private const val EXTRA_WINDOWS_VR_OPEN_COMPOSITE = "windowsVrOpenCompositeEnabled" fun start(context: Context, appId: String, isOffline: Boolean) { val intent = Intent(context, ImmersiveXrActivity::class.java).apply { @@ -129,6 +132,7 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { @Volatile private var quickMenuFocusTabRail: (() -> Unit)? = null + private var quickMenuFocusExit: (() -> Unit)? = null @Volatile private var quickMenuFocusedActivate: (() -> (() -> Unit)?)? = null @@ -175,15 +179,18 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { private var directGLBridge: DirectGLBridge? = null private var directVulkanBridge: DirectVulkanBridge? = null private var bridgedRenderer: Any? = null + private var windowsVrRuntimeService: app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeService? = null @Volatile private var directRenderActive = false + @Volatile + private var flatPresentationSuspended = false private var directRenderProbeLogged = false private var lastMenuDpadKeyCode: Int? = null private var lastMenuDpadHeldSince = 0L private var lastMenuDpadEventTime = 0L - private var lastMenuButtonAPressed = false - private var lastMenuButtonBPressed = false + private var lastMenuConfirmPressed = false + private var lastMenuBackPressed = false private var lastMenuButtonLBPressed = false private var lastMenuButtonRBPressed = false @@ -220,6 +227,11 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { private var quadVertical by mutableFloatStateOf(ImmersiveControls.DEFAULT_OFFSET) private var quadScale by mutableFloatStateOf(ImmersiveControls.DEFAULT_SCALE) private var passthroughEnabled by mutableStateOf(false) + private var windowsVrEnabled by mutableStateOf(false) + @Volatile + private var immersiveSettingsLoaded = false + private var openCompositeEnabled by mutableStateOf(false) + private var windowsVrStatus by mutableStateOf("Waiting for runtime") private var showControlsOnboarding by mutableStateOf(false) private var mappedWindowCount by androidx.compose.runtime.mutableIntStateOf(0) private var overlayPausedUi by mutableStateOf(false) @@ -248,6 +260,7 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { return } currentAppId = appId + windowsVrRuntimeService = app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeService(this) PluviaApp.isActivityInForeground = true AppUtils.keepScreenOn(this) @@ -288,23 +301,37 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { }, onGameLaunchError = { error -> viewModel.onGameLaunchError(error) + val vrEnabled = if (immersiveSettingsLoaded) { + windowsVrEnabled + } else { + (cachedContainer ?: runCatching { ContainerUtils.getContainer(this@ImmersiveXrActivity, appId) }.getOrNull()) + ?.let { app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeConfig.from(it).enabled } + ?: windowsVrEnabled + } + if (vrEnabled) { + runOnUiThread { windowsVrStatus = "Guest stopped: $error" } + windowsVrRuntimeService?.onGuestProcessError(error) + } finish() }, immersiveHooks = ImmersiveSessionHooks( - onQuickMenuVisibilityChanged = { visible -> - Timber.i("Immersive: quick menu visibility changed to %b", visible) - quickMenuVisible = visible - if (visible) { - directRenderBlockedByEffects = (PluviaApp.xServerView?.renderer as? com.winlator.renderer.VulkanRenderer) - ?.isEffectsRequireCompositor() - } - }, + windowsVr = windowsVrRuntimeService, + onQuickMenuVisibilityChanged = { visible -> + Timber.i("Immersive: quick menu visibility changed to %b", visible) + quickMenuVisible = visible + if (xrSessionHandle != 0L) XrNative.nativeSetWindowsOverlayVisible(xrSessionHandle, visible) + if (visible) { + directRenderBlockedByEffects = (PluviaApp.xServerView?.renderer as? com.winlator.renderer.VulkanRenderer) + ?.isEffectsRequireCompositor() + } + }, registerToggle = { toggle -> quickMenuToggle = toggle }, registerStartHeld = { setter -> quickMenuSetStartHeld = setter }, registerFocusManager = { fm -> quickMenuFocusManager = fm }, registerCycleTab = { cycle -> quickMenuCycleTab = cycle }, registerAdjustmentControl = { control -> quickMenuAdjustmentControl = control }, registerFocusTabRail = { action -> quickMenuFocusTabRail = action }, + registerFocusExit = { action -> quickMenuFocusExit = action }, registerFocusedActivate = { getter -> quickMenuFocusedActivate = getter }, controls = ImmersiveControls( passthroughEnabled = passthroughEnabled, @@ -335,6 +362,27 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { } Timber.i("Immersive: resize handles toggled %s from quick menu, pointer mode now %s", enabled, enabled) }, + windowsVrEnabled = windowsVrEnabled, + onWindowsVrToggle = { enabled -> + windowsVrEnabled = enabled + windowsVrStatus = if (enabled) "Restart required" else "Disabled" + persistImmersiveSettings(appId) + }, + openCompositeEnabled = openCompositeEnabled, + onOpenCompositeToggle = { enabled -> + openCompositeEnabled = enabled + windowsVrStatus = "Restart required" + persistImmersiveSettings(appId) + }, + windowsVrStatus = windowsVrStatus, + windowsVrRuntimePath = if (openCompositeEnabled) "OpenVR compatibility" else "Native OpenXR", + onExportWindowsVrDiagnostics = { + lifecycleScope.launch(Dispatchers.IO) { + val result = runCatching { windowsVrRuntimeService?.exportDiagnostics() } + val message = result.getOrNull()?.path ?: result.exceptionOrNull()?.message ?: "Windows VR is inactive" + app.gamenative.ui.util.SnackbarManager.show(message) + } + }, ), ), ) @@ -374,6 +422,10 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { quadScale = container.getExtra(EXTRA_QUAD_SCALE, ImmersiveControls.DEFAULT_SCALE.toString()) .toFloatOrNull() ?: ImmersiveControls.DEFAULT_SCALE passthroughEnabled = container.getExtra(EXTRA_PASSTHROUGH_ENABLED, "false").toBoolean() + windowsVrEnabled = container.getExtra(EXTRA_WINDOWS_VR_ENABLED, "false").toBoolean() + openCompositeEnabled = container.getExtra(EXTRA_WINDOWS_VR_OPEN_COMPOSITE, "false").toBoolean() + windowsVrStatus = if (windowsVrEnabled) "Waiting for runtime" else "Disabled" + immersiveSettingsLoaded = true applyQuadTransform() if (xrSessionHandle != 0L) { XrNative.nativeSetPassthroughEnabled(xrSessionHandle, passthroughEnabled) @@ -392,6 +444,8 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { container.putExtra(EXTRA_QUAD_DISTANCE, quadDistance.toString()) container.putExtra(EXTRA_QUAD_SCALE, quadScale.toString()) container.putExtra(EXTRA_PASSTHROUGH_ENABLED, passthroughEnabled.toString()) + container.putExtra(EXTRA_WINDOWS_VR_ENABLED, windowsVrEnabled.toString()) + container.putExtra(EXTRA_WINDOWS_VR_OPEN_COMPOSITE, openCompositeEnabled.toString()) container.saveData() } } @@ -421,6 +475,7 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { override fun onResume() { super.onResume() + PluviaApp.isImmersiveActivityResumed = true PluviaApp.isActivityInForeground = true if (SteamService.keepAlive && PluviaApp.hasValidSuspendPolicyState() && PluviaApp.xEnvironment != null) { when { @@ -433,6 +488,7 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { } override fun onPause() { + PluviaApp.isImmersiveActivityResumed = false PluviaApp.isActivityInForeground = false Timber.i( "Immersive: onPause, isFinishing=%b isChangingConfigurations=%b", @@ -451,6 +507,8 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { override fun onDestroy() { stopXrSession() + windowsVrRuntimeService?.close() + windowsVrRuntimeService = null PluviaApp.shutdownEnvironment() super.onDestroy() } @@ -486,6 +544,7 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { } applyQuadTransform() + windowsVrRuntimeService?.attachSession(xrSessionHandle) XrNative.nativeSetPassthroughEnabled(xrSessionHandle, passthroughEnabled) startControllerPollingLoop() @@ -508,6 +567,13 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { cachedBridge = XrGamepadBridge(winHandler) } val quickMenuClicked = XrNative.nativePollSnapshot(xrSessionHandle, buttons, axes, handPoses, flags) + val stereoActive = XrNative.nativeIsWindowsStereoActive(xrSessionHandle) + if (stereoActive != flatPresentationSuspended) { + flatPresentationSuspended = stereoActive + windowsVrStatus = if (stereoActive) "Stereo active" else if (windowsVrEnabled) "Flat fallback" else "Disabled" + windowsVrRuntimeService?.onPresentationState(windowsVrStatus) + runOnUiThread { applyFlatPresentationGate(!stereoActive) } + } if (flags[2] != lastPushedStartHeld) { lastPushedStartHeld = flags[2] val setter = quickMenuSetStartHeld @@ -948,9 +1014,15 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { } val buttonAPressed = (buttons and (1 shl XrGamepadBridge.BUTTON_A)) != 0 - if (buttonAPressed && !lastMenuButtonAPressed) { + val triggerPressed = axes[4] > POINTER_GRAB_PRESS_THRESHOLD || axes[5] > POINTER_GRAB_PRESS_THRESHOLD + val confirmPressed = buttonAPressed || triggerPressed + if (confirmPressed && !lastMenuConfirmPressed) { val focusedActivate = quickMenuFocusedActivate?.invoke() - Timber.i("Immersive: A pressed, focusedActivatePresent=%b", focusedActivate != null) + Timber.i( + "Immersive: menu confirm pressed, source=%s focusedActivatePresent=%b", + if (buttonAPressed) "A" else "trigger", + focusedActivate != null, + ) if (focusedActivate != null) { runOnUiThread { focusedActivate.invoke() } } else { @@ -958,11 +1030,16 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { dispatchMenuKeyEvent(android.view.KeyEvent.KEYCODE_BUTTON_A) } } - lastMenuButtonAPressed = buttonAPressed + lastMenuConfirmPressed = confirmPressed val buttonBPressed = (buttons and (1 shl XrGamepadBridge.BUTTON_B)) != 0 - if (buttonBPressed && !lastMenuButtonBPressed) dispatchMenuKeyEvent(android.view.KeyEvent.KEYCODE_BACK) - lastMenuButtonBPressed = buttonBPressed + val rightStickPressed = (buttons and (1 shl XrGamepadBridge.BUTTON_R3)) != 0 + val backPressed = buttonBPressed || rightStickPressed + if (backPressed && !lastMenuBackPressed) { + Timber.i("Immersive: menu back pressed, source=%s", if (buttonBPressed) "B" else "right-stick") + dispatchMenuKeyEvent(android.view.KeyEvent.KEYCODE_BACK) + } + lastMenuBackPressed = backPressed val stickClickHeld = (buttons and ((1 shl XrGamepadBridge.BUTTON_L3) or (1 shl XrGamepadBridge.BUTTON_R3))) != 0 val buttonLBPressed = (buttons and (1 shl XrGamepadBridge.BUTTON_LB)) != 0 @@ -1016,6 +1093,9 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { runOnUiThread { val moved = quickMenuFocusManager?.moveFocus(direction) Timber.i("Immersive: moveFocus direction=%s focusManagerPresent=%b result=%s", direction, quickMenuFocusManager != null, moved) + if (moved == false && direction == androidx.compose.ui.focus.FocusDirection.Down) { + quickMenuFocusExit?.invoke() + } } } @@ -1120,22 +1200,48 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { } private fun teardownDirectRenderBridge() { + val renderer = bridgedRenderer bridgedRenderer = null directVulkanBridge = null - (PluviaApp.xServerView?.renderer as? com.winlator.renderer.VulkanRenderer)?.setVulkanXrFrameBridge(null) + (renderer as? com.winlator.renderer.VulkanRenderer)?.setVulkanXrFrameBridge(null) val bridge = directGLBridge if (bridge != null) { directGLBridge = null - (PluviaApp.xServerView?.renderer as? GLRenderer)?.setXrFrameBridge(null) + (renderer as? GLRenderer)?.setXrFrameBridge(null) PluviaApp.xServerView?.queueEvent { bridge.release() } } directRenderActive = false } + private fun applyFlatPresentationGate(enabled: Boolean) { + val xServerView = PluviaApp.xServerView + val renderer = xServerView?.renderer + xServerView?.getxServer()?.setFlatPresentationEnabled(enabled) + (renderer as? GLRenderer)?.setFlatPresentationEnabled(enabled) + (renderer as? com.winlator.renderer.VulkanRenderer)?.setFlatPresentationEnabled(enabled) + if (!enabled) { + teardownDirectRenderBridge() + gameCaptureBitmap = null + finalFrameBitmap = null + } + Timber.i( + "Immersive: flat presentation enabled=%s presentCopies=%s rendererUpdates=%s capture=%s", + enabled, + if (enabled) "active" else "suspended", + if (enabled) "active" else "suspended", + if (enabled) "fallback-ready" else "released", + ) + } + private fun scheduleNextCapture() { if (!captureActive.get()) return val handler = captureHandler ?: return + if (flatPresentationSuspended) { + handler.postDelayed({ scheduleNextCapture() }, CAPTURE_RETRY_DELAY_MS) + return + } + setupDirectRenderBridgeIfSupported() val surfaceView = PluviaApp.xServerView as? SurfaceView @@ -1184,7 +1290,7 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { val captureStartTime = android.os.SystemClock.uptimeMillis() try { PixelCopy.request(surfaceView, bitmap, { result -> - if (result == PixelCopy.SUCCESS) { + if (result == PixelCopy.SUCCESS && !flatPresentationSuspended) { compositeGameFrame(width, height) } val elapsed = android.os.SystemClock.uptimeMillis() - captureStartTime @@ -1200,7 +1306,7 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { /** Composites the game frame with the cached overlay on the capture thread. Runs only when * not direct-rendering (see scheduleNextCapture). */ private fun compositeGameFrame(width: Int, height: Int) { - if (!captureActive.get() || xrSessionHandle == 0L) return + if (!captureActive.get() || flatPresentationSuspended || xrSessionHandle == 0L) return val gameFrame = gameCaptureBitmap ?: return val finalBitmap = finalFrameBitmap?.takeIf { it.width == width && it.height == height } @@ -1220,10 +1326,10 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { /** Slow path: re-renders just the Compose overlay (transparent where the game is) into * [overlayLayerBitmap], on its own throttled timer, on the UI thread (required for * View.draw). Self-chaining like the capture loop, independent of it. */ - private fun scheduleNextOverlayRefresh() { + private fun scheduleNextOverlayRefresh(delayMs: Long = OVERLAY_REFRESH_INTERVAL_MS) { if (!captureActive.get()) return val handler = captureHandler ?: return - handler.postDelayed({ refreshOverlayLayer() }, OVERLAY_REFRESH_INTERVAL_MS) + handler.postDelayed({ refreshOverlayLayer() }, delayMs) } private fun refreshOverlayLayer() { @@ -1242,6 +1348,7 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { val hasOverlayContent = quickMenuVisible || overlayPausedUi || showControlsOnboarding || xrPointerModeActive || bootingSplashVisible + if (xrSessionHandle != 0L) XrNative.nativeSetWindowsOverlayVisible(xrSessionHandle, hasOverlayContent) val now = android.os.SystemClock.uptimeMillis() if (hasOverlayContent) { overlayContentLastVisibleAt = now @@ -1250,7 +1357,7 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { // Fade-outs and the mode indicator outlive their flag, so keep drawing for a grace window. if (!hasOverlayContent && now - overlayContentLastVisibleAt > OVERLAY_CONTENT_GRACE_MS) { if (overlayClearSubmitted) { - scheduleNextOverlayRefresh() + scheduleNextOverlayRefresh(OVERLAY_IDLE_REFRESH_INTERVAL_MS) return@runOnUiThread } overlayClearSubmitted = true @@ -1259,11 +1366,11 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { ?: Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { overlayLayerBitmap = it } android.graphics.Canvas(bitmap) .drawColor(android.graphics.Color.TRANSPARENT, android.graphics.PorterDuff.Mode.CLEAR) - if (directRenderActive && xrSessionHandle != 0L) { + if ((directRenderActive || flatPresentationSuspended) && xrSessionHandle != 0L) { XrNative.nativeSubmitFrame(xrSessionHandle, bitmap) } } - scheduleNextOverlayRefresh() + scheduleNextOverlayRefresh(OVERLAY_IDLE_REFRESH_INTERVAL_MS) return@runOnUiThread } @@ -1286,7 +1393,7 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { if (surfaceView != null) surfaceView.alpha = previousAlpha } } - if (directRenderActive && xrSessionHandle != 0L) { + if ((directRenderActive || flatPresentationSuspended) && xrSessionHandle != 0L) { synchronized(overlayLock) { overlayLayerBitmap?.let { XrNative.nativeSubmitFrame(xrSessionHandle, it) } } @@ -1330,7 +1437,10 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { pollingThread = null stopFrameCaptureLoop() teardownDirectRenderBridge() + flatPresentationSuspended = false + applyFlatPresentationGate(true) if (xrSessionHandle != 0L) { + windowsVrRuntimeService?.detachSession() XrNative.nativeRequestStop(xrSessionHandle) XrNative.nativeJoinAndDestroy(xrSessionHandle) xrSessionHandle = 0L diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/XrNative.kt b/app/src/main/java/app/gamenative/ui/screen/xr/XrNative.kt index 8b2dda125a..c1df4f0170 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xr/XrNative.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xr/XrNative.kt @@ -45,6 +45,28 @@ object XrNative { outFlags: BooleanArray, ): Boolean + external fun nativeWaitWindowsFrame( + handle: Long, + afterSerial: Long, + timeoutMs: Int, + outTiming: LongArray, + outViews: FloatArray, + outInput: FloatArray, + outFlags: IntArray, + ): Boolean + + external fun nativeIsWindowsStereoActive(handle: Long): Boolean + + external fun nativeApplyWindowsHaptic( + handle: Long, + hand: Int, + amplitude: Float, + duration: Long, + frequency: Float, + ): Boolean + + external fun nativeSetWindowsOverlayVisible(handle: Long, visible: Boolean) + /** * Hands off one PixelCopy'd frame of the game's actual rendered output (ARGB_8888) to be * drawn into the immersive quad layer. See ImmersiveXrActivity's capture loop. diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServer.kt b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServer.kt new file mode 100644 index 0000000000..78471576d0 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServer.kt @@ -0,0 +1,293 @@ +package app.gamenative.ui.screen.xr.windows + +import java.io.BufferedWriter +import java.io.Closeable +import java.io.InputStream +import java.io.OutputStreamWriter +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.Executors +import java.util.concurrent.Semaphore +import java.util.concurrent.atomic.AtomicBoolean + +class WindowsVrControlServer( + private val config: WindowsVrRuntimeConfig, + private val diagnostics: WindowsVrDiagnostics, + private val snapshots: WindowsVrSnapshotProvider, +) : Closeable { + private val running = AtomicBoolean(false) + private val clients = Semaphore(16) + private val executor = Executors.newFixedThreadPool(5) + private var serverSocket: ServerSocket? = null + private var lastFrameSerial = 0L + private var firstFrameRecorded = false + private var trackingSpaceRecorded = false + + fun start() { + if (!running.compareAndSet(false, true)) return + val socket = ServerSocket(config.controlPort, 4, InetAddress.getByName("127.0.0.1")) + socket.reuseAddress = true + serverSocket = socket + executor.execute { + while (running.get()) { + val client = runCatching { socket.accept() }.getOrNull() ?: break + if (!clients.tryAcquire()) { + client.close() + } else { + executor.execute { handle(client) } + } + } + } + diagnostics.record("control", "listening=127.0.0.1:${config.controlPort}") + } + + private fun handle(socket: Socket) { + try { + socket.tcpNoDelay = true + socket.soTimeout = 120000 + val input = socket.getInputStream() + val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.US_ASCII), 1024) + var greeted = false + while (running.get()) { + val line = readBoundedLine(input) ?: break + if (!greeted && line != "HELLO") break + val response = respond(line) + if (!greeted) greeted = response == "OK GameNativeVR ${config.protocolVersion}" + writer.write(response) + writer.newLine() + writer.flush() + if (line == "BYE") break + } + } catch (error: Exception) { + diagnostics.record("control-client", error.javaClass.simpleName) + } finally { + runCatching { socket.close() } + clients.release() + } + } + + private fun readBoundedLine(input: InputStream): String? { + val bytes = ByteArray(1024) + var length = 0 + while (true) { + val value = input.read() + if (value < 0) return null + if (value == 10) return bytes.decodeToString(0, length) + if (value !in 32..126 || length == bytes.size) return null + bytes[length++] = value.toByte() + } + } + + private fun respond(line: String): String { + val tokens = line.trim().split(' ').filter { it.isNotEmpty() } + if (tokens.isEmpty()) return "ERROR malformed" + return when (tokens[0]) { + "HELLO" -> if (tokens.size == 1) "OK GameNativeVR ${config.protocolVersion}" else "ERROR malformed" + "GET_SYSTEM" -> if (tokens.size == 1) "OK system=1 vendor=2833 name=Meta_Quest_GameNative" else "ERROR malformed" + "GET_VIEWS" -> if (tokens.size == 1) getViews() else "ERROR malformed" + "GET_BOUNDS" -> if (tokens.size == 1) getBounds() else "ERROR malformed" + "WAIT_FRAME" -> if (tokens.size == 1) waitFrame() else "ERROR malformed" + "FRAME_SYNC" -> if (tokens.size == 1) frameSync() else "ERROR malformed" + "LOCATE_VIEWS" -> if (tokens.size == 1) locateViews() else "ERROR malformed" + "GET_INPUT" -> getInput(tokens) + "HAPTIC" -> haptic(tokens) + "BEGIN_SESSION" -> if (tokens.size == 1) { + diagnostics.record("session", "began") + "OK" + } else "ERROR malformed" + "END_SESSION", "REQUEST_EXIT", "BEGIN_FRAME" -> if (tokens.size == 1) "OK" else "ERROR malformed" + "END_FRAME" -> run { + val layers = tokens.singleOrNull { it.startsWith("layers=") }?.substringAfter('=')?.toIntOrNull() + if (tokens.size != 2 || layers !in 0..16) "ERROR malformed" else { + if (!firstFrameRecorded) { + firstFrameRecorded = true + diagnostics.record("frame", "first-end layers=$layers") + } + "OK" + } + } + "STATUS" -> if (tokens.size == 1) "OK ${diagnostics.compactStatus().replace(' ', '_').take(768)}" else "ERROR malformed" + "SWAPCHAIN_CREATE" -> validateSwapchainCreate(tokens) + "SWAPCHAIN_DESTROY", "SWAPCHAIN_RESET", "BYE" -> if (tokens.size == 1) "OK" else "ERROR malformed" + "GFX_API" -> if (tokens.size == 2 && tokens[1] in setOf("d3d11", "d3d12", "vulkan")) { + diagnostics.record("graphics", "api=${tokens[1]}") + "OK" + } else "ERROR unsupported" + else -> "ERROR unsupported" + } + } + + private fun validateSwapchainCreate(tokens: List): String { + if (tokens.size == 2 && tokens[1].startsWith("images=")) { + val count = tokens[1].substringAfter('=').toIntOrNull() ?: return "ERROR malformed" + if (count !in 1..8) return "ERROR malformed" + diagnostics.record("swapchain", "create images=$count") + return "OK" + } + if (tokens.size != 5) return "ERROR malformed" + val values = tokens.drop(1).associate { token -> + val separator = token.indexOf('=') + if (separator <= 0 || separator == token.lastIndex) return "ERROR malformed" + token.substring(0, separator) to token.substring(separator + 1) + } + if (values.keys != setOf("w", "h", "count", "format")) return "ERROR malformed" + val width = values["w"]?.toIntOrNull() ?: return "ERROR malformed" + val height = values["h"]?.toIntOrNull() ?: return "ERROR malformed" + val count = values["count"]?.toIntOrNull() ?: return "ERROR malformed" + if (values["format"]?.toLongOrNull() == null || width !in 1..8192 || height !in 1..8192 || count !in 1..4) { + return "ERROR malformed" + } + diagnostics.record("swapchain", "create width=$width height=$height count=$count") + return "OK" + } + + /** One round-trip for the whole per-frame state: frame timing, view poses, and both + * hands' input as four lines. Saves three guest-side TCP round trips every frame. */ + private var frameCount = 0 + private var frameCountStartMs = 0L + + private var handlerMs = 0L + + private fun frameSync(): String { + val handlerStart = System.nanoTime() + val frame = waitFrame() + if (!frame.startsWith("OK")) return frame + handlerMs += (System.nanoTime() - handlerStart) / 1_000_000 + val now = System.currentTimeMillis() + if (frameCountStartMs == 0L) frameCountStartMs = now + frameCount++ + if (now - frameCountStartMs >= 5000) { + val fps = frameCount * 1000f / (now - frameCountStartMs) + timber.log.Timber.i( + "Windows VR game frame rate: %.1f fps (server wait avg %.1f ms)", + fps, + handlerMs.toFloat() / frameCount, + ) + frameCount = 0 + handlerMs = 0 + frameCountStartMs = now + } + return frame + "\n" + locateViews() + "\n" + + getInput(listOf("GET_INPUT", "hand=0")) + "\n" + + getInput(listOf("GET_INPUT", "hand=1")) + } + + private fun waitFrame(): String { + val snapshot = snapshots.waitFrame(lastFrameSerial, 1000) ?: return "ERROR timeout" + lastFrameSerial = snapshot.timing[0] + if (!trackingSpaceRecorded) { + trackingSpaceRecorded = true + diagnostics.record( + "tracking", + "guestSpace=${if (snapshot.timing[10] != 0L) "STAGE" else "LOCAL-fallback"} " + + "bounds=${if (snapshot.timing[7] != 0L) "${snapshot.timing[8]}x${snapshot.timing[9]}um" else "unavailable"}", + ) + } + return "OK serial=${snapshot.timing[0]} time=${snapshot.timing[1]} period=${snapshot.timing[2]} shouldRender=${snapshot.timing[4]} state=${snapshot.timing[3]} render=${snapshot.timing[4]} recenter=${snapshot.timing[11]}" + } + + private fun currentSnapshot(): WindowsVrRuntimeSnapshot? { + return snapshots.latest() ?: snapshots.waitFrame(0, 1000)?.also { lastFrameSerial = it.timing[0] } + } + + private fun getViews(): String { + val snapshot = currentSnapshot() ?: return "ERROR unavailable" + val scale = config.renderScalePercent.toLong() + val scaledWidth = (snapshot.timing[5] * scale / 100) and 1L.inv() + val scaledHeight = (snapshot.timing[6] * scale / 100) and 1L.inv() + return "OK count=2 width=$scaledWidth height=$scaledHeight" + } + + private fun getBounds(): String { + val snapshot = currentSnapshot() ?: return "ERROR unavailable" + return "OK available=${snapshot.timing[7]} width=${snapshot.timing[8]} height=${snapshot.timing[9]} supported=${snapshot.timing[10]}" + } + + private fun locateViews(): String { + val snapshot = snapshots.latest() ?: return "ERROR unavailable" + val rawValues = buildList(22) { + for (eye in 0 until 2) { + for (field in 0 until 11) { + add((snapshot.views[eye * 11 + field] * 1_000_000f).toLong()) + } + } + }.joinToString(" ") + val namedValues = buildList(22) { + for (eye in 0 until 2) { + val prefix = if (eye == 0) "l" else "r" + for (field in viewFields.indices) { + add("$prefix${viewFields[field]}=${(snapshot.views[eye * 11 + field] * 1_000_000f).toLong()}") + } + } + }.joinToString(" ") + return "OK flags=${snapshot.flags[0]} $rawValues $namedValues" + } + + private fun getInput(tokens: List): String { + if (tokens.size != 2 || tokens[1] !in setOf("hand=0", "hand=1")) return "ERROR malformed" + val snapshot = snapshots.latest() ?: return "ERROR unavailable" + val hand = tokens[1].last().digitToInt() + val base = hand * 18 + val rawValues = (0 until 18).joinToString(" ") { field -> + (snapshot.input[base + field] * 1_000_000f).toLong().toString() + } + val namedValues = inputFields.indices.joinToString(" ") { field -> + "${inputFields[field]}=${(snapshot.input[base + field] * 1_000_000f).toLong()}" + } + val active = (snapshot.flags[2] shr hand) and 1 + return "OK active=$active buttons=${handButtons(snapshot.flags[1], hand)} $rawValues $namedValues" + } + + private fun handButtons(buttons: Int, hand: Int): Int { + val sourceBits = if (hand == 0) intArrayOf(2, 3, 8, 7) else intArrayOf(0, 1, 9, -1) + var packed = 0 + sourceBits.forEachIndexed { index, bit -> + if (bit >= 0 && buttons and (1 shl bit) != 0) packed = packed or (1 shl index) + } + return packed + } + + private fun haptic(tokens: List): String { + if (tokens.size != 5) return "ERROR malformed" + val values = tokens.drop(1).associate { token -> + val separator = token.indexOf('=') + if (separator <= 0 || separator == token.lastIndex) return "ERROR malformed" + token.substring(0, separator) to token.substring(separator + 1) + } + if (values.keys != setOf("hand", "amp", "dur", "freq")) return "ERROR malformed" + val hand = values["hand"]?.toIntOrNull() ?: return "ERROR malformed" + val amplitude = values["amp"]?.toIntOrNull() ?: return "ERROR malformed" + val duration = values["dur"]?.toLongOrNull() ?: return "ERROR malformed" + val frequency = values["freq"]?.toIntOrNull() ?: return "ERROR malformed" + if (hand !in 0..1 || amplitude !in 0..1_000_000 || duration !in 0..5_000_000_000L || + frequency !in 0..1_000_000_000) return "ERROR malformed" + return if (snapshots.applyHaptic( + hand, + amplitude / 1_000_000f, + duration, + frequency / 1_000_000f, + )) { + diagnostics.record("haptic", "hand=$hand") + "OK" + } else { + diagnostics.record("haptic", "unavailable hand=$hand") + "ERROR unavailable" + } + } + + override fun close() { + if (!running.compareAndSet(true, false)) return + runCatching { serverSocket?.close() } + executor.shutdownNow() + diagnostics.record("control", "stopped") + } + + private companion object { + val viewFields = arrayOf("qx", "qy", "qz", "qw", "px", "py", "pz", "fl", "fr", "fu", "fd") + val inputFields = arrayOf( + "tr", "sq", "sx", "sy", "gqx", "gqy", "gqz", "gqw", "gpx", "gpy", "gpz", + "aqx", "aqy", "aqz", "aqw", "apx", "apy", "apz", + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrDiagnostics.kt b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrDiagnostics.kt new file mode 100644 index 0000000000..cdd57366b5 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrDiagnostics.kt @@ -0,0 +1,88 @@ +package app.gamenative.ui.screen.xr.windows + +import android.content.Context +import android.os.Process +import java.io.File +import java.io.RandomAccessFile +import java.text.SimpleDateFormat +import java.util.ArrayDeque +import java.util.Date +import java.util.Locale +import timber.log.Timber + +class WindowsVrDiagnostics(context: Context, private val capacity: Int = 512) { + private val entries = ArrayDeque(capacity) + private val file = File( + context.getExternalFilesDir(null) ?: context.filesDir, + "gamenativevr/launch.log", + ).apply { runCatching { parentFile?.mkdirs() } } + + @Synchronized + fun begin(appId: String, executable: String, arguments: String) { + trimIfNeeded() + appendToFile( + buildString { + appendLine() + appendLine("===== GameNativeVR immersive launch ${timestamp()} =====") + appendLine("pid=${Process.myPid()} appId=$appId") + appendLine("executable=${executable.ifBlank { "" }}") + appendLine("arguments=${arguments.ifBlank { "" }}") + }, + ) + } + + @Synchronized + fun record(stage: String, value: String) { + if (entries.size == capacity) entries.removeFirst() + val entry = "${timestamp()} $stage $value" + entries.addLast(entry) + trimIfNeeded() + appendToFile("$entry\n") + } + + @Synchronized + fun recordFileTail(label: String, source: File, maxChars: Int = 12_000) { + val tail = runCatching { readTail(source, maxChars) }.getOrNull() + record( + "diagnostics", + if (tail.isNullOrBlank()) { + "$label is unavailable at ${source.absolutePath}" + } else { + "$label tail (${source.absolutePath}):\n$tail" + }, + ) + } + + @Synchronized + fun snapshot(): List = entries.toList() + + @Synchronized + fun compactStatus(): String = entries.lastOrNull() ?: "idle" + + fun logFile(): File = file + + private fun readTail(source: File, maxChars: Int): String? { + if (!source.isFile) return null + return RandomAccessFile(source, "r").use { input -> + val length = input.length() + val bytes = ByteArray(minOf(length, maxChars.toLong() * 4).toInt()) + input.seek(length - bytes.size) + input.readFully(bytes) + bytes.toString(Charsets.UTF_8).takeLast(maxChars) + } + } + + private fun appendToFile(text: String) { + runCatching { file.appendText(text) }.onFailure { Timber.w(it, "Windows VR diagnostics write failed") } + } + + private fun trimIfNeeded() { + runCatching { + if (!file.isFile || file.length() <= 512 * 1024L) return + file.writeText("[older GameNativeVR diagnostics trimmed]\n${readTail(file, 256 * 1024).orEmpty()}") + } + } + + private fun timestamp(): String = + SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.ROOT).format(Date()) +} diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrEmulationDiagnostics.kt b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrEmulationDiagnostics.kt new file mode 100644 index 0000000000..a2a9925e54 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrEmulationDiagnostics.kt @@ -0,0 +1,75 @@ +package app.gamenative.ui.screen.xr.windows + +import com.winlator.container.Container +import org.json.JSONObject + +object WindowsVrEmulationDiagnostics { + private val keys = listOf( + "graphicsDriver", + "graphicsDriverVersion", + "graphicsDriverConfig", + "dxwrapper", + "dxwrapperConfig", + "displayRendererMode", + "containerVariant", + "wineVersion", + "wow64Mode", + "emulator", + "fexcoreVersion", + "fexcorePreset", + "box64Version", + "box64Preset", + "box86Version", + "box86Preset", + "cpuList", + "cpuListWoW64", + "startupSelection", + "steamType", + ) + + fun snapshot(container: Container): String { + val configFile = container.configFile + val persisted = runCatching { JSONObject(configFile.readText()) }.getOrNull() + return buildString { + appendLine("GameNativeVR emulation settings at immersive start:") + appendLine( + " configFile=${configFile.absolutePath} exists=${configFile.isFile} " + + "bytes=${if (configFile.isFile) configFile.length() else 0} " + + "modified=${if (configFile.isFile) configFile.lastModified() else 0}", + ) + appendLine(" saved: ${keys.joinToString(" ") { "$it=${persisted.value(it)}" }}") + appendLine(" loaded: ${loaded(container)}") + }.trimEnd() + } + + fun effective(container: Container): String = + "wine=${container.wineVersion} graphics=${container.graphicsDriver} " + + "graphicsConfig=${container.graphicsDriverConfig.clean()} wrapper=${container.dxWrapper} " + + "wrapperConfig=${container.dxWrapperConfig.clean()} variant=${container.containerVariant} " + + "wow64=${container.isWoW64Mode} emulator=${container.emulator} " + + "fex=${container.fexCoreVersion}/${container.fexCorePreset} " + + "box64=${container.box64Version}/${container.box64Preset} " + + "box86=${container.box86Version}/${container.box86Preset} " + + "cpu=${container.cpuList} wow64Cpu=${container.cpuListWoW64} " + + "startup=${container.startupSelection} steam=${container.steamType} args=${container.execArgs.clean()}" + + private fun loaded(container: Container): String = + "graphicsDriver=${container.graphicsDriver} graphicsDriverVersion=${container.graphicsDriverVersion} " + + "graphicsDriverConfig=${container.graphicsDriverConfig.clean()} dxwrapper=${container.dxWrapper} " + + "dxwrapperConfig=${container.dxWrapperConfig.clean()} displayRendererMode=${container.displayRenderer} " + + "containerVariant=${container.containerVariant} wineVersion=${container.wineVersion} " + + "wow64Mode=${container.isWoW64Mode} emulator=${container.emulator} " + + "fexcoreVersion=${container.fexCoreVersion} fexcorePreset=${container.fexCorePreset} " + + "box64Version=${container.box64Version} box64Preset=${container.box64Preset} " + + "box86Version=${container.box86Version} box86Preset=${container.box86Preset} " + + "cpuList=${container.cpuList} cpuListWoW64=${container.cpuListWoW64} " + + "startupSelection=${container.startupSelection} steamType=${container.steamType}" + + private fun String.clean(): String = replace('\n', ' ').replace('\r', ' ') + + private fun JSONObject?.value(key: String): String { + if (this == null) return "" + if (!has(key) || isNull(key)) return "" + return opt(key)?.toString()?.clean() ?: "" + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrPayloadManager.kt b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrPayloadManager.kt new file mode 100644 index 0000000000..54f4a215a1 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrPayloadManager.kt @@ -0,0 +1,346 @@ +package app.gamenative.ui.screen.xr.windows + +import android.content.Context +import com.winlator.container.Container +import com.winlator.xenvironment.ImageFs +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.Base64 + +class WindowsVrPayloadManager( + private val context: Context, + private val diagnostics: WindowsVrDiagnostics, +) { + data class PreparedPayload(val prefixDirectory: File, val manifest: File) + private data class RegistryMutation(val registry: File, val backup: File, val missing: File) + private data class FileMutation(val target: File, val backup: File, val missing: File, val targetRecord: File) + private val registryMutations = mutableListOf() + private val fileMutations = mutableListOf() + private val openCompositeDirectories = mutableListOf() + private var openCompositeRecord: File? = null + private var activeMarker: File? = null + + fun prepare(container: Container): PreparedPayload { + val prefixDirectory = File(container.rootDir, ".wine/drive_c/gamenative-xr") + check(prefixDirectory.exists() || prefixDirectory.mkdirs()) + recoverRegistry(container, prefixDirectory) + recoverSharedPayload(container, prefixDirectory) + recoverOpenComposite(container, prefixDirectory) + val runtime64 = File(prefixDirectory, "gamenative_openxr_runtime64.dll") + val runtime32 = File(prefixDirectory, "gamenative_openxr_runtime32.dll") + copyAssetIfChanged("gamenative_openxr_runtime64.dll", runtime64) + copyAssetIfChanged("gamenative_openxr_runtime32.dll", runtime32) + val targets = sharedTargets(container) + val bridge = targets.getValue("bridge") + val bridge32 = targets.getValue("bridge32") + val unixlib = targets.getValue("unixlib") + check(bridge.parentFile?.exists() == true || bridge.parentFile?.mkdirs() == true) + check(unixlib.parentFile?.exists() == true || unixlib.parentFile?.mkdirs() == true) + sharedAssets.forEach { (name, assetPath) -> + installSharedFile(assetPath, targets.getValue(name), prefixDirectory, name) + } + val manifest = File(prefixDirectory, "active_runtime.json") + val manifest64 = File(prefixDirectory, "active_runtime64.json") + val manifest32 = File(prefixDirectory, "active_runtime32.json") + val commonJson = "{\"file_format_version\":\"1.0.0\",\"runtime\":{\"library_path\":\"C:\\\\windows\\\\system32\\\\gamenative_openxr.dll\",\"name\":\"GameNative Windows OpenXR\"}}" + val json64 = "{\"file_format_version\":\"1.0.0\",\"runtime\":{\"library_path\":\"C:\\\\gamenative-xr\\\\gamenative_openxr_runtime64.dll\",\"name\":\"GameNative Windows OpenXR\"}}" + val json32 = "{\"file_format_version\":\"1.0.0\",\"runtime\":{\"library_path\":\"C:\\\\gamenative-xr\\\\gamenative_openxr_runtime32.dll\",\"name\":\"GameNative Windows OpenXR\"}}" + writeIfChanged(manifest, commonJson.toByteArray()) + writeIfChanged(manifest64, json64.toByteArray()) + writeIfChanged(manifest32, json32.toByteArray()) + val marker = File(prefixDirectory, "payload.version") + copyAssetIfChanged("payload.version", marker) + installRegistry(container, prefixDirectory) + diagnostics.record("payload", "prepared path=${prefixDirectory.path} runtime64=${runtime64.length()} runtime32=${runtime32.length()} bridge64=${bridge.length()} bridge32=${bridge32.length()} unixlib=${unixlib.length()} manifest=${manifest.length()}") + return PreparedPayload(prefixDirectory, manifest) + } + + fun installOpenComposite(container: Container) { + val gameRoot = Container.drivesIterator(container.drives).asSequence() + .firstOrNull { it[0].equals("A", ignoreCase = true) } + ?.get(1) + ?.let(::File) + ?.canonicalFile + ?: error("OpenComposite requires the launched game's A: drive") + check(gameRoot.isDirectory) + val candidates = gameRoot.walkTopDown() + .onEnter { it.canonicalFile.path.startsWith(gameRoot.path + File.separator) || it.canonicalFile == gameRoot } + .take(20001) + .toList() + check(candidates.size <= 20000) { "OpenComposite scan exceeded 20000 files" } + val targets = candidates.filter { it.isFile && it.name.equals("openvr_api.dll", ignoreCase = true) } + .filter { runCatching { peMachineOf(it) == 0x8664 }.getOrDefault(false) } + check(targets.isNotEmpty()) { "No x64 openvr_api.dll was found under the launched game" } + val adapter = context.assets.open("opencomposite_x64.dll").use { it.readBytes() } + val record = File(File(container.rootDir, ".wine/drive_c/gamenative-xr"), "opencomposite.targets") + val encodedTargets = targets.map { checkNotNull(it.parentFile).canonicalPath } + .distinct() + .joinToString("\n") { Base64.getUrlEncoder().withoutPadding().encodeToString(it.toByteArray()) } + writeIfChanged(record, encodedTargets.toByteArray()) + openCompositeRecord = record + targets.forEach { target -> + val directory = checkNotNull(target.parentFile).canonicalFile + val backup = File(directory, "openvr_api.dll.gamenative-original") + val owner = File(directory, "openvr_api.dll.gamenative-owner") + check(!backup.exists() && !owner.exists()) + writeIfChanged(owner, "2\n".toByteArray()) + openCompositeDirectories += directory + writeIfChanged(backup, target.readBytes()) + writeIfChanged(target, adapter) + diagnostics.record("opencomposite", "installed path=${target.path}") + } + } + + fun restore() { + registryMutations.asReversed().forEach { mutation -> + when { + mutation.backup.isFile -> { + atomicReplace(mutation.backup, mutation.registry) + } + mutation.missing.isFile -> mutation.registry.delete() + } + mutation.backup.delete() + mutation.missing.delete() + } + registryMutations.clear() + fileMutations.asReversed().forEach { mutation -> + when { + mutation.backup.isFile -> atomicReplace(mutation.backup, mutation.target) + mutation.missing.isFile -> mutation.target.delete() + } + mutation.backup.delete() + mutation.missing.delete() + mutation.targetRecord.delete() + } + fileMutations.clear() + openCompositeDirectories.asReversed().forEach(::restoreOpenCompositeDirectory) + openCompositeDirectories.clear() + openCompositeRecord?.delete() + openCompositeRecord = null + activeMarker?.delete() + activeMarker = null + diagnostics.record("payload", "restored") + } + + private fun recoverRegistry(container: Container, payloadDirectory: File) { + listOf("system.reg", "user.reg").forEach { name -> + val registry = File(container.rootDir, ".wine/$name") + val backup = File(payloadDirectory, "$name.backup") + val missing = File(payloadDirectory, "$name.missing") + when { + backup.isFile -> atomicReplace(backup, registry) + missing.isFile -> registry.delete() + } + backup.delete() + missing.delete() + } + File(payloadDirectory, "registry.active").delete() + } + + private fun installRegistry(container: Container, payloadDirectory: File) { + val runtimePath64 = "C:\\\\gamenative-xr\\\\active_runtime64.json" + val runtimePath32 = "C:\\\\gamenative-xr\\\\active_runtime32.json" + val sections = """ + +[Software\\Khronos\\OpenXR\\1] +"ActiveRuntime"="$runtimePath64" + +[Software\\Wow6432Node\\Khronos\\OpenXR\\1] +"ActiveRuntime"="$runtimePath32" +""".trimIndent().toByteArray() + listOf("system.reg", "user.reg").forEach { name -> + val registry = File(container.rootDir, ".wine/$name") + val backup = File(payloadDirectory, "$name.backup") + val missing = File(payloadDirectory, "$name.missing") + if (registry.isFile) { + writeIfChanged(backup, registry.readBytes()) + } else { + writeIfChanged(missing, byteArrayOf(1)) + } + val existing = if (registry.isFile) registry.readBytes() else "WINE REGISTRY Version 2\n".toByteArray() + writeIfChanged(registry, existing + sections) + registryMutations += RegistryMutation(registry, backup, missing) + } + activeMarker = File(payloadDirectory, "registry.active").also { writeIfChanged(it, "2\n".toByteArray()) } + diagnostics.record("registry", "installed views=64,32") + } + + private val sharedAssets = linkedMapOf( + "runtime64" to "gamenative_openxr_runtime64.dll", + "runtime32" to "gamenative_openxr_runtime32.dll", + "bridge" to "gamenative_xr_unixbridge.dll", + "bridgePrefix" to "gamenative_xr_unixbridge.dll", + "bridge32" to "gamenative_xr_unixbridge32.dll", + "bridge32Prefix" to "gamenative_xr_unixbridge32.dll", + "unixlib" to "gamenative_xr_unixbridge.so", + ) + + private fun sharedTargets(container: Container): Map { + val winePath = ImageFs.find(context).winePath + return mapOf( + "runtime64" to File(container.rootDir, ".wine/drive_c/windows/system32/gamenative_openxr.dll"), + "runtime32" to File(container.rootDir, ".wine/drive_c/windows/syswow64/gamenative_openxr.dll"), + "bridge" to File(winePath, "lib/wine/aarch64-windows/gamenative_xr_unixbridge.dll"), + "bridgePrefix" to File(container.rootDir, ".wine/drive_c/windows/system32/gamenative_xr_unixbridge.dll"), + "bridge32" to File(winePath, "lib/wine/i386-windows/gamenative_xr_unixbridge.dll"), + "bridge32Prefix" to File(container.rootDir, ".wine/drive_c/windows/syswow64/gamenative_xr_unixbridge.dll"), + "unixlib" to File(winePath, "lib/wine/aarch64-unix/gamenative_xr_unixbridge.so"), + ) + } + + private fun recoverSharedPayload(container: Container, payloadDirectory: File) { + val targets = sharedTargets(container) + sharedAssets.keys.forEach { name -> + val backup = File(payloadDirectory, "$name.backup") + val missing = File(payloadDirectory, "$name.missing") + val targetRecord = File(payloadDirectory, "$name.target") + val canonicalTarget = targets.getValue(name).canonicalFile + val recorded = targetRecord.takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() }?.let(::File)?.canonicalFile + val restoreTarget = recorded?.takeIf { + it == canonicalTarget && runCatching { validateSharedTarget(it) }.isSuccess + } + if (restoreTarget != null) { + when { + backup.isFile -> atomicReplace(backup, restoreTarget) + missing.isFile -> restoreTarget.delete() + } + } else if (recorded != null) { + diagnostics.record("payload", "ignoring unexpected recovery record for $name") + } + backup.delete() + missing.delete() + targetRecord.delete() + } + } + + private fun recoverOpenComposite(container: Container, payloadDirectory: File) { + val gameRoot = Container.drivesIterator(container.drives).asSequence() + .firstOrNull { it[0].equals("A", ignoreCase = true) } + ?.get(1) + ?.let(::File) + ?.canonicalFile + ?: return + if (!gameRoot.isDirectory) return + val record = File(payloadDirectory, "opencomposite.targets") + val recorded = record.takeIf { it.isFile }?.readLines().orEmpty().mapNotNull { value -> + runCatching { File(String(Base64.getUrlDecoder().decode(value))).canonicalFile }.getOrNull() + } + val discovered = gameRoot.walkTopDown() + .onEnter { it.canonicalFile.path.startsWith(gameRoot.path + File.separator) || it.canonicalFile == gameRoot } + .take(20000) + .filter { it.isFile && it.name == "openvr_api.dll.gamenative-owner" } + .mapNotNull { it.parentFile } + .toList() + (recorded + discovered).distinctBy { it.path }.filter { + it.path.startsWith(gameRoot.path + File.separator) + }.forEach(::restoreOpenCompositeDirectory) + record.delete() + } + + private fun restoreOpenCompositeDirectory(directory: File) { + val owner = File(directory, "openvr_api.dll.gamenative-owner") + if (!owner.isFile || owner.readText().trim() != "2") return + val target = File(directory, "openvr_api.dll") + val backup = File(directory, "openvr_api.dll.gamenative-original") + if (backup.isFile) atomicReplace(backup, target) + backup.delete() + owner.delete() + diagnostics.record("opencomposite", "restored path=${target.path}") + } + + private fun installSharedFile(assetPath: String, target: File, payloadDirectory: File, name: String) { + val backup = File(payloadDirectory, "$name.backup") + val missing = File(payloadDirectory, "$name.missing") + val targetRecord = File(payloadDirectory, "$name.target") + val canonicalTarget = target.canonicalFile + validateSharedTarget(canonicalTarget) + if (canonicalTarget.isFile) writeIfChanged(backup, canonicalTarget.readBytes()) else writeIfChanged(missing, byteArrayOf(1)) + writeIfChanged(targetRecord, canonicalTarget.path.toByteArray()) + copyAssetIfChanged(assetPath, canonicalTarget) + fileMutations += FileMutation(canonicalTarget, backup, missing, targetRecord) + } + + private fun validateSharedTarget(target: File) { + val roots = listOfNotNull( + ImageFs.find(context).rootDir, + context.filesDir, + context.getExternalFilesDir(null), + ).map { it.canonicalFile } + check(roots.any { target.path.startsWith(it.path + File.separator) }) + } + + private fun copyAssetIfChanged(assetPath: String, destination: File) { + val bytes = context.assets.open(assetPath).use { it.readBytes() } + writeIfChanged(destination, bytes) + diagnostics.record("payload-file", "${destination.name} size=${bytes.size} sha256=${sha256(bytes)}") + } + + private fun writeIfChanged(destination: File, bytes: ByteArray) { + check(destination.parentFile?.exists() == true || destination.parentFile?.mkdirs() == true) + if (destination.isFile && destination.readBytes().contentEquals(bytes)) return + val temporary = File(destination.parentFile, "${destination.name}.new") + FileOutputStream(temporary).use { output -> + output.write(bytes) + output.fd.sync() + } + replaceFile(temporary, destination) + } + + private fun atomicReplace(source: File, destination: File) { + val temporary = File(destination.parentFile, "${destination.name}.restore") + source.copyTo(temporary, overwrite = true) + FileOutputStream(temporary, true).use { it.fd.sync() } + replaceFile(temporary, destination) + } + + private fun replaceFile(source: File, destination: File) { + runCatching { + Files.move( + source.toPath(), + destination.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + }.getOrElse { + check(source.renameTo(destination) || run { + destination.delete() + source.renameTo(destination) + }) + } + } + + private fun sha256(bytes: ByteArray): String { + return MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + } + + private fun peMachineOf(file: File): Int { + java.io.RandomAccessFile(file, "r").use { input -> + val header = ByteArray(0x40) + input.readFully(header) + check(header[0] == 'M'.code.toByte() && header[1] == 'Z'.code.toByte()) + val offset = (header[0x3c].toInt() and 0xff) or + ((header[0x3d].toInt() and 0xff) shl 8) or + ((header[0x3e].toInt() and 0xff) shl 16) or + ((header[0x3f].toInt() and 0xff) shl 24) + check(offset >= 0x40 && offset.toLong() + 6 <= input.length()) + input.seek(offset.toLong()) + val pe = ByteArray(6) + input.readFully(pe) + check(pe[0] == 'P'.code.toByte() && pe[1] == 'E'.code.toByte() && pe[2] == 0.toByte() && pe[3] == 0.toByte()) + return (pe[4].toInt() and 0xff) or ((pe[5].toInt() and 0xff) shl 8) + } + } + + private fun peMachine(bytes: ByteArray): Int { + check(bytes.size >= 256) + val offset = (bytes[0x3c].toInt() and 0xff) or + ((bytes[0x3d].toInt() and 0xff) shl 8) or + ((bytes[0x3e].toInt() and 0xff) shl 16) or + ((bytes[0x3f].toInt() and 0xff) shl 24) + check(offset >= 0x40 && offset + 6 <= bytes.size) + return (bytes[offset + 4].toInt() and 0xff) or ((bytes[offset + 5].toInt() and 0xff) shl 8) + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfig.kt b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfig.kt new file mode 100644 index 0000000000..66734e56c3 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfig.kt @@ -0,0 +1,24 @@ +package app.gamenative.ui.screen.xr.windows + +import com.winlator.container.Container + +data class WindowsVrRuntimeConfig( + val enabled: Boolean, + val openCompositeEnabled: Boolean, + val controlPort: Int = 38476, + val protocolVersion: Int = 2, + val runtimeDirectory: String = "C:\\gamenative-xr", + val runtimeManifest: String = "C:\\gamenative-xr\\active_runtime.json", + val transportEndpoint: String = "@gamenative-xr", + val renderScalePercent: Int = 100, +) { + companion object { + fun from(container: Container): WindowsVrRuntimeConfig { + return WindowsVrRuntimeConfig( + enabled = container.getExtra("windowsVrEnabled", "false").toBoolean(), + openCompositeEnabled = container.getExtra("windowsVrOpenCompositeEnabled", "false").toBoolean(), + renderScalePercent = container.xrRenderScale.coerceIn(25, 100), + ) + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeService.kt b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeService.kt new file mode 100644 index 0000000000..159604f750 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeService.kt @@ -0,0 +1,221 @@ +package app.gamenative.ui.screen.xr.windows + +import android.content.Context +import app.gamenative.CrashHandler +import app.gamenative.ui.util.SnackbarManager +import com.winlator.container.Container +import com.winlator.core.ProcessHelper +import com.winlator.core.envvars.EnvVars +import java.io.Closeable +import java.io.File +import timber.log.Timber + +class WindowsVrRuntimeService(context: Context) : Closeable { + private val applicationContext = context.applicationContext + private val diagnostics = WindowsVrDiagnostics(applicationContext) + private val payloadManager = WindowsVrPayloadManager(applicationContext, diagnostics) + private val snapshots = WindowsVrSnapshotProvider() + private var config: WindowsVrRuntimeConfig? = null + private var controlServer: WindowsVrControlServer? = null + private var presentationState = "" + private var runtimeLogDirectory: File? = null + private var container: Container? = null + + fun beforeWineSystemSetup(container: Container) { + this.container = container + config = WindowsVrRuntimeConfig.from(container) + if (config?.enabled != true) return + diagnostics.begin(container.id, container.executablePath, container.execArgs) + diagnostics.record("activity", "Immersive VR host active") + diagnostics.record("configuration", "enabled=${config?.enabled} openComposite=${config?.openCompositeEnabled}") + diagnostics.record("emulation", WindowsVrEmulationDiagnostics.snapshot(container)) + } + + fun afterContainerEnvironmentMerged(env: EnvVars, container: Container) { + this.container = container + val active = config ?: WindowsVrRuntimeConfig.from(container).also { config = it } + if (!active.enabled) return + val payload = try { + check(container.wineVersion.contains("arm64ec", ignoreCase = true)) { + "Windows VR requires a supported ARM64EC Wine or Proton build" + } + check(container.dxWrapper.contains("dxvk", ignoreCase = true)) { + "Windows VR D3D11 requires DXVK native interop" + } + diagnostics.record("launch", "Preparing Windows OpenXR payload") + val prepared = payloadManager.prepare(container) + if (active.openCompositeEnabled) payloadManager.installOpenComposite(container) + if (controlServer == null) { + val server = WindowsVrControlServer(active, diagnostics, snapshots) + try { + server.start() + } catch (e: Exception) { + runCatching { server.close() } + throw e + } + controlServer = server + diagnostics.record("control", "listening on 127.0.0.1:${active.controlPort}") + } + prepared + } catch (e: Exception) { + val reason = e.message ?: e.javaClass.simpleName + diagnostics.record("launch", "Windows VR disabled for this session: $reason") + Timber.w(e, "Windows VR disabled for this session") + SnackbarManager.show("VR mode unavailable: $reason") + runCatching { controlServer?.close() } + controlServer = null + runCatching { payloadManager.restore() } + config = active.copy(enabled = false) + return + } + runtimeLogDirectory = payload.prefixDirectory + listOf("runtime.log", "unix.log").forEach { payload.prefixDirectory.resolve(it).delete() } + env.put("XR_RUNTIME_JSON", active.runtimeManifest) + env.put("GAMENATIVE_XR", "1") + env.put("GAMENATIVE_XR_LOG", "1") + env.put("GAMENATIVE_XR_SOCKET", active.transportEndpoint) + env.put("GAMENATIVE_XR_BRIDGE_HOST", "127.0.0.1") + env.put("GAMENATIVE_XR_BRIDGE_PORT", active.controlPort.toString()) + env.put("GAMENATIVE_XR_CONTROL", "127.0.0.1:${active.controlPort}") + env.put("GAMENATIVE_XR_TRANSPORT", active.transportEndpoint) + env.put("GAMENATIVE_XR_RUNTIME_DIR", active.runtimeDirectory) + env.put("GAMENATIVE_XR_UNIX_LOG", payload.prefixDirectory.resolve("unix.log").path) + val overrides = env.get("WINEDLLOVERRIDES").split(';') + .map(String::trim) + .filter(String::isNotEmpty) + .filterNot { + val name = it.substringBefore('=') + name.equals("gamenative_xr_unixbridge", ignoreCase = true) || + name.equals("gameoverlayrenderer", ignoreCase = true) || + name.equals("gameoverlayrenderer64", ignoreCase = true) + } + .plus("gameoverlayrenderer=n") + .plus("gameoverlayrenderer64=n") + .plus("gamenative_xr_unixbridge=b") + env.put("WINEDLLOVERRIDES", overrides.joinToString(";")) + diagnostics.record( + "environment", + "runtime=${active.runtimeManifest} transport=${active.transportEndpoint} " + + "control=127.0.0.1:${active.controlPort}", + ) + diagnostics.record("effective-launch", WindowsVrEmulationDiagnostics.effective(container)) + diagnostics.record("payload", payloadSummary(payload.prefixDirectory)) + } + + fun beforeGuestProcessStart() { + val active = config ?: return + if (active.enabled && controlServer == null) { + diagnostics.record("control", "control server missing at guest start; disabling Windows VR") + config = active.copy(enabled = false) + } + } + + fun onEnvironmentStarted() { + diagnostics.record("environment", "Wine environment started; waiting for OpenXR") + } + + fun onPresentationState(state: String) { + if (presentationState == state) return + presentationState = state + diagnostics.record("presentation", state) + } + + fun onGuestProcessError(error: String) { + diagnostics.record("guest-error", error) + captureDiagnostics() + } + + fun status(): List = diagnostics.snapshot() + + fun exportDiagnostics(): File { + captureDiagnostics() + val directory = File(applicationContext.getExternalFilesDir(null) ?: applicationContext.filesDir, "diagnostics") + check(directory.exists() || directory.mkdirs()) + val report = File(directory, "windows-vr-${System.currentTimeMillis()}.txt") + diagnostics.record("diagnostics", "exported path=${report.path}") + report.writeText( + runCatching { diagnostics.logFile().takeIf(File::isFile)?.readText() }.getOrNull()?.ifBlank { null } + ?: diagnostics.snapshot().joinToString("\n"), + ) + return report + } + + fun attachSession(handle: Long) { + snapshots.attach(handle) + diagnostics.record("openxr", "Immersive session attached handle=$handle") + } + + fun detachSession() { + snapshots.detach() + diagnostics.record("openxr", "Immersive session detached") + } + + private fun captureDiagnostics() { + diagnostics.record("android", "app log tail:\n${CrashHandler.getAppLogs(800)}") + val active = container + if (active != null) { + val runtime = File(active.rootDir, ".wine/drive_c/gamenative-xr") + diagnostics.recordFileTail("Windows OpenXR runtime log", runtime.resolve("runtime.log")) + diagnostics.recordFileTail("Wine OpenXR Unix bridge log", runtime.resolve("unix.log")) + diagnostics.recordFileTail( + "ColdClientLoader configuration", + File(active.rootDir, ".wine/drive_c/Program Files (x86)/Steam/ColdClientLoader.ini"), + ) + val users = File(active.rootDir, ".wine/drive_c/users") + val openComposite = users.walkTopDown().maxDepth(6) + .filter { it.isFile && it.name.equals("opencomposite.log", ignoreCase = true) } + .maxByOrNull(File::lastModified) + diagnostics.recordFileTail( + "OpenComposite log", + openComposite ?: File(users, "/AppData/Local/OpenComposite/logs/opencomposite.log"), + ) + val playerLog = users.walkTopDown().maxDepth(6) + .filter { it.isFile && it.name.equals("Player.log", ignoreCase = true) } + .maxByOrNull(File::lastModified) + diagnostics.recordFileTail( + "latest Unity Player log", + playerLog ?: File(users, "/AppData/LocalLow//Player.log"), + ) + } + val processes = runCatching { + ProcessHelper.listSubProcesses().sortedBy { it.pid }.joinToString("\n") { process -> + val command = runCatching { + File("/proc/${process.pid}/cmdline").readBytes().toString(Charsets.UTF_8) + .replace('\u0000', ' ').trim() + }.getOrDefault("") + val state = runCatching { + File("/proc/${process.pid}/status").useLines { lines -> + lines.firstOrNull { it.startsWith("State:") } + } + }.getOrNull() ?: "State: " + val wait = runCatching { File("/proc/${process.pid}/wchan").readText().trim() } + .getOrDefault("") + "pid=${process.pid} ppid=${process.ppid} rss=${process.rssBytes} " + + "name=${process.name} $state wchan=$wait cmd=$command" + } + }.getOrElse { "" } + diagnostics.record("processes", processes.ifBlank { "" }) + diagnostics.recordFileTail( + "Wine debug log", + File(applicationContext.getExternalFilesDir(null), "wine_logs/wine_debug.log"), + ) + } + + private fun payloadSummary(directory: File): String = buildString { + append("directory=${directory.path}") + directory.listFiles().orEmpty().sortedBy(File::getName).forEach { file -> + append(" ${file.name}=") + append(if (file.isFile) "file(${file.length()} bytes)" else if (file.isDirectory) "directory" else "missing") + } + } + + override fun close() { + diagnostics.record("service", "closing") + runCatching { controlServer?.close() }.onFailure { Timber.w(it, "Windows VR control server close failed") } + controlServer = null + snapshots.detach() + runCatching { payloadManager.restore() }.onFailure { Timber.w(it, "Windows VR payload restore failed") } + runtimeLogDirectory = null + container = null + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrSnapshotProvider.kt b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrSnapshotProvider.kt new file mode 100644 index 0000000000..c549f37c51 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrSnapshotProvider.kt @@ -0,0 +1,64 @@ +package app.gamenative.ui.screen.xr.windows + +import app.gamenative.ui.screen.xr.XrNative + +data class WindowsVrRuntimeSnapshot( + val timing: LongArray, + val views: FloatArray, + val input: FloatArray, + val flags: IntArray, +) + +class WindowsVrSnapshotProvider { + @Volatile + private var handle = 0L + @Volatile + private var latest: WindowsVrRuntimeSnapshot? = null + + private val lock = Any() + + fun attach(handle: Long) { + synchronized(lock) { this.handle = handle } + } + + fun detach() { + synchronized(lock) { + handle = 0L + latest = null + } + } + + fun waitFrame(afterSerial: Long, timeoutMs: Int): WindowsVrRuntimeSnapshot? { + val activeHandle = handle + if (activeHandle == 0L) return null + val snapshot = WindowsVrRuntimeSnapshot(LongArray(12), FloatArray(22), FloatArray(36), IntArray(3)) + if (!XrNative.nativeWaitWindowsFrame( + activeHandle, + afterSerial, + timeoutMs, + snapshot.timing, + snapshot.views, + snapshot.input, + snapshot.flags, + )) return null + synchronized(lock) { + if (handle != activeHandle) return null + val current = latest + if (current == null || current.timing[0] <= snapshot.timing[0]) latest = snapshot + } + return snapshot + } + + fun latest(): WindowsVrRuntimeSnapshot? = latest + + fun applyHaptic(hand: Int, amplitude: Float, duration: Long, frequency: Float): Boolean { + val activeHandle = handle + return activeHandle != 0L && XrNative.nativeApplyWindowsHaptic( + activeHandle, + hand, + amplitude, + duration, + frequency, + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 7b82e3c5ac..be6e67e82a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -2123,6 +2123,7 @@ fun XServerScreen( private fun refreshFrameRatingTracking(reason: String) { val rating = frameRating ?: return + if (!getxServer().isFlatPresentationEnabled) return val topmost = findTopmostApplicationWindow(getxServer().windowManager.rootWindow) val nextId = topmost?.id ?: -1 if (frameRatingWindowId == nextId) return @@ -2158,6 +2159,7 @@ fun XServerScreen( if (shouldShowMouseCursor()) renderer?.setCursorVisible(true) xServerState.value.winStarted = true } + if (!getxServer().isFlatPresentationEnabled) return if (frameRatingWindowId == -1 && window.isApplicationWindow()) { refreshFrameRatingTracking("content-update") } @@ -2304,6 +2306,7 @@ fun XServerScreen( Timber.i("Doing things once") val envVars = EnvVars() + immersiveHooks?.windowsVr?.beforeWineSystemSetup(container) runBlocking { setupWineSystemFiles( @@ -2350,7 +2353,8 @@ fun XServerScreen( xServerView!!.getxServer(), containerVariantChanged, onGameLaunchError, - isOffline + isOffline, + immersiveHooks, ) // Autostart performance driver after environment is set up @@ -3823,7 +3827,8 @@ private fun setupXEnvironment( xServer: XServer, containerVariantChanged: Boolean, onGameLaunchError: ((String) -> Unit)? = null, - offline: Boolean = false + offline: Boolean = false, + immersiveHooks: app.gamenative.ui.screen.xr.ImmersiveSessionHooks? = null, ): XEnvironment { ProcessHelper.hardKillStaleWineProcesses() @@ -3974,6 +3979,7 @@ private fun setupXEnvironment( guestProgramLauncherComponent.setSteamType(container.getSteamType()) envVars.putAll(container.envVars) + immersiveHooks?.windowsVr?.afterContainerEnvironmentMerged(envVars, container) envVars.remove("DXVK_FRAME_RATE") envVars.remove("VKD3D_FRAME_RATE") if (!envVars.has("WINEESYNC")) envVars.put("WINEESYNC", "1") @@ -4202,7 +4208,9 @@ private fun setupXEnvironment( } try { + immersiveHooks?.windowsVr?.beforeGuestProcessStart() environment.startEnvironmentComponents() + immersiveHooks?.windowsVr?.onEnvironmentStarted() } catch (e: Exception) { Timber.e(e, "Failed to start environment components, cleaning up") try { diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 3c7fbd5b71..67574657c2 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -305,6 +305,7 @@ object ContainerUtils { rendererPresentMode = container.rendererPresentMode, displayRenderer = container.displayRenderer, xrRefreshRate = container.xrRefreshRate, + xrRenderScale = container.xrRenderScale, sfCompatMode = container.sfCompatMode, dxwrapper = container.dxWrapper, dxwrapperConfig = container.dxWrapperConfig, @@ -368,6 +369,8 @@ object ContainerUtils { sharpnessDenoise = container.getExtra("sharpnessDenoise", "100").toIntOrNull() ?: 100, // LSFG Vulkan frame generation lsfgEnabled = container.getExtra(LsfgVkManager.EXTRA_ARMED, "false").toBoolean(), + windowsVrEnabled = container.getExtra("windowsVrEnabled", "false").toBoolean(), + openCompositeEnabled = container.getExtra("windowsVrOpenCompositeEnabled", "false").toBoolean(), ) } @@ -492,6 +495,7 @@ object ContainerUtils { container.rendererPresentMode = containerData.rendererPresentMode container.displayRenderer = containerData.displayRenderer container.xrRefreshRate = containerData.xrRefreshRate + container.xrRenderScale = containerData.xrRenderScale container.sfCompatMode = containerData.sfCompatMode container.dxWrapper = containerData.dxwrapper container.dxWrapperConfig = containerData.dxwrapperConfig @@ -555,6 +559,8 @@ object ContainerUtils { container.putExtra("sharpnessDenoise", containerData.sharpnessDenoise.toString()) // LSFG Vulkan frame generation container.putExtra(LsfgVkManager.EXTRA_ARMED, containerData.lsfgEnabled.toString()) + container.putExtra("windowsVrEnabled", containerData.windowsVrEnabled.toString()) + container.putExtra("windowsVrOpenCompositeEnabled", containerData.openCompositeEnabled.toString()) try { container.language = containerData.language } catch (e: Exception) { diff --git a/app/src/main/java/com/winlator/container/Container.java b/app/src/main/java/com/winlator/container/Container.java index accd82e1e5..2a53c0a4cc 100644 --- a/app/src/main/java/com/winlator/container/Container.java +++ b/app/src/main/java/com/winlator/container/Container.java @@ -86,6 +86,7 @@ public enum XrControllerMapping { private String rendererPresentMode = "fifo"; private String displayRenderer = Container.DEFAULT_DISPLAY_RENDERER; private int xrRefreshRate = 72; + private int xrRenderScale = 100; private boolean sfCompatMode = true; private String wincomponents = DEFAULT_WINCOMPONENTS; private String audioDriver = DEFAULT_AUDIO_DRIVER; @@ -280,6 +281,10 @@ public void setGraphicsDriverConfig(String graphicsDriverConfig) { public void setXrRefreshRate(int v) { this.xrRefreshRate = v; } + public int getXrRenderScale() { return xrRenderScale; } + + public void setXrRenderScale(int v) { this.xrRenderScale = v; } + public void setDisplayRenderer(String v) { this.displayRenderer = v; } public boolean getSfCompatMode() { return sfCompatMode; } @@ -721,6 +726,7 @@ public void saveData() { data.put("rendererPresentMode", rendererPresentMode); data.put("displayRendererMode", displayRenderer); data.put("xrRefreshRate", xrRefreshRate); + data.put("xrRenderScale", xrRenderScale); data.put("sfCompatMode", sfCompatMode); data.put("dxwrapper", dxwrapper); if (!dxwrapperConfig.isEmpty()) data.put("dxwrapperConfig", dxwrapperConfig); @@ -851,6 +857,9 @@ public void loadData(JSONObject data) throws JSONException { case "xrRefreshRate" : setXrRefreshRate(data.getInt(key)); break; + case "xrRenderScale" : + setXrRenderScale(data.getInt(key)); + break; case "sfCompatMode" : setSfCompatMode(data.getBoolean(key)); break; diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt index b4775bb29c..52acaaf1ff 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -20,6 +20,7 @@ data class ContainerData( val rendererPresentMode: String = "fifo", val displayRenderer: String = Container.DEFAULT_DISPLAY_RENDERER, val xrRefreshRate: Int = 72, + val xrRenderScale: Int = 100, val sfCompatMode: Boolean = true, var dxwrapper: String = Container.DEFAULT_DXWRAPPER, val dxwrapperConfig: String = "", @@ -110,6 +111,8 @@ data class ContainerData( // LSFG Vulkan frame generation /** Whether LSFG frame generation is enabled for this container */ val lsfgEnabled: Boolean = false, + val windowsVrEnabled: Boolean = false, + val openCompositeEnabled: Boolean = false, ) { companion object { val Saver = mapSaver( @@ -124,6 +127,7 @@ data class ContainerData( "rendererPresentMode" to state.rendererPresentMode, "displayRenderer" to state.displayRenderer, "xrRefreshRate" to state.xrRefreshRate, + "xrRenderScale" to state.xrRenderScale, "sfCompatMode" to state.sfCompatMode, "dxwrapper" to state.dxwrapper, "dxwrapperConfig" to state.dxwrapperConfig, @@ -184,6 +188,8 @@ data class ContainerData( "sharpnessLevel" to state.sharpnessLevel, "sharpnessDenoise" to state.sharpnessDenoise, "lsfgEnabled" to state.lsfgEnabled, + "windowsVrEnabled" to state.windowsVrEnabled, + "openCompositeEnabled" to state.openCompositeEnabled, ) }, restore = { savedMap -> @@ -197,6 +203,7 @@ data class ContainerData( rendererPresentMode = (savedMap["rendererPresentMode"] as? String) ?: "fifo", displayRenderer = (savedMap["displayRenderer"] as? String) ?: "vulkan", xrRefreshRate = (savedMap["xrRefreshRate"] as? Int) ?: 72, + xrRenderScale = (savedMap["xrRenderScale"] as? Int) ?: 100, sfCompatMode = (savedMap["sfCompatMode"] as? Boolean) ?: true, dxwrapper = savedMap["dxwrapper"] as String, dxwrapperConfig = savedMap["dxwrapperConfig"] as String, @@ -257,6 +264,8 @@ data class ContainerData( sharpnessLevel = (savedMap["sharpnessLevel"] as? Int) ?: 100, sharpnessDenoise = (savedMap["sharpnessDenoise"] as? Int) ?: 100, lsfgEnabled = (savedMap["lsfgEnabled"] as? Boolean) ?: false, + windowsVrEnabled = (savedMap["windowsVrEnabled"] as? Boolean) ?: false, + openCompositeEnabled = (savedMap["openCompositeEnabled"] as? Boolean) ?: false, ) }, ) diff --git a/app/src/main/java/com/winlator/renderer/GLRenderer.java b/app/src/main/java/com/winlator/renderer/GLRenderer.java index f5645df8fb..2d5a9af1bd 100644 --- a/app/src/main/java/com/winlator/renderer/GLRenderer.java +++ b/app/src/main/java/com/winlator/renderer/GLRenderer.java @@ -62,6 +62,16 @@ public class GLRenderer implements GLSurfaceView.Renderer, WindowManager.OnWindo private final EffectComposer effectComposer; private FrameRating frameRating; private volatile XrFrameBridge xrFrameBridge = null; + private volatile boolean flatPresentationEnabled = true; + + public void setFlatPresentationEnabled(boolean enabled) { + if (flatPresentationEnabled == enabled) return; + flatPresentationEnabled = enabled; + if (enabled) { + xServerView.queueEvent(this::updateScene); + xServerView.requestRender(); + } + } public GLRenderer(XServerViewGL xServerView, XServer xServer) { this.xServerView = xServerView; @@ -127,6 +137,7 @@ public void onSurfaceChanged(GL10 gl, int width, int height) { @Override public void onDrawFrame(GL10 gl) { + if (!flatPresentationEnabled) return; if (toggleFullscreen) { fullscreen = !fullscreen; toggleFullscreen = false; @@ -239,29 +250,34 @@ else if (magnifierEnabled) { @Override public void onMapWindow(Window window) { + if (!flatPresentationEnabled) return; xServerView.queueEvent(this::updateScene); xServerView.requestRender(); } @Override public void onUnmapWindow(Window window) { + if (!flatPresentationEnabled) return; xServerView.queueEvent(this::updateScene); xServerView.requestRender(); } @Override public void onChangeWindowZOrder(Window window) { + if (!flatPresentationEnabled) return; xServerView.queueEvent(this::updateScene); xServerView.requestRender(); } @Override public void onUpdateWindowContent(Window window) { + if (!flatPresentationEnabled) return; xServerView.requestRender(); } @Override public void onUpdateWindowGeometry(final Window window, boolean resized) { + if (!flatPresentationEnabled) return; if (resized) { xServerView.queueEvent(this::updateScene); } @@ -271,11 +287,13 @@ public void onUpdateWindowGeometry(final Window window, boolean resized) { @Override public void onUpdateWindowAttributes(Window window, Bitmask mask) { + if (!flatPresentationEnabled) return; if (mask.isSet(WindowAttributes.FLAG_CURSOR)) xServerView.requestRender(); } @Override public void onPointerMove(short x, short y) { + if (!flatPresentationEnabled) return; xServerView.requestRender(); } @@ -355,7 +373,7 @@ public void setXrFrameBridge(XrFrameBridge xrFrameBridge) { this.xrFrameBridge = xrFrameBridge; if (xrFrameBridge == null) clearRenderTargetSizeOverride(); viewportNeedsUpdate = true; - xServerView.requestRender(); + if (flatPresentationEnabled) xServerView.requestRender(); } /** Whether active screen effects are forcing the composer path, which blocks the XR direct @@ -373,6 +391,7 @@ private Drawable createRootCursorDrawable() { } private void updateScene() { + if (!flatPresentationEnabled) return; try (XLock lock = xServer.lock(XServer.Lockable.WINDOW_MANAGER, XServer.Lockable.DRAWABLE_MANAGER)) { renderableWindows.clear(); collectRenderableWindows(xServer.windowManager.rootWindow, xServer.windowManager.rootWindow.getX(), xServer.windowManager.rootWindow.getY()); diff --git a/app/src/main/java/com/winlator/renderer/VulkanRenderer.java b/app/src/main/java/com/winlator/renderer/VulkanRenderer.java index d0781ccfac..a7b7e0c8f3 100644 --- a/app/src/main/java/com/winlator/renderer/VulkanRenderer.java +++ b/app/src/main/java/com/winlator/renderer/VulkanRenderer.java @@ -68,6 +68,19 @@ public class VulkanRenderer implements WindowManager.OnWindowModificationListene private boolean xRenderingPausedForScanout = false; private volatile VulkanXrFrameBridge xrFrameBridge = null; private volatile long xrTargetAhbPtr = 0; + private volatile boolean flatPresentationEnabled = true; + + public void setFlatPresentationEnabled(boolean enabled) { + if (flatPresentationEnabled == enabled) return; + flatPresentationEnabled = enabled; + if (!enabled) { + scenePending.set(false); + } else { + onPointerMove(xServer.pointer.getX(), xServer.pointer.getY()); + queueSceneUpdate(); + xServerView.requestRender(); + } + } /** See VulkanXrFrameBridge's kdoc — null except for the Meta Quest immersive path. */ public void setVulkanXrFrameBridge(VulkanXrFrameBridge xrFrameBridge) { @@ -174,6 +187,7 @@ private long did(Drawable d) { } public void queueSceneUpdate() { + if (!flatPresentationEnabled) return; if (scenePending.compareAndSet(false, true)) { xServerView.queueEvent(() -> { scenePending.set(false); @@ -378,6 +392,7 @@ private void updateTransform() { } public void updateScene() { + if (!flatPresentationEnabled) return; ArrayList newList = new ArrayList<>(); try (XLock xl = xServer.lock(XServer.Lockable.WINDOW_MANAGER, XServer.Lockable.DRAWABLE_MANAGER)) { collectWindows(newList, xServer.windowManager.rootWindow, @@ -478,6 +493,7 @@ private void sendCursorToNative(Cursor cursor) { } public void onUpdateWindowContentDirect(Window window, Drawable pixmap, short xOff, short yOff) { + if (!flatPresentationEnabled) return; if (hudRef != null && !nativeMode) hudRef.update(); if (nativeHandle == 0 || pixmap == null) return; Drawable targetDrawable = window.getContent(); @@ -527,6 +543,7 @@ public void onUpdateWindowContentDirect(Window window, Drawable pixmap, short xO @Override public void onUpdateWindowContent(Window window) { + if (!flatPresentationEnabled) return; if (hudRef != null) hudRef.update(); final long handle; synchronized (lock) { handle = nativeHandle; } @@ -583,6 +600,7 @@ public void onUpdateWindowContent(Window window) { @Override public void onPointerMove(short x, short y) { + if (!flatPresentationEnabled) return; synchronized (lock) { if (nativeHandle == 0) return; nativeSetPointerPos(nativeHandle, x, y); @@ -607,7 +625,9 @@ public void onDestroyWindow(Window window) { }); } - @Override public void onMapWindow(Window window) { queueSceneUpdate(); } + @Override public void onMapWindow(Window window) { + if (flatPresentationEnabled) queueSceneUpdate(); + } @Override public void onUnmapWindow(Window window) { @@ -618,15 +638,18 @@ public void onUnmapWindow(Window window) { }); } - @Override public void onChangeWindowZOrder(Window window) { queueSceneUpdate(); } + @Override public void onChangeWindowZOrder(Window window) { + if (flatPresentationEnabled) queueSceneUpdate(); + } @Override public void onUpdateWindowGeometry(Window window, boolean resized) { - queueSceneUpdate(); + if (flatPresentationEnabled) queueSceneUpdate(); } @Override public void onUpdateWindowAttributes(Window window, Bitmask mask) { + if (!flatPresentationEnabled) return; if (mask.isSet(WindowAttributes.FLAG_CURSOR)) { synchronized (lock) { Window pw = xServer.inputDeviceManager.getPointWindow(); diff --git a/app/src/main/java/com/winlator/xserver/XServer.java b/app/src/main/java/com/winlator/xserver/XServer.java index 12fd46a004..18565e8480 100644 --- a/app/src/main/java/com/winlator/xserver/XServer.java +++ b/app/src/main/java/com/winlator/xserver/XServer.java @@ -48,6 +48,7 @@ public enum Lockable {WINDOW_MANAGER, PIXMAP_MANAGER, DRAWABLE_MANAGER, GRAPHIC_ private final boolean mouseDragCompatibilityEnabled; private boolean simulateTouchScreen = false; private final boolean runningFromGlibc; + private volatile boolean flatPresentationEnabled = true; public XServer(ScreenInfo screenInfo, boolean useGlibcContainer) { this(screenInfo, useGlibcContainer, false); @@ -105,6 +106,14 @@ public void setRenderingEnabled(boolean enabled) { // intentionally empty } + public void setFlatPresentationEnabled(boolean enabled) { + flatPresentationEnabled = enabled; + } + + public boolean isFlatPresentationEnabled() { + return flatPresentationEnabled; + } + public WinHandler getWinHandler() { return winHandler; } diff --git a/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java b/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java index 5892feeba7..dd7b682a29 100644 --- a/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java +++ b/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java @@ -320,6 +320,13 @@ private void presentPixmap(XClient client, XInputStream inputStream, XOutputStre long ust = System.nanoTime() / 1000; long msc = ust / (targetFps > 0 ? (1_000_000L / targetFps) : (1_000_000L / 60)); + if (!client.xServer.isFlatPresentationEnabled()) { + sendCompleteNotify(window, serial, Kind.PIXMAP, Mode.SKIP, ust, msc); + if (targetFps > 0) scheduleIdleNotify(window, pixmap, serial, idleFence, targetFps, vr); + else sendIdleNotify(window, pixmap, serial, idleFence); + return; + } + synchronized (content.renderLock) { if (asr != null) { content.setTexture(pixmap.drawable.getTexture()); diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index fb504f9a42..de4773d101 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -2293,6 +2293,17 @@ Sekvens Sekvensforsinkelse (ms) %1$s (%2$d ms) + Windows VR + Aktivér Windows VR + OpenVR-kompatibilitet (eksperimentel) + Ændringer af runtime og kompatibilitet træder i kraft ved næste start. + Eksportér Windows VR-diagnostik + Gem den afgrænsede rapport over runtime-hændelser + Kør som VR-app (eksperimentel) + Til native VR-titler som Beat Saber. Kræver arm64ec Wine, DXVK og D3D11. Andre spil beholder den flade immersive skærm. + SteamVR (OpenVR)-kompatibilitet + Sender spil bygget til SteamVR gennem OpenComposite. Slå til, hvis spillet siger, at SteamVR ikke kører, eller forbliver fladt med VR-tilstand slået til. + VR-gengivelsesskala Sponsorerede indlæsningsskærme At lade dette være slået til hjælper med at støtte udviklingen af GameNative. Det gør aldrig spilstart langsommere og påvirker dig ikke på nogen måde. Spilanbefalinger på indlæsningsskærme diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 23161b5335..32593cc0d7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2363,6 +2363,17 @@ Sequenz Sequenzverzögerung (ms) %1$s (%2$d ms) + Windows VR + Windows VR aktivieren + OpenVR-Kompatibilität (experimentell) + Änderungen an Runtime und Kompatibilität gelten ab dem nächsten Start. + Windows-VR-Diagnose exportieren + Den begrenzten Bericht der Runtime-Ereignisse speichern + Als VR-App ausführen (experimentell) + Für native VR-Titel wie Beat Saber. Benötigt arm64ec-Wine, DXVK und D3D11. Andere Spiele behalten den flachen Immersiv-Bildschirm. + SteamVR-(OpenVR-)Kompatibilität + Leitet für SteamVR entwickelte Spiele über OpenComposite. Aktivieren, wenn das Spiel meldet, dass SteamVR nicht läuft, oder im VR-Modus flach bleibt. + VR-Renderskalierung Gesponserte Ladebildschirme Wenn du das aktiviert lässt, unterstützt du die Entwicklung von GameNative. Es verlangsamt niemals den Spielstart und beeinträchtigt dich in keiner Weise. Spielempfehlungen auf Ladebildschirmen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9d98073102..33dcc35dd3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2421,6 +2421,17 @@ Secuencia Retraso de secuencia (ms) %1$s (%2$d ms) + Windows VR + Activar Windows VR + Compatibilidad con OpenVR (experimental) + Los cambios de runtime y compatibilidad se aplican en el próximo inicio. + Exportar diagnóstico de Windows VR + Guardar el informe acotado de eventos del runtime + Ejecutar como app de VR (experimental) + Para títulos de VR nativos como Beat Saber. Requiere Wine arm64ec, DXVK y D3D11. Los demás juegos mantienen la pantalla inmersiva plana. + Compatibilidad con SteamVR (OpenVR) + Redirige los juegos creados para SteamVR a través de OpenComposite. Actívalo si el juego dice que SteamVR no está en ejecución o se queda en plano con el modo VR activado. + Escala de renderizado de RV Pantallas de carga patrocinadas Mantener esto activado ayuda a apoyar el desarrollo de GameNative. Nunca ralentiza el inicio de los juegos ni te afecta de ninguna manera. Recomendaciones de juegos en pantallas de carga diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ebe225625c..de83179e30 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2423,6 +2423,17 @@ Séquence Délai de séquence (ms) %1$s (%2$d ms) + Windows VR + Activer Windows VR + Compatibilité OpenVR (expérimental) + Les changements de runtime et de compatibilité s’appliquent au prochain lancement. + Exporter le diagnostic Windows VR + Enregistrer le rapport limité des événements du runtime + Lancer en tant qu’application VR (expérimental) + Pour les titres VR natifs comme Beat Saber. Nécessite Wine arm64ec, DXVK et D3D11. Les autres jeux conservent l’écran immersif plat. + Compatibilité SteamVR (OpenVR) + Fait passer les jeux conçus pour SteamVR par OpenComposite. À activer si le jeu indique que SteamVR n’est pas lancé ou reste à plat avec le mode VR activé. + Échelle de rendu VR Écrans de chargement sponsorisés Laisser cette option activée aide à soutenir le développement de GameNative. Cela ne ralentit jamais le lancement des jeux et ne vous affecte en aucune façon. Recommandations de jeux sur les écrans de chargement diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 65f81a2ab1..4da97cdf67 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2414,6 +2414,17 @@ Sequenza Ritardo sequenza (ms) %1$s (%2$d ms) + Windows VR + Attiva Windows VR + Compatibilità OpenVR (sperimentale) + Le modifiche a runtime e compatibilità si applicano al prossimo avvio. + Esporta diagnostica Windows VR + Salva il rapporto limitato degli eventi del runtime + Esegui come app VR (sperimentale) + Per titoli VR nativi come Beat Saber. Richiede Wine arm64ec, DXVK e D3D11. Gli altri giochi mantengono lo schermo immersivo piatto. + Compatibilità SteamVR (OpenVR) + Instrada i giochi creati per SteamVR tramite OpenComposite. Attivalo se il gioco segnala che SteamVR non è in esecuzione o resta piatto con la modalità VR attiva. + Scala di rendering VR Schermate di caricamento sponsorizzate Lasciare questa opzione attiva aiuta a sostenere lo sviluppo di GameNative. Non rallenta mai l\'avvio dei giochi e non ti influenza in alcun modo. Giochi consigliati nelle schermate di caricamento diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 800f45c9a8..44d379b097 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2376,6 +2376,17 @@ シーケンス シーケンス遅延 (ミリ秒) %1$s(%2$d ミリ秒) + Windows VR + Windows VRを有効化 + OpenVR互換(試験的) + ランタイムと互換性の変更は次回起動時に反映されます。 + Windows VR診断をエクスポート + ランタイムイベントの要約レポートを保存 + VRアプリとして実行(試験的) + Beat SaberなどのネイティブVRタイトル向け。arm64ec Wine、DXVK、D3D11が必要です。その他のゲームは平面の没入型画面のままです。 + SteamVR(OpenVR)互換 + SteamVR向けに作られたゲームをOpenCompositeで動かします。ゲームがSteamVRが起動していないと表示する場合や、VRモードでも平面のままの場合に有効にしてください。 + VRレンダースケール スポンサー付きローディング画面 オンのままにしておくと GameNative の開発を支援できます。ゲームの起動が遅くなることはなく、あなたに影響することも一切ありません。 ローディング画面におすすめゲームを表示 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index aa41576d40..c0f4ec743b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2417,6 +2417,17 @@ 순차 순차 지연 (ms) %1$s (%2$d ms) + Windows VR + Windows VR 사용 + OpenVR 호환 (실험적) + 런타임 및 호환성 변경은 다음 실행 시 적용됩니다. + Windows VR 진단 내보내기 + 런타임 이벤트 요약 보고서 저장 + VR 앱으로 실행 (실험적) + Beat Saber 같은 네이티브 VR 타이틀용입니다. arm64ec Wine, DXVK, D3D11이 필요합니다. 다른 게임은 평면 몰입형 화면을 유지합니다. + SteamVR(OpenVR) 호환 + SteamVR용으로 만들어진 게임을 OpenComposite를 통해 실행합니다. 게임이 SteamVR이 실행 중이 아니라고 표시하거나 VR 모드에서도 평면으로 남아 있으면 켜세요. + VR 렌더링 배율 스폰서 로딩 화면 이 옵션을 켜 두면 GameNative 개발을 지원하는 데 도움이 됩니다. 게임 실행이 느려지거나 사용자에게 어떤 영향도 주지 않습니다. 로딩 화면에 게임 추천 표시 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index cd85c7fcd6..b468d17e14 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2429,6 +2429,17 @@ Sekwencja Opóźnienie sekwencji (ms) %1$s (%2$d ms) + Windows VR + Włącz Windows VR + Zgodność z OpenVR (eksperymentalne) + Zmiany środowiska i zgodności zostaną zastosowane przy następnym uruchomieniu. + Eksportuj diagnostykę Windows VR + Zapisz ograniczony raport zdarzeń środowiska + Uruchom jako aplikację VR (eksperymentalne) + Dla natywnych tytułów VR, takich jak Beat Saber. Wymaga Wine arm64ec, DXVK i D3D11. Pozostałe gry zachowują płaski ekran immersyjny. + Zgodność ze SteamVR (OpenVR) + Kieruje gry stworzone dla SteamVR przez OpenComposite. Włącz, jeśli gra zgłasza, że SteamVR nie działa, lub pozostaje płaska przy włączonym trybie VR. + Skala renderowania VR Sponsorowane ekrany ładowania Pozostawienie tej opcji włączonej wspiera rozwój GameNative. Nigdy nie spowalnia uruchamiania gier i w żaden sposób na Ciebie nie wpływa. Polecane gry na ekranach ładowania diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 1b44b65c4a..a9a35abccc 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -2293,6 +2293,17 @@ Sequência Atraso da sequência (ms) %1$s (%2$d ms) + Windows VR + Ativar Windows VR + Compatibilidade com OpenVR (experimental) + As mudanças de runtime e compatibilidade valem a partir da próxima inicialização. + Exportar diagnóstico do Windows VR + Salvar o relatório resumido de eventos do runtime + Executar como app de VR (experimental) + Para títulos de VR nativos como Beat Saber. Requer Wine arm64ec, DXVK e D3D11. Os demais jogos mantêm a tela imersiva plana. + Compatibilidade com SteamVR (OpenVR) + Roteia jogos feitos para SteamVR pelo OpenComposite. Ative se o jogo disser que o SteamVR não está em execução ou continuar plano com o modo VR ativado. + Escala de renderização de VR Telas de carregamento patrocinadas Manter isso ativado ajuda a apoiar o desenvolvimento do GameNative. Nunca deixa o início dos jogos mais lento nem afeta você de forma alguma. Recomendações de jogos nas telas de carregamento diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 0549bace59..055a598b4d 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2428,6 +2428,17 @@ Secvență Întârziere secvență (ms) %1$s (%2$d ms) + Windows VR + Activează Windows VR + Compatibilitate OpenVR (experimental) + Modificările de runtime și compatibilitate se aplică la următoarea lansare. + Exportă diagnosticul Windows VR + Salvează raportul restrâns al evenimentelor runtime + Rulează ca aplicație VR (experimental) + Pentru titluri VR native precum Beat Saber. Necesită Wine arm64ec, DXVK și D3D11. Celelalte jocuri păstrează ecranul imersiv plat. + Compatibilitate SteamVR (OpenVR) + Direcționează jocurile create pentru SteamVR prin OpenComposite. Activează dacă jocul spune că SteamVR nu rulează sau rămâne plat cu modul VR pornit. + Scară de randare VR Ecrane de încărcare sponsorizate Păstrarea acestei opțiuni activate ajută la susținerea dezvoltării GameNative. Nu încetinește niciodată pornirea jocurilor și nu te afectează în niciun fel. Recomandări de jocuri pe ecranele de încărcare diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 30154ffb9a..8a03587716 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2357,6 +2357,17 @@ https://gamenative.app Последовательно Задержка последовательности (мс) %1$s (%2$d мс) + Windows VR + Включить Windows VR + Совместимость с OpenVR (экспериментально) + Изменения среды выполнения и совместимости вступят в силу при следующем запуске. + Экспорт диагностики Windows VR + Сохранить краткий отчёт о событиях среды выполнения + Запускать как VR-приложение (экспериментально) + Для нативных VR-игр, таких как Beat Saber. Требуются Wine arm64ec, DXVK и D3D11. Остальные игры сохраняют плоский иммерсивный экран. + Совместимость со SteamVR (OpenVR) + Запускает игры, созданные для SteamVR, через OpenComposite. Включите, если игра сообщает, что SteamVR не запущен, или остаётся плоской при включённом VR-режиме. + Масштаб рендеринга VR Спонсорские экраны загрузки Если оставить это включённым, вы поддержите разработку GameNative. Это никогда не замедляет запуск игр и никак на вас не влияет. Рекомендации игр на экранах загрузки diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index bf5f66e793..a5bdecd81a 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2425,6 +2425,17 @@ Послідовно Затримка послідовності (мс) %1$s (%2$d мс) + Windows VR + Увімкнути Windows VR + Сумісність з OpenVR (експериментально) + Зміни середовища виконання та сумісності набудуть чинності після наступного запуску. + Експортувати діагностику Windows VR + Зберегти стислий звіт про події середовища виконання + Запускати як VR-застосунок (експериментально) + Для нативних VR-ігор, як-от Beat Saber. Потрібні Wine arm64ec, DXVK і D3D11. Інші ігри зберігають плаский імерсивний екран. + Сумісність зі SteamVR (OpenVR) + Запускає ігри, створені для SteamVR, через OpenComposite. Увімкніть, якщо гра повідомляє, що SteamVR не запущено, або залишається пласкою з увімкненим VR-режимом. + Масштаб рендерингу VR Спонсорські екрани завантаження Якщо залишити це увімкненим, ви підтримаєте розробку GameNative. Це ніколи не сповільнює запуск ігор і жодним чином на вас не впливає. Рекомендації ігор на екранах завантаження diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 661e3b87cb..6f252f0d32 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2437,6 +2437,17 @@ 顺序 顺序延迟(毫秒) %1$s(%2$d 毫秒) + Windows VR + 启用 Windows VR + OpenVR 兼容(实验性) + 运行时和兼容性更改将在下次启动时生效。 + 导出 Windows VR 诊断 + 保存精简的运行时事件报告 + 以 VR 应用运行(实验性) + 适用于 Beat Saber 等原生 VR 游戏。需要 arm64ec Wine、DXVK 和 D3D11。其他游戏仍使用平面沉浸式屏幕。 + SteamVR (OpenVR) 兼容 + 通过 OpenComposite 运行为 SteamVR 开发的游戏。若游戏提示 SteamVR 未运行,或开启 VR 模式后仍为平面显示,请开启此项。 + VR 渲染比例 赞助加载画面 保持开启有助于支持 GameNative 的开发。它绝不会拖慢游戏启动,也不会以任何方式影响你。 在加载画面显示游戏推荐 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ea83ef138b..ed535eea1a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2428,6 +2428,17 @@ 順序 順序延遲(毫秒) %1$s(%2$d 毫秒) + Windows VR + 啟用 Windows VR + OpenVR 相容(實驗性) + 執行階段與相容性變更將在下次啟動時生效。 + 匯出 Windows VR 診斷 + 儲存精簡的執行階段事件報告 + 以 VR 應用程式執行(實驗性) + 適用於 Beat Saber 等原生 VR 遊戲。需要 arm64ec Wine、DXVK 與 D3D11。其他遊戲仍使用平面沉浸式畫面。 + SteamVR (OpenVR) 相容 + 透過 OpenComposite 執行為 SteamVR 開發的遊戲。若遊戲提示 SteamVR 未執行,或開啟 VR 模式後仍為平面顯示,請開啟此項。 + VR 算繪比例 贊助載入畫面 保持開啟有助於支持 GameNative 的開發。它絕不會拖慢遊戲啟動,也不會以任何方式影響你。 在載入畫面顯示遊戲推薦 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2483498170..2d97919a94 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -342,6 +342,12 @@ Double-click a thumbstick to switch Xbox controller / XR pointer XR pointer mode Xbox controller mode + Windows VR + Enable Windows VR + OpenVR compatibility (experimental) + Runtime and compatibility changes apply on the next launch. + Export Windows VR diagnostics + Save the bounded runtime event report %1$d running, tap/click a process to close No running EXEs detected. Performance HUD @@ -2439,6 +2445,11 @@ Sequence Sequence delay (ms) %1$s (%2$d ms) + Run as VR app (experimental) + For native VR titles such as Beat Saber. Needs arm64ec Wine, DXVK and D3D11. Other games keep the flat immersive screen. + SteamVR (OpenVR) compatibility + Routes games built for SteamVR through OpenComposite. Turn on if the game says SteamVR is not running or stays flat with VR mode on. + VR Render Scale Gyro aiming Gyro settings Output and motion diff --git a/app/src/main/windows/openxr_runtime/CMakeLists.txt b/app/src/main/windows/openxr_runtime/CMakeLists.txt new file mode 100644 index 0000000000..f43507268f --- /dev/null +++ b/app/src/main/windows/openxr_runtime/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 3.22) +project(gamenative_openxr_runtime C) + +include(FetchContent) +FetchContent_Declare( + openxr_sdk + URL https://github.com/KhronosGroup/OpenXR-SDK-Source/archive/refs/tags/release-1.1.61.tar.gz + URL_HASH SHA256=b88e83e2515cadb08608747b6c0f272a62b04f5089a965288f3aa4a4332291ba + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) +set(BUILD_LOADER OFF CACHE BOOL "" FORCE) +set(BUILD_API_LAYERS OFF CACHE BOOL "" FORCE) +set(BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_CONFORMANCE_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_SDK_TESTS OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(openxr_sdk) + +add_library(gamenative_openxr_runtime SHARED gamenative_openxr_runtime.c) +target_compile_definitions(gamenative_openxr_runtime PRIVATE XR_USE_PLATFORM_WIN32 XR_USE_GRAPHICS_API_D3D11 WIN32_LEAN_AND_MEAN) +target_include_directories(gamenative_openxr_runtime PRIVATE + ${openxr_sdk_BINARY_DIR}/include + ${openxr_sdk_SOURCE_DIR}/include + ${openxr_sdk_SOURCE_DIR}/src/common +) +target_link_libraries(gamenative_openxr_runtime PRIVATE ws2_32 dxgi) +set_target_properties(gamenative_openxr_runtime PROPERTIES OUTPUT_NAME gamenative_openxr_runtime64) +if(MSVC) + target_compile_options(gamenative_openxr_runtime PRIVATE /W4 /WX /wd4201 /guard:cf) + target_link_options(gamenative_openxr_runtime PRIVATE /DEF:${CMAKE_CURRENT_SOURCE_DIR}/gamenative_openxr_runtime_x64.def) +endif() diff --git a/app/src/main/windows/openxr_runtime/builtin/Makefile.in b/app/src/main/windows/openxr_runtime/builtin/Makefile.in new file mode 100644 index 0000000000..315a4e9aa6 --- /dev/null +++ b/app/src/main/windows/openxr_runtime/builtin/Makefile.in @@ -0,0 +1,4 @@ +MODULE = gamenative_xr_unixbridge.dll +IMPORTS = ntdll +EXTRADLLFLAGS = -marm64x -Wl,/align:65536 -Wl,--file-alignment,65536 +SOURCES = gamenative_xr_unixbridge.c diff --git a/app/src/main/windows/openxr_runtime/builtin/gamenative_xr_unixbridge.c b/app/src/main/windows/openxr_runtime/builtin/gamenative_xr_unixbridge.c new file mode 100644 index 0000000000..8b95de6173 --- /dev/null +++ b/app/src/main/windows/openxr_runtime/builtin/gamenative_xr_unixbridge.c @@ -0,0 +1,29 @@ + + + + + +typedef unsigned int gn_dword; +typedef int gn_bool; +typedef unsigned long long gn_unixlib_handle_t; +typedef int (*gn_unix_call_dispatcher_t)( + gn_unixlib_handle_t handle, unsigned int code, void *args); + +extern gn_unixlib_handle_t __wine_unixlib_handle; +extern gn_unix_call_dispatcher_t __wine_unix_call_dispatcher; +extern int __wine_init_unix_call(void); + +__declspec(dllexport) +int gnWineUnixCall(unsigned int code, void *args) +{ + return __wine_unix_call_dispatcher(__wine_unixlib_handle, code, args); +} + +gn_bool DllMain(void *instance, gn_dword reason, void *reserved) +{ + (void)instance; + (void)reserved; + if (reason == 1 ) + return __wine_init_unix_call() == 0; + return 1; +} diff --git a/app/src/main/windows/openxr_runtime/builtin/gamenative_xr_unixbridge.spec b/app/src/main/windows/openxr_runtime/builtin/gamenative_xr_unixbridge.spec new file mode 100644 index 0000000000..8f6771b7da --- /dev/null +++ b/app/src/main/windows/openxr_runtime/builtin/gamenative_xr_unixbridge.spec @@ -0,0 +1 @@ +@ stdcall gnWineUnixCall(long ptr) diff --git a/app/src/main/windows/openxr_runtime/builtin/gamenative_xr_unixbridge32.dll b/app/src/main/windows/openxr_runtime/builtin/gamenative_xr_unixbridge32.dll new file mode 100644 index 0000000000..54762881b3 Binary files /dev/null and b/app/src/main/windows/openxr_runtime/builtin/gamenative_xr_unixbridge32.dll differ diff --git a/app/src/main/windows/openxr_runtime/dxgi_x64.def b/app/src/main/windows/openxr_runtime/dxgi_x64.def new file mode 100644 index 0000000000..4bbd16333a --- /dev/null +++ b/app/src/main/windows/openxr_runtime/dxgi_x64.def @@ -0,0 +1,3 @@ +LIBRARY dxgi.dll +EXPORTS + CreateDXGIFactory diff --git a/app/src/main/windows/openxr_runtime/dxgi_x86.def b/app/src/main/windows/openxr_runtime/dxgi_x86.def new file mode 100644 index 0000000000..e502f4888b --- /dev/null +++ b/app/src/main/windows/openxr_runtime/dxgi_x86.def @@ -0,0 +1,3 @@ +LIBRARY dxgi.dll +EXPORTS + CreateDXGIFactory@8 diff --git a/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime.c b/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime.c new file mode 100644 index 0000000000..2f4da042a8 --- /dev/null +++ b/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime.c @@ -0,0 +1,3564 @@ + + + + +#define XR_NO_PROTOTYPES +#define XR_EXTENSION_PROTOTYPES + +typedef void* VkInstance; +typedef void* VkPhysicalDevice; +typedef void* VkDevice; +typedef void* VkImage; +typedef void* VkQueue; +typedef int VkResult; +typedef struct ID3D11Device ID3D11Device; +typedef struct ID3D11Texture2D ID3D11Texture2D; +typedef struct ID3D12Device ID3D12Device; +typedef struct ID3D12CommandQueue ID3D12CommandQueue; +typedef struct ID3D12Resource ID3D12Resource; +typedef struct LUID { + unsigned long LowPart; + long HighPart; +} LUID; +typedef enum D3D_FEATURE_LEVEL { + D3D_FEATURE_LEVEL_11_0 = 0xb000 +} D3D_FEATURE_LEVEL; + +#include +#include +#include "gamenative_openxr_unix.h" + +#define XR_KHR_D3D11_enable_SPEC_VERSION 11 +#define XR_KHR_D3D11_ENABLE_EXTENSION_NAME "XR_KHR_D3D11_enable" +#define XR_KHR_vulkan_enable_SPEC_VERSION 10 +#define XR_KHR_VULKAN_ENABLE_EXTENSION_NAME "XR_KHR_vulkan_enable" +#define XR_KHR_vulkan_enable2_SPEC_VERSION 4 +#define XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME "XR_KHR_vulkan_enable2" +#define XR_KHR_D3D12_enable_SPEC_VERSION 9 +#define XR_KHR_D3D12_ENABLE_EXTENSION_NAME "XR_KHR_D3D12_enable" + +typedef struct XrGraphicsRequirementsD3D11KHR { + XrStructureType type; + void* XR_MAY_ALIAS next; + LUID adapterLuid; + D3D_FEATURE_LEVEL minFeatureLevel; +} XrGraphicsRequirementsD3D11KHR; + +typedef struct XrGraphicsRequirementsVulkanKHR { + XrStructureType type; + void* XR_MAY_ALIAS next; + XrVersion minApiVersionSupported; + XrVersion maxApiVersionSupported; +} XrGraphicsRequirementsVulkanKHR; + +typedef struct XrVulkanGraphicsDeviceGetInfoKHR { + XrStructureType type; + const void* XR_MAY_ALIAS next; + XrSystemId systemId; + VkInstance vulkanInstance; +} XrVulkanGraphicsDeviceGetInfoKHR; + +typedef struct XrGraphicsBindingVulkanKHR { + XrStructureType type; + const void* XR_MAY_ALIAS next; + VkInstance instance; + VkPhysicalDevice physicalDevice; + VkDevice device; + unsigned int queueFamilyIndex; + unsigned int queueIndex; +} XrGraphicsBindingVulkanKHR; + +typedef struct XrSwapchainImageVulkanKHR { + XrStructureType type; + void* XR_MAY_ALIAS next; + VkImage image; +} XrSwapchainImageVulkanKHR; + +typedef struct XrGraphicsBindingD3D11KHR { + XrStructureType type; + const void* XR_MAY_ALIAS next; + ID3D11Device* device; +} XrGraphicsBindingD3D11KHR; + +typedef struct XrGraphicsBindingD3D12KHR { + XrStructureType type; + const void* XR_MAY_ALIAS next; + ID3D12Device* device; + ID3D12CommandQueue* queue; +} XrGraphicsBindingD3D12KHR; + +typedef struct XrGraphicsRequirementsD3D12KHR { + XrStructureType type; + void* XR_MAY_ALIAS next; + LUID adapterLuid; + D3D_FEATURE_LEVEL minFeatureLevel; +} XrGraphicsRequirementsD3D12KHR; + +typedef struct XrSwapchainImageD3D11KHR { + XrStructureType type; + void* XR_MAY_ALIAS next; + ID3D11Texture2D* texture; +} XrSwapchainImageD3D11KHR; + +typedef struct XrSwapchainImageD3D12KHR { + XrStructureType type; + void* XR_MAY_ALIAS next; + ID3D12Resource* texture; +} XrSwapchainImageD3D12KHR; + +typedef struct GnGuid { + unsigned long Data1; + unsigned short Data2; + unsigned short Data3; + unsigned char Data4[8]; +} GnGuid; + +typedef struct GnDxgiSampleDesc { + unsigned int Count; + unsigned int Quality; +} GnDxgiSampleDesc; + +typedef struct GnD3D11Texture2DDesc1 { + unsigned int Width; + unsigned int Height; + unsigned int MipLevels; + unsigned int ArraySize; + int Format; + GnDxgiSampleDesc SampleDesc; + int Usage; + unsigned int BindFlags; + unsigned int CPUAccessFlags; + unsigned int MiscFlags; + int TextureLayout; +} GnD3D11Texture2DDesc1; + +typedef struct GnD3D12ResourceDesc1 { + int Dimension; + unsigned long long Alignment; + unsigned long long Width; + unsigned int Height; + unsigned short DepthOrArraySize; + unsigned short MipLevels; + int Format; + GnDxgiSampleDesc SampleDesc; + int Layout; + unsigned int Flags; + unsigned int SamplerFeedbackMipRegion[3]; +} GnD3D12ResourceDesc1; + +typedef void* (__attribute__((stdcall)) *PFN_vkGetInstanceProcAddr)(VkInstance, const char*); +typedef struct XrVulkanInstanceCreateInfoKHR { + XrStructureType type; + const void* XR_MAY_ALIAS next; + XrSystemId systemId; + XrFlags64 createFlags; + PFN_vkGetInstanceProcAddr pfnGetInstanceProcAddr; + const void* vulkanCreateInfo; + const void* vulkanAllocator; +} XrVulkanInstanceCreateInfoKHR; + +typedef struct XrVulkanDeviceCreateInfoKHR { + XrStructureType type; + const void* XR_MAY_ALIAS next; + XrSystemId systemId; + XrFlags64 createFlags; + PFN_vkGetInstanceProcAddr pfnGetInstanceProcAddr; + VkPhysicalDevice vulkanPhysicalDevice; + const void* vulkanCreateInfo; + const void* vulkanAllocator; +} XrVulkanDeviceCreateInfoKHR; + +#ifndef NULL +#define NULL ((void*)0) +#endif + +#define GN_EXPORT __declspec(dllexport) +#define GN_IMPORT __declspec(dllimport) +#define GN_STDCALL __attribute__((stdcall)) + +typedef unsigned int gn_uint32; +typedef unsigned long long gn_uint64; +typedef unsigned short gn_uint16; +typedef unsigned long gn_ulong; +#ifdef _WIN64 +typedef unsigned long long gn_size; +typedef unsigned long long gn_socket; +#else +typedef unsigned int gn_size; +typedef unsigned int gn_socket; +#endif + +#define GN_AF_INET 2 +#define GN_SOCK_STREAM 1 +#define GN_IPPROTO_TCP 6 +#define GN_INVALID_SOCKET ((gn_socket)~0ULL) +#define GN_SOCKET_ERROR (-1) + + +void* memcpy(void* destination, const void* source, gn_size size) { + unsigned char* dst = (unsigned char*)destination; + const unsigned char* src = (const unsigned char*)source; + for (gn_size i = 0; i < size; ++i) dst[i] = src[i]; + return destination; +} + +void* memset(void* destination, int value, gn_size size) { + unsigned char* dst = (unsigned char*)destination; + for (gn_size i = 0; i < size; ++i) dst[i] = (unsigned char)value; + return destination; +} + +static void gn_zero_memory(void* destination, gn_size size) { + unsigned char* dst = (unsigned char*)destination; + for (gn_size i = 0; i < size; ++i) dst[i] = 0; +} + +struct gn_in_addr { + gn_ulong s_addr; +}; + +struct gn_sockaddr_in { + short sin_family; + gn_uint16 sin_port; + struct gn_in_addr sin_addr; + char sin_zero[8]; +}; + +struct gn_wsadata { + unsigned char bytes[512]; +}; + +GN_IMPORT int GN_STDCALL WSAStartup(gn_uint16 version, struct gn_wsadata* data); +GN_IMPORT gn_socket GN_STDCALL socket(int af, int type, int protocol); +GN_IMPORT int GN_STDCALL connect(gn_socket s, const void* name, int namelen); +GN_IMPORT int GN_STDCALL send(gn_socket s, const char* buf, int len, int flags); +GN_IMPORT int GN_STDCALL recv(gn_socket s, char* buf, int len, int flags); +GN_IMPORT int GN_STDCALL closesocket(gn_socket s); +GN_IMPORT gn_uint16 GN_STDCALL htons(gn_uint16 hostshort); +GN_IMPORT gn_ulong GN_STDCALL inet_addr(const char* cp); + +GN_IMPORT void* GN_STDCALL CreateFileA(const char* name, gn_ulong access, gn_ulong share, void* sec, gn_ulong disposition, gn_ulong flags, void* template_); +GN_IMPORT int GN_STDCALL WriteFile(void* handle, const void* buf, gn_ulong len, gn_ulong* written, void* overlapped); +GN_IMPORT int GN_STDCALL CloseHandle(void* handle); +GN_IMPORT gn_ulong GN_STDCALL GetEnvironmentVariableA(const char* name, char* buffer, gn_ulong size); +GN_IMPORT void GN_STDCALL OutputDebugStringA(const char* text); +GN_IMPORT void* GN_STDCALL LoadLibraryA(const char* name); +GN_IMPORT void* GN_STDCALL GetProcAddress(void* module, const char* name); +GN_IMPORT unsigned long GN_STDCALL GetLastError(void); +GN_IMPORT long GN_STDCALL CreateDXGIFactory(const void* iid, void** factory); + +typedef int gn_ntstatus; +typedef struct { + gn_uint16 Length; + gn_uint16 MaximumLength; + gn_uint16* Buffer; +} GnUnicodeString; +GN_IMPORT gn_ntstatus GN_STDCALL NtQueryVirtualMemory( + void* process, const void* address, int informationClass, + void* buffer, gn_size length, gn_size* resultLength); +GN_IMPORT gn_ntstatus (GN_STDCALL *__wine_unix_call_dispatcher)( + gn_uint64 handle, unsigned int code, void* args); + + + + + +static gn_size gn_strlen(const char* s) { + gn_size n = 0; + if (!s) return 0; + while (s[n]) ++n; + return n; +} + +static int gn_streq(const char* a, const char* b) { + gn_size i = 0; + if (!a || !b) return 0; + while (a[i] && b[i] && a[i] == b[i]) ++i; + return a[i] == b[i]; +} + +static int gn_starts_with(const char* a, const char* prefix) { + gn_size i = 0; + if (!a || !prefix) return 0; + while (prefix[i]) { + if (a[i] != prefix[i]) return 0; + ++i; + } + return 1; +} + +static int gn_contains(const char* haystack, const char* needle) { + gn_size i = 0; + if (!haystack || !needle || !needle[0]) return 0; + while (haystack[i]) { + gn_size j = 0; + while (needle[j] && haystack[i + j] == needle[j]) ++j; + if (!needle[j]) return 1; + ++i; + } + return 0; +} + +static void gn_copy(char* dst, gn_size dst_size, const char* src) { + gn_size i = 0; + if (!dst || dst_size == 0) return; + if (!src) src = ""; + while (i + 1 < dst_size && src[i]) { + dst[i] = src[i]; + ++i; + } + dst[i] = 0; +} + +static void gn_u64_to_dec(gn_uint64 value, char* out) { + char tmp[24]; + int i = 0; + if (value == 0) { + out[0] = '0'; + out[1] = 0; + return; + } + while (value > 0 && i < 23) { + tmp[i++] = (char)('0' + (value % 10)); + value /= 10; + } + int j = 0; + while (i > 0) out[j++] = tmp[--i]; + out[j] = 0; +} + +static void gn_i64_to_dec(long long value, char* out) { + if (value < 0) { + out[0] = '-'; + gn_u64_to_dec((gn_uint64)(-value), out + 1); + } else { + gn_u64_to_dec((gn_uint64)value, out); + } +} + +static gn_size gn_append(char* dst, gn_size dst_size, gn_size offset, const char* src) { + if (!dst || dst_size == 0 || !src) return offset; + while (offset + 1 < dst_size && *src) dst[offset++] = *src++; + dst[offset] = 0; + return offset; +} + +static gn_size gn_append_i64(char* dst, gn_size dst_size, gn_size offset, long long value) { + char num[24]; + gn_i64_to_dec(value, num); + return gn_append(dst, dst_size, offset, num); +} + + + +#define GN_PARSE_MISSING 0x7fffffffffffffffLL + +static long long gn_parse_i64(const char* text, const char* key, long long fallback) { + gn_size i = 0; + gn_size key_len = gn_strlen(key); + if (!text || !key) return fallback; + while (text[i]) { + if (i == 0 || text[i - 1] == ' ') { + gn_size j = 0; + while (j < key_len && text[i + j] == key[j]) ++j; + if (j == key_len && text[i + j] == '=') { + long long sign = 1; + long long value = 0; + i += j + 1; + if (text[i] == '-') { + sign = -1; + ++i; + } + while (text[i] >= '0' && text[i] <= '9') { + value = value * 10 + (text[i] - '0'); + ++i; + } + return value * sign; + } + } + ++i; + } + return fallback; +} + + +static float gn_parse_micro(const char* text, const char* key, float fallback) { + long long raw = gn_parse_i64(text, key, GN_PARSE_MISSING); + if (raw == GN_PARSE_MISSING) return fallback; + return (float)raw * 1e-6f; +} + +static long long gn_float_to_micro(float value) { + float scaled = value * 1e6f; + if (scaled >= 0.0f) return (long long)(scaled + 0.5f); + return (long long)(scaled - 0.5f); +} + + + + + +#define GN_GENERIC_APPEND 0x0004UL +#define GN_FILE_SHARE_RW 0x3UL +#define GN_OPEN_ALWAYS 4UL +#define GN_FILE_ATTRIBUTE_NORMAL 0x80UL +#define GN_INVALID_HANDLE ((void*)(gn_size)~0ULL) + +static void* gn_log_handle = NULL; +static int gn_log_state = 0; + +static void gn_log_line(const char* line) { + char buf[400]; + gn_size n; + + if (gn_log_state == 0) { + char env[8]; + gn_ulong got = GetEnvironmentVariableA("GAMENATIVE_XR_LOG", env, sizeof(env)); + gn_log_state = (got > 0 && env[0] != '0') ? 1 : -1; + if (gn_log_state == 1) { + gn_log_handle = CreateFileA( + "C:\\gamenative-xr\\runtime.log", + GN_GENERIC_APPEND, + GN_FILE_SHARE_RW, + NULL, + GN_OPEN_ALWAYS, + GN_FILE_ATTRIBUTE_NORMAL, + NULL); + if (gn_log_handle == GN_INVALID_HANDLE) gn_log_handle = NULL; + } + } + + n = gn_append(buf, sizeof(buf), 0, "gamenative-xr: "); + n = gn_append(buf, sizeof(buf), n, line); + OutputDebugStringA(buf); + if (gn_log_handle != NULL) { + gn_ulong written = 0; + n = gn_append(buf, sizeof(buf), n, "\r\n"); + WriteFile(gn_log_handle, buf, (gn_ulong)n, &written, NULL); + } +} + +static void gn_log2(const char* a, const char* b) { + char buf[360]; + gn_size n = gn_append(buf, sizeof(buf), 0, a); + n = gn_append(buf, sizeof(buf), n, b ? b : "(null)"); + gn_log_line(buf); +} + +static void gn_log_num(const char* a, long long value) { + char buf[360]; + gn_size n = gn_append(buf, sizeof(buf), 0, a); + n = gn_append_i64(buf, sizeof(buf), n, value); + gn_log_line(buf); +} + + + + + +#define GN_MAX_PATHS 512 +#define GN_PATH_LEN 160 +#define GN_MAX_ACTION_SETS 32 +#define GN_MAX_ACTIONS 256 +#define GN_MAX_REF_SPACES 32 +#define GN_MAX_ACTION_SPACES 64 + +#ifdef _WIN64 +#define GN_ACTIONSET_BASE 0x4753455400000ULL +#define GN_ACTION_BASE 0x4741435400000ULL +#define GN_REFSPACE_BASE 0x4752455300000ULL +#define GN_ACTSPACE_BASE 0x4741535000000ULL +#define GN_SWAPCHAIN_BASE 0x4753575000000ULL +#else + + +#define GN_ACTIONSET_BASE 0x47500000ULL +#define GN_ACTION_BASE 0x47400000ULL +#define GN_REFSPACE_BASE 0x47600000ULL +#define GN_ACTSPACE_BASE 0x47700000ULL +#define GN_SWAPCHAIN_BASE 0x47800000ULL +#endif +#define GN_HANDLE_IDX_MASK 0xFFFFULL +#define GN_MAX_SWAPCHAINS 32 + + +enum { + GN_COMP_NONE = 0, + GN_COMP_PRIMARY, + GN_COMP_SECONDARY, + GN_COMP_STICK_CLICK, + GN_COMP_MENU, + GN_COMP_TRIGGER, + GN_COMP_SQUEEZE, + GN_COMP_STICK, + GN_COMP_STICK_X, + GN_COMP_STICK_Y, + GN_COMP_GRIP_POSE, + GN_COMP_AIM_POSE, + GN_COMP_HAPTIC, + GN_COMP_COUNT +}; + +typedef struct { + int used; + int attached; + char name[64]; +} GnActionSet; + +typedef struct { + int used; + int action_set_idx; + XrActionType type; + unsigned char component[2]; + unsigned char binding_priority[2]; + unsigned char active_hands; + char name[64]; +} GnAction; + +typedef struct { + int used; + XrReferenceSpaceType type; + XrPosef pose_in_reference_space; +} GnRefSpace; + +typedef struct { + int used; + int action_idx; + int hand; + XrPosef pose_in_action_space; +} GnActionSpace; + +typedef struct { + int active; + unsigned buttons; + float trigger, squeeze, sx, sy; + float grip[7]; + float aim[7]; +} GnHandState; + +typedef struct { + float quat[4]; + float pos[3]; + float fov[4]; +} GnEyeView; + +enum { + GN_IMAGE_AVAILABLE = 0, + GN_IMAGE_ACQUIRED, + GN_IMAGE_WAITED +}; + +typedef struct { + int used; + gn_uint32 image_count; + gn_uint32 next_image; + gn_uint32 acquire_queue[GN_UNIX_MAX_IMAGES]; + gn_uint32 acquire_head; + gn_uint32 acquire_count; + gn_uint32 last_released_image; + int last_released_valid; + unsigned char image_state[GN_UNIX_MAX_IMAGES]; + unsigned char submitted[GN_UNIX_MAX_IMAGES]; + gn_uint64 images[GN_UNIX_MAX_IMAGES]; + void* d3d_images[GN_UNIX_MAX_IMAGES]; + XrSwapchainCreateInfo create_info; +} GnSwapchain; + +static XrInstance gn_instance = (XrInstance)0x474e5852494e5354ULL; +static XrSession gn_session = (XrSession)0x474e585253455353ULL; +static XrSystemId gn_system_id = 1; +static XrTime gn_next_display_time = 1; +static int gn_session_running = 0; +static int gn_stopping_pushed = 0; +static int gn_winsock_started = 0; +static gn_socket gn_bridge_socket = GN_INVALID_SOCKET; +static int gn_bridge_ever_connected = 0; +static char gn_bridge_rxbuf[1024]; +static gn_size gn_bridge_rxlen = 0; +static gn_size gn_bridge_rxoff = 0; + +static int gn_bridge_recv_byte(char* value) { + if (gn_bridge_rxoff >= gn_bridge_rxlen) { + int got = recv(gn_bridge_socket, gn_bridge_rxbuf, (int)sizeof(gn_bridge_rxbuf), 0); + if (got <= 0) return 0; + gn_bridge_rxlen = (gn_size)got; + gn_bridge_rxoff = 0; + } + *value = gn_bridge_rxbuf[gn_bridge_rxoff++]; + return 1; +} +static int gn_frame_milestone_sent = 0; +static XrSessionState gn_event_states[16]; +static gn_uint32 gn_event_head = 0; +static gn_uint32 gn_event_tail = 0; + +static char gn_paths[GN_MAX_PATHS][GN_PATH_LEN]; +static gn_uint32 gn_path_count = 0; + +static GnActionSet gn_action_sets[GN_MAX_ACTION_SETS]; +static GnAction gn_actions[GN_MAX_ACTIONS]; +static gn_uint32 gn_action_count = 0; +static gn_uint32 gn_action_set_count = 0; +static GnRefSpace gn_ref_spaces[GN_MAX_REF_SPACES]; +static gn_uint32 gn_ref_space_count = 0; +static GnActionSpace gn_action_spaces[GN_MAX_ACTION_SPACES]; +static gn_uint32 gn_action_space_count = 0; + +static GnHandState gn_hands[2]; +static float gn_comp_prev[2][GN_COMP_COUNT]; +static XrTime gn_comp_change_time[2][GN_COMP_COUNT]; +static unsigned char gn_comp_changed[2][GN_COMP_COUNT]; +static XrTime gn_last_sync_time = 0; + +static GnEyeView gn_eye_views[2]; +static int gn_eye_views_valid = 0; +static XrPosef gn_local_origin; +static int gn_local_origin_valid = 0; +static long long gn_last_recenter_serial = -1; +static int gn_recenter_serial_supported = 0; +static XrPosef gn_last_head_pose; +static XrTime gn_last_head_time = 0; +static float gn_head_linear_velocity[3]; +static float gn_head_angular_velocity[3]; +static XrPosef gn_last_hand_pose[2]; +static XrTime gn_last_hand_time[2]; +static float gn_hand_linear_velocity[2][3]; +static float gn_hand_angular_velocity[2][3]; + +static gn_uint32 gn_view_width = 1440; +static gn_uint32 gn_view_height = 1584; +static int gn_stage_supported = 1; + +static XrPath gn_path_hand_left = 0; +static XrPath gn_path_hand_right = 0; + + +enum { GN_GFX_UNKNOWN = 0, GN_GFX_D3D11, GN_GFX_D3D12, GN_GFX_VULKAN }; +static int gn_gfx_api = GN_GFX_UNKNOWN; +static int gn_action_sets_attached = 0; +static int gn_exit_requested = 0; +static gn_uint32 gn_events_lost = 0; +static int gn_interaction_profile_event_pending = 0; +static int gn_reference_space_event_pending = 0; +static int gn_instance_loss_event_pending = 0; +static GnSwapchain gn_swapchains[GN_MAX_SWAPCHAINS]; +static gn_uint32 gn_swapchain_count = 0; +static gn_uint32 gn_transport_eye_mask = 0; +static int gn_transport_failure_logged = 0; +static gn_uint64 gn_unix_handle = 0; +static int gn_unix_load_attempted = 0; +typedef gn_ntstatus (GN_STDCALL *GnWineUnixCall)(gn_uint32 code, void* args); +static GnWineUnixCall gn_wine_unix_call = NULL; +static VkInstance gn_vk_instance = NULL; +static VkPhysicalDevice gn_vk_physical_device = NULL; +static VkDevice gn_vk_device = NULL; +static VkQueue gn_vk_queue = NULL; +static void* gn_dxvk_interop = NULL; +static void* gn_vkd3d_interop = NULL; +static void* gn_vkd3d_device_ext = NULL; +static ID3D12CommandQueue* gn_d3d12_queue = NULL; + +static int gn_bridge_call(const char* command, char* response, gn_size response_size); +static void gn_identity_pose(XrPosef* pose); +static void gn_level_pose(XrPosef* pose); +static XrQuaternionf gn_quat_multiply(XrQuaternionf a, XrQuaternionf b); + +static const char* GN_VULKAN_INSTANCE_EXTENSIONS = ""; + + + +static const char* GN_VULKAN_DEVICE_EXTENSIONS = ""; + + + +static volatile int gn_lock = 0; + +static void gn_lock_acquire(void) { + while (__atomic_exchange_n(&gn_lock, 1, __ATOMIC_ACQUIRE) != 0) { +#if defined(__x86_64__) || defined(__i386__) + __builtin_ia32_pause(); +#endif + } +} + +static void gn_lock_release(void) { + __atomic_store_n(&gn_lock, 0, __ATOMIC_RELEASE); +} + +static int gn_load_unixlib(void) { + if (gn_unix_handle) return 1; + if (gn_unix_load_attempted) return 0; + gn_unix_load_attempted = 1; + + + + + { + static gn_uint16 unixlib_name[] = { + 'g','a','m','e','n','a','t','i','v','e','_','x','r','_', + 'u','n','i','x','b','r','i','d','g','e','.','d','l','l',0 + }; + GnUnicodeString name; + gn_uint64 result[2] = {0, 0}; + gn_size result_length = 0; + name.Length = (gn_uint16)(sizeof(unixlib_name) - sizeof(gn_uint16)); + name.MaximumLength = (gn_uint16)sizeof(unixlib_name); + name.Buffer = unixlib_name; + + + gn_ntstatus status = NtQueryVirtualMemory( + (void*)(gn_size)-1, + &name, + 1002, + result, + sizeof(result), + &result_length); + if (status == 0 && result[1]) { + gn_unix_handle = result[1]; + gn_log_line("Wine unixlib loaded directly by name"); + } else { + gn_log_num("Wine direct unixlib load unavailable, status=", status); + } + } + + if (!gn_unix_handle) { + + + void* module = LoadLibraryA("gamenative_xr_unixbridge.dll"); + if (!module) { + gn_log_num("Wine unix bridge module load failed, error=", GetLastError()); + return 0; + } + gn_wine_unix_call = (GnWineUnixCall)GetProcAddress(module, "gnWineUnixCall"); + if (!gn_wine_unix_call) { + gn_log_line("Wine unix bridge entry point missing"); + return 0; + } + gn_unix_handle = (gn_uint64)(gn_size)module; + } + + { + struct gn_unix_init_args args; + args.abi_version = GN_UNIX_ABI_VERSION; + args.result = GN_UNIX_ERROR_UNAVAILABLE; + gn_ntstatus status = gn_wine_unix_call ? + gn_wine_unix_call(GN_UNIX_INIT, &args) : + __wine_unix_call_dispatcher(gn_unix_handle, GN_UNIX_INIT, &args); + if (status != 0 || args.result != GN_UNIX_SUCCESS) { + gn_log_num("Wine unixlib init failed status=", status); + gn_log_num("Wine unixlib init result=", args.result); + gn_unix_handle = 0; + gn_wine_unix_call = NULL; + return 0; + } + } + gn_log_line("Wine unixlib ready"); + return 1; +} + +static int gn_unix_call(unsigned int code, void* args) { + if (!gn_load_unixlib()) return 0; + gn_ntstatus status = gn_wine_unix_call ? + gn_wine_unix_call(code, args) : + __wine_unix_call_dispatcher(gn_unix_handle, code, args); + if (status != 0) { + gn_log_num("Wine unix call failed status=", status); + return 0; + } + return 1; +} + +static int gn_swapchain_index(XrSwapchain swapchain) { + gn_uint64 handle = (gn_uint64)swapchain; + if ((handle & ~GN_HANDLE_IDX_MASK) != GN_SWAPCHAIN_BASE) return -1; + gn_uint64 index = handle & GN_HANDLE_IDX_MASK; + if (index >= gn_swapchain_count || !gn_swapchains[index].used) return -1; + return (int)index; +} + +typedef VkResult (GN_STDCALL *GnVkEnumeratePhysicalDevices)( + VkInstance instance, gn_uint32* count, VkPhysicalDevice* devices); +typedef void (GN_STDCALL *GnVkGetDeviceQueue)( + VkDevice device, gn_uint32 family, gn_uint32 index, VkQueue* queue); + +static void* gn_vulkan_proc(const char* name) { + static void* module = NULL; + if (!module) module = LoadLibraryA("vulkan-1.dll"); + return module ? GetProcAddress(module, name) : NULL; +} + +static VkPhysicalDevice gn_first_vulkan_physical_device(VkInstance instance) { + GnVkEnumeratePhysicalDevices enumerate = + (GnVkEnumeratePhysicalDevices)gn_vulkan_proc("vkEnumeratePhysicalDevices"); + VkPhysicalDevice devices[16]; + gn_uint32 count = 16; + if (!enumerate || enumerate(instance, &count, devices) != 0 || count == 0) return NULL; + return devices[0]; +} + +static int gn_set_vulkan_context(const XrGraphicsBindingVulkanKHR* binding) { + GnVkGetDeviceQueue get_queue = + (GnVkGetDeviceQueue)gn_vulkan_proc("vkGetDeviceQueue"); + if (!binding || !get_queue) return 0; + gn_vk_physical_device = binding->physicalDevice; + gn_vk_device = binding->device; + get_queue(binding->device, binding->queueFamilyIndex, binding->queueIndex, &gn_vk_queue); + if (!gn_vk_physical_device || !gn_vk_device || !gn_vk_queue) return 0; + + struct gn_unix_vulkan_context_args args; + args.client_physical_device = (gn_u64)gn_vk_physical_device; + args.client_device = (gn_u64)gn_vk_device; + args.client_queue = (gn_u64)gn_vk_queue; + args.queue_family_index = binding->queueFamilyIndex; + args.queue_index = binding->queueIndex; + args.handles_are_host = 0; + args.diagnostic_flags = 0; + args.result = GN_UNIX_ERROR_UNAVAILABLE; + if (!gn_unix_call(GN_UNIX_SET_VULKAN_CONTEXT, &args) || + args.result != GN_UNIX_SUCCESS) { + gn_log_num("Wine Vulkan context result=", args.result); + gn_log_num("Wine Vulkan context flags=", args.diagnostic_flags); + return 0; + } + return 1; +} + +typedef long (GN_STDCALL *GnComQueryInterface)(void*, const GnGuid*, void**); +typedef unsigned long (GN_STDCALL *GnComRelease)(void*); +typedef void (GN_STDCALL *GnDxvkGetVulkanHandles)( + void*, VkInstance*, VkPhysicalDevice*, VkDevice*); +typedef void (GN_STDCALL *GnDxvkGetSubmissionQueue1)( + void*, VkQueue*, gn_uint32*, gn_uint32*); +typedef void (GN_STDCALL *GnDxvkVoidCall)(void*); +typedef long (GN_STDCALL *GnDxvkCreateTexture)( + void*, const GnD3D11Texture2DDesc1*, VkImage, ID3D11Texture2D**); + +static void* gn_com_method(void* object, int index) { + if (!object) return NULL; + return (*(void***)object)[index]; +} + +static void gn_com_release(void* object) { + GnComRelease release = (GnComRelease)gn_com_method(object, 2); + if (release) release(object); +} + +typedef struct GnDxgiAdapterDesc { + gn_uint16 Description[128]; + gn_uint32 VendorId; + gn_uint32 DeviceId; + gn_uint32 SubSysId; + gn_uint32 Revision; + gn_size DedicatedVideoMemory; + gn_size DedicatedSystemMemory; + gn_size SharedSystemMemory; + LUID AdapterLuid; +} GnDxgiAdapterDesc; + +typedef long (GN_STDCALL *GnDxgiEnumAdapters)(void*, gn_uint32, void**); +typedef long (GN_STDCALL *GnDxgiGetDesc)(void*, GnDxgiAdapterDesc*); + +static LUID gn_primary_adapter_luid(void) { + static int queried = 0; + static LUID cached = {1, 0}; + if (queried) return cached; + queried = 1; + static const GnGuid iid_factory = { + 0x7b7166ec, 0x21c7, 0x44ae, + {0xb2,0x1a,0xc9,0xae,0x32,0x1a,0xe3,0x69} + }; + void* factory = NULL; + void* adapter = NULL; + if (CreateDXGIFactory(&iid_factory, &factory) >= 0 && factory) { + GnDxgiEnumAdapters enumerate = + (GnDxgiEnumAdapters)gn_com_method(factory, 7); + if (enumerate && enumerate(factory, 0, &adapter) >= 0 && adapter) { + GnDxgiAdapterDesc desc; + GnDxgiGetDesc get_desc = + (GnDxgiGetDesc)gn_com_method(adapter, 8); + if (get_desc && get_desc(adapter, &desc) >= 0) { + cached = desc.AdapterLuid; + if (!cached.LowPart && !cached.HighPart) cached.LowPart = 1; + } + gn_com_release(adapter); + } + gn_com_release(factory); + } + return cached; +} + +static int gn_set_d3d11_context(ID3D11Device* device11) { + static const GnGuid iid_interop1 = { + 0xe2ef5fa5, 0xdc21, 0x4af7, + {0x90,0xc4,0xf6,0x7e,0xf6,0xa0,0x93,0x24} + }; + GnComQueryInterface query = (GnComQueryInterface)gn_com_method(device11, 0); + if (!query || query(device11, &iid_interop1, &gn_dxvk_interop) < 0 || + !gn_dxvk_interop) { + gn_log_line("DXVK IDXGIVkInteropDevice1 unavailable"); + return 0; + } + + VkInstance instance = NULL; + gn_uint32 queue_family = 0; + gn_uint32 queue_index = 0; + GnDxvkGetVulkanHandles get_handles = + (GnDxvkGetVulkanHandles)gn_com_method(gn_dxvk_interop, 3); + GnDxvkGetSubmissionQueue1 get_queue = + (GnDxvkGetSubmissionQueue1)gn_com_method(gn_dxvk_interop, 9); + if (!get_handles || !get_queue) return 0; + get_handles(gn_dxvk_interop, &instance, &gn_vk_physical_device, &gn_vk_device); + get_queue(gn_dxvk_interop, &gn_vk_queue, &queue_index, &queue_family); + if (!gn_vk_physical_device || !gn_vk_device || !gn_vk_queue) return 0; + + struct gn_unix_vulkan_context_args args; + args.client_physical_device = (gn_u64)gn_vk_physical_device; + args.client_device = (gn_u64)gn_vk_device; + args.client_queue = (gn_u64)gn_vk_queue; + args.queue_family_index = queue_family; + args.queue_index = queue_index; + + + + args.handles_are_host = 0; + args.diagnostic_flags = 0; + args.result = GN_UNIX_ERROR_UNAVAILABLE; + if (!gn_unix_call(GN_UNIX_SET_VULKAN_CONTEXT, &args) || + args.result != GN_UNIX_SUCCESS) { + gn_log_num("Wine DXVK Vulkan context result=", args.result); + gn_log_num("Wine DXVK Vulkan context flags=", args.diagnostic_flags); + return 0; + } + return 1; +} + +static void gn_dxvk_flush_and_lock(void) { + GnDxvkVoidCall flush = + (GnDxvkVoidCall)gn_com_method(gn_dxvk_interop, 6); + GnDxvkVoidCall lock = + (GnDxvkVoidCall)gn_com_method(gn_dxvk_interop, 7); + if (flush) flush(gn_dxvk_interop); + if (lock) lock(gn_dxvk_interop); +} + +static void gn_dxvk_unlock(void) { + GnDxvkVoidCall unlock = + (GnDxvkVoidCall)gn_com_method(gn_dxvk_interop, 8); + if (unlock) unlock(gn_dxvk_interop); +} + +typedef long (GN_STDCALL *GnVkd3dGetVulkanHandles)( + void*, VkInstance*, VkPhysicalDevice*, VkDevice*); +typedef long (GN_STDCALL *GnVkd3dGetQueueInfoEx)( + void*, ID3D12CommandQueue*, VkQueue*, gn_uint32*, gn_uint32*, gn_uint32*); +typedef long (GN_STDCALL *GnVkd3dQueueCall)( + void*, ID3D12CommandQueue*); +typedef long (GN_STDCALL *GnVkd3dCreateResource)( + void*, const GnD3D12ResourceDesc1*, gn_uint64, ID3D12Resource**); + +static int gn_set_d3d12_context( + ID3D12Device* device12, ID3D12CommandQueue* queue12) { + static const GnGuid iid_interop = { + 0x39da4e09, 0xbd1c, 0x4198, + {0x9f,0xae,0x86,0xbb,0xe3,0xbe,0x41,0xfd} + }; + static const GnGuid iid_device_ext1 = { + 0x099a73fd, 0x2199, 0x4f45, + {0xbf,0x48,0x0e,0xb8,0x6f,0x6f,0xdb,0x65} + }; + GnComQueryInterface query = + (GnComQueryInterface)gn_com_method(device12, 0); + if (!query || + query(device12, &iid_interop, &gn_vkd3d_interop) < 0 || + query(device12, &iid_device_ext1, &gn_vkd3d_device_ext) < 0 || + !gn_vkd3d_interop || !gn_vkd3d_device_ext) { + gn_log_line("vkd3d-proton native interop unavailable"); + return 0; + } + + VkInstance instance = NULL; + gn_uint32 queue_index = 0; + gn_uint32 queue_flags = 0; + gn_uint32 queue_family = 0; + GnVkd3dGetVulkanHandles get_handles = + (GnVkd3dGetVulkanHandles)gn_com_method(gn_vkd3d_interop, 7); + GnVkd3dGetQueueInfoEx get_queue = + (GnVkd3dGetQueueInfoEx)gn_com_method(gn_vkd3d_device_ext, 11); + if (!get_handles || !get_queue || + get_handles(gn_vkd3d_interop, &instance, + &gn_vk_physical_device, &gn_vk_device) < 0 || + get_queue(gn_vkd3d_device_ext, queue12, &gn_vk_queue, + &queue_index, &queue_flags, &queue_family) < 0) { + return 0; + } + gn_d3d12_queue = queue12; + struct gn_unix_vulkan_context_args args; + args.client_physical_device = (gn_u64)gn_vk_physical_device; + args.client_device = (gn_u64)gn_vk_device; + args.client_queue = (gn_u64)gn_vk_queue; + args.queue_family_index = queue_family; + args.queue_index = queue_index; + + args.handles_are_host = 0; + args.diagnostic_flags = 0; + args.result = GN_UNIX_ERROR_UNAVAILABLE; + if (!gn_unix_call(GN_UNIX_SET_VULKAN_CONTEXT, &args) || + args.result != GN_UNIX_SUCCESS) { + gn_log_num("Wine vkd3d Vulkan context result=", args.result); + gn_log_num("Wine vkd3d Vulkan context flags=", args.diagnostic_flags); + return 0; + } + return 1; +} + +static void gn_vkd3d_lock(void) { + GnVkd3dQueueCall lock = + (GnVkd3dQueueCall)gn_com_method(gn_vkd3d_interop, 11); + if (lock) lock(gn_vkd3d_interop, gn_d3d12_queue); +} + +static void gn_vkd3d_unlock(void) { + GnVkd3dQueueCall unlock = + (GnVkd3dQueueCall)gn_com_method(gn_vkd3d_interop, 12); + if (unlock) unlock(gn_vkd3d_interop, gn_d3d12_queue); +} + +static int64_t gn_d3d_format_to_vk(int64_t format) { + switch (format) { + case 29: return 43; + case 28: return 37; + case 91: return 50; + case 87: return 44; + default: return 0; + } +} + + + + + +static void gn_push_session_event(XrSessionState state) { + gn_uint32 next_tail = (gn_event_tail + 1) % 16; + if (next_tail == gn_event_head) { + gn_event_head = (gn_event_head + 1) % 16; + ++gn_events_lost; + } + gn_event_states[gn_event_tail] = state; + gn_event_tail = next_tail; +} + +static int gn_pop_session_event(XrSessionState* state) { + if (gn_event_head == gn_event_tail || state == NULL) return 0; + *state = gn_event_states[gn_event_head]; + gn_event_head = (gn_event_head + 1) % 16; + return 1; +} + +static void gn_clear_events(void) { + gn_event_head = 0; + gn_event_tail = 0; + gn_events_lost = 0; + gn_interaction_profile_event_pending = 0; + gn_reference_space_event_pending = 0; + gn_instance_loss_event_pending = 0; +} + + + + + +static XrResult gn_fill_string(const char* src, gn_uint32 capacity, gn_uint32* count, char* dst) { + gn_uint32 needed = (gn_uint32)gn_strlen(src) + 1; + if (count) *count = needed; + if (capacity == 0) return XR_SUCCESS; + if (!dst || capacity < needed) return XR_ERROR_SIZE_INSUFFICIENT; + gn_copy(dst, capacity, src); + return XR_SUCCESS; +} + +static XrResult gn_copy_props(gn_uint32 available, gn_uint32 capacity, gn_uint32* count) { + if (count) *count = available; + if (capacity != 0 && capacity < available) return XR_ERROR_SIZE_INSUFFICIENT; + return XR_SUCCESS; +} + +static void gn_write_extension(XrExtensionProperties* property, const char* name, gn_uint32 version) { + property->type = XR_TYPE_EXTENSION_PROPERTIES; + property->next = NULL; + gn_copy(property->extensionName, XR_MAX_EXTENSION_NAME_SIZE, name); + property->extensionVersion = version; +} + + + + + +static void gn_bridge_close(void) { + if (gn_bridge_socket != GN_INVALID_SOCKET) { + closesocket(gn_bridge_socket); + gn_bridge_socket = GN_INVALID_SOCKET; + } +} + +static int gn_bridge_connect_locked(void) { + struct gn_wsadata wsa; + struct gn_sockaddr_in addr; + + if (gn_bridge_socket != GN_INVALID_SOCKET) return 1; + if (!gn_winsock_started) { + if (WSAStartup(0x0202, &wsa) != 0) { + gn_log_line("WSAStartup failed"); + return 0; + } + gn_winsock_started = 1; + } + + gn_bridge_socket = socket(GN_AF_INET, GN_SOCK_STREAM, GN_IPPROTO_TCP); + if (gn_bridge_socket == GN_INVALID_SOCKET) { + gn_log_line("socket() failed"); + return 0; + } + + addr.sin_family = GN_AF_INET; + addr.sin_port = htons(38476); + addr.sin_addr.s_addr = inet_addr("127.0.0.1"); + for (gn_size i = 0; i < sizeof(addr.sin_zero); ++i) addr.sin_zero[i] = 0; + + if (connect(gn_bridge_socket, &addr, sizeof(addr)) == GN_SOCKET_ERROR) { + gn_bridge_close(); + gn_log_line("connect to 127.0.0.1:38476 failed (is XrBridgeServer running?)"); + return 0; + } + return 1; +} + + +static int gn_bridge_call_locked(const char* command, char* response, gn_size response_size) { + char local_response[192]; + char* out = response; + gn_size out_size = response_size; + char ch = 0; + gn_size offset = 0; + gn_size len = gn_strlen(command); + int got = 0; + + if (out == NULL || out_size == 0) { + out = local_response; + out_size = sizeof(local_response); + } + out[0] = 0; + if (gn_bridge_socket == GN_INVALID_SOCKET) { + if (!gn_bridge_connect_locked()) return 0; + gn_bridge_rxlen = 0; + gn_bridge_rxoff = 0; + + { + char hello[64]; + const char* h = "HELLO\n"; + if (send(gn_bridge_socket, h, 6, 0) == GN_SOCKET_ERROR) { + gn_bridge_close(); + return 0; + } + gn_size hoff = 0; + while (hoff + 1 < sizeof(hello)) { + if (!gn_bridge_recv_byte(&ch)) { + gn_bridge_close(); + return 0; + } + if (ch == '\n') break; + hello[hoff++] = ch; + } + hello[hoff] = 0; + if (!gn_starts_with(hello, "OK")) { + gn_bridge_close(); + gn_log2("bridge HELLO rejected: ", hello); + return 0; + } + if (!gn_bridge_ever_connected) { + gn_bridge_ever_connected = 1; + gn_log2("bridge connected: ", hello); + } + } + if (gn_streq(command, "HELLO")) { + gn_copy(out, out_size, "OK GameNativeVR"); + return 1; + } + } + if (send(gn_bridge_socket, command, (int)len, 0) == GN_SOCKET_ERROR || + send(gn_bridge_socket, "\n", 1, 0) == GN_SOCKET_ERROR) { + gn_bridge_close(); + gn_log2("bridge send failed for: ", command); + return 0; + } + + for (;;) { + if (!gn_bridge_recv_byte(&ch)) { + gn_bridge_close(); + gn_log2("bridge recv failed for: ", command); + return 0; + } + if (ch == '\n') break; + if (offset + 1 < out_size) out[offset++] = ch; + } + out[offset] = 0; + return gn_starts_with(out, "OK"); +} + +static int gn_unix_control_state; /* 0 untried, 1 works, -1 unavailable */ + +static int gn_unix_control_transact(const char* command, gn_uint32 lines, + char* response, gn_size response_size) { + struct gn_unix_control_transact_args args; + gn_size len = 0; + if (gn_unix_control_state < 0) return 0; + while (command[len] && len + 1 < sizeof(args.request)) { + args.request[len] = command[len]; + ++len; + } + if (command[len]) return 0; + args.request[len] = 0; + args.response[0] = 0; + args.response_lines = lines; + args.result = GN_UNIX_ERROR_UNAVAILABLE; + if (!gn_unix_call(GN_UNIX_CONTROL_TRANSACT, &args) || + args.result != GN_UNIX_SUCCESS) { + /* Transient failures (the server may briefly refuse connections while stale + * clients age out) must not disable the fast path forever. */ + static int consecutive_failures; + if (++consecutive_failures >= 300 && gn_unix_control_state <= 0) { + gn_unix_control_state = -1; + gn_log_line("unix control fast path unavailable; staying on winsock"); + } + return 0; + } + if (gn_unix_control_state <= 0) { + gn_unix_control_state = 1; + gn_log_line("unix control fast path active"); + } + gn_copy(response, response_size, args.response); + return 1; +} + +static int gn_bridge_read_line_locked(char* out, gn_size out_size) { + gn_size offset = 0; + char ch = 0; + for (;;) { + if (!gn_bridge_recv_byte(&ch)) { + gn_bridge_close(); + return 0; + } + if (ch == '\n') break; + if (offset + 1 < out_size) out[offset++] = ch; + } + out[offset] = 0; + return gn_starts_with(out, "OK"); +} + + +static int gn_bridge_call(const char* command, char* response, gn_size response_size) { + int ok; + char local[1024]; + gn_lock_acquire(); + if (gn_unix_control_state >= 0 && + gn_unix_control_transact(command, 1, local, sizeof(local))) { + if (response && response_size) gn_copy(response, response_size, local); + ok = gn_starts_with(local, "OK"); + gn_lock_release(); + return ok; + } + ok = gn_bridge_call_locked(command, response, response_size); + gn_lock_release(); + return ok; +} + +static char gn_cached_views[1024]; +static char gn_cached_input[2][1024]; +static int gn_cache_valid = 0; +static int gn_frame_sync_supported = 1; +static int gn_split_line(const char** cursor, char* out, gn_size out_size) { + const char* p = *cursor; + gn_size n = 0; + if (!*p) return 0; + while (*p && *p != '\n') { + if (n + 1 < out_size) out[n++] = *p; + ++p; + } + out[n] = 0; + if (*p == '\n') ++p; + *cursor = p; + return 1; +} + +static int gn_bridge_frame_sync(char* frame_out, gn_size frame_size) { + int ok; + gn_lock_acquire(); + gn_cache_valid = 0; + if (gn_unix_control_state >= 0) { + char bundle[2048]; + if (gn_unix_control_transact("FRAME_SYNC", 4, bundle, sizeof(bundle))) { + const char* cursor = bundle; + char first[192]; + if (gn_split_line(&cursor, first, sizeof(first)) && + gn_starts_with(first, "OK")) { + gn_copy(frame_out, frame_size, first); + gn_cache_valid = + gn_split_line(&cursor, gn_cached_views, sizeof(gn_cached_views)) && + gn_split_line(&cursor, gn_cached_input[0], sizeof(gn_cached_input[0])) && + gn_split_line(&cursor, gn_cached_input[1], sizeof(gn_cached_input[1])); + gn_lock_release(); + return 1; + } + gn_copy(frame_out, frame_size, first); + if (gn_starts_with(first, "ERROR")) { + gn_frame_sync_supported = 0; + gn_log_line("FRAME_SYNC unsupported by bridge; using separate per-frame requests"); + } + gn_lock_release(); + return 0; + } + } + ok = gn_bridge_call_locked("FRAME_SYNC", frame_out, frame_size); + if (ok) { + gn_cache_valid = + gn_bridge_read_line_locked(gn_cached_views, sizeof(gn_cached_views)) && + gn_bridge_read_line_locked(gn_cached_input[0], sizeof(gn_cached_input[0])) && + gn_bridge_read_line_locked(gn_cached_input[1], sizeof(gn_cached_input[1])); + } else if (gn_starts_with(frame_out, "ERROR")) { + gn_frame_sync_supported = 0; + gn_log_line("FRAME_SYNC unsupported by bridge; using separate per-frame requests"); + } + gn_lock_release(); + return ok; +} + +static int gn_cached_line(const char* which, int hand, char* out, gn_size out_size) { + int ok = 0; + gn_lock_acquire(); + if (gn_cache_valid) { + if (which[0] == 'v') gn_copy(out, out_size, gn_cached_views); + else gn_copy(out, out_size, gn_cached_input[hand]); + ok = 1; + } + gn_lock_release(); + return ok; +} + + + + + +static XrPath gn_path_get_or_create(const char* str) { + gn_uint32 i; + if (!str || !str[0]) return XR_NULL_PATH; + for (i = 0; i < gn_path_count; ++i) { + if (gn_streq(gn_paths[i], str)) return (XrPath)(i + 1); + } + if (gn_path_count >= GN_MAX_PATHS) return XR_NULL_PATH; + gn_copy(gn_paths[gn_path_count], GN_PATH_LEN, str); + ++gn_path_count; + return (XrPath)gn_path_count; +} + +static const char* gn_path_string(XrPath path) { + gn_uint64 idx = (gn_uint64)path; + if (idx == 0 || idx > gn_path_count) return NULL; + return gn_paths[idx - 1]; +} + +static void gn_paths_init(void) { + if (gn_path_hand_left == 0) { + gn_path_hand_left = gn_path_get_or_create("/user/hand/left"); + gn_path_hand_right = gn_path_get_or_create("/user/hand/right"); + } +} + + +static int gn_hand_from_path(XrPath path) { + const char* str; + if (path == XR_NULL_PATH) return -1; + if (path == gn_path_hand_left) return 0; + if (path == gn_path_hand_right) return 1; + str = gn_path_string(path); + if (!str) return -1; + if (gn_starts_with(str, "/user/hand/left")) return 0; + if (gn_starts_with(str, "/user/hand/right")) return 1; + return -1; +} + + +static int gn_component_from_binding(const char* str) { + if (!str) return GN_COMP_NONE; + if (gn_contains(str, "/output/haptic")) return GN_COMP_HAPTIC; + if (gn_contains(str, "/input/grip")) return GN_COMP_GRIP_POSE; + if (gn_contains(str, "/input/aim")) return GN_COMP_AIM_POSE; + if (gn_contains(str, "/input/trigger")) return GN_COMP_TRIGGER; + if (gn_contains(str, "/input/squeeze")) return GN_COMP_SQUEEZE; + if (gn_contains(str, "/input/thumbstick/click")) return GN_COMP_STICK_CLICK; + if (gn_contains(str, "/input/thumbstick/x")) return GN_COMP_STICK_X; + if (gn_contains(str, "/input/thumbstick/y")) return GN_COMP_STICK_Y; + if (gn_contains(str, "/input/thumbstick")) return GN_COMP_STICK; + if (gn_contains(str, "/input/joystick")) return GN_COMP_STICK; + if (gn_contains(str, "/input/a/")) return GN_COMP_PRIMARY; + if (gn_contains(str, "/input/x/")) return GN_COMP_PRIMARY; + if (gn_contains(str, "/input/b/")) return GN_COMP_SECONDARY; + if (gn_contains(str, "/input/y/")) return GN_COMP_SECONDARY; + if (gn_contains(str, "/input/menu")) return GN_COMP_MENU; + if (gn_contains(str, "/input/system")) return GN_COMP_MENU; + if (gn_contains(str, "/input/select")) return GN_COMP_TRIGGER; + return GN_COMP_NONE; +} + + + +static int gn_interaction_profile_priority(const char* profile) { + if (!profile) return 0; + if (gn_streq(profile, "/interaction_profiles/oculus/touch_controller")) return 100; + if (gn_streq(profile, "/interaction_profiles/meta/touch_controller_plus")) return 100; + if (gn_streq(profile, "/interaction_profiles/khr/simple_controller")) return 10; + return 1; +} + + + + + +static int gn_action_index(XrAction action) { + gn_uint64 h = (gn_uint64)action; + if ((h & ~GN_HANDLE_IDX_MASK) != GN_ACTION_BASE) return -1; + gn_uint64 idx = h & GN_HANDLE_IDX_MASK; + if (idx >= gn_action_count || !gn_actions[idx].used) return -1; + return (int)idx; +} + +static int gn_action_set_index(XrActionSet action_set) { + gn_uint64 h = (gn_uint64)action_set; + if ((h & ~GN_HANDLE_IDX_MASK) != GN_ACTIONSET_BASE) return -1; + gn_uint64 idx = h & GN_HANDLE_IDX_MASK; + if (idx >= gn_action_set_count || !gn_action_sets[idx].used) return -1; + return (int)idx; +} + +static int gn_ref_space_index(XrSpace space) { + gn_uint64 h = (gn_uint64)space; + if ((h & ~GN_HANDLE_IDX_MASK) != GN_REFSPACE_BASE) return -1; + gn_uint64 idx = h & GN_HANDLE_IDX_MASK; + if (idx >= gn_ref_space_count || !gn_ref_spaces[idx].used) return -1; + return (int)idx; +} + +static int gn_action_space_index(XrSpace space) { + gn_uint64 h = (gn_uint64)space; + if ((h & ~GN_HANDLE_IDX_MASK) != GN_ACTSPACE_BASE) return -1; + gn_uint64 idx = h & GN_HANDLE_IDX_MASK; + if (idx >= gn_action_space_count || !gn_action_spaces[idx].used) return -1; + return (int)idx; +} + + +static int gn_action_default_hand(const GnAction* action) { + if (action->component[0] != GN_COMP_NONE) return 0; + if (action->component[1] != GN_COMP_NONE) return 1; + return 0; +} + + + + + +static float gn_comp_value(int hand, int comp) { + const GnHandState* h = &gn_hands[hand]; + switch (comp) { + case GN_COMP_PRIMARY: return (h->buttons & 1u) ? 1.0f : 0.0f; + case GN_COMP_SECONDARY: return (h->buttons & 2u) ? 1.0f : 0.0f; + case GN_COMP_STICK_CLICK: return (h->buttons & 4u) ? 1.0f : 0.0f; + case GN_COMP_MENU: return (h->buttons & 8u) ? 1.0f : 0.0f; + case GN_COMP_TRIGGER: return h->trigger; + case GN_COMP_SQUEEZE: return h->squeeze; + case GN_COMP_STICK_X: return h->sx; + case GN_COMP_STICK_Y: return h->sy; + case GN_COMP_STICK: return h->sx; + default: return 0.0f; + } +} + +static void gn_refresh_hand(int hand) { + char cmd[32]; + char response[512]; + gn_size n; + GnHandState* h = &gn_hands[hand]; + + if (!gn_cached_line("input", hand, response, sizeof(response))) { + n = gn_append(cmd, sizeof(cmd), 0, "GET_INPUT hand="); + n = gn_append_i64(cmd, sizeof(cmd), n, hand); + if (!gn_bridge_call(cmd, response, sizeof(response))) { + h->active = 0; + return; + } + } + + h->active = (int)gn_parse_i64(response, "active", 0); + h->buttons = (unsigned)gn_parse_i64(response, "buttons", 0); + h->trigger = gn_parse_micro(response, "tr", 0.0f); + h->squeeze = gn_parse_micro(response, "sq", 0.0f); + h->sx = gn_parse_micro(response, "sx", 0.0f); + h->sy = gn_parse_micro(response, "sy", 0.0f); + + h->grip[0] = gn_parse_micro(response, "gqx", 0.0f); + h->grip[1] = gn_parse_micro(response, "gqy", 0.0f); + h->grip[2] = gn_parse_micro(response, "gqz", 0.0f); + h->grip[3] = gn_parse_micro(response, "gqw", 1.0f); + h->grip[4] = gn_parse_micro(response, "gpx", 0.0f); + h->grip[5] = gn_parse_micro(response, "gpy", 0.0f); + h->grip[6] = gn_parse_micro(response, "gpz", 0.0f); + + h->aim[0] = gn_parse_micro(response, "aqx", 0.0f); + h->aim[1] = gn_parse_micro(response, "aqy", 0.0f); + h->aim[2] = gn_parse_micro(response, "aqz", 0.0f); + h->aim[3] = gn_parse_micro(response, "aqw", 1.0f); + h->aim[4] = gn_parse_micro(response, "apx", 0.0f); + h->aim[5] = gn_parse_micro(response, "apy", 0.0f); + h->aim[6] = gn_parse_micro(response, "apz", 0.0f); +} + +static void gn_update_change_tracking(XrTime sync_time) { + for (int hand = 0; hand < 2; ++hand) { + for (int comp = 1; comp < GN_COMP_COUNT; ++comp) { + float value = gn_comp_value(hand, comp); + float prev = gn_comp_prev[hand][comp]; + float delta = value - prev; + if (delta < 0.0f) delta = -delta; + if (delta > 0.0001f) { + gn_comp_changed[hand][comp] = 1; + gn_comp_change_time[hand][comp] = sync_time; + } else { + gn_comp_changed[hand][comp] = 0; + } + gn_comp_prev[hand][comp] = value; + } + } + gn_last_sync_time = sync_time; +} + +static void gn_write_pose(XrPosef* pose, const float* data) { + pose->orientation.x = data[0]; + pose->orientation.y = data[1]; + pose->orientation.z = data[2]; + pose->orientation.w = data[3]; + pose->position.x = data[4]; + pose->position.y = data[5]; + pose->position.z = data[6]; +} + +static void gn_update_pose_velocity( + const XrPosef* pose, + XrTime time, + XrPosef* previous, + XrTime* previous_time, + float linear[3], + float angular[3], + int detect_recenter) { + for (int i = 0; i < 3; ++i) { + linear[i] = 0.0f; + angular[i] = 0.0f; + } + if (*previous_time > 0 && time > *previous_time) { + float dt = (float)(time - *previous_time) * 1e-9f; + float dx = pose->position.x - previous->position.x; + float dy = pose->position.y - previous->position.y; + float dz = pose->position.z - previous->position.z; + float orientation_dot = + pose->orientation.x * previous->orientation.x + + pose->orientation.y * previous->orientation.y + + pose->orientation.z * previous->orientation.z + + pose->orientation.w * previous->orientation.w; + if (orientation_dot < 0.0f) orientation_dot = -orientation_dot; + if (detect_recenter && + (dx * dx + dy * dy + dz * dz > 0.64f || + orientation_dot < 0.9238795f)) { + gn_reference_space_event_pending = 1; + gn_local_origin = *pose; + gn_level_pose(&gn_local_origin); + *previous = *pose; + *previous_time = time; + return; + } + if (dt > 0.00001f && dt < 0.5f) { + XrQuaternionf previous_inverse = { + -previous->orientation.x, + -previous->orientation.y, + -previous->orientation.z, + previous->orientation.w, + }; + XrQuaternionf delta = gn_quat_multiply(pose->orientation, previous_inverse); + if (delta.w < 0.0f) { + delta.x = -delta.x; + delta.y = -delta.y; + delta.z = -delta.z; + } + linear[0] = dx / dt; + linear[1] = dy / dt; + linear[2] = dz / dt; + angular[0] = 2.0f * delta.x / dt; + angular[1] = 2.0f * delta.y / dt; + angular[2] = 2.0f * delta.z / dt; + } + } + *previous = *pose; + *previous_time = time; +} + + + + + +static XrResult XRAPI_CALL gn_xrEnumerateInstanceExtensionProperties( + const char* layerName, + gn_uint32 propertyCapacityInput, + gn_uint32* propertyCountOutput, + XrExtensionProperties* properties) { + (void)layerName; + const gn_uint32 available = 4; + if (!propertyCountOutput) return XR_ERROR_VALIDATION_FAILURE; + if (propertyCapacityInput != 0 && properties == NULL) return XR_ERROR_VALIDATION_FAILURE; + XrResult r = gn_copy_props(available, propertyCapacityInput, propertyCountOutput); + if (XR_FAILED(r) || propertyCapacityInput == 0) return r; + gn_write_extension(&properties[0], XR_KHR_D3D11_ENABLE_EXTENSION_NAME, XR_KHR_D3D11_enable_SPEC_VERSION); + if (propertyCapacityInput > 1) { + gn_write_extension(&properties[1], XR_KHR_VULKAN_ENABLE_EXTENSION_NAME, XR_KHR_vulkan_enable_SPEC_VERSION); + } + if (propertyCapacityInput > 2) { + gn_write_extension(&properties[2], XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME, XR_KHR_vulkan_enable2_SPEC_VERSION); + } + if (propertyCapacityInput > 3) { + gn_write_extension(&properties[3], XR_KHR_D3D12_ENABLE_EXTENSION_NAME, XR_KHR_D3D12_enable_SPEC_VERSION); + } + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrCreateInstance(const XrInstanceCreateInfo* createInfo, XrInstance* instance) { + if (!instance) return XR_ERROR_VALIDATION_FAILURE; + gn_clear_events(); + gn_session_running = 0; + gn_stopping_pushed = 0; + gn_action_sets_attached = 0; + gn_exit_requested = 0; + gn_local_origin_valid = 0; + gn_last_recenter_serial = -1; + gn_recenter_serial_supported = 0; + gn_vk_instance = NULL; + gn_vk_physical_device = NULL; + gn_vk_device = NULL; + gn_vk_queue = NULL; + gn_paths_init(); + if (createInfo) { + gn_log2("xrCreateInstance app=", createInfo->applicationInfo.applicationName); + for (gn_uint32 i = 0; i < createInfo->enabledExtensionCount; ++i) { + gn_log2(" enabled extension: ", createInfo->enabledExtensionNames[i]); + } + } + gn_bridge_call("HELLO", NULL, 0); + *instance = gn_instance; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrDestroyInstance(XrInstance instance) { + (void)instance; + gn_log_line("xrDestroyInstance"); + gn_bridge_call("BYE", NULL, 0); + gn_lock_acquire(); + gn_bridge_close(); + gn_lock_release(); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetInstanceProperties(XrInstance instance, XrInstanceProperties* properties) { + (void)instance; + if (!properties) return XR_ERROR_VALIDATION_FAILURE; + properties->runtimeVersion = XR_MAKE_VERSION(0, 2, 0); + gn_copy(properties->runtimeName, XR_MAX_RUNTIME_NAME_SIZE, "GameNativeVR OpenXR Runtime"); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrPollEvent(XrInstance instance, XrEventDataBuffer* eventData) { + XrSessionState state; + if (instance != gn_instance) return XR_ERROR_HANDLE_INVALID; + if (!eventData) return XR_ERROR_VALIDATION_FAILURE; + if (gn_events_lost != 0) { + XrEventDataEventsLost* lost = (XrEventDataEventsLost*)eventData; + lost->type = XR_TYPE_EVENT_DATA_EVENTS_LOST; + lost->next = NULL; + lost->lostEventCount = gn_events_lost; + gn_events_lost = 0; + return XR_SUCCESS; + } + if (gn_instance_loss_event_pending) { + XrEventDataInstanceLossPending* loss = (XrEventDataInstanceLossPending*)eventData; + loss->type = XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING; + loss->next = NULL; + loss->lossTime = gn_next_display_time; + gn_instance_loss_event_pending = 0; + return XR_SUCCESS; + } + if (gn_reference_space_event_pending) { + XrEventDataReferenceSpaceChangePending* change = + (XrEventDataReferenceSpaceChangePending*)eventData; + change->type = XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING; + change->next = NULL; + change->session = gn_session; + change->referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL; + change->changeTime = gn_next_display_time; + change->poseValid = XR_FALSE; + gn_identity_pose(&change->poseInPreviousSpace); + gn_reference_space_event_pending = 0; + return XR_SUCCESS; + } + if (gn_interaction_profile_event_pending) { + XrEventDataInteractionProfileChanged* changed = + (XrEventDataInteractionProfileChanged*)eventData; + changed->type = XR_TYPE_EVENT_DATA_INTERACTION_PROFILE_CHANGED; + changed->next = NULL; + changed->session = gn_session; + gn_interaction_profile_event_pending = 0; + return XR_SUCCESS; + } + if (gn_pop_session_event(&state)) { + XrEventDataSessionStateChanged* changed = (XrEventDataSessionStateChanged*)eventData; + changed->type = XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED; + changed->next = NULL; + changed->session = gn_session; + changed->state = state; + changed->time = gn_next_display_time; + gn_log_num("session state event -> ", (long long)state); + return XR_SUCCESS; + } + return XR_EVENT_UNAVAILABLE; +} + +static XrResult XRAPI_CALL gn_xrResultToString(XrInstance instance, XrResult value, char buffer[XR_MAX_RESULT_STRING_SIZE]) { + (void)instance; + char out[XR_MAX_RESULT_STRING_SIZE]; + gn_size n = gn_append(out, sizeof(out), 0, "XR_RESULT_"); + gn_append_i64(out, sizeof(out), n, (long long)value); + gn_copy(buffer, XR_MAX_RESULT_STRING_SIZE, out); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrStructureTypeToString(XrInstance instance, XrStructureType value, char buffer[XR_MAX_STRUCTURE_NAME_SIZE]) { + (void)instance; + (void)value; + gn_copy(buffer, XR_MAX_STRUCTURE_NAME_SIZE, "XR_STRUCTURE_TYPE"); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetSystem(XrInstance instance, const XrSystemGetInfo* getInfo, XrSystemId* systemId) { + (void)instance; + if (!getInfo || !systemId) return XR_ERROR_VALIDATION_FAILURE; + if (getInfo->formFactor != XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY) return XR_ERROR_FORM_FACTOR_UNAVAILABLE; + *systemId = gn_system_id; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetSystemProperties(XrInstance instance, XrSystemId systemId, XrSystemProperties* properties) { + (void)instance; + char response[128]; + if (systemId != gn_system_id || !properties) return XR_ERROR_VALIDATION_FAILURE; + gn_bridge_call("GET_SYSTEM", response, sizeof(response)); + properties->systemId = gn_system_id; + properties->vendorId = 0x474e; + gn_copy(properties->systemName, XR_MAX_SYSTEM_NAME_SIZE, "Meta Quest via GameNativeVR"); + properties->graphicsProperties.maxSwapchainImageHeight = 4096; + properties->graphicsProperties.maxSwapchainImageWidth = 4096; + properties->graphicsProperties.maxLayerCount = 16; + properties->trackingProperties.orientationTracking = XR_TRUE; + properties->trackingProperties.positionTracking = XR_TRUE; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrEnumerateEnvironmentBlendModes( + XrInstance instance, + XrSystemId systemId, + XrViewConfigurationType viewConfigurationType, + gn_uint32 capacity, + gn_uint32* count, + XrEnvironmentBlendMode* modes) { + (void)instance; + if (systemId != gn_system_id || viewConfigurationType != XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO) { + return XR_ERROR_VIEW_CONFIGURATION_TYPE_UNSUPPORTED; + } + XrResult r = gn_copy_props(1, capacity, count); + if (XR_FAILED(r) || capacity == 0) return r; + modes[0] = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrEnumerateViewConfigurations( + XrInstance instance, + XrSystemId systemId, + gn_uint32 capacity, + gn_uint32* count, + XrViewConfigurationType* types) { + (void)instance; + if (systemId != gn_system_id) return XR_ERROR_SYSTEM_INVALID; + XrResult r = gn_copy_props(1, capacity, count); + if (XR_FAILED(r) || capacity == 0) return r; + types[0] = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetViewConfigurationProperties( + XrInstance instance, + XrSystemId systemId, + XrViewConfigurationType type, + XrViewConfigurationProperties* properties) { + (void)instance; + if (systemId != gn_system_id || type != XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO || !properties) { + return XR_ERROR_VALIDATION_FAILURE; + } + properties->viewConfigurationType = type; + properties->fovMutable = XR_FALSE; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrEnumerateViewConfigurationViews( + XrInstance instance, + XrSystemId systemId, + XrViewConfigurationType type, + gn_uint32 capacity, + gn_uint32* count, + XrViewConfigurationView* views) { + (void)instance; + char response[128]; + if (systemId != gn_system_id || type != XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO) { + return XR_ERROR_VIEW_CONFIGURATION_TYPE_UNSUPPORTED; + } + if (gn_bridge_call("GET_VIEWS", response, sizeof(response))) { + long long w = gn_parse_i64(response, "width", 0); + long long h = gn_parse_i64(response, "height", 0); + if (w > 0 && h > 0 && w <= 16384 && h <= 16384) { + gn_view_width = (gn_uint32)w; + gn_view_height = (gn_uint32)h; + } + } + XrResult r = gn_copy_props(2, capacity, count); + if (XR_FAILED(r) || capacity == 0) return r; + for (gn_uint32 i = 0; i < 2; ++i) { + views[i].recommendedImageRectWidth = gn_view_width; + views[i].maxImageRectWidth = gn_view_width > 2048 ? gn_view_width : 2048; + views[i].recommendedImageRectHeight = gn_view_height; + views[i].maxImageRectHeight = gn_view_height > 2048 ? gn_view_height : 2048; + views[i].recommendedSwapchainSampleCount = 1; + views[i].maxSwapchainSampleCount = 1; + } + return XR_SUCCESS; +} + + + + + +static XrResult XRAPI_CALL gn_xrCreateSession(XrInstance instance, const XrSessionCreateInfo* createInfo, XrSession* session) { + (void)instance; + if (!createInfo || createInfo->systemId != gn_system_id || !session) return XR_ERROR_VALIDATION_FAILURE; + { + typedef struct GnNextHeader { + XrStructureType type; + const struct GnNextHeader* next; + } GnNextHeader; + const GnNextHeader* next = (const GnNextHeader*)createInfo->next; + int binding_found = 0; + while (next) { + if (next->type == XR_TYPE_GRAPHICS_BINDING_VULKAN_KHR) { + const XrGraphicsBindingVulkanKHR* binding = + (const XrGraphicsBindingVulkanKHR*)next; + gn_gfx_api = GN_GFX_VULKAN; + binding_found = 1; + if (!gn_set_vulkan_context(binding)) { + gn_log_line("xrCreateSession: Vulkan unix context failed"); + return XR_ERROR_GRAPHICS_DEVICE_INVALID; + } + break; + } + if (next->type == XR_TYPE_GRAPHICS_BINDING_D3D11_KHR) { + const XrGraphicsBindingD3D11KHR* binding = + (const XrGraphicsBindingD3D11KHR*)next; + gn_gfx_api = GN_GFX_D3D11; + binding_found = 1; + if (!gn_set_d3d11_context(binding->device)) { + gn_log_line("xrCreateSession: DXVK native interop failed"); + return XR_ERROR_GRAPHICS_DEVICE_INVALID; + } + break; + } + if (next->type == XR_TYPE_GRAPHICS_BINDING_D3D12_KHR) { + const XrGraphicsBindingD3D12KHR* binding = + (const XrGraphicsBindingD3D12KHR*)next; + gn_gfx_api = GN_GFX_D3D12; + binding_found = 1; + if (!gn_set_d3d12_context(binding->device, binding->queue)) { + gn_log_line("xrCreateSession: vkd3d native interop failed"); + return XR_ERROR_GRAPHICS_DEVICE_INVALID; + } + break; + } + next = next->next; + } + if (!binding_found) { + gn_log_line("xrCreateSession: no supported graphics binding"); + return XR_ERROR_GRAPHICS_DEVICE_INVALID; + } + } + *session = gn_session; + gn_clear_events(); + gn_stopping_pushed = 0; + gn_exit_requested = 0; + gn_action_sets_attached = 0; + gn_push_session_event(XR_SESSION_STATE_IDLE); + gn_push_session_event(XR_SESSION_STATE_READY); + gn_log_line("xrCreateSession"); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrDestroySession(XrSession session) { + (void)session; + for (gn_uint32 i = 0; i < gn_swapchain_count; ++i) { + if (gn_swapchains[i].used && gn_unix_handle) { + for (gn_uint32 image = 0; image < gn_swapchains[i].image_count; ++image) { + if (gn_swapchains[i].d3d_images[image]) + gn_com_release(gn_swapchains[i].d3d_images[image]); + } + struct gn_unix_destroy_swapchain_args args; + args.slot = i; + args.result = GN_UNIX_ERROR_UNAVAILABLE; + gn_unix_call(GN_UNIX_DESTROY_SWAPCHAIN, &args); + } + gn_swapchains[i].used = 0; + } + gn_swapchain_count = 0; + gn_bridge_call("SWAPCHAIN_RESET", NULL, 0); + if (gn_dxvk_interop) { + gn_com_release(gn_dxvk_interop); + gn_dxvk_interop = NULL; + } + if (gn_vkd3d_device_ext) { + gn_com_release(gn_vkd3d_device_ext); + gn_vkd3d_device_ext = NULL; + } + if (gn_vkd3d_interop) { + gn_com_release(gn_vkd3d_interop); + gn_vkd3d_interop = NULL; + } + gn_d3d12_queue = NULL; + gn_session_running = 0; + gn_clear_events(); + gn_log_line("xrDestroySession"); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrBeginSession(XrSession session, const XrSessionBeginInfo* beginInfo) { + (void)beginInfo; + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + gn_bridge_call("BEGIN_SESSION", NULL, 0); + gn_session_running = 1; + gn_stopping_pushed = 0; + gn_push_session_event(XR_SESSION_STATE_SYNCHRONIZED); + gn_push_session_event(XR_SESSION_STATE_VISIBLE); + gn_push_session_event(XR_SESSION_STATE_FOCUSED); + gn_log_line("xrBeginSession"); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrEndSession(XrSession session) { + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + gn_bridge_call("END_SESSION", NULL, 0); + gn_session_running = 0; + gn_push_session_event(gn_exit_requested ? XR_SESSION_STATE_EXITING : XR_SESSION_STATE_IDLE); + gn_log_line("xrEndSession"); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrRequestExitSession(XrSession session) { + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + gn_exit_requested = 1; + gn_bridge_call("REQUEST_EXIT", NULL, 0); + if (!gn_stopping_pushed) { + gn_stopping_pushed = 1; + gn_push_session_event(XR_SESSION_STATE_STOPPING); + } + gn_log_line("xrRequestExitSession -> STOPPING"); + return XR_SUCCESS; +} + + + + + +static XrResult XRAPI_CALL gn_xrEnumerateReferenceSpaces(XrSession session, gn_uint32 capacity, gn_uint32* count, XrReferenceSpaceType* spaces) { + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + { + char response[128]; + if (gn_bridge_call("GET_BOUNDS", response, sizeof(response))) + gn_stage_supported = gn_parse_i64(response, "supported", 1) != 0; + } + XrResult r = gn_copy_props(gn_stage_supported ? 3 : 2, capacity, count); + if (XR_FAILED(r) || capacity == 0) return r; + spaces[0] = XR_REFERENCE_SPACE_TYPE_VIEW; + if (capacity > 1) spaces[1] = XR_REFERENCE_SPACE_TYPE_LOCAL; + if (gn_stage_supported && capacity > 2) spaces[2] = XR_REFERENCE_SPACE_TYPE_STAGE; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrCreateReferenceSpace(XrSession session, const XrReferenceSpaceCreateInfo* createInfo, XrSpace* space) { + if (createInfo) gn_log_num("xrCreateReferenceSpace type=", (long long)createInfo->referenceSpaceType); + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + if (!space || !createInfo || + createInfo->type != XR_TYPE_REFERENCE_SPACE_CREATE_INFO) + return XR_ERROR_VALIDATION_FAILURE; + if (createInfo->referenceSpaceType != XR_REFERENCE_SPACE_TYPE_VIEW && + createInfo->referenceSpaceType != XR_REFERENCE_SPACE_TYPE_LOCAL && + createInfo->referenceSpaceType != XR_REFERENCE_SPACE_TYPE_STAGE) + return XR_ERROR_REFERENCE_SPACE_UNSUPPORTED; + if (createInfo->referenceSpaceType == XR_REFERENCE_SPACE_TYPE_STAGE && !gn_stage_supported) + return XR_ERROR_REFERENCE_SPACE_UNSUPPORTED; + if (gn_ref_space_count >= GN_MAX_REF_SPACES) return XR_ERROR_LIMIT_REACHED; + gn_ref_spaces[gn_ref_space_count].used = 1; + gn_ref_spaces[gn_ref_space_count].type = createInfo->referenceSpaceType; + gn_ref_spaces[gn_ref_space_count].pose_in_reference_space = createInfo->poseInReferenceSpace; + *space = (XrSpace)(GN_REFSPACE_BASE | gn_ref_space_count); + ++gn_ref_space_count; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetReferenceSpaceBoundsRect( + XrSession session, + XrReferenceSpaceType referenceSpaceType, + XrExtent2Df* bounds) { + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + if (!bounds) return XR_ERROR_VALIDATION_FAILURE; + bounds->width = 0.0f; + bounds->height = 0.0f; + if (referenceSpaceType != XR_REFERENCE_SPACE_TYPE_STAGE || !gn_stage_supported) + return XR_SPACE_BOUNDS_UNAVAILABLE; + { + char response[128]; + if (gn_bridge_call("GET_BOUNDS", response, sizeof(response)) && + gn_parse_i64(response, "available", 0) != 0) { + bounds->width = gn_parse_micro(response, "width", 0.0f); + bounds->height = gn_parse_micro(response, "height", 0.0f); + if (bounds->width > 0.0f && bounds->height > 0.0f) + return XR_SUCCESS; + } + } + return XR_SPACE_BOUNDS_UNAVAILABLE; +} + +static XrResult XRAPI_CALL gn_xrCreateActionSpace(XrSession session, const XrActionSpaceCreateInfo* createInfo, XrSpace* space) { + if (session != gn_session || !space || !createInfo) return XR_ERROR_HANDLE_INVALID; + int action_idx = gn_action_index(createInfo->action); + if (action_idx < 0) return XR_ERROR_HANDLE_INVALID; + if (gn_action_space_count >= GN_MAX_ACTION_SPACES) return XR_ERROR_LIMIT_REACHED; + gn_action_spaces[gn_action_space_count].used = 1; + gn_action_spaces[gn_action_space_count].action_idx = action_idx; + gn_action_spaces[gn_action_space_count].hand = gn_hand_from_path(createInfo->subactionPath); + gn_action_spaces[gn_action_space_count].pose_in_action_space = createInfo->poseInActionSpace; + *space = (XrSpace)(GN_ACTSPACE_BASE | gn_action_space_count); + ++gn_action_space_count; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrDestroySpace(XrSpace space) { + int idx = gn_ref_space_index(space); + if (idx >= 0) { + gn_ref_spaces[idx].used = 0; + return XR_SUCCESS; + } + idx = gn_action_space_index(space); + if (idx >= 0) { + gn_action_spaces[idx].used = 0; + return XR_SUCCESS; + } + return XR_ERROR_HANDLE_INVALID; +} + +static float gn_sqrt(float v) { + float x = v > 1.0f ? v : 1.0f; + int i; + if (v <= 0.0f) return 0.0f; + for (i = 0; i < 24; ++i) x = 0.5f * (x + v / x); + return x; +} + +static void gn_level_pose(XrPosef* pose) { + float y = pose->orientation.y; + float w = pose->orientation.w; + float n = gn_sqrt(y * y + w * w); + pose->orientation.x = 0.0f; + pose->orientation.z = 0.0f; + if (n < 1e-4f) { + pose->orientation.y = 0.0f; + pose->orientation.w = 1.0f; + } else { + pose->orientation.y = y / n; + pose->orientation.w = w / n; + } +} + +static void gn_identity_pose(XrPosef* pose) { + pose->orientation.x = 0.0f; + pose->orientation.y = 0.0f; + pose->orientation.z = 0.0f; + pose->orientation.w = 1.0f; + pose->position.x = 0.0f; + pose->position.y = 0.0f; + pose->position.z = 0.0f; +} + +static XrQuaternionf gn_quat_multiply(XrQuaternionf a, XrQuaternionf b) { + XrQuaternionf q; + q.x = a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y; + q.y = a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x; + q.z = a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w; + q.w = a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z; + return q; +} + +static XrVector3f gn_quat_rotate(XrQuaternionf q, XrVector3f v) { + XrVector3f out; + + float tx = 2.0f * (q.y * v.z - q.z * v.y); + float ty = 2.0f * (q.z * v.x - q.x * v.z); + float tz = 2.0f * (q.x * v.y - q.y * v.x); + out.x = v.x + q.w * tx + (q.y * tz - q.z * ty); + out.y = v.y + q.w * ty + (q.z * tx - q.x * tz); + out.z = v.z + q.w * tz + (q.x * ty - q.y * tx); + return out; +} + +static XrPosef gn_pose_multiply(XrPosef a, XrPosef b) { + XrPosef out; + XrVector3f rotated = gn_quat_rotate(a.orientation, b.position); + out.orientation = gn_quat_multiply(a.orientation, b.orientation); + out.position.x = a.position.x + rotated.x; + out.position.y = a.position.y + rotated.y; + out.position.z = a.position.z + rotated.z; + return out; +} + +static XrPosef gn_pose_inverse(XrPosef pose) { + XrPosef out; + XrVector3f negative; + out.orientation.x = -pose.orientation.x; + out.orientation.y = -pose.orientation.y; + out.orientation.z = -pose.orientation.z; + out.orientation.w = pose.orientation.w; + negative.x = -pose.position.x; + negative.y = -pose.position.y; + negative.z = -pose.position.z; + out.position = gn_quat_rotate(out.orientation, negative); + return out; +} + +static void gn_get_head_pose(XrPosef* pose) { + gn_identity_pose(pose); + if (!gn_eye_views_valid) return; + pose->orientation.x = gn_eye_views[0].quat[0]; + pose->orientation.y = gn_eye_views[0].quat[1]; + pose->orientation.z = gn_eye_views[0].quat[2]; + pose->orientation.w = gn_eye_views[0].quat[3]; + pose->position.x = (gn_eye_views[0].pos[0] + gn_eye_views[1].pos[0]) * 0.5f; + pose->position.y = (gn_eye_views[0].pos[1] + gn_eye_views[1].pos[1]) * 0.5f; + pose->position.z = (gn_eye_views[0].pos[2] + gn_eye_views[1].pos[2]) * 0.5f; +} + + +static int gn_space_absolute_pose( + XrSpace space, + XrPosef* pose, + float linear_velocity[3], + float angular_velocity[3]) { + int ref_idx = gn_ref_space_index(space); + gn_identity_pose(pose); + for (int i = 0; i < 3; ++i) { + linear_velocity[i] = 0.0f; + angular_velocity[i] = 0.0f; + } + if (ref_idx >= 0) { + XrPosef natural; + gn_identity_pose(&natural); + if (gn_ref_spaces[ref_idx].type == XR_REFERENCE_SPACE_TYPE_VIEW) { + if (!gn_eye_views_valid) return 0; + gn_get_head_pose(&natural); + for (int i = 0; i < 3; ++i) { + linear_velocity[i] = gn_head_linear_velocity[i]; + angular_velocity[i] = gn_head_angular_velocity[i]; + } + } else if (gn_ref_spaces[ref_idx].type == XR_REFERENCE_SPACE_TYPE_LOCAL) { + if (gn_local_origin_valid) natural = gn_local_origin; + } else if (gn_ref_spaces[ref_idx].type != XR_REFERENCE_SPACE_TYPE_STAGE) { + return -1; + } + *pose = gn_pose_multiply(natural, gn_ref_spaces[ref_idx].pose_in_reference_space); + return 1; + } + + int action_space_idx = gn_action_space_index(space); + if (action_space_idx >= 0) { + const GnActionSpace* action_space = &gn_action_spaces[action_space_idx]; + const GnAction* action = &gn_actions[action_space->action_idx]; + int hand = action_space->hand >= 0 ? action_space->hand : gn_action_default_hand(action); + int comp = action->component[hand]; + XrPosef tracked; + if (!gn_hands[hand].active || (action->active_hands & (1u << hand)) == 0) return 0; + gn_write_pose(&tracked, comp == GN_COMP_AIM_POSE ? gn_hands[hand].aim : gn_hands[hand].grip); + *pose = gn_pose_multiply(tracked, action_space->pose_in_action_space); + for (int i = 0; i < 3; ++i) { + linear_velocity[i] = gn_hand_linear_velocity[hand][i]; + angular_velocity[i] = gn_hand_angular_velocity[hand][i]; + } + return 1; + } + return -1; +} + +static XrResult XRAPI_CALL gn_xrLocateSpace(XrSpace space, XrSpace baseSpace, XrTime time, XrSpaceLocation* location) { + (void)time; + XrPosef absolute_pose, base_pose; + float linear[3], angular[3], base_linear[3], base_angular[3]; + if (!location) return XR_ERROR_VALIDATION_FAILURE; + + int space_status = gn_space_absolute_pose(space, &absolute_pose, linear, angular); + int base_status = gn_space_absolute_pose(baseSpace, &base_pose, base_linear, base_angular); + if (space_status < 0 || base_status < 0) return XR_ERROR_HANDLE_INVALID; + + if (location->next) { + XrSpaceVelocity* velocity = (XrSpaceVelocity*)location->next; + if (velocity->type == XR_TYPE_SPACE_VELOCITY) { + velocity->velocityFlags = 0; + velocity->linearVelocity.x = 0.0f; + velocity->linearVelocity.y = 0.0f; + velocity->linearVelocity.z = 0.0f; + velocity->angularVelocity.x = 0.0f; + velocity->angularVelocity.y = 0.0f; + velocity->angularVelocity.z = 0.0f; + if (space_status > 0 && base_status > 0) { + XrQuaternionf base_inverse = gn_pose_inverse(base_pose).orientation; + XrVector3f relative_linear = { + linear[0] - base_linear[0], + linear[1] - base_linear[1], + linear[2] - base_linear[2], + }; + XrVector3f relative_angular = { + angular[0] - base_angular[0], + angular[1] - base_angular[1], + angular[2] - base_angular[2], + }; + velocity->linearVelocity = gn_quat_rotate(base_inverse, relative_linear); + velocity->angularVelocity = gn_quat_rotate(base_inverse, relative_angular); + velocity->velocityFlags = + XR_SPACE_VELOCITY_LINEAR_VALID_BIT | XR_SPACE_VELOCITY_ANGULAR_VALID_BIT; + } + } + } + + location->locationFlags = 0; + gn_identity_pose(&location->pose); + if (space_status == 0 || base_status == 0) return XR_SUCCESS; + location->pose = gn_pose_multiply(gn_pose_inverse(base_pose), absolute_pose); + location->locationFlags = + XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | + XR_SPACE_LOCATION_POSITION_VALID_BIT | + XR_SPACE_LOCATION_ORIENTATION_TRACKED_BIT | + XR_SPACE_LOCATION_POSITION_TRACKED_BIT; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrLocateSpaces( + XrSession session, + const XrSpacesLocateInfo* locateInfo, + XrSpaceLocations* spaceLocations) { + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + if (!locateInfo || locateInfo->type != XR_TYPE_SPACES_LOCATE_INFO || + !spaceLocations || spaceLocations->type != XR_TYPE_SPACE_LOCATIONS || + locateInfo->spaceCount != spaceLocations->locationCount || + (locateInfo->spaceCount && (!locateInfo->spaces || !spaceLocations->locations))) { + return XR_ERROR_VALIDATION_FAILURE; + } + + XrSpaceVelocities* space_velocities = NULL; + if (spaceLocations->next) { + typedef struct GnOutHeader { + XrStructureType type; + void* next; + } GnOutHeader; + GnOutHeader* next = (GnOutHeader*)spaceLocations->next; + while (next) { + if (next->type == XR_TYPE_SPACE_VELOCITIES) { + space_velocities = (XrSpaceVelocities*)next; + break; + } + next = (GnOutHeader*)next->next; + } + } + if (space_velocities && + (space_velocities->velocityCount != locateInfo->spaceCount || + (space_velocities->velocityCount && !space_velocities->velocities))) { + return XR_ERROR_VALIDATION_FAILURE; + } + + for (uint32_t i = 0; i < locateInfo->spaceCount; ++i) { + XrSpaceVelocity velocity = { + XR_TYPE_SPACE_VELOCITY, + NULL, + 0, + {0.0f, 0.0f, 0.0f}, + {0.0f, 0.0f, 0.0f}, + }; + XrSpaceLocation location = { + XR_TYPE_SPACE_LOCATION, + space_velocities ? &velocity : NULL, + 0, + {{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}}, + }; + XrResult result = gn_xrLocateSpace( + locateInfo->spaces[i], + locateInfo->baseSpace, + locateInfo->time, + &location); + if (XR_FAILED(result)) return result; + + spaceLocations->locations[i].locationFlags = location.locationFlags; + spaceLocations->locations[i].pose = location.pose; + if (space_velocities) { + space_velocities->velocities[i].velocityFlags = velocity.velocityFlags; + space_velocities->velocities[i].linearVelocity = velocity.linearVelocity; + space_velocities->velocities[i].angularVelocity = velocity.angularVelocity; + } + } + return XR_SUCCESS; +} + + + + + +static XrResult XRAPI_CALL gn_xrWaitFrame(XrSession session, const XrFrameWaitInfo* waitInfo, XrFrameState* frameState) { + (void)waitInfo; + char response[160]; + int synced; + if (session != gn_session || !frameState) return XR_ERROR_HANDLE_INVALID; + synced = gn_frame_sync_supported && gn_bridge_frame_sync(response, sizeof(response)); + if (!synced && !gn_frame_sync_supported) + synced = gn_bridge_call("WAIT_FRAME", response, sizeof(response)); + if (synced) { + frameState->predictedDisplayTime = gn_parse_i64(response, "time", gn_next_display_time); + frameState->predictedDisplayPeriod = gn_parse_i64(response, "period", 11111111); + frameState->shouldRender = gn_parse_i64(response, "render", gn_session_running ? 1 : 0) != 0 ? XR_TRUE : XR_FALSE; + gn_next_display_time = frameState->predictedDisplayTime + frameState->predictedDisplayPeriod; + + long long recenter_serial = gn_parse_i64(response, "recenter", -1); + if (recenter_serial >= 0) { + gn_recenter_serial_supported = 1; + if (gn_last_recenter_serial >= 0 && recenter_serial != gn_last_recenter_serial) { + gn_reference_space_event_pending = 1; + gn_local_origin_valid = 0; + gn_last_head_time = 0; + gn_log_num("headset recenter serial=", recenter_serial); + } + gn_last_recenter_serial = recenter_serial; + } + long long quest_state = gn_parse_i64(response, "state", 0); + if (quest_state == XR_SESSION_STATE_LOSS_PENDING) { + gn_instance_loss_event_pending = 1; + } + if (quest_state >= XR_SESSION_STATE_STOPPING && gn_session_running && !gn_stopping_pushed) { + gn_stopping_pushed = 1; + gn_push_session_event(XR_SESSION_STATE_STOPPING); + gn_log_num("quest session stopping, state=", quest_state); + } + } else { + frameState->predictedDisplayTime = gn_next_display_time; + frameState->predictedDisplayPeriod = 11111111; + frameState->shouldRender = gn_session_running ? XR_TRUE : XR_FALSE; + gn_next_display_time += frameState->predictedDisplayPeriod; + } + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrBeginFrame(XrSession session, const XrFrameBeginInfo* frameBeginInfo) { + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + if (!frameBeginInfo || frameBeginInfo->type != XR_TYPE_FRAME_BEGIN_INFO) + return XR_ERROR_VALIDATION_FAILURE; + + + return XR_SUCCESS; +} + +static XrRect2Di gn_normalize_rect(XrRect2Di rect, int* flip_y) { + if (flip_y) *flip_y = 0; + if (rect.extent.height < 0) { + rect.offset.y += rect.extent.height; + rect.extent.height = -rect.extent.height; + if (flip_y) *flip_y = 1; + } + return rect; +} + +static XrResult XRAPI_CALL gn_xrEndFrame(XrSession session, const XrFrameEndInfo* frameEndInfo) { + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + if (!frameEndInfo || frameEndInfo->type != XR_TYPE_FRAME_END_INFO || + (frameEndInfo->layerCount && !frameEndInfo->layers)) + return XR_ERROR_VALIDATION_FAILURE; + + for (gn_uint32 layer_index = 0; layer_index < frameEndInfo->layerCount; ++layer_index) { + const XrCompositionLayerBaseHeader* base = frameEndInfo->layers[layer_index]; + if (!base) return XR_ERROR_LAYER_INVALID; + if (base->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) { + const XrCompositionLayerProjection* projection = + (const XrCompositionLayerProjection*)base; + if (projection->viewCount != 2 || !projection->views) + return XR_ERROR_VALIDATION_FAILURE; + for (gn_uint32 eye = 0; eye < projection->viewCount; ++eye) { + if (projection->views[eye].type != + XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW) + return XR_ERROR_VALIDATION_FAILURE; + const XrSwapchainSubImage* sub_image = + &projection->views[eye].subImage; + int slot = gn_swapchain_index(sub_image->swapchain); + if (slot < 0) return XR_ERROR_LAYER_INVALID; + const GnSwapchain* state = &gn_swapchains[slot]; + XrRect2Di rect = gn_normalize_rect(sub_image->imageRect, NULL); + if (sub_image->imageArrayIndex >= state->create_info.arraySize || + rect.offset.x < 0 || + rect.offset.y < 0 || + rect.extent.width <= 0 || + rect.extent.height <= 0 || + rect.extent.width > + (int32_t)state->create_info.width - rect.offset.x || + rect.extent.height > + (int32_t)state->create_info.height - rect.offset.y) + return XR_ERROR_SWAPCHAIN_RECT_INVALID; + } + } + } + + int submission_failed = 0; + if (gn_gfx_api == GN_GFX_D3D11) gn_dxvk_flush_and_lock(); + if (gn_gfx_api == GN_GFX_D3D12) gn_vkd3d_lock(); + for (gn_uint32 layer_index = 0; + layer_index < frameEndInfo->layerCount; ++layer_index) { + const XrCompositionLayerBaseHeader* base = + frameEndInfo->layers[layer_index]; + if (base->type != XR_TYPE_COMPOSITION_LAYER_PROJECTION) continue; + const XrCompositionLayerProjection* projection = + (const XrCompositionLayerProjection*)base; + XrPosef layer_space_pose; + float layer_linear[3], layer_angular[3]; + int layer_space_status = gn_space_absolute_pose( + projection->space, &layer_space_pose, + layer_linear, layer_angular); + if (layer_space_status <= 0) { + submission_failed = 1; + continue; + } + struct gn_unix_submit_stereo_args args; + args.view_count = projection->viewCount; + int projection_ready = 1; + for (gn_uint32 eye = 0; eye < projection->viewCount; ++eye) { + int slot = gn_swapchain_index( + projection->views[eye].subImage.swapchain); + GnSwapchain* state = &gn_swapchains[slot]; + if (!state->last_released_valid) { + submission_failed = 1; + projection_ready = 0; + break; + } + struct gn_unix_submit_view_args* view = &args.views[eye]; + view->slot = (gn_u32)slot; + view->image_index = state->last_released_image; + view->eye = eye; + view->array_index = + projection->views[eye].subImage.imageArrayIndex; + { + int flip_y = 0; + XrRect2Di rect = gn_normalize_rect( + projection->views[eye].subImage.imageRect, &flip_y); + view->rect_x = rect.offset.x; + view->rect_y = rect.offset.y; + view->rect_width = (gn_u32)rect.extent.width; + view->rect_height = (gn_u32)rect.extent.height; + view->flip_y = (gn_u32)flip_y; + } + { + XrPosef absolute_view = gn_pose_multiply( + layer_space_pose, projection->views[eye].pose); + view->orientation_micro[0] = + gn_float_to_micro(absolute_view.orientation.x); + view->orientation_micro[1] = + gn_float_to_micro(absolute_view.orientation.y); + view->orientation_micro[2] = + gn_float_to_micro(absolute_view.orientation.z); + view->orientation_micro[3] = + gn_float_to_micro(absolute_view.orientation.w); + view->position_micro[0] = + gn_float_to_micro(absolute_view.position.x); + view->position_micro[1] = + gn_float_to_micro(absolute_view.position.y); + view->position_micro[2] = + gn_float_to_micro(absolute_view.position.z); + view->fov_micro[0] = gn_float_to_micro( + projection->views[eye].fov.angleLeft); + view->fov_micro[1] = gn_float_to_micro( + projection->views[eye].fov.angleRight); + view->fov_micro[2] = gn_float_to_micro( + projection->views[eye].fov.angleUp); + view->fov_micro[3] = gn_float_to_micro( + projection->views[eye].fov.angleDown); + } + } + if (!projection_ready) continue; + args.result = GN_UNIX_ERROR_UNAVAILABLE; + if (!gn_unix_call(GN_UNIX_SUBMIT_STEREO, &args) || + args.result != GN_UNIX_SUCCESS) { + submission_failed = 1; + if (!gn_transport_failure_logged) { + gn_log_num("xrEndFrame stereo transport failed result=", args.result); + gn_transport_failure_logged = 1; + } + } else { + for (gn_uint32 eye = 0; eye < projection->viewCount; ++eye) { + int slot = (int)args.views[eye].slot; + GnSwapchain* state = &gn_swapchains[slot]; + state->submitted[state->last_released_image] = 1; + gn_transport_eye_mask |= (1u << eye); + } + if (gn_transport_eye_mask == 3u) { + gn_log_line("xrEndFrame batched image transport accepted both eyes"); + gn_transport_eye_mask |= 4u; + } + } + } + for (gn_uint32 i = 0; i < gn_swapchain_count; ++i) + gn_swapchains[i].last_released_valid = 0; + if (gn_gfx_api == GN_GFX_D3D11) gn_dxvk_unlock(); + if (gn_gfx_api == GN_GFX_D3D12) gn_vkd3d_unlock(); + + + + if (!gn_frame_milestone_sent && frameEndInfo->layerCount > 0) { + char cmd[64]; + gn_size n = gn_append(cmd, sizeof(cmd), 0, "END_FRAME layers="); + n = gn_append_i64(cmd, sizeof(cmd), n, (long long)frameEndInfo->layerCount); + if (gn_bridge_call(cmd, NULL, 0)) gn_frame_milestone_sent = 1; + } + return submission_failed ? XR_ERROR_RUNTIME_FAILURE : XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrLocateViews( + XrSession session, + const XrViewLocateInfo* viewLocateInfo, + XrViewState* viewState, + gn_uint32 capacity, + gn_uint32* count, + XrView* views) { + char response[1024]; + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + if (!viewLocateInfo || viewLocateInfo->type != XR_TYPE_VIEW_LOCATE_INFO || + !viewState || viewState->type != XR_TYPE_VIEW_STATE || !count || + (capacity && !views)) { + return XR_ERROR_VALIDATION_FAILURE; + } + if (viewLocateInfo->viewConfigurationType != + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO) { + return XR_ERROR_VIEW_CONFIGURATION_TYPE_UNSUPPORTED; + } + + XrResult r = gn_copy_props(2, capacity, count); + if (XR_FAILED(r) || capacity == 0) return r; + for (gn_uint32 i = 0; i < 2; ++i) { + if (views[i].type != XR_TYPE_VIEW) + return XR_ERROR_VALIDATION_FAILURE; + } + + if (gn_cached_line("views", 0, response, sizeof(response)) || + gn_bridge_call("LOCATE_VIEWS", response, sizeof(response))) { + static const char* qk[2][4] = {{"lqx", "lqy", "lqz", "lqw"}, {"rqx", "rqy", "rqz", "rqw"}}; + static const char* pk[2][3] = {{"lpx", "lpy", "lpz"}, {"rpx", "rpy", "rpz"}}; + static const char* fk[2][4] = {{"lfl", "lfr", "lfu", "lfd"}, {"rfl", "rfr", "rfu", "rfd"}}; + for (int eye = 0; eye < 2; ++eye) { + GnEyeView* v = &gn_eye_views[eye]; + for (int i = 0; i < 4; ++i) v->quat[i] = gn_parse_micro(response, qk[eye][i], i == 3 ? 1.0f : 0.0f); + for (int i = 0; i < 3; ++i) v->pos[i] = gn_parse_micro(response, pk[eye][i], 0.0f); + v->fov[0] = gn_parse_micro(response, fk[eye][0], -0.75f); + v->fov[1] = gn_parse_micro(response, fk[eye][1], 0.75f); + v->fov[2] = gn_parse_micro(response, fk[eye][2], 0.75f); + v->fov[3] = gn_parse_micro(response, fk[eye][3], -0.75f); + } + gn_eye_views_valid = 1; + { + XrPosef head_pose; + gn_get_head_pose(&head_pose); + if (!gn_local_origin_valid) { + gn_local_origin = head_pose; + gn_level_pose(&gn_local_origin); + gn_local_origin_valid = 1; + } + gn_update_pose_velocity( + &head_pose, + viewLocateInfo ? viewLocateInfo->displayTime : gn_next_display_time, + &gn_last_head_pose, + &gn_last_head_time, + gn_head_linear_velocity, + gn_head_angular_velocity, + gn_recenter_serial_supported ? 0 : 1); + } + } + + XrPosef base_pose; + float base_linear[3], base_angular[3]; + int base_status = gn_space_absolute_pose( + viewLocateInfo->space, &base_pose, base_linear, base_angular); + if (base_status < 0) return XR_ERROR_HANDLE_INVALID; + XrPosef base_inverse = gn_pose_inverse(base_pose); + + viewState->viewStateFlags = 0; + if (gn_eye_views_valid && base_status > 0) { + viewState->viewStateFlags = + XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT | + XR_VIEW_STATE_ORIENTATION_TRACKED_BIT | XR_VIEW_STATE_POSITION_TRACKED_BIT; + } + for (gn_uint32 i = 0; i < 2; ++i) { + if (gn_eye_views_valid) { + const GnEyeView* v = &gn_eye_views[i]; + XrPosef absolute_eye; + absolute_eye.orientation.x = v->quat[0]; + absolute_eye.orientation.y = v->quat[1]; + absolute_eye.orientation.z = v->quat[2]; + absolute_eye.orientation.w = v->quat[3]; + absolute_eye.position.x = v->pos[0]; + absolute_eye.position.y = v->pos[1]; + absolute_eye.position.z = v->pos[2]; + views[i].pose = base_status > 0 + ? gn_pose_multiply(base_inverse, absolute_eye) + : absolute_eye; + views[i].fov.angleLeft = v->fov[0]; + views[i].fov.angleRight = v->fov[1]; + views[i].fov.angleUp = v->fov[2]; + views[i].fov.angleDown = v->fov[3]; + } else { + gn_identity_pose(&views[i].pose); + views[i].pose.position.x = i == 0 ? -0.032f : 0.032f; + views[i].fov.angleLeft = -0.75f; + views[i].fov.angleRight = 0.75f; + views[i].fov.angleUp = 0.75f; + views[i].fov.angleDown = -0.75f; + } + } + return XR_SUCCESS; +} + + + + + +static XrResult XRAPI_CALL gn_xrEnumerateSwapchainFormats(XrSession session, gn_uint32 capacity, gn_uint32* count, int64_t* formats) { + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + + + + + static const int64_t vk_formats[] = {50 , 44 , 43 , 37 }; + static const int64_t d3d_formats[] = {91 , 87 , 29 , 28 }; + const int64_t* list = + (gn_gfx_api == GN_GFX_D3D11 || gn_gfx_api == GN_GFX_D3D12) ? + d3d_formats : vk_formats; + XrResult r = gn_copy_props(4, capacity, count); + if (XR_FAILED(r) || capacity == 0) return r; + for (gn_uint32 i = 0; i < 4 && i < capacity; ++i) formats[i] = list[i]; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetD3D11GraphicsRequirementsKHR( + XrInstance instance, + XrSystemId systemId, + XrGraphicsRequirementsD3D11KHR* graphicsRequirements) { + (void)instance; + if (systemId != gn_system_id || !graphicsRequirements) return XR_ERROR_VALIDATION_FAILURE; + gn_gfx_api = GN_GFX_D3D11; + gn_log_line("graphics binding requested: d3d11"); + gn_bridge_call("GFX_API d3d11", NULL, 0); + graphicsRequirements->adapterLuid = gn_primary_adapter_luid(); + graphicsRequirements->minFeatureLevel = D3D_FEATURE_LEVEL_11_0; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetD3D12GraphicsRequirementsKHR( + XrInstance instance, + XrSystemId systemId, + XrGraphicsRequirementsD3D12KHR* graphicsRequirements) { + (void)instance; + if (systemId != gn_system_id || !graphicsRequirements) return XR_ERROR_VALIDATION_FAILURE; + gn_gfx_api = GN_GFX_D3D12; + gn_log_line("graphics binding requested: d3d12"); + gn_bridge_call("GFX_API d3d12", NULL, 0); + graphicsRequirements->adapterLuid = gn_primary_adapter_luid(); + graphicsRequirements->minFeatureLevel = D3D_FEATURE_LEVEL_11_0; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetVulkanInstanceExtensionsKHR( + XrInstance instance, + XrSystemId systemId, + gn_uint32 bufferCapacityInput, + gn_uint32* bufferCountOutput, + char* buffer) { + (void)instance; + if (systemId != gn_system_id) return XR_ERROR_SYSTEM_INVALID; + return gn_fill_string(GN_VULKAN_INSTANCE_EXTENSIONS, bufferCapacityInput, bufferCountOutput, buffer); +} + +static XrResult XRAPI_CALL gn_xrGetVulkanDeviceExtensionsKHR( + XrInstance instance, + XrSystemId systemId, + gn_uint32 bufferCapacityInput, + gn_uint32* bufferCountOutput, + char* buffer) { + (void)instance; + if (systemId != gn_system_id) return XR_ERROR_SYSTEM_INVALID; + return gn_fill_string(GN_VULKAN_DEVICE_EXTENSIONS, bufferCapacityInput, bufferCountOutput, buffer); +} + +static XrResult XRAPI_CALL gn_xrGetVulkanGraphicsDeviceKHR( + XrInstance instance, + XrSystemId systemId, + VkInstance vkInstance, + VkPhysicalDevice* vkPhysicalDevice) { + (void)instance; + if (systemId != gn_system_id || !vkPhysicalDevice) return XR_ERROR_VALIDATION_FAILURE; + *vkPhysicalDevice = gn_first_vulkan_physical_device(vkInstance); + if (!*vkPhysicalDevice) return XR_ERROR_GRAPHICS_DEVICE_INVALID; + gn_vk_instance = vkInstance; + gn_vk_physical_device = *vkPhysicalDevice; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetVulkanGraphicsRequirementsKHR( + XrInstance instance, + XrSystemId systemId, + XrGraphicsRequirementsVulkanKHR* graphicsRequirements) { + (void)instance; + if (systemId != gn_system_id || !graphicsRequirements) return XR_ERROR_VALIDATION_FAILURE; + gn_gfx_api = GN_GFX_VULKAN; + gn_log_line("graphics binding requested: vulkan"); + gn_bridge_call("GFX_API vulkan", NULL, 0); + graphicsRequirements->minApiVersionSupported = XR_MAKE_VERSION(1, 1, 0); + graphicsRequirements->maxApiVersionSupported = XR_MAKE_VERSION(1, 3, 0); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetVulkanGraphicsDevice2KHR( + XrInstance instance, + const XrVulkanGraphicsDeviceGetInfoKHR* getInfo, + VkPhysicalDevice* vulkanPhysicalDevice) { + (void)instance; + if (!getInfo || !vulkanPhysicalDevice) return XR_ERROR_VALIDATION_FAILURE; + if (getInfo->systemId != gn_system_id) return XR_ERROR_SYSTEM_INVALID; + *vulkanPhysicalDevice = gn_first_vulkan_physical_device(getInfo->vulkanInstance); + if (!*vulkanPhysicalDevice) return XR_ERROR_GRAPHICS_DEVICE_INVALID; + gn_vk_instance = getInfo->vulkanInstance; + gn_vk_physical_device = *vulkanPhysicalDevice; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetVulkanGraphicsRequirements2KHR( + XrInstance instance, + XrSystemId systemId, + XrGraphicsRequirementsVulkanKHR* graphicsRequirements) { + return gn_xrGetVulkanGraphicsRequirementsKHR(instance, systemId, graphicsRequirements); +} + +typedef VkResult (GN_STDCALL *GnVkCreateInstance)( + const void* createInfo, const void* allocator, VkInstance* instance); +typedef VkResult (GN_STDCALL *GnVkCreateDevice)( + VkPhysicalDevice physicalDevice, const void* createInfo, + const void* allocator, VkDevice* device); + +static XrResult XRAPI_CALL gn_xrCreateVulkanInstanceKHR( + XrInstance instance, + const XrVulkanInstanceCreateInfoKHR* createInfo, + VkInstance* vulkanInstance, + VkResult* vulkanResult) { + (void)instance; + if (!createInfo || createInfo->systemId != gn_system_id || + !createInfo->pfnGetInstanceProcAddr || !createInfo->vulkanCreateInfo || + !vulkanInstance || !vulkanResult) return XR_ERROR_VALIDATION_FAILURE; + GnVkCreateInstance create = + (GnVkCreateInstance)createInfo->pfnGetInstanceProcAddr(NULL, "vkCreateInstance"); + if (!create) return XR_ERROR_FUNCTION_UNSUPPORTED; + *vulkanResult = create( + createInfo->vulkanCreateInfo, createInfo->vulkanAllocator, vulkanInstance); + if (*vulkanResult == 0) gn_vk_instance = *vulkanInstance; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrCreateVulkanDeviceKHR( + XrInstance instance, + const XrVulkanDeviceCreateInfoKHR* createInfo, + VkDevice* vulkanDevice, + VkResult* vulkanResult) { + (void)instance; + if (!createInfo || createInfo->systemId != gn_system_id || + !createInfo->pfnGetInstanceProcAddr || !createInfo->vulkanCreateInfo || + !createInfo->vulkanPhysicalDevice || !vulkanDevice || !vulkanResult) { + return XR_ERROR_VALIDATION_FAILURE; + } + GnVkCreateDevice create = + (GnVkCreateDevice)createInfo->pfnGetInstanceProcAddr( + gn_vk_instance, "vkCreateDevice"); + if (!create) return XR_ERROR_FUNCTION_UNSUPPORTED; + + *vulkanResult = create( + createInfo->vulkanPhysicalDevice, createInfo->vulkanCreateInfo, + createInfo->vulkanAllocator, vulkanDevice); + return XR_SUCCESS; +} + + + + + +static XrResult XRAPI_CALL gn_xrCreateSwapchain(XrSession session, const XrSwapchainCreateInfo* createInfo, XrSwapchain* swapchain) { + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + if (!createInfo || !swapchain || !createInfo->width || !createInfo->height || + !createInfo->arraySize || createInfo->faceCount != 1 || + createInfo->width > 16384 || createInfo->height > 16384 || + createInfo->arraySize > 65535 || createInfo->mipCount > 65535) { + return XR_ERROR_VALIDATION_FAILURE; + } + if (gn_gfx_api != GN_GFX_VULKAN && + gn_gfx_api != GN_GFX_D3D11 && + gn_gfx_api != GN_GFX_D3D12) { + gn_log_line("xrCreateSwapchain: graphics native interop is unavailable"); + return XR_ERROR_FEATURE_UNSUPPORTED; + } + + gn_uint32 slot = GN_MAX_SWAPCHAINS; + for (gn_uint32 i = 0; i < gn_swapchain_count; ++i) { + if (!gn_swapchains[i].used) { slot = i; break; } + } + if (slot == GN_MAX_SWAPCHAINS) { + if (gn_swapchain_count >= GN_MAX_SWAPCHAINS) return XR_ERROR_LIMIT_REACHED; + slot = gn_swapchain_count++; + } + + struct gn_unix_create_swapchain_args args; + gn_zero_memory(&args, sizeof(args)); + args.slot = slot; + args.width = createInfo->width; + args.height = createInfo->height; + args.array_size = createInfo->arraySize; + args.mip_count = createInfo->mipCount; + args.sample_count = createInfo->sampleCount; + args.format = (gn_gfx_api == GN_GFX_D3D11 || gn_gfx_api == GN_GFX_D3D12) ? + gn_d3d_format_to_vk(createInfo->format) : createInfo->format; + if (!args.format) return XR_ERROR_SWAPCHAIN_FORMAT_UNSUPPORTED; + gn_log_num("xrCreateSwapchain client format=", createInfo->format); + gn_log_num("xrCreateSwapchain Vulkan format=", args.format); + args.usage = createInfo->usageFlags; + args.result = GN_UNIX_ERROR_UNAVAILABLE; + if (!gn_unix_call(GN_UNIX_CREATE_SWAPCHAIN, &args) || + args.result != GN_UNIX_SUCCESS || args.image_count < 2 || + args.image_count > GN_UNIX_MAX_IMAGES) { + gn_log_num("xrCreateSwapchain unix result=", args.result); + return XR_ERROR_RUNTIME_FAILURE; + } + + GnSwapchain* state = &gn_swapchains[slot]; + gn_zero_memory(state, sizeof(*state)); + state->used = 1; + state->image_count = args.image_count; + state->create_info = *createInfo; + for (gn_uint32 i = 0; i < args.image_count; ++i) { + state->images[i] = args.images[i]; + state->image_state[i] = GN_IMAGE_AVAILABLE; + } + if (gn_gfx_api == GN_GFX_D3D11) { + GnDxvkCreateTexture create_texture = + (GnDxvkCreateTexture)gn_com_method(gn_dxvk_interop, 10); + GnD3D11Texture2DDesc1 desc; + gn_zero_memory(&desc, sizeof(desc)); + desc.Width = createInfo->width; + desc.Height = createInfo->height; + desc.MipLevels = createInfo->mipCount; + desc.ArraySize = createInfo->arraySize; + desc.Format = (int)createInfo->format; + desc.SampleDesc.Count = createInfo->sampleCount; + desc.Usage = 0; + desc.BindFlags = 0x8; + if (createInfo->usageFlags & XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT) + desc.BindFlags |= 0x20; + if (createInfo->usageFlags & XR_SWAPCHAIN_USAGE_UNORDERED_ACCESS_BIT) + desc.BindFlags |= 0x80; + for (gn_uint32 i = 0; i < args.image_count; ++i) { + ID3D11Texture2D* texture = NULL; + if (!create_texture || + create_texture(gn_dxvk_interop, &desc, + (VkImage)state->images[i], &texture) < 0 || + !texture) { + for (gn_uint32 j = 0; j < i; ++j) + gn_com_release(state->d3d_images[j]); + struct gn_unix_destroy_swapchain_args destroy_args; + destroy_args.slot = slot; + destroy_args.result = GN_UNIX_ERROR_UNAVAILABLE; + gn_unix_call(GN_UNIX_DESTROY_SWAPCHAIN, &destroy_args); + gn_zero_memory(state, sizeof(*state)); + return XR_ERROR_RUNTIME_FAILURE; + } + state->d3d_images[i] = texture; + } + } else if (gn_gfx_api == GN_GFX_D3D12) { + GnVkd3dCreateResource create_resource = + (GnVkd3dCreateResource)gn_com_method(gn_vkd3d_device_ext, 10); + GnD3D12ResourceDesc1 desc; + gn_zero_memory(&desc, sizeof(desc)); + desc.Dimension = 3; + desc.Width = createInfo->width; + desc.Height = createInfo->height; + desc.DepthOrArraySize = (unsigned short)createInfo->arraySize; + desc.MipLevels = (unsigned short)createInfo->mipCount; + desc.Format = (int)createInfo->format; + desc.SampleDesc.Count = createInfo->sampleCount; + if (createInfo->usageFlags & XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT) + desc.Flags |= 0x1; + if (createInfo->usageFlags & XR_SWAPCHAIN_USAGE_UNORDERED_ACCESS_BIT) + desc.Flags |= 0x4; + for (gn_uint32 i = 0; i < args.image_count; ++i) { + ID3D12Resource* resource = NULL; + if (!create_resource || + create_resource(gn_vkd3d_device_ext, &desc, + state->images[i], &resource) < 0 || + !resource) { + for (gn_uint32 j = 0; j < i; ++j) + gn_com_release(state->d3d_images[j]); + struct gn_unix_destroy_swapchain_args destroy_args; + destroy_args.slot = slot; + destroy_args.result = GN_UNIX_ERROR_UNAVAILABLE; + gn_unix_call(GN_UNIX_DESTROY_SWAPCHAIN, &destroy_args); + gn_zero_memory(state, sizeof(*state)); + return XR_ERROR_RUNTIME_FAILURE; + } + state->d3d_images[i] = resource; + } + } + *swapchain = (XrSwapchain)(GN_SWAPCHAIN_BASE | slot); + { + char command[64]; + gn_size n = gn_append( + command, sizeof(command), 0, "SWAPCHAIN_CREATE images="); + gn_append_i64( + command, sizeof(command), n, (long long)args.image_count); + gn_bridge_call(command, NULL, 0); + } + gn_log_num("xrCreateSwapchain images=", args.image_count); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrDestroySwapchain(XrSwapchain swapchain) { + int slot = gn_swapchain_index(swapchain); + if (slot < 0) return XR_ERROR_HANDLE_INVALID; + for (gn_uint32 i = 0; i < gn_swapchains[slot].image_count; ++i) { + if (gn_swapchains[slot].d3d_images[i]) + gn_com_release(gn_swapchains[slot].d3d_images[i]); + } + struct gn_unix_destroy_swapchain_args args; + args.slot = (gn_u32)slot; + args.result = GN_UNIX_ERROR_UNAVAILABLE; + if (!gn_unix_call(GN_UNIX_DESTROY_SWAPCHAIN, &args) || + args.result != GN_UNIX_SUCCESS) return XR_ERROR_RUNTIME_FAILURE; + gn_zero_memory(&gn_swapchains[slot], sizeof(gn_swapchains[slot])); + gn_bridge_call("SWAPCHAIN_DESTROY", NULL, 0); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrEnumerateSwapchainImages(XrSwapchain swapchain, gn_uint32 capacity, gn_uint32* count, XrSwapchainImageBaseHeader* images) { + int slot = gn_swapchain_index(swapchain); + if (slot < 0) return XR_ERROR_HANDLE_INVALID; + if (!count || (capacity && !images)) return XR_ERROR_VALIDATION_FAILURE; + GnSwapchain* state = &gn_swapchains[slot]; + *count = state->image_count; + if (!capacity) return XR_SUCCESS; + if (capacity < state->image_count) return XR_ERROR_SIZE_INSUFFICIENT; + if (gn_gfx_api == GN_GFX_VULKAN) { + for (gn_uint32 i = 0; i < state->image_count; ++i) { + XrSwapchainImageVulkanKHR* image = + (XrSwapchainImageVulkanKHR*)((unsigned char*)images + + i * sizeof(XrSwapchainImageVulkanKHR)); + if (image->type != XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR) { + return XR_ERROR_VALIDATION_FAILURE; + } + image->image = (VkImage)state->images[i]; + } + } else if (gn_gfx_api == GN_GFX_D3D11) { + for (gn_uint32 i = 0; i < state->image_count; ++i) { + XrSwapchainImageD3D11KHR* image = + (XrSwapchainImageD3D11KHR*)((unsigned char*)images + + i * sizeof(XrSwapchainImageD3D11KHR)); + if (image->type != XR_TYPE_SWAPCHAIN_IMAGE_D3D11_KHR) + return XR_ERROR_VALIDATION_FAILURE; + image->texture = (ID3D11Texture2D*)state->d3d_images[i]; + } + } else if (gn_gfx_api == GN_GFX_D3D12) { + for (gn_uint32 i = 0; i < state->image_count; ++i) { + XrSwapchainImageD3D12KHR* image = + (XrSwapchainImageD3D12KHR*)((unsigned char*)images + + i * sizeof(XrSwapchainImageD3D12KHR)); + if (image->type != XR_TYPE_SWAPCHAIN_IMAGE_D3D12_KHR) + return XR_ERROR_VALIDATION_FAILURE; + image->texture = (ID3D12Resource*)state->d3d_images[i]; + } + } else { + return XR_ERROR_FEATURE_UNSUPPORTED; + } + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrAcquireSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageAcquireInfo* acquireInfo, gn_uint32* index) { + int slot = gn_swapchain_index(swapchain); + if (slot < 0) return XR_ERROR_HANDLE_INVALID; + if (!acquireInfo || acquireInfo->type != XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO || !index) + return XR_ERROR_VALIDATION_FAILURE; + GnSwapchain* state = &gn_swapchains[slot]; + if (state->acquire_count >= state->image_count) + return XR_ERROR_CALL_ORDER_INVALID; + for (gn_uint32 n = 0; n < state->image_count; ++n) { + gn_uint32 candidate = (state->next_image + n) % state->image_count; + if (state->image_state[candidate] == GN_IMAGE_AVAILABLE) { + state->image_state[candidate] = GN_IMAGE_ACQUIRED; + state->acquire_queue[ + (state->acquire_head + state->acquire_count) % GN_UNIX_MAX_IMAGES] = + candidate; + ++state->acquire_count; + state->next_image = (candidate + 1) % state->image_count; + *index = candidate; + return XR_SUCCESS; + } + } + return XR_ERROR_CALL_ORDER_INVALID; +} + +static XrResult XRAPI_CALL gn_xrWaitSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageWaitInfo* waitInfo) { + int slot = gn_swapchain_index(swapchain); + if (slot < 0) return XR_ERROR_HANDLE_INVALID; + if (!waitInfo || waitInfo->type != XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO || + (waitInfo->timeout < 0 && waitInfo->timeout != XR_INFINITE_DURATION)) + return XR_ERROR_VALIDATION_FAILURE; + GnSwapchain* state = &gn_swapchains[slot]; + if (!state->acquire_count) return XR_ERROR_CALL_ORDER_INVALID; + gn_uint32 index = state->acquire_queue[state->acquire_head]; + if (index >= state->image_count || state->image_state[index] != GN_IMAGE_ACQUIRED) { + return XR_ERROR_CALL_ORDER_INVALID; + } + if (state->submitted[index]) { + struct gn_unix_acquire_image_args args; + args.slot = (gn_u32)slot; + args.image_index = index; + args.timeout_ns = waitInfo->timeout; + args.result = GN_UNIX_ERROR_UNAVAILABLE; + if (!gn_unix_call(GN_UNIX_ACQUIRE_IMAGE, &args)) return XR_ERROR_RUNTIME_FAILURE; + if (args.result == GN_UNIX_ERROR_TIMEOUT) return XR_TIMEOUT_EXPIRED; + if (args.result != GN_UNIX_SUCCESS) return XR_ERROR_RUNTIME_FAILURE; + state->submitted[index] = 0; + } + state->image_state[index] = GN_IMAGE_WAITED; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrReleaseSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageReleaseInfo* releaseInfo) { + int slot = gn_swapchain_index(swapchain); + if (slot < 0) return XR_ERROR_HANDLE_INVALID; + if (!releaseInfo || releaseInfo->type != XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO) + return XR_ERROR_VALIDATION_FAILURE; + GnSwapchain* state = &gn_swapchains[slot]; + if (!state->acquire_count) return XR_ERROR_CALL_ORDER_INVALID; + gn_uint32 index = state->acquire_queue[state->acquire_head]; + if (index >= state->image_count || state->image_state[index] != GN_IMAGE_WAITED) { + return XR_ERROR_CALL_ORDER_INVALID; + } + state->image_state[index] = GN_IMAGE_AVAILABLE; + state->acquire_head = (state->acquire_head + 1) % GN_UNIX_MAX_IMAGES; + --state->acquire_count; + state->last_released_image = index; + state->last_released_valid = 1; + return XR_SUCCESS; +} + + + + + +static XrResult XRAPI_CALL gn_xrStringToPath(XrInstance instance, const char* pathString, XrPath* path) { + (void)instance; + if (!pathString || !path) return XR_ERROR_VALIDATION_FAILURE; + gn_paths_init(); + *path = gn_path_get_or_create(pathString); + return *path != XR_NULL_PATH ? XR_SUCCESS : XR_ERROR_PATH_FORMAT_INVALID; +} + +static XrResult XRAPI_CALL gn_xrPathToString(XrInstance instance, XrPath path, gn_uint32 capacity, gn_uint32* count, char* buffer) { + (void)instance; + const char* str = gn_path_string(path); + if (!str) return XR_ERROR_PATH_INVALID; + return gn_fill_string(str, capacity, count, buffer); +} + +static XrResult XRAPI_CALL gn_xrCreateActionSet(XrInstance instance, const XrActionSetCreateInfo* createInfo, XrActionSet* actionSet) { + if (instance != gn_instance || !actionSet || !createInfo) return XR_ERROR_HANDLE_INVALID; + if (gn_action_set_count >= GN_MAX_ACTION_SETS) return XR_ERROR_LIMIT_REACHED; + GnActionSet* record = &gn_action_sets[gn_action_set_count]; + record->used = 1; + record->attached = 0; + gn_copy(record->name, sizeof(record->name), createInfo->actionSetName); + *actionSet = (XrActionSet)(GN_ACTIONSET_BASE | gn_action_set_count); + ++gn_action_set_count; + gn_log2("xrCreateActionSet: ", createInfo->actionSetName); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrDestroyActionSet(XrActionSet actionSet) { + int idx = gn_action_set_index(actionSet); + if (idx < 0) return XR_ERROR_HANDLE_INVALID; + gn_action_sets[idx].used = 0; + for (gn_uint32 i = 0; i < gn_action_count; ++i) { + if (gn_actions[i].used && gn_actions[i].action_set_idx == idx) gn_actions[i].used = 0; + } + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrCreateAction(XrActionSet actionSet, const XrActionCreateInfo* createInfo, XrAction* action) { + int set_idx = gn_action_set_index(actionSet); + if (!action || !createInfo) return XR_ERROR_VALIDATION_FAILURE; + if (set_idx < 0) return XR_ERROR_HANDLE_INVALID; + if (gn_action_sets[set_idx].attached) return XR_ERROR_ACTIONSETS_ALREADY_ATTACHED; + if (gn_action_count >= GN_MAX_ACTIONS) return XR_ERROR_LIMIT_REACHED; + GnAction* record = &gn_actions[gn_action_count]; + record->used = 1; + record->action_set_idx = set_idx; + record->type = createInfo->actionType; + record->component[0] = GN_COMP_NONE; + record->component[1] = GN_COMP_NONE; + record->binding_priority[0] = 0; + record->binding_priority[1] = 0; + record->active_hands = 0; + gn_copy(record->name, sizeof(record->name), createInfo->actionName); + *action = (XrAction)(GN_ACTION_BASE | gn_action_count); + ++gn_action_count; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrDestroyAction(XrAction action) { + int idx = gn_action_index(action); + if (idx >= 0) gn_actions[idx].used = 0; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrSuggestInteractionProfileBindings( + XrInstance instance, + const XrInteractionProfileSuggestedBinding* suggestedBindings) { + if (instance != gn_instance) return XR_ERROR_HANDLE_INVALID; + if (!suggestedBindings) return XR_ERROR_VALIDATION_FAILURE; + + const char* profile = gn_path_string(suggestedBindings->interactionProfile); + const int priority = gn_interaction_profile_priority(profile); + gn_log2("xrSuggestInteractionProfileBindings: ", profile ? profile : "?"); + + for (gn_uint32 i = 0; i < suggestedBindings->countSuggestedBindings; ++i) { + const XrActionSuggestedBinding* binding = &suggestedBindings->suggestedBindings[i]; + int action_idx = gn_action_index(binding->action); + const char* path = gn_path_string(binding->binding); + if (action_idx < 0 || !path) continue; + int hand = -1; + if (gn_starts_with(path, "/user/hand/left")) hand = 0; + else if (gn_starts_with(path, "/user/hand/right")) hand = 1; + int comp = gn_component_from_binding(path); + if (comp == GN_COMP_NONE) { + gn_log2(" unmapped binding: ", path); + continue; + } + if (hand >= 0) { + if (priority >= gn_actions[action_idx].binding_priority[hand]) { + gn_actions[action_idx].component[hand] = (unsigned char)comp; + gn_actions[action_idx].binding_priority[hand] = (unsigned char)priority; + } + } else { + for (int candidate = 0; candidate < 2; ++candidate) { + if (priority >= gn_actions[action_idx].binding_priority[candidate]) { + gn_actions[action_idx].component[candidate] = (unsigned char)comp; + gn_actions[action_idx].binding_priority[candidate] = (unsigned char)priority; + } + } + } + } + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrAttachSessionActionSets(XrSession session, const XrSessionActionSetsAttachInfo* attachInfo) { + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + if (!attachInfo || (attachInfo->countActionSets != 0 && !attachInfo->actionSets)) { + return XR_ERROR_VALIDATION_FAILURE; + } + if (gn_action_sets_attached) return XR_ERROR_ACTIONSETS_ALREADY_ATTACHED; + for (gn_uint32 i = 0; i < attachInfo->countActionSets; ++i) { + if (gn_action_set_index(attachInfo->actionSets[i]) < 0) return XR_ERROR_HANDLE_INVALID; + } + for (gn_uint32 i = 0; i < attachInfo->countActionSets; ++i) { + gn_action_sets[gn_action_set_index(attachInfo->actionSets[i])].attached = 1; + } + gn_action_sets_attached = 1; + gn_interaction_profile_event_pending = 1; + gn_log_line("xrAttachSessionActionSets"); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrSyncActions(XrSession session, const XrActionsSyncInfo* syncInfo) { + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + if (!syncInfo || (syncInfo->countActiveActionSets != 0 && !syncInfo->activeActionSets)) { + return XR_ERROR_VALIDATION_FAILURE; + } + if (!gn_action_sets_attached) return XR_ERROR_ACTIONSET_NOT_ATTACHED; + for (gn_uint32 i = 0; i < gn_action_count; ++i) gn_actions[i].active_hands = 0; + for (gn_uint32 i = 0; i < syncInfo->countActiveActionSets; ++i) { + const XrActiveActionSet* active = &syncInfo->activeActionSets[i]; + int set_idx = gn_action_set_index(active->actionSet); + if (set_idx < 0) return XR_ERROR_HANDLE_INVALID; + if (!gn_action_sets[set_idx].attached) return XR_ERROR_ACTIONSET_NOT_ATTACHED; + int hand = gn_hand_from_path(active->subactionPath); + unsigned char hand_mask = hand < 0 ? 3u : (unsigned char)(1u << hand); + for (gn_uint32 action_idx = 0; action_idx < gn_action_count; ++action_idx) { + if (gn_actions[action_idx].used && gn_actions[action_idx].action_set_idx == set_idx) { + gn_actions[action_idx].active_hands |= hand_mask; + } + } + } + gn_refresh_hand(0); + gn_refresh_hand(1); + for (int hand = 0; hand < 2; ++hand) { + XrPosef pose; + gn_write_pose(&pose, gn_hands[hand].grip); + gn_update_pose_velocity( + &pose, + gn_next_display_time, + &gn_last_hand_pose[hand], + &gn_last_hand_time[hand], + gn_hand_linear_velocity[hand], + gn_hand_angular_velocity[hand], + 0); + } + gn_update_change_tracking(gn_next_display_time); + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetCurrentInteractionProfile( + XrSession session, + XrPath topLevelUserPath, + XrInteractionProfileState* interactionProfile) { + (void)topLevelUserPath; + if (session != gn_session || !interactionProfile) return XR_ERROR_HANDLE_INVALID; + interactionProfile->interactionProfile = gn_path_get_or_create("/interaction_profiles/oculus/touch_controller"); + return XR_SUCCESS; +} + + +static int gn_resolve_action(const XrActionStateGetInfo* getInfo, int* out_hand, int* out_comp) { + int action_idx = gn_action_index(getInfo->action); + if (action_idx < 0) return 0; + const GnAction* action = &gn_actions[action_idx]; + int hand = gn_hand_from_path(getInfo->subactionPath); + if (getInfo->subactionPath != XR_NULL_PATH && hand < 0) return 0; + if (hand < 0) hand = gn_action_default_hand(action); + *out_hand = hand; + *out_comp = action->component[hand]; + return 1; +} + +static int gn_action_is_active(XrAction action, int hand, int comp) { + int action_idx = gn_action_index(action); + if (action_idx < 0 || hand < 0 || hand > 1 || comp == GN_COMP_NONE) return 0; + return (gn_actions[action_idx].active_hands & (1u << hand)) != 0 && gn_hands[hand].active; +} + +static XrResult XRAPI_CALL gn_xrGetActionStateBoolean( + XrSession session, + const XrActionStateGetInfo* getInfo, + XrActionStateBoolean* state) { + int hand = 0, comp = GN_COMP_NONE; + if (session != gn_session || !state || !getInfo) return XR_ERROR_HANDLE_INVALID; + if (!gn_resolve_action(getInfo, &hand, &comp)) return XR_ERROR_HANDLE_INVALID; + float value = gn_comp_value(hand, comp); + state->currentState = value > 0.7f ? XR_TRUE : XR_FALSE; + state->changedSinceLastSync = gn_comp_changed[hand][comp] ? XR_TRUE : XR_FALSE; + state->lastChangeTime = gn_comp_change_time[hand][comp] ? gn_comp_change_time[hand][comp] : gn_last_sync_time; + state->isActive = gn_action_is_active(getInfo->action, hand, comp) ? XR_TRUE : XR_FALSE; + if (!state->isActive) { + state->currentState = XR_FALSE; + state->changedSinceLastSync = XR_FALSE; + } + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetActionStateFloat( + XrSession session, + const XrActionStateGetInfo* getInfo, + XrActionStateFloat* state) { + int hand = 0, comp = GN_COMP_NONE; + if (session != gn_session || !state || !getInfo) return XR_ERROR_HANDLE_INVALID; + if (!gn_resolve_action(getInfo, &hand, &comp)) return XR_ERROR_HANDLE_INVALID; + state->currentState = gn_comp_value(hand, comp); + state->changedSinceLastSync = gn_comp_changed[hand][comp] ? XR_TRUE : XR_FALSE; + state->lastChangeTime = gn_comp_change_time[hand][comp] ? gn_comp_change_time[hand][comp] : gn_last_sync_time; + state->isActive = gn_action_is_active(getInfo->action, hand, comp) ? XR_TRUE : XR_FALSE; + if (!state->isActive) { + state->currentState = 0.0f; + state->changedSinceLastSync = XR_FALSE; + } + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetActionStateVector2f( + XrSession session, + const XrActionStateGetInfo* getInfo, + XrActionStateVector2f* state) { + int hand = 0, comp = GN_COMP_NONE; + if (session != gn_session || !state || !getInfo) return XR_ERROR_HANDLE_INVALID; + if (!gn_resolve_action(getInfo, &hand, &comp)) return XR_ERROR_HANDLE_INVALID; + state->currentState.x = gn_hands[hand].sx; + state->currentState.y = gn_hands[hand].sy; + state->changedSinceLastSync = + (gn_comp_changed[hand][GN_COMP_STICK_X] || gn_comp_changed[hand][GN_COMP_STICK_Y] || gn_comp_changed[hand][GN_COMP_STICK]) + ? XR_TRUE : XR_FALSE; + state->lastChangeTime = gn_last_sync_time; + state->isActive = gn_action_is_active(getInfo->action, hand, comp) ? XR_TRUE : XR_FALSE; + if (!state->isActive) { + state->currentState.x = 0.0f; + state->currentState.y = 0.0f; + state->changedSinceLastSync = XR_FALSE; + } + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrGetActionStatePose( + XrSession session, + const XrActionStateGetInfo* getInfo, + XrActionStatePose* state) { + int hand = 0, comp = GN_COMP_NONE; + if (session != gn_session || !state || !getInfo) return XR_ERROR_HANDLE_INVALID; + if (!gn_resolve_action(getInfo, &hand, &comp)) return XR_ERROR_HANDLE_INVALID; + state->isActive = gn_action_is_active(getInfo->action, hand, comp) ? XR_TRUE : XR_FALSE; + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrEnumerateBoundSourcesForAction( + XrSession session, + const XrBoundSourcesForActionEnumerateInfo* enumerateInfo, + gn_uint32 capacity, + gn_uint32* count, + XrPath* sources) { + XrPath resolved[2]; + gn_uint32 resolved_count = 0; + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + if (!enumerateInfo) return XR_ERROR_VALIDATION_FAILURE; + int action_idx = gn_action_index(enumerateInfo->action); + if (action_idx < 0) return XR_ERROR_HANDLE_INVALID; + for (int hand = 0; hand < 2; ++hand) { + int comp = gn_actions[action_idx].component[hand]; + const char* suffix = NULL; + switch (comp) { + case GN_COMP_PRIMARY: suffix = hand == 0 ? "/input/x/click" : "/input/a/click"; break; + case GN_COMP_SECONDARY: suffix = hand == 0 ? "/input/y/click" : "/input/b/click"; break; + case GN_COMP_STICK_CLICK: suffix = "/input/thumbstick/click"; break; + case GN_COMP_MENU: suffix = "/input/menu/click"; break; + case GN_COMP_TRIGGER: suffix = "/input/trigger/value"; break; + case GN_COMP_SQUEEZE: suffix = "/input/squeeze/value"; break; + case GN_COMP_STICK: suffix = "/input/thumbstick"; break; + case GN_COMP_STICK_X: suffix = "/input/thumbstick/x"; break; + case GN_COMP_STICK_Y: suffix = "/input/thumbstick/y"; break; + case GN_COMP_GRIP_POSE: suffix = "/input/grip/pose"; break; + case GN_COMP_AIM_POSE: suffix = "/input/aim/pose"; break; + case GN_COMP_HAPTIC: suffix = "/output/haptic"; break; + default: break; + } + if (suffix) { + char path[GN_PATH_LEN]; + gn_size n = gn_append(path, sizeof(path), 0, hand == 0 ? "/user/hand/left" : "/user/hand/right"); + gn_append(path, sizeof(path), n, suffix); + resolved[resolved_count++] = gn_path_get_or_create(path); + } + } + XrResult r = gn_copy_props(resolved_count, capacity, count); + if (XR_SUCCEEDED(r) && capacity != 0) { + if (!sources) return XR_ERROR_VALIDATION_FAILURE; + for (gn_uint32 i = 0; i < resolved_count; ++i) sources[i] = resolved[i]; + } + return r; +} + +static XrResult XRAPI_CALL gn_xrGetInputSourceLocalizedName( + XrSession session, + const XrInputSourceLocalizedNameGetInfo* getInfo, + gn_uint32 capacity, + gn_uint32* count, + char* buffer) { + (void)getInfo; + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + return gn_fill_string("GameNativeVR Touch", capacity, count, buffer); +} + +static XrResult XRAPI_CALL gn_xrApplyHapticFeedback( + XrSession session, + const XrHapticActionInfo* hapticActionInfo, + const XrHapticBaseHeader* hapticFeedback) { + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + if (!hapticActionInfo || !hapticFeedback) return XR_ERROR_VALIDATION_FAILURE; + + int hand = gn_hand_from_path(hapticActionInfo->subactionPath); + if (hand < 0) { + int action_idx = gn_action_index(hapticActionInfo->action); + hand = action_idx >= 0 ? gn_action_default_hand(&gn_actions[action_idx]) : 0; + } + + if (hapticFeedback->type == XR_TYPE_HAPTIC_VIBRATION) { + const XrHapticVibration* vibration = (const XrHapticVibration*)hapticFeedback; + char cmd[160]; + gn_size n = gn_append(cmd, sizeof(cmd), 0, "HAPTIC hand="); + n = gn_append_i64(cmd, sizeof(cmd), n, hand); + n = gn_append(cmd, sizeof(cmd), n, " amp="); + n = gn_append_i64(cmd, sizeof(cmd), n, gn_float_to_micro(vibration->amplitude)); + n = gn_append(cmd, sizeof(cmd), n, " dur="); + n = gn_append_i64(cmd, sizeof(cmd), n, vibration->duration); + n = gn_append(cmd, sizeof(cmd), n, " freq="); + n = gn_append_i64(cmd, sizeof(cmd), n, gn_float_to_micro(vibration->frequency)); + gn_bridge_call(cmd, NULL, 0); + } + return XR_SUCCESS; +} + +static XrResult XRAPI_CALL gn_xrStopHapticFeedback(XrSession session, const XrHapticActionInfo* hapticActionInfo) { + if (session != gn_session) return XR_ERROR_HANDLE_INVALID; + if (hapticActionInfo) { + int hand = gn_hand_from_path(hapticActionInfo->subactionPath); + char cmd[64]; + gn_size n = gn_append(cmd, sizeof(cmd), 0, "HAPTIC hand="); + n = gn_append_i64(cmd, sizeof(cmd), n, hand < 0 ? 0 : hand); + n = gn_append(cmd, sizeof(cmd), n, " amp=0 dur=0 freq=0"); + gn_bridge_call(cmd, NULL, 0); + } + return XR_SUCCESS; +} + + + + + +GN_EXPORT XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const char* name, PFN_xrVoidFunction* function) { + (void)instance; + if (!name || !function) return XR_ERROR_VALIDATION_FAILURE; + *function = NULL; +#define GN_PROC(n) if (gn_streq(name, #n)) { *function = (PFN_xrVoidFunction)gn_##n; return XR_SUCCESS; } + GN_PROC(xrEnumerateInstanceExtensionProperties) + GN_PROC(xrCreateInstance) + GN_PROC(xrDestroyInstance) + GN_PROC(xrGetInstanceProperties) + GN_PROC(xrPollEvent) + GN_PROC(xrResultToString) + GN_PROC(xrStructureTypeToString) + GN_PROC(xrGetSystem) + GN_PROC(xrGetSystemProperties) + GN_PROC(xrEnumerateEnvironmentBlendModes) + GN_PROC(xrEnumerateViewConfigurations) + GN_PROC(xrGetViewConfigurationProperties) + GN_PROC(xrEnumerateViewConfigurationViews) + GN_PROC(xrCreateSession) + GN_PROC(xrDestroySession) + GN_PROC(xrBeginSession) + GN_PROC(xrEndSession) + GN_PROC(xrRequestExitSession) + GN_PROC(xrEnumerateReferenceSpaces) + GN_PROC(xrCreateReferenceSpace) + GN_PROC(xrGetReferenceSpaceBoundsRect) + GN_PROC(xrDestroySpace) + GN_PROC(xrCreateActionSpace) + GN_PROC(xrLocateSpace) + GN_PROC(xrLocateSpaces) + GN_PROC(xrWaitFrame) + GN_PROC(xrBeginFrame) + GN_PROC(xrEndFrame) + GN_PROC(xrLocateViews) + GN_PROC(xrEnumerateSwapchainFormats) + GN_PROC(xrGetD3D11GraphicsRequirementsKHR) + GN_PROC(xrGetD3D12GraphicsRequirementsKHR) + GN_PROC(xrGetVulkanInstanceExtensionsKHR) + GN_PROC(xrGetVulkanDeviceExtensionsKHR) + GN_PROC(xrGetVulkanGraphicsDeviceKHR) + GN_PROC(xrGetVulkanGraphicsRequirementsKHR) + GN_PROC(xrGetVulkanGraphicsDevice2KHR) + GN_PROC(xrGetVulkanGraphicsRequirements2KHR) + GN_PROC(xrCreateVulkanInstanceKHR) + GN_PROC(xrCreateVulkanDeviceKHR) + GN_PROC(xrCreateSwapchain) + GN_PROC(xrDestroySwapchain) + GN_PROC(xrEnumerateSwapchainImages) + GN_PROC(xrAcquireSwapchainImage) + GN_PROC(xrWaitSwapchainImage) + GN_PROC(xrReleaseSwapchainImage) + GN_PROC(xrStringToPath) + GN_PROC(xrPathToString) + GN_PROC(xrCreateActionSet) + GN_PROC(xrDestroyActionSet) + GN_PROC(xrCreateAction) + GN_PROC(xrDestroyAction) + GN_PROC(xrSuggestInteractionProfileBindings) + GN_PROC(xrAttachSessionActionSets) + GN_PROC(xrSyncActions) + GN_PROC(xrGetCurrentInteractionProfile) + GN_PROC(xrGetActionStateBoolean) + GN_PROC(xrGetActionStateFloat) + GN_PROC(xrGetActionStateVector2f) + GN_PROC(xrGetActionStatePose) + GN_PROC(xrEnumerateBoundSourcesForAction) + GN_PROC(xrGetInputSourceLocalizedName) + GN_PROC(xrApplyHapticFeedback) + GN_PROC(xrStopHapticFeedback) +#undef GN_PROC + if (gn_streq(name, "xrLocateSpacesKHR")) { + *function = (PFN_xrVoidFunction)gn_xrLocateSpaces; + return XR_SUCCESS; + } + if (gn_streq(name, "xrGetInstanceProcAddr")) { + *function = (PFN_xrVoidFunction)xrGetInstanceProcAddr; + return XR_SUCCESS; + } + gn_log2("unsupported function requested: ", name); + return XR_ERROR_FUNCTION_UNSUPPORTED; +} + +GN_EXPORT XrResult XRAPI_CALL xrNegotiateLoaderRuntimeInterface( + const XrNegotiateLoaderInfo* loaderInfo, + XrNegotiateRuntimeRequest* runtimeRequest) { + if (!loaderInfo || !runtimeRequest) return XR_ERROR_VALIDATION_FAILURE; + if (loaderInfo->minInterfaceVersion > XR_CURRENT_LOADER_RUNTIME_VERSION || + loaderInfo->maxInterfaceVersion < XR_CURRENT_LOADER_RUNTIME_VERSION) { + return XR_ERROR_INITIALIZATION_FAILED; + } + runtimeRequest->structType = XR_LOADER_INTERFACE_STRUCT_RUNTIME_REQUEST; + runtimeRequest->structVersion = XR_RUNTIME_INFO_STRUCT_VERSION; + runtimeRequest->structSize = sizeof(*runtimeRequest); + runtimeRequest->runtimeInterfaceVersion = XR_CURRENT_LOADER_RUNTIME_VERSION; + runtimeRequest->runtimeApiVersion = XR_MAKE_VERSION(1, 0, 0); + runtimeRequest->getInstanceProcAddr = xrGetInstanceProcAddr; + gn_log_line("runtime negotiated with loader"); + return XR_SUCCESS; +} + +GN_EXPORT int DllMain(void* module, unsigned long reason, void* reserved) { + (void)module; + (void)reason; + (void)reserved; + return 1; +} diff --git a/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime_x64.def b/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime_x64.def new file mode 100644 index 0000000000..40b9b2df87 --- /dev/null +++ b/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime_x64.def @@ -0,0 +1,4 @@ +LIBRARY gamenative_openxr_runtime64.dll +EXPORTS + xrGetInstanceProcAddr + xrNegotiateLoaderRuntimeInterface diff --git a/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime_x86.def b/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime_x86.def new file mode 100644 index 0000000000..a211551184 --- /dev/null +++ b/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime_x86.def @@ -0,0 +1,4 @@ +LIBRARY gamenative_openxr_runtime32.dll +EXPORTS + xrGetInstanceProcAddr = xrGetInstanceProcAddr@16 + xrNegotiateLoaderRuntimeInterface = xrNegotiateLoaderRuntimeInterface@8 diff --git a/app/src/main/windows/openxr_runtime/gamenative_openxr_unix.h b/app/src/main/windows/openxr_runtime/gamenative_openxr_unix.h new file mode 100644 index 0000000000..b9362660bd --- /dev/null +++ b/app/src/main/windows/openxr_runtime/gamenative_openxr_unix.h @@ -0,0 +1,140 @@ +#pragma once + + + + + +#if defined(_MSC_VER) +typedef unsigned __int32 gn_u32; +typedef signed __int32 gn_i32; +typedef unsigned __int64 gn_u64; +typedef signed __int64 gn_i64; +#else +typedef unsigned int gn_u32; +typedef signed int gn_i32; +typedef unsigned long long gn_u64; +typedef signed long long gn_i64; +#endif + +#define GN_UNIX_ABI_VERSION 6u +#define GN_UNIX_MAX_SWAPCHAINS 32u +#define GN_UNIX_MAX_IMAGES 4u + +enum gn_unix_call_code { + GN_UNIX_INIT = 0, + GN_UNIX_SET_VULKAN_CONTEXT, + GN_UNIX_CREATE_SWAPCHAIN, + GN_UNIX_DESTROY_SWAPCHAIN, + GN_UNIX_ACQUIRE_IMAGE, + GN_UNIX_SUBMIT_IMAGE, + GN_UNIX_SUBMIT_STEREO, + GN_UNIX_CONTROL_TRANSACT, + GN_UNIX_CALL_COUNT +}; + +enum gn_unix_result { + GN_UNIX_SUCCESS = 0, + GN_UNIX_ERROR_ARGUMENT = -1, + GN_UNIX_ERROR_UNAVAILABLE = -2, + GN_UNIX_ERROR_VULKAN = -3, + GN_UNIX_ERROR_TRANSPORT = -4, + GN_UNIX_ERROR_TIMEOUT = -5 +}; + +enum gn_unix_vulkan_diagnostic_flag { + GN_UNIX_VK_DIAG_PHYSICAL_DEVICE = 1u << 0, + GN_UNIX_VK_DIAG_DEVICE = 1u << 1, + GN_UNIX_VK_DIAG_QUEUE = 1u << 2, + GN_UNIX_VK_DIAG_VULKAN_LIBRARY = 1u << 3, + GN_UNIX_VK_DIAG_GET_DEVICE_PROC_ADDR = 1u << 4, + GN_UNIX_VK_DIAG_CREATE_IMAGE = 1u << 5, + GN_UNIX_VK_DIAG_GET_MEMORY_FD = 1u << 6 +}; + +#pragma pack(push, 1) +struct gn_unix_init_args { + gn_u32 abi_version; + gn_i32 result; +}; + +struct gn_unix_vulkan_context_args { + gn_u64 client_physical_device; + gn_u64 client_device; + gn_u64 client_queue; + gn_u32 queue_family_index; + gn_u32 queue_index; + gn_u32 handles_are_host; + gn_u32 diagnostic_flags; + gn_i32 result; +}; + +struct gn_unix_create_swapchain_args { + gn_u32 slot; + gn_u32 width; + gn_u32 height; + gn_u32 array_size; + gn_u32 mip_count; + gn_u32 sample_count; + gn_i64 format; + gn_u64 usage; + gn_u32 image_count; + gn_u64 images[GN_UNIX_MAX_IMAGES]; + gn_i32 result; +}; + +struct gn_unix_destroy_swapchain_args { + gn_u32 slot; + gn_i32 result; +}; + +struct gn_unix_acquire_image_args { + gn_u32 slot; + gn_u32 image_index; + gn_i64 timeout_ns; + gn_i32 result; +}; + +struct gn_unix_submit_view_args { + gn_u32 slot; + gn_u32 image_index; + gn_u32 eye; + gn_u32 array_index; + gn_i32 rect_x; + gn_i32 rect_y; + gn_u32 rect_width; + gn_u32 rect_height; + gn_i64 orientation_micro[4]; + gn_i64 position_micro[3]; + gn_i64 fov_micro[4]; + gn_u32 flip_y; +}; + +struct gn_unix_submit_image_args { + gn_u32 slot; + gn_u32 image_index; + gn_u32 eye; + gn_u32 array_index; + gn_i32 rect_x; + gn_i32 rect_y; + gn_u32 rect_width; + gn_u32 rect_height; + gn_i64 orientation_micro[4]; + gn_i64 position_micro[3]; + gn_i64 fov_micro[4]; + gn_u32 flip_y; + gn_i32 result; +}; + +struct gn_unix_control_transact_args { + char request[192]; + char response[2048]; + gn_u32 response_lines; + gn_i32 result; +}; + +struct gn_unix_submit_stereo_args { + gn_u32 view_count; + struct gn_unix_submit_view_args views[2]; + gn_i32 result; +}; +#pragma pack(pop) diff --git a/app/src/main/windows/openxr_runtime/kernel32_x64.def b/app/src/main/windows/openxr_runtime/kernel32_x64.def new file mode 100644 index 0000000000..cced63522e --- /dev/null +++ b/app/src/main/windows/openxr_runtime/kernel32_x64.def @@ -0,0 +1,13 @@ +LIBRARY KERNEL32.dll +EXPORTS + CreateFileA + WriteFile + CloseHandle + GetEnvironmentVariableA + OutputDebugStringA + LoadLibraryA + GetProcAddress + GetLastError + FreeLibrary + ExitProcess + GetTickCount64 diff --git a/app/src/main/windows/openxr_runtime/kernel32_x86.def b/app/src/main/windows/openxr_runtime/kernel32_x86.def new file mode 100644 index 0000000000..0d1cef62df --- /dev/null +++ b/app/src/main/windows/openxr_runtime/kernel32_x86.def @@ -0,0 +1,13 @@ +LIBRARY KERNEL32.dll +EXPORTS + CreateFileA@28 + WriteFile@20 + CloseHandle@4 + GetEnvironmentVariableA@12 + OutputDebugStringA@4 + LoadLibraryA@4 + GetProcAddress@8 + GetLastError@0 + FreeLibrary@4 + ExitProcess@4 + GetTickCount64 diff --git a/app/src/main/windows/openxr_runtime/ntdll_x64.def b/app/src/main/windows/openxr_runtime/ntdll_x64.def new file mode 100644 index 0000000000..17ada6bcd9 --- /dev/null +++ b/app/src/main/windows/openxr_runtime/ntdll_x64.def @@ -0,0 +1,4 @@ +LIBRARY ntdll.dll +EXPORTS + NtQueryVirtualMemory + __wine_unix_call_dispatcher DATA diff --git a/app/src/main/windows/openxr_runtime/ntdll_x86.def b/app/src/main/windows/openxr_runtime/ntdll_x86.def new file mode 100644 index 0000000000..476c3696b3 --- /dev/null +++ b/app/src/main/windows/openxr_runtime/ntdll_x86.def @@ -0,0 +1,4 @@ +LIBRARY ntdll.dll +EXPORTS + NtQueryVirtualMemory@24 + __wine_unix_call_dispatcher DATA diff --git a/app/src/main/windows/openxr_runtime/unix/CMakeLists.txt b/app/src/main/windows/openxr_runtime/unix/CMakeLists.txt new file mode 100644 index 0000000000..84c6908286 --- /dev/null +++ b/app/src/main/windows/openxr_runtime/unix/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.22) +project(gamenative_xr_unixbridge C) + +add_library(gamenative_xr_unixbridge SHARED gamenative_openxr_unix.c) +target_include_directories(gamenative_xr_unixbridge PRIVATE ..) +target_link_libraries(gamenative_xr_unixbridge PRIVATE android dl log vulkan) +target_compile_options(gamenative_xr_unixbridge PRIVATE -Wall -Wextra -Werror) +set_target_properties(gamenative_xr_unixbridge PROPERTIES PREFIX "" OUTPUT_NAME "gamenative_xr_unixbridge") diff --git a/app/src/main/windows/openxr_runtime/unix/gamenative_openxr_unix.c b/app/src/main/windows/openxr_runtime/unix/gamenative_openxr_unix.c new file mode 100644 index 0000000000..dfcf5fe1a6 --- /dev/null +++ b/app/src/main/windows/openxr_runtime/unix/gamenative_openxr_unix.c @@ -0,0 +1,2675 @@ +#define _GNU_SOURCE +#include "../gamenative_openxr_unix.h" + +#if defined(__ANDROID__) +#define VK_USE_PLATFORM_ANDROID_KHR 1 +#include +#endif +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define GN_FOURCC(a, b, c, d) \ + ((uint32_t)(a) | ((uint32_t)(b) << 8) | \ + ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24)) +#define DRM_FORMAT_ABGR8888 GN_FOURCC('A', 'B', '2', '4') +#define DRM_FORMAT_ARGB8888 GN_FOURCC('A', 'R', '2', '4') + +typedef int32_t (*unixlib_entry_t)(void *); + +enum gn_transport_kind { + GN_TRANSPORT_UNKNOWN = 0, + GN_TRANSPORT_AHARDWAREBUFFER = 1, + GN_TRANSPORT_DMABUF = 2, + GN_TRANSPORT_RELAY = 3 +}; + +struct gn_transport_image { + VkImage image; + VkDeviceMemory memory; + VkCommandBuffer command_buffer; + void *hardware_buffer; + uint8_t relay; + uint8_t registered; + uint8_t initialized; + uint8_t steady_recorded; +}; + +struct wine_client_object { + uint64_t loader_magic; + uint64_t unix_handle; +}; + +struct gn_image { + VkImage image; + VkDeviceMemory memory; + int dma_buf_fd; + uint32_t plane_count; + uint32_t strides[4]; + uint32_t offsets[4]; + uint64_t modifier; + uint8_t registered_eye_mask; + uint32_t registered_array_index[2]; + uint8_t transport_kind[2]; + struct gn_transport_image transport[2]; + VkImage relay_source_image; + VkDeviceMemory relay_source_memory; + uint8_t submitted; +}; + +struct gn_swapchain { + uint8_t allocated; + uint32_t width; + uint32_t height; + VkFormat format; + uint32_t array_size; + uint32_t sample_count; + uint32_t image_count; + struct gn_image images[GN_UNIX_MAX_IMAGES]; +}; + +static void submit_worker_flush(void); +static uint8_t image_copy_busy[32u][4u]; +static pthread_mutex_t submit_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t submit_space_cond = PTHREAD_COND_INITIALIZER; + +static pthread_mutex_t state_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_mutex_t socket_mutex = PTHREAD_MUTEX_INITIALIZER; +static struct gn_swapchain swapchains[GN_UNIX_MAX_SWAPCHAINS]; +static VkPhysicalDevice physical_device; +static VkDevice device; +static VkQueue queue; +static uint32_t queue_family_index; +static VkCommandPool command_pool; +static void *vulkan_so; +static int transport_fd = -1; +static uint8_t transport_frame_announced[2]; +static uint64_t transport_frame_id; + +static PFN_vkGetDeviceProcAddr p_vkGetDeviceProcAddr; +static PFN_vkGetPhysicalDeviceMemoryProperties p_vkGetPhysicalDeviceMemoryProperties; +static PFN_vkGetPhysicalDeviceFormatProperties2 p_vkGetPhysicalDeviceFormatProperties2; +static PFN_vkCreateImage p_vkCreateImage; +static PFN_vkDestroyImage p_vkDestroyImage; +static PFN_vkGetImageMemoryRequirements p_vkGetImageMemoryRequirements; +static PFN_vkAllocateMemory p_vkAllocateMemory; +static PFN_vkFreeMemory p_vkFreeMemory; +static PFN_vkBindImageMemory p_vkBindImageMemory; +static PFN_vkGetImageSubresourceLayout p_vkGetImageSubresourceLayout; +static PFN_vkGetImageDrmFormatModifierPropertiesEXT p_vkGetImageDrmFormatModifierPropertiesEXT; +static PFN_vkGetMemoryFdKHR p_vkGetMemoryFdKHR; +static PFN_vkCreateFence p_vkCreateFence; +static PFN_vkDestroyFence p_vkDestroyFence; +static PFN_vkWaitForFences p_vkWaitForFences; +static PFN_vkGetFenceFdKHR p_vkGetFenceFdKHR; +static PFN_vkQueueSubmit p_vkQueueSubmit; +static PFN_vkQueueWaitIdle p_vkQueueWaitIdle; +static PFN_vkCreateCommandPool p_vkCreateCommandPool; +static PFN_vkDestroyCommandPool p_vkDestroyCommandPool; +static PFN_vkAllocateCommandBuffers p_vkAllocateCommandBuffers; +static PFN_vkFreeCommandBuffers p_vkFreeCommandBuffers; +static PFN_vkResetCommandBuffer p_vkResetCommandBuffer; +static PFN_vkBeginCommandBuffer p_vkBeginCommandBuffer; +static PFN_vkEndCommandBuffer p_vkEndCommandBuffer; +static PFN_vkCmdPipelineBarrier p_vkCmdPipelineBarrier; +static PFN_vkCmdCopyImage p_vkCmdCopyImage; +#if defined(__ANDROID__) +static PFN_vkGetAndroidHardwareBufferPropertiesANDROID + p_vkGetAndroidHardwareBufferPropertiesANDROID; +#endif + +static void log_line(const char *line) +{ + const char *path = getenv("GAMENATIVE_XR_UNIX_LOG"); + if (!path || !*path) path = "/tmp/gamenative-xr-unix.log"; + FILE *f = fopen(path, "a"); + if (!f) return; + fprintf(f, "%s\n", line); + fclose(f); +} + +static void log_vulkan_context_state(void) +{ + char line[384]; + snprintf(line, sizeof(line), + "Vulkan context phys=%p device=%p queue=%p gpa=%p createImage=%p " + "getMemoryFd=%p createFence=%p getFenceFd=%p queueSubmit=%p " + "commandPool=%p ahb=%p", + (void *)physical_device, (void *)device, (void *)queue, + (void *)p_vkGetDeviceProcAddr, (void *)p_vkCreateImage, + (void *)p_vkGetMemoryFdKHR, (void *)p_vkCreateFence, + (void *)p_vkGetFenceFdKHR, (void *)p_vkQueueSubmit, + (void *)command_pool, +#if defined(__ANDROID__) + (void *)p_vkGetAndroidHardwareBufferPropertiesANDROID +#else + NULL +#endif + ); + log_line(line); +} + +static uintptr_t mapping_lookup_address(uintptr_t address) +{ +#if UINTPTR_MAX > 0xffffffffu + + + + return address & (uintptr_t)0x00ffffffffffffffULL; +#else + return address; +#endif +} + +static uintptr_t readable_mapping_end(uintptr_t address) +{ + address = mapping_lookup_address(address); + FILE *maps = fopen("/proc/self/maps", "r"); + char line[256]; + if (!maps) return address; + while (fgets(line, sizeof(line), maps)) { + unsigned long long start, end; + char permissions[5] = {0}; + if (sscanf(line, "%llx-%llx %4s", &start, &end, permissions) == 3 && + address >= (uintptr_t)start && address < (uintptr_t)end && + permissions[0] == 'r') { + fclose(maps); + return (uintptr_t)end; + } + } + fclose(maps); + return address; +} + +static int readable_mapping_contains(uintptr_t address, size_t size) +{ + uintptr_t lookup_address; + uintptr_t end; + if (!address || !size) return 0; + lookup_address = mapping_lookup_address(address); + end = readable_mapping_end(lookup_address); + return end > lookup_address && size <= end - lookup_address; +} + +static uint64_t unwrap_dispatchable(uint64_t client_handle) +{ + char trace[224]; + const struct wine_client_object *client = + (const struct wine_client_object *)(uintptr_t)client_handle; + if (!readable_mapping_contains((uintptr_t)client, sizeof(*client))) { + snprintf(trace, sizeof(trace), + "unwrap client=0x%llx is not a readable Wine object", + (unsigned long long)client_handle); + log_line(trace); + return 0; + } + if (!client->unix_handle) { + snprintf(trace, sizeof(trace), "unwrap client=0x%llx has no unix object", + (unsigned long long)client_handle); + log_line(trace); + return 0; + } + const uint64_t *object = + (const uint64_t *)(uintptr_t)client->unix_handle; + if (!readable_mapping_contains((uintptr_t)object, 2 * sizeof(*object))) { + snprintf(trace, sizeof(trace), + "unwrap client=0x%llx unix object=0x%llx is not readable", + (unsigned long long)client_handle, + (unsigned long long)client->unix_handle); + log_line(trace); + return 0; + } + if (object[1] == client_handle) { + snprintf(trace, sizeof(trace), + "unwrap client=0x%llx object=0x%llx Wine10+ host=0x%llx", + (unsigned long long)client_handle, + (unsigned long long)client->unix_handle, + (unsigned long long)object[0]); + log_line(trace); + return object[0]; + } + + + + + uintptr_t object_address = mapping_lookup_address((uintptr_t)object); + uintptr_t end = readable_mapping_end(object_address); + size_t words = end > object_address ? + (end - object_address) / sizeof(*object) : 0; + if (words > 8192) words = 8192; + for (size_t i = 0; i + 1 < words; ++i) { + if (object[i] == client_handle) { + snprintf(trace, sizeof(trace), + "unwrap client=0x%llx object=0x%llx Wine9 index=%zu host=0x%llx", + (unsigned long long)client_handle, + (unsigned long long)client->unix_handle, i, + (unsigned long long)object[i + 1]); + log_line(trace); + return object[i + 1]; + } + } + + + + const size_t wine_92_device_host_index = (4088 / sizeof(*object)) + 1; + if (words > wine_92_device_host_index && + object[wine_92_device_host_index - 2] && + object[wine_92_device_host_index]) { + snprintf(trace, sizeof(trace), + "unwrap client=0x%llx object=0x%llx Wine9.2 device host=0x%llx", + (unsigned long long)client_handle, + (unsigned long long)client->unix_handle, + (unsigned long long)object[wine_92_device_host_index]); + log_line(trace); + return object[wine_92_device_host_index]; + } + snprintf(trace, sizeof(trace), + "unwrap client=0x%llx object=0x%llx no match words=%zu " + "w510=0x%llx w511=0x%llx w512=0x%llx w513=0x%llx", + (unsigned long long)client_handle, + (unsigned long long)client->unix_handle, words, + (unsigned long long)(words > 510 ? object[510] : 0), + (unsigned long long)(words > 511 ? object[511] : 0), + (unsigned long long)(words > 512 ? object[512] : 0), + (unsigned long long)(words > 513 ? object[513] : 0)); + log_line(trace); + return words ? object[0] : 0; +} + +static int write_all(int fd, const void *data, size_t len) +{ + const char *p = data; + while (len) { + ssize_t n = send(fd, p, len, MSG_NOSIGNAL); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return 0; + p += n; + len -= (size_t)n; + } + return 1; +} + +static int read_line(int fd, char *line, size_t capacity) +{ + size_t used = 0; + if (!capacity) return 0; + while (used + 1 < capacity) { + char ch; + ssize_t n = recv(fd, &ch, 1, 0); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return 0; + if (ch == '\n') { + line[used] = 0; + return 1; + } + line[used++] = ch; + } + line[capacity - 1] = 0; + return 0; +} + +static int send_fd(int socket_fd, int fd) +{ + char payload = 'F'; + struct iovec iov = {&payload, 1}; + char control[CMSG_SPACE(sizeof(int))] = {0}; + struct msghdr msg = {0}; + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = control; + msg.msg_controllen = sizeof(control); + struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + cmsg->cmsg_len = CMSG_LEN(sizeof(int)); + memcpy(CMSG_DATA(cmsg), &fd, sizeof(fd)); + ssize_t n; + do n = sendmsg(socket_fd, &msg, MSG_NOSIGNAL); while (n < 0 && errno == EINTR); + return n == 1; +} + +static int recv_fd(int socket_fd) +{ + char payload; + struct iovec iov = {&payload, 1}; + char control[CMSG_SPACE(sizeof(int))] = {0}; + struct msghdr msg = {0}; + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = control; + msg.msg_controllen = sizeof(control); + ssize_t n; + do n = recvmsg(socket_fd, &msg, 0); while (n < 0 && errno == EINTR); + if (n <= 0) return -1; + for (struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); cmsg; + cmsg = CMSG_NXTHDR(&msg, cmsg)) { + if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS && + cmsg->cmsg_len == CMSG_LEN(sizeof(int))) { + int fd; + memcpy(&fd, CMSG_DATA(cmsg), sizeof(fd)); + return fd; + } + } + return -1; +} + +static void close_transport(void) +{ + if (transport_fd >= 0) close(transport_fd); + transport_fd = -1; +} + +static int ensure_transport(void) +{ + if (transport_fd >= 0) return 1; + const char *path = getenv("GAMENATIVE_XR_SOCKET"); + if (!path || !*path) path = "@gamenative-xr"; + + int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) return 0; + struct sockaddr_un address; + memset(&address, 0, sizeof(address)); + address.sun_family = AF_UNIX; + socklen_t length; + if (path[0] == '@') { + size_t size = strlen(path + 1); + if (size > sizeof(address.sun_path) - 2) size = sizeof(address.sun_path) - 2; + address.sun_path[0] = 0; + memcpy(address.sun_path + 1, path + 1, size); + length = (socklen_t)(offsetof(struct sockaddr_un, sun_path) + 1 + size); + } else { + strncpy(address.sun_path, path, sizeof(address.sun_path) - 1); + length = sizeof(address); + } + if (connect(fd, (struct sockaddr *)&address, length) < 0) { + close(fd); + return 0; + } + if (!write_all(fd, "HELLO producer=wine-unixlib version=2\n", 38)) { + close(fd); + return 0; + } + char response[64]; + if (!read_line(fd, response, sizeof(response)) || strncmp(response, "OK", 2)) { + close(fd); + return 0; + } + transport_fd = fd; + return 1; +} + +static int transact_line(const char *line, char *response, size_t response_size) +{ + if (!ensure_transport() || !write_all(transport_fd, line, strlen(line)) || + !read_line(transport_fd, response, response_size)) { + close_transport(); + return 0; + } + return 1; +} + +static void load_vulkan_functions(void) +{ + if (!vulkan_so) vulkan_so = dlopen("libvulkan.so.1", RTLD_NOW | RTLD_LOCAL); + if (!vulkan_so) return; + p_vkGetDeviceProcAddr = dlsym(vulkan_so, "vkGetDeviceProcAddr"); + p_vkGetPhysicalDeviceMemoryProperties = + dlsym(vulkan_so, "vkGetPhysicalDeviceMemoryProperties"); + p_vkGetPhysicalDeviceFormatProperties2 = + dlsym(vulkan_so, "vkGetPhysicalDeviceFormatProperties2"); + if (!p_vkGetDeviceProcAddr || !device) return; +#define LOAD_DEVICE(name) p_##name = (PFN_##name)p_vkGetDeviceProcAddr(device, #name) + LOAD_DEVICE(vkCreateImage); + LOAD_DEVICE(vkDestroyImage); + LOAD_DEVICE(vkGetImageMemoryRequirements); + LOAD_DEVICE(vkAllocateMemory); + LOAD_DEVICE(vkFreeMemory); + LOAD_DEVICE(vkBindImageMemory); + LOAD_DEVICE(vkGetImageSubresourceLayout); + LOAD_DEVICE(vkGetImageDrmFormatModifierPropertiesEXT); + LOAD_DEVICE(vkGetMemoryFdKHR); + LOAD_DEVICE(vkCreateFence); + LOAD_DEVICE(vkDestroyFence); + LOAD_DEVICE(vkWaitForFences); + LOAD_DEVICE(vkGetFenceFdKHR); + LOAD_DEVICE(vkQueueSubmit); + LOAD_DEVICE(vkQueueWaitIdle); + LOAD_DEVICE(vkCreateCommandPool); + LOAD_DEVICE(vkDestroyCommandPool); + LOAD_DEVICE(vkAllocateCommandBuffers); + LOAD_DEVICE(vkFreeCommandBuffers); + LOAD_DEVICE(vkResetCommandBuffer); + LOAD_DEVICE(vkBeginCommandBuffer); + LOAD_DEVICE(vkEndCommandBuffer); + LOAD_DEVICE(vkCmdPipelineBarrier); + LOAD_DEVICE(vkCmdCopyImage); +#if defined(__ANDROID__) + LOAD_DEVICE(vkGetAndroidHardwareBufferPropertiesANDROID); +#endif +#undef LOAD_DEVICE + + + +#define LOAD_HOST_TRAMPOLINE(name) \ + if (!p_##name) p_##name = (PFN_##name)dlsym(vulkan_so, #name) + LOAD_HOST_TRAMPOLINE(vkGetImageDrmFormatModifierPropertiesEXT); + LOAD_HOST_TRAMPOLINE(vkGetMemoryFdKHR); + LOAD_HOST_TRAMPOLINE(vkGetFenceFdKHR); +#if defined(__ANDROID__) + LOAD_HOST_TRAMPOLINE(vkGetAndroidHardwareBufferPropertiesANDROID); +#endif +#undef LOAD_HOST_TRAMPOLINE +} + +static int find_memory_type(uint32_t bits, VkMemoryPropertyFlags desired) +{ + VkPhysicalDeviceMemoryProperties props; + p_vkGetPhysicalDeviceMemoryProperties(physical_device, &props); + for (uint32_t i = 0; i < props.memoryTypeCount; ++i) { + if ((bits & (1u << i)) && + (props.memoryTypes[i].propertyFlags & desired) == desired) return (int)i; + } + for (uint32_t i = 0; i < props.memoryTypeCount; ++i) + if (bits & (1u << i)) return (int)i; + return -1; +} + +static uint32_t format_to_fourcc(VkFormat format) +{ + switch (format) { + case VK_FORMAT_R8G8B8A8_UNORM: + case VK_FORMAT_R8G8B8A8_SRGB: + return DRM_FORMAT_ABGR8888; + case VK_FORMAT_B8G8R8A8_UNORM: + case VK_FORMAT_B8G8R8A8_SRGB: + return DRM_FORMAT_ARGB8888; + default: + return 0; + } +} + +static void log_vk_result(const char *operation, VkResult result) +{ + char line[160]; + snprintf(line, sizeof(line), "%s failed with VkResult %d", operation, result); + log_line(line); +} + +#if defined(__ANDROID__) +static int ahb_transport_probe(void) +{ + static int cached; + if (cached) return cached > 0; + AHardwareBuffer_Desc descriptor = { + .width = 16, + .height = 16, + .layers = 1, + .format = AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM, + .usage = AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE | + AHARDWAREBUFFER_USAGE_GPU_COLOR_OUTPUT + }; + AHardwareBuffer *buffer = NULL; + if (p_vkGetAndroidHardwareBufferPropertiesANDROID && command_pool && + p_vkCmdCopyImage && AHardwareBuffer_allocate(&descriptor, &buffer) == 0 && buffer) { + AHardwareBuffer_release(buffer); + cached = 1; + log_line("AHardwareBuffer transport available; using optimal-tiling render targets"); + } else { + cached = -1; + log_line("AHardwareBuffer transport unavailable; render targets stay linear/exportable"); + } + return cached > 0; +} +#endif + +static int create_image(struct gn_swapchain *swapchain, struct gn_image *out, + uint32_t array_size, uint32_t mip_count, uint32_t sample_count, + int optimal) +{ + if (optimal) { + VkImageCreateInfo optimal_info = { + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, + .imageType = VK_IMAGE_TYPE_2D, + .format = swapchain->format, + .extent = {swapchain->width, swapchain->height, 1}, + .mipLevels = mip_count ? mip_count : 1, + .arrayLayers = array_size ? array_size : 1, + .samples = sample_count == 2 ? VK_SAMPLE_COUNT_2_BIT : + sample_count == 4 ? VK_SAMPLE_COUNT_4_BIT : + sample_count == 8 ? VK_SAMPLE_COUNT_8_BIT : VK_SAMPLE_COUNT_1_BIT, + .tiling = VK_IMAGE_TILING_OPTIMAL, + .usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, + .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED + }; + VkResult optimal_result = p_vkCreateImage(device, &optimal_info, NULL, &out->image); + if (optimal_result != VK_SUCCESS) { + log_vk_result("vkCreateImage(optimal)", optimal_result); + goto optimal_fail; + } + VkMemoryRequirements optimal_requirements; + p_vkGetImageMemoryRequirements(device, out->image, &optimal_requirements); + int optimal_type = find_memory_type( + optimal_requirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + if (optimal_type < 0) + optimal_type = find_memory_type(optimal_requirements.memoryTypeBits, 0); + if (optimal_type < 0) { + log_line("No compatible memory type for optimal image"); + goto optimal_fail; + } + VkMemoryDedicatedAllocateInfo optimal_dedicated = { + .sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO, + .image = out->image + }; + VkMemoryAllocateInfo optimal_allocate = { + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .pNext = &optimal_dedicated, + .allocationSize = optimal_requirements.size, + .memoryTypeIndex = (uint32_t)optimal_type + }; + optimal_result = p_vkAllocateMemory(device, &optimal_allocate, NULL, &out->memory); + if (optimal_result != VK_SUCCESS) { + log_vk_result("vkAllocateMemory(optimal)", optimal_result); + goto optimal_fail; + } + optimal_result = p_vkBindImageMemory(device, out->image, out->memory, 0); + if (optimal_result != VK_SUCCESS) { + log_vk_result("vkBindImageMemory(optimal)", optimal_result); + goto optimal_fail; + } + out->dma_buf_fd = -1; + out->plane_count = 0; + out->modifier = 0; + return 1; + optimal_fail: + if (out->memory && p_vkFreeMemory) p_vkFreeMemory(device, out->memory, NULL); + out->memory = VK_NULL_HANDLE; + if (out->image && p_vkDestroyImage) p_vkDestroyImage(device, out->image, NULL); + out->image = VK_NULL_HANDLE; + return 0; + } + + VkExternalMemoryImageCreateInfo external = { + .sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO, + .handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT + }; + VkImageCreateInfo info = { + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, + .pNext = &external, + .imageType = VK_IMAGE_TYPE_2D, + .format = swapchain->format, + .extent = {swapchain->width, swapchain->height, 1}, + .mipLevels = mip_count ? mip_count : 1, + .arrayLayers = array_size ? array_size : 1, + .samples = sample_count == 2 ? VK_SAMPLE_COUNT_2_BIT : + sample_count == 4 ? VK_SAMPLE_COUNT_4_BIT : + sample_count == 8 ? VK_SAMPLE_COUNT_8_BIT : VK_SAMPLE_COUNT_1_BIT, + .tiling = VK_IMAGE_TILING_LINEAR, + .usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, + .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED + }; + + VkDrmFormatModifierPropertiesListEXT modifier_list = { + .sType = VK_STRUCTURE_TYPE_DRM_FORMAT_MODIFIER_PROPERTIES_LIST_EXT + }; + VkFormatProperties2 format_props = { + .sType = VK_STRUCTURE_TYPE_FORMAT_PROPERTIES_2, + .pNext = &modifier_list + }; + VkDrmFormatModifierPropertiesEXT *modifiers = NULL; + VkImageDrmFormatModifierListCreateInfoEXT modifier_info = { + .sType = VK_STRUCTURE_TYPE_IMAGE_DRM_FORMAT_MODIFIER_LIST_CREATE_INFO_EXT + }; + uint64_t chosen_modifier = 0; + uint32_t chosen_plane_count = 1; + int has_chosen_modifier = 0; + if (p_vkGetPhysicalDeviceFormatProperties2) { + p_vkGetPhysicalDeviceFormatProperties2(physical_device, swapchain->format, &format_props); + if (modifier_list.drmFormatModifierCount) { + modifiers = calloc(modifier_list.drmFormatModifierCount, sizeof(*modifiers)); + modifier_list.pDrmFormatModifierProperties = modifiers; + p_vkGetPhysicalDeviceFormatProperties2(physical_device, swapchain->format, &format_props); + for (uint32_t i = 0; i < modifier_list.drmFormatModifierCount; ++i) { + VkFormatFeatureFlags features = modifiers[i].drmFormatModifierTilingFeatures; + if ((features & VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT) && + (features & VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT) && + modifiers[i].drmFormatModifierPlaneCount >= 1 && + modifiers[i].drmFormatModifierPlaneCount <= 4) { + + + + if (!has_chosen_modifier || modifiers[i].drmFormatModifier == 0) { + chosen_modifier = modifiers[i].drmFormatModifier; + chosen_plane_count = + modifiers[i].drmFormatModifierPlaneCount; + has_chosen_modifier = 1; + } + if (modifiers[i].drmFormatModifier == 0) break; + } + } + } + } + if (has_chosen_modifier) { + modifier_info.drmFormatModifierCount = 1; + modifier_info.pDrmFormatModifiers = &chosen_modifier; + external.pNext = &modifier_info; + info.tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT; + } + + VkResult result = p_vkCreateImage(device, &info, NULL, &out->image); + free(modifiers); + if (result != VK_SUCCESS && has_chosen_modifier) { + chosen_modifier = 0; + chosen_plane_count = 1; + has_chosen_modifier = 0; + external.pNext = NULL; + info.tiling = VK_IMAGE_TILING_LINEAR; + result = p_vkCreateImage(device, &info, NULL, &out->image); + } + if (result != VK_SUCCESS) { + log_vk_result("vkCreateImage(dma-buf)", result); + goto fail; + } + + VkMemoryRequirements requirements; + p_vkGetImageMemoryRequirements(device, out->image, &requirements); + + + + int memory_type = find_memory_type( + requirements.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (memory_type < 0) { + log_line("No compatible memory type for dma-buf image"); + goto fail; + } + + VkMemoryDedicatedAllocateInfo dedicated = { + .sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO, + .image = out->image + }; + VkExportMemoryAllocateInfo export_info = { + .sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO, + .pNext = &dedicated, + .handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT + }; + VkMemoryAllocateInfo allocate_info = { + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .pNext = &export_info, + .allocationSize = requirements.size, + .memoryTypeIndex = (uint32_t)memory_type + }; + result = p_vkAllocateMemory(device, &allocate_info, NULL, &out->memory); + if (result != VK_SUCCESS) { + log_vk_result("vkAllocateMemory(dma-buf)", result); + goto fail; + } + result = p_vkBindImageMemory(device, out->image, out->memory, 0); + if (result != VK_SUCCESS) { + log_vk_result("vkBindImageMemory(dma-buf)", result); + goto fail; + } + + VkMemoryGetFdInfoKHR fd_info = { + .sType = VK_STRUCTURE_TYPE_MEMORY_GET_FD_INFO_KHR, + .memory = out->memory, + .handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT + }; + if (!p_vkGetMemoryFdKHR) { + log_line("vkGetMemoryFdKHR is unavailable"); + goto fail; + } + result = p_vkGetMemoryFdKHR(device, &fd_info, &out->dma_buf_fd); + if (result != VK_SUCCESS) { + log_vk_result("vkGetMemoryFdKHR(dma-buf)", result); + goto fail; + } + + out->plane_count = chosen_plane_count; + for (uint32_t plane = 0; plane < out->plane_count; ++plane) { + VkImageSubresource subresource = { + .aspectMask = out->plane_count == 1 + ? VK_IMAGE_ASPECT_COLOR_BIT + : (VkImageAspectFlags) + (VK_IMAGE_ASPECT_MEMORY_PLANE_0_BIT_EXT << plane), + .mipLevel = 0, + .arrayLayer = 0 + }; + VkSubresourceLayout layout; + p_vkGetImageSubresourceLayout( + device, out->image, &subresource, &layout); + out->strides[plane] = (uint32_t)layout.rowPitch; + out->offsets[plane] = (uint32_t)layout.offset; + } + out->modifier = has_chosen_modifier ? chosen_modifier : 0; + if (has_chosen_modifier && p_vkGetImageDrmFormatModifierPropertiesEXT) { + VkImageDrmFormatModifierPropertiesEXT props = { + .sType = VK_STRUCTURE_TYPE_IMAGE_DRM_FORMAT_MODIFIER_PROPERTIES_EXT + }; + if (p_vkGetImageDrmFormatModifierPropertiesEXT(device, out->image, &props) == VK_SUCCESS) + out->modifier = props.drmFormatModifier; + } + { + char trace[320]; + snprintf(trace, sizeof(trace), + "dma-buf image format=%d size=%ux%u layers=%u samples=%u tiling=%s " + "modifier=0x%llx planes=%u stride0=%u offset0=%u", + (int)swapchain->format, swapchain->width, swapchain->height, + array_size ? array_size : 1, + sample_count ? sample_count : 1, + has_chosen_modifier ? "modifier" : "linear", + (unsigned long long)out->modifier, out->plane_count, + out->strides[0], out->offsets[0]); + log_line(trace); + } + return 1; + +fail: + if (out->dma_buf_fd >= 0) close(out->dma_buf_fd); + out->dma_buf_fd = -1; + if (out->memory && p_vkFreeMemory) p_vkFreeMemory(device, out->memory, NULL); + out->memory = VK_NULL_HANDLE; + if (out->image && p_vkDestroyImage) p_vkDestroyImage(device, out->image, NULL); + out->image = VK_NULL_HANDLE; + return 0; +} + +#if defined(__ANDROID__) +/* Relay: a second Vulkan device (turnip, or the system driver) used when the game's + * host device was created without the Android hardware-buffer extension. The game's + * linear dma-buf render image is imported here and GPU-copied into an AHardwareBuffer + * so the compositor gets the zero-copy path instead of a per-frame CPU upload. */ +static void *relay_lib; +static VkInstance relay_instance; +static VkPhysicalDevice relay_phys; +static VkDevice relay_device; +static VkQueue relay_queue; +static uint32_t relay_queue_family; +static VkCommandPool relay_pool; +static int relay_state; /* 0 untried, 1 ready, -1 unavailable */ +static int relay_can_export_fence; + +static PFN_vkGetDeviceProcAddr r_vkGetDeviceProcAddr; +static PFN_vkCreateImage r_vkCreateImage; +static PFN_vkDestroyImage r_vkDestroyImage; +static PFN_vkGetImageMemoryRequirements r_vkGetImageMemoryRequirements; +static PFN_vkAllocateMemory r_vkAllocateMemory; +static PFN_vkFreeMemory r_vkFreeMemory; +static PFN_vkBindImageMemory r_vkBindImageMemory; +static PFN_vkCreateCommandPool r_vkCreateCommandPool; +static PFN_vkAllocateCommandBuffers r_vkAllocateCommandBuffers; +static PFN_vkFreeCommandBuffers r_vkFreeCommandBuffers; +static PFN_vkResetCommandBuffer r_vkResetCommandBuffer; +static PFN_vkBeginCommandBuffer r_vkBeginCommandBuffer; +static PFN_vkEndCommandBuffer r_vkEndCommandBuffer; +static PFN_vkCmdPipelineBarrier r_vkCmdPipelineBarrier; +static PFN_vkCmdCopyImage r_vkCmdCopyImage; +static PFN_vkQueueSubmit r_vkQueueSubmit; +static PFN_vkQueueWaitIdle r_vkQueueWaitIdle; +static PFN_vkCreateFence r_vkCreateFence; +static PFN_vkDestroyFence r_vkDestroyFence; +static PFN_vkGetFenceFdKHR r_vkGetFenceFdKHR; +static PFN_vkGetAndroidHardwareBufferPropertiesANDROID r_vkGetAndroidHardwareBufferPropertiesANDROID; +static PFN_vkGetMemoryFdPropertiesKHR r_vkGetMemoryFdPropertiesKHR; +static PFN_vkGetPhysicalDeviceMemoryProperties r_vkGetPhysicalDeviceMemoryProperties; + +static int relay_find_memory_type(uint32_t type_bits, VkMemoryPropertyFlags wanted) +{ + VkPhysicalDeviceMemoryProperties properties; + r_vkGetPhysicalDeviceMemoryProperties(relay_phys, &properties); + for (uint32_t i = 0; i < properties.memoryTypeCount; ++i) { + if ((type_bits & (1u << i)) && + (properties.memoryTypes[i].propertyFlags & wanted) == wanted) + return (int)i; + } + return -1; +} + +/* Minimal Android HAL vulkan module interface: adrenotools-packaged drivers export only + * HMI (HAL_MODULE_INFO_SYM); the loader entry points come from opening device "vk0". */ +struct gn_hw_module; +struct gn_hw_device; +struct gn_hw_methods { + int (*open)(const struct gn_hw_module *, const char *, struct gn_hw_device **); +}; +struct gn_hw_module { + uint32_t tag; + uint16_t module_api_version; + uint16_t hal_api_version; + const char *id; + const char *name; + const char *author; + struct gn_hw_methods *methods; + void *dso; + uint64_t reserved[24]; +}; +struct gn_hw_device { + uint32_t tag; + uint32_t version; + struct gn_hw_module *module; + uint64_t reserved[12]; + int (*close)(struct gn_hw_device *); +}; +struct gn_hwvulkan_device { + struct gn_hw_device common; + PFN_vkEnumerateInstanceExtensionProperties enumerate_instance_extensions; + PFN_vkCreateInstance create_instance; + PFN_vkGetInstanceProcAddr get_instance_proc_addr; +}; + +static PFN_vkCreateInstance relay_hal_create_instance; + +static PFN_vkGetInstanceProcAddr relay_open_hal(void *lib, const char *path) +{ + struct gn_hw_module *module = (struct gn_hw_module *)dlsym(lib, "HMI"); + struct gn_hw_device *device_handle = NULL; + if (!module || !module->methods || !module->methods->open) return NULL; + if (module->methods->open(module, "vk0", &device_handle) != 0 || !device_handle) { + char trace[640]; + snprintf(trace, sizeof(trace), "relay: HAL open failed for %s", path); + log_line(trace); + return NULL; + } + { + struct gn_hwvulkan_device *vulkan_device = + (struct gn_hwvulkan_device *)device_handle; + if (!vulkan_device->get_instance_proc_addr) return NULL; + relay_hal_create_instance = vulkan_device->create_instance; + char trace[640]; + snprintf(trace, sizeof(trace), "relay: opened %s via HAL module", path); + log_line(trace); + return vulkan_device->get_instance_proc_addr; + } +} + +static int relay_init(void) +{ + if (relay_state) return relay_state > 0; + relay_state = -1; + + /* The Wine rootfs shadows the bare sonames with a Linux-platform Mesa that has no + * Android AHB support, so the useful candidates need absolute paths: the system + * loader, and the adrenotools turnip the game itself renders with. */ + static char adrenotools_path[512]; + const char *candidates[5]; + int candidate_count = 0; + candidates[candidate_count++] = "/system/lib64/libvulkan.so"; + { + const char *dir = getenv("ADRENOTOOLS_DRIVER_PATH"); + const char *name = getenv("ADRENOTOOLS_DRIVER_NAME"); + if (dir && name && strlen(dir) + strlen(name) + 2 < sizeof(adrenotools_path)) { + snprintf(adrenotools_path, sizeof(adrenotools_path), "%s%s", dir, name); + candidates[candidate_count++] = adrenotools_path; + } else { + log_line("relay: adrenotools driver env not set in this process"); + } + } + candidates[candidate_count++] = "libvulkan_freedreno.so"; + candidates[candidate_count] = NULL; + for (int i = 0; i < candidate_count; ++i) { + char trace[640]; + snprintf(trace, sizeof(trace), "relay: candidate %d: %s", i, candidates[i]); + log_line(trace); + } + PFN_vkGetInstanceProcAddr gipa = NULL; + PFN_vkCreateInstance create_instance = NULL; + int candidate = 0; +next_candidate: + if (relay_instance) { + PFN_vkDestroyInstance destroy_instance = gipa ? + (PFN_vkDestroyInstance)gipa(relay_instance, "vkDestroyInstance") : NULL; + if (destroy_instance) destroy_instance(relay_instance, NULL); + relay_instance = VK_NULL_HANDLE; + } + if (relay_lib) { dlclose(relay_lib); relay_lib = NULL; } + gipa = NULL; + for (; candidates[candidate] && !gipa; ++candidate) { + relay_lib = dlopen(candidates[candidate], RTLD_NOW | RTLD_LOCAL); + if (!relay_lib) { + char trace[640]; + snprintf(trace, sizeof(trace), "relay: dlopen(%s) failed: %s", + candidates[candidate], dlerror()); + log_line(trace); + continue; + } + relay_hal_create_instance = NULL; + gipa = (PFN_vkGetInstanceProcAddr)dlsym(relay_lib, "vk_icdGetInstanceProcAddr"); + if (!gipa) gipa = (PFN_vkGetInstanceProcAddr)dlsym(relay_lib, "vkGetInstanceProcAddr"); + if (!gipa) gipa = relay_open_hal(relay_lib, candidates[candidate]); + if (!gipa) { + char trace[640]; + snprintf(trace, sizeof(trace), "relay: %s has no usable entry point", + candidates[candidate]); + log_line(trace); + dlclose(relay_lib); + relay_lib = NULL; + continue; + } + { + char trace[128]; + snprintf(trace, sizeof(trace), "relay: trying %s", candidates[candidate]); + log_line(trace); + } + } + if (!gipa) { log_line("relay: no usable Vulkan driver"); return 0; } + + create_instance = relay_hal_create_instance; + if (!create_instance) create_instance = (PFN_vkCreateInstance)gipa(NULL, "vkCreateInstance"); + if (!create_instance) goto next_candidate; + VkApplicationInfo app = { + .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, + .pApplicationName = "gamenative-xr-relay", + .apiVersion = VK_API_VERSION_1_1 + }; + VkInstanceCreateInfo instance_info = { + .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, + .pApplicationInfo = &app + }; + if (create_instance(&instance_info, NULL, &relay_instance) != VK_SUCCESS) { + log_line("relay: vkCreateInstance failed"); + goto next_candidate; + } + +#define RELAY_ILOAD(name) PFN_##name i_##name = (PFN_##name)gipa(relay_instance, #name) + RELAY_ILOAD(vkEnumeratePhysicalDevices); + RELAY_ILOAD(vkGetPhysicalDeviceQueueFamilyProperties); + RELAY_ILOAD(vkEnumerateDeviceExtensionProperties); + RELAY_ILOAD(vkCreateDevice); + RELAY_ILOAD(vkGetDeviceProcAddr); + RELAY_ILOAD(vkGetPhysicalDeviceMemoryProperties); +#undef RELAY_ILOAD + if (!i_vkEnumeratePhysicalDevices || !i_vkGetPhysicalDeviceQueueFamilyProperties || + !i_vkEnumerateDeviceExtensionProperties || !i_vkCreateDevice || + !i_vkGetDeviceProcAddr || !i_vkGetPhysicalDeviceMemoryProperties) + goto next_candidate; + r_vkGetPhysicalDeviceMemoryProperties = i_vkGetPhysicalDeviceMemoryProperties; + + uint32_t count = 1; + if (i_vkEnumeratePhysicalDevices(relay_instance, &count, &relay_phys) < 0 || !count) { + log_line("relay: no physical device"); + goto next_candidate; + } + + VkQueueFamilyProperties families[8]; + uint32_t family_count = 8; + i_vkGetPhysicalDeviceQueueFamilyProperties(relay_phys, &family_count, families); + relay_queue_family = 0; + for (uint32_t i = 0; i < family_count; ++i) { + if (families[i].queueFlags & (VK_QUEUE_GRAPHICS_BIT | VK_QUEUE_TRANSFER_BIT)) { + relay_queue_family = i; + break; + } + } + + static const char *wanted[] = { + "VK_ANDROID_external_memory_android_hardware_buffer", + "VK_EXT_external_memory_dma_buf", + "VK_KHR_external_memory_fd", + "VK_EXT_queue_family_foreign", + "VK_KHR_sampler_ycbcr_conversion", + "VK_KHR_external_fence_fd", + "VK_KHR_dedicated_allocation", + "VK_KHR_get_memory_requirements2", + "VK_KHR_bind_memory2", + "VK_KHR_maintenance1", + "VK_KHR_external_memory" + }; + VkExtensionProperties supported[256]; + uint32_t supported_count = 256; + if (i_vkEnumerateDeviceExtensionProperties(relay_phys, NULL, &supported_count, supported) < 0) + goto next_candidate; + const char *enabled[16]; + uint32_t enabled_count = 0; + int have_ahb = 0, have_dmabuf = 0, have_fd = 0; + relay_can_export_fence = 0; + for (uint32_t w = 0; w < sizeof(wanted) / sizeof(wanted[0]); ++w) { + for (uint32_t i = 0; i < supported_count; ++i) { + if (strcmp(supported[i].extensionName, wanted[w]) == 0) { + enabled[enabled_count++] = wanted[w]; + if (w == 0) have_ahb = 1; + if (w == 1) have_dmabuf = 1; + if (w == 2) have_fd = 1; + if (w == 5) relay_can_export_fence = 1; + break; + } + } + } + if (!have_ahb || !have_dmabuf || !have_fd) { + char trace[128]; + snprintf(trace, sizeof(trace), "relay: missing extensions ahb=%d dmabuf=%d fd=%d", + have_ahb, have_dmabuf, have_fd); + log_line(trace); + goto next_candidate; + } + + float priority = 1.0f; + VkDeviceQueueCreateInfo queue_info = { + .sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO, + .queueFamilyIndex = relay_queue_family, + .queueCount = 1, + .pQueuePriorities = &priority + }; + VkDeviceCreateInfo device_info = { + .sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO, + .queueCreateInfoCount = 1, + .pQueueCreateInfos = &queue_info, + .enabledExtensionCount = enabled_count, + .ppEnabledExtensionNames = enabled + }; + if (i_vkCreateDevice(relay_phys, &device_info, NULL, &relay_device) != VK_SUCCESS) { + log_line("relay: vkCreateDevice failed"); + goto next_candidate; + } + r_vkGetDeviceProcAddr = i_vkGetDeviceProcAddr; + +#define RELAY_DLOAD(name) \ + r_##name = (PFN_##name)r_vkGetDeviceProcAddr(relay_device, #name); \ + if (!r_##name) { log_line("relay: missing " #name); return 0; } + RELAY_DLOAD(vkCreateImage); + RELAY_DLOAD(vkDestroyImage); + RELAY_DLOAD(vkGetImageMemoryRequirements); + RELAY_DLOAD(vkAllocateMemory); + RELAY_DLOAD(vkFreeMemory); + RELAY_DLOAD(vkBindImageMemory); + RELAY_DLOAD(vkCreateCommandPool); + RELAY_DLOAD(vkAllocateCommandBuffers); + RELAY_DLOAD(vkFreeCommandBuffers); + RELAY_DLOAD(vkResetCommandBuffer); + RELAY_DLOAD(vkBeginCommandBuffer); + RELAY_DLOAD(vkEndCommandBuffer); + RELAY_DLOAD(vkCmdPipelineBarrier); + RELAY_DLOAD(vkCmdCopyImage); + RELAY_DLOAD(vkQueueSubmit); + RELAY_DLOAD(vkQueueWaitIdle); + RELAY_DLOAD(vkCreateFence); + RELAY_DLOAD(vkDestroyFence); + RELAY_DLOAD(vkGetAndroidHardwareBufferPropertiesANDROID); + RELAY_DLOAD(vkGetMemoryFdPropertiesKHR); +#undef RELAY_DLOAD + r_vkGetFenceFdKHR = (PFN_vkGetFenceFdKHR)r_vkGetDeviceProcAddr(relay_device, "vkGetFenceFdKHR"); + if (!r_vkGetFenceFdKHR) relay_can_export_fence = 0; + + PFN_vkGetDeviceQueue get_queue = + (PFN_vkGetDeviceQueue)r_vkGetDeviceProcAddr(relay_device, "vkGetDeviceQueue"); + if (!get_queue) return 0; + get_queue(relay_device, relay_queue_family, 0, &relay_queue); + + VkCommandPoolCreateInfo pool_info = { + .sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, + .flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, + .queueFamilyIndex = relay_queue_family + }; + if (r_vkCreateCommandPool(relay_device, &pool_info, NULL, &relay_pool) != VK_SUCCESS) { + log_line("relay: command pool failed"); + return 0; + } + relay_state = 1; + log_line("relay: ready — GPU dma-buf to AHardwareBuffer copies enabled"); + return 1; +} + +static int relay_create_source(struct gn_swapchain *swapchain, struct gn_image *image) +{ + if (image->relay_source_image) return 1; + if (image->dma_buf_fd < 0) return 0; + int fd = dup(image->dma_buf_fd); + if (fd < 0) return 0; + + VkMemoryFdPropertiesKHR fd_properties = { + .sType = VK_STRUCTURE_TYPE_MEMORY_FD_PROPERTIES_KHR + }; + if (r_vkGetMemoryFdPropertiesKHR( + relay_device, VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT, + fd, &fd_properties) != VK_SUCCESS) { + log_line("relay: vkGetMemoryFdPropertiesKHR failed"); + close(fd); + return 0; + } + + VkExternalMemoryImageCreateInfo external = { + .sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO, + .handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT + }; + VkImageCreateInfo image_info = { + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, + .pNext = &external, + .imageType = VK_IMAGE_TYPE_2D, + .format = swapchain->format, + .extent = {swapchain->width, swapchain->height, 1}, + .mipLevels = 1, + .arrayLayers = swapchain->array_size ? swapchain->array_size : 1, + .samples = VK_SAMPLE_COUNT_1_BIT, + .tiling = VK_IMAGE_TILING_LINEAR, + .usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT, + .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED + }; + if (r_vkCreateImage(relay_device, &image_info, NULL, &image->relay_source_image) != VK_SUCCESS) { + log_line("relay: source image create failed"); + close(fd); + return 0; + } + VkMemoryRequirements requirements; + r_vkGetImageMemoryRequirements(relay_device, image->relay_source_image, &requirements); + int memory_type = relay_find_memory_type( + requirements.memoryTypeBits & fd_properties.memoryTypeBits, 0); + if (memory_type < 0) { + log_line("relay: no memory type for source"); + goto fail; + } + VkImportMemoryFdInfoKHR import_info = { + .sType = VK_STRUCTURE_TYPE_IMPORT_MEMORY_FD_INFO_KHR, + .handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT, + .fd = fd + }; + VkMemoryDedicatedAllocateInfo dedicated = { + .sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO, + .pNext = &import_info, + .image = image->relay_source_image + }; + VkMemoryAllocateInfo allocate_info = { + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .pNext = &dedicated, + .allocationSize = requirements.size, + .memoryTypeIndex = (uint32_t)memory_type + }; + if (r_vkAllocateMemory(relay_device, &allocate_info, NULL, &image->relay_source_memory) != VK_SUCCESS) { + log_line("relay: source import failed"); + goto fail; + } + fd = -1; /* consumed by the import */ + if (r_vkBindImageMemory(relay_device, image->relay_source_image, + image->relay_source_memory, 0) != VK_SUCCESS) { + log_line("relay: source bind failed"); + goto fail; + } + return 1; +fail: + if (fd >= 0) close(fd); + if (image->relay_source_memory) { + r_vkFreeMemory(relay_device, image->relay_source_memory, NULL); + image->relay_source_memory = VK_NULL_HANDLE; + } + if (image->relay_source_image) { + r_vkDestroyImage(relay_device, image->relay_source_image, NULL); + image->relay_source_image = VK_NULL_HANDLE; + } + return 0; +} + +static int relay_create_transport( + const struct gn_swapchain *swapchain, struct gn_transport_image *transport) +{ + AHardwareBuffer_Desc descriptor = { + .width = swapchain->width, + .height = swapchain->height, + .layers = 1, + .format = AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM, + .usage = AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE | + AHARDWAREBUFFER_USAGE_GPU_COLOR_OUTPUT + }; + AHardwareBuffer *buffer = NULL; + if (AHardwareBuffer_allocate(&descriptor, &buffer) != 0 || !buffer) { + log_line("relay: AHardwareBuffer allocation failed"); + return 0; + } + transport->hardware_buffer = buffer; + transport->relay = 1; + + VkAndroidHardwareBufferPropertiesANDROID properties = { + .sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_PROPERTIES_ANDROID + }; + if (r_vkGetAndroidHardwareBufferPropertiesANDROID(relay_device, buffer, &properties) != VK_SUCCESS) { + log_line("relay: AHB properties failed"); + return 0; + } + VkExternalMemoryImageCreateInfo external = { + .sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO, + .handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_ANDROID_HARDWARE_BUFFER_BIT_ANDROID + }; + VkImageCreateInfo image_info = { + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, + .pNext = &external, + .imageType = VK_IMAGE_TYPE_2D, + .format = VK_FORMAT_R8G8B8A8_UNORM, + .extent = {swapchain->width, swapchain->height, 1}, + .mipLevels = 1, + .arrayLayers = 1, + .samples = VK_SAMPLE_COUNT_1_BIT, + .tiling = VK_IMAGE_TILING_OPTIMAL, + .usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED + }; + if (r_vkCreateImage(relay_device, &image_info, NULL, &transport->image) != VK_SUCCESS) { + log_line("relay: transport image failed"); + return 0; + } + VkMemoryRequirements requirements; + r_vkGetImageMemoryRequirements(relay_device, transport->image, &requirements); + int memory_type = relay_find_memory_type( + requirements.memoryTypeBits & properties.memoryTypeBits, 0); + if (memory_type < 0) { + log_line("relay: no memory type for transport"); + return 0; + } + VkImportAndroidHardwareBufferInfoANDROID import_info = { + .sType = VK_STRUCTURE_TYPE_IMPORT_ANDROID_HARDWARE_BUFFER_INFO_ANDROID, + .buffer = buffer + }; + VkMemoryDedicatedAllocateInfo dedicated = { + .sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO, + .pNext = &import_info, + .image = transport->image + }; + VkMemoryAllocateInfo allocate_info = { + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .pNext = &dedicated, + .allocationSize = properties.allocationSize, + .memoryTypeIndex = (uint32_t)memory_type + }; + if (r_vkAllocateMemory(relay_device, &allocate_info, NULL, &transport->memory) != VK_SUCCESS || + r_vkBindImageMemory(relay_device, transport->image, transport->memory, 0) != VK_SUCCESS) { + log_line("relay: transport memory failed"); + return 0; + } + VkCommandBufferAllocateInfo command_info = { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = relay_pool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = 1 + }; + if (r_vkAllocateCommandBuffers(relay_device, &command_info, &transport->command_buffer) != VK_SUCCESS) { + log_line("relay: command buffer failed"); + return 0; + } + return 1; +} + +static int relay_record_copy( + const struct gn_swapchain *swapchain, struct gn_image *image, + struct gn_transport_image *transport, uint32_t array_index) +{ + if (transport->steady_recorded) return 1; + if (r_vkResetCommandBuffer(transport->command_buffer, 0) != VK_SUCCESS) return 0; + VkCommandBufferBeginInfo begin = { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO + }; + if (r_vkBeginCommandBuffer(transport->command_buffer, &begin) != VK_SUCCESS) return 0; + + VkImageMemoryBarrier before[2] = { + { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = 0, + .dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT, + .oldLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = image->relay_source_image, + .subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, array_index, 1} + }, + { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = 0, + .dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, + .oldLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = transport->image, + .subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1} + } + }; + r_vkCmdPipelineBarrier( + transport->command_buffer, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, NULL, 0, NULL, 2, before); + + VkImageCopy copy = { + .srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, array_index, 1}, + .dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, + .extent = {swapchain->width, swapchain->height, 1} + }; + r_vkCmdCopyImage( + transport->command_buffer, image->relay_source_image, + VK_IMAGE_LAYOUT_GENERAL, transport->image, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ©); + + VkImageMemoryBarrier after = { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, + .dstAccessMask = VK_ACCESS_MEMORY_READ_BIT, + .oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, + .srcQueueFamilyIndex = relay_queue_family, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_EXTERNAL, + .image = transport->image, + .subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1} + }; + r_vkCmdPipelineBarrier( + transport->command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, NULL, 0, NULL, 1, &after); + if (r_vkEndCommandBuffer(transport->command_buffer) != VK_SUCCESS) return 0; + transport->steady_recorded = 1; + return 1; +} + +static int relay_submit(const VkCommandBuffer *commands, uint32_t command_count, int *submit_ok) +{ + *submit_ok = 0; + VkSubmitInfo submit = { + .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, + .commandBufferCount = command_count, + .pCommandBuffers = commands + }; + if (!relay_can_export_fence) { + if (r_vkQueueSubmit(relay_queue, 1, &submit, VK_NULL_HANDLE) != VK_SUCCESS) return -1; + if (r_vkQueueWaitIdle(relay_queue) != VK_SUCCESS) return -1; + *submit_ok = 1; + return -1; + } + VkExportFenceCreateInfo export_info = { + .sType = VK_STRUCTURE_TYPE_EXPORT_FENCE_CREATE_INFO, + .handleTypes = VK_EXTERNAL_FENCE_HANDLE_TYPE_SYNC_FD_BIT + }; + VkFenceCreateInfo fence_info = { + .sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO, + .pNext = &export_info + }; + VkFence fence = VK_NULL_HANDLE; + if (r_vkCreateFence(relay_device, &fence_info, NULL, &fence) != VK_SUCCESS) { + if (r_vkQueueSubmit(relay_queue, 1, &submit, VK_NULL_HANDLE) != VK_SUCCESS) return -1; + if (r_vkQueueWaitIdle(relay_queue) != VK_SUCCESS) return -1; + *submit_ok = 1; + return -1; + } + if (r_vkQueueSubmit(relay_queue, 1, &submit, fence) != VK_SUCCESS) { + r_vkDestroyFence(relay_device, fence, NULL); + return -1; + } + int fd = -1; + VkFenceGetFdInfoKHR fd_info = { + .sType = VK_STRUCTURE_TYPE_FENCE_GET_FD_INFO_KHR, + .fence = fence, + .handleType = VK_EXTERNAL_FENCE_HANDLE_TYPE_SYNC_FD_BIT + }; + if (r_vkGetFenceFdKHR(relay_device, &fd_info, &fd) != VK_SUCCESS) fd = -1; + r_vkDestroyFence(relay_device, fence, NULL); + *submit_ok = 1; + return fd; +} + +static int relay_register(uint32_t slot, uint32_t image_index, uint32_t eye) +{ + struct gn_swapchain *swapchain = &swapchains[slot]; + struct gn_image *image = &swapchain->images[image_index]; + struct gn_transport_image *transport = &image->transport[eye]; + if (swapchain->sample_count != 1) return 0; + if (!relay_init()) return 0; + if (!relay_create_source(swapchain, image)) return 0; + if (!transport->hardware_buffer && !relay_create_transport(swapchain, transport)) return 0; + if (transport->registered) return 1; + + const uint32_t transport_index = slot * GN_UNIX_MAX_IMAGES + image_index; + const int swap_red_blue = + swapchain->format == VK_FORMAT_B8G8R8A8_UNORM || + swapchain->format == VK_FORMAT_B8G8R8A8_SRGB; + char line[192], response[64] = {0}; + snprintf(line, sizeof(line), + "BUFFER eye=%u index=%u w=%u h=%u swizzle=%u\n", + eye, transport_index, swapchain->width, swapchain->height, + swap_red_blue ? 1u : 0u); + if (!transact_line(line, response, sizeof(response)) || + strncmp(response, "OK", 2) || + AHardwareBuffer_sendHandleToUnixSocket( + (AHardwareBuffer *)transport->hardware_buffer, transport_fd) != 0 || + !read_line(transport_fd, response, sizeof(response)) || + strncmp(response, "OK", 2)) { + log_line("relay: registration failed"); + close_transport(); + return 0; + } + transport->registered = 1; + { + char trace[192]; + snprintf(trace, sizeof(trace), + "relay registered eye=%u index=%u size=%ux%u swizzle=%u", + eye, transport_index, swapchain->width, swapchain->height, + swap_red_blue ? 1u : 0u); + log_line(trace); + } + return 1; +} +#endif + +static void destroy_transport_image(struct gn_transport_image *transport) +{ +#if defined(__ANDROID__) + if (transport->relay) { + if (transport->command_buffer && relay_pool) + r_vkFreeCommandBuffers(relay_device, relay_pool, 1, &transport->command_buffer); + if (transport->image) r_vkDestroyImage(relay_device, transport->image, NULL); + if (transport->memory) r_vkFreeMemory(relay_device, transport->memory, NULL); + if (transport->hardware_buffer) + AHardwareBuffer_release((AHardwareBuffer *)transport->hardware_buffer); + memset(transport, 0, sizeof(*transport)); + return; + } +#endif + if (transport->command_buffer && command_pool && p_vkFreeCommandBuffers) + p_vkFreeCommandBuffers(device, command_pool, 1, &transport->command_buffer); + if (transport->image && p_vkDestroyImage) + p_vkDestroyImage(device, transport->image, NULL); + if (transport->memory && p_vkFreeMemory) + p_vkFreeMemory(device, transport->memory, NULL); +#if defined(__ANDROID__) + if (transport->hardware_buffer) + AHardwareBuffer_release((AHardwareBuffer *)transport->hardware_buffer); +#endif + memset(transport, 0, sizeof(*transport)); +} + +#if defined(__ANDROID__) +static int create_ahardwarebuffer_transport( + const struct gn_swapchain *swapchain, struct gn_transport_image *transport) +{ + if (!p_vkGetAndroidHardwareBufferPropertiesANDROID || !command_pool || + !p_vkAllocateCommandBuffers || !p_vkResetCommandBuffer || + !p_vkBeginCommandBuffer || !p_vkEndCommandBuffer || + !p_vkCmdPipelineBarrier || !p_vkCmdCopyImage || !p_vkQueueSubmit) + return 0; + + AHardwareBuffer_Desc descriptor = { + .width = swapchain->width, + .height = swapchain->height, + .layers = 1, + .format = AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM, + .usage = AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE | + AHARDWAREBUFFER_USAGE_GPU_COLOR_OUTPUT + }; + AHardwareBuffer *buffer = NULL; + if (AHardwareBuffer_allocate(&descriptor, &buffer) != 0 || !buffer) { + log_line("AHardwareBuffer allocation unavailable; using dma-buf fallback"); + return 0; + } + transport->hardware_buffer = buffer; + + VkAndroidHardwareBufferFormatPropertiesANDROID format_properties = { + .sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_FORMAT_PROPERTIES_ANDROID + }; + VkAndroidHardwareBufferPropertiesANDROID properties = { + .sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_PROPERTIES_ANDROID, + .pNext = &format_properties + }; + VkResult result = p_vkGetAndroidHardwareBufferPropertiesANDROID( + device, buffer, &properties); + if (result != VK_SUCCESS) { + log_vk_result("vkGetAndroidHardwareBufferPropertiesANDROID", result); + goto fail; + } + if (format_properties.format != VK_FORMAT_R8G8B8A8_UNORM) { + char trace[160]; + snprintf(trace, sizeof(trace), + "AHardwareBuffer returned unsupported Vulkan format=%d", + (int)format_properties.format); + log_line(trace); + goto fail; + } + + VkExternalMemoryImageCreateInfo external = { + .sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO, + .handleTypes = + VK_EXTERNAL_MEMORY_HANDLE_TYPE_ANDROID_HARDWARE_BUFFER_BIT_ANDROID + }; + VkImageCreateInfo image_info = { + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, + .pNext = &external, + .imageType = VK_IMAGE_TYPE_2D, + .format = VK_FORMAT_R8G8B8A8_UNORM, + .extent = {swapchain->width, swapchain->height, 1}, + .mipLevels = 1, + .arrayLayers = 1, + .samples = VK_SAMPLE_COUNT_1_BIT, + .tiling = VK_IMAGE_TILING_OPTIMAL, + .usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED + }; + result = p_vkCreateImage(device, &image_info, NULL, &transport->image); + if (result != VK_SUCCESS) { + log_vk_result("vkCreateImage(AHardwareBuffer)", result); + goto fail; + } + + VkMemoryRequirements requirements; + p_vkGetImageMemoryRequirements(device, transport->image, &requirements); + const uint32_t compatible_types = + requirements.memoryTypeBits & properties.memoryTypeBits; + int memory_type = find_memory_type(compatible_types, 0); + if (memory_type < 0) { + log_line("No compatible memory type for AHardwareBuffer image"); + goto fail; + } + VkImportAndroidHardwareBufferInfoANDROID import_info = { + .sType = VK_STRUCTURE_TYPE_IMPORT_ANDROID_HARDWARE_BUFFER_INFO_ANDROID, + .buffer = buffer + }; + VkMemoryDedicatedAllocateInfo dedicated = { + .sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO, + .pNext = &import_info, + .image = transport->image + }; + VkMemoryAllocateInfo allocate_info = { + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .pNext = &dedicated, + .allocationSize = properties.allocationSize, + .memoryTypeIndex = (uint32_t)memory_type + }; + result = p_vkAllocateMemory( + device, &allocate_info, NULL, &transport->memory); + if (result != VK_SUCCESS) { + log_vk_result("vkAllocateMemory(AHardwareBuffer)", result); + goto fail; + } + result = p_vkBindImageMemory( + device, transport->image, transport->memory, 0); + if (result != VK_SUCCESS) { + log_vk_result("vkBindImageMemory(AHardwareBuffer)", result); + goto fail; + } + + VkCommandBufferAllocateInfo command_info = { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = command_pool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = 1 + }; + result = p_vkAllocateCommandBuffers( + device, &command_info, &transport->command_buffer); + if (result != VK_SUCCESS) { + log_vk_result("vkAllocateCommandBuffers(AHardwareBuffer)", result); + goto fail; + } + log_line("AHardwareBuffer Vulkan transport image created"); + return 1; + +fail: + destroy_transport_image(transport); + return 0; +} + +static int record_ahardwarebuffer_copy( + const struct gn_swapchain *swapchain, const struct gn_image *source, + struct gn_transport_image *transport, uint32_t array_index) +{ + + + if (transport->initialized && transport->steady_recorded) return 1; + + VkResult result = p_vkResetCommandBuffer(transport->command_buffer, 0); + if (result != VK_SUCCESS) { + log_vk_result("vkResetCommandBuffer(AHardwareBuffer)", result); + return 0; + } + VkCommandBufferBeginInfo begin = { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT + }; + result = p_vkBeginCommandBuffer(transport->command_buffer, &begin); + if (result != VK_SUCCESS) { + log_vk_result("vkBeginCommandBuffer(AHardwareBuffer)", result); + return 0; + } + + VkImageMemoryBarrier before[2] = { + { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | + VK_ACCESS_SHADER_WRITE_BIT, + .dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT, + .oldLayout = VK_IMAGE_LAYOUT_GENERAL, + .newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = source->image, + .subresourceRange = { + VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, array_index, 1 + } + }, + { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = transport->initialized ? VK_ACCESS_MEMORY_READ_BIT : 0, + .dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, + .oldLayout = transport->initialized ? + VK_IMAGE_LAYOUT_GENERAL : VK_IMAGE_LAYOUT_UNDEFINED, + .newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + .srcQueueFamilyIndex = transport->initialized ? + VK_QUEUE_FAMILY_EXTERNAL : VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = transport->initialized ? + queue_family_index : VK_QUEUE_FAMILY_IGNORED, + .image = transport->image, + .subresourceRange = { + VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 + } + } + }; + p_vkCmdPipelineBarrier( + transport->command_buffer, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, NULL, 0, NULL, 2, before); + + VkImageCopy copy = { + .srcSubresource = { + VK_IMAGE_ASPECT_COLOR_BIT, 0, array_index, 1 + }, + .dstSubresource = { + VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 + }, + .extent = {swapchain->width, swapchain->height, 1} + }; + p_vkCmdCopyImage( + transport->command_buffer, source->image, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, transport->image, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ©); + + VkImageMemoryBarrier after[2] = { + { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT, + .dstAccessMask = VK_ACCESS_MEMORY_READ_BIT | + VK_ACCESS_MEMORY_WRITE_BIT, + .oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = source->image, + .subresourceRange = { + VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, array_index, 1 + } + }, + { + .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, + .dstAccessMask = VK_ACCESS_MEMORY_READ_BIT, + .oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, + .srcQueueFamilyIndex = queue_family_index, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_EXTERNAL, + .image = transport->image, + .subresourceRange = { + VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 + } + } + }; + p_vkCmdPipelineBarrier( + transport->command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, NULL, 0, NULL, 2, after); + result = p_vkEndCommandBuffer(transport->command_buffer); + if (result != VK_SUCCESS) { + log_vk_result("vkEndCommandBuffer(AHardwareBuffer)", result); + return 0; + } + if (transport->initialized) transport->steady_recorded = 1; + return 1; +} +#endif + + +static void destroy_image(struct gn_image *image) +{ + for (uint32_t eye = 0; eye < 2; ++eye) + destroy_transport_image(&image->transport[eye]); +#if defined(__ANDROID__) + if (image->relay_source_image) + r_vkDestroyImage(relay_device, image->relay_source_image, NULL); + if (image->relay_source_memory) + r_vkFreeMemory(relay_device, image->relay_source_memory, NULL); +#endif + if (image->dma_buf_fd >= 0) close(image->dma_buf_fd); + if (image->image && p_vkDestroyImage) p_vkDestroyImage(device, image->image, NULL); + if (image->memory && p_vkFreeMemory) p_vkFreeMemory(device, image->memory, NULL); + memset(image, 0, sizeof(*image)); + image->dma_buf_fd = -1; +} + +static int register_ahardwarebuffer( + uint32_t slot, uint32_t image_index, uint32_t eye) +{ +#if defined(__ANDROID__) + struct gn_swapchain *swapchain = &swapchains[slot]; + struct gn_image *image = &swapchain->images[image_index]; + struct gn_transport_image *transport = &image->transport[eye]; + if (swapchain->sample_count != 1) return 0; + if (!transport->hardware_buffer && + !create_ahardwarebuffer_transport(swapchain, transport)) return 0; + if (transport->registered) return 1; + + const uint32_t transport_index = slot * GN_UNIX_MAX_IMAGES + image_index; + const int swap_red_blue = + swapchain->format == VK_FORMAT_B8G8R8A8_UNORM || + swapchain->format == VK_FORMAT_B8G8R8A8_SRGB; + char line[192], response[64] = {0}; + snprintf(line, sizeof(line), + "BUFFER eye=%u index=%u w=%u h=%u swizzle=%u\n", + eye, transport_index, swapchain->width, swapchain->height, + swap_red_blue ? 1u : 0u); + if (!transact_line(line, response, sizeof(response)) || + strncmp(response, "OK", 2) || + AHardwareBuffer_sendHandleToUnixSocket( + (AHardwareBuffer *)transport->hardware_buffer, + transport_fd) != 0 || + !read_line(transport_fd, response, sizeof(response)) || + strncmp(response, "OK", 2)) { + log_line("AHardwareBuffer registration failed; using dma-buf fallback"); + close_transport(); + return 0; + } + transport->registered = 1; + { + char trace[192]; + snprintf(trace, sizeof(trace), + "AHardwareBuffer registered eye=%u index=%u size=%ux%u swizzle=%u", + eye, transport_index, swapchain->width, swapchain->height, + swap_red_blue ? 1u : 0u); + log_line(trace); + } + return 1; +#else + (void)slot; + (void)image_index; + (void)eye; + return 0; +#endif +} + +static int register_image(uint32_t slot, uint32_t image_index, uint32_t eye, + uint32_t array_index) +{ + struct gn_swapchain *swapchain = &swapchains[slot]; + struct gn_image *image = &swapchain->images[image_index]; + const uint8_t bit = (uint8_t)(1u << eye); + if (array_index >= swapchain->array_size) return 0; + if ((image->registered_eye_mask & bit) && + image->registered_array_index[eye] == array_index) return 1; + + if (image->transport_kind[eye] == GN_TRANSPORT_UNKNOWN) { + if (register_ahardwarebuffer(slot, image_index, eye)) { + image->transport_kind[eye] = GN_TRANSPORT_AHARDWAREBUFFER; + image->registered_eye_mask |= bit; + image->registered_array_index[eye] = array_index; + return 1; + } + destroy_transport_image(&image->transport[eye]); +#if defined(__ANDROID__) + if (relay_register(slot, image_index, eye)) { + image->transport_kind[eye] = GN_TRANSPORT_RELAY; + image->registered_eye_mask |= bit; + image->registered_array_index[eye] = array_index; + return 1; + } + destroy_transport_image(&image->transport[eye]); +#endif + image->transport_kind[eye] = GN_TRANSPORT_DMABUF; + } + if (image->transport_kind[eye] == GN_TRANSPORT_RELAY) { + if (image->registered_array_index[eye] != array_index) { + image->transport[eye].steady_recorded = 0; + } + image->registered_array_index[eye] = array_index; + return 1; + } + if (image->transport_kind[eye] == GN_TRANSPORT_AHARDWAREBUFFER) { + if (image->registered_array_index[eye] != array_index) { + image->transport[eye].steady_recorded = 0; + } + image->registered_array_index[eye] = array_index; + return 1; + } + + if (image->dma_buf_fd < 0) { + log_line("optimal render image has no dma-buf; AHardwareBuffer transport is required"); + return 0; + } + + VkSubresourceLayout layouts[4]; + for (uint32_t plane = 0; plane < image->plane_count; ++plane) { + VkImageSubresource subresource = { + .aspectMask = image->plane_count == 1 + ? VK_IMAGE_ASPECT_COLOR_BIT + : (VkImageAspectFlags) + (VK_IMAGE_ASPECT_MEMORY_PLANE_0_BIT_EXT << plane), + .mipLevel = 0, + .arrayLayer = array_index + }; + p_vkGetImageSubresourceLayout( + device, image->image, &subresource, &layouts[plane]); + } + + char line[512], response[64] = {0}; + const uint32_t fourcc = format_to_fourcc(swapchain->format); + if (!fourcc) return 0; + const uint32_t transport_index = slot * GN_UNIX_MAX_IMAGES + image_index; + int length = snprintf( + line, sizeof(line), + "DMABUF eye=%u index=%u w=%u h=%u planes=%u fourcc=0x%08x " + "modifier=0x%llx", + eye, transport_index, swapchain->width, swapchain->height, + image->plane_count, fourcc, (unsigned long long)image->modifier); + for (uint32_t plane = 0; + plane < image->plane_count && length > 0 && + (size_t)length < sizeof(line); ++plane) { + length += snprintf( + line + length, sizeof(line) - (size_t)length, + " stride%u=%u offset%u=%u", + plane, (uint32_t)layouts[plane].rowPitch, + plane, (uint32_t)layouts[plane].offset); + } + if (length <= 0 || (size_t)length + 2 > sizeof(line)) return 0; + line[length++] = '\n'; + line[length] = 0; + int sent_planes = 1; + if (!transact_line(line, response, sizeof(response)) || + strncmp(response, "OK", 2)) { + char trace[192]; + snprintf(trace, sizeof(trace), + "dma-buf registration rejected eye=%u index=%u response=%s", + eye, transport_index, response[0] ? response : ""); + log_line(trace); + sent_planes = 0; + } + for (uint32_t plane = 0; + sent_planes && plane < image->plane_count; ++plane) { + if (!send_fd(transport_fd, image->dma_buf_fd)) sent_planes = 0; + } + if (!sent_planes || + !read_line(transport_fd, response, sizeof(response)) || strncmp(response, "OK", 2)) { + close_transport(); + return 0; + } + image->registered_eye_mask |= bit; + image->registered_array_index[eye] = array_index; + { + char trace[320]; + snprintf(trace, sizeof(trace), + "dma-buf registered eye=%u index=%u array=%u size=%ux%u " + "fourcc=0x%08x modifier=0x%llx planes=%u stride0=%u offset0=%u", + eye, transport_index, array_index, swapchain->width, + swapchain->height, fourcc, + (unsigned long long)image->modifier, image->plane_count, + (uint32_t)layouts[0].rowPitch, (uint32_t)layouts[0].offset); + log_line(trace); + } + return 1; +} + +static int submit_and_make_acquire_fence_fd( + const VkCommandBuffer *commands, uint32_t command_count, int *submit_ok) +{ + *submit_ok = 0; + if (!p_vkQueueSubmit) return -1; + VkSubmitInfo submit = { + .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, + .commandBufferCount = command_count, + .pCommandBuffers = command_count ? commands : NULL + }; + const int can_export = p_vkCreateFence && p_vkDestroyFence && + p_vkGetFenceFdKHR; + VkFence fence = VK_NULL_HANDLE; + if (!can_export) { + VkResult result = p_vkQueueSubmit(queue, 1, &submit, VK_NULL_HANDLE); + if (result != VK_SUCCESS) { + log_vk_result("vkQueueSubmit(stereo transport)", result); + return -1; + } + if (!p_vkQueueWaitIdle || p_vkQueueWaitIdle(queue) != VK_SUCCESS) + return -1; + *submit_ok = 1; + return -1; + } + VkExportFenceCreateInfo export_info = { + .sType = VK_STRUCTURE_TYPE_EXPORT_FENCE_CREATE_INFO, + .handleTypes = VK_EXTERNAL_FENCE_HANDLE_TYPE_SYNC_FD_BIT + }; + VkFenceCreateInfo create_info = { + .sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO, + .pNext = &export_info + }; + if (p_vkCreateFence(device, &create_info, NULL, &fence) != VK_SUCCESS) { + VkResult result = p_vkQueueSubmit(queue, 1, &submit, VK_NULL_HANDLE); + if (result != VK_SUCCESS) { + log_vk_result("vkQueueSubmit(stereo transport fallback)", result); + return -1; + } + if (!p_vkQueueWaitIdle || p_vkQueueWaitIdle(queue) != VK_SUCCESS) + return -1; + *submit_ok = 1; + return -1; + } + if (p_vkQueueSubmit(queue, 1, &submit, fence) != VK_SUCCESS) { + p_vkDestroyFence(device, fence, NULL); + return -1; + } + *submit_ok = 1; + VkFenceGetFdInfoKHR fd_info = { + .sType = VK_STRUCTURE_TYPE_FENCE_GET_FD_INFO_KHR, + .fence = fence, + .handleType = VK_EXTERNAL_FENCE_HANDLE_TYPE_SYNC_FD_BIT + }; + int fd = -1; + if (p_vkGetFenceFdKHR(device, &fd_info, &fd) != VK_SUCCESS) fd = -1; + p_vkDestroyFence(device, fence, NULL); + if (fd < 0) { + if (!p_vkQueueWaitIdle || p_vkQueueWaitIdle(queue) != VK_SUCCESS) { + *submit_ok = 0; + return -1; + } + } + return fd; +} + +static int32_t unix_init(void *opaque) +{ + struct gn_unix_init_args *args = opaque; + args->result = args->abi_version == GN_UNIX_ABI_VERSION ? + GN_UNIX_SUCCESS : GN_UNIX_ERROR_ARGUMENT; + log_line("unixlib initialized"); + return 0; +} + +static int32_t unix_set_vulkan_context(void *opaque) +{ + struct gn_unix_vulkan_context_args *args = opaque; + char trace[256]; + pthread_mutex_lock(&state_mutex); + args->diagnostic_flags = 0; + snprintf(trace, sizeof(trace), + "Vulkan context request source=%s phys=0x%llx device=0x%llx queue=0x%llx", + args->handles_are_host ? "host" : "wine-client", + (unsigned long long)args->client_physical_device, + (unsigned long long)args->client_device, + (unsigned long long)args->client_queue); + log_line(trace); + if (args->handles_are_host) { + physical_device = (VkPhysicalDevice)(uintptr_t)args->client_physical_device; + device = (VkDevice)(uintptr_t)args->client_device; + queue = (VkQueue)(uintptr_t)args->client_queue; + } else { + physical_device = (VkPhysicalDevice)(uintptr_t)unwrap_dispatchable(args->client_physical_device); + device = (VkDevice)(uintptr_t)unwrap_dispatchable(args->client_device); + queue = (VkQueue)(uintptr_t)unwrap_dispatchable(args->client_queue); + } + queue_family_index = args->queue_family_index; + if (readable_mapping_contains((uintptr_t)physical_device, sizeof(void *))) + args->diagnostic_flags |= GN_UNIX_VK_DIAG_PHYSICAL_DEVICE; + if (readable_mapping_contains((uintptr_t)device, sizeof(void *))) + args->diagnostic_flags |= GN_UNIX_VK_DIAG_DEVICE; + if (readable_mapping_contains((uintptr_t)queue, sizeof(void *))) + args->diagnostic_flags |= GN_UNIX_VK_DIAG_QUEUE; + if ((args->diagnostic_flags & + (GN_UNIX_VK_DIAG_PHYSICAL_DEVICE | GN_UNIX_VK_DIAG_DEVICE | + GN_UNIX_VK_DIAG_QUEUE)) != + (GN_UNIX_VK_DIAG_PHYSICAL_DEVICE | GN_UNIX_VK_DIAG_DEVICE | + GN_UNIX_VK_DIAG_QUEUE)) { + log_line("Vulkan context rejected: resolved host handles are not readable"); + physical_device = VK_NULL_HANDLE; + device = VK_NULL_HANDLE; + queue = VK_NULL_HANDLE; + args->result = GN_UNIX_ERROR_UNAVAILABLE; + pthread_mutex_unlock(&state_mutex); + return 0; + } + load_vulkan_functions(); +#if defined(__ANDROID__) + if (p_vkGetAndroidHardwareBufferPropertiesANDROID && + p_vkCreateCommandPool && !command_pool) { + VkCommandPoolCreateInfo pool_info = { + .sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, + .flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, + .queueFamilyIndex = queue_family_index + }; + VkResult pool_result = p_vkCreateCommandPool( + device, &pool_info, NULL, &command_pool); + if (pool_result != VK_SUCCESS) { + command_pool = VK_NULL_HANDLE; + log_vk_result("vkCreateCommandPool(AHardwareBuffer)", pool_result); + } + } +#endif + if (vulkan_so) args->diagnostic_flags |= GN_UNIX_VK_DIAG_VULKAN_LIBRARY; + if (p_vkGetDeviceProcAddr) + args->diagnostic_flags |= GN_UNIX_VK_DIAG_GET_DEVICE_PROC_ADDR; + if (p_vkCreateImage) args->diagnostic_flags |= GN_UNIX_VK_DIAG_CREATE_IMAGE; + if (p_vkGetMemoryFdKHR) + args->diagnostic_flags |= GN_UNIX_VK_DIAG_GET_MEMORY_FD; + log_vulkan_context_state(); + args->result = physical_device && device && queue && p_vkCreateImage && + p_vkGetMemoryFdKHR ? GN_UNIX_SUCCESS : GN_UNIX_ERROR_UNAVAILABLE; + pthread_mutex_unlock(&state_mutex); + log_line(args->result ? "Vulkan context unavailable" : "Vulkan context ready"); + return 0; +} + +static int32_t unix_create_swapchain(void *opaque) +{ + struct gn_unix_create_swapchain_args *args = opaque; + if (args->slot >= GN_UNIX_MAX_SWAPCHAINS || !args->width || !args->height || + !device || !format_to_fourcc((VkFormat)args->format)) { + args->result = GN_UNIX_ERROR_ARGUMENT; + return 0; + } + pthread_mutex_lock(&state_mutex); + struct gn_swapchain *swapchain = &swapchains[args->slot]; + if (swapchain->allocated) { + args->result = GN_UNIX_ERROR_ARGUMENT; + pthread_mutex_unlock(&state_mutex); + return 0; + } + memset(swapchain, 0, sizeof(*swapchain)); + swapchain->width = args->width; + swapchain->height = args->height; + swapchain->format = (VkFormat)args->format; + swapchain->array_size = args->array_size ? args->array_size : 1; + swapchain->sample_count = args->sample_count ? args->sample_count : 1; + + + swapchain->image_count = 4; + int optimal = 0; +#if defined(__ANDROID__) + optimal = swapchain->sample_count == 1 && ahb_transport_probe(); +#endif + for (uint32_t i = 0; i < swapchain->image_count; ++i) { + swapchain->images[i].dma_buf_fd = -1; + if (!create_image(swapchain, &swapchain->images[i], args->array_size, + args->mip_count, args->sample_count, optimal)) { + for (uint32_t j = 0; j <= i; ++j) destroy_image(&swapchain->images[j]); + memset(swapchain, 0, sizeof(*swapchain)); + args->result = GN_UNIX_ERROR_VULKAN; + pthread_mutex_unlock(&state_mutex); + return 0; + } + args->images[i] = (gn_u64)(uintptr_t)swapchain->images[i].image; + } + swapchain->allocated = 1; + args->image_count = swapchain->image_count; + args->result = GN_UNIX_SUCCESS; + pthread_mutex_unlock(&state_mutex); + log_line("swapchain created"); + return 0; +} + +static int32_t unix_destroy_swapchain(void *opaque) +{ + struct gn_unix_destroy_swapchain_args *args = opaque; + if (args->slot >= GN_UNIX_MAX_SWAPCHAINS) { + args->result = GN_UNIX_ERROR_ARGUMENT; + return 0; + } + submit_worker_flush(); + pthread_mutex_lock(&state_mutex); + struct gn_swapchain *swapchain = &swapchains[args->slot]; + for (uint32_t i = 0; i < swapchain->image_count; ++i) + destroy_image(&swapchain->images[i]); + memset(swapchain, 0, sizeof(*swapchain)); + args->result = GN_UNIX_SUCCESS; + pthread_mutex_unlock(&state_mutex); + return 0; +} + +static int32_t unix_acquire_image(void *opaque) +{ + struct gn_unix_acquire_image_args *args = opaque; + if (args->slot >= GN_UNIX_MAX_SWAPCHAINS || + args->image_index >= GN_UNIX_MAX_IMAGES) { + args->result = GN_UNIX_ERROR_ARGUMENT; + return 0; + } + pthread_mutex_lock(&submit_mutex); + while (image_copy_busy[args->slot][args->image_index]) + pthread_cond_wait(&submit_space_cond, &submit_mutex); + pthread_mutex_unlock(&submit_mutex); + + pthread_mutex_lock(&socket_mutex); + char line[512], response[64] = {0}; + snprintf(line, sizeof(line), "ACQUIRE eye=0 index=%u\n", args->image_index); + + + struct gn_image *image = &swapchains[args->slot].images[args->image_index]; + int timeout_ms; + if (args->timeout_ns < 0 || args->timeout_ns > 2147483647000000LL) + timeout_ms = -1; + else + timeout_ms = (int)((args->timeout_ns + 999999) / 1000000); + for (uint32_t eye = 0; eye < 2; ++eye) { + if (!(image->registered_eye_mask & (1u << eye))) continue; + const uint32_t transport_index = + args->slot * GN_UNIX_MAX_IMAGES + args->image_index; + snprintf(line, sizeof(line), "ACQUIRE eye=%u index=%u timeout=%d\n", + eye, transport_index, timeout_ms); + if (!transact_line(line, response, sizeof(response))) { + args->result = GN_UNIX_ERROR_TRANSPORT; + pthread_mutex_unlock(&socket_mutex); + return 0; + } + if (!strcmp(response, "ERR timeout")) { + args->result = GN_UNIX_ERROR_TIMEOUT; + pthread_mutex_unlock(&socket_mutex); + return 0; + } + if (strncmp(response, "OK", 2)) { + args->result = GN_UNIX_ERROR_TRANSPORT; + pthread_mutex_unlock(&socket_mutex); + return 0; + } + if (strstr(response, "fence=1")) { + int fd = recv_fd(transport_fd); + if (fd < 0) { + close_transport(); + args->result = GN_UNIX_ERROR_TRANSPORT; + pthread_mutex_unlock(&socket_mutex); + return 0; + } + struct pollfd pfd = {fd, POLLIN, 0}; + int waited; + do waited = poll(&pfd, 1, timeout_ms); while (waited < 0 && errno == EINTR); + close(fd); + if (waited <= 0) { + args->result = waited == 0 ? GN_UNIX_ERROR_TIMEOUT : GN_UNIX_ERROR_TRANSPORT; + pthread_mutex_unlock(&socket_mutex); + return 0; + } + } + } + image->submitted = 0; + args->result = GN_UNIX_SUCCESS; + pthread_mutex_unlock(&socket_mutex); + return 0; +} + +static int send_frame(const struct gn_unix_submit_view_args *view, int fence_fd, + uint64_t frame_id) +{ + char line[512], response[64] = {0}; + const uint32_t transport_index = + view->slot * GN_UNIX_MAX_IMAGES + view->image_index; + snprintf(line, sizeof(line), + "FRAME frame=%llu eye=%u index=%u fence=%u x=%d y=%d w=%u h=%u flip=%u " + "projection=1 qx=%lld qy=%lld qz=%lld qw=%lld " + "px=%lld py=%lld pz=%lld fl=%lld fr=%lld fu=%lld fd=%lld\n", + (unsigned long long)frame_id, view->eye, transport_index, + fence_fd >= 0 ? 1u : 0u, + view->rect_x, view->rect_y, view->rect_width, view->rect_height, + view->flip_y ? 1u : 0u, + (long long)view->orientation_micro[0], + (long long)view->orientation_micro[1], + (long long)view->orientation_micro[2], + (long long)view->orientation_micro[3], + (long long)view->position_micro[0], + (long long)view->position_micro[1], + (long long)view->position_micro[2], + (long long)view->fov_micro[0], + (long long)view->fov_micro[1], + (long long)view->fov_micro[2], + (long long)view->fov_micro[3]); + int ok = 0; + if (fence_fd >= 0) { + ok = transact_line(line, response, sizeof(response)) && + !strncmp(response, "OK", 2) && + send_fd(transport_fd, fence_fd) && + read_line(transport_fd, response, sizeof(response)) && + !strncmp(response, "OK", 2); + } else { + ok = transact_line(line, response, sizeof(response)) && + !strncmp(response, "OK", 2); + } + if (!ok) { + char trace[192]; + snprintf(trace, sizeof(trace), + "frame transport failed eye=%u index=%u response=%s", + view->eye, transport_index, response[0] ? response : ""); + log_line(trace); + close_transport(); + } else if (!transport_frame_announced[view->eye]) { + char trace[160]; + snprintf(trace, sizeof(trace), + "first transported frame eye=%u index=%u fence=%u", + view->eye, transport_index, fence_fd >= 0 ? 1u : 0u); + log_line(trace); + transport_frame_announced[view->eye] = 1; + } + swapchains[view->slot].images[view->image_index].submitted = ok ? 1 : 0; + return ok; +} + + +/* Asynchronous frame shipper: xrEndFrame used to drain the game's GPU queue synchronously + * before transporting the frame, serializing the game's CPU and GPU completely. Instead, + * xrEndFrame drops a fence on the game queue and returns; this worker waits for the render + * to finish, runs the transport (relay copy + socket send), and the game overlaps its next + * frame's CPU work with the GPU. */ +struct gn_pending_submit { + struct gn_unix_submit_view_args views[2]; + uint32_t view_count; + VkFence fence; + int used; +}; + +static struct gn_pending_submit submit_ring[4]; +static uint32_t submit_ring_head, submit_ring_tail; +static pthread_cond_t submit_cond = PTHREAD_COND_INITIALIZER; +static pthread_t submit_thread; +static int submit_thread_running; +static int submit_worker_busy; + +static int submit_views_transport(const struct gn_unix_submit_view_args *views, uint32_t view_count); + +static void *submit_worker(void *unused) +{ + (void)unused; + for (;;) { + struct gn_pending_submit item; + pthread_mutex_lock(&submit_mutex); + while (submit_thread_running && !submit_ring[submit_ring_tail].used) + pthread_cond_wait(&submit_cond, &submit_mutex); + if (!submit_thread_running) { + pthread_mutex_unlock(&submit_mutex); + return NULL; + } + item = submit_ring[submit_ring_tail]; + submit_ring[submit_ring_tail].used = 0; + submit_ring_tail = (submit_ring_tail + 1) % 4; + submit_worker_busy = 1; + pthread_cond_signal(&submit_space_cond); + pthread_mutex_unlock(&submit_mutex); + + if (item.fence != VK_NULL_HANDLE) { + p_vkWaitForFences(device, 1, &item.fence, VK_TRUE, UINT64_MAX); + p_vkDestroyFence(device, item.fence, NULL); + } + if (!submit_views_transport(item.views, item.view_count)) { + static int failure_logged; + if (!failure_logged) { + failure_logged = 1; + log_line("async transport failed; later frames may recover"); + } + } + /* The relay copy reads the source image on its own queue; the game must not + * re-render into that image until the copy has actually executed. */ + if (relay_state > 0 && r_vkQueueWaitIdle) r_vkQueueWaitIdle(relay_queue); + pthread_mutex_lock(&submit_mutex); + for (uint32_t i = 0; i < item.view_count; ++i) { + if (item.views[i].slot < GN_UNIX_MAX_SWAPCHAINS && + item.views[i].image_index < GN_UNIX_MAX_IMAGES && + image_copy_busy[item.views[i].slot][item.views[i].image_index]) + --image_copy_busy[item.views[i].slot][item.views[i].image_index]; + } + submit_worker_busy = 0; + pthread_cond_broadcast(&submit_space_cond); + pthread_mutex_unlock(&submit_mutex); + } +} + +static void submit_worker_flush(void) +{ + pthread_mutex_lock(&submit_mutex); + while (submit_thread_running && + (submit_ring[submit_ring_tail].used || submit_worker_busy)) + pthread_cond_wait(&submit_space_cond, &submit_mutex); + pthread_mutex_unlock(&submit_mutex); +} + +static int submit_views_async(const struct gn_unix_submit_view_args *views, uint32_t view_count) +{ + if (!p_vkWaitForFences || !p_vkCreateFence || !p_vkDestroyFence || !p_vkQueueSubmit) + return submit_views_transport(views, view_count); + + pthread_mutex_lock(&submit_mutex); + if (!submit_thread_running) { + submit_thread_running = 1; + if (pthread_create(&submit_thread, NULL, submit_worker, NULL) != 0) { + submit_thread_running = 0; + pthread_mutex_unlock(&submit_mutex); + return submit_views_transport(views, view_count); + } + log_line("async frame shipper started"); + } + pthread_mutex_unlock(&submit_mutex); + + VkFence fence = VK_NULL_HANDLE; + VkFenceCreateInfo fence_info = {.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO}; + if (p_vkCreateFence(device, &fence_info, NULL, &fence) == VK_SUCCESS) { + VkSubmitInfo marker = {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO}; + if (p_vkQueueSubmit(queue, 1, &marker, fence) != VK_SUCCESS) { + p_vkDestroyFence(device, fence, NULL); + fence = VK_NULL_HANDLE; + } + } + + pthread_mutex_lock(&submit_mutex); + while (submit_ring[submit_ring_head].used) + pthread_cond_wait(&submit_space_cond, &submit_mutex); + struct gn_pending_submit *slot = &submit_ring[submit_ring_head]; + for (uint32_t i = 0; i < view_count; ++i) { + slot->views[i] = views[i]; + if (views[i].slot < GN_UNIX_MAX_SWAPCHAINS && + views[i].image_index < GN_UNIX_MAX_IMAGES) + ++image_copy_busy[views[i].slot][views[i].image_index]; + } + slot->view_count = view_count; + slot->fence = fence; + slot->used = 1; + submit_ring_head = (submit_ring_head + 1) % 4; + pthread_cond_signal(&submit_cond); + pthread_mutex_unlock(&submit_mutex); + return 1; +} + +static int submit_views_transport( + const struct gn_unix_submit_view_args *views, uint32_t view_count) +{ + if (!view_count || view_count > 2) return 0; + for (uint32_t i = 0; i < view_count; ++i) { + const struct gn_unix_submit_view_args *view = &views[i]; + if (view->slot >= GN_UNIX_MAX_SWAPCHAINS || view->eye >= 2 || + view->image_index >= swapchains[view->slot].image_count) + return 0; + } + + pthread_mutex_lock(&socket_mutex); + VkCommandBuffer commands[2]; + struct gn_transport_image *recorded[2]; + uint32_t command_count = 0; + VkCommandBuffer relay_commands[2]; + struct gn_transport_image *relay_recorded[2]; + uint32_t relay_count = 0; + for (uint32_t i = 0; i < view_count; ++i) { + const struct gn_unix_submit_view_args *view = &views[i]; + if (!register_image(view->slot, view->image_index, view->eye, + view->array_index)) { + pthread_mutex_unlock(&socket_mutex); + return 0; + } +#if defined(__ANDROID__) + struct gn_image *image = + &swapchains[view->slot].images[view->image_index]; + if (image->transport_kind[view->eye] == GN_TRANSPORT_RELAY) { + struct gn_transport_image *transport = &image->transport[view->eye]; + if (!relay_record_copy(&swapchains[view->slot], image, transport, + view->array_index)) { + pthread_mutex_unlock(&socket_mutex); + return 0; + } + relay_commands[relay_count] = transport->command_buffer; + relay_recorded[relay_count] = transport; + ++relay_count; + } + if (image->transport_kind[view->eye] == GN_TRANSPORT_AHARDWAREBUFFER) { + struct gn_transport_image *transport = &image->transport[view->eye]; + if (!record_ahardwarebuffer_copy( + &swapchains[view->slot], image, transport, + view->array_index)) { + const uint8_t bit = (uint8_t)(1u << view->eye); + log_line("AHardwareBuffer GPU copy failed; switching image to dma-buf"); + destroy_transport_image(transport); + image->transport_kind[view->eye] = GN_TRANSPORT_DMABUF; + image->registered_eye_mask &= (uint8_t)~bit; + if (!register_image(view->slot, view->image_index, view->eye, + view->array_index)) { + pthread_mutex_unlock(&socket_mutex); + return 0; + } + } else { + commands[command_count] = transport->command_buffer; + recorded[command_count] = transport; + ++command_count; + } + } +#endif + } + + int submit_ok = 0; + int fence_fd = -1; + if (command_count == 0) { + submit_ok = 1; + } else { + fence_fd = submit_and_make_acquire_fence_fd( + commands, command_count, &submit_ok); + } + if (!submit_ok) { + if (fence_fd >= 0) close(fence_fd); + pthread_mutex_unlock(&socket_mutex); + return 0; + } + for (uint32_t i = 0; i < command_count; ++i) recorded[i]->initialized = 1; +#if defined(__ANDROID__) + if (relay_count) { + // The game-queue submit above already waited for the render to finish; the relay + // copy runs after it on its own device, and its fence becomes the acquire fence. + int relay_ok = 0; + int relay_fence = relay_submit(relay_commands, relay_count, &relay_ok); + if (!relay_ok) { + if (relay_fence >= 0) close(relay_fence); + if (fence_fd >= 0) close(fence_fd); + pthread_mutex_unlock(&socket_mutex); + return 0; + } + for (uint32_t i = 0; i < relay_count; ++i) relay_recorded[i]->initialized = 1; + if (fence_fd >= 0) close(fence_fd); + fence_fd = relay_fence; + } +#endif + + int ok = 1; + const uint64_t frame_id = ++transport_frame_id; + for (uint32_t i = 0; i < view_count; ++i) { + const int view_fence_fd = i == 0 ? fence_fd : -1; + if (!send_frame(&views[i], view_fence_fd, frame_id)) { + ok = 0; + break; + } + } + if (fence_fd >= 0) close(fence_fd); + + pthread_mutex_unlock(&socket_mutex); + return ok; +} + +static int32_t unix_submit_image(void *opaque) +{ + struct gn_unix_submit_image_args *args = opaque; + args->result = submit_views_async( + (const struct gn_unix_submit_view_args *)args, 1) ? + GN_UNIX_SUCCESS : GN_UNIX_ERROR_TRANSPORT; + return 0; +} + + +/* Control-plane fast path: the PE runtime's winsock round trip to the Kotlin control + * server costs ~40ms under Wine+FEX because blocking recv detours through wineserver. + * The same request over a plain bionic socket from the unix side is sub-millisecond. */ +static int control_fast_fd = -1; +static pthread_mutex_t control_fast_mutex = PTHREAD_MUTEX_INITIALIZER; +static char control_fast_buf[1024]; +static uint32_t control_fast_len, control_fast_off; + +static void control_fast_close(void) +{ + if (control_fast_fd >= 0) close(control_fast_fd); + control_fast_fd = -1; + control_fast_len = 0; + control_fast_off = 0; +} + +static int control_fast_read_byte(char *value) +{ + if (control_fast_off >= control_fast_len) { + ssize_t got = recv(control_fast_fd, control_fast_buf, sizeof(control_fast_buf), 0); + if (got <= 0) return 0; + control_fast_len = (uint32_t)got; + control_fast_off = 0; + } + *value = control_fast_buf[control_fast_off++]; + return 1; +} + +static int control_fast_read_line(char *out, size_t out_size) +{ + size_t offset = 0; + char value = 0; + for (;;) { + if (!control_fast_read_byte(&value)) return 0; + if (value == '\n') break; + if (offset + 1 < out_size) out[offset++] = value; + } + out[offset] = 0; + return 1; +} + +static int control_fast_connect(void) +{ + if (control_fast_fd >= 0) return 1; + const char *port_text = getenv("GAMENATIVE_XR_BRIDGE_PORT"); + int port = port_text ? atoi(port_text) : 38476; + if (port <= 0) port = 38476; + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return 0; + int nodelay = 1; + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &nodelay, sizeof(nodelay)); + struct sockaddr_in address; + memset(&address, 0, sizeof(address)); + address.sin_family = AF_INET; + address.sin_port = htons((uint16_t)port); + address.sin_addr.s_addr = htonl(0x7f000001); + if (connect(fd, (struct sockaddr *)&address, sizeof(address)) != 0) { + close(fd); + return 0; + } + control_fast_fd = fd; + control_fast_len = 0; + control_fast_off = 0; + char line[128]; + if (!write_all(fd, "HELLO\n", 6) || + !control_fast_read_line(line, sizeof(line)) || + strncmp(line, "OK", 2) != 0) { + control_fast_close(); + return 0; + } + log_line("unix control fast path connected"); + return 1; +} + +static int32_t unix_control_transact(void *opaque) +{ + struct gn_unix_control_transact_args *args = opaque; + args->request[sizeof(args->request) - 1] = 0; + uint32_t lines = args->response_lines ? args->response_lines : 1; + if (lines > 8) lines = 8; + args->result = GN_UNIX_ERROR_TRANSPORT; + pthread_mutex_lock(&control_fast_mutex); + if (!control_fast_connect()) { + pthread_mutex_unlock(&control_fast_mutex); + return 0; + } + size_t request_length = strlen(args->request); + args->request[request_length] = '\n'; + int sent = write_all(control_fast_fd, args->request, request_length + 1); + args->request[request_length] = 0; + if (!sent) { + control_fast_close(); + pthread_mutex_unlock(&control_fast_mutex); + return 0; + } + size_t offset = 0; + int truncated = 0; + for (uint32_t i = 0; i < lines; ++i) { + char line[1024]; + if (!control_fast_read_line(line, sizeof(line))) { + control_fast_close(); + pthread_mutex_unlock(&control_fast_mutex); + return 0; + } + size_t line_length = strlen(line); + if (truncated || offset + line_length + 2 > sizeof(args->response)) { + truncated = 1; + } else { + if (i) args->response[offset++] = '\n'; + memcpy(args->response + offset, line, line_length); + offset += line_length; + } + /* single-line commands whose reply is an error carry no continuation lines */ + if (i == 0 && strncmp(line, "OK", 2) != 0) break; + } + args->response[offset] = 0; + args->result = GN_UNIX_SUCCESS; + pthread_mutex_unlock(&control_fast_mutex); + return 0; +} + +static int32_t unix_submit_stereo(void *opaque) +{ + struct gn_unix_submit_stereo_args *args = opaque; + if (!args->view_count || args->view_count > 2) { + args->result = GN_UNIX_ERROR_ARGUMENT; + return 0; + } + args->result = submit_views_async(args->views, args->view_count) ? + GN_UNIX_SUCCESS : GN_UNIX_ERROR_TRANSPORT; + return 0; +} + +__attribute__((visibility("default"))) +const unixlib_entry_t __wine_unix_call_funcs[GN_UNIX_CALL_COUNT] = { + unix_init, + unix_set_vulkan_context, + unix_create_swapchain, + unix_destroy_swapchain, + unix_acquire_image, + unix_submit_image, + unix_submit_stereo, + unix_control_transact +}; + + + +__attribute__((visibility("default"))) +const unixlib_entry_t __wine_unix_call_wow64_funcs[GN_UNIX_CALL_COUNT] = { + unix_init, + unix_set_vulkan_context, + unix_create_swapchain, + unix_destroy_swapchain, + unix_acquire_image, + unix_submit_image, + unix_submit_stereo, + unix_control_transact +}; + +__attribute__((destructor)) +static void unix_shutdown(void) +{ + close_transport(); + for (uint32_t slot = 0; slot < GN_UNIX_MAX_SWAPCHAINS; ++slot) { + if (!swapchains[slot].allocated) continue; + for (uint32_t image = 0; + image < swapchains[slot].image_count; ++image) + destroy_image(&swapchains[slot].images[image]); + } + if (command_pool && p_vkDestroyCommandPool) + p_vkDestroyCommandPool(device, command_pool, NULL); + command_pool = VK_NULL_HANDLE; +} diff --git a/app/src/main/windows/openxr_runtime/ws2_32_x64.def b/app/src/main/windows/openxr_runtime/ws2_32_x64.def new file mode 100644 index 0000000000..6d828209c4 --- /dev/null +++ b/app/src/main/windows/openxr_runtime/ws2_32_x64.def @@ -0,0 +1,10 @@ +LIBRARY WS2_32.dll +EXPORTS + WSAStartup + socket + connect + send + recv + closesocket + htons + inet_addr diff --git a/app/src/main/windows/openxr_runtime/ws2_32_x86.def b/app/src/main/windows/openxr_runtime/ws2_32_x86.def new file mode 100644 index 0000000000..90517dd91b --- /dev/null +++ b/app/src/main/windows/openxr_runtime/ws2_32_x86.def @@ -0,0 +1,10 @@ +LIBRARY WS2_32.dll +EXPORTS + WSAStartup@8 + socket@12 + connect@12 + send@16 + recv@16 + closesocket@4 + htons@4 + inet_addr@4 diff --git a/app/src/modernXr/AndroidManifest.xml b/app/src/modernXr/AndroidManifest.xml index 607765a95a..9e5f637c81 100644 --- a/app/src/modernXr/AndroidManifest.xml +++ b/app/src/modernXr/AndroidManifest.xml @@ -70,6 +70,9 @@ + diff --git a/app/src/modernXr/assets/gamenative_openxr_runtime32.dll b/app/src/modernXr/assets/gamenative_openxr_runtime32.dll new file mode 100755 index 0000000000..985e44faef Binary files /dev/null and b/app/src/modernXr/assets/gamenative_openxr_runtime32.dll differ diff --git a/app/src/modernXr/assets/gamenative_openxr_runtime64.dll b/app/src/modernXr/assets/gamenative_openxr_runtime64.dll new file mode 100755 index 0000000000..6e0e3cfc26 Binary files /dev/null and b/app/src/modernXr/assets/gamenative_openxr_runtime64.dll differ diff --git a/app/src/modernXr/assets/gamenative_xr_unixbridge.dll b/app/src/modernXr/assets/gamenative_xr_unixbridge.dll new file mode 100755 index 0000000000..b8da8b4f9c Binary files /dev/null and b/app/src/modernXr/assets/gamenative_xr_unixbridge.dll differ diff --git a/app/src/modernXr/assets/gamenative_xr_unixbridge.so b/app/src/modernXr/assets/gamenative_xr_unixbridge.so new file mode 100755 index 0000000000..991a5dd1ba Binary files /dev/null and b/app/src/modernXr/assets/gamenative_xr_unixbridge.so differ diff --git a/app/src/modernXr/assets/gamenative_xr_unixbridge.wine-commit b/app/src/modernXr/assets/gamenative_xr_unixbridge.wine-commit new file mode 100644 index 0000000000..0c13c2e787 --- /dev/null +++ b/app/src/modernXr/assets/gamenative_xr_unixbridge.wine-commit @@ -0,0 +1 @@ +77eed5501e077f5f0422d063b171a3a4491f52a6 diff --git a/app/src/modernXr/assets/gamenative_xr_unixbridge32.dll b/app/src/modernXr/assets/gamenative_xr_unixbridge32.dll new file mode 100644 index 0000000000..54762881b3 Binary files /dev/null and b/app/src/modernXr/assets/gamenative_xr_unixbridge32.dll differ diff --git a/app/src/modernXr/assets/opencomposite_x64.dll b/app/src/modernXr/assets/opencomposite_x64.dll new file mode 100644 index 0000000000..8ce58c88f9 Binary files /dev/null and b/app/src/modernXr/assets/opencomposite_x64.dll differ diff --git a/app/src/modernXr/assets/payload.version b/app/src/modernXr/assets/payload.version new file mode 100644 index 0000000000..a3c2f6ec94 --- /dev/null +++ b/app/src/modernXr/assets/payload.version @@ -0,0 +1 @@ +3 fb4792ecc21725bec26e3e1754af6c954b39e0f641192d257e578629dea9c92c eb77fcd88b7a93c1fd28e8aaa05b22d0d454a11ebf488743cb4008a058d93e8e \ No newline at end of file diff --git a/app/src/modernXr/jniLibs/arm64-v8a/libxrimmersive.so b/app/src/modernXr/jniLibs/arm64-v8a/libxrimmersive.so index f92c95c98e..50e74975df 100755 Binary files a/app/src/modernXr/jniLibs/arm64-v8a/libxrimmersive.so and b/app/src/modernXr/jniLibs/arm64-v8a/libxrimmersive.so differ diff --git a/tools/build-windows-xr-runtime.ps1 b/tools/build-windows-xr-runtime.ps1 new file mode 100644 index 0000000000..d51d8cebfd --- /dev/null +++ b/tools/build-windows-xr-runtime.ps1 @@ -0,0 +1,60 @@ +param( + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" +$repository = Split-Path -Parent $PSScriptRoot +$source = Join-Path $repository "app\src\main\windows\openxr_runtime" +$build = Join-Path $repository "app\build\windows-xr-runtime" +$output = Join-Path $repository "app\src\modernXr\assets" +$ndk = Join-Path $env:LOCALAPPDATA "Android\Sdk\ndk\29.0.14206865" +$clang = Join-Path $ndk "toolchains\llvm\prebuilt\windows-x86_64\bin\clang.exe" +$dlltool = Join-Path $ndk "toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-dlltool.exe" +if (!(Test-Path $clang) -or !(Test-Path $dlltool)) { throw "Android NDK 29 LLVM tools are required" } +New-Item -ItemType Directory -Force -Path $build, $output | Out-Null +cmake -S $source -B $build -G "Visual Studio 17 2022" -A x64 +if ($LASTEXITCODE -ne 0) { throw "OpenXR SDK preparation failed" } +$include = Join-Path $build "_deps\openxr_sdk-build\include" +if (!(Test-Path (Join-Path $include "openxr\openxr.h"))) { throw "OpenXR SDK headers are unavailable" } + +function Invoke-Tool { + param([scriptblock]$Command, [string]$Name) + & $Command + if ($LASTEXITCODE -ne 0) { throw "$Name failed with exit code $LASTEXITCODE" } +} + +$imports = @{} +foreach ($architecture in @("x64", "x86")) { + $machine = if ($architecture -eq "x64") { "i386:x86-64" } else { "i386" } + foreach ($library in @("ws2_32", "kernel32", "ntdll", "dxgi")) { + $archive = Join-Path $build "lib${library}_${architecture}.a" + $definition = Join-Path $source "${library}_${architecture}.def" + if ($architecture -eq "x86") { + Invoke-Tool { & $dlltool -m $machine -k -d $definition -l $archive } "$library $architecture import library" + } else { + Invoke-Tool { & $dlltool -m $machine -d $definition -l $archive } "$library $architecture import library" + } + $imports["$library-$architecture"] = $archive + } +} + +$runtimeSource = Join-Path $source "gamenative_openxr_runtime.c" +$runtime64 = Join-Path $build "gamenative_openxr_runtime64.dll" +$runtime32 = Join-Path $build "gamenative_openxr_runtime32.dll" +Invoke-Tool { & $clang --target=x86_64-w64-windows-gnu -shared -nostdlib "-Wl,-e,DllMain" -I $include -o $runtime64 $runtimeSource (Join-Path $source "gamenative_openxr_runtime_x64.def") $imports["ws2_32-x64"] $imports["kernel32-x64"] $imports["ntdll-x64"] $imports["dxgi-x64"] } "x64 OpenXR runtime" +Invoke-Tool { & $clang --target=i686-w64-windows-gnu -shared -nostdlib "-Wl,-e,DllMain" -I $include -o $runtime32 $runtimeSource (Join-Path $source "gamenative_openxr_runtime_x86.def") $imports["ws2_32-x86"] $imports["kernel32-x86"] $imports["ntdll-x86"] $imports["dxgi-x86"] } "x86 OpenXR runtime" + +function Assert-Machine { + param([string]$Path, [int]$Expected) + $bytes = [System.IO.File]::ReadAllBytes($Path) + $offset = [BitConverter]::ToInt32($bytes, 0x3c) + $actual = [BitConverter]::ToUInt16($bytes, $offset + 4) + if ($actual -ne $Expected) { throw "Unexpected PE machine for $Path" } +} +Assert-Machine $runtime64 0x8664 +Assert-Machine $runtime32 0x014c +Copy-Item -Force -LiteralPath $runtime64 -Destination (Join-Path $output "gamenative_openxr_runtime64.dll") +Copy-Item -Force -LiteralPath $runtime32 -Destination (Join-Path $output "gamenative_openxr_runtime32.dll") +$hash64 = (Get-FileHash -Algorithm SHA256 -LiteralPath $runtime64).Hash.ToLowerInvariant() +$hash32 = (Get-FileHash -Algorithm SHA256 -LiteralPath $runtime32).Hash.ToLowerInvariant() +Set-Content -NoNewline -LiteralPath (Join-Path $output "payload.version") -Value "3 $hash64 $hash32" diff --git a/tools/build-wine-xr-bridge.sh b/tools/build-wine-xr-bridge.sh new file mode 100644 index 0000000000..5dd4b7c3ef --- /dev/null +++ b/tools/build-wine-xr-bridge.sh @@ -0,0 +1,3 @@ +set -eu +repository="$1" +exec "$repository/tools/provision-build-arm64x-wine-bridge.sh" "$repository" diff --git a/tools/build-xr-native.ps1 b/tools/build-xr-native.ps1 new file mode 100644 index 0000000000..025fb457e3 --- /dev/null +++ b/tools/build-xr-native.ps1 @@ -0,0 +1,39 @@ +param( + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" +$repository = Split-Path -Parent $PSScriptRoot +$sdkLine = Get-Content -LiteralPath (Join-Path $repository "local.properties") | Where-Object { $_ -like "sdk.dir=*" } | Select-Object -First 1 +if (-not $sdkLine) { throw "sdk.dir is missing from local.properties" } +$sdk = $sdkLine.Substring(8).Replace("\\", "\").Replace("\:", ":") +$ndk = Join-Path $sdk "ndk\27.3.13750724" +$cmake = Join-Path $sdk "cmake\3.22.1\bin\cmake.exe" +$ninja = Join-Path $sdk "cmake\3.22.1\bin\ninja.exe" +$gradleHome = if ($env:GRADLE_USER_HOME) { $env:GRADLE_USER_HOME } else { Join-Path $env:USERPROFILE ".gradle" } +$aarCache = Join-Path $gradleHome "caches\modules-2\files-2.1\org.khronos.openxr\openxr_loader_for_android\1.1.61" +$aar = if (Test-Path -LiteralPath $aarCache) { Get-ChildItem $aarCache -Recurse -Filter "openxr_loader_for_android-1.1.61.aar" | Select-Object -First 1 } +if (-not $aar) { throw "OpenXR Android loader 1.1.61 is not in the Gradle cache ($gradleHome); run a Gradle sync first so it is downloaded" } +$work = Join-Path $repository "app\build\xr-native" +$dependency = Join-Path $work "openxr" +$build = Join-Path $work "build" +$output = Join-Path $repository "app\src\modernXr\jniLibs\arm64-v8a" +$unixBuild = Join-Path $repository "app\build\xr-unixlib" +$payloadOutput = Join-Path $repository "app\src\modernXr\assets" +New-Item -ItemType Directory -Force -Path $dependency, $build, $output | Out-Null +if (-not (Test-Path (Join-Path $dependency "prefab"))) { + tar -xf $aar.FullName -C $dependency + if ($LASTEXITCODE -ne 0) { throw "Could not extract the OpenXR Android loader" } +} +$openXrDirectory = Join-Path $dependency "prefab\modules\openxr_loader\libs\android.arm64-v8a\cmake\openxr" +& $cmake -S (Join-Path $repository "app\src\main\cpp\xrimmersive") -B $build -G Ninja "-DCMAKE_MAKE_PROGRAM=$ninja" "-DCMAKE_BUILD_TYPE=$Configuration" "-DCMAKE_TOOLCHAIN_FILE=$ndk\build\cmake\android.toolchain.cmake" -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-29 "-DOpenXR_DIR=$openXrDirectory" +if ($LASTEXITCODE -ne 0) { throw "OpenXR native configuration failed" } +& $cmake --build $build --config $Configuration +if ($LASTEXITCODE -ne 0) { throw "OpenXR native build failed" } +Copy-Item -Force -LiteralPath (Join-Path $build "libxrimmersive.so") -Destination (Join-Path $output "libxrimmersive.so") +New-Item -ItemType Directory -Force -Path $unixBuild, $payloadOutput | Out-Null +& $cmake -S (Join-Path $repository "app\src\main\windows\openxr_runtime\unix") -B $unixBuild -G Ninja "-DCMAKE_MAKE_PROGRAM=$ninja" "-DCMAKE_BUILD_TYPE=$Configuration" "-DCMAKE_TOOLCHAIN_FILE=$ndk\build\cmake\android.toolchain.cmake" -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-29 +if ($LASTEXITCODE -ne 0) { throw "Wine unixlib configuration failed" } +& $cmake --build $unixBuild --config $Configuration +if ($LASTEXITCODE -ne 0) { throw "Wine unixlib build failed" } +Copy-Item -Force -LiteralPath (Join-Path $unixBuild "gamenative_xr_unixbridge.so") -Destination (Join-Path $payloadOutput "gamenative_xr_unixbridge.so") diff --git a/tools/build-xr-payload-macos.sh b/tools/build-xr-payload-macos.sh new file mode 100755 index 0000000000..2866ea4e48 --- /dev/null +++ b/tools/build-xr-payload-macos.sh @@ -0,0 +1,108 @@ +#!/bin/bash +# Builds the complete Windows-VR payload on macOS into app/src/modernXr/assets. +# +# tools/build-xr-payload-macos.sh # runtime DLLs + unixlib (the usual iteration loop) +# tools/build-xr-payload-macos.sh --bridge # also the arm64x Wine builtin (Docker; slow first run) +# +# After this, `./gradlew assembleModernXrDebug` packages everything. +set -eu + +repository="$(cd "$(dirname "$0")/.." && pwd)" +sdk="${ANDROID_HOME:-$HOME/Library/Android/sdk}" +ndk="$sdk/ndk/27.3.13750724" +bin="$ndk/toolchains/llvm/prebuilt/darwin-x86_64/bin" +cmake="$sdk/cmake/3.22.1/bin/cmake" +ninja="$sdk/cmake/3.22.1/bin/ninja" +source_dir="$repository/app/src/main/windows/openxr_runtime" +work="$repository/app/build/xr-payload-macos" +output="$repository/app/src/modernXr/assets" +mkdir -p "$work" "$output" + +# OpenXR headers (pinned to the same release the Windows build uses). +if [ ! -f "$work/inc/openxr/openxr.h" ]; then + mkdir -p "$work/inc/openxr" + curl -sL https://github.com/KhronosGroup/OpenXR-SDK/archive/refs/tags/release-1.1.61.tar.gz \ + -o "$work/openxr-sdk-1.1.61.tar.gz" + echo "d5e2773d282642e6e250bd10266f316fc3ff3b8602c83db009a1f434205998e6 $work/openxr-sdk-1.1.61.tar.gz" \ + | shasum -a 256 -c - >/dev/null || { echo "OpenXR SDK checksum mismatch"; exit 1; } + tar -xz -C "$work" --strip-components=2 -f "$work/openxr-sdk-1.1.61.tar.gz" "OpenXR-SDK-release-1.1.61/include/openxr" + mv "$work/openxr/"*.h "$work/inc/openxr/" 2>/dev/null || true + cp -R "$work"/*.h "$work/inc/openxr/" 2>/dev/null || true + [ -f "$work/inc/openxr/openxr.h" ] || { echo "OpenXR headers missing"; exit 1; } +fi + +# Import libraries from the .def files. +for lib in ws2_32 kernel32 ntdll dxgi; do + "$bin/llvm-dlltool" -m i386:x86-64 -d "$source_dir/${lib}_x64.def" -l "$work/lib${lib}_x64.a" + "$bin/llvm-dlltool" -m i386 -k -d "$source_dir/${lib}_x86.def" -l "$work/lib${lib}_x86.a" +done + +# The two OpenXR runtime DLLs (CRT-less PE, built with the NDK's clang). +"$bin/clang" --target=x86_64-w64-windows-gnu -shared -nostdlib -Wl,-e,DllMain -I "$work/inc" \ + -o "$output/gamenative_openxr_runtime64.dll" \ + "$source_dir/gamenative_openxr_runtime.c" "$source_dir/gamenative_openxr_runtime_x64.def" \ + "$work/libws2_32_x64.a" "$work/libkernel32_x64.a" "$work/libntdll_x64.a" "$work/libdxgi_x64.a" +"$bin/clang" --target=i686-w64-windows-gnu -shared -nostdlib -Wl,-e,DllMain -I "$work/inc" \ + -o "$output/gamenative_openxr_runtime32.dll" \ + "$source_dir/gamenative_openxr_runtime.c" "$source_dir/gamenative_openxr_runtime_x86.def" \ + "$work/libws2_32_x86.a" "$work/libkernel32_x86.a" "$work/libntdll_x86.a" "$work/libdxgi_x86.a" +hash64=$(shasum -a 256 "$output/gamenative_openxr_runtime64.dll" | cut -d' ' -f1) +hash32=$(shasum -a 256 "$output/gamenative_openxr_runtime32.dll" | cut -d' ' -f1) +printf "3 %s %s" "$hash64" "$hash32" > "$output/payload.version" + +# The native immersive compositor (libxrimmersive.so) against the Khronos loader prefab. +aar=$(find "${GRADLE_USER_HOME:-$HOME/.gradle}/caches/modules-2/files-2.1/org.khronos.openxr/openxr_loader_for_android/1.1.61" -name "*.aar" 2>/dev/null | head -1) +[ -n "$aar" ] || { echo "OpenXR Android loader 1.1.61 is not in the Gradle cache; run a gradle sync first"; exit 1; } +mkdir -p "$work/openxr" +unzip -q -o "$aar" -d "$work/openxr" +"$cmake" -S "$repository/app/src/main/cpp/xrimmersive" -B "$work/xrimmersive" -GNinja \ + -DCMAKE_MAKE_PROGRAM="$ninja" \ + -DCMAKE_TOOLCHAIN_FILE="$ndk/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-29 -DCMAKE_BUILD_TYPE=Release \ + -DOpenXR_DIR="$work/openxr/prefab/modules/openxr_loader/libs/android.arm64-v8a/cmake/openxr" >/dev/null +"$cmake" --build "$work/xrimmersive" +mkdir -p "$repository/app/src/modernXr/jniLibs/arm64-v8a" +cp "$work/xrimmersive/libxrimmersive.so" "$repository/app/src/modernXr/jniLibs/arm64-v8a/" +mkdir -p "$repository/app/src/legacyXr/jniLibs/arm64-v8a" +cp "$work/xrimmersive/libxrimmersive.so" "$repository/app/src/legacyXr/jniLibs/arm64-v8a/" + +# The Wine unixlib (plain NDK shared library). +"$cmake" -S "$source_dir/unix" -B "$work/unixlib" -GNinja \ + -DCMAKE_MAKE_PROGRAM="$ninja" \ + -DCMAKE_TOOLCHAIN_FILE="$ndk/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-28 -DCMAKE_BUILD_TYPE=Release >/dev/null +"$cmake" --build "$work/unixlib" +cp "$work/unixlib/gamenative_xr_unixbridge.so" "$output/" + +# The 32-bit Wine builtin stub ships prebuilt in the repo. +cp "$source_dir/builtin/gamenative_xr_unixbridge32.dll" "$output/" + +# OpenComposite (pinned download, checksum-verified) for OpenVR titles. +if [ ! -f "$output/opencomposite_x64.dll" ]; then + curl -sL "https://opencomposite.znix.xyz/builds/download_build?artefact_id=JjRFMXaxas695QK-&build_id=52366409&commit=a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d" \ + -o "$output/opencomposite_x64.dll" + echo "827ad85f3606a4dc4a8f5561a8ca69e4c6c1b5d2b9cd3315a461b9270b08242c $output/opencomposite_x64.dll" \ + | shasum -a 256 -c - >/dev/null || { echo "OpenComposite checksum mismatch"; exit 1; } +fi + +# The arm64x Wine builtin needs Linux; run it in Docker on request. +# The gn-arm64x volume caches the toolchains and Wine tree, so reruns take minutes. +if [ "${1:-}" = "--bridge" ]; then + docker run --rm --platform linux/amd64 \ + -v gn-arm64x:/root/gamenative-arm64x -v "$repository":/repo ubuntu:22.04 bash -c ' +set -e +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq build-essential flex bison git wget unzip xz-utils python3 perl pkg-config autoconf automake ca-certificates file >/dev/null +mkdir -p /root/Android/Sdk/ndk +cd /repo && bash tools/provision-build-arm64x-wine-bridge.sh /repo' +elif [ ! -f "$output/gamenative_xr_unixbridge.dll" ]; then + echo "note: arm64x bridge DLL not present — run with --bridge to build it (Docker)" +fi + +# legacyXr ships the same payload. +mkdir -p "$repository/app/src/legacyXr/assets" +cp "$output"/* "$repository/app/src/legacyXr/assets/" + +echo "payload ready:" +ls -la "$output" diff --git a/tools/provision-build-arm64x-wine-bridge.sh b/tools/provision-build-arm64x-wine-bridge.sh new file mode 100755 index 0000000000..e3a05bb9ab --- /dev/null +++ b/tools/provision-build-arm64x-wine-bridge.sh @@ -0,0 +1,121 @@ +#!/bin/bash +set -eu + +repository="$1" +base="${GAMENATIVE_ARM64X_HOME:-$HOME/gamenative-arm64x}" +wine_commit="77eed5501e077f5f0422d063b171a3a4491f52a6" +llvm_archive="llvm-mingw-20250920-ucrt-ubuntu-22.04-x86_64.tar.xz" +llvm_sha="8dd8c34fc051a50c2fae86015f35057f8aae93fe1e19b34537ef1269a8b4c772" +ndk_archive="android-ndk-r27d-linux.zip" +ndk_sha="601246087a682d1944e1e16dd85bc6e49560fe8b6d61255be2829178c8ed15d9" +downloads="$base/downloads" +toolchains="$base/toolchains" +wine_source="$base/wine-arm64x-source" +wine_tools="$base/wine-arm64x-tools" +wine_build="$base/wine-arm64x-build" +output="$repository/app/src/modernXr/assets" +llvm="$toolchains/llvm-mingw-20250920-ucrt-ubuntu-22.04-x86_64" +ndk="$HOME/Android/Sdk/ndk/27.3.13750724" + +mkdir -p "$downloads" "$toolchains" "$HOME/Android/Sdk/ndk" "$output" +if test ! -f "$downloads/$llvm_archive"; then + wget -q "https://github.com/bylaws/llvm-mingw/releases/download/20250920/$llvm_archive" -O "$downloads/$llvm_archive" +fi +if test ! -f "$downloads/$ndk_archive"; then + wget -q "https://dl.google.com/android/repository/$ndk_archive" -O "$downloads/$ndk_archive" +fi +printf '%s %s\n' "$llvm_sha" "$downloads/$llvm_archive" | sha256sum -c - +printf '%s %s\n' "$ndk_sha" "$downloads/$ndk_archive" | sha256sum -c - +if test ! -x "$llvm/bin/clang"; then + tar -xJf "$downloads/$llvm_archive" -C "$toolchains" +fi +if test ! -x "$ndk/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android28-clang"; then + unzip -q "$downloads/$ndk_archive" -d "$HOME/Android/Sdk/ndk" + rm -rf "$ndk" + mv "$HOME/Android/Sdk/ndk/android-ndk-r27d" "$ndk" +fi +if test ! -d "$wine_source/.git"; then + git clone --filter=blob:none --depth 1 --branch build-p10-20260503-sdk28 https://github.com/GameNative/proton-wine.git "$wine_source" +fi +test "$(git -C "$wine_source" rev-parse HEAD)" = "$wine_commit" +module="$wine_source/dlls/gamenative_xr_unixbridge" +mkdir -p "$module" +cp "$repository/app/src/main/windows/openxr_runtime/builtin/Makefile.in" "$module/Makefile.in" +cp "$repository/app/src/main/windows/openxr_runtime/builtin/gamenative_xr_unixbridge.c" "$module/gamenative_xr_unixbridge.c" +cp "$repository/app/src/main/windows/openxr_runtime/builtin/gamenative_xr_unixbridge.spec" "$module/gamenative_xr_unixbridge.spec" +if ! grep -q 'WINE_CONFIG_MAKEFILE(dlls/gamenative_xr_unixbridge)' "$wine_source/configure.ac"; then + sed -i '/WINE_CONFIG_MAKEFILE(dlls\/gameinput)/aWINE_CONFIG_MAKEFILE(dlls/gamenative_xr_unixbridge)' "$wine_source/configure.ac" +fi +if test ! -f "$wine_source/.gamenative-arm64x-autogen"; then + (cd "$wine_source" && ./autogen.sh --aarch64 && touch .gamenative-arm64x-autogen) +fi +if test ! -x "$wine_tools/tools/winebuild/winebuild"; then + mkdir -p "$wine_tools" + (cd "$wine_tools" && "$wine_source/configure" --enable-win64 --without-x --without-alsa --without-capi --without-coreaudio --without-cups --without-dbus --without-ffmpeg --without-fontconfig --without-freetype --without-gcrypt --without-gettext --without-gphoto --without-gnutls --without-gssapi --without-gstreamer --without-inotify --without-krb5 --without-netapi --without-opencl --without-opengl --without-osmesa --without-oss --without-pcap --without-pcsclite --without-piper --without-pulse --without-sane --without-sdl --without-udev --without-unwind --without-usb --without-v4l2 --without-vosk --without-vulkan --without-wayland) + make -C "$wine_tools" -j"$(nproc)" __tooldeps__ nls/all +fi +target_tools="$ndk/toolchains/llvm/prebuilt/linux-x86_64/bin" +mingw_tools="$llvm/bin" +export PATH="$mingw_tools:$PATH" +export CC="$target_tools/aarch64-linux-android28-clang" +export CXX="$target_tools/aarch64-linux-android28-clang++" +export AS="$CC" +export AR="$target_tools/llvm-ar" +export RANLIB="$target_tools/llvm-ranlib" +export STRIP="$target_tools/llvm-strip" +export DLLTOOL="$mingw_tools/llvm-dlltool" +if test ! -f "$wine_build/config.status"; then + mkdir -p "$wine_build" + (cd "$wine_build" && "$wine_source/configure" \ + --enable-archs=arm64ec,aarch64 \ + --host=aarch64-linux-android28 \ + --with-mingw=clang \ + --with-wine-tools="$wine_tools" \ + --enable-win64 \ + --disable-win16 \ + --disable-tests \ + --without-x \ + --without-alsa \ + --without-capi \ + --without-coreaudio \ + --without-cups \ + --without-dbus \ + --without-ffmpeg \ + --without-fontconfig \ + --without-freetype \ + --without-gcrypt \ + --without-gettext \ + --without-gphoto \ + --without-gnutls \ + --without-gssapi \ + --without-gstreamer \ + --without-inotify \ + --without-krb5 \ + --without-netapi \ + --without-opencl \ + --without-opengl \ + --without-osmesa \ + --without-oss \ + --without-pcap \ + --without-pcsclite \ + --without-piper \ + --with-pthread \ + --without-pulse \ + --without-sane \ + --without-sdl \ + --without-udev \ + --without-unwind \ + --without-usb \ + --without-v4l2 \ + --without-vosk \ + --without-vulkan \ + --without-wayland) +else + (cd "$wine_build" && ./config.status) +fi +make -C "$wine_build" -j"$(nproc)" dlls/gamenative_xr_unixbridge/all +artifact="$wine_build/dlls/gamenative_xr_unixbridge/aarch64-windows/gamenative_xr_unixbridge.dll" +test -f "$artifact" +cp "$artifact" "$output/gamenative_xr_unixbridge.dll" +printf '%s\n' "$wine_commit" > "$output/gamenative_xr_unixbridge.wine-commit" +"$llvm/bin/llvm-readobj" --file-headers --coff-load-config "$artifact" diff --git a/tools/stage-opencomposite.ps1 b/tools/stage-opencomposite.ps1 new file mode 100644 index 0000000000..9f73b77ac1 --- /dev/null +++ b/tools/stage-opencomposite.ps1 @@ -0,0 +1,16 @@ +$ErrorActionPreference = "Stop" +$repository = Split-Path -Parent $PSScriptRoot +$payload = Join-Path $repository "app\src\modernXr\assets" +$destination = Join-Path $payload "opencomposite_x64.dll" +$uri = "https://opencomposite.znix.xyz/builds/download_build?artefact_id=JjRFMXaxas695QK-&build_id=52366409&commit=a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d" +$expected = "827ad85f3606a4dc4a8f5561a8ca69e4c6c1b5d2b9cd3315a461b9270b08242c" +New-Item -ItemType Directory -Force -Path $payload | Out-Null +if ((Test-Path -LiteralPath $destination -PathType Leaf) -and (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash.ToLowerInvariant() -eq $expected) { exit 0 } +$temporary = "$destination.download" +Invoke-WebRequest -Uri $uri -OutFile $temporary +$actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $temporary).Hash.ToLowerInvariant() +if ($actual -ne $expected) { throw "OpenComposite checksum mismatch: $actual" } +$bytes = [System.IO.File]::ReadAllBytes($temporary) +$offset = [BitConverter]::ToInt32($bytes, 0x3c) +if ([BitConverter]::ToUInt16($bytes, $offset + 4) -ne 0x8664) { throw "OpenComposite payload is not x64" } +Move-Item -Force -LiteralPath $temporary -Destination $destination diff --git a/tools/stage-wine-xr-bridge.ps1 b/tools/stage-wine-xr-bridge.ps1 new file mode 100644 index 0000000000..137a2f3baf --- /dev/null +++ b/tools/stage-wine-xr-bridge.ps1 @@ -0,0 +1,15 @@ +param( + [Parameter(Mandatory = $true)] + [string]$CompanionPath +) + +$ErrorActionPreference = "Stop" +$repository = Split-Path -Parent $PSScriptRoot +$output = Join-Path $repository "app\src\modernXr\assets" +$unixlib = Join-Path $output "gamenative_xr_unixbridge.so" +$companion32 = Join-Path $repository "app\src\main\windows\openxr_runtime\builtin\gamenative_xr_unixbridge32.dll" +if (-not (Test-Path -LiteralPath $companion32 -PathType Leaf)) { throw "Missing x86 Wine XR bridge companion" } +& (Join-Path $PSScriptRoot "verify-arm64x-wine-pair.ps1") -CompanionPath $CompanionPath -UnixlibPath $unixlib | Out-Host +New-Item -ItemType Directory -Force -Path $output | Out-Null +Copy-Item -Force -LiteralPath $CompanionPath -Destination (Join-Path $output "gamenative_xr_unixbridge.dll") +Copy-Item -Force -LiteralPath $companion32 -Destination (Join-Path $output "gamenative_xr_unixbridge32.dll") diff --git a/tools/verify-arm64x-wine-pair.ps1 b/tools/verify-arm64x-wine-pair.ps1 new file mode 100644 index 0000000000..1a8b7c4200 --- /dev/null +++ b/tools/verify-arm64x-wine-pair.ps1 @@ -0,0 +1,115 @@ +param( + [Parameter(Mandatory = $true)] + [string]$CompanionPath, + [Parameter(Mandatory = $true)] + [string]$UnixlibPath +) + +$ErrorActionPreference = "Stop" + +function Read-U16([byte[]]$Bytes, [int64]$Offset) { + if ($Offset -lt 0 -or $Offset + 2 -gt $Bytes.Length) { throw "Truncated binary field" } + [BitConverter]::ToUInt16($Bytes, [int]$Offset) +} + +function Read-U32([byte[]]$Bytes, [int64]$Offset) { + if ($Offset -lt 0 -or $Offset + 4 -gt $Bytes.Length) { throw "Truncated binary field" } + [BitConverter]::ToUInt32($Bytes, [int]$Offset) +} + +function Read-U64([byte[]]$Bytes, [int64]$Offset) { + if ($Offset -lt 0 -or $Offset + 8 -gt $Bytes.Length) { throw "Truncated binary field" } + [BitConverter]::ToUInt64($Bytes, [int]$Offset) +} + +function Find-RvaOffset([byte[]]$Bytes, [int64]$PeOffset, [uint32]$Rva) { + $sectionCount = Read-U16 $Bytes ($PeOffset + 6) + $optionalSize = Read-U16 $Bytes ($PeOffset + 20) + $sectionTable = $PeOffset + 24 + $optionalSize + for ($index = 0; $index -lt $sectionCount; $index++) { + $section = $sectionTable + 40 * $index + $virtualSize = Read-U32 $Bytes ($section + 8) + $virtualAddress = Read-U32 $Bytes ($section + 12) + $rawSize = Read-U32 $Bytes ($section + 16) + $rawOffset = Read-U32 $Bytes ($section + 20) + $mappedSize = [Math]::Max($virtualSize, $rawSize) + if ($Rva -ge $virtualAddress -and $Rva -lt $virtualAddress + $mappedSize) { + $offset = $rawOffset + ($Rva - $virtualAddress) + if ($offset -ge $Bytes.Length) { throw "PE RVA maps outside the image" } + return [int64]$offset + } + } + throw "PE RVA is not mapped by a section" +} + +function Has-Ascii([byte[]]$Bytes, [string]$Value) { + $needle = [System.Text.Encoding]::ASCII.GetBytes($Value) + for ($offset = 0; $offset -le $Bytes.Length - $needle.Length; $offset++) { + $matched = $true + for ($index = 0; $index -lt $needle.Length; $index++) { + if ($Bytes[$offset + $index] -ne $needle[$index]) { + $matched = $false + break + } + } + if ($matched) { return $true } + } + return $false +} + +$companion = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $CompanionPath)) +if ($companion.Length -lt 512 -or (Read-U16 $companion 0) -ne 0x5a4d) { throw "Wine XR bridge is not a valid PE image" } +$peOffset = Read-U32 $companion 0x3c +if ((Read-U32 $companion $peOffset) -ne 0x00004550) { throw "Wine XR bridge PE signature is invalid" } +$machine = Read-U16 $companion ($peOffset + 4) +if ($machine -ne 0xaa64 -and $machine -ne 0xa64e) { throw "Wine XR bridge physical machine is neither ARM64 nor ARM64X" } +$optionalOffset = $peOffset + 24 +if ((Read-U16 $companion $optionalOffset) -ne 0x20b) { throw "Wine XR bridge is not PE32+" } +$sectionAlignment = Read-U32 $companion ($optionalOffset + 32) +$fileAlignment = Read-U32 $companion ($optionalOffset + 36) +if ($sectionAlignment -ne 65536 -or $fileAlignment -ne 65536) { throw "Wine XR bridge alignment is not 64 KiB" } +if ([System.Text.Encoding]::ASCII.GetString($companion, 64, 16) -ne "Wine builtin DLL") { throw "Wine XR bridge has no Wine builtin signature" } +if (-not (Has-Ascii $companion "gnWineUnixCall")) { throw "Wine XR bridge export is missing" } +$directoryCount = Read-U32 $companion ($optionalOffset + 108) +if ($directoryCount -le 10) { throw "Wine XR bridge has no load configuration directory" } +$loadConfigRva = Read-U32 $companion ($optionalOffset + 112 + 10 * 8) +$loadConfigSize = Read-U32 $companion ($optionalOffset + 116 + 10 * 8) +if ($loadConfigRva -eq 0 -or $loadConfigSize -lt 208) { throw "Wine XR bridge load configuration cannot contain CHPE metadata" } +$loadConfigOffset = Find-RvaOffset $companion $peOffset $loadConfigRva +$imageBase = Read-U64 $companion ($optionalOffset + 24) +$chpePointer = Read-U64 $companion ($loadConfigOffset + 200) +if ($chpePointer -eq 0) { throw "Wine XR bridge has no CHPE metadata pointer" } +$chpeRva = if ($chpePointer -ge $imageBase) { $chpePointer - $imageBase } else { $chpePointer } +$chpeOffset = Find-RvaOffset $companion $peOffset ([uint32]$chpeRva) +$chpeVersion = Read-U32 $companion $chpeOffset +$codeMapRva = Read-U32 $companion ($chpeOffset + 4) +$codeMapCount = Read-U32 $companion ($chpeOffset + 8) +if ($chpeVersion -eq 0 -or $codeMapRva -eq 0 -or $codeMapCount -eq 0 -or $codeMapCount -gt 1048576) { throw "Wine XR bridge CHPE metadata is incomplete" } +$codeMapOffset = Find-RvaOffset $companion $peOffset $codeMapRva +$hasArm64 = $false +$hasArm64Ec = $false +for ($index = 0; $index -lt $codeMapCount; $index++) { + $rangeType = (Read-U32 $companion ($codeMapOffset + 8 * $index)) -band 3 + if ($rangeType -eq 0) { $hasArm64 = $true } + if ($rangeType -eq 1) { $hasArm64Ec = $true } +} +if (-not $hasArm64 -or -not $hasArm64Ec) { throw "Wine XR bridge CHPE code map is not ARM64X hybrid" } + +$unixlib = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $UnixlibPath)) +if ($unixlib.Length -lt 64 -or $unixlib[0] -ne 0x7f -or $unixlib[1] -ne 0x45 -or $unixlib[2] -ne 0x4c -or + $unixlib[3] -ne 0x46 -or $unixlib[4] -ne 2 -or $unixlib[5] -ne 1 -or (Read-U16 $unixlib 18) -ne 183) { + throw "Wine XR unixlib is not a little-endian aarch64 ELF" +} +if (-not (Has-Ascii $unixlib "__wine_unix_call_funcs") -or -not (Has-Ascii $unixlib "__wine_unix_call_wow64_funcs")) { + throw "Wine XR unixlib dispatch exports are missing" +} + +[PSCustomObject]@{ + PhysicalMachine = ('0x{0:x4}' -f $machine) + ChpeVersion = $chpeVersion + ChpeCodeRanges = $codeMapCount + SectionAlignment = $sectionAlignment + FileAlignment = $fileAlignment + CompanionSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $CompanionPath).Hash.ToLowerInvariant() + UnixlibSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $UnixlibPath).Hash.ToLowerInvariant() +} diff --git a/tools/verify-xr-payload.ps1 b/tools/verify-xr-payload.ps1 new file mode 100644 index 0000000000..9371460e2c --- /dev/null +++ b/tools/verify-xr-payload.ps1 @@ -0,0 +1,33 @@ +$ErrorActionPreference = "Stop" +$repository = Split-Path -Parent $PSScriptRoot +$payload = Join-Path $repository "app\src\modernXr\assets" +$runtime64 = Join-Path $payload "gamenative_openxr_runtime64.dll" +$runtime32 = Join-Path $payload "gamenative_openxr_runtime32.dll" +$bridge = Join-Path $payload "gamenative_xr_unixbridge.dll" +$bridge32 = Join-Path $payload "gamenative_xr_unixbridge32.dll" +$unixlib = Join-Path $payload "gamenative_xr_unixbridge.so" +$openComposite = Join-Path $payload "opencomposite_x64.dll" + +foreach ($file in @($runtime64, $runtime32, $bridge, $bridge32, $unixlib, $openComposite)) { + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { throw "Missing XR payload file: $file" } +} + +function Get-PeMachine([string]$Path) { + $bytes = [System.IO.File]::ReadAllBytes($Path) + if ($bytes.Length -lt 256) { throw "Invalid PE image: $Path" } + if ($bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { throw "Invalid PE image: $Path" } + $offset = [BitConverter]::ToInt32($bytes, 0x3c) + if ($offset -lt 0x40 -or $offset + 6 -gt $bytes.Length) { throw "Invalid PE header: $Path" } + if ([BitConverter]::ToUInt32($bytes, $offset) -ne 0x00004550) { throw "Invalid PE header: $Path" } + return [BitConverter]::ToUInt16($bytes, $offset + 4) +} + +if ((Get-PeMachine $runtime64) -ne 0x8664) { throw "Windows OpenXR runtime is not x64" } +if ((Get-PeMachine $runtime32) -ne 0x014c) { throw "Windows OpenXR runtime is not x86" } +if ((Get-PeMachine $bridge32) -ne 0x014c) { throw "Wine OpenXR bridge companion is not x86" } +if ((Get-PeMachine $openComposite) -ne 0x8664) { throw "OpenComposite adapter is not x64" } +& (Join-Path $PSScriptRoot "verify-arm64x-wine-pair.ps1") -CompanionPath $bridge -UnixlibPath $unixlib | Out-Host +$entries = foreach ($file in @($runtime64, $runtime32, $bridge, $bridge32, $unixlib, $openComposite)) { + "$(Split-Path -Leaf $file) $((Get-FileHash -Algorithm SHA256 -LiteralPath $file).Hash.ToLowerInvariant())" +} +Set-Content -LiteralPath (Join-Path $payload "payload.version") -Value ((@("schema 4") + $entries) -join "`n")