From d4af1eb9d63017a9460ec357b6fa2e999028923d Mon Sep 17 00:00:00 2001 From: Florian Reintgen <85225228+TheReal-Flo@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:18:57 +0200 Subject: [PATCH 1/5] Add per-container OpenComposite support - Persist the OpenComposite setting in container configuration - Add UI controls and update Windows VR payload setup --- app/.container | 1 + .../ui/component/dialog/GraphicsTab.kt | 11 + .../ui/screen/xr/ImmersiveXrActivity.kt | 17 +- .../xr/windows/WindowsVrPayloadManager.kt | 2 +- .../xr/windows/WindowsVrRuntimeConfig.kt | 7 +- .../app/gamenative/utils/ContainerUtils.kt | 9 + .../com/winlator/container/ContainerData.kt | 3 + app/src/main/res/values/strings.xml | 2 + .../gamenative-main-rebuild-agent-prompt.md | 764 ++++++++++++++++++ tools/opencomposite-vulkan-x64.def | 21 + .../opencomposite-gamenative-wine.patch | 105 +++ tools/stage-opencomposite.ps1 | 58 +- 12 files changed, 983 insertions(+), 17 deletions(-) create mode 100644 app/.container create mode 100644 docs/xr/gamenative-main-rebuild-agent-prompt.md create mode 100644 tools/opencomposite-vulkan-x64.def create mode 100644 tools/patches/opencomposite-gamenative-wine.patch diff --git a/app/.container b/app/.container new file mode 100644 index 0000000000..d86536525b --- /dev/null +++ b/app/.container @@ -0,0 +1 @@ +{"id":"STEAM_XR_STALE_SNAPSHOT_TEST","name":"Container-STEAM_XR_STALE_SNAPSHOT_TEST","screenSize":"1280x720","envVars":"WRAPPER_MAX_IMAGE_COUNT=0 ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact,deck_emu MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform VKD3D_SHADER_MODEL=6_0 PULSE_LATENCY_MSEC=144","graphicsDriver":"wrapper-gamenative","graphicsDriverVersion":"25.1.0","graphicsDriverConfig":"vulkanVersion=1.3,version=System,blacklistedExtensions=,maxDeviceMemory=0,presentMode=mailbox,syncFrame=0,disablePresentWait=0,resourceType=auto,bcnEmulation=auto,bcnEmulationType=compute,bcnEmulationCache=0,gpuName=Device","rendererPresentMode":"fifo","displayRendererMode":"vulkan","sfCompatMode":true,"dxwrapper":"dxvk","dxwrapperConfig":"version=2.6.1-gplasync,framerate=0,maxDeviceMemory=0,async=1,asyncCache=0,vkd3dVersion=2.14.1,vkd3dLevel=12_1,ddrawrapper=none,csmt=3,gpuName=NVIDIA GeForce GTX 480,videoMemorySize=2048,strict_shader_math=1,OffscreenRenderingMode=fbo,renderer=gl","audioDriver":"pulseaudio","pulseaudioLowLatency":false,"wincomponents":"direct3d=1,directsound=1,directinput8=0,directinput=0,directmusic=0,directshow=0,directplay=0,vcrun2010=1,wmdecoder=1,opengl=0","drives":"D:C:\\Users\\flori\\AppData\\Local\\Temp\\robolectric-XrLaunchPreferencesPersistenceTest_launchPreferenceSaves_reloadContainerAndPreserveNewerEmulationSettings9075047523230950129\\external-files\\DownloadE:\/data\/data\/app.gamenative\/storage","showFPS":false,"launchRealSteam":false,"launchBionicSteam":false,"allowSteamUpdates":false,"inputType":3,"dinputMapperType":1,"wow64Mode":true,"startupSelection":2,"box86Version":"0.3.2","box64Version":"0.4.2","box86Preset":"PERFORMANCE","box64Preset":"PERFORMANCE","fexcorePreset":"INTERMEDIATE","desktopTheme":"LIGHT,IMAGE,#0277bd","extraData":{"xrLaunchMode":"vr","xrSteamLaunchIndex":1,"xrCustomArgs":"-openxr","xrPromptEveryLaunch":true,"xrRenderScale":"100","xrFramePacingDivisor":"1","xrOpenCompositeEnabled":"false","xrTheaterScreenEnabled":"true","xrClockEnabled":"true"},"rcfileId":0,"midiSoundFont":"","lc_all":"en_US.utf8","primaryController":1,"controllerMapping":"","execArgs":"","executablePath":"","needsUnpacking":true,"sdlControllerAPI":false,"disableMouseInput":false,"touchscreenMode":true,"shooterMode":true,"externalDisplayMode":"off","externalDisplaySwap":false,"useDRI3":true,"installPath":"","steamType":"normal","language":"english","containerVariant":"glibc","emulator":"FEXCore","fexcoreVersion":"2505","forceDlc":false,"localSavesOnly":false,"steamOfflineMode":false,"epicOfflineMode":false,"useLegacyDRM":false,"unpackFiles":false,"suspendPolicy":"manual","portraitMode":false} \ No newline at end of file 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 4acec3fef8..6ea660c0a9 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 @@ -195,6 +195,17 @@ fun GraphicsTabContent(state: ContainerConfigState, default: Boolean = false) { ) Text(text = "${config.xrRenderScale}%") } + if (!default) { + SettingsSwitch( + colors = settingsTileColorsAlt(), + title = { Text(text = stringResource(R.string.xr_open_composite)) }, + subtitle = { Text(text = stringResource(R.string.xr_open_composite_description)) }, + state = config.openCompositeEnabled, + onCheckedChange = { + state.config.value = config.copy(openCompositeEnabled = it) + }, + ) + } } SettingsListDropdown( colors = settingsTileColors(), 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 25a70ced10..62eb1fc9c7 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 @@ -46,6 +46,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope +import app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeConfig import kotlinx.coroutines.delay import app.gamenative.PluviaApp import app.gamenative.R @@ -99,8 +100,6 @@ 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 { @@ -401,8 +400,11 @@ 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, "true").toBoolean() - openCompositeEnabled = container.getExtra(EXTRA_WINDOWS_VR_OPEN_COMPOSITE, "false").toBoolean() + windowsVrEnabled = container.getExtra(WindowsVrRuntimeConfig.EXTRA_ENABLED, "true").toBoolean() + openCompositeEnabled = container.getExtra( + WindowsVrRuntimeConfig.EXTRA_OPEN_COMPOSITE_ENABLED, + "false", + ).toBoolean() windowsVrStatus = if (windowsVrEnabled) "Waiting for runtime" else "Disabled" applyQuadTransform() if (xrSessionHandle != 0L) { @@ -422,8 +424,11 @@ 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.putExtra(WindowsVrRuntimeConfig.EXTRA_ENABLED, windowsVrEnabled.toString()) + container.putExtra( + WindowsVrRuntimeConfig.EXTRA_OPEN_COMPOSITE_ENABLED, + openCompositeEnabled.toString(), + ) container.saveData() } } 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 index 5b79ea1d8c..9d14847d73 100644 --- 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 @@ -109,7 +109,7 @@ class WindowsVrPayloadManager( if (ini.isFile) writeIfChanged(iniBackup, ini.readBytes()) else writeIfChanged(iniMissing, byteArrayOf(1)) writeIfChanged(owner, "2\n".toByteArray()) writeIfChanged(target, adapter) - writeIfChanged(ini, "enableAudio=false\nlogEnabled=true\n".toByteArray()) + writeIfChanged(ini, "initUsingVulkan=false\nlogAllOpenVRCalls=false\n".toByteArray()) openCompositeDirectories += directory diagnostics.record("opencomposite", "installed path=${target.path}") } 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 index 67a474f475..ca6f31184b 100644 --- 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 @@ -13,10 +13,13 @@ data class WindowsVrRuntimeConfig( val renderScalePercent: Int = 100, ) { companion object { + const val EXTRA_ENABLED = "windowsVrEnabled" + const val EXTRA_OPEN_COMPOSITE_ENABLED = "windowsVrOpenCompositeEnabled" + fun from(container: Container): WindowsVrRuntimeConfig { return WindowsVrRuntimeConfig( - enabled = container.getExtra("windowsVrEnabled", "true").toBoolean(), - openCompositeEnabled = container.getExtra("windowsVrOpenCompositeEnabled", "false").toBoolean(), + enabled = container.getExtra(EXTRA_ENABLED, "true").toBoolean(), + openCompositeEnabled = container.getExtra(EXTRA_OPEN_COMPOSITE_ENABLED, "false").toBoolean(), renderScalePercent = container.xrRenderScale.coerceIn(25, 100), ) } diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 26131d87be..536255c925 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -10,6 +10,7 @@ import app.gamenative.service.SteamService import app.gamenative.service.amazon.AmazonService import app.gamenative.service.epic.EpicService import app.gamenative.service.gog.GOGService +import app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeConfig import com.winlator.container.Container import com.winlator.container.ContainerData import com.winlator.container.ContainerManager @@ -306,6 +307,10 @@ object ContainerUtils { displayRenderer = container.displayRenderer, xrRefreshRate = container.xrRefreshRate, xrRenderScale = container.xrRenderScale, + openCompositeEnabled = container.getExtra( + WindowsVrRuntimeConfig.EXTRA_OPEN_COMPOSITE_ENABLED, + "false", + ).toBoolean(), sfCompatMode = container.sfCompatMode, dxwrapper = container.dxWrapper, dxwrapperConfig = container.dxWrapperConfig, @@ -491,6 +496,10 @@ object ContainerUtils { container.displayRenderer = containerData.displayRenderer container.xrRefreshRate = containerData.xrRefreshRate container.xrRenderScale = containerData.xrRenderScale + container.putExtra( + WindowsVrRuntimeConfig.EXTRA_OPEN_COMPOSITE_ENABLED, + containerData.openCompositeEnabled.toString(), + ) container.sfCompatMode = containerData.sfCompatMode container.dxWrapper = containerData.dxwrapper container.dxWrapperConfig = containerData.dxwrapperConfig diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt index b1b5e3f5e1..3f4a8cf8e0 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -21,6 +21,7 @@ data class ContainerData( val displayRenderer: String = Container.DEFAULT_DISPLAY_RENDERER, val xrRefreshRate: Int = 72, val xrRenderScale: Int = 100, + val openCompositeEnabled: Boolean = false, val sfCompatMode: Boolean = true, var dxwrapper: String = Container.DEFAULT_DXWRAPPER, val dxwrapperConfig: String = "", @@ -124,6 +125,7 @@ data class ContainerData( "displayRenderer" to state.displayRenderer, "xrRefreshRate" to state.xrRefreshRate, "xrRenderScale" to state.xrRenderScale, + "openCompositeEnabled" to state.openCompositeEnabled, "sfCompatMode" to state.sfCompatMode, "dxwrapper" to state.dxwrapper, "dxwrapperConfig" to state.dxwrapperConfig, @@ -196,6 +198,7 @@ data class ContainerData( displayRenderer = (savedMap["displayRenderer"] as? String) ?: "vulkan", xrRefreshRate = (savedMap["xrRefreshRate"] as? Int) ?: 72, xrRenderScale = (savedMap["xrRenderScale"] as? Int) ?: 100, + openCompositeEnabled = (savedMap["openCompositeEnabled"] as? Boolean) ?: false, sfCompatMode = (savedMap["sfCompatMode"] as? Boolean) ?: true, dxwrapper = savedMap["dxwrapper"] as String, dxwrapperConfig = savedMap["dxwrapperConfig"] as String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1e080ea082..a42e6fa069 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2347,4 +2347,6 @@ AdjustsGradual gradually with a focus on stability VR Refresh Rate VR Render Scale + OpenComposite + Use OpenComposite for games that require SteamVR/OpenVR. Changes apply on the next game launch. diff --git a/docs/xr/gamenative-main-rebuild-agent-prompt.md b/docs/xr/gamenative-main-rebuild-agent-prompt.md new file mode 100644 index 0000000000..43227774e8 --- /dev/null +++ b/docs/xr/gamenative-main-rebuild-agent-prompt.md @@ -0,0 +1,764 @@ +# Agent handoff: rebuild GameNativeVR on official GameNative + +> This document is a self-contained implementation prompt. Give the complete file to the agent performing the migration. + +## Mission + +Build the GameNativeVR pipeline on top of the current official GameNative `master`, using GameNative's official `modernXr` immersive activity as the Android and Quest foundation. + +Do **not** create a standalone VR activity alongside the official one. Recreate the hardware-validated Windows OpenXR/Wine/AHardwareBuffer behavior specified below, terminating it inside the official immersive activity's single OpenXR session. The result must retain GameNative's normal container, launch, renderer, quick-menu, and lifecycle behavior while adding native PC-VR stereo presentation. + +The primary objective is the lowest-effort feature-complete migration that does not preserve architectural dead ends. Optimize only after the functional pipeline and fallback behavior are measurable. + +## Fresh-start assumptions and source of truth + +Assume a completely fresh environment with no previous conversation, no local migration branch, and no access to any earlier GameNativeVR implementation. Everything required to rebuild the feature is specified in this prompt. + +- The only source repository is `https://github.com/utkarshdalal/GameNative.git`. +- Start from the latest official `master` and record its exact SHA before editing. +- Official master was audited at `7213561c34b9619e5a0cd36b34362e94f0e735d7` on 2026-08-19. This is context, not a commit to reset to. +- The official immersive implementation originated in `https://github.com/utkarshdalal/GameNative/pull/1831`. +- FEX `FEX-2608`, published 2026-08-05, was current during the audit. + +Do not add another remote, fetch a fork, search for an old GameNativeVR branch, or assume prebuilt GameNativeVR source is available. Work from the checked-out current official `master` and implement the specification below using that codebase as the sole foundation. If the execution environment automatically requires a task branch for safe editing, it may create one from the recorded `master` SHA, but it must not import code from any other branch or repository. + +## Non-negotiable architecture + +There must be exactly **one Quest OpenXR session** and it must be the one owned by the official immersive activity/native module. + +```text +Windows game + ├─ Native OpenXR ───────────────┐ + └─ OpenVR → OpenComposite ──────┤ + v +Windows GameNative OpenXR runtime DLL + ├─ control/timing/input protocol + └─ Wine unixlib + AHardwareBuffer frame transport + v +WindowsVrRuntimeService in official ImmersiveXrActivity + v +official XrImmersiveSession (the only Quest OpenXR session) + ├─ stereo projection mode + ├─ flat fallback quad + ├─ official quick menu and passthrough + └─ official controller/lifecycle/performance handling + v +Quest compositor +``` + +Do not introduce another VR activity, create a second native Quest session, or layer a custom activity over `ImmersiveXrActivity`. Two competing sessions would create severe lifecycle, focus, timing, input, and performance problems. + +## What currently works and should be preserved + +### Proven on hardware + +The following path has produced a visible and playable Beat Saber image on Quest: + +```text +Windows OpenXR loader + → GameNative Windows OpenXR runtime + → D3D11/DXVK swapchains + → Wine unixlib + → AHardwareBuffer stereo transport + → Quest native projection layers +``` + +Preserve the behavior and data contracts behind: + +- Windows runtime negotiation, instance/session lifecycle, spaces, views, actions, haptics, and projection-layer parsing. +- D3D11/DXVK swapchain handling and the ARM64EC Wine bridge. +- AHardwareBuffer handle transfer over the local abstract Unix socket. +- Control-plane tracking, input, timing, frame state, and diagnostics. +- Runtime manifest plus 32-bit/64-bit registry/environment configuration. +- Per-game OpenComposite installation with backup and restoration. +- Effective container/emulator/graphics launch logging. +- Suspending redundant flat presentation after stereo becomes healthy. + +Implement these as new, `modernXr`-scoped modules in the official tree: + +- a Windows OpenXR runtime DLL for x64, with x86 support kept as a later compatibility target +- a fixed-width Wine unix-call ABI shared by the PE runtime and Wine unixlib +- a Wine unixlib plus ARM64EC/ARM64X builtin companion for Vulkan/DXVK interop +- an Android control-plane service +- a native AHardwareBuffer frame receiver integrated into `XrImmersiveSession` +- a crash-safe payload/runtime-manifest manager +- an optional per-game OpenComposite installer and restorer + +Use a clear new-source layout rather than mixing the implementation into the large Compose screen. Adapt names only if current official conventions require it: + +```text +app/src/main/java/app/gamenative/ui/screen/xr/windows/ + WindowsVrRuntimeService.kt + WindowsVrRuntimeConfig.kt + WindowsVrPayloadManager.kt + WindowsVrControlServer.kt + WindowsVrDiagnostics.kt + +app/src/main/cpp/xrimmersive/ + xr_immersive.cpp/.h # existing official session, extended + xr_windows_projection.cpp/.h # projection swapchains/layer submission + xr_windows_transport.cpp/.h # AHB/dma-buf receive and lifetime + xr_windows_protocol.h # shared bounded native structs/constants + +app/src/main/windows/openxr_runtime/ + gamenative_openxr_runtime.c + gamenative_openxr_runtime_x64.def + gamenative_openxr_runtime_x86.def # later milestone + gamenative_openxr_unix_abi.h + unix/gamenative_openxr_unix.c + builtin/gamenative_xr_unixbridge.c + +app/build/generated/xrPayload/modernXr/ # generated artifacts only +``` + +Register the generated payload directory only with the `modernXr` source set in `app/build.gradle.kts`; the audited build script explicitly defines `modernXr` asset directories, so merely creating `src/modernXr/assets` is not sufficient. Add reproducible build tasks/scripts for the Windows PE runtime, Wine unixlib/builtin companions, and Android native module. Pin compiler inputs and the Khronos OpenXR-Headers version/checksum. It is acceptable for a build task to download the pinned Khronos headers; do not copy an unexplained header snapshot or binary from another repository. Generated artifacts must be traceable to source and fail validation when stale or wrong-architecture. + +### Partially working, retain behind an experimental compatibility boundary + +- OpenComposite successfully initializes for some OpenVR titles and can establish OpenXR sessions, actions, and D3D11 bindings. +- The Lab reached the runtime, established sessions, and selected Quest Touch profiles, but did not produce a verified image. +- Bigscreen initialized OpenComposite but did not become a usable session and has crashed in its OpenVR startup path. + +Therefore OpenVR/OpenComposite is **not** a proven general compatibility path. Retain it as an adapter and diagnostic target, but do not describe it as complete or make it block native OpenXR parity. + +### Compiled or implemented but not sufficiently validated + +- Vulkan-client and D3D12 paths. +- 32-bit Windows runtime path. +- dma-buf/modifier transport on Quest; tested devices reported dma-buf import unavailable. +- Generic compatibility across non-Unity engines. + +Keep these code paths only where they remain maintainable and covered by build/smoke tests. Label them accurately in UI and logs. + +## What to discard from the new implementation + +“Discard” means do not recreate these legacy architectural paths in the fresh implementation. + +| Rejected path or behavior | Disposition | Reason | +|---|---|---| +| A second custom VR activity and native Quest session | Do not create | Conflicts with the official immersive activity and creates a second OpenXR owner. | +| Separate VR launchers or detours in `MainActivity`/`PluviaMain` | Do not create | Official `MainViewModel` already selects `ImmersiveXrActivity`. | +| A second custom X-server surface/view registry | Do not create | Official DirectGL/DirectVulkan bridges own flat presentation. Implement presentation suspension through their official hooks. | +| A hand-rendered menu `SurfaceTexture` or separate native menu renderer | Do not create | The official immersive quick menu is the correct UI/lifecycle owner. | +| Duplicate XR copies of normal container settings | Do not create | Official container persistence is the source of truth. Duplicated preferences create stale settings. | +| Launch-argument sanitizing heuristics | Do not create | They can delete legitimate game arguments. Use the official `LaunchInfo` and Steam launch selection unchanged. | +| Broad graphics-driver/wrapper forcing | Do not create | Respect the selected container configuration. Validate capability and report an actionable error instead. | +| Vendored OpenXR headers and a private Android loader setup | Do not create | Official `modernXr` already uses Khronos Prefab/OpenXR loader dependencies. | +| Patched Wine 9.2 winevulkan binaries/scripts | Do not create | Use GameNative-supported Proton/Wine ARM64EC paths and the Wine builtin companion described below. | +| Synthetic/test producer in production packaging | Move to debug/test only | Useful for transport testing, unnecessary in release. | +| Separate native theater and clock implementations | Do not create as core features | Official flat quad, passthrough, and quick menu cover the core use case. Add optional overlays later through the official session. | +| A second monolithic native Quest frame loop | Do not create | Extend the official session with small projection, transport, and timing components. | +| Beat Saber-specific TSO metadata | Do not implement | The product is generic and disabling TSO is correctness-sensitive. | + +Do not remove or rewrite unrelated official GameNative behavior to make the migration easier. + +## Official code that must remain the foundation + +The current official implementation is centered on: + +- `app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt` +- `app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveSessionHooks.kt` +- `app/src/main/java/app/gamenative/ui/screen/xr/XrNative.kt` +- `app/src/main/java/app/gamenative/ui/screen/xr/DirectGLBridge.kt` +- `app/src/main/java/app/gamenative/ui/screen/xr/DirectVulkanBridge.kt` +- `app/src/main/cpp/xrimmersive/xr_immersive.cpp` +- `app/src/main/cpp/xrimmersive/xr_immersive.h` + +The JNI entry points are currently implemented alongside the Kotlin declarations in `XrNative.kt` and the native immersive source rather than in a separate `xr_immersive_jni.cpp` file. + +It already provides: + +- One lifecycle-correct Quest OpenXR session. +- A flat 1280×720 quad layer. +- Direct GL and Vulkan AHardwareBuffer sharing for flat content, with PixelCopy fallback. +- Passthrough, Xbox-style Touch-controller input, official quick menu integration, and activity lifecycle handling. +- Sustained-high Quest CPU/GPU performance requests, a render-thread hint, and a 72 Hz display request. + +Extend this implementation; do not replace it. + +`XServerScreen.kt` is already close to Android's DEX/register limit. Preserve `ImmersiveSessionHooks` as a single parameter. Add capabilities through one cohesive hook/service object rather than adding many composable parameters or lambdas. + +## Self-contained implementation specification + +The following contracts replace any dependency on an older implementation. Names may be adapted to official conventions, but behavior, ownership, validation, and lifecycle boundaries must be preserved. + +### Windows OpenXR runtime + +Create a Windows OpenXR runtime DLL that exports `xrNegotiateLoaderRuntimeInterface` and `xrGetInstanceProcAddr`, negotiates OpenXR loader/runtime interface version correctly, and initially advertises OpenXR 1.0 plus `XR_KHR_D3D11_enable`. Build x64 first. Keep the source architecture-neutral enough to add x86 after x64 is hardware-validated. Vulkan and D3D12 extensions are later milestones, not prerequisites for the first working path. + +The first complete runtime must implement: + +- instance/system discovery and properties +- stereo view configuration and environment blend modes +- session create/destroy/begin/end/request-exit plus ordered session-state events +- local, stage, view, and action spaces +- frame wait/begin/end and view location +- swapchain create/destroy/enumerate/acquire/wait/release +- D3D11 graphics requirements +- paths and action sets/actions/bindings/synchronization +- boolean, float, vector2, and pose action state +- bound-source enumeration/localized names +- controller haptics + +Validate OpenXR structure types, array capacities, handle ownership, session call order, swapchain image state, sub-image rectangles, and projection-layer view counts. Unsupported extension functions must return `XR_ERROR_FUNCTION_UNSUPPORTED`; optional debug-utils requests must not abort initialization. + +For D3D11 under DXVK, obtain the underlying Vulkan physical device, logical device, queue, queue-family index, and submission synchronization through DXVK's native interop interface. Flush and lock the DXVK submission queue while scheduling transport copies, then unlock promptly. If native interop is unavailable, return a clear OpenXR graphics-device error and diagnostics instead of silently advertising a broken path. + +The runtime is a proxy: Windows swapchains are application-facing images, while submitted projection metadata and completed image contents are sent to the Quest endpoint. Parse every `XrCompositionLayerProjection` in `xrEndFrame`, preserve each view's swapchain/image index, array index, crop rectangle, orientation, position, and FOV, and submit left and right as one frame identity. + +### Control-plane protocol + +Implement a versioned, localhost-only ASCII request/response server at `127.0.0.1:38476`. Bind explicitly to loopback, set `TCP_NODELAY`, cap line length, cap concurrent clients, validate every token, and terminate malformed clients without affecting the activity. Protocol version 2 uses one command per line and one response per line. Floating-point values are signed integer micro-units (`float * 1_000_000`) so the Windows runtime may remain CRT-light. + +Required commands: + +| Command | Required response/behavior | +|---|---| +| `HELLO` | `OK GameNativeVR 2` | +| `GET_SYSTEM` | stable system id, vendor id, and a generic Meta Quest/GameNative name | +| `GET_VIEWS` | `OK count=2 width= height=` from the Quest view configuration | +| `GET_BOUNDS` | stage availability and width/height in micro-units | +| `BEGIN_SESSION`, `END_SESSION`, `REQUEST_EXIT` | validate/update the proxy session and forward exit requests | +| `WAIT_FRAME` | compositor-derived predicted display time, period, `shouldRender`, and session state; block on the next Quest frame signal rather than returning unbounded | +| `BEGIN_FRAME`, `END_FRAME layers=` | validate order and record first-frame progress | +| `LOCATE_VIEWS` | two poses/FOVs with validity flags | +| `GET_INPUT hand=<0|1>` | active flag, button mask, analog values, grip pose, and aim pose | +| `HAPTIC hand= amp= dur= freq=` | enqueue haptics on the Quest session | +| `GFX_API ` | record the active binding; unsupported bindings fail explicitly | +| `SWAPCHAIN_CREATE ...`, `SWAPCHAIN_DESTROY`, `SWAPCHAIN_RESET` | diagnostic/accounting events with validated dimensions/counts | +| `STATUS`, `BYE` | bounded status snapshot and clean disconnect | + +`LOCATE_VIEWS` carries, per eye, quaternion `qx qy qz qw`, position `px py pz`, and FOV angles `fl fr fu fd`. `GET_INPUT` carries trigger, squeeze, stick x/y, grip quaternion/position, and aim quaternion/position. Keep tracking/input snapshots behind small locks or atomically swapped immutable structs; never update Compose state for each request. + +The Quest native frame loop is the timing authority. Publish its `xrWaitFrame` result and view/controller locations to the control service once per compositor frame. The Windows runtime must not create an independent timer. + +### Wine unix-call ABI + +Create a packed, fixed-width ABI containing no native pointer-sized fields. Use `uint32`, `int32`, `uint64`, and `int64`; 32-bit clients zero-extend addresses into 64-bit fields. Include an explicit ABI version and reject mismatches. + +Required calls: + +1. initialize/version handshake +2. set Vulkan context: client physical device, device, queue, queue family/index, and whether handles are already host handles +3. create/destroy proxy swapchain +4. acquire/reuse an image slot with timeout +5. submit one image/view +6. submit a stereo pair atomically + +Support at most a small bounded number of swapchains and images (for example 32 swapchains and four images each). The stereo submission contains at most two views and includes slot, image index, eye, array index, crop rectangle, pose, and FOV in micro-units. Return explicit argument, unavailable, Vulkan, transport, and timeout errors. + +For ARM64EC Wine, package the unixlib as an aarch64 ELF plus a Wine-builtin ARM64X PE companion with correct machine type, 64 KiB section/file alignment, and Wine builtin signature. Also define where an x86 builtin companion will live when x86 support is enabled. Do not patch or replace Wine's generic `winevulkan` module. + +### Frame data-plane protocol + +Use a Linux abstract `AF_UNIX` `SOCK_STREAM` endpoint named `@gamenative-xr`; a filesystem `/tmp` socket inside proot is not reliably the same namespace as the Android process. Start the listener before Wine. Use bounded registration tables: two eyes, no more than 128 registered image slots per eye, and a latest-complete-frame exchange rather than an unbounded queue. + +Protocol messages are newline-delimited ASCII followed, where specified, by an ancillary handle/fd transfer: + +| Message | Semantics | +|---|---| +| `HELLO version=2` | negotiate protocol before any registration | +| `BUFFER eye=<0|1> index= w= h= swizzle=<0|1>` | consumer replies `OK`, producer sends an AHardwareBuffer handle, consumer replies `OK stored` | +| `DMABUF eye= index= w= h= planes=<1..4> fourcc= modifier= strideN= offsetN=` | optional fallback; transfer one `SCM_RIGHTS` fd per plane after `OK` | +| `FRAME frame= eye= index= fence=<0|1> x= y= w= h= projection=1 qx=... fd=...` | present one registered image plus crop and projection metadata; transfer acquire sync-fd when `fence=1` | +| `ACQUIRE eye= index= timeout=` | wait until the consumer releases the slot; return `OK fence=0` or `OK fence=1` followed by release sync-fd | +| `BYE` | cleanly disconnect without stopping the listener | + +The abbreviated `FRAME` projection fields are `qx qy qz qw px py pz fl fr fu fd`, all in micro-units. Validate crop bounds and all indices before claiming a frame. A line reader must stop exactly at newline and must not consume the one-byte payload used by the following ancillary transfer. + +On registration, receive the AHardwareBuffer with Android NDK handle APIs and retain it for the slot lifetime. Import it into the Quest render device only on the Quest render thread. On each `FRAME`, wait/import the acquire fence, sample/copy it into the Quest OpenXR projection swapchain, create a release fence after GPU consumption is queued, and make that fence available to `ACQUIRE`. Close every fd exactly once. Release or immediately acknowledge superseded, never-sampled frames so a fast producer cannot fill the ring and deadlock. + +Pair eyes by the monotonic `frame` id. Submit only a complete pair; never combine a new left eye with an unrelated right eye. If one eye is late, retain the previous complete pair for bounded compositor reuse. Buffer registration is persistent; do not resend hardware handles every frame. + +AHardwareBuffer is the required first path. dma-buf/modifiers are optional fallback because Quest devices may not expose usable dma-buf import. Log the selected path once, not every frame. + +### Initial producer copy path + +For the first reliable D3D11 implementation, allocate one importable transport AHardwareBuffer per eye/image slot using `AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM` with GPU sampled and color-output usage. Import it into the producer's Vulkan device with `VK_ANDROID_external_memory_android_hardware_buffer`. At submission: + +1. transition the game/DXVK source image to transfer source +2. transition the AHardwareBuffer-backed image to transfer destination +3. `vkCmdCopyImage` the requested subresource/rectangle +4. restore required layouts +5. submit on the synchronized DXVK queue +6. export a sync-fd acquire fence when supported +7. send the corresponding `FRAME` + +Handle BGRA-to-RGBA channel order through an explicit swizzle flag. Reuse command buffers, images, memory, and synchronization objects; do not allocate them per frame. This copy path is the known-safe baseline and must remain as fallback when the later direct-render optimization is attempted. + +### Payload and prefix layout + +Package generated artifacts inside the `modernXr` APK assets and install them idempotently into each selected prefix: + +```text +C:\gamenative\xr\gamenative_openxr64.dll +C:\gamenative\xr\gamenative_openxr32.dll # only when x86 is supported +C:\gamenative\xr\gamenative_openxr.json +C:\gamenative\xr\gamenative_openxr64.json +C:\gamenative\xr\gamenative_openxr32.json +C:\windows\system32\gamenative_openxr.dll # x64/common redirected path +C:\windows\syswow64\gamenative_openxr.dll # x86 redirected path +/lib/wine/aarch64-unix/gamenative_xr_unixbridge.so +/lib/wine/aarch64-windows/gamenative_xr_unixbridge.dll +C:\windows\system32\gamenative_xr_unixbridge.dll +``` + +Use architecture-correct equivalents for non-ARM64EC Wine. Validate PE machine type, ELF magic, builtin signature, and ARM64X alignment before advertising the runtime. + +Generate OpenXR runtime JSON with `file_format_version: "1.0.0"`, runtime name `GameNativeVR`, and the correct Windows library path. Set both Wine registry locations: + +```text +HKLM/Software/Khronos/OpenXR/1/ActiveRuntime +HKLM/Software/Wow6432Node/Khronos/OpenXR/1/ActiveRuntime +``` + +Also set `XR_RUNTIME_JSON=C:\gamenative\xr\gamenative_openxr.json`. Configure: + +```text +GAMENATIVE_XR=1 +GAMENATIVE_XR_SOCKET=@gamenative-xr +GAMENATIVE_XR_BRIDGE_HOST=127.0.0.1 +GAMENATIVE_XR_BRIDGE_PORT=38476 +GAMENATIVE_XR_RUNTIME_DIR= +``` + +Add logging variables only in diagnostic mode. Merge the required builtin override for `gamenative_xr_unixbridge=b` with the user's existing `WINEDLLOVERRIDES`; do not erase existing entries. The desktop Steam overlay may be disabled specifically for immersive Windows VR because it is not visible in the projection layer and adds hooks/memory, but log that effective override. + +For OpenVR compatibility, find `openvr_api.dll` files under the launched game only, verify architecture, save an adjacent uniquely named backup, atomically install the matching OpenComposite DLL, and write minimal `opencomposite.ini` settings. On disable or recovery, restore the original and remove only files marked as GameNative-owned. Never recursively modify unrelated Steam libraries. + +Obtain OpenComposite independently from its upstream project during implementation. Pin the selected commit/release and checksum in the build/payload metadata, package the x64 artifact in `modernXr`, and add x86 only with the x86 runtime milestone. Do not depend on an unversioned runtime download URL for normal launches. OpenComposite remains a replaceable compatibility adapter; no OpenComposite source is assumed to exist in the fresh clone. + +## Target state model + +Add an explicit presentation state owned by `XrImmersiveSession`: + +```text +STARTING + → FLAT_FALLBACK + → STEREO_ACTIVE + → STEREO_DEGRADED + → FLAT_FALLBACK + → STOPPING +``` + +- `STARTING`: activity/session is being created; transport listeners may start, but no game frame is assumed. +- `FLAT_FALLBACK`: submit the official flat quad and keep its normal DirectGL/DirectVulkan behavior. +- `STEREO_ACTIVE`: submit an `XrCompositionLayerProjection` with the latest complete left/right pair. Suspend redundant flat presentation. +- `STEREO_DEGRADED`: retain the latest safe stereo pair for a short bounded grace period while waiting for transport recovery. +- Return to `FLAT_FALLBACK` only after persistent loss, session teardown, or a fatal stereo compatibility error. +- `STOPPING`: stop listeners, restore payload replacements, and tear down in official lifecycle order. + +Never switch modes on a single late frame. Use consecutive success/miss thresholds and log each transition exactly once. + +## Canonical settings and launch behavior + +The normal GameNative container remains authoritative for: + +- Graphics driver and driver configuration. +- DX wrapper and DXVK/VKD3D configuration. +- Wine/Proton version. +- FEX/Box64/Box86 version and preset. +- CPU lists, WoW64 mode, Steam mode, environment variables, screen size, and launch arguments. + +Add only genuinely XR-specific settings to the official container model, for example: + +- `immersiveWindowsVrEnabled` +- `openVrCompatibilityMode`: `AUTO`, `ON`, or `OFF` +- `xrRenderScale` +- `xrPacingMode`: `AUTO`, `NATIVE`, or explicit `HALF_RATE` +- optional diagnostic logging level + +Do not write an entire stale `Container` snapshot from the VR UI. Update only the requested XR field through the official repository/manager and reload the canonical container before the next launch. + +Do not invent a second argument store. The final Windows command, `ColdClientLoader.ini`, and process snapshot must show the same executable and arguments selected by the official launch flow. + +## Implementation path + +### Phase 0 — Establish a clean official-master baseline and evidence ledger + +1. Clone `https://github.com/utkarshdalal/GameNative.git` if it is not already the fresh checkout. +2. Check out `master`, fast-forward it from `origin/master`, and record `git rev-parse HEAD`. Continue from that tree. Only create a task branch if the execution environment requires one for safe editing. +3. Do not add a fork/reference remote or import patches from another GameNativeVR implementation. +4. Run the existing official tests and a `modernXr` build before editing; record any pre-existing failures. +5. Inspect the official immersive files listed above and confirm how the current master wires `ImmersiveXrActivity`, `ImmersiveSessionHooks`, renderer bridges, quick menu, and container launch lifecycle. +6. Create a compact compatibility matrix with columns for API (`OpenXR`, `OpenVR`), graphics (`D3D11`, `D3D12`, `Vulkan`), architecture, runtime connection, session, first stereo pair, visible image, input, and teardown. + +### Phase 1 — Add a single Windows-VR integration service and minimal hooks + +Create a modernXr-scoped service, with names similar to: + +- `WindowsVrRuntimeService.kt` +- `WindowsVrRuntimeConfig.kt` +- `WindowsVrPayloadManager.kt` +- `WindowsVrDiagnostics.kt` + +The service owns: + +- Runtime payload preparation and restoration. +- Runtime manifest/registry/environment configuration. +- Control-server lifecycle. +- Frame-transport lifecycle coordination. +- Status/diagnostic snapshots. +- Presentation-state requests to the existing `XrImmersiveSession`. + +Extend `ImmersiveSessionHooks` with one nested object/interface, not a collection of new parameters. Provide lifecycle points equivalent to: + +```kotlin +interface WindowsVrLaunchHooks { + fun beforeWineSystemSetup(...) + fun afterContainerEnvironmentMerged(env: EnvVars, ...) + fun beforeGuestProcessStart(...) + fun onEnvironmentStarted(...) + fun onRendererAvailable(...) + fun onTeardown() +} +``` + +Adapt the signatures to official types. The important ordering rule is that critical XR environment variables must be applied **after** normal container environment variables are merged and **before** the guest process starts. Do not let an old/stale XR snapshot overwrite the canonical container. + +Keep changes inside `XServerScreen.kt` small and structural. Move logic into the service/helper classes to avoid another verifier/register failure. + +Acceptance criteria: + +- Non-immersive and non-`modernXr` builds behave exactly as before. +- Official flat immersive mode still launches, renders, opens its quick menu, and tears down normally with Windows VR disabled. +- One service instance is created and closed per immersive game launch, with no receiver/thread/socket leaks. + +### Phase 2 — Implement payload staging without policy overrides + +Implement `WindowsVrPayloadManager` and runtime configuration from the payload/prefix specification above: + +- Build/package x64 and x86 Windows runtime DLLs. +- Package the Wine unixlib and ARM64EC/PE bridge companions. +- Generate the active-runtime JSON manifests. +- Set `XR_RUNTIME_JSON` and 32-/64-bit active-runtime registry keys inside the selected prefix. +- Configure localhost control endpoint and abstract Unix transport endpoint. +- Install OpenComposite only when requested/auto-detected, preserving and restoring the original per-game `openvr_api.dll` atomically. +- Make preparation idempotent and recovery-safe after a killed activity. + +Do not add: + +- Wine 9.2 patch assets. +- hard-coded Beat Saber paths or Player.log names. +- forced wrapper changes. +- launch-argument filtering. + +Generalize diagnostics to discover logs from the launched executable/game directory. A missing game-specific log must not be labeled as “Beat Saber Unity Player log unavailable.” + +Add a payload version/schema marker so stale installed files are refreshed only when their content changes. Log file size, machine type, and hash in diagnostic mode without hashing large files every frame or launch-stage callback. + +Acceptance criteria: + +- A native OpenXR probe inside the prefix loads the GameNative runtime for both supported registry views. +- Repeated prepare/restore cycles do not lose original game DLLs. +- Selected graphics, wrapper, Wine, emulator, CPU, and launch arguments match the canonical container in effective-launch logs. + +### Phase 3 — Fold stereo presentation into the official native session + +Refactor the official native module into small internal units if necessary: + +- flat quad presenter (existing behavior) +- projection presenter +- imported-frame transport/queue +- input/tracking bridge +- timing/state coordinator + +Do not create another session creation or lifecycle loop. Implement these focused components inside the official native module: + +- AHardwareBuffer import and texture creation. +- left/right frame pairing and safe replacement. +- projection-view/sub-image construction. +- pose/FOV conversion where still applicable. +- bounded transport recovery logic. + +The official `XrImmersiveSession` continues to own: + +- `XrInstance`, `XrSession`, reference spaces, and frame loop. +- Quest swapchains. +- `xrWaitFrame`/`xrBeginFrame`/`xrEndFrame`. +- activity focus/session-state handling. +- flat fallback, quick menu, passthrough, and controller ownership. + +Add stereo color swapchains to this same session and submit the projection layer only when a complete, valid pair exists. Preserve the official flat quad as startup and compatibility fallback. + +Use the official Khronos Prefab dependency and loader. Remove any need to copy the vendored OpenXR header tree into the new branch. Make `libxrimmersive.so` reproducibly buildable from Gradle/CMake or add a verified generation task; do not rely on an unexplained checked-in binary. + +Acceptance criteria: + +- Native debug producer can drive projection mode without Wine in a debug build. +- Only one `xrCreateSession` occurs on the Quest side. +- Opening/closing the quick menu and pausing/resuming the activity do not create another session. +- Loss of producer falls back to the official flat panel without restarting the activity. + +### Phase 4 — Implement the Windows control plane + +Implement the localhost TCP protocol defined above inside the modernXr-scoped runtime service. Keep protocol versioning and validate all lengths/counts before allocation or JNI calls. + +The control plane must supply: + +- predicted display time/period +- head and eye poses/FOVs +- session state and `shouldRender` +- controller poses, buttons, axes, and interaction profile +- haptic requests +- frame acknowledgements and health information + +The data plane remains the abstract Unix socket carrying AHardwareBuffer handles and synchronization metadata. Do not combine both protocols merely for architectural neatness. Consider unification only after profiling shows a meaningful cost. + +Connect the service and native session through one bounded callback/status interface. Avoid high-frequency Compose state writes and avoid per-frame logcat/file logging. + +Acceptance criteria: + +- Windows `xrWaitFrame` timing is derived from the actual Quest frame loop. +- Tracking and input continue while flat fallback is visible. +- Malformed or disconnected clients cannot crash the activity. +- Teardown closes server sockets promptly and allows the next launch to bind immediately. + +### Phase 5 — Implement the frame data plane and prove native OpenXR/D3D11 first + +Implement the Wine unixlib, fixed-width ABI, builtin bridge companions, and AHardwareBuffer transport exactly from the contracts above. Integrate their build and packaging with the Proton/Wine versions supported by official GameNative. + +Bring up paths in this order: + +1. x64 native OpenXR + D3D11/DXVK +2. input and haptics +3. activity pause/resume and clean teardown +4. 32-bit runtime if a real test application requires it +5. Vulkan-client and D3D12 only after the first path is stable + +Do not claim a path based only on successful compilation or `xrCreateSession`. Require first valid stereo pair and a visible image. + +Maintain a small queue or latest-frame exchange rather than an unbounded frame queue. Hold a frame's hardware buffer until the compositor has finished the consuming operation, then release it deterministically. + +Acceptance criteria for the first milestone: + +- A native OpenXR D3D11 title reaches `STEREO_ACTIVE` and produces a visible image. +- The log records runtime connection, graphics binding, swapchain dimensions/formats, first complete pair, first projection submission, and stable mode transition. +- No per-frame allocations occur in the steady-state Android/native transport loop beyond driver/runtime behavior. + +### Phase 6 — Stop redundant flat presentation safely + +Keep this optimization in the initial integration. + +Official DirectGL already renders into its shared target efficiently, but the Vulkan immersive path can still forward content to the flat Vulkan compositor. Once the first valid stereo pair has been submitted: + +- suspend only flat **presentation/scanout** work +- keep X server state, window updates, input, and any content required for fallback alive +- stop PixelCopy/fallback capture timers +- prevent DirectVulkan from submitting the redundant flat target +- resume only after persistent stereo loss or explicit return to flat mode + +Implement this through official renderer/session APIs and one session-owned presentation gate. Make suspension idempotent and visible in diagnostics. + +Acceptance criteria: + +- A trace shows that flat presentation callbacks/submissions cease in `STEREO_ACTIVE`. +- Returning to `FLAT_FALLBACK` restores the flat image without restarting Wine. +- Menu rendering remains independent of flat game presentation. + +### Phase 7 — Integrate settings into the official immersive quick menu + +Add a Windows VR section/tab to the existing quick menu; do not create a separate hand-attached custom renderer. + +Show: + +- presentation state and connection health +- selected runtime path (`Native OpenXR` or `OpenVR compatibility`) +- render scale and pacing mode +- restart-required labels for settings that affect Wine/runtime creation +- a compact diagnostics/export action + +Runtime-safe changes may apply immediately. Wine, graphics-wrapper, emulator, runtime-installation, and most swapchain changes must be clearly marked for next launch. + +Do not keep the flat settings screen alive or mirror it into another texture solely to provide VR controls. + +### Phase 8 — Restore OpenVR through OpenComposite as an adapter + +After native OpenXR/D3D11 is stable, reconnect per-game OpenComposite: + +```text +OpenVR game → OpenComposite → GameNative Windows OpenXR runtime → same transport/session +``` + +OpenComposite must not create a second Quest endpoint. It is only an in-prefix API translator. + +Test startup, action manifests, controller bindings, session recreation on graphics-API selection, first stereo pair, and teardown. Preserve detailed OpenComposite logs, but rate-limit repetitive unmapped-binding warnings in GameNative's own logs. + +Do not add title-specific compatibility hacks during the first migration. Record failures by missing API/extension/format so fixes remain engine/runtime-general. + +Acceptance criteria: + +- At least one OpenVR title reaches a visible projection image. +- Native OpenXR continues to work with OpenComposite disabled. +- Original game DLL restoration survives normal exit, crash, and forced-stop recovery. + +### Phase 9 — Diagnostics and regression protection + +Replace game-specific diagnostics with a generic staged report: + +- canonical saved and loaded container settings +- effective launch configuration +- payload files, machine types, and versions +- runtime JSON and active registry values +- final executable, working directory, and exact arguments +- control/data connection state +- Quest and Windows session state +- graphics API, formats, sizes, and image counts +- first-frame milestones and presentation mode +- transport/import/copy path selected +- process snapshot and discovered game/runtime logs + +Use an in-memory ring buffer and flush on failure, explicit export, or teardown. Do not run full filesystem/process scans on the render path. + +Add tests for: + +- protocol encode/decode and malformed lengths +- format mapping and swapchain subrect validation +- left/right pairing and buffer lifetime +- state transition success/miss thresholds +- payload backup/restore and crash recovery +- container settings round-trip without stale overwrites +- launch-argument preservation +- native build/ABI symbols for x64, x86, ARM64EC, and Android arm64 where supported + +## Performance work: keep, defer, or discard + +### 1. Stop flat X-server rendering after stereo transport becomes active — keep now + +Implement as Phase 6. The goal is not to shut down the X server; it is to remove redundant flat presentation, capture, and compositor work while retaining a fast fallback. + +### 2. Eliminate the remaining AHardwareBuffer image copy — keep as post-parity R&D + +The safe baseline specified in “Initial producer copy path” imports a transport AHardwareBuffer as a Vulkan image and uses `vkCmdCopyImage` from the game's swapchain image. The Quest side then imports the buffer and blits it into the real Quest OpenXR swapchain. The first realistic optimization is to expose AHardwareBuffer-backed transport images directly as Windows OpenXR swapchain images so DXVK/the application renders into them, eliminating the producer-side copy. + +Do **not** make this the first implementation milestone. Directly sharing the Quest runtime's own swapchain images across the Windows/Wine boundary may be unsupported because of device, process, ownership, layout, and synchronization constraints. Implement capability probing and retain the proven copy path as fallback. + +Measure separately: + +- producer render-to-copy GPU time +- transport synchronization wait +- Quest import/blit GPU time +- end-to-end frame age + +Only call the path zero-copy when the measured producer copy is genuinely gone; importing an AHardwareBuffer followed by a blit is not full zero-copy. + +### 3. Profile and tune CPU scheduling and transport timing — keep after instrumentation + +Official immersive mode already requests sustained-high CPU/GPU performance, a render-thread hint, and 72 Hz. Do not add duplicate boost calls or hard-pin all Wine/FEX/Android threads. + +First collect Perfetto/OVR Metrics and per-stage timestamps. Then tune only identified hot threads such as the Quest frame loop, transport receiver, control server, and producer submission thread. Prefer thread hints/priority and bounded wakeups over global affinity. Confirm thermal behavior over a sustained run, not only startup FPS. + +### 4. Update FEX and use lower-memory JIT caches — separate post-parity workstream + +Official GameNative already uses FEX 2605 in relevant configurations; FEX 2608 was current during this audit. Upstream FEX 2605 and 2608 already default `DisableL2Cache=true` and `DynamicL1Cache=true`, which reduce memory use with possible compilation/stutter tradeoffs. Do not blindly force conflicting environment values. + +After functional parity: + +1. package/test the newer supported FEX release through GameNative's normal version mechanism +2. log effective cache settings, not merely selected preset names +3. benchmark memory, translation time, average frame time, and p95/p99 frame time +4. keep upstream defaults unless measurements justify a per-container option + +`EnableLazyCodeCachingWIP` exists in newer FEX but is explicitly WIP and default-off. Do not enable it globally. + +Reference: `https://github.com/FEX-Emu/FEX/releases/tag/FEX-2608` + +### 5. Beat Saber-specific TSO disabling metadata — discard + +Do not implement it. FEX warns that disabling TSO can break multithreaded applications, and a generic GameNative VR runtime should not ship a one-title unsafe exception without a broad, reproducible compatibility framework. + +### 6. Compositor-aware pacing instead of generic 2:1 repetition — keep after parity + +Replace the arbitrary frame divisor with timing derived from the single Quest compositor loop: + +- Quest `xrWaitFrame` predicted display time/period drives the Windows runtime's `xrWaitFrame` response. +- Submit each newly completed stereo pair when ready. +- If the producer misses a compositor interval, reuse the latest complete safe pair; do not block the Quest frame loop waiting indefinitely. +- Track frame age and missed producer/compositor intervals. +- Keep explicit half-rate mode only as a user-selected fallback, not the universal default. + +Avoid generic sleeps and DXVK latency-sleep options until timestamps show where latency is introduced. + +## Build and packaging requirements + +- Keep Windows runtime/unixlib artifacts scoped to `modernXr` so normal APKs do not absorb unnecessary payloads. +- Use the official OpenXR loader/Prefab dependency for Android native code. +- Make every generated native artifact reproducible and document its source/build command. +- Fail the build when an expected runtime DLL/unixlib is absent or the wrong architecture, rather than shipping a silent partial payload. +- Preserve release stripping while retaining a symbol/archive strategy for crash diagnosis. +- Do not run expensive hashes or directory scans during frame processing. + +## Validation matrix and milestone order + +Use this order and do not skip directly to broad game testing: + +1. Official flat immersive regression test with Windows VR disabled. +2. Native debug stereo producer in the official single session. +3. Windows native OpenXR x64 D3D11 probe. +4. Known working native OpenXR Unity/D3D11 title. +5. Input, haptics, pause/resume, menu, and transport-loss recovery. +6. One OpenVR/OpenComposite title with visible output. +7. Additional Unity versions, then a non-Unity engine. +8. Only then validate Vulkan-client, D3D12, and 32-bit paths. + +For every test record: + +- app/API/graphics/architecture +- exact GameNative, Wine/Proton, FEX, DXVK, and payload versions +- runtime connected +- session began +- first complete stereo pair +- first projection layer submitted +- visible image +- input/haptics +- flat suspension/fallback +- clean teardown +- average, p95, and p99 frame timing plus memory after a sustained interval + +“Session began” is not equivalent to “working.” + +## Commit strategy + +Keep changes reviewable: + +1. hook/service skeleton and flat regression +2. reproducible payload/build integration +3. native projection mode with debug producer +4. Windows control/data transport +5. native OpenXR D3D11 end-to-end +6. flat-presentation suspension and fallback +7. official quick-menu settings/diagnostics +8. OpenComposite compatibility +9. measured performance changes + +Each commit must build and preserve the previous milestone. Avoid a single merge commit containing the entire runtime, transport, UI, and performance work at once. + +## Definition of done + +The migration is complete only when: + +- Official `ImmersiveXrActivity` is the sole Quest activity/session for flat and stereo modes. +- Normal GameNative container settings and arguments are demonstrably the effective launch settings. +- Official flat immersive mode remains functional as startup and runtime fallback. +- The proven x64 native OpenXR D3D11 path produces a visible stereo image with input and clean teardown. +- Redundant flat presentation stops during stable stereo and resumes after persistent loss. +- OpenComposite is isolated as an optional adapter and its actual validation status is documented. +- Runtime payload installation/restoration is crash-safe and game-agnostic. +- There is no second VR activity/menu/settings fork, vendored OpenXR header snapshot, or Wine 9.2 pin in the new branch. +- Diagnostics identify the selected path and first failing stage without relying on Beat Saber-specific files. +- Performance claims are backed by frame-time, memory, and sustained thermal measurements. +- The compatibility matrix distinguishes proven, partial, compiled-only, and unsupported paths. + +## Final reporting format + +When handing the implementation back, report: + +1. official `master` base SHA and final implementation SHA +2. files/components added, modified, and architectural paths intentionally omitted +3. working compatibility matrix with evidence +4. exact remaining blockers by pipeline stage +5. before/after CPU, GPU, frame-time, latency, and RAM measurements +6. payload/build reproduction instructions +7. any manual device tests still required + +Do not report the project as generally SteamVR-compatible until at least one native OpenXR title and one OpenVR/OpenComposite title have each produced a visible, interactive image through the new single-session architecture. diff --git a/tools/opencomposite-vulkan-x64.def b/tools/opencomposite-vulkan-x64.def new file mode 100644 index 0000000000..8d09573e9b --- /dev/null +++ b/tools/opencomposite-vulkan-x64.def @@ -0,0 +1,21 @@ +LIBRARY vulkan-1.dll +EXPORTS + vkAllocateCommandBuffers + vkBeginCommandBuffer + vkCmdBlitImage + vkCmdCopyImage + vkCmdPipelineBarrier + vkCmdResolveImage + vkCreateCommandPool + vkCreateDevice + vkCreateInstance + vkDestroyCommandPool + vkDestroyDevice + vkDestroyInstance + vkEndCommandBuffer + vkEnumeratePhysicalDevices + vkFreeCommandBuffers + vkGetDeviceQueue + vkGetPhysicalDeviceProperties2 + vkGetPhysicalDeviceQueueFamilyProperties + vkQueueSubmit diff --git a/tools/patches/opencomposite-gamenative-wine.patch b/tools/patches/opencomposite-gamenative-wine.patch new file mode 100644 index 0000000000..0ee080d502 --- /dev/null +++ b/tools/patches/opencomposite-gamenative-wine.patch @@ -0,0 +1,105 @@ +diff --git a/DrvOpenXR/DrvOpenXR.cpp b/DrvOpenXR/DrvOpenXR.cpp +index 8e3bd9c..208e612 100644 +--- a/DrvOpenXR/DrvOpenXR.cpp ++++ b/DrvOpenXR/DrvOpenXR.cpp +@@ -28,8 +28,9 @@ std::string GetExeName() + { + char exePath[MAX_PATH + 1] = { 0 }; + DWORD len = GetModuleFileNameA(NULL, exePath, MAX_PATH); +- PathStripPathA(exePath); +- return { exePath }; ++ std::string path(exePath, len); ++ auto separator = path.find_last_of("\\/"); ++ return separator == std::string::npos ? path : path.substr(separator + 1); + } + #else + #include // basename +diff --git a/DrvOpenXR/XrHMD.cpp b/DrvOpenXR/XrHMD.cpp +index ffeab79..d2fe55c 100644 +--- a/DrvOpenXR/XrHMD.cpp ++++ b/DrvOpenXR/XrHMD.cpp +@@ -9,6 +9,7 @@ + #include "../OpenOVR/Reimpl/BaseSystem.h" + #include "../OpenOVR/convert.h" + #include "generated/static_bases.gen.h" ++#include + #include + #include + +diff --git a/OCOVR/openvr_api.cpp b/OCOVR/openvr_api.cpp +index a49444a..d8c3ffb 100644 +--- a/OCOVR/openvr_api.cpp ++++ b/OCOVR/openvr_api.cpp +@@ -111,9 +111,7 @@ VR_INTERFACE void* VR_CALLTYPE VR_GetGenericInterface(const char* interfaceVersi + + if (!valid_apptypes_success) { + valid_apptypes = 1ull << VRApplication_Scene; +-#ifndef _WIN32 + valid_apptypes |= (1ull << VRApplication_Background); // Proton uses this +-#endif + } + + if ((valid_apptypes & (1ull << current_apptype)) == 0) { +@@ -254,11 +254,9 @@ VR_INTERFACE uint32_t VR_CALLTYPE VR_InitInternal2(EVRInitError* peError, EVRApp + return current_init_token; + } + +- if (eApplicationType != VRApplication_Scene) +-#ifndef _WIN32 +- if (eApplicationType != VRApplication_Background) // Proton uses this +-#endif +- ERR("Cannot init VR: unsupported apptype " + to_string(eApplicationType)); ++ if (eApplicationType != VRApplication_Scene && ++ eApplicationType != VRApplication_Background) // Wine/Proton uses this ++ ERR("Cannot init VR: unsupported apptype " + to_string(eApplicationType)); + + if (running) + ERR("Cannot init VR: Already running!"); +diff --git a/OpenOVR/Compositor/compositor.h b/OpenOVR/Compositor/compositor.h +index e83a16a..d650b08 100644 +--- a/OpenOVR/Compositor/compositor.h ++++ b/OpenOVR/Compositor/compositor.h +@@ -2,8 +2,6 @@ + + #include "generated/interfaces/vrtypes.h" + #ifdef WIN32 +-// Windows template libraries +-#include + #include + #endif + +diff --git a/OpenOVR/Compositor/dx12compositor.cpp b/OpenOVR/Compositor/dx12compositor.cpp +index ff2fedd..4462eee 100644 +--- a/OpenOVR/Compositor/dx12compositor.cpp ++++ b/OpenOVR/Compositor/dx12compositor.cpp +@@ -6,8 +6,6 @@ + + #include + +-#include +- + #include + #include + +diff --git a/OpenOVR/Misc/Keyboard/VRKeyboard.cpp b/OpenOVR/Misc/Keyboard/VRKeyboard.cpp +index 6766ed4..c91cba3 100644 +--- a/OpenOVR/Misc/Keyboard/VRKeyboard.cpp ++++ b/OpenOVR/Misc/Keyboard/VRKeyboard.cpp +@@ -298,7 +298,7 @@ void VRKeyboard::Refresh() + { pixels, sizeof(pix_t) * desc.Width, sizeof(pix_t) * desc.Width * desc.Height } + }; + +- CComPtr tex; ++ ComPtr tex; + HRESULT rres = dev->CreateTexture2D(&desc, init, &tex); + // HRESULT hrr = dev->GetDeviceRemovedReason(); + OOVR_FAILED_DX_ABORT(rres); +@@ -309,7 +309,7 @@ void VRKeyboard::Refresh() + int index; + OOVR_FAILED_OVR_ABORT(ovr_GetTextureSwapChainCurrentIndex(*ovr::session, chain, &index)); + +- CComPtr dst; ++ ComPtr dst; + OOVR_FAILED_OVR_ABORT(ovr_GetTextureSwapChainBufferDX(*ovr::session, chain, index, IID_PPV_ARGS(&dst))); + + ctx->CopyResource(dst, tex); diff --git a/tools/stage-opencomposite.ps1 b/tools/stage-opencomposite.ps1 index 29a7f3cc2a..170beae118 100644 --- a/tools/stage-opencomposite.ps1 +++ b/tools/stage-opencomposite.ps1 @@ -2,14 +2,56 @@ $ErrorActionPreference = "Stop" $repository = Split-Path -Parent $PSScriptRoot $payload = Join-Path $repository "app\build\generated\xrPayload\modernXr" $destination = Join-Path $payload "opencomposite_x64.dll" -$uri = "https://opencomposite.znix.xyz/builds/download_build?artefact_id=5r2yCwgFn_ozJ44c&build_id=49918237&commit=43e551a4506880ab1a71b8b9fec2fd7fbb27372f" -$expected = "2f56d45323f252a2ea7c3047c806f9aca3cab36c3f9f71d1dad05a5008b7731a" +$commit = "a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d" +$source = Join-Path $repository "app\build\opencomposite-source" +$build = Join-Path $repository "app\build\opencomposite-build" +$patch = Join-Path $PSScriptRoot "patches\opencomposite-gamenative-wine.patch" +$vulkanDefinition = Join-Path $PSScriptRoot "opencomposite-vulkan-x64.def" +$ndk = if ($env:ANDROID_NDK_HOME) { $env:ANDROID_NDK_HOME } else { Join-Path $env:LOCALAPPDATA "Android\Sdk\ndk\29.0.14206865" } +$ndkIncludes = Join-Path $ndk "toolchains\llvm\prebuilt\windows-x86_64\sysroot\usr\include" +$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" + +function Invoke-Checked { + param([scriptblock]$Command, [string]$Name) + & $Command + if ($LASTEXITCODE -ne 0) { throw "$Name failed with exit code $LASTEXITCODE" } +} + +if (!(Test-Path (Join-Path $ndkIncludes "vulkan\vulkan.h"))) { throw "Android NDK Vulkan headers are required" } +if (!(Test-Path $vswhere)) { throw "Visual Studio Installer is required" } +$visualStudio = (& $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath | Select-Object -First 1) +if (!$visualStudio) { throw "Visual Studio C++ x64 build tools are required" } +$libTool = Get-ChildItem (Join-Path $visualStudio "VC\Tools\MSVC\*\bin\Hostx64\x64\lib.exe") | Sort-Object FullName -Descending | Select-Object -First 1 +if (!$libTool) { throw "Visual Studio lib.exe is required" } + New-Item -ItemType Directory -Force -Path $payload | Out-Null -$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) +if (!(Test-Path (Join-Path $source ".git"))) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $source) | Out-Null + Invoke-Checked { git clone --no-checkout https://gitlab.com/znixian/OpenOVR.git $source } "OpenComposite clone" +} +& git -C $source cat-file -e "$commit^{commit}" 2>$null +if ($LASTEXITCODE -ne 0) { + Invoke-Checked { git -C $source fetch --depth 1 origin $commit } "OpenComposite source fetch" +} +Invoke-Checked { git -C $source checkout --detach --force $commit } "OpenComposite checkout" +Invoke-Checked { git -C $source submodule update --init --recursive --depth 1 } "OpenComposite submodules" +Invoke-Checked { git -C $source apply --check $patch } "OpenComposite patch check" +Invoke-Checked { git -C $source apply $patch } "OpenComposite patch" + +$vulkan = Join-Path $source "libs\vulkan" +$vulkanInclude = Join-Path $vulkan "Include\vulkan" +$videoInclude = Join-Path $vulkan "Include\vk_video" +New-Item -ItemType Directory -Force -Path $vulkanInclude, $videoInclude, (Join-Path $vulkan "Lib") | Out-Null +Copy-Item -Recurse -Force -Path (Join-Path $ndkIncludes "vulkan\*") -Destination $vulkanInclude +Copy-Item -Recurse -Force -Path (Join-Path $ndkIncludes "vk_video\*") -Destination $videoInclude +Copy-Item -Force -LiteralPath $vulkanDefinition -Destination (Join-Path $vulkan "vulkan-1.def") +Invoke-Checked { & $libTool.FullName /nologo "/def:$(Join-Path $vulkan 'vulkan-1.def')" /machine:x64 "/out:$(Join-Path $vulkan 'Lib\vulkan-1.lib')" } "Vulkan import library" + +Invoke-Checked { cmake -S $source -B $build -G "Visual Studio 17 2022" -A x64 -DERROR_ON_WARNING=OFF "-DOC_VERSION=$commit-gamenative-wine-background" } "OpenComposite configure" +Invoke-Checked { cmake --build $build --config Release --target OCOVR --parallel } "OpenComposite build" +$built = Join-Path $build "bin\Release\vrclient_x64.dll" +if (!(Test-Path -LiteralPath $built -PathType Leaf)) { throw "OpenComposite build output is missing" } +Copy-Item -Force -LiteralPath $built -Destination $destination +$bytes = [System.IO.File]::ReadAllBytes($destination) $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 From 7fe36a1554412afa6a6bfbeb0f986ae0e7a7c7cd Mon Sep 17 00:00:00 2001 From: Florian Reintgen <85225228+TheReal-Flo@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:31:35 +0200 Subject: [PATCH 2/5] Fix OpenComposite background session handling --- .gitattributes | 1 + .gitignore | 3 + app/.container | 1 - app/build.gradle.kts | 16 + .../ui/screen/xr/ImmersiveXrActivity.kt | 126 ++- .../xr/windows/WindowsVrPayloadManager.kt | 2 + .../xr/windows/WindowsVrRuntimeConfig.kt | 8 + .../app/gamenative/utils/ContainerUtils.kt | 10 +- .../xr/windows/WindowsVrRuntimeConfigTest.kt | 34 + .../gamenative-main-rebuild-agent-prompt.md | 764 ------------------ .../opencomposite-gamenative-wine.patch | 39 +- tools/stage-opencomposite.ps1 | 2 +- 12 files changed, 144 insertions(+), 862 deletions(-) delete mode 100644 app/.container create mode 100644 app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfigTest.kt delete mode 100644 docs/xr/gamenative-main-rebuild-agent-prompt.md diff --git a/.gitattributes b/.gitattributes index e69de29bb2..6b653bb123 100644 --- a/.gitattributes +++ b/.gitattributes @@ -0,0 +1 @@ +*.patch whitespace=-space-before-tab diff --git a/.gitignore b/.gitignore index 52052d7600..bb5760a3e9 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,9 @@ resign.keystore /app/modernXr/ +# Robolectric/debug container snapshots written relative to the app module. +/app/.container + # Steam bootstrap shim source — withheld out of respect for Valve, since it # encodes internal details of Steam's proprietary client that Valve does not # publish. See THIRD_PARTY_NOTICES ("Steam Client Bootstrap Shim"). diff --git a/app/.container b/app/.container deleted file mode 100644 index d86536525b..0000000000 --- a/app/.container +++ /dev/null @@ -1 +0,0 @@ -{"id":"STEAM_XR_STALE_SNAPSHOT_TEST","name":"Container-STEAM_XR_STALE_SNAPSHOT_TEST","screenSize":"1280x720","envVars":"WRAPPER_MAX_IMAGE_COUNT=0 ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact,deck_emu MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform VKD3D_SHADER_MODEL=6_0 PULSE_LATENCY_MSEC=144","graphicsDriver":"wrapper-gamenative","graphicsDriverVersion":"25.1.0","graphicsDriverConfig":"vulkanVersion=1.3,version=System,blacklistedExtensions=,maxDeviceMemory=0,presentMode=mailbox,syncFrame=0,disablePresentWait=0,resourceType=auto,bcnEmulation=auto,bcnEmulationType=compute,bcnEmulationCache=0,gpuName=Device","rendererPresentMode":"fifo","displayRendererMode":"vulkan","sfCompatMode":true,"dxwrapper":"dxvk","dxwrapperConfig":"version=2.6.1-gplasync,framerate=0,maxDeviceMemory=0,async=1,asyncCache=0,vkd3dVersion=2.14.1,vkd3dLevel=12_1,ddrawrapper=none,csmt=3,gpuName=NVIDIA GeForce GTX 480,videoMemorySize=2048,strict_shader_math=1,OffscreenRenderingMode=fbo,renderer=gl","audioDriver":"pulseaudio","pulseaudioLowLatency":false,"wincomponents":"direct3d=1,directsound=1,directinput8=0,directinput=0,directmusic=0,directshow=0,directplay=0,vcrun2010=1,wmdecoder=1,opengl=0","drives":"D:C:\\Users\\flori\\AppData\\Local\\Temp\\robolectric-XrLaunchPreferencesPersistenceTest_launchPreferenceSaves_reloadContainerAndPreserveNewerEmulationSettings9075047523230950129\\external-files\\DownloadE:\/data\/data\/app.gamenative\/storage","showFPS":false,"launchRealSteam":false,"launchBionicSteam":false,"allowSteamUpdates":false,"inputType":3,"dinputMapperType":1,"wow64Mode":true,"startupSelection":2,"box86Version":"0.3.2","box64Version":"0.4.2","box86Preset":"PERFORMANCE","box64Preset":"PERFORMANCE","fexcorePreset":"INTERMEDIATE","desktopTheme":"LIGHT,IMAGE,#0277bd","extraData":{"xrLaunchMode":"vr","xrSteamLaunchIndex":1,"xrCustomArgs":"-openxr","xrPromptEveryLaunch":true,"xrRenderScale":"100","xrFramePacingDivisor":"1","xrOpenCompositeEnabled":"false","xrTheaterScreenEnabled":"true","xrClockEnabled":"true"},"rcfileId":0,"midiSoundFont":"","lc_all":"en_US.utf8","primaryController":1,"controllerMapping":"","execArgs":"","executablePath":"","needsUnpacking":true,"sdlControllerAPI":false,"disableMouseInput":false,"touchscreenMode":true,"shooterMode":true,"externalDisplayMode":"off","externalDisplaySwap":false,"useDRI3":true,"installPath":"","steamType":"normal","language":"english","containerVariant":"glibc","emulator":"FEXCore","fexcoreVersion":"2505","forceDlc":false,"localSavesOnly":false,"steamOfflineMode":false,"epicOfflineMode":false,"useLegacyDRM":false,"unpackFiles":false,"suspendPolicy":"manual","portraitMode":false} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 399f57028a..c31a632977 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -326,6 +326,22 @@ android { tasks.register("stageOpenComposite") { enabled = hostCanRunXrPayloadScripts + inputs.files( + rootProject.file("tools/stage-opencomposite.ps1"), + rootProject.file("tools/patches/opencomposite-gamenative-wine.patch"), + rootProject.file("tools/opencomposite-vulkan-x64.def"), + ) + inputs.property("openCompositeCommit", "a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d") + inputs.property("openCompositeBuildRevision", 2) + inputs.property( + "openCompositeNdkHome", + providers.environmentVariable("ANDROID_NDK_HOME").orElse("default-ndk-29.0.14206865"), + ) + inputs.property( + "openCompositeVisualStudioVersion", + providers.environmentVariable("VisualStudioVersion").orElse("auto-vs2022"), + ) + outputs.file(layout.buildDirectory.file("generated/xrPayload/modernXr/opencomposite_x64.dll")) commandLine( "powershell", "-ExecutionPolicy", 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 62eb1fc9c7..e07d920ae8 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 @@ -400,11 +400,9 @@ 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(WindowsVrRuntimeConfig.EXTRA_ENABLED, "true").toBoolean() - openCompositeEnabled = container.getExtra( - WindowsVrRuntimeConfig.EXTRA_OPEN_COMPOSITE_ENABLED, - "false", - ).toBoolean() + val windowsVrConfig = WindowsVrRuntimeConfig.from(container) + windowsVrEnabled = windowsVrConfig.enabled + openCompositeEnabled = windowsVrConfig.openCompositeEnabled windowsVrStatus = if (windowsVrEnabled) "Waiting for runtime" else "Disabled" applyQuadTransform() if (xrSessionHandle != 0L) { @@ -424,11 +422,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(WindowsVrRuntimeConfig.EXTRA_ENABLED, windowsVrEnabled.toString()) - container.putExtra( - WindowsVrRuntimeConfig.EXTRA_OPEN_COMPOSITE_ENABLED, - openCompositeEnabled.toString(), - ) + WindowsVrRuntimeConfig.setEnabled(container, windowsVrEnabled) + WindowsVrRuntimeConfig.setOpenCompositeEnabled(container, openCompositeEnabled) container.saveData() } } @@ -542,64 +537,65 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { var lastFedGamepad = false while (pollingActive.get()) { val winHandler = PluviaApp.xServerView?.getxServer()?.winHandler - if (winHandler != null) { - if (winHandler !== cachedWinHandler) { - cachedWinHandler = winHandler - 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 - if (setter != null) runOnUiThread { setter(lastPushedStartHeld) } - } - if (flags[1]) { - xrPointerModeActive = !xrPointerModeActive - Timber.i("Immersive: XR pointer mode %s", if (xrPointerModeActive) "enabled" else "disabled") - lastLeftActionPressed = (axes[4] > POINTER_GRAB_PRESS_THRESHOLD) || - ((buttons[0] and (1 shl XrGamepadBridge.BUTTON_LB)) != 0) - lastRightActionPressed = (axes[5] > POINTER_GRAB_PRESS_THRESHOLD) || - ((buttons[0] and (1 shl XrGamepadBridge.BUTTON_RB)) != 0) - if (!xrPointerModeActive) { - pointerGrabHand = null - pointerCursorLeftValid = false - pointerCursorRightValid = false - } - } - val inMenuMode = quickMenuVisible || PluviaApp.isOverlayPaused - overlayPausedUi = PluviaApp.isOverlayPaused - if (wasInMenuNavigationMode && !inMenuMode) { - buttonSuppressMaskUntilRelease = buttons[0] - } - wasInMenuNavigationMode = inMenuMode - val feedGame = !xrPointerModeActive && !inMenuMode - if (!feedGame && lastFedGamepad) cachedBridge?.reset() - lastFedGamepad = feedGame - when { - xrPointerModeActive -> handlePointerMode(buttons[0], axes, handPoses, flags[0]) - inMenuMode && !flags[2] -> handleMenuNavigation(buttons[0], axes) - inMenuMode -> Unit - else -> { - buttonSuppressMaskUntilRelease = buttonSuppressMaskUntilRelease and buttons[0] - cachedBridge?.applySnapshot(buttons[0] and buttonSuppressMaskUntilRelease.inv(), axes) - } + if (winHandler == null) { + cachedWinHandler = null + cachedBridge = null + } else if (winHandler !== cachedWinHandler) { + cachedWinHandler = winHandler + 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 + if (setter != null) runOnUiThread { setter(lastPushedStartHeld) } + } + if (flags[1]) { + xrPointerModeActive = !xrPointerModeActive + Timber.i("Immersive: XR pointer mode %s", if (xrPointerModeActive) "enabled" else "disabled") + lastLeftActionPressed = (axes[4] > POINTER_GRAB_PRESS_THRESHOLD) || + ((buttons[0] and (1 shl XrGamepadBridge.BUTTON_LB)) != 0) + lastRightActionPressed = (axes[5] > POINTER_GRAB_PRESS_THRESHOLD) || + ((buttons[0] and (1 shl XrGamepadBridge.BUTTON_RB)) != 0) + if (!xrPointerModeActive) { + pointerGrabHand = null + pointerCursorLeftValid = false + pointerCursorRightValid = false } - if (quickMenuClicked) { - Timber.i( - "Immersive: quick-menu chord fired, quickMenuToggle registered=%b buttons=0x%03x " + - "quickMenuVisible=%b isOverlayPaused=%b xrPointerModeActive=%b", - quickMenuToggle != null, buttons[0], quickMenuVisible, PluviaApp.isOverlayPaused, xrPointerModeActive, - ) - runOnUiThread { quickMenuToggle?.invoke() } + } + val inMenuMode = quickMenuVisible || PluviaApp.isOverlayPaused + overlayPausedUi = PluviaApp.isOverlayPaused + if (wasInMenuNavigationMode && !inMenuMode) { + buttonSuppressMaskUntilRelease = buttons[0] + } + wasInMenuNavigationMode = inMenuMode + val feedGame = winHandler != null && !xrPointerModeActive && !inMenuMode + if (!feedGame && lastFedGamepad) cachedBridge?.reset() + lastFedGamepad = feedGame + when { + xrPointerModeActive -> handlePointerMode(buttons[0], axes, handPoses, flags[0]) + inMenuMode && !flags[2] -> handleMenuNavigation(buttons[0], axes) + inMenuMode -> Unit + feedGame -> { + buttonSuppressMaskUntilRelease = buttonSuppressMaskUntilRelease and buttons[0] + cachedBridge?.applySnapshot(buttons[0] and buttonSuppressMaskUntilRelease.inv(), axes) } } + if (quickMenuClicked) { + Timber.i( + "Immersive: quick-menu chord fired, quickMenuToggle registered=%b buttons=0x%03x " + + "quickMenuVisible=%b isOverlayPaused=%b xrPointerModeActive=%b", + quickMenuToggle != null, buttons[0], quickMenuVisible, PluviaApp.isOverlayPaused, xrPointerModeActive, + ) + runOnUiThread { quickMenuToggle?.invoke() } + } try { Thread.sleep(POLL_INTERVAL_MS) } catch (e: InterruptedException) { 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 index 9d14847d73..ab2e186c15 100644 --- 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 @@ -109,6 +109,8 @@ class WindowsVrPayloadManager( if (ini.isFile) writeIfChanged(iniBackup, ini.readBytes()) else writeIfChanged(iniMissing, byteArrayOf(1)) writeIfChanged(owner, "2\n".toByteArray()) writeIfChanged(target, adapter) + // OpenComposite treats unknown INI keys as fatal. Keep this limited to + // options supported by the pinned payload; enableAudio is not one of them. writeIfChanged(ini, "initUsingVulkan=false\nlogAllOpenVRCalls=false\n".toByteArray()) openCompositeDirectories += directory diagnostics.record("opencomposite", "installed path=${target.path}") 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 index ca6f31184b..e02c95b267 100644 --- 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 @@ -23,5 +23,13 @@ data class WindowsVrRuntimeConfig( renderScalePercent = container.xrRenderScale.coerceIn(25, 100), ) } + + fun setEnabled(container: Container, enabled: Boolean) { + container.putExtra(EXTRA_ENABLED, enabled.toString()) + } + + fun setOpenCompositeEnabled(container: Container, enabled: Boolean) { + container.putExtra(EXTRA_OPEN_COMPOSITE_ENABLED, enabled.toString()) + } } } diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 536255c925..4803668448 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -307,10 +307,7 @@ object ContainerUtils { displayRenderer = container.displayRenderer, xrRefreshRate = container.xrRefreshRate, xrRenderScale = container.xrRenderScale, - openCompositeEnabled = container.getExtra( - WindowsVrRuntimeConfig.EXTRA_OPEN_COMPOSITE_ENABLED, - "false", - ).toBoolean(), + openCompositeEnabled = WindowsVrRuntimeConfig.from(container).openCompositeEnabled, sfCompatMode = container.sfCompatMode, dxwrapper = container.dxWrapper, dxwrapperConfig = container.dxWrapperConfig, @@ -496,10 +493,7 @@ object ContainerUtils { container.displayRenderer = containerData.displayRenderer container.xrRefreshRate = containerData.xrRefreshRate container.xrRenderScale = containerData.xrRenderScale - container.putExtra( - WindowsVrRuntimeConfig.EXTRA_OPEN_COMPOSITE_ENABLED, - containerData.openCompositeEnabled.toString(), - ) + WindowsVrRuntimeConfig.setOpenCompositeEnabled(container, containerData.openCompositeEnabled) container.sfCompatMode = containerData.sfCompatMode container.dxWrapper = containerData.dxwrapper container.dxWrapperConfig = containerData.dxwrapperConfig diff --git a/app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfigTest.kt b/app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfigTest.kt new file mode 100644 index 0000000000..d53263a217 --- /dev/null +++ b/app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfigTest.kt @@ -0,0 +1,34 @@ +package app.gamenative.ui.screen.xr.windows + +import com.winlator.container.Container +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class WindowsVrRuntimeConfigTest { + + @Test + fun `defaults keep runtime enabled and OpenComposite disabled`() { + val config = WindowsVrRuntimeConfig.from(Container("STEAM_TEST")) + + assertTrue(config.enabled) + assertFalse(config.openCompositeEnabled) + } + + @Test + fun `accessors round trip container settings through canonical keys`() { + val container = Container("STEAM_TEST") + + WindowsVrRuntimeConfig.setEnabled(container, false) + WindowsVrRuntimeConfig.setOpenCompositeEnabled(container, true) + + val config = WindowsVrRuntimeConfig.from(container) + assertFalse(config.enabled) + assertTrue(config.openCompositeEnabled) + } +} diff --git a/docs/xr/gamenative-main-rebuild-agent-prompt.md b/docs/xr/gamenative-main-rebuild-agent-prompt.md deleted file mode 100644 index 43227774e8..0000000000 --- a/docs/xr/gamenative-main-rebuild-agent-prompt.md +++ /dev/null @@ -1,764 +0,0 @@ -# Agent handoff: rebuild GameNativeVR on official GameNative - -> This document is a self-contained implementation prompt. Give the complete file to the agent performing the migration. - -## Mission - -Build the GameNativeVR pipeline on top of the current official GameNative `master`, using GameNative's official `modernXr` immersive activity as the Android and Quest foundation. - -Do **not** create a standalone VR activity alongside the official one. Recreate the hardware-validated Windows OpenXR/Wine/AHardwareBuffer behavior specified below, terminating it inside the official immersive activity's single OpenXR session. The result must retain GameNative's normal container, launch, renderer, quick-menu, and lifecycle behavior while adding native PC-VR stereo presentation. - -The primary objective is the lowest-effort feature-complete migration that does not preserve architectural dead ends. Optimize only after the functional pipeline and fallback behavior are measurable. - -## Fresh-start assumptions and source of truth - -Assume a completely fresh environment with no previous conversation, no local migration branch, and no access to any earlier GameNativeVR implementation. Everything required to rebuild the feature is specified in this prompt. - -- The only source repository is `https://github.com/utkarshdalal/GameNative.git`. -- Start from the latest official `master` and record its exact SHA before editing. -- Official master was audited at `7213561c34b9619e5a0cd36b34362e94f0e735d7` on 2026-08-19. This is context, not a commit to reset to. -- The official immersive implementation originated in `https://github.com/utkarshdalal/GameNative/pull/1831`. -- FEX `FEX-2608`, published 2026-08-05, was current during the audit. - -Do not add another remote, fetch a fork, search for an old GameNativeVR branch, or assume prebuilt GameNativeVR source is available. Work from the checked-out current official `master` and implement the specification below using that codebase as the sole foundation. If the execution environment automatically requires a task branch for safe editing, it may create one from the recorded `master` SHA, but it must not import code from any other branch or repository. - -## Non-negotiable architecture - -There must be exactly **one Quest OpenXR session** and it must be the one owned by the official immersive activity/native module. - -```text -Windows game - ├─ Native OpenXR ───────────────┐ - └─ OpenVR → OpenComposite ──────┤ - v -Windows GameNative OpenXR runtime DLL - ├─ control/timing/input protocol - └─ Wine unixlib + AHardwareBuffer frame transport - v -WindowsVrRuntimeService in official ImmersiveXrActivity - v -official XrImmersiveSession (the only Quest OpenXR session) - ├─ stereo projection mode - ├─ flat fallback quad - ├─ official quick menu and passthrough - └─ official controller/lifecycle/performance handling - v -Quest compositor -``` - -Do not introduce another VR activity, create a second native Quest session, or layer a custom activity over `ImmersiveXrActivity`. Two competing sessions would create severe lifecycle, focus, timing, input, and performance problems. - -## What currently works and should be preserved - -### Proven on hardware - -The following path has produced a visible and playable Beat Saber image on Quest: - -```text -Windows OpenXR loader - → GameNative Windows OpenXR runtime - → D3D11/DXVK swapchains - → Wine unixlib - → AHardwareBuffer stereo transport - → Quest native projection layers -``` - -Preserve the behavior and data contracts behind: - -- Windows runtime negotiation, instance/session lifecycle, spaces, views, actions, haptics, and projection-layer parsing. -- D3D11/DXVK swapchain handling and the ARM64EC Wine bridge. -- AHardwareBuffer handle transfer over the local abstract Unix socket. -- Control-plane tracking, input, timing, frame state, and diagnostics. -- Runtime manifest plus 32-bit/64-bit registry/environment configuration. -- Per-game OpenComposite installation with backup and restoration. -- Effective container/emulator/graphics launch logging. -- Suspending redundant flat presentation after stereo becomes healthy. - -Implement these as new, `modernXr`-scoped modules in the official tree: - -- a Windows OpenXR runtime DLL for x64, with x86 support kept as a later compatibility target -- a fixed-width Wine unix-call ABI shared by the PE runtime and Wine unixlib -- a Wine unixlib plus ARM64EC/ARM64X builtin companion for Vulkan/DXVK interop -- an Android control-plane service -- a native AHardwareBuffer frame receiver integrated into `XrImmersiveSession` -- a crash-safe payload/runtime-manifest manager -- an optional per-game OpenComposite installer and restorer - -Use a clear new-source layout rather than mixing the implementation into the large Compose screen. Adapt names only if current official conventions require it: - -```text -app/src/main/java/app/gamenative/ui/screen/xr/windows/ - WindowsVrRuntimeService.kt - WindowsVrRuntimeConfig.kt - WindowsVrPayloadManager.kt - WindowsVrControlServer.kt - WindowsVrDiagnostics.kt - -app/src/main/cpp/xrimmersive/ - xr_immersive.cpp/.h # existing official session, extended - xr_windows_projection.cpp/.h # projection swapchains/layer submission - xr_windows_transport.cpp/.h # AHB/dma-buf receive and lifetime - xr_windows_protocol.h # shared bounded native structs/constants - -app/src/main/windows/openxr_runtime/ - gamenative_openxr_runtime.c - gamenative_openxr_runtime_x64.def - gamenative_openxr_runtime_x86.def # later milestone - gamenative_openxr_unix_abi.h - unix/gamenative_openxr_unix.c - builtin/gamenative_xr_unixbridge.c - -app/build/generated/xrPayload/modernXr/ # generated artifacts only -``` - -Register the generated payload directory only with the `modernXr` source set in `app/build.gradle.kts`; the audited build script explicitly defines `modernXr` asset directories, so merely creating `src/modernXr/assets` is not sufficient. Add reproducible build tasks/scripts for the Windows PE runtime, Wine unixlib/builtin companions, and Android native module. Pin compiler inputs and the Khronos OpenXR-Headers version/checksum. It is acceptable for a build task to download the pinned Khronos headers; do not copy an unexplained header snapshot or binary from another repository. Generated artifacts must be traceable to source and fail validation when stale or wrong-architecture. - -### Partially working, retain behind an experimental compatibility boundary - -- OpenComposite successfully initializes for some OpenVR titles and can establish OpenXR sessions, actions, and D3D11 bindings. -- The Lab reached the runtime, established sessions, and selected Quest Touch profiles, but did not produce a verified image. -- Bigscreen initialized OpenComposite but did not become a usable session and has crashed in its OpenVR startup path. - -Therefore OpenVR/OpenComposite is **not** a proven general compatibility path. Retain it as an adapter and diagnostic target, but do not describe it as complete or make it block native OpenXR parity. - -### Compiled or implemented but not sufficiently validated - -- Vulkan-client and D3D12 paths. -- 32-bit Windows runtime path. -- dma-buf/modifier transport on Quest; tested devices reported dma-buf import unavailable. -- Generic compatibility across non-Unity engines. - -Keep these code paths only where they remain maintainable and covered by build/smoke tests. Label them accurately in UI and logs. - -## What to discard from the new implementation - -“Discard” means do not recreate these legacy architectural paths in the fresh implementation. - -| Rejected path or behavior | Disposition | Reason | -|---|---|---| -| A second custom VR activity and native Quest session | Do not create | Conflicts with the official immersive activity and creates a second OpenXR owner. | -| Separate VR launchers or detours in `MainActivity`/`PluviaMain` | Do not create | Official `MainViewModel` already selects `ImmersiveXrActivity`. | -| A second custom X-server surface/view registry | Do not create | Official DirectGL/DirectVulkan bridges own flat presentation. Implement presentation suspension through their official hooks. | -| A hand-rendered menu `SurfaceTexture` or separate native menu renderer | Do not create | The official immersive quick menu is the correct UI/lifecycle owner. | -| Duplicate XR copies of normal container settings | Do not create | Official container persistence is the source of truth. Duplicated preferences create stale settings. | -| Launch-argument sanitizing heuristics | Do not create | They can delete legitimate game arguments. Use the official `LaunchInfo` and Steam launch selection unchanged. | -| Broad graphics-driver/wrapper forcing | Do not create | Respect the selected container configuration. Validate capability and report an actionable error instead. | -| Vendored OpenXR headers and a private Android loader setup | Do not create | Official `modernXr` already uses Khronos Prefab/OpenXR loader dependencies. | -| Patched Wine 9.2 winevulkan binaries/scripts | Do not create | Use GameNative-supported Proton/Wine ARM64EC paths and the Wine builtin companion described below. | -| Synthetic/test producer in production packaging | Move to debug/test only | Useful for transport testing, unnecessary in release. | -| Separate native theater and clock implementations | Do not create as core features | Official flat quad, passthrough, and quick menu cover the core use case. Add optional overlays later through the official session. | -| A second monolithic native Quest frame loop | Do not create | Extend the official session with small projection, transport, and timing components. | -| Beat Saber-specific TSO metadata | Do not implement | The product is generic and disabling TSO is correctness-sensitive. | - -Do not remove or rewrite unrelated official GameNative behavior to make the migration easier. - -## Official code that must remain the foundation - -The current official implementation is centered on: - -- `app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt` -- `app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveSessionHooks.kt` -- `app/src/main/java/app/gamenative/ui/screen/xr/XrNative.kt` -- `app/src/main/java/app/gamenative/ui/screen/xr/DirectGLBridge.kt` -- `app/src/main/java/app/gamenative/ui/screen/xr/DirectVulkanBridge.kt` -- `app/src/main/cpp/xrimmersive/xr_immersive.cpp` -- `app/src/main/cpp/xrimmersive/xr_immersive.h` - -The JNI entry points are currently implemented alongside the Kotlin declarations in `XrNative.kt` and the native immersive source rather than in a separate `xr_immersive_jni.cpp` file. - -It already provides: - -- One lifecycle-correct Quest OpenXR session. -- A flat 1280×720 quad layer. -- Direct GL and Vulkan AHardwareBuffer sharing for flat content, with PixelCopy fallback. -- Passthrough, Xbox-style Touch-controller input, official quick menu integration, and activity lifecycle handling. -- Sustained-high Quest CPU/GPU performance requests, a render-thread hint, and a 72 Hz display request. - -Extend this implementation; do not replace it. - -`XServerScreen.kt` is already close to Android's DEX/register limit. Preserve `ImmersiveSessionHooks` as a single parameter. Add capabilities through one cohesive hook/service object rather than adding many composable parameters or lambdas. - -## Self-contained implementation specification - -The following contracts replace any dependency on an older implementation. Names may be adapted to official conventions, but behavior, ownership, validation, and lifecycle boundaries must be preserved. - -### Windows OpenXR runtime - -Create a Windows OpenXR runtime DLL that exports `xrNegotiateLoaderRuntimeInterface` and `xrGetInstanceProcAddr`, negotiates OpenXR loader/runtime interface version correctly, and initially advertises OpenXR 1.0 plus `XR_KHR_D3D11_enable`. Build x64 first. Keep the source architecture-neutral enough to add x86 after x64 is hardware-validated. Vulkan and D3D12 extensions are later milestones, not prerequisites for the first working path. - -The first complete runtime must implement: - -- instance/system discovery and properties -- stereo view configuration and environment blend modes -- session create/destroy/begin/end/request-exit plus ordered session-state events -- local, stage, view, and action spaces -- frame wait/begin/end and view location -- swapchain create/destroy/enumerate/acquire/wait/release -- D3D11 graphics requirements -- paths and action sets/actions/bindings/synchronization -- boolean, float, vector2, and pose action state -- bound-source enumeration/localized names -- controller haptics - -Validate OpenXR structure types, array capacities, handle ownership, session call order, swapchain image state, sub-image rectangles, and projection-layer view counts. Unsupported extension functions must return `XR_ERROR_FUNCTION_UNSUPPORTED`; optional debug-utils requests must not abort initialization. - -For D3D11 under DXVK, obtain the underlying Vulkan physical device, logical device, queue, queue-family index, and submission synchronization through DXVK's native interop interface. Flush and lock the DXVK submission queue while scheduling transport copies, then unlock promptly. If native interop is unavailable, return a clear OpenXR graphics-device error and diagnostics instead of silently advertising a broken path. - -The runtime is a proxy: Windows swapchains are application-facing images, while submitted projection metadata and completed image contents are sent to the Quest endpoint. Parse every `XrCompositionLayerProjection` in `xrEndFrame`, preserve each view's swapchain/image index, array index, crop rectangle, orientation, position, and FOV, and submit left and right as one frame identity. - -### Control-plane protocol - -Implement a versioned, localhost-only ASCII request/response server at `127.0.0.1:38476`. Bind explicitly to loopback, set `TCP_NODELAY`, cap line length, cap concurrent clients, validate every token, and terminate malformed clients without affecting the activity. Protocol version 2 uses one command per line and one response per line. Floating-point values are signed integer micro-units (`float * 1_000_000`) so the Windows runtime may remain CRT-light. - -Required commands: - -| Command | Required response/behavior | -|---|---| -| `HELLO` | `OK GameNativeVR 2` | -| `GET_SYSTEM` | stable system id, vendor id, and a generic Meta Quest/GameNative name | -| `GET_VIEWS` | `OK count=2 width= height=` from the Quest view configuration | -| `GET_BOUNDS` | stage availability and width/height in micro-units | -| `BEGIN_SESSION`, `END_SESSION`, `REQUEST_EXIT` | validate/update the proxy session and forward exit requests | -| `WAIT_FRAME` | compositor-derived predicted display time, period, `shouldRender`, and session state; block on the next Quest frame signal rather than returning unbounded | -| `BEGIN_FRAME`, `END_FRAME layers=` | validate order and record first-frame progress | -| `LOCATE_VIEWS` | two poses/FOVs with validity flags | -| `GET_INPUT hand=<0|1>` | active flag, button mask, analog values, grip pose, and aim pose | -| `HAPTIC hand= amp= dur= freq=` | enqueue haptics on the Quest session | -| `GFX_API ` | record the active binding; unsupported bindings fail explicitly | -| `SWAPCHAIN_CREATE ...`, `SWAPCHAIN_DESTROY`, `SWAPCHAIN_RESET` | diagnostic/accounting events with validated dimensions/counts | -| `STATUS`, `BYE` | bounded status snapshot and clean disconnect | - -`LOCATE_VIEWS` carries, per eye, quaternion `qx qy qz qw`, position `px py pz`, and FOV angles `fl fr fu fd`. `GET_INPUT` carries trigger, squeeze, stick x/y, grip quaternion/position, and aim quaternion/position. Keep tracking/input snapshots behind small locks or atomically swapped immutable structs; never update Compose state for each request. - -The Quest native frame loop is the timing authority. Publish its `xrWaitFrame` result and view/controller locations to the control service once per compositor frame. The Windows runtime must not create an independent timer. - -### Wine unix-call ABI - -Create a packed, fixed-width ABI containing no native pointer-sized fields. Use `uint32`, `int32`, `uint64`, and `int64`; 32-bit clients zero-extend addresses into 64-bit fields. Include an explicit ABI version and reject mismatches. - -Required calls: - -1. initialize/version handshake -2. set Vulkan context: client physical device, device, queue, queue family/index, and whether handles are already host handles -3. create/destroy proxy swapchain -4. acquire/reuse an image slot with timeout -5. submit one image/view -6. submit a stereo pair atomically - -Support at most a small bounded number of swapchains and images (for example 32 swapchains and four images each). The stereo submission contains at most two views and includes slot, image index, eye, array index, crop rectangle, pose, and FOV in micro-units. Return explicit argument, unavailable, Vulkan, transport, and timeout errors. - -For ARM64EC Wine, package the unixlib as an aarch64 ELF plus a Wine-builtin ARM64X PE companion with correct machine type, 64 KiB section/file alignment, and Wine builtin signature. Also define where an x86 builtin companion will live when x86 support is enabled. Do not patch or replace Wine's generic `winevulkan` module. - -### Frame data-plane protocol - -Use a Linux abstract `AF_UNIX` `SOCK_STREAM` endpoint named `@gamenative-xr`; a filesystem `/tmp` socket inside proot is not reliably the same namespace as the Android process. Start the listener before Wine. Use bounded registration tables: two eyes, no more than 128 registered image slots per eye, and a latest-complete-frame exchange rather than an unbounded queue. - -Protocol messages are newline-delimited ASCII followed, where specified, by an ancillary handle/fd transfer: - -| Message | Semantics | -|---|---| -| `HELLO version=2` | negotiate protocol before any registration | -| `BUFFER eye=<0|1> index= w= h= swizzle=<0|1>` | consumer replies `OK`, producer sends an AHardwareBuffer handle, consumer replies `OK stored` | -| `DMABUF eye= index= w= h= planes=<1..4> fourcc= modifier= strideN= offsetN=` | optional fallback; transfer one `SCM_RIGHTS` fd per plane after `OK` | -| `FRAME frame= eye= index= fence=<0|1> x= y= w= h= projection=1 qx=... fd=...` | present one registered image plus crop and projection metadata; transfer acquire sync-fd when `fence=1` | -| `ACQUIRE eye= index= timeout=` | wait until the consumer releases the slot; return `OK fence=0` or `OK fence=1` followed by release sync-fd | -| `BYE` | cleanly disconnect without stopping the listener | - -The abbreviated `FRAME` projection fields are `qx qy qz qw px py pz fl fr fu fd`, all in micro-units. Validate crop bounds and all indices before claiming a frame. A line reader must stop exactly at newline and must not consume the one-byte payload used by the following ancillary transfer. - -On registration, receive the AHardwareBuffer with Android NDK handle APIs and retain it for the slot lifetime. Import it into the Quest render device only on the Quest render thread. On each `FRAME`, wait/import the acquire fence, sample/copy it into the Quest OpenXR projection swapchain, create a release fence after GPU consumption is queued, and make that fence available to `ACQUIRE`. Close every fd exactly once. Release or immediately acknowledge superseded, never-sampled frames so a fast producer cannot fill the ring and deadlock. - -Pair eyes by the monotonic `frame` id. Submit only a complete pair; never combine a new left eye with an unrelated right eye. If one eye is late, retain the previous complete pair for bounded compositor reuse. Buffer registration is persistent; do not resend hardware handles every frame. - -AHardwareBuffer is the required first path. dma-buf/modifiers are optional fallback because Quest devices may not expose usable dma-buf import. Log the selected path once, not every frame. - -### Initial producer copy path - -For the first reliable D3D11 implementation, allocate one importable transport AHardwareBuffer per eye/image slot using `AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM` with GPU sampled and color-output usage. Import it into the producer's Vulkan device with `VK_ANDROID_external_memory_android_hardware_buffer`. At submission: - -1. transition the game/DXVK source image to transfer source -2. transition the AHardwareBuffer-backed image to transfer destination -3. `vkCmdCopyImage` the requested subresource/rectangle -4. restore required layouts -5. submit on the synchronized DXVK queue -6. export a sync-fd acquire fence when supported -7. send the corresponding `FRAME` - -Handle BGRA-to-RGBA channel order through an explicit swizzle flag. Reuse command buffers, images, memory, and synchronization objects; do not allocate them per frame. This copy path is the known-safe baseline and must remain as fallback when the later direct-render optimization is attempted. - -### Payload and prefix layout - -Package generated artifacts inside the `modernXr` APK assets and install them idempotently into each selected prefix: - -```text -C:\gamenative\xr\gamenative_openxr64.dll -C:\gamenative\xr\gamenative_openxr32.dll # only when x86 is supported -C:\gamenative\xr\gamenative_openxr.json -C:\gamenative\xr\gamenative_openxr64.json -C:\gamenative\xr\gamenative_openxr32.json -C:\windows\system32\gamenative_openxr.dll # x64/common redirected path -C:\windows\syswow64\gamenative_openxr.dll # x86 redirected path -/lib/wine/aarch64-unix/gamenative_xr_unixbridge.so -/lib/wine/aarch64-windows/gamenative_xr_unixbridge.dll -C:\windows\system32\gamenative_xr_unixbridge.dll -``` - -Use architecture-correct equivalents for non-ARM64EC Wine. Validate PE machine type, ELF magic, builtin signature, and ARM64X alignment before advertising the runtime. - -Generate OpenXR runtime JSON with `file_format_version: "1.0.0"`, runtime name `GameNativeVR`, and the correct Windows library path. Set both Wine registry locations: - -```text -HKLM/Software/Khronos/OpenXR/1/ActiveRuntime -HKLM/Software/Wow6432Node/Khronos/OpenXR/1/ActiveRuntime -``` - -Also set `XR_RUNTIME_JSON=C:\gamenative\xr\gamenative_openxr.json`. Configure: - -```text -GAMENATIVE_XR=1 -GAMENATIVE_XR_SOCKET=@gamenative-xr -GAMENATIVE_XR_BRIDGE_HOST=127.0.0.1 -GAMENATIVE_XR_BRIDGE_PORT=38476 -GAMENATIVE_XR_RUNTIME_DIR= -``` - -Add logging variables only in diagnostic mode. Merge the required builtin override for `gamenative_xr_unixbridge=b` with the user's existing `WINEDLLOVERRIDES`; do not erase existing entries. The desktop Steam overlay may be disabled specifically for immersive Windows VR because it is not visible in the projection layer and adds hooks/memory, but log that effective override. - -For OpenVR compatibility, find `openvr_api.dll` files under the launched game only, verify architecture, save an adjacent uniquely named backup, atomically install the matching OpenComposite DLL, and write minimal `opencomposite.ini` settings. On disable or recovery, restore the original and remove only files marked as GameNative-owned. Never recursively modify unrelated Steam libraries. - -Obtain OpenComposite independently from its upstream project during implementation. Pin the selected commit/release and checksum in the build/payload metadata, package the x64 artifact in `modernXr`, and add x86 only with the x86 runtime milestone. Do not depend on an unversioned runtime download URL for normal launches. OpenComposite remains a replaceable compatibility adapter; no OpenComposite source is assumed to exist in the fresh clone. - -## Target state model - -Add an explicit presentation state owned by `XrImmersiveSession`: - -```text -STARTING - → FLAT_FALLBACK - → STEREO_ACTIVE - → STEREO_DEGRADED - → FLAT_FALLBACK - → STOPPING -``` - -- `STARTING`: activity/session is being created; transport listeners may start, but no game frame is assumed. -- `FLAT_FALLBACK`: submit the official flat quad and keep its normal DirectGL/DirectVulkan behavior. -- `STEREO_ACTIVE`: submit an `XrCompositionLayerProjection` with the latest complete left/right pair. Suspend redundant flat presentation. -- `STEREO_DEGRADED`: retain the latest safe stereo pair for a short bounded grace period while waiting for transport recovery. -- Return to `FLAT_FALLBACK` only after persistent loss, session teardown, or a fatal stereo compatibility error. -- `STOPPING`: stop listeners, restore payload replacements, and tear down in official lifecycle order. - -Never switch modes on a single late frame. Use consecutive success/miss thresholds and log each transition exactly once. - -## Canonical settings and launch behavior - -The normal GameNative container remains authoritative for: - -- Graphics driver and driver configuration. -- DX wrapper and DXVK/VKD3D configuration. -- Wine/Proton version. -- FEX/Box64/Box86 version and preset. -- CPU lists, WoW64 mode, Steam mode, environment variables, screen size, and launch arguments. - -Add only genuinely XR-specific settings to the official container model, for example: - -- `immersiveWindowsVrEnabled` -- `openVrCompatibilityMode`: `AUTO`, `ON`, or `OFF` -- `xrRenderScale` -- `xrPacingMode`: `AUTO`, `NATIVE`, or explicit `HALF_RATE` -- optional diagnostic logging level - -Do not write an entire stale `Container` snapshot from the VR UI. Update only the requested XR field through the official repository/manager and reload the canonical container before the next launch. - -Do not invent a second argument store. The final Windows command, `ColdClientLoader.ini`, and process snapshot must show the same executable and arguments selected by the official launch flow. - -## Implementation path - -### Phase 0 — Establish a clean official-master baseline and evidence ledger - -1. Clone `https://github.com/utkarshdalal/GameNative.git` if it is not already the fresh checkout. -2. Check out `master`, fast-forward it from `origin/master`, and record `git rev-parse HEAD`. Continue from that tree. Only create a task branch if the execution environment requires one for safe editing. -3. Do not add a fork/reference remote or import patches from another GameNativeVR implementation. -4. Run the existing official tests and a `modernXr` build before editing; record any pre-existing failures. -5. Inspect the official immersive files listed above and confirm how the current master wires `ImmersiveXrActivity`, `ImmersiveSessionHooks`, renderer bridges, quick menu, and container launch lifecycle. -6. Create a compact compatibility matrix with columns for API (`OpenXR`, `OpenVR`), graphics (`D3D11`, `D3D12`, `Vulkan`), architecture, runtime connection, session, first stereo pair, visible image, input, and teardown. - -### Phase 1 — Add a single Windows-VR integration service and minimal hooks - -Create a modernXr-scoped service, with names similar to: - -- `WindowsVrRuntimeService.kt` -- `WindowsVrRuntimeConfig.kt` -- `WindowsVrPayloadManager.kt` -- `WindowsVrDiagnostics.kt` - -The service owns: - -- Runtime payload preparation and restoration. -- Runtime manifest/registry/environment configuration. -- Control-server lifecycle. -- Frame-transport lifecycle coordination. -- Status/diagnostic snapshots. -- Presentation-state requests to the existing `XrImmersiveSession`. - -Extend `ImmersiveSessionHooks` with one nested object/interface, not a collection of new parameters. Provide lifecycle points equivalent to: - -```kotlin -interface WindowsVrLaunchHooks { - fun beforeWineSystemSetup(...) - fun afterContainerEnvironmentMerged(env: EnvVars, ...) - fun beforeGuestProcessStart(...) - fun onEnvironmentStarted(...) - fun onRendererAvailable(...) - fun onTeardown() -} -``` - -Adapt the signatures to official types. The important ordering rule is that critical XR environment variables must be applied **after** normal container environment variables are merged and **before** the guest process starts. Do not let an old/stale XR snapshot overwrite the canonical container. - -Keep changes inside `XServerScreen.kt` small and structural. Move logic into the service/helper classes to avoid another verifier/register failure. - -Acceptance criteria: - -- Non-immersive and non-`modernXr` builds behave exactly as before. -- Official flat immersive mode still launches, renders, opens its quick menu, and tears down normally with Windows VR disabled. -- One service instance is created and closed per immersive game launch, with no receiver/thread/socket leaks. - -### Phase 2 — Implement payload staging without policy overrides - -Implement `WindowsVrPayloadManager` and runtime configuration from the payload/prefix specification above: - -- Build/package x64 and x86 Windows runtime DLLs. -- Package the Wine unixlib and ARM64EC/PE bridge companions. -- Generate the active-runtime JSON manifests. -- Set `XR_RUNTIME_JSON` and 32-/64-bit active-runtime registry keys inside the selected prefix. -- Configure localhost control endpoint and abstract Unix transport endpoint. -- Install OpenComposite only when requested/auto-detected, preserving and restoring the original per-game `openvr_api.dll` atomically. -- Make preparation idempotent and recovery-safe after a killed activity. - -Do not add: - -- Wine 9.2 patch assets. -- hard-coded Beat Saber paths or Player.log names. -- forced wrapper changes. -- launch-argument filtering. - -Generalize diagnostics to discover logs from the launched executable/game directory. A missing game-specific log must not be labeled as “Beat Saber Unity Player log unavailable.” - -Add a payload version/schema marker so stale installed files are refreshed only when their content changes. Log file size, machine type, and hash in diagnostic mode without hashing large files every frame or launch-stage callback. - -Acceptance criteria: - -- A native OpenXR probe inside the prefix loads the GameNative runtime for both supported registry views. -- Repeated prepare/restore cycles do not lose original game DLLs. -- Selected graphics, wrapper, Wine, emulator, CPU, and launch arguments match the canonical container in effective-launch logs. - -### Phase 3 — Fold stereo presentation into the official native session - -Refactor the official native module into small internal units if necessary: - -- flat quad presenter (existing behavior) -- projection presenter -- imported-frame transport/queue -- input/tracking bridge -- timing/state coordinator - -Do not create another session creation or lifecycle loop. Implement these focused components inside the official native module: - -- AHardwareBuffer import and texture creation. -- left/right frame pairing and safe replacement. -- projection-view/sub-image construction. -- pose/FOV conversion where still applicable. -- bounded transport recovery logic. - -The official `XrImmersiveSession` continues to own: - -- `XrInstance`, `XrSession`, reference spaces, and frame loop. -- Quest swapchains. -- `xrWaitFrame`/`xrBeginFrame`/`xrEndFrame`. -- activity focus/session-state handling. -- flat fallback, quick menu, passthrough, and controller ownership. - -Add stereo color swapchains to this same session and submit the projection layer only when a complete, valid pair exists. Preserve the official flat quad as startup and compatibility fallback. - -Use the official Khronos Prefab dependency and loader. Remove any need to copy the vendored OpenXR header tree into the new branch. Make `libxrimmersive.so` reproducibly buildable from Gradle/CMake or add a verified generation task; do not rely on an unexplained checked-in binary. - -Acceptance criteria: - -- Native debug producer can drive projection mode without Wine in a debug build. -- Only one `xrCreateSession` occurs on the Quest side. -- Opening/closing the quick menu and pausing/resuming the activity do not create another session. -- Loss of producer falls back to the official flat panel without restarting the activity. - -### Phase 4 — Implement the Windows control plane - -Implement the localhost TCP protocol defined above inside the modernXr-scoped runtime service. Keep protocol versioning and validate all lengths/counts before allocation or JNI calls. - -The control plane must supply: - -- predicted display time/period -- head and eye poses/FOVs -- session state and `shouldRender` -- controller poses, buttons, axes, and interaction profile -- haptic requests -- frame acknowledgements and health information - -The data plane remains the abstract Unix socket carrying AHardwareBuffer handles and synchronization metadata. Do not combine both protocols merely for architectural neatness. Consider unification only after profiling shows a meaningful cost. - -Connect the service and native session through one bounded callback/status interface. Avoid high-frequency Compose state writes and avoid per-frame logcat/file logging. - -Acceptance criteria: - -- Windows `xrWaitFrame` timing is derived from the actual Quest frame loop. -- Tracking and input continue while flat fallback is visible. -- Malformed or disconnected clients cannot crash the activity. -- Teardown closes server sockets promptly and allows the next launch to bind immediately. - -### Phase 5 — Implement the frame data plane and prove native OpenXR/D3D11 first - -Implement the Wine unixlib, fixed-width ABI, builtin bridge companions, and AHardwareBuffer transport exactly from the contracts above. Integrate their build and packaging with the Proton/Wine versions supported by official GameNative. - -Bring up paths in this order: - -1. x64 native OpenXR + D3D11/DXVK -2. input and haptics -3. activity pause/resume and clean teardown -4. 32-bit runtime if a real test application requires it -5. Vulkan-client and D3D12 only after the first path is stable - -Do not claim a path based only on successful compilation or `xrCreateSession`. Require first valid stereo pair and a visible image. - -Maintain a small queue or latest-frame exchange rather than an unbounded frame queue. Hold a frame's hardware buffer until the compositor has finished the consuming operation, then release it deterministically. - -Acceptance criteria for the first milestone: - -- A native OpenXR D3D11 title reaches `STEREO_ACTIVE` and produces a visible image. -- The log records runtime connection, graphics binding, swapchain dimensions/formats, first complete pair, first projection submission, and stable mode transition. -- No per-frame allocations occur in the steady-state Android/native transport loop beyond driver/runtime behavior. - -### Phase 6 — Stop redundant flat presentation safely - -Keep this optimization in the initial integration. - -Official DirectGL already renders into its shared target efficiently, but the Vulkan immersive path can still forward content to the flat Vulkan compositor. Once the first valid stereo pair has been submitted: - -- suspend only flat **presentation/scanout** work -- keep X server state, window updates, input, and any content required for fallback alive -- stop PixelCopy/fallback capture timers -- prevent DirectVulkan from submitting the redundant flat target -- resume only after persistent stereo loss or explicit return to flat mode - -Implement this through official renderer/session APIs and one session-owned presentation gate. Make suspension idempotent and visible in diagnostics. - -Acceptance criteria: - -- A trace shows that flat presentation callbacks/submissions cease in `STEREO_ACTIVE`. -- Returning to `FLAT_FALLBACK` restores the flat image without restarting Wine. -- Menu rendering remains independent of flat game presentation. - -### Phase 7 — Integrate settings into the official immersive quick menu - -Add a Windows VR section/tab to the existing quick menu; do not create a separate hand-attached custom renderer. - -Show: - -- presentation state and connection health -- selected runtime path (`Native OpenXR` or `OpenVR compatibility`) -- render scale and pacing mode -- restart-required labels for settings that affect Wine/runtime creation -- a compact diagnostics/export action - -Runtime-safe changes may apply immediately. Wine, graphics-wrapper, emulator, runtime-installation, and most swapchain changes must be clearly marked for next launch. - -Do not keep the flat settings screen alive or mirror it into another texture solely to provide VR controls. - -### Phase 8 — Restore OpenVR through OpenComposite as an adapter - -After native OpenXR/D3D11 is stable, reconnect per-game OpenComposite: - -```text -OpenVR game → OpenComposite → GameNative Windows OpenXR runtime → same transport/session -``` - -OpenComposite must not create a second Quest endpoint. It is only an in-prefix API translator. - -Test startup, action manifests, controller bindings, session recreation on graphics-API selection, first stereo pair, and teardown. Preserve detailed OpenComposite logs, but rate-limit repetitive unmapped-binding warnings in GameNative's own logs. - -Do not add title-specific compatibility hacks during the first migration. Record failures by missing API/extension/format so fixes remain engine/runtime-general. - -Acceptance criteria: - -- At least one OpenVR title reaches a visible projection image. -- Native OpenXR continues to work with OpenComposite disabled. -- Original game DLL restoration survives normal exit, crash, and forced-stop recovery. - -### Phase 9 — Diagnostics and regression protection - -Replace game-specific diagnostics with a generic staged report: - -- canonical saved and loaded container settings -- effective launch configuration -- payload files, machine types, and versions -- runtime JSON and active registry values -- final executable, working directory, and exact arguments -- control/data connection state -- Quest and Windows session state -- graphics API, formats, sizes, and image counts -- first-frame milestones and presentation mode -- transport/import/copy path selected -- process snapshot and discovered game/runtime logs - -Use an in-memory ring buffer and flush on failure, explicit export, or teardown. Do not run full filesystem/process scans on the render path. - -Add tests for: - -- protocol encode/decode and malformed lengths -- format mapping and swapchain subrect validation -- left/right pairing and buffer lifetime -- state transition success/miss thresholds -- payload backup/restore and crash recovery -- container settings round-trip without stale overwrites -- launch-argument preservation -- native build/ABI symbols for x64, x86, ARM64EC, and Android arm64 where supported - -## Performance work: keep, defer, or discard - -### 1. Stop flat X-server rendering after stereo transport becomes active — keep now - -Implement as Phase 6. The goal is not to shut down the X server; it is to remove redundant flat presentation, capture, and compositor work while retaining a fast fallback. - -### 2. Eliminate the remaining AHardwareBuffer image copy — keep as post-parity R&D - -The safe baseline specified in “Initial producer copy path” imports a transport AHardwareBuffer as a Vulkan image and uses `vkCmdCopyImage` from the game's swapchain image. The Quest side then imports the buffer and blits it into the real Quest OpenXR swapchain. The first realistic optimization is to expose AHardwareBuffer-backed transport images directly as Windows OpenXR swapchain images so DXVK/the application renders into them, eliminating the producer-side copy. - -Do **not** make this the first implementation milestone. Directly sharing the Quest runtime's own swapchain images across the Windows/Wine boundary may be unsupported because of device, process, ownership, layout, and synchronization constraints. Implement capability probing and retain the proven copy path as fallback. - -Measure separately: - -- producer render-to-copy GPU time -- transport synchronization wait -- Quest import/blit GPU time -- end-to-end frame age - -Only call the path zero-copy when the measured producer copy is genuinely gone; importing an AHardwareBuffer followed by a blit is not full zero-copy. - -### 3. Profile and tune CPU scheduling and transport timing — keep after instrumentation - -Official immersive mode already requests sustained-high CPU/GPU performance, a render-thread hint, and 72 Hz. Do not add duplicate boost calls or hard-pin all Wine/FEX/Android threads. - -First collect Perfetto/OVR Metrics and per-stage timestamps. Then tune only identified hot threads such as the Quest frame loop, transport receiver, control server, and producer submission thread. Prefer thread hints/priority and bounded wakeups over global affinity. Confirm thermal behavior over a sustained run, not only startup FPS. - -### 4. Update FEX and use lower-memory JIT caches — separate post-parity workstream - -Official GameNative already uses FEX 2605 in relevant configurations; FEX 2608 was current during this audit. Upstream FEX 2605 and 2608 already default `DisableL2Cache=true` and `DynamicL1Cache=true`, which reduce memory use with possible compilation/stutter tradeoffs. Do not blindly force conflicting environment values. - -After functional parity: - -1. package/test the newer supported FEX release through GameNative's normal version mechanism -2. log effective cache settings, not merely selected preset names -3. benchmark memory, translation time, average frame time, and p95/p99 frame time -4. keep upstream defaults unless measurements justify a per-container option - -`EnableLazyCodeCachingWIP` exists in newer FEX but is explicitly WIP and default-off. Do not enable it globally. - -Reference: `https://github.com/FEX-Emu/FEX/releases/tag/FEX-2608` - -### 5. Beat Saber-specific TSO disabling metadata — discard - -Do not implement it. FEX warns that disabling TSO can break multithreaded applications, and a generic GameNative VR runtime should not ship a one-title unsafe exception without a broad, reproducible compatibility framework. - -### 6. Compositor-aware pacing instead of generic 2:1 repetition — keep after parity - -Replace the arbitrary frame divisor with timing derived from the single Quest compositor loop: - -- Quest `xrWaitFrame` predicted display time/period drives the Windows runtime's `xrWaitFrame` response. -- Submit each newly completed stereo pair when ready. -- If the producer misses a compositor interval, reuse the latest complete safe pair; do not block the Quest frame loop waiting indefinitely. -- Track frame age and missed producer/compositor intervals. -- Keep explicit half-rate mode only as a user-selected fallback, not the universal default. - -Avoid generic sleeps and DXVK latency-sleep options until timestamps show where latency is introduced. - -## Build and packaging requirements - -- Keep Windows runtime/unixlib artifacts scoped to `modernXr` so normal APKs do not absorb unnecessary payloads. -- Use the official OpenXR loader/Prefab dependency for Android native code. -- Make every generated native artifact reproducible and document its source/build command. -- Fail the build when an expected runtime DLL/unixlib is absent or the wrong architecture, rather than shipping a silent partial payload. -- Preserve release stripping while retaining a symbol/archive strategy for crash diagnosis. -- Do not run expensive hashes or directory scans during frame processing. - -## Validation matrix and milestone order - -Use this order and do not skip directly to broad game testing: - -1. Official flat immersive regression test with Windows VR disabled. -2. Native debug stereo producer in the official single session. -3. Windows native OpenXR x64 D3D11 probe. -4. Known working native OpenXR Unity/D3D11 title. -5. Input, haptics, pause/resume, menu, and transport-loss recovery. -6. One OpenVR/OpenComposite title with visible output. -7. Additional Unity versions, then a non-Unity engine. -8. Only then validate Vulkan-client, D3D12, and 32-bit paths. - -For every test record: - -- app/API/graphics/architecture -- exact GameNative, Wine/Proton, FEX, DXVK, and payload versions -- runtime connected -- session began -- first complete stereo pair -- first projection layer submitted -- visible image -- input/haptics -- flat suspension/fallback -- clean teardown -- average, p95, and p99 frame timing plus memory after a sustained interval - -“Session began” is not equivalent to “working.” - -## Commit strategy - -Keep changes reviewable: - -1. hook/service skeleton and flat regression -2. reproducible payload/build integration -3. native projection mode with debug producer -4. Windows control/data transport -5. native OpenXR D3D11 end-to-end -6. flat-presentation suspension and fallback -7. official quick-menu settings/diagnostics -8. OpenComposite compatibility -9. measured performance changes - -Each commit must build and preserve the previous milestone. Avoid a single merge commit containing the entire runtime, transport, UI, and performance work at once. - -## Definition of done - -The migration is complete only when: - -- Official `ImmersiveXrActivity` is the sole Quest activity/session for flat and stereo modes. -- Normal GameNative container settings and arguments are demonstrably the effective launch settings. -- Official flat immersive mode remains functional as startup and runtime fallback. -- The proven x64 native OpenXR D3D11 path produces a visible stereo image with input and clean teardown. -- Redundant flat presentation stops during stable stereo and resumes after persistent loss. -- OpenComposite is isolated as an optional adapter and its actual validation status is documented. -- Runtime payload installation/restoration is crash-safe and game-agnostic. -- There is no second VR activity/menu/settings fork, vendored OpenXR header snapshot, or Wine 9.2 pin in the new branch. -- Diagnostics identify the selected path and first failing stage without relying on Beat Saber-specific files. -- Performance claims are backed by frame-time, memory, and sustained thermal measurements. -- The compatibility matrix distinguishes proven, partial, compiled-only, and unsupported paths. - -## Final reporting format - -When handing the implementation back, report: - -1. official `master` base SHA and final implementation SHA -2. files/components added, modified, and architectural paths intentionally omitted -3. working compatibility matrix with evidence -4. exact remaining blockers by pipeline stage -5. before/after CPU, GPU, frame-time, latency, and RAM measurements -6. payload/build reproduction instructions -7. any manual device tests still required - -Do not report the project as generally SteamVR-compatible until at least one native OpenXR title and one OpenVR/OpenComposite title have each produced a visible, interactive image through the new single-session architecture. diff --git a/tools/patches/opencomposite-gamenative-wine.patch b/tools/patches/opencomposite-gamenative-wine.patch index 0ee080d502..4d124c6791 100644 --- a/tools/patches/opencomposite-gamenative-wine.patch +++ b/tools/patches/opencomposite-gamenative-wine.patch @@ -27,34 +27,27 @@ index ffeab79..d2fe55c 100644 #include diff --git a/OCOVR/openvr_api.cpp b/OCOVR/openvr_api.cpp -index a49444a..d8c3ffb 100644 +index a49444a..3401468 100644 --- a/OCOVR/openvr_api.cpp +++ b/OCOVR/openvr_api.cpp -@@ -111,9 +111,7 @@ VR_INTERFACE void* VR_CALLTYPE VR_GetGenericInterface(const char* interfaceVersi - - if (!valid_apptypes_success) { - valid_apptypes = 1ull << VRApplication_Scene; --#ifndef _WIN32 - valid_apptypes |= (1ull << VRApplication_Background); // Proton uses this --#endif - } - - if ((valid_apptypes & (1ull << current_apptype)) == 0) { -@@ -254,11 +254,9 @@ VR_INTERFACE uint32_t VR_CALLTYPE VR_InitInternal2(EVRInitError* peError, EVRApp +@@ -254,6 +254,17 @@ VR_INTERFACE uint32_t VR_CALLTYPE VR_InitInternal2(EVRInitError* peError, EVRApp return current_init_token; } -- if (eApplicationType != VRApplication_Scene) --#ifndef _WIN32 -- if (eApplicationType != VRApplication_Background) // Proton uses this --#endif -- ERR("Cannot init VR: unsupported apptype " + to_string(eApplicationType)); -+ if (eApplicationType != VRApplication_Scene && -+ eApplicationType != VRApplication_Background) // Wine/Proton uses this -+ ERR("Cannot init VR: unsupported apptype " + to_string(eApplicationType)); - - if (running) - ERR("Cannot init VR: Already running!"); ++#ifdef _WIN32 ++ // GameNative embeds OpenComposite in each Wine process and has no shared ++ // vrserver for background clients. Do not let helpers allocate a competing ++ // OpenXR session; report the spec-defined benign background-app result. ++ if (eApplicationType == VRApplication_Background) { ++ OOVR_LOG("Ignoring VRApplication_Background: no shared OpenVR server is available"); ++ if (peError) *peError = VRInitError_Init_NoServerForBackgroundApp; ++ return current_init_token; ++ } ++#endif ++ + if (eApplicationType != VRApplication_Scene) + #ifndef _WIN32 + if (eApplicationType != VRApplication_Background) // Proton uses this diff --git a/OpenOVR/Compositor/compositor.h b/OpenOVR/Compositor/compositor.h index e83a16a..d650b08 100644 --- a/OpenOVR/Compositor/compositor.h diff --git a/tools/stage-opencomposite.ps1 b/tools/stage-opencomposite.ps1 index 170beae118..e26c45efaa 100644 --- a/tools/stage-opencomposite.ps1 +++ b/tools/stage-opencomposite.ps1 @@ -47,7 +47,7 @@ Copy-Item -Recurse -Force -Path (Join-Path $ndkIncludes "vk_video\*") -Destinati Copy-Item -Force -LiteralPath $vulkanDefinition -Destination (Join-Path $vulkan "vulkan-1.def") Invoke-Checked { & $libTool.FullName /nologo "/def:$(Join-Path $vulkan 'vulkan-1.def')" /machine:x64 "/out:$(Join-Path $vulkan 'Lib\vulkan-1.lib')" } "Vulkan import library" -Invoke-Checked { cmake -S $source -B $build -G "Visual Studio 17 2022" -A x64 -DERROR_ON_WARNING=OFF "-DOC_VERSION=$commit-gamenative-wine-background" } "OpenComposite configure" +Invoke-Checked { cmake -S $source -B $build -G "Visual Studio 17 2022" -A x64 -DERROR_ON_WARNING=OFF "-DOC_VERSION=$commit-gamenative-scene-only" } "OpenComposite configure" Invoke-Checked { cmake --build $build --config Release --target OCOVR --parallel } "OpenComposite build" $built = Join-Path $build "bin\Release\vrclient_x64.dll" if (!(Test-Path -LiteralPath $built -PathType Leaf)) { throw "OpenComposite build output is missing" } From 47d4c7fd9e3d33dca5070bbfb85683aba6fcd897 Mon Sep 17 00:00:00 2001 From: Florian Reintgen <85225228+TheReal-Flo@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:21:31 +0200 Subject: [PATCH 3/5] Track OpenComposite build toolchain inputs --- app/build.gradle.kts | 24 ++++++++------- tools/stage-opencomposite.ps1 | 56 +++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c31a632977..4599d67440 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -326,21 +326,25 @@ android { tasks.register("stageOpenComposite") { enabled = hostCanRunXrPayloadScripts + val stageScript = rootProject.file("tools/stage-opencomposite.ps1") + val toolchainFingerprint = providers.exec { + commandLine( + "powershell", + "-ExecutionPolicy", + "Bypass", + "-File", + stageScript.absolutePath, + "-PrintToolchainFingerprint", + ) + }.standardOutput.asText.map { it.trim() } inputs.files( - rootProject.file("tools/stage-opencomposite.ps1"), + stageScript, rootProject.file("tools/patches/opencomposite-gamenative-wine.patch"), rootProject.file("tools/opencomposite-vulkan-x64.def"), ) inputs.property("openCompositeCommit", "a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d") - inputs.property("openCompositeBuildRevision", 2) - inputs.property( - "openCompositeNdkHome", - providers.environmentVariable("ANDROID_NDK_HOME").orElse("default-ndk-29.0.14206865"), - ) - inputs.property( - "openCompositeVisualStudioVersion", - providers.environmentVariable("VisualStudioVersion").orElse("auto-vs2022"), - ) + inputs.property("openCompositeBuildRevision", 3) + inputs.property("openCompositeToolchainFingerprint", toolchainFingerprint) outputs.file(layout.buildDirectory.file("generated/xrPayload/modernXr/opencomposite_x64.dll")) commandLine( "powershell", diff --git a/tools/stage-opencomposite.ps1 b/tools/stage-opencomposite.ps1 index e26c45efaa..751f8c397e 100644 --- a/tools/stage-opencomposite.ps1 +++ b/tools/stage-opencomposite.ps1 @@ -1,3 +1,7 @@ +param( + [switch]$PrintToolchainFingerprint +) + $ErrorActionPreference = "Stop" $repository = Split-Path -Parent $PSScriptRoot $payload = Join-Path $repository "app\build\generated\xrPayload\modernXr" @@ -10,6 +14,7 @@ $vulkanDefinition = Join-Path $PSScriptRoot "opencomposite-vulkan-x64.def" $ndk = if ($env:ANDROID_NDK_HOME) { $env:ANDROID_NDK_HOME } else { Join-Path $env:LOCALAPPDATA "Android\Sdk\ndk\29.0.14206865" } $ndkIncludes = Join-Path $ndk "toolchains\llvm\prebuilt\windows-x86_64\sysroot\usr\include" $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" +$visualStudioVersionRange = "[17.0,18.0)" function Invoke-Checked { param([scriptblock]$Command, [string]$Name) @@ -17,13 +22,58 @@ function Invoke-Checked { if ($LASTEXITCODE -ne 0) { throw "$Name failed with exit code $LASTEXITCODE" } } +function Get-DirectoryFingerprint { + param([string]$Path) + $resolvedRoot = (Resolve-Path -LiteralPath $Path).Path.TrimEnd('\') + '\' + $manifest = Get-ChildItem -LiteralPath $resolvedRoot -Recurse -File | + Sort-Object FullName | + ForEach-Object { + $relativePath = $_.FullName.Substring($resolvedRoot.Length).Replace('\', '/') + $fileHash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + "$relativePath|$($_.Length)|$fileHash" + } + $manifestBytes = [Text.Encoding]::UTF8.GetBytes(($manifest -join "`n")) + $hasher = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($hasher.ComputeHash($manifestBytes))).Replace('-', '') + } finally { + $hasher.Dispose() + } +} + if (!(Test-Path (Join-Path $ndkIncludes "vulkan\vulkan.h"))) { throw "Android NDK Vulkan headers are required" } if (!(Test-Path $vswhere)) { throw "Visual Studio Installer is required" } -$visualStudio = (& $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath | Select-Object -First 1) -if (!$visualStudio) { throw "Visual Studio C++ x64 build tools are required" } +$visualStudio = (& $vswhere -latest -version $visualStudioVersionRange -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath | Select-Object -First 1) +if (!$visualStudio) { throw "Visual Studio 2022 C++ x64 build tools are required" } $libTool = Get-ChildItem (Join-Path $visualStudio "VC\Tools\MSVC\*\bin\Hostx64\x64\lib.exe") | Sort-Object FullName -Descending | Select-Object -First 1 if (!$libTool) { throw "Visual Studio lib.exe is required" } +if ($PrintToolchainFingerprint) { + $ndkSourceProperties = Join-Path $ndk "source.properties" + if (!(Test-Path -LiteralPath $ndkSourceProperties -PathType Leaf)) { throw "Android NDK source.properties is required" } + $visualStudioVersion = (& $vswhere -latest -version $visualStudioVersionRange -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationVersion | Select-Object -First 1) + if (!$visualStudioVersion) { throw "Visual Studio version could not be resolved" } + $toolDirectory = Split-Path -Parent $libTool.FullName + $compilerTool = Join-Path $toolDirectory "cl.exe" + $linkerTool = Join-Path $toolDirectory "link.exe" + if (!(Test-Path -LiteralPath $compilerTool -PathType Leaf)) { throw "Visual Studio cl.exe is required" } + if (!(Test-Path -LiteralPath $linkerTool -PathType Leaf)) { throw "Visual Studio link.exe is required" } + $fingerprint = [ordered]@{ + ndkPath = (Resolve-Path -LiteralPath $ndk).Path + ndkSourcePropertiesSha256 = (Get-FileHash -LiteralPath $ndkSourceProperties -Algorithm SHA256).Hash + ndkVulkanHeadersSha256 = Get-DirectoryFingerprint (Join-Path $ndkIncludes "vulkan") + ndkVideoHeadersSha256 = Get-DirectoryFingerprint (Join-Path $ndkIncludes "vk_video") + visualStudioPath = (Resolve-Path -LiteralPath $visualStudio).Path + visualStudioVersion = $visualStudioVersion + compilerPath = (Resolve-Path -LiteralPath $compilerTool).Path + compilerSha256 = (Get-FileHash -LiteralPath $compilerTool -Algorithm SHA256).Hash + linkerSha256 = (Get-FileHash -LiteralPath $linkerTool -Algorithm SHA256).Hash + librarianSha256 = (Get-FileHash -LiteralPath $libTool.FullName -Algorithm SHA256).Hash + } + $fingerprint | ConvertTo-Json -Compress + exit 0 +} + New-Item -ItemType Directory -Force -Path $payload | Out-Null if (!(Test-Path (Join-Path $source ".git"))) { New-Item -ItemType Directory -Force -Path (Split-Path -Parent $source) | Out-Null @@ -47,7 +97,7 @@ Copy-Item -Recurse -Force -Path (Join-Path $ndkIncludes "vk_video\*") -Destinati Copy-Item -Force -LiteralPath $vulkanDefinition -Destination (Join-Path $vulkan "vulkan-1.def") Invoke-Checked { & $libTool.FullName /nologo "/def:$(Join-Path $vulkan 'vulkan-1.def')" /machine:x64 "/out:$(Join-Path $vulkan 'Lib\vulkan-1.lib')" } "Vulkan import library" -Invoke-Checked { cmake -S $source -B $build -G "Visual Studio 17 2022" -A x64 -DERROR_ON_WARNING=OFF "-DOC_VERSION=$commit-gamenative-scene-only" } "OpenComposite configure" +Invoke-Checked { cmake --fresh -S $source -B $build -G "Visual Studio 17 2022" -A x64 "-DCMAKE_GENERATOR_INSTANCE=$visualStudio" -DERROR_ON_WARNING=OFF "-DOC_VERSION=$commit-gamenative-scene-only" } "OpenComposite configure" Invoke-Checked { cmake --build $build --config Release --target OCOVR --parallel } "OpenComposite build" $built = Join-Path $build "bin\Release\vrclient_x64.dll" if (!(Test-Path -LiteralPath $built -PathType Leaf)) { throw "OpenComposite build output is missing" } From dbe1916dba4448b006284f742df0187591c53b21 Mon Sep 17 00:00:00 2001 From: Florian Reintgen <85225228+TheReal-Flo@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:59:01 +0200 Subject: [PATCH 4/5] Add native OpenVR and Android XR immersive support --- AGENTS.md | 34 ++ app/build.gradle.kts | 37 +- app/src/androidXr/AndroidManifest.xml | 53 ++ app/src/main/cpp/xrimmersive/jni_bridge.cpp | 15 +- .../main/java/app/gamenative/MainActivity.kt | 19 +- .../ui/screen/xr/ImmersiveRuntimeViewModel.kt | 24 + .../ui/screen/xr/ImmersiveSessionOwnership.kt | 70 +++ .../ui/screen/xr/ImmersiveXrActivity.kt | 45 +- .../xr/windows/WindowsVrControlServer.kt | 21 +- .../xr/windows/WindowsVrPayloadManager.kt | 60 +- .../xr/windows/WindowsVrRuntimeService.kt | 7 +- .../xr/windows/WindowsVrSnapshotProvider.kt | 2 +- .../windows/openvr_client/DrvGameNative.cpp | 548 ++++++++++++++++++ .../windows/openvr_client/DrvGameNative.h | 12 + .../openxr_runtime/gamenative_control.h | 8 + .../gamenative_openxr_runtime.c | 24 +- .../app/gamenative/AndroidXrLaunchTest.kt | 53 ++ .../xr/ImmersiveSessionOwnershipTest.kt | 62 ++ .../xr/windows/WindowsVrControlServerTest.kt | 29 + docs/xr/native-openvr-runtime-plan.md | 198 +++++++ tools/build-native-openvr-dev.ps1 | 19 + ...ncomposite-gamenative-native-backend.patch | 108 ++++ tools/prepare-native-openvr-dev.ps1 | 71 +++ tools/stage-opencomposite.ps1 | 59 +- ubuntufs/build.gradle.kts | 3 + 25 files changed, 1498 insertions(+), 83 deletions(-) create mode 100644 AGENTS.md create mode 100644 app/src/androidXr/AndroidManifest.xml create mode 100644 app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveRuntimeViewModel.kt create mode 100644 app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnership.kt create mode 100644 app/src/main/windows/openvr_client/DrvGameNative.cpp create mode 100644 app/src/main/windows/openvr_client/DrvGameNative.h create mode 100644 app/src/test/java/app/gamenative/AndroidXrLaunchTest.kt create mode 100644 app/src/test/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnershipTest.kt create mode 100644 app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServerTest.kt create mode 100644 docs/xr/native-openvr-runtime-plan.md create mode 100644 tools/build-native-openvr-dev.ps1 create mode 100644 tools/patches/opencomposite-gamenative-native-backend.patch create mode 100644 tools/prepare-native-openvr-dev.ps1 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..94b889950a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Project agent instructions + +## Remote Quest ADB through Tailminal + +- A Quest 3 is attached by ADB to the tailnet host `lily`. Local `adb devices` + may be empty; check the remote ADB server before concluding that the headset + is unavailable. +- Tailminal is already installed on this workstation and running on `lily`. + It connects directly over Tailscale. Its operating guide is + . +- From PowerShell on this workstation, invoke the npm CMD launcher explicitly. + The `.ps1` wrapper has mishandled the `--` command boundary here: + + ```powershell + & "$env:APPDATA\npm\tailminal.cmd" exec lily --timeout-ms 30000 -- "adb devices -l" + ``` + +- Pass the entire remote command after `--` as one quoted argument. Prefer + one-shot `exec` calls with a finite `--timeout-ms` for unattended work: + + ```powershell + & "$env:APPDATA\npm\tailminal.cmd" exec lily --timeout-ms 30000 -- "adb shell getprop ro.product.model" + & "$env:APPDATA\npm\tailminal.cmd" exec lily --timeout-ms 30000 -- "adb shell tail -n 300 /sdcard/Android/data/app.gamenative/files/gamenativevr/launch.log" + & "$env:APPDATA\npm\tailminal.cmd" exec lily --timeout-ms 30000 -- "adb logcat -d -v threadtime -t 1000" + ``` + +- `tailminal hosts` depends on a local Tailminal server and may return no peers + when that server is stopped. Directly addressing the MagicDNS host `lily` + still works. If a direct command fails, check `tailscale status`, then retry + the Tailminal health/command path before treating the Quest as disconnected. +- Tailminal grants a full shell on the remote host. Keep commands scoped to the + requested task, prefer read-only diagnostics first, and do not install, + delete, reboot, or otherwise mutate the headset unless the user's request + authorizes it. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4599d67440..182f6b3491 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -63,6 +63,7 @@ android { manifestPlaceholders["screenOrientation"] = "unspecified" buildConfigField("boolean", "XR_BUILD", "false") buildConfigField("boolean", "MODERN_XR", "false") + buildConfigField("boolean", "ANDROID_XR", "false") versionCode = 21 versionName = "1.2.0" @@ -159,6 +160,19 @@ android { buildConfigField("String", "PRODUCT_SKU", "\"$productSku\"") manifestPlaceholders["screenOrientation"] = "landscape" } + create("androidXr") { + dimension = "androidApi" + minSdk = 29 + targetSdk = 36 + ndk.abiFilters += listOf("arm64-v8a") + buildConfigField("boolean", "MODERN_ANDROID", "true") + buildConfigField("String", "PRELOAD_BIONIC_SO", "\"libredirect-bionic-wx.so\"") + buildConfigField("boolean", "XR_BUILD", "true") + buildConfigField("boolean", "MODERN_XR", "true") + buildConfigField("boolean", "ANDROID_XR", "true") + // Keep the launcher in Android XR Home Space. Only ImmersiveXrActivity is + // promoted to unmanaged Full Space by src/androidXr/AndroidManifest.xml. + } } buildTypes { @@ -274,6 +288,17 @@ android { setSrcDirs(listOf("src/modern/jniLibs", "build/generated/xrNative/modernXr")) } } + getByName("androidXr") { + // The platform launch contract differs, but the OpenXR renderer, Wine runtime + // payload and launch-readiness implementation are shared with modernXr. + java.srcDir("src/modernXr/java") + assets { + srcDirs("src/modern/assets", "src/main/assets", "build/generated/xrPayload/modernXr") + } + jniLibs { + setSrcDirs(listOf("src/modern/jniLibs", "build/generated/xrNative/modernXr")) + } + } getByName("debug") { assets.srcDir(copyDebugManifest) } @@ -340,10 +365,16 @@ android { inputs.files( stageScript, rootProject.file("tools/patches/opencomposite-gamenative-wine.patch"), + rootProject.file("tools/patches/opencomposite-gamenative-native-backend.patch"), rootProject.file("tools/opencomposite-vulkan-x64.def"), + rootProject.file("app/src/main/windows/openvr_client/DrvGameNative.cpp"), + rootProject.file("app/src/main/windows/openvr_client/DrvGameNative.h"), + rootProject.file("app/src/main/windows/openxr_runtime/gamenative_control.c"), + rootProject.file("app/src/main/windows/openxr_runtime/gamenative_control.h"), + rootProject.file("app/src/main/windows/openxr_runtime/gamenative_openxr_unix.h"), ) inputs.property("openCompositeCommit", "a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d") - inputs.property("openCompositeBuildRevision", 3) + inputs.property("openCompositeBuildRevision", 4) inputs.property("openCompositeToolchainFingerprint", toolchainFingerprint) outputs.file(layout.buildDirectory.file("generated/xrPayload/modernXr/opencomposite_x64.dll")) commandLine( @@ -372,7 +403,8 @@ android { } tasks.matching { - it.name.startsWith("mergeModernXr") && it.name.endsWith("Assets") + (it.name.startsWith("mergeModernXr") || it.name.startsWith("mergeAndroidXr")) && + it.name.endsWith("Assets") }.configureEach { dependsOn("prepareModernXrPayload") } @@ -463,6 +495,7 @@ dependencies { // Official Khronos OpenXR loader (Apache-2.0) for the Meta Quest immersive launch mode's // native module (app/src/main/cpp/xrimmersive) — not a Winlator/GameNativeXR dependency. "modernXrImplementation"("org.khronos.openxr:openxr_loader_for_android:1.1.61") + "androidXrImplementation"("org.khronos.openxr:openxr_loader_for_android:1.1.61") // Winlator implementation(libs.bundles.winlator) diff --git a/app/src/androidXr/AndroidManifest.xml b/app/src/androidXr/AndroidManifest.xml new file mode 100644 index 0000000000..ce3873408d --- /dev/null +++ b/app/src/androidXr/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/cpp/xrimmersive/jni_bridge.cpp b/app/src/main/cpp/xrimmersive/jni_bridge.cpp index 9e032a8b28..42cec855da 100644 --- a/app/src/main/cpp/xrimmersive/jni_bridge.cpp +++ b/app/src/main/cpp/xrimmersive/jni_bridge.cpp @@ -132,7 +132,7 @@ 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) < 11 || env->GetArrayLength(outViews) < 22 || - env->GetArrayLength(outInput) < 36 || env->GetArrayLength(outFlags) < 3) return JNI_FALSE; + env->GetArrayLength(outInput) < 36 || env->GetArrayLength(outFlags) < 4) return JNI_FALSE; xrimmersive::WindowsRuntimeSnapshot snapshot; { std::lock_guard lock(gHandleMutex); @@ -200,13 +200,18 @@ Java_app_gamenative_ui_screen_xr_XrNative_nativeWaitWindowsFrame( inputValues[base + 17] = aim.position.z; } env->SetFloatArrayRegion(outInput, 0, 36, inputValues); - const jint flags[3] = { + const bool leftActive = input.aimPoseValid[0] || input.gripPoseValid[0]; + const bool rightActive = input.aimPoseValid[1] || input.gripPoseValid[1]; + const jint flags[4] = { 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)), + static_cast((leftActive ? 1 : 0) | (rightActive ? 2 : 0)), + static_cast((input.gripPoseValid[0] ? 1 : 0) | + (input.gripPoseValid[1] ? 2 : 0) | + (input.aimPoseValid[0] ? 4 : 0) | + (input.aimPoseValid[1] ? 8 : 0)), }; - env->SetIntArrayRegion(outFlags, 0, 3, flags); + env->SetIntArrayRegion(outFlags, 0, 4, flags); return JNI_TRUE; } diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index 97919fb51b..ce5b9d03e8 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -40,6 +40,7 @@ import app.gamenative.mods.NexusDownloadLinkInbox import app.gamenative.mods.NexusIntegrationStatus import app.gamenative.mods.NexusPendingDownloadStore import app.gamenative.ui.screen.library.appscreen.BaseAppScreen +import app.gamenative.ui.screen.xr.ImmersiveSessionOwnership import app.gamenative.service.SteamService import app.gamenative.service.gog.GOGService import app.gamenative.service.epic.EpicService @@ -77,6 +78,7 @@ class MainActivity : ComponentActivity() { fun isHeadset(context: Context): Boolean = context.packageManager.hasSystemFeature("android.hardware.vr.headtracking") || + context.packageManager.hasSystemFeature("android.software.xr.api.openxr") || Build.MANUFACTURER.equals("Oculus", true) || Build.MANUFACTURER.equals("Meta", true) || Build.MANUFACTURER.equals("Pico", true) @@ -205,9 +207,16 @@ class MainActivity : ComponentActivity() { } // stale keepAlive from a prior crash/swipe — no container is actually running - if (SteamService.keepAlive && PluviaApp.xEnvironment == null) { + if (SteamService.keepAlive && PluviaApp.xEnvironment == null && + !ImmersiveSessionOwnership.isOwnedByImmersive() + ) { Timber.w("onCreate: clearing stale keepAlive — no container running") PluviaApp.shutdownEnvironment() + } else if (SteamService.keepAlive && PluviaApp.xEnvironment == null) { + Timber.i( + "onCreate: preserving keepAlive during immersive handoff (%s)", + ImmersiveSessionOwnership.snapshot(), + ) } // Apply immersive mode based on user preference @@ -372,7 +381,7 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { // emit before super so Compose DisposableEffects (which unregister // listeners during super.onDestroy's lifecycle transition) still fire - if (!isChangingConfigurations) { + if (ImmersiveSessionOwnership.shouldLauncherHandleDestruction(isChangingConfigurations)) { PluviaApp.events.emit(AndroidEvent.ActivityDestroyed) // if exit() didn't run (listener already unregistered, race, etc.) @@ -381,6 +390,12 @@ class MainActivity : ComponentActivity() { Timber.w("onDestroy: keepAlive still set after ActivityDestroyed — forcing cleanup") PluviaApp.shutdownEnvironment() } + } else { + Timber.i( + "onDestroy: launcher teardown skipped; changingConfig=%b immersive=%s", + isChangingConfigurations, + ImmersiveSessionOwnership.snapshot(), + ) } super.onDestroy() diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveRuntimeViewModel.kt b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveRuntimeViewModel.kt new file mode 100644 index 0000000000..4738cf38e5 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveRuntimeViewModel.kt @@ -0,0 +1,24 @@ +package app.gamenative.ui.screen.xr + +import android.content.Context +import androidx.lifecycle.ViewModel +import app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeService + +/** Retains the Windows VR control server across Android activity configuration recreation. */ +internal class ImmersiveRuntimeViewModel : ViewModel() { + private var windowsVrRuntimeService: WindowsVrRuntimeService? = null + + fun windowsVrRuntimeService(context: Context): WindowsVrRuntimeService = + windowsVrRuntimeService ?: WindowsVrRuntimeService(context.applicationContext).also { + windowsVrRuntimeService = it + } + + fun closeWindowsVrRuntimeService() { + windowsVrRuntimeService?.close() + windowsVrRuntimeService = null + } + + override fun onCleared() { + closeWindowsVrRuntimeService() + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnership.kt b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnership.kt new file mode 100644 index 0000000000..ec67c7b48a --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnership.kt @@ -0,0 +1,70 @@ +package app.gamenative.ui.screen.xr + +import timber.log.Timber + +/** + * Process-local ownership of a flat-launcher -> immersive-session handoff. + * + * Android XR may stop, recreate, or destroy the Home Space activity while the Full Space + * activity is starting. The flat activity must not interpret that transition as an app exit: + * [ImmersiveXrActivity] owns Wine/environment teardown until its final destruction. + */ +internal object ImmersiveSessionOwnership { + internal enum class Phase { + IDLE, + LAUNCHING, + ACTIVE, + } + + internal data class Snapshot( + val appId: String? = null, + val phase: Phase = Phase.IDLE, + ) + + private val lock = Any() + + @Volatile + private var state = Snapshot() + + fun beginLaunch(appId: String) { + synchronized(lock) { + state = Snapshot(appId, Phase.LAUNCHING) + } + Timber.i("Immersive ownership: launch handoff started for %s", appId) + } + + fun markActivityActive(appId: String) { + synchronized(lock) { + state = Snapshot(appId, Phase.ACTIVE) + } + Timber.i("Immersive ownership: activity owns session for %s", appId) + } + + fun release(appId: String?) { + var released = false + synchronized(lock) { + if (appId == null || state.appId == appId) { + state = Snapshot() + released = true + } + } + if (released) { + Timber.i("Immersive ownership: session released for %s", appId ?: "") + } else { + Timber.w("Immersive ownership: ignored release for non-owner %s", appId ?: "") + } + } + + fun isOwnedByImmersive(): Boolean = state.phase != Phase.IDLE + + fun shouldLauncherHandleDestruction(isChangingConfigurations: Boolean): Boolean = + !isChangingConfigurations && !isOwnedByImmersive() + + fun snapshot(): Snapshot = state + + internal fun resetForTest() { + synchronized(lock) { + state = Snapshot() + } + } +} 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 e07d920ae8..d6cc782902 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 @@ -1,5 +1,6 @@ package app.gamenative.ui.screen.xr +import android.app.Activity import android.content.Context import android.content.Intent import android.graphics.Bitmap @@ -67,7 +68,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -/** Dedicated entry point for launching a game directly in Meta Quest immersive mode. */ +/** Shared OpenXR entry point for launching a game in headset immersive mode. */ @AndroidEntryPoint class ImmersiveXrActivity : androidx.activity.ComponentActivity() { @@ -101,17 +102,29 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { private const val EXTRA_QUAD_SCALE = "immersiveQuadScale" private const val EXTRA_PASSTHROUGH_ENABLED = "immersivePassthroughEnabled" - fun start(context: Context, appId: String, isOffline: Boolean) { - val intent = Intent(context, ImmersiveXrActivity::class.java).apply { + internal fun createLaunchIntent(context: Context, appId: String, isOffline: Boolean): Intent = + Intent(context, ImmersiveXrActivity::class.java).apply { putExtra(EXTRA_APP_ID, appId) putExtra(EXTRA_IS_OFFLINE, isOffline) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + // An Activity context keeps this on the flat launcher's task so finish() + // returns to Home Space on Android XR. Application contexts still require + // NEW_TASK (the existing Quest/background launch path). + if (context !is Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + fun start(context: Context, appId: String, isOffline: Boolean) { + ImmersiveSessionOwnership.beginLaunch(appId) + try { + context.startActivity(createLaunchIntent(context, appId, isOffline)) + } catch (t: Throwable) { + ImmersiveSessionOwnership.release(appId) + throw t } - context.startActivity(intent) } } private val viewModel: MainViewModel by viewModels() + private val immersiveRuntimeViewModel: ImmersiveRuntimeViewModel by viewModels() @Volatile private var backAction: (() -> Unit)? = null @@ -255,7 +268,8 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { return } currentAppId = appId - windowsVrRuntimeService = app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeService(this) + ImmersiveSessionOwnership.markActivityActive(appId) + windowsVrRuntimeService = immersiveRuntimeViewModel.windowsVrRuntimeService(this) PluviaApp.isActivityInForeground = true AppUtils.keepScreenOn(this) @@ -482,10 +496,25 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { } override fun onDestroy() { + val changingConfigurations = isChangingConfigurations + Timber.i( + "Immersive: onDestroy, changingConfig=%b finishing=%b appId=%s", + changingConfigurations, + isFinishing, + currentAppId, + ) stopXrSession() - windowsVrRuntimeService?.close() windowsVrRuntimeService = null - PluviaApp.shutdownEnvironment() + if (changingConfigurations) { + // The replacement Activity receives the same ViewModel and control server. Keeping + // both alive prevents a display-density/configuration transition from killing Wine + // or disconnecting the Windows OpenXR/OpenVR runtime during startup. + Timber.i("Immersive: preserving Wine and Windows VR service across recreation") + } else { + immersiveRuntimeViewModel.closeWindowsVrRuntimeService() + ImmersiveSessionOwnership.release(currentAppId) + PluviaApp.shutdownEnvironment() + } super.onDestroy() } 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 index 1835ee10a1..e76801a804 100644 --- 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 @@ -11,6 +11,21 @@ import java.util.concurrent.Executors import java.util.concurrent.Semaphore import java.util.concurrent.atomic.AtomicBoolean +/** Translate the shared Xbox-layout bit mask into the compact per-hand mask consumed by the + * Windows OpenXR compatibility runtime: primary, secondary, stick-click, and menu. */ +internal fun openVrControllerButtons(buttons: Int, hand: Int): Int { + require(hand in 0..1) + val sourceBits = if (hand == 0) { + intArrayOf(2, 3, 8, 7) // X, Y, left-stick click, left menu/start. + } else { + intArrayOf(0, 1, 9, -1) // A, B, right-stick click; no right-hand menu button. + } + return sourceBits.foldIndexed(0) { outputBit, result, sourceBit -> + if (sourceBit >= 0 && buttons and (1 shl sourceBit) != 0) result or (1 shl outputBit) + else result + } +} + class WindowsVrControlServer( private val config: WindowsVrRuntimeConfig, private val diagnostics: WindowsVrDiagnostics, @@ -236,7 +251,11 @@ class WindowsVrControlServer( "${inputFields[field]}=${(snapshot.input[base + field] * 1_000_000f).toLong()}" } val active = (snapshot.flags[2] shr hand) and 1 - return "OK active=$active buttons=${snapshot.flags[1]} $rawValues $namedValues" + val poseFlags = snapshot.flags.getOrElse(3) { 0 } + val gripActive = (poseFlags shr hand) and 1 + val aimActive = (poseFlags shr (hand + 2)) and 1 + val buttons = openVrControllerButtons(snapshot.flags[1], hand) + return "OK active=$active gripActive=$gripActive aimActive=$aimActive buttons=$buttons $rawValues $namedValues" } private fun haptic(tokens: List): String { 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 index ab2e186c15..5bdd788c2c 100644 --- 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 @@ -74,47 +74,25 @@ class WindowsVrPayloadManager( 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) } - check(targets.isNotEmpty()) { "No 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 -> - check(peMachine(target.readBytes()) == 0x8664) { "OpenVR library is not x64: ${target.path}" } - val directory = checkNotNull(target.parentFile).canonicalFile - val backup = File(directory, "openvr_api.dll.gamenative-original") - val owner = File(directory, "openvr_api.dll.gamenative-owner") - val ini = File(directory, "opencomposite.ini") - val iniBackup = File(directory, "opencomposite.ini.gamenative-original") - val iniMissing = File(directory, "opencomposite.ini.gamenative-missing") - check(!backup.exists() && !owner.exists()) - writeIfChanged(backup, target.readBytes()) - if (ini.isFile) writeIfChanged(iniBackup, ini.readBytes()) else writeIfChanged(iniMissing, byteArrayOf(1)) - writeIfChanged(owner, "2\n".toByteArray()) - writeIfChanged(target, adapter) - // OpenComposite treats unknown INI keys as fatal. Keep this limited to - // options supported by the pinned payload; enableAudio is not one of them. - writeIfChanged(ini, "initUsingVulkan=false\nlogAllOpenVRCalls=false\n".toByteArray()) - openCompositeDirectories += directory - diagnostics.record("opencomposite", "installed path=${target.path}") - } + fun installOpenCompositeRuntime(container: Container): String { + val payloadDirectory = File(container.rootDir, ".wine/drive_c/gamenative-xr") + val runtimeDirectory = File(payloadDirectory, "openvr") + val binDirectory = File(runtimeDirectory, "bin") + check(binDirectory.exists() || binDirectory.mkdirs()) + val client = File(binDirectory, "vrclient_x64.dll") + copyAssetIfChanged("opencomposite_x64.dll", client) + check(peMachine(client.readBytes()) == 0x8664) { "OpenComposite runtime is not x64" } + writeIfChanged(File(binDirectory, "version.txt"), byteArrayOf()) + writeIfChanged( + File(runtimeDirectory, "openvrpaths.vrpath"), + "{\"jsonid\":\"vrpathreg\",\"runtime\":[\"C:\\\\gamenative-xr\\\\openvr\"],\"version\":1}\n".toByteArray(), + ) + val windowsPath = "C:\\gamenative-xr\\openvr" + diagnostics.record( + "opencomposite", + "runtime installed path=${client.path} mode=VR_OVERRIDE backend=GameNative-direct", + ) + return windowsPath } fun restore() { 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 index f936b34889..248fee1408 100644 --- 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 @@ -42,7 +42,9 @@ class WindowsVrRuntimeService(context: Context) : Closeable { val payload = payloadManager.prepare(container) runtimeLogDirectory = payload.prefixDirectory listOf("runtime.log", "unix.log").forEach { payload.prefixDirectory.resolve(it).delete() } - if (active.openCompositeEnabled) payloadManager.installOpenComposite(container) + if (active.openCompositeEnabled) { + env.put("VR_OVERRIDE", payloadManager.installOpenCompositeRuntime(container)) + } env.put("XR_RUNTIME_JSON", active.runtimeManifest) env.put("GAMENATIVE_XR", "1") env.put("GAMENATIVE_XR_LOG", "1") @@ -69,7 +71,8 @@ class WindowsVrRuntimeService(context: Context) : Closeable { diagnostics.record( "environment", "runtime=${active.runtimeManifest} transport=${active.transportEndpoint} " + - "control=127.0.0.1:${active.controlPort}", + "control=127.0.0.1:${active.controlPort} " + + "openvr=${if (active.openCompositeEnabled) "GameNative-direct" else "disabled"}", ) diagnostics.record("effective-launch", WindowsVrEmulationDiagnostics.effective(container)) diagnostics.record("payload", payloadSummary(payload.prefixDirectory)) 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 index 0f29a6879a..e5758f7bee 100644 --- 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 @@ -27,7 +27,7 @@ class WindowsVrSnapshotProvider { fun waitFrame(afterSerial: Long, timeoutMs: Int): WindowsVrRuntimeSnapshot? { val activeHandle = handle if (activeHandle == 0L) return null - val snapshot = WindowsVrRuntimeSnapshot(LongArray(11), FloatArray(22), FloatArray(36), IntArray(3)) + val snapshot = WindowsVrRuntimeSnapshot(LongArray(11), FloatArray(22), FloatArray(36), IntArray(4)) if (!XrNative.nativeWaitWindowsFrame( activeHandle, afterSerial, diff --git a/app/src/main/windows/openvr_client/DrvGameNative.cpp b/app/src/main/windows/openvr_client/DrvGameNative.cpp new file mode 100644 index 0000000000..9386fd4627 --- /dev/null +++ b/app/src/main/windows/openvr_client/DrvGameNative.cpp @@ -0,0 +1,548 @@ +#include "stdafx.h" + +#include "DrvGameNative.h" + +#include "DrvOpenXR.h" +#include "XrBackend.h" +#include "logging.h" + +#include "gamenative_control.h" +#include "gamenative_openxr_unix.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using WineUnixCall = int(__stdcall*)(unsigned int, void*); + +struct DxgiVkInteropDevice; +struct DxgiVkInteropDevice1; + +struct DxgiVkInteropDeviceVtable { + HRESULT(__stdcall* QueryInterface)(DxgiVkInteropDevice*, REFIID, void**); + ULONG(__stdcall* AddRef)(DxgiVkInteropDevice*); + ULONG(__stdcall* Release)(DxgiVkInteropDevice*); + void(__stdcall* GetVulkanHandles)(DxgiVkInteropDevice*, uint64_t*, uint64_t*, uint64_t*); + void(__stdcall* GetSubmissionQueue)(DxgiVkInteropDevice*, uint64_t*, uint32_t*); + void(__stdcall* TransitionSurfaceLayout)(DxgiVkInteropDevice*, void*, const void*, uint32_t, uint32_t); + void(__stdcall* FlushRenderingCommands)(DxgiVkInteropDevice*); + void(__stdcall* LockSubmissionQueue)(DxgiVkInteropDevice*); + void(__stdcall* ReleaseSubmissionQueue)(DxgiVkInteropDevice*); +}; + +struct DxgiVkInteropDevice1Vtable : DxgiVkInteropDeviceVtable { + void(__stdcall* GetSubmissionQueue1)(DxgiVkInteropDevice*, uint64_t*, uint32_t*, uint32_t*); + HRESULT(__stdcall* CreateTexture2DFromVkImage)(DxgiVkInteropDevice*, const void*, uint64_t, ID3D11Texture2D**); +}; + +struct DxgiVkInteropDevice { + const DxgiVkInteropDeviceVtable* lpVtbl; +}; + +struct D3D11Texture2DDesc1 { + UINT Width; + UINT Height; + UINT MipLevels; + UINT ArraySize; + DXGI_FORMAT Format; + DXGI_SAMPLE_DESC SampleDesc; + D3D11_USAGE Usage; + UINT BindFlags; + UINT CPUAccessFlags; + UINT MiscFlags; + UINT TextureLayout; +}; + +constexpr GUID kIidDxgiVkInteropDevice1 = { + 0xe2ef5fa5, 0xdc21, 0x4af7, {0x90, 0xc4, 0xf6, 0x7e, 0xf6, 0xa0, 0x93, 0x24} +}; + +int64_t dxgiToVulkan(DXGI_FORMAT format) +{ + switch (format) { + case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: + return 43; // VK_FORMAT_R8G8B8A8_SRGB + case DXGI_FORMAT_R8G8B8A8_UNORM: + return 37; // VK_FORMAT_R8G8B8A8_UNORM + case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: + return 50; // VK_FORMAT_B8G8R8A8_SRGB + case DXGI_FORMAT_B8G8R8A8_UNORM: + return 44; // VK_FORMAT_B8G8R8A8_UNORM + default: + return 0; + } +} + +int64_t toMicro(float value) +{ + return static_cast(std::llround(static_cast(value) * 1000000.0)); +} + +class DirectD3D11Transport { +public: + DirectD3D11Transport() = default; + ~DirectD3D11Transport() { shutdown(); } + + bool store(vr::EVREye eye, const vr::Texture_t* texture, const vr::VRTextureBounds_t* bounds) + { + if (!texture || !texture->handle || texture->eType != vr::TextureType_DirectX) { + OOVR_LOGF("DrvGameNative: unsupported texture type %d", texture ? texture->eType : -1); + return false; + } + + auto* source = static_cast(texture->handle); + D3D11_TEXTURE2D_DESC sourceDesc{}; + source->GetDesc(&sourceDesc); + if (!ensureDevice(source)) return false; + + const unsigned eyeIndex = eye == vr::Eye_Right ? 1u : 0u; + D3D11_BOX sourceBox{}; + sourceBox.left = 0; + sourceBox.top = 0; + sourceBox.front = 0; + sourceBox.right = sourceDesc.Width; + sourceBox.bottom = sourceDesc.Height; + sourceBox.back = 1; + bool verticallyInverted = false; + if (bounds) { + const float uMin = std::clamp(std::min(bounds->uMin, bounds->uMax), 0.0f, 1.0f); + const float uMax = std::clamp(std::max(bounds->uMin, bounds->uMax), 0.0f, 1.0f); + const float vMin = std::clamp(std::min(bounds->vMin, bounds->vMax), 0.0f, 1.0f); + const float vMax = std::clamp(std::max(bounds->vMin, bounds->vMax), 0.0f, 1.0f); + sourceBox.left = static_cast(std::floor(uMin * sourceDesc.Width)); + sourceBox.right = static_cast(std::ceil(uMax * sourceDesc.Width)); + sourceBox.top = static_cast(std::floor(vMin * sourceDesc.Height)); + sourceBox.bottom = static_cast(std::ceil(vMax * sourceDesc.Height)); + verticallyInverted = bounds->vMin > bounds->vMax; + } + if (sourceBox.right <= sourceBox.left || sourceBox.bottom <= sourceBox.top) return false; + + const UINT width = sourceBox.right - sourceBox.left; + const UINT height = sourceBox.bottom - sourceBox.top; + EyeChain& chain = eyes_[eyeIndex]; + if (!ensureChain(chain, eyeIndex, width, height, sourceDesc.Format)) return false; + if (!acquire(chain)) return false; + + const UINT sourceArrayIndex = sourceDesc.ArraySize > 1 ? eyeIndex : 0; + const UINT sourceSubresource = D3D11CalcSubresource(0, sourceArrayIndex, sourceDesc.MipLevels); + ID3D11Texture2D* copySource = source; + UINT copySubresource = sourceSubresource; + if (sourceDesc.SampleDesc.Count > 1) { + if (!ensureResolve(chain, sourceDesc)) return false; + context_->ResolveSubresource(chain.resolve, sourceSubresource, source, sourceSubresource, sourceDesc.Format); + copySource = chain.resolve; + } + + context_->CopySubresourceRegion( + chain.images[chain.current], 0, 0, 0, 0, copySource, copySubresource, &sourceBox); + chain.ready = true; + chain.verticallyInverted = verticallyInverted; + if (verticallyInverted && !loggedInverted_) { + loggedInverted_ = true; + OOVR_LOG("DrvGameNative: vertically inverted bounds use the compatibility copy path"); + } + return true; + } + + bool submit(const gamenative_control_view views[2]) + { + if (!ready_ || !eyes_[0].ready || !eyes_[1].ready) return false; + + interop_->lpVtbl->FlushRenderingCommands(interop_); + interop_->lpVtbl->LockSubmissionQueue(interop_); + + gn_unix_submit_stereo_args args{}; + args.view_count = 2; + for (unsigned eye = 0; eye < 2; ++eye) { + EyeChain& chain = eyes_[eye]; + gn_unix_submit_view_args& view = args.views[eye]; + view.slot = chain.slot; + view.image_index = chain.current; + view.eye = eye; + view.array_index = 0; + view.rect_x = 0; + view.rect_y = 0; + view.rect_width = chain.width; + view.rect_height = chain.height; + for (unsigned i = 0; i < 4; ++i) { + view.orientation_micro[i] = views[eye].orientation[i]; + view.fov_micro[i] = views[eye].fov[i]; + } + for (unsigned i = 0; i < 3; ++i) view.position_micro[i] = views[eye].position[i]; + } + args.result = GN_UNIX_ERROR_UNAVAILABLE; + const bool ok = unixCall_(GN_UNIX_SUBMIT_STEREO, &args) == 0 && args.result == GN_UNIX_SUCCESS; + interop_->lpVtbl->ReleaseSubmissionQueue(interop_); + if (!ok) { + OOVR_LOGF("DrvGameNative: stereo submit failed result=%d", args.result); + return false; + } + for (EyeChain& chain : eyes_) { + chain.submitted[chain.current] = true; + chain.ready = false; + chain.next = (chain.current + 1) % chain.imageCount; + } + ++submittedFrames_; + if (submittedFrames_ == 1) { + OOVR_LOG("DrvGameNative: first device-local stereo frame submitted"); + } + return true; + } + + bool configured() const { return ready_; } + + bool locateViews(gamenative_control_view views[2], uint32_t* flags) + { + if (!views || !flags || !ensureBridge()) return false; + gn_unix_control_transact_args control{}; + strcpy_s(control.request, sizeof(control.request), "LOCATE_VIEWS"); + control.response_lines = 1; + control.result = GN_UNIX_ERROR_UNAVAILABLE; + if (unixCall_(GN_UNIX_CONTROL_TRANSACT, &control) != 0 || control.result != GN_UNIX_SUCCESS) { + OOVR_LOGF("DrvGameNative: fast view request failed result=%d", control.result); + return false; + } + int consumed = 0; + if (sscanf_s(control.response, "OK flags=%u %n", flags, &consumed) < 1 || consumed <= 0) return false; + const char* cursor = control.response + consumed; + for (uint32_t eye = 0; eye < 2; ++eye) { + int32_t* values[] = { + &views[eye].orientation[0], &views[eye].orientation[1], + &views[eye].orientation[2], &views[eye].orientation[3], + &views[eye].position[0], &views[eye].position[1], &views[eye].position[2], + &views[eye].fov[0], &views[eye].fov[1], &views[eye].fov[2], &views[eye].fov[3], + }; + for (int32_t* value : values) { + char* end = nullptr; + const long parsed = strtol(cursor, &end, 10); + if (end == cursor) return false; + *value = static_cast(parsed); + cursor = end; + while (*cursor == ' ') ++cursor; + } + } + return true; + } + +private: + struct EyeChain { + uint32_t slot = 0; + uint32_t width = 0; + uint32_t height = 0; + DXGI_FORMAT format = DXGI_FORMAT_UNKNOWN; + uint32_t imageCount = 0; + uint32_t current = 0; + uint32_t next = 0; + std::array images{}; + std::array submitted{}; + ID3D11Texture2D* resolve = nullptr; + bool ready = false; + bool verticallyInverted = false; + }; + + bool ensureBridge() + { + if (unixCall_) return true; + bridge_ = LoadLibraryA("gamenative_xr_unixbridge.dll"); + unixCall_ = bridge_ ? reinterpret_cast(GetProcAddress(bridge_, "gnWineUnixCall")) : nullptr; + if (!unixCall_) { + OOVR_LOGF("DrvGameNative: Wine unix bridge unavailable error=%lu", GetLastError()); + if (bridge_) FreeLibrary(bridge_); + bridge_ = nullptr; + return false; + } + gn_unix_init_args init{}; + init.abi_version = GN_UNIX_ABI_VERSION; + init.result = GN_UNIX_ERROR_UNAVAILABLE; + if (unixCall_(GN_UNIX_INIT, &init) != 0 || init.result != GN_UNIX_SUCCESS) { + OOVR_LOGF("DrvGameNative: unix ABI handshake failed abi=%u result=%d", GN_UNIX_ABI_VERSION, init.result); + FreeLibrary(bridge_); + bridge_ = nullptr; + unixCall_ = nullptr; + return false; + } + OOVR_LOGF("DrvGameNative: unix bridge ready ABI=%u control=unix-fast", GN_UNIX_ABI_VERSION); + return true; + } + + bool ensureDevice(ID3D11Texture2D* source) + { + ID3D11Device* sourceDevice = nullptr; + source->GetDevice(&sourceDevice); + if (!sourceDevice) return false; + if (device_ == sourceDevice && ready_) { + sourceDevice->Release(); + return true; + } + if (device_ && device_ != sourceDevice) shutdown(); + device_ = sourceDevice; + device_->GetImmediateContext(&context_); + + void* rawInterop = nullptr; + HRESULT hr = device_->QueryInterface(kIidDxgiVkInteropDevice1, &rawInterop); + if (FAILED(hr) || !rawInterop) { + OOVR_LOGF("DrvGameNative: IDXGIVkInteropDevice1 unavailable hr=0x%08x", static_cast(hr)); + shutdown(); + return false; + } + interop_ = static_cast(rawInterop); + const auto* interop1 = reinterpret_cast(interop_->lpVtbl); + + uint64_t instance = 0; + uint64_t physicalDevice = 0; + uint64_t device = 0; + uint64_t queue = 0; + uint32_t queueFamily = 0; + uint32_t queueIndex = 0; + interop_->lpVtbl->GetVulkanHandles(interop_, &instance, &physicalDevice, &device); + interop1->GetSubmissionQueue1(interop_, &queue, &queueIndex, &queueFamily); + + if (!ensureBridge()) { + shutdown(); + return false; + } + + gn_unix_vulkan_context_args vk{}; + vk.client_physical_device = physicalDevice; + vk.client_device = device; + vk.client_queue = queue; + vk.queue_family_index = queueFamily; + vk.queue_index = queueIndex; + vk.handles_are_host = 0; + vk.result = GN_UNIX_ERROR_UNAVAILABLE; + if (unixCall_(GN_UNIX_SET_VULKAN_CONTEXT, &vk) != 0 || vk.result != GN_UNIX_SUCCESS) { + OOVR_LOGF("DrvGameNative: Vulkan context rejected result=%d flags=0x%x", vk.result, vk.diagnostic_flags); + shutdown(); + return false; + } + ready_ = true; + OOVR_LOGF("DrvGameNative: direct transport ready ABI=%u queue=%u:%u", GN_UNIX_ABI_VERSION, queueFamily, queueIndex); + return true; + } + + bool ensureChain(EyeChain& chain, uint32_t eye, UINT width, UINT height, DXGI_FORMAT format) + { + if (chain.imageCount && chain.width == width && chain.height == height && chain.format == format) return true; + destroyChain(chain); + const int64_t vkFormat = dxgiToVulkan(format); + if (!vkFormat) { + OOVR_LOGF("DrvGameNative: unsupported DXGI format %d", format); + return false; + } + + chain.slot = 28u + eye; + gn_unix_create_swapchain_args create{}; + create.slot = chain.slot; + create.width = width; + create.height = height; + create.array_size = 1; + create.mip_count = 1; + create.sample_count = 1; + create.format = vkFormat; + // XrSwapchainUsageFlags: color attachment, transfer destination and sampled. + create.usage = 0x1u | 0x10u | 0x20u; + create.result = GN_UNIX_ERROR_UNAVAILABLE; + if (unixCall_(GN_UNIX_CREATE_SWAPCHAIN, &create) != 0 || + create.result != GN_UNIX_SUCCESS || create.image_count < 2 || create.image_count > GN_UNIX_MAX_IMAGES) { + OOVR_LOGF("DrvGameNative: eye %u transport swapchain failed result=%d", eye, create.result); + return false; + } + chain.imageCount = create.image_count; + + const auto* interop1 = reinterpret_cast(interop_->lpVtbl); + D3D11Texture2DDesc1 desc{}; + desc.Width = width; + desc.Height = height; + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = format; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET; + for (uint32_t i = 0; i < create.image_count; ++i) { + HRESULT hr = interop1->CreateTexture2DFromVkImage(interop_, &desc, create.images[i], &chain.images[i]); + if (FAILED(hr) || !chain.images[i]) { + OOVR_LOGF("DrvGameNative: wrapping eye %u image %u failed hr=0x%08x", eye, i, static_cast(hr)); + destroyChain(chain); + return false; + } + } + chain.width = width; + chain.height = height; + chain.format = format; + OOVR_LOGF("DrvGameNative: registered eye=%u images=%u size=%ux%u format=%d path=device-local-gpu-copy", + eye, chain.imageCount, width, height, format); + return true; + } + + bool ensureResolve(EyeChain& chain, const D3D11_TEXTURE2D_DESC& sourceDesc) + { + if (chain.resolve) return true; + D3D11_TEXTURE2D_DESC desc = sourceDesc; + desc.SampleDesc.Count = 1; + desc.SampleDesc.Quality = 0; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.CPUAccessFlags = 0; + desc.MiscFlags = 0; + return SUCCEEDED(device_->CreateTexture2D(&desc, nullptr, &chain.resolve)) && chain.resolve; + } + + bool acquire(EyeChain& chain) + { + chain.current = chain.next; + if (!chain.submitted[chain.current]) return true; + gn_unix_acquire_image_args acquire{}; + acquire.slot = chain.slot; + acquire.image_index = chain.current; + acquire.timeout_ns = 500000000; + acquire.result = GN_UNIX_ERROR_UNAVAILABLE; + if (unixCall_(GN_UNIX_ACQUIRE_IMAGE, &acquire) != 0 || acquire.result != GN_UNIX_SUCCESS) { + OOVR_LOGF("DrvGameNative: acquire timeout/failure slot=%u image=%u result=%d", + chain.slot, chain.current, acquire.result); + return false; + } + chain.submitted[chain.current] = false; + return true; + } + + void destroyChain(EyeChain& chain) + { + if (chain.resolve) chain.resolve->Release(); + chain.resolve = nullptr; + for (ID3D11Texture2D*& image : chain.images) { + if (image) image->Release(); + image = nullptr; + } + if (chain.imageCount && unixCall_) { + gn_unix_destroy_swapchain_args destroy{}; + destroy.slot = chain.slot; + destroy.result = GN_UNIX_ERROR_UNAVAILABLE; + unixCall_(GN_UNIX_DESTROY_SWAPCHAIN, &destroy); + } + chain = EyeChain{}; + } + + void shutdown() + { + for (EyeChain& chain : eyes_) destroyChain(chain); + if (interop_) interop_->lpVtbl->Release(interop_); + interop_ = nullptr; + if (context_) context_->Release(); + context_ = nullptr; + if (device_) device_->Release(); + device_ = nullptr; + if (bridge_) FreeLibrary(bridge_); + bridge_ = nullptr; + unixCall_ = nullptr; + ready_ = false; + } + + ID3D11Device* device_ = nullptr; + ID3D11DeviceContext* context_ = nullptr; + DxgiVkInteropDevice* interop_ = nullptr; + HMODULE bridge_ = nullptr; + WineUnixCall unixCall_ = nullptr; + std::array eyes_{}; + uint64_t submittedFrames_ = 0; + bool ready_ = false; + bool loggedInverted_ = false; +}; + +class Backend final : public IBackend { +public: + explicit Backend(XrBackend* compatibility) + : compatibility_(compatibility) + { + OOVR_LOGF("DrvGameNative: selected backend protocol=%u graphics=direct-unixlib input=openxr-compat", GN_UNIX_ABI_VERSION); + } + + ~Backend() override { delete compatibility_; } + + std::shared_ptr GetPrimaryHMD() override { return compatibility_->GetPrimaryHMD(); } + std::shared_ptr GetDevice(vr::TrackedDeviceIndex_t index) override { return compatibility_->GetDevice(index); } + std::shared_ptr GetDeviceByHand(ITrackedDevice::TrackedDeviceType hand) override { return compatibility_->GetDeviceByHand(hand); } + void GetDeviceToAbsoluteTrackingPose(vr::ETrackingUniverseOrigin origin, float prediction, vr::TrackedDevicePose_t* poses, uint32_t count) override + { + compatibility_->GetDeviceToAbsoluteTrackingPose(origin, prediction, poses, count); + } + + void WaitForTrackingData() override + { + compatibility_->WaitForTrackingData(); + uint32_t flags = 0; + if (!transport_.locateViews(views_.data(), &flags)) { + OOVR_LOG("DrvGameNative: view snapshot unavailable after frame wait"); + } else { + viewFlags_ = flags; + } + } + + void StoreEyeTexture(vr::EVREye eye, const vr::Texture_t* texture, const vr::VRTextureBounds_t* bounds, + vr::EVRSubmitFlags submitFlags, bool isFirstEye) override + { + (void)submitFlags; + (void)isFirstEye; + submitted_[eye == vr::Eye_Right ? 1 : 0] = transport_.store(eye, texture, bounds); + } + + void SubmitFrames(bool showSkybox, bool postPresent) override + { + (void)showSkybox; + if (!postPresent && submitted_[0] && submitted_[1] && viewFlags_ != 0) { + transport_.submit(views_.data()); + } + submitted_[0] = submitted_[1] = false; + // End the control-only OpenXR compatibility frame with no graphics layers. + compatibility_->SubmitFrames(false, postPresent); + } + + openvr_enum_t SetSkyboxOverride(const vr::Texture_t* textures, uint32_t count) override + { + return compatibility_->SetSkyboxOverride(textures, count); + } + void ClearSkyboxOverride() override { compatibility_->ClearSkyboxOverride(); } + bool GetFrameTiming(OOVR_Compositor_FrameTiming* timing, uint32_t framesAgo) override + { + return compatibility_->GetFrameTiming(timing, framesAgo); + } + openvr_enum_t GetMirrorTextureD3D11(vr::EVREye eye, void* device, void** view) override + { + return compatibility_->GetMirrorTextureD3D11(eye, device, view); + } + void ReleaseMirrorTextureD3D11(void* view) override { compatibility_->ReleaseMirrorTextureD3D11(view); } + bool GetPlayAreaPoints(vr::HmdVector3_t* points, int* count) override { return compatibility_->GetPlayAreaPoints(points, count); } + bool AreBoundsVisible() override { return compatibility_->AreBoundsVisible(); } + void ForceBoundsVisible(bool visible) override { compatibility_->ForceBoundsVisible(visible); } + void PumpEvents() override { compatibility_->PumpEvents(); } + bool IsInputAvailable() override { return compatibility_->IsInputAvailable(); } + bool IsGraphicsConfigured() override { return transport_.configured(); } + void OnOverlayTexture(const vr::Texture_t* texture) override { compatibility_->OnOverlayTexture(texture); } + +private: + XrBackend* compatibility_; + DirectD3D11Transport transport_; + std::array views_{}; + std::array submitted_{}; + uint32_t viewFlags_ = 0; +}; + +} // namespace + +IBackend* DrvGameNative::CreateGameNativeBackend(const char* startupInfo) +{ + auto* compatibility = static_cast(DrvOpenXR::CreateOpenXRBackend(startupInfo)); + if (!compatibility) { + OOVR_LOG("DrvGameNative: failed to initialize OpenXR input compatibility layer"); + return nullptr; + } + return new Backend(compatibility); +} diff --git a/app/src/main/windows/openvr_client/DrvGameNative.h b/app/src/main/windows/openvr_client/DrvGameNative.h new file mode 100644 index 0000000000..929274c47f --- /dev/null +++ b/app/src/main/windows/openvr_client/DrvGameNative.h @@ -0,0 +1,12 @@ +#pragma once + +#include "Drivers/Backend.h" + +namespace DrvGameNative { + +// Creates the GameNative OpenVR backend. OpenComposite keeps its mature +// OpenXR-backed input compatibility session, while all eye images bypass the +// Windows OpenXR compositor and are submitted to GameNative's Wine unixlib. +IBackend* CreateGameNativeBackend(const char* startupInfo); + +} // namespace DrvGameNative diff --git a/app/src/main/windows/openxr_runtime/gamenative_control.h b/app/src/main/windows/openxr_runtime/gamenative_control.h index 24ece0eecf..a6cdccb05e 100644 --- a/app/src/main/windows/openxr_runtime/gamenative_control.h +++ b/app/src/main/windows/openxr_runtime/gamenative_control.h @@ -3,6 +3,10 @@ #include +#ifdef __cplusplus +extern "C" { +#endif + typedef struct gamenative_control_frame { uint64_t serial; int64_t display_time; @@ -39,4 +43,8 @@ int gamenative_control_locate_views(gamenative_control_view views[2], uint32_t * int gamenative_control_get_input(uint32_t hand, gamenative_control_input *input); int gamenative_control_haptic(uint32_t hand, int32_t amplitude, int64_t duration, int32_t frequency); +#ifdef __cplusplus +} +#endif + #endif diff --git a/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime.c b/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime.c index a836ee55c3..91c6bd3a51 100644 --- a/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime.c +++ b/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime.c @@ -530,6 +530,8 @@ typedef struct { typedef struct { int active; + int grip_active; + int aim_active; unsigned buttons; float trigger, squeeze, sx, sy; float grip[7]; @@ -1491,6 +1493,8 @@ static void gn_refresh_hand(int hand) { } h->active = (int)gn_parse_i64(response, "active", 0); + h->grip_active = (int)gn_parse_i64(response, "gripActive", h->active); + h->aim_active = (int)gn_parse_i64(response, "aimActive", h->active); 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); @@ -2176,8 +2180,19 @@ static int gn_space_absolute_pose( 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); + /* OpenVR device tracking is independent from the game's active input action sets. + * Requiring active_hands here made OpenComposite expose a valid identity pose until its + * legacy actions were synced, which placed both controllers at the stage origin. */ + if (!gn_hands[hand].active) return 0; + if (comp == GN_COMP_AIM_POSE) { + if (!gn_hands[hand].aim_active) return 0; + gn_write_pose(&tracked, gn_hands[hand].aim); + } else if (comp == GN_COMP_GRIP_POSE) { + if (!gn_hands[hand].grip_active) return 0; + gn_write_pose(&tracked, gn_hands[hand].grip); + } else { + return 0; + } *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]; @@ -3227,7 +3242,10 @@ static int gn_resolve_action(const XrActionStateGetInfo* getInfo, int* out_hand, 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; + if ((gn_actions[action_idx].active_hands & (1u << hand)) == 0 || !gn_hands[hand].active) return 0; + if (comp == GN_COMP_GRIP_POSE) return gn_hands[hand].grip_active; + if (comp == GN_COMP_AIM_POSE) return gn_hands[hand].aim_active; + return 1; } static XrResult XRAPI_CALL gn_xrGetActionStateBoolean( diff --git a/app/src/test/java/app/gamenative/AndroidXrLaunchTest.kt b/app/src/test/java/app/gamenative/AndroidXrLaunchTest.kt new file mode 100644 index 0000000000..812693e258 --- /dev/null +++ b/app/src/test/java/app/gamenative/AndroidXrLaunchTest.kt @@ -0,0 +1,53 @@ +package app.gamenative + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.test.core.app.ApplicationProvider +import app.gamenative.ui.screen.xr.ImmersiveXrActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [35]) +class AndroidXrLaunchTest { + + @Test + fun androidXrOpenXrFeatureIsRecognizedAsHeadset() { + val packageManager = mock() + val context = mock() + whenever(context.packageManager).thenReturn(packageManager) + whenever(packageManager.hasSystemFeature("android.hardware.vr.headtracking")).thenReturn(false) + whenever(packageManager.hasSystemFeature("android.software.xr.api.openxr")).thenReturn(true) + + assertTrue(MainActivity.isHeadset(context)) + } + + @Test + fun activityLaunchStaysOnFlatLauncherTask() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + + val intent = ImmersiveXrActivity.createLaunchIntent(activity, "STEAM_123", false) + + assertFalse(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + assertEquals(ImmersiveXrActivity::class.java.name, intent.component?.className) + } + + @Test + fun nonActivityLaunchUsesNewTask() { + val context = ApplicationProvider.getApplicationContext() + + val intent = ImmersiveXrActivity.createLaunchIntent(context, "STEAM_123", true) + + assertTrue(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + } +} diff --git a/app/src/test/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnershipTest.kt b/app/src/test/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnershipTest.kt new file mode 100644 index 0000000000..4d22009f8b --- /dev/null +++ b/app/src/test/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnershipTest.kt @@ -0,0 +1,62 @@ +package app.gamenative.ui.screen.xr + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ImmersiveSessionOwnershipTest { + @Before + fun setUp() { + ImmersiveSessionOwnership.resetForTest() + } + + @After + fun tearDown() { + ImmersiveSessionOwnership.resetForTest() + } + + @Test + fun launcherCannotTearDownEnvironmentWhileImmersiveLaunchIsPending() { + ImmersiveSessionOwnership.beginLaunch("STEAM_123") + + assertFalse(ImmersiveSessionOwnership.shouldLauncherHandleDestruction(false)) + assertEquals(ImmersiveSessionOwnership.Phase.LAUNCHING, ImmersiveSessionOwnership.snapshot().phase) + } + + @Test + fun launcherCannotTearDownEnvironmentWhileImmersiveActivityIsActive() { + ImmersiveSessionOwnership.beginLaunch("STEAM_123") + ImmersiveSessionOwnership.markActivityActive("STEAM_123") + + assertFalse(ImmersiveSessionOwnership.shouldLauncherHandleDestruction(false)) + assertEquals(ImmersiveSessionOwnership.Phase.ACTIVE, ImmersiveSessionOwnership.snapshot().phase) + } + + @Test + fun launcherRegainsTeardownOwnershipAfterImmersiveSessionFinishes() { + ImmersiveSessionOwnership.beginLaunch("STEAM_123") + ImmersiveSessionOwnership.markActivityActive("STEAM_123") + ImmersiveSessionOwnership.release("STEAM_123") + + assertTrue(ImmersiveSessionOwnership.shouldLauncherHandleDestruction(false)) + assertEquals(ImmersiveSessionOwnership.Phase.IDLE, ImmersiveSessionOwnership.snapshot().phase) + } + + @Test + fun mismatchedActivityCannotReleaseAnotherGamesHandoff() { + ImmersiveSessionOwnership.beginLaunch("STEAM_123") + + ImmersiveSessionOwnership.release("STEAM_456") + + assertTrue(ImmersiveSessionOwnership.isOwnedByImmersive()) + assertEquals("STEAM_123", ImmersiveSessionOwnership.snapshot().appId) + } + + @Test + fun configurationRecreationNeverGivesLauncherTeardownOwnership() { + assertFalse(ImmersiveSessionOwnership.shouldLauncherHandleDestruction(true)) + } +} diff --git a/app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServerTest.kt b/app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServerTest.kt new file mode 100644 index 0000000000..670257653b --- /dev/null +++ b/app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServerTest.kt @@ -0,0 +1,29 @@ +package app.gamenative.ui.screen.xr.windows + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WindowsVrControlServerTest { + @Test + fun `left controller buttons map to OpenVR semantics`() { + val xboxButtons = (1 shl 2) or (1 shl 3) or (1 shl 8) or (1 shl 7) + + assertEquals(0b1111, openVrControllerButtons(xboxButtons, hand = 0)) + } + + @Test + fun `right controller buttons map independently`() { + val xboxButtons = (1 shl 0) or (1 shl 1) or (1 shl 9) or (1 shl 7) + + assertEquals(0b0111, openVrControllerButtons(xboxButtons, hand = 1)) + } + + @Test + fun `buttons from the other hand do not leak`() { + val leftOnly = (1 shl 2) or (1 shl 3) or (1 shl 8) + val rightOnly = (1 shl 0) or (1 shl 1) or (1 shl 9) + + assertEquals(0, openVrControllerButtons(leftOnly, hand = 1)) + assertEquals(0, openVrControllerButtons(rightOnly, hand = 0)) + } +} diff --git a/docs/xr/native-openvr-runtime-plan.md b/docs/xr/native-openvr-runtime-plan.md new file mode 100644 index 0000000000..afbd32c13b --- /dev/null +++ b/docs/xr/native-openvr-runtime-plan.md @@ -0,0 +1,198 @@ +# GameNative native OpenVR runtime preparation + +## Objective + +Keep OpenComposite's mature OpenVR application ABI and compatibility logic, but replace its `DrvOpenXR` backend with a `DrvGameNative` backend. The new backend must connect to the same native XR host used by GameNative's Windows OpenXR runtime. It must not create a second Windows OpenXR session. + +The target paths are: + +```text +OpenXR game -> GameNative Windows OpenXR runtime ----\ + -> shared XR transport/core -> native Vulkan presenter -> headset OpenXR +OpenVR game -> OpenComposite ABI -> DrvGameNative --/ +``` + +This should make the OpenVR path comparable to the direct OpenXR path. A final native composition/blit may still be required when an application texture cannot itself be a headset-runtime swapchain image, but there must be no CPU image copy and no per-frame texture re-registration. + +## Prepared repositories + +- GameNative: `C:\Users\flori\Documents\Coding\gamenative` +- OpenComposite development checkout: `C:\Users\flori\Documents\Coding\OpenComposite-gamenative` +- OpenComposite upstream revision: `a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d` +- Baseline compatibility commit in the prepared checkout: `f0dde5f` +- Development branch: `gamenative-native-openvr` +- Upstream remote is named `upstream` and its push URL is disabled. Add a writable fork as `origin` before pushing development work. + +Recreate the checkout with: + +```powershell +powershell -ExecutionPolicy Bypass -File .\tools\prepare-native-openvr-dev.ps1 +``` + +Build the current developer checkout without changing APK payloads with: + +```powershell +powershell -ExecutionPolicy Bypass -File .\tools\build-native-openvr-dev.ps1 +``` + +The isolated output defaults to `app/build/native-openvr-dev/opencomposite_x64.dll`. Normal Gradle packaging continues to use the disposable, pinned `app/build/opencomposite-source` checkout. + +## Correct integration seam + +`OpenOVR/Drivers/Backend.h` defines `IBackend`, `ITrackedDevice`, and `IHMD`. `OCOVR/openvr_api.cpp` currently constructs `DrvOpenXR::CreateOpenXRBackend(...)`, while compositor calls are routed through `BackendManager`. Add a sibling `DrvGameNative` static library implementing `IBackend` and choose it in `VR_InitInternal2` for GameNative builds. + +Do not fork or rewrite the generated OpenVR interfaces. Retain: + +- OpenVR interface/version exports and generated stubs; +- application/action manifest parsing and legacy input compatibility; +- D3D11/Vulkan texture interpretation and bounds handling; +- controller/profile property emulation; +- known application-type compatibility behavior. + +Do not retain in the GameNative backend: + +- creation of a Windows OpenXR instance or session; +- OpenXR action sets owned by `DrvOpenXR`; +- OpenXR swapchains or compositor submission inside the Wine process; +- per-game replacement of Valve's public `openvr_api.dll` as the final deployment model. + +During bring-up, DLL replacement remains an acceptable loader mechanism. The final runtime should expose a SteamVR-compatible runtime registration and provide `vrclient_x64.dll` centrally. + +## Shared client/host protocol + +Extend the existing GameNative XR client/Unix bridge instead of creating an unrelated socket protocol. Version every structure and reject incompatible peers explicitly. + +### Control plane + +Use the existing local Unix-domain control channel only for low-frequency operations: + +- protocol handshake and feature negotiation; +- create/destroy client and scene session; +- register/unregister graphics device and submitted images; +- transfer external-memory and synchronization file descriptors; +- action manifest and binding setup; +- haptic requests, errors, and orderly shutdown. + +### Data plane + +Use shared memory for high-frequency state: + +- predicted display time and frame serial; +- HMD/controller poses and validity flags; +- buttons, touches, axes, skeletal state where available; +- session visibility/focus state; +- compositor timing counters and dropped/repeated-frame statistics. + +Use atomic sequence counters around snapshots. Never serialize poses or controller state through a request/response socket once the shared state page is mapped. + +### Graphics plane + +For each eye or array image: + +1. Import the Wine/DXVK Vulkan image into the native process once using external memory. +2. Cache the registration by stable image identity, dimensions, format, array layer, and generation. +3. Transfer file descriptors only during registration/recreation. +4. Per frame, publish image id, bounds, color space, pose/time serial, and a GPU-completion primitive. +5. Wait on the GPU primitive natively, perform only the required layout/format transition or composition pass, and signal completion before reuse. + +Never map image pixels on the CPU. Never allocate or import an `AHardwareBuffer` per frame. Prefer timeline semaphores when the Wine/Vulkan export path supports them; otherwise use sync FDs/fences with an explicit ownership protocol. + +## Work packages + +### 1. Backend selection and clean compile + +- Add `GAMENATIVE_BACKEND` CMake option. +- Add `DrvGameNative` target and a small backend factory header independent of OpenXR. +- Move the direct `DrvOpenXR::CreateOpenXRBackend` call behind the factory. +- Make `VR_IsHmdPresent` query the selected backend rather than constructing a temporary OpenXR instance. +- Keep upstream `DrvOpenXR` fully buildable for comparison. + +Acceptance: both stock OpenComposite and the GameNative variant compile; the latter loads and performs a versioned handshake without creating Windows OpenXR objects. + +### 2. Remove hidden `XrBackend` coupling + +Audit `BaseCompositor`, `BaseInput`, `BaseOverlay`, `BaseSystem`, and compositor implementations. Replace direct `XrBackend` calls with narrow `IBackend` capabilities. Important known calls include input-session restart and Vulkan physical-device lookup. Add capability methods rather than downcasts. + +Acceptance: `OCCore` has no include dependency on `DrvOpenXR/XrBackend.h` in a GameNative build. + +### 3. Tracking, display, and timing + +- Implement HMD and controller devices from the shared state page. +- Convert GameNative stage/local spaces to OpenVR standing/seated/raw universes consistently. +- Report actual floor-level standing origin; do not derive floor height from the current HMD pose. +- Source recommended size, eye transforms, FOV, IPD, refresh period, focus, and vsync timing from the native host. +- Make `WaitGetPoses` wait for the native predicted-frame serial, not a generic sleep. + +Acceptance: a scene application receives stable stereo poses and correct floor height before graphics submission. + +### 4. Device-local graphics submission + +- Start with D3D11/DXVK because it is the dominant GameNative path. +- Reuse the proven Wine Vulkan handle-unwrapping and Unix-bridge mechanisms. +- Register images once and keep them device-local. +- Support per-eye textures, texture arrays, bounds, sRGB/linear formats, and image recreation. +- Add Vulkan application submission after D3D11 is stable. + +Acceptance: eye images appear as native stereo projection layers, never as the immersive-mode flat panel; transport logs show zero CPU image copies. + +### 5. Input, haptics, and compatibility + +- Map OpenVR action manifests to the native input snapshot. +- Preserve legacy controller-state APIs. +- Implement Oculus Touch-compatible properties and paths while keeping other profiles graceful. +- Forward haptics through the host control plane. +- Support scene, overlay, utility, and background application types without allowing helper clients to steal the scene session. + +Acceptance: The Lab interactions work; background/utility clients do not crash or start competing sessions. + +### 6. Runtime registration and lifecycle + +- Package one central OpenVR runtime directory in GameNative. +- Generate `openvrpaths.vrpath`/registry-compatible discovery for the Wine prefix. +- Provide `vrclient_x64.dll` and required manifests centrally. +- Keep per-game DLL replacement only as an opt-in fallback. +- Tie scene ownership to immersive activity lifecycle and retain diagnostics after activity exit. + +Acceptance: an unmodified OpenVR game discovers the runtime as SteamVR-compatible and starts through GameNative immersive mode. + +### 7. Performance completion + +- Move the native presenter from GLES to Vulkan so import, synchronization, and headset submission share one graphics API. +- Stop flat X-server rendering and flat-panel image copies only after native stereo frames are confirmed active; restore them on disconnect/error. +- Replace generic 2:1 repetition with compositor-aware pacing driven by predicted display time and measured application cadence. +- Profile render, Wine/FEX, transport, and XR threads separately before changing affinities/priorities. +- Update FEX and its lower-memory JIT cache independently of the OpenVR backend. Do not add Beat Saber-specific TSO metadata. + +## Required diagnostics + +Every launch must log: + +- OpenVR application type and requested interface versions; +- selected backend and protocol versions/features; +- native host connection/session state; +- graphics API, device identity, image registration/recreation, format and dimensions; +- external-memory and synchronization path selected; +- frame serials, waits, drops, repeats, and GPU timeout/errors; +- input profile/action manifest status; +- orderly teardown reason. + +Logs must be written outside ephemeral activity state so they remain exportable after a crash. + +## Regression matrix + +- Beat Saber direct OpenXR: unchanged behavior and performance. +- Beat Saber OpenVR mode: stereo projection, controls, audio, correct floor. +- The Lab: multiple action sets, session recreation, overlays, and controller bindings. +- Vertigo 2: stable loading/menu/gameplay without positional flicker or GPU-memory growth. +- Bigscreen: application-type handling and media/audio behavior. +- Disconnect/crash: immersive menu recovers and the next launch does not inherit stale sessions or image handles. + +Record CPU/GPU frame time, image-copy count, imported-image count, memory growth, and end-to-end pose age for every comparison. + +## Guardrails + +- Keep the current GameNative OpenXR path working throughout development. +- Do not mix experimental device-local transport patches into the known-good branch without a feature gate. +- Do not create a second headset OpenXR session for OpenVR. +- Do not add title-specific behavior unless it represents a documented compatibility quirk with a safe default. +- Preserve GPLv3 notices and track upstream OpenComposite commits and local patches explicitly. diff --git a/tools/build-native-openvr-dev.ps1 b/tools/build-native-openvr-dev.ps1 new file mode 100644 index 0000000000..9e214e0b3a --- /dev/null +++ b/tools/build-native-openvr-dev.ps1 @@ -0,0 +1,19 @@ +param( + [string]$SourceDirectory, + [string]$BuildDirectory, + [string]$Destination +) + +$ErrorActionPreference = "Stop" +$repository = Split-Path -Parent $PSScriptRoot +$workspace = Split-Path -Parent $repository +$source = if ($SourceDirectory) { [IO.Path]::GetFullPath($SourceDirectory) } else { Join-Path $workspace "OpenComposite-gamenative" } +$build = if ($BuildDirectory) { [IO.Path]::GetFullPath($BuildDirectory) } else { Join-Path $repository "app\build\native-openvr-dev" } +$output = if ($Destination) { [IO.Path]::GetFullPath($Destination) } else { Join-Path $build "opencomposite_x64.dll" } +$stage = Join-Path $PSScriptRoot "stage-opencomposite.ps1" + +& powershell -ExecutionPolicy Bypass -File $stage -SourceDirectory $source -BuildDirectory $build -Destination $output +if ($LASTEXITCODE -ne 0) { throw "Native OpenVR developer build failed with exit code $LASTEXITCODE" } + +Write-Host "Native OpenVR developer payload ready: $output" +Write-Host "This isolated output is not packaged into the APK unless explicitly staged later." diff --git a/tools/patches/opencomposite-gamenative-native-backend.patch b/tools/patches/opencomposite-gamenative-native-backend.patch new file mode 100644 index 0000000000..7e8ee6be8c --- /dev/null +++ b/tools/patches/opencomposite-gamenative-native-backend.patch @@ -0,0 +1,108 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 17fd03e..3acddf5 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -21,6 +21,8 @@ option(USE_SYSTEM_OPENXR "Try using system installation of OpenXR if available" + option(USE_SYSTEM_GLM "Try using system installation of glm if available" OFF) + option(OC_BACKTRACE "Print the backtrace on crash" OFF) + option(ERROR_ON_WARNING "Set all warnings to be errors" OFF) ++option(GAMENATIVE_BACKEND "Build the GameNative direct OpenVR backend" OFF) ++set(GAMENATIVE_SOURCE_DIR "" CACHE PATH "Path to the GameNative source checkout") + + # Directory for generated files, those being split headers and stubs + set(GENERATED_DIR ${CMAKE_BINARY_DIR}/generated) +@@ -250,6 +252,35 @@ endif () + target_compile_definitions(DrvOpenXR PRIVATE ${GRAPHICS_API_SUPPORT_FLAGS}) + source_group(Public REGULAR_EXPRESSION DrvOpenXR/pub/*) + ++if (GAMENATIVE_BACKEND) ++ if (NOT WIN32) ++ message(FATAL_ERROR "The GameNative OpenVR backend currently requires Windows") ++ endif () ++ if (NOT EXISTS "${GAMENATIVE_SOURCE_DIR}/app/src/main/windows/openvr_client/DrvGameNative.cpp") ++ message(FATAL_ERROR "GAMENATIVE_SOURCE_DIR does not contain the GameNative OpenVR backend") ++ endif () ++ set(GAMENATIVE_OPENVR_DIR "${GAMENATIVE_SOURCE_DIR}/app/src/main/windows/openvr_client") ++ set(GAMENATIVE_OPENXR_DIR "${GAMENATIVE_SOURCE_DIR}/app/src/main/windows/openxr_runtime") ++ add_library(DrvGameNative STATIC ++ ${GAMENATIVE_OPENVR_DIR}/DrvGameNative.cpp ++ ${GAMENATIVE_OPENVR_DIR}/DrvGameNative.h ++ ${GAMENATIVE_OPENXR_DIR}/gamenative_control.h ++ ${GAMENATIVE_OPENXR_DIR}/gamenative_openxr_unix.h ++ ) ++ target_include_directories(DrvGameNative PUBLIC ${GAMENATIVE_OPENVR_DIR}) ++ target_include_directories(DrvGameNative PRIVATE ++ ${GAMENATIVE_OPENXR_DIR} ++ DrvOpenXR ++ DrvOpenXR/pub ++ OpenOVR ++ OpenVRHeaders ++ ${CMAKE_BINARY_DIR} ++ ) ++ target_link_libraries(DrvGameNative PUBLIC OpenVR PRIVATE DrvOpenXR ws2_32 d3d11 dxgi) ++ target_compile_definitions(DrvGameNative PRIVATE ${GRAPHICS_API_SUPPORT_FLAGS}) ++ target_precompile_headers(DrvGameNative PRIVATE ${CMAKE_SOURCE_DIR}/OpenOVR/stdafx.h) ++endif () ++ + if (NOT (XR_VALIDATION_LAYER_PATH STREQUAL "")) + target_compile_definitions(DrvOpenXR PRIVATE -DXR_VALIDATION_LAYER_PATH=\"${XR_VALIDATION_LAYER_PATH}\") + endif () +@@ -519,6 +550,10 @@ if (NOT WIN32) + else () + # On Windows this isn't a problem since the linker knows how to figure this stuff out + target_link_libraries(OCOVR OCCore DrvOpenXR) ++ if (GAMENATIVE_BACKEND) ++ target_link_libraries(OCOVR DrvGameNative) ++ target_compile_definitions(OCOVR PRIVATE GAMENATIVE_BACKEND) ++ endif () + endif () + + # If we're on Linux, compile everything as position-independent +diff --git a/DrvOpenXR/DrvOpenXR.cpp b/DrvOpenXR/DrvOpenXR.cpp +index 208e612..261b82c 100644 +--- a/DrvOpenXR/DrvOpenXR.cpp ++++ b/DrvOpenXR/DrvOpenXR.cpp +@@ -346,15 +346,11 @@ void DrvOpenXR::SetupSession() + + void DrvOpenXR::ShutdownSession() + { +- BackendManager* instance = BackendManager::InstancePtr(); + // Is it already being shut down? + // Note that this is indirectly called by the XrBackend destructor, which will have + // already called this function in that case. +- if (instance) { +- auto* backend = (XrBackend*)instance->GetBackendInstance(); +- if (backend) +- backend->PrepareForSessionShutdown(); +- } ++ if (currentBackend) ++ currentBackend->PrepareForSessionShutdown(); + + delete xr_gbl; + xr_gbl = nullptr; +diff --git a/OCOVR/openvr_api.cpp b/OCOVR/openvr_api.cpp +index 3401468..6de5f66 100644 +--- a/OCOVR/openvr_api.cpp ++++ b/OCOVR/openvr_api.cpp +@@ -19,6 +19,9 @@ + // Specific to OCOVR + #include "Drivers/Backend.h" + #include "DrvOpenXR.h" ++#ifdef GAMENATIVE_BACKEND ++#include "DrvGameNative.h" ++#endif + + using namespace vr; + +@@ -280,7 +283,11 @@ VR_INTERFACE uint32_t VR_CALLTYPE VR_InitInternal2(EVRInitError* peError, EVRApp + running = true; + + // TODO seperate this from the rest of dllmain ++ #ifdef GAMENATIVE_BACKEND ++ IBackend* backend = DrvGameNative::CreateGameNativeBackend(pStartupInfo); ++ #else + IBackend* backend = DrvOpenXR::CreateOpenXRBackend(pStartupInfo); ++ #endif + if (!backend && peError) { + *peError = VRInitError_Init_Internal; + } else { diff --git a/tools/prepare-native-openvr-dev.ps1 b/tools/prepare-native-openvr-dev.ps1 new file mode 100644 index 0000000000..ee4af40233 --- /dev/null +++ b/tools/prepare-native-openvr-dev.ps1 @@ -0,0 +1,71 @@ +param( + [string]$Destination, + [string]$Branch = "gamenative-native-openvr" +) + +$ErrorActionPreference = "Stop" +$repository = Split-Path -Parent $PSScriptRoot +$workspace = Split-Path -Parent $repository +$source = if ($Destination) { [IO.Path]::GetFullPath($Destination) } else { Join-Path $workspace "OpenComposite-gamenative" } +$upstream = "https://gitlab.com/znixian/OpenOVR.git" +$commit = "a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d" +$compatibilityPatch = Join-Path $PSScriptRoot "patches\opencomposite-gamenative-wine.patch" +$nativeBackendPatch = Join-Path $PSScriptRoot "patches\opencomposite-gamenative-native-backend.patch" + +function Invoke-Checked { + param([scriptblock]$Command, [string]$Name) + & $Command + if ($LASTEXITCODE -ne 0) { throw "$Name failed with exit code $LASTEXITCODE" } +} + +if (!(Test-Path (Join-Path $source ".git"))) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $source) | Out-Null + Invoke-Checked { git clone --recurse-submodules $upstream $source } "OpenComposite clone" +} + +$status = (& git -c "safe.directory=$source" -C $source status --porcelain) +if ($LASTEXITCODE -ne 0) { throw "Unable to inspect OpenComposite checkout" } +if ($status) { throw "OpenComposite checkout has local changes; refusing to switch or patch it: $source" } + +$remoteNames = @(& git -c "safe.directory=$source" -C $source remote) +if ($remoteNames -contains "origin" -and !($remoteNames -contains "upstream")) { + Invoke-Checked { git -c "safe.directory=$source" -C $source remote rename origin upstream } "OpenComposite remote rename" +} +if (!(@(& git -c "safe.directory=$source" -C $source remote) -contains "upstream")) { + Invoke-Checked { git -c "safe.directory=$source" -C $source remote add upstream $upstream } "OpenComposite upstream remote" +} +Invoke-Checked { git -c "safe.directory=$source" -C $source remote set-url --push upstream DISABLED } "OpenComposite upstream push protection" + +& git -c "safe.directory=$source" -C $source cat-file -e "$commit^{commit}" 2>$null +if ($LASTEXITCODE -ne 0) { + Invoke-Checked { git -c "safe.directory=$source" -C $source fetch upstream $commit } "OpenComposite pinned revision fetch" +} + +& git -c "safe.directory=$source" -C $source show-ref --verify --quiet "refs/heads/$Branch" +if ($LASTEXITCODE -eq 0) { + Invoke-Checked { git -c "safe.directory=$source" -C $source switch $Branch } "OpenComposite branch switch" +} else { + Invoke-Checked { git -c "safe.directory=$source" -C $source switch --create $Branch $commit } "OpenComposite branch creation" +} +Invoke-Checked { git -c "safe.directory=$source" -C $source merge-base --is-ancestor $commit HEAD } "OpenComposite pinned-base validation" +Invoke-Checked { git -c "safe.directory=$source" -C $source submodule update --init --recursive } "OpenComposite submodules" + +& git -c "safe.directory=$source" -C $source apply --reverse --check $compatibilityPatch 2>$null +if ($LASTEXITCODE -ne 0) { + Invoke-Checked { git -c "safe.directory=$source" -C $source apply --check $compatibilityPatch } "OpenComposite compatibility patch check" + Invoke-Checked { git -c "safe.directory=$source" -C $source apply $compatibilityPatch } "OpenComposite compatibility patch" + Write-Host "Applied the GameNative Wine compatibility patch. Review and commit it in the OpenComposite checkout." +} + +& git -c "safe.directory=$source" -C $source apply --reverse --check $nativeBackendPatch 2>$null +if ($LASTEXITCODE -ne 0) { + Invoke-Checked { git -c "safe.directory=$source" -C $source apply --check $nativeBackendPatch } "OpenComposite native backend patch check" + Invoke-Checked { git -c "safe.directory=$source" -C $source apply $nativeBackendPatch } "OpenComposite native backend patch" + Write-Host "Applied the GameNative direct OpenVR backend patch. Review and commit it in the OpenComposite checkout." +} + +Write-Host "OpenComposite native-OpenVR checkout ready" +Write-Host " source: $source" +Write-Host " branch: $Branch" +Write-Host " pinned upstream: $commit" +Write-Host " remote: upstream (fetch-only by convention; add your fork as origin before pushing)" diff --git a/tools/stage-opencomposite.ps1 b/tools/stage-opencomposite.ps1 index 751f8c397e..1b444c57ba 100644 --- a/tools/stage-opencomposite.ps1 +++ b/tools/stage-opencomposite.ps1 @@ -1,15 +1,20 @@ param( - [switch]$PrintToolchainFingerprint + [switch]$PrintToolchainFingerprint, + [string]$SourceDirectory, + [string]$BuildDirectory, + [string]$Destination ) $ErrorActionPreference = "Stop" $repository = Split-Path -Parent $PSScriptRoot $payload = Join-Path $repository "app\build\generated\xrPayload\modernXr" -$destination = Join-Path $payload "opencomposite_x64.dll" +$destination = if ($Destination) { [IO.Path]::GetFullPath($Destination) } else { Join-Path $payload "opencomposite_x64.dll" } $commit = "a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d" -$source = Join-Path $repository "app\build\opencomposite-source" -$build = Join-Path $repository "app\build\opencomposite-build" -$patch = Join-Path $PSScriptRoot "patches\opencomposite-gamenative-wine.patch" +$managedSource = [string]::IsNullOrWhiteSpace($SourceDirectory) +$source = if ($managedSource) { Join-Path $repository "app\build\opencomposite-source" } else { [IO.Path]::GetFullPath($SourceDirectory) } +$build = if ($BuildDirectory) { [IO.Path]::GetFullPath($BuildDirectory) } else { Join-Path $repository "app\build\opencomposite-build" } +$compatibilityPatch = Join-Path $PSScriptRoot "patches\opencomposite-gamenative-wine.patch" +$nativeBackendPatch = Join-Path $PSScriptRoot "patches\opencomposite-gamenative-native-backend.patch" $vulkanDefinition = Join-Path $PSScriptRoot "opencomposite-vulkan-x64.def" $ndk = if ($env:ANDROID_NDK_HOME) { $env:ANDROID_NDK_HOME } else { Join-Path $env:LOCALAPPDATA "Android\Sdk\ndk\29.0.14206865" } $ndkIncludes = Join-Path $ndk "toolchains\llvm\prebuilt\windows-x86_64\sysroot\usr\include" @@ -74,19 +79,28 @@ if ($PrintToolchainFingerprint) { exit 0 } -New-Item -ItemType Directory -Force -Path $payload | Out-Null -if (!(Test-Path (Join-Path $source ".git"))) { - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $source) | Out-Null - Invoke-Checked { git clone --no-checkout https://gitlab.com/znixian/OpenOVR.git $source } "OpenComposite clone" -} -& git -C $source cat-file -e "$commit^{commit}" 2>$null -if ($LASTEXITCODE -ne 0) { - Invoke-Checked { git -C $source fetch --depth 1 origin $commit } "OpenComposite source fetch" +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) | Out-Null +if ($managedSource) { + if (!(Test-Path (Join-Path $source ".git"))) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $source) | Out-Null + Invoke-Checked { git clone --no-checkout https://gitlab.com/znixian/OpenOVR.git $source } "OpenComposite clone" + } + & git -c "safe.directory=$source" -C $source cat-file -e "$commit^{commit}" 2>$null + if ($LASTEXITCODE -ne 0) { + Invoke-Checked { git -c "safe.directory=$source" -C $source fetch --depth 1 origin $commit } "OpenComposite source fetch" + } + Invoke-Checked { git -c "safe.directory=$source" -C $source checkout --detach --force $commit } "OpenComposite checkout" + Invoke-Checked { git -c "safe.directory=$source" -C $source submodule update --init --recursive --depth 1 } "OpenComposite submodules" + Invoke-Checked { git -c "safe.directory=$source" -C $source apply --check $compatibilityPatch } "OpenComposite compatibility patch check" + Invoke-Checked { git -c "safe.directory=$source" -C $source apply $compatibilityPatch } "OpenComposite compatibility patch" + Invoke-Checked { git -c "safe.directory=$source" -C $source apply --check $nativeBackendPatch } "OpenComposite native backend patch check" + Invoke-Checked { git -c "safe.directory=$source" -C $source apply $nativeBackendPatch } "OpenComposite native backend patch" +} else { + if (!(Test-Path (Join-Path $source ".git"))) { throw "Developer OpenComposite source is not a Git checkout: $source" } + Invoke-Checked { git -c "safe.directory=$source" -C $source merge-base --is-ancestor $commit HEAD } "OpenComposite pinned-base validation" + Invoke-Checked { git -c "safe.directory=$source" -C $source submodule update --init --recursive } "OpenComposite developer submodules" + Write-Host "Building developer OpenComposite checkout without checkout, reset, or patch: $source" } -Invoke-Checked { git -C $source checkout --detach --force $commit } "OpenComposite checkout" -Invoke-Checked { git -C $source submodule update --init --recursive --depth 1 } "OpenComposite submodules" -Invoke-Checked { git -C $source apply --check $patch } "OpenComposite patch check" -Invoke-Checked { git -C $source apply $patch } "OpenComposite patch" $vulkan = Join-Path $source "libs\vulkan" $vulkanInclude = Join-Path $vulkan "Include\vulkan" @@ -97,7 +111,16 @@ Copy-Item -Recurse -Force -Path (Join-Path $ndkIncludes "vk_video\*") -Destinati Copy-Item -Force -LiteralPath $vulkanDefinition -Destination (Join-Path $vulkan "vulkan-1.def") Invoke-Checked { & $libTool.FullName /nologo "/def:$(Join-Path $vulkan 'vulkan-1.def')" /machine:x64 "/out:$(Join-Path $vulkan 'Lib\vulkan-1.lib')" } "Vulkan import library" -Invoke-Checked { cmake --fresh -S $source -B $build -G "Visual Studio 17 2022" -A x64 "-DCMAKE_GENERATOR_INSTANCE=$visualStudio" -DERROR_ON_WARNING=OFF "-DOC_VERSION=$commit-gamenative-scene-only" } "OpenComposite configure" +if ($managedSource -and (Test-Path -LiteralPath $build)) { + $allowedBuildRoot = [IO.Path]::GetFullPath((Join-Path $repository "app\build")).TrimEnd('\') + '\' + $resolvedBuild = [IO.Path]::GetFullPath($build) + if (!$resolvedBuild.StartsWith($allowedBuildRoot, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to clear OpenComposite build directory outside app/build: $resolvedBuild" + } + Remove-Item -LiteralPath $resolvedBuild -Recurse -Force +} + +Invoke-Checked { cmake -S $source -B $build -G "Visual Studio 17 2022" -A x64 "-DCMAKE_GENERATOR_INSTANCE=$visualStudio" -DERROR_ON_WARNING=OFF "-DOC_VERSION=$commit-gamenative-native-openvr" -DGAMENATIVE_BACKEND=ON "-DGAMENATIVE_SOURCE_DIR=$repository" } "OpenComposite configure" Invoke-Checked { cmake --build $build --config Release --target OCOVR --parallel } "OpenComposite build" $built = Join-Path $build "bin\Release\vrclient_x64.dll" if (!(Test-Path -LiteralPath $built -PathType Leaf)) { throw "OpenComposite build output is missing" } diff --git a/ubuntufs/build.gradle.kts b/ubuntufs/build.gradle.kts index eea8b5bcc8..74061895ab 100644 --- a/ubuntufs/build.gradle.kts +++ b/ubuntufs/build.gradle.kts @@ -25,6 +25,9 @@ android { create("modernXr") { dimension = "androidApi" } + create("androidXr") { + dimension = "androidApi" + } } buildTypes { From e894b26798ede6512f5e1ed10016d79741a9e220 Mon Sep 17 00:00:00 2001 From: Florian Reintgen <85225228+TheReal-Flo@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:54:10 +0200 Subject: [PATCH 5/5] Fix Quest immersive activity launch --- .../main/java/app/gamenative/MainActivity.kt | 11 +++-- .../ui/screen/xr/ImmersiveXrActivity.kt | 43 +++++++++++++------ .../app/gamenative/AndroidXrLaunchTest.kt | 32 ++++++++++++-- 3 files changed, 66 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index ce5b9d03e8..88d39cf766 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -208,11 +208,11 @@ class MainActivity : ComponentActivity() { // stale keepAlive from a prior crash/swipe — no container is actually running if (SteamService.keepAlive && PluviaApp.xEnvironment == null && - !ImmersiveSessionOwnership.isOwnedByImmersive() + (!BuildConfig.ANDROID_XR || !ImmersiveSessionOwnership.isOwnedByImmersive()) ) { Timber.w("onCreate: clearing stale keepAlive — no container running") PluviaApp.shutdownEnvironment() - } else if (SteamService.keepAlive && PluviaApp.xEnvironment == null) { + } else if (BuildConfig.ANDROID_XR && SteamService.keepAlive && PluviaApp.xEnvironment == null) { Timber.i( "onCreate: preserving keepAlive during immersive handoff (%s)", ImmersiveSessionOwnership.snapshot(), @@ -381,7 +381,12 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { // emit before super so Compose DisposableEffects (which unregister // listeners during super.onDestroy's lifecycle transition) still fire - if (ImmersiveSessionOwnership.shouldLauncherHandleDestruction(isChangingConfigurations)) { + val launcherShouldHandleDestruction = if (BuildConfig.ANDROID_XR) { + ImmersiveSessionOwnership.shouldLauncherHandleDestruction(isChangingConfigurations) + } else { + !isChangingConfigurations + } + if (launcherShouldHandleDestruction) { PluviaApp.events.emit(AndroidEvent.ActivityDestroyed) // if exit() didn't run (listener already unregistered, race, etc.) 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 d6cc782902..de6834ec8a 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 @@ -47,6 +47,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope +import app.gamenative.BuildConfig import app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeConfig import kotlinx.coroutines.delay import app.gamenative.PluviaApp @@ -102,22 +103,29 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { private const val EXTRA_QUAD_SCALE = "immersiveQuadScale" private const val EXTRA_PASSTHROUGH_ENABLED = "immersivePassthroughEnabled" - internal fun createLaunchIntent(context: Context, appId: String, isOffline: Boolean): Intent = + internal fun createLaunchIntent( + context: Context, + appId: String, + isOffline: Boolean, + isAndroidXr: Boolean = BuildConfig.ANDROID_XR, + ): Intent = Intent(context, ImmersiveXrActivity::class.java).apply { putExtra(EXTRA_APP_ID, appId) putExtra(EXTRA_IS_OFFLINE, isOffline) - // An Activity context keeps this on the flat launcher's task so finish() - // returns to Home Space on Android XR. Application contexts still require - // NEW_TASK (the existing Quest/background launch path). - if (context !is Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + // Android XR keeps an Activity-context launch on the flat launcher's task so + // finish() returns to Home Space. Quest must retain its proven separate-task + // transition into an Activity carrying com.oculus.intent.category.VR. + if (!isAndroidXr || context !is Activity) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } } fun start(context: Context, appId: String, isOffline: Boolean) { - ImmersiveSessionOwnership.beginLaunch(appId) + if (BuildConfig.ANDROID_XR) ImmersiveSessionOwnership.beginLaunch(appId) try { context.startActivity(createLaunchIntent(context, appId, isOffline)) } catch (t: Throwable) { - ImmersiveSessionOwnership.release(appId) + if (BuildConfig.ANDROID_XR) ImmersiveSessionOwnership.release(appId) throw t } } @@ -268,8 +276,12 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { return } currentAppId = appId - ImmersiveSessionOwnership.markActivityActive(appId) - windowsVrRuntimeService = immersiveRuntimeViewModel.windowsVrRuntimeService(this) + if (BuildConfig.ANDROID_XR) { + ImmersiveSessionOwnership.markActivityActive(appId) + windowsVrRuntimeService = immersiveRuntimeViewModel.windowsVrRuntimeService(this) + } else { + windowsVrRuntimeService = app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeService(this) + } PluviaApp.isActivityInForeground = true AppUtils.keepScreenOn(this) @@ -504,15 +516,20 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { currentAppId, ) stopXrSession() - windowsVrRuntimeService = null - if (changingConfigurations) { + if (BuildConfig.ANDROID_XR && changingConfigurations) { // The replacement Activity receives the same ViewModel and control server. Keeping // both alive prevents a display-density/configuration transition from killing Wine // or disconnecting the Windows OpenXR/OpenVR runtime during startup. + windowsVrRuntimeService = null Timber.i("Immersive: preserving Wine and Windows VR service across recreation") } else { - immersiveRuntimeViewModel.closeWindowsVrRuntimeService() - ImmersiveSessionOwnership.release(currentAppId) + if (BuildConfig.ANDROID_XR) { + immersiveRuntimeViewModel.closeWindowsVrRuntimeService() + ImmersiveSessionOwnership.release(currentAppId) + } else { + windowsVrRuntimeService?.close() + } + windowsVrRuntimeService = null PluviaApp.shutdownEnvironment() } super.onDestroy() diff --git a/app/src/test/java/app/gamenative/AndroidXrLaunchTest.kt b/app/src/test/java/app/gamenative/AndroidXrLaunchTest.kt index 812693e258..09b3d6bf2a 100644 --- a/app/src/test/java/app/gamenative/AndroidXrLaunchTest.kt +++ b/app/src/test/java/app/gamenative/AndroidXrLaunchTest.kt @@ -33,20 +33,44 @@ class AndroidXrLaunchTest { } @Test - fun activityLaunchStaysOnFlatLauncherTask() { + fun androidXrActivityLaunchStaysOnFlatLauncherTask() { val activity = Robolectric.buildActivity(Activity::class.java).setup().get() - val intent = ImmersiveXrActivity.createLaunchIntent(activity, "STEAM_123", false) + val intent = ImmersiveXrActivity.createLaunchIntent( + activity, + "STEAM_123", + false, + isAndroidXr = true, + ) assertFalse(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) assertEquals(ImmersiveXrActivity::class.java.name, intent.component?.className) } @Test - fun nonActivityLaunchUsesNewTask() { + fun questActivityLaunchUsesNewTask() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + + val intent = ImmersiveXrActivity.createLaunchIntent( + activity, + "STEAM_123", + false, + isAndroidXr = false, + ) + + assertTrue(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + } + + @Test + fun androidXrNonActivityLaunchUsesNewTask() { val context = ApplicationProvider.getApplicationContext() - val intent = ImmersiveXrActivity.createLaunchIntent(context, "STEAM_123", true) + val intent = ImmersiveXrActivity.createLaunchIntent( + context, + "STEAM_123", + true, + isAndroidXr = true, + ) assertTrue(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) }