Skip to content

feat: add Meta Quest immersive launch mode with Xbox-mapped Touch controllers - #1753

Draft
moi952 wants to merge 7 commits into
utkarshdalal:masterfrom
moi952:feat/xr-game
Draft

feat: add Meta Quest immersive launch mode with Xbox-mapped Touch controllers#1753
moi952 wants to merge 7 commits into
utkarshdalal:masterfrom
moi952:feat/xr-game

Conversation

@moi952

@moi952 moi952 commented Jul 21, 2026

Copy link
Copy Markdown

Description

Note that there are 7 commits listed, but only the last one relates to this feature; I based my code on that pull request. : #1742

So, we need to wait for that one to be approved before reviewing this PR.

Build for test : https://www.transfernow.net/dl/20260723m453mObn

Summary

Implements the Meta Quest immersive/VR launch mode end-to-end: a native OpenXR session that renders the game directly into the headset via a zero-copy GPU buffer (GL and Vulkan paths), Touch controllers mapped as a standard Xbox controller for Wine, a full port of the quick menu's input handling to work with that controller mapping, and the launch entry point (per-game checkbox, manifest activity, container flag) that ties it all together. There was no immersive mode before this commit — everything below is new.

Native OpenXR session and direct GPU-to-headset rendering

  1. New xrimmersive native module (app/src/main/cpp/xrimmersive): opens an OpenXR session against the official Khronos loader (org.khronos.openxr:openxr_loader_for_android, Apache-2.0 — not a Winlator/GameNativeXR dependency), submits the game's frame as a composition-layer quad positioned/oriented in front of the user, and supports passthrough (requires declaring com.oculus.feature.PASSTHROUGH in the manifest, otherwise Horizon OS silently omits the XR_FB_passthrough extension).
  2. Two direct-render bridges avoid any CPU-side copy of the game's frame:
    • DirectGLBridge (legacy GL renderer): allocates a HardwareBuffer, imports it as a GL texture/FBO inside GLRenderer's own EGL context (native import via GLHardwareBufferImporter, since the public SDK has no usable Java binding for that), and hands the same buffer to the OpenXR session to import a second time and sample directly.
    • DirectVulkanBridge (default dxvk/Vulkan renderer): VulkanRenderer already produces an AHardwareBuffer on every real frame (onUpdateWindowContentDirect/nativeUpdateWindowContentAHB) — this just forwards that same pointer straight to the OpenXR session.
    • Both plug into the renderers via a new XrFrameBridge/VulkanXrFrameBridge interface pair, null on every non-immersive launch, so the existing 2D panel rendering path is unaffected either way.

Touch controllers mapped as an Xbox controller

  1. New XrGamepadBridge: translates native OpenXR controller state into WinHandler's existing shared-memory gamepad buffer (the same one real Bluetooth/USB controllers use), so from the Windows game's point of view a Touch controller is indistinguishable from a physical Xbox controller. No synthetic d-pad — Touch controllers don't have one, and faking it from the thumbstick fought with the real analog values.

Launch entry point

  1. New "Launch in immersive mode" checkbox in the library app screen, shown only on Meta Quest (MainActivity.isMetaQuest) and only for an installed game; persisted as a new Container.launchImmersiveMode flag.
  2. MainViewModel.launchApp branches to ImmersiveXrActivity.start(...) instead of the normal MainUiEvent.LaunchApp when that flag is set on a Quest device; immersive launches always boot straight to the executable (no "open the Wine desktop" step).
  3. New manifest entry for ImmersiveXrActivity (com.oculus.intent.category.VR, no LAUNCHER category — reachable only via the checkbox). Deliberately keeps the default taskAffinity (same task as MainActivity, no separate process): an earlier attempt at excludeFromRecents forced Android to spin up a genuinely separate task, whose teardown fired SteamService.onTaskRemoved() independently of MainActivity's own lifecycle checks and force-stopped the Steam connection mid-session (looked like a permanent "stuck on Launching" hang, fixed alongside by making onTaskRemoved respect the same keepAlive flag MainActivity already does).

Quick menu ported to immersive input

The quick menu's existing Compose UI is reused as-is; everything below is about making its focus/click/lock model work reliably when driven by native OpenXR controller polling instead of real Android KeyEvents.

  1. Added a generic LocalImmersiveInputBypass CompositionLocal so any row — current or future — can report "I'm focused and want BUTTON_A to activate/adjust me" without threading a dedicated callback through every intermediate composable. Used it for:
    • Locked-slider left/right adjustment (QuickMenuAdjustmentRow, and ScreenEffectsPanel's ScreenEffectAdjustmentRow).
    • Plain radio/toggle rows whose default "DPAD_CENTER/A clicks whatever holds real view focus" behavior is unreliable in this Activity — ScreenEffectRadioRow (upscaler mode, colorimetry), QuickMenuChoiceChip (LSFG multiplier), and QuickMenuTabButton (the tab rail — previously only reachable via LB/RB, not joystick+A).
  2. New Immersive tab in the quick menu (ImmersiveControls), for adjusting the quad's distance/scale and passthrough while in a session.
  3. Input scheme: long-press Start opens/closes the quick menu (replacing a "hold both thumbsticks" chord); double-click of either thumbstick toggles a laser-pointer "XR pointer mode" for grabbing the quad's resize/reposition handles (replacing a four-button chord). Start dispatches through a dedicated toggleQuickMenu() (open/close only) rather than the shared gameBack() handler, which carries unrelated IME/controller-rescan/touchpad side effects.
  4. Root-caused and fixed Start's physical hold also clicking whatever was focused: Horizon OS delivers the Menu/Start button as genuine Android gamepad KeyEvents through a path entirely separate from this module's native OpenXR polling (confirmed via logs), and Android's own key-fallback mechanism turns an unconsumed BUTTON_START into DPAD_CENTER. Fixed by pushing the button's physical-hold state into the quick menu, which now consumes any key event reaching it while visible and the button is held.
  5. Adjustment-row lock model: BUTTON_A locks (idempotent — a second press while already locked is a no-op, it does not toggle back off); BUTTON_B unlocks. Fixed BUTTON_B not actually matching its own check — this Activity dispatches KEYCODE_BACK for B, but the row only checked KEYCODE_BUTTON_B, so unlocking previously only ever happened as a side effect of the unconsumed BACK event clearing Compose focus.

Known remaining issue: sliders (e.g. FPS limiter target) don't reliably step through every value when held/repeated in some cases — root cause not yet confirmed, deferred for a follow-up with fresh diagnostics.

Fixed a stuck "Launching game..." splash after exiting an immersive session

ImmersiveXrActivity hosts its own separate MainViewModel instance from MainActivity's. Both stay subscribed to the same global SetBootingSplashText event bus for the whole session, so MainActivity's (backgrounded) instance kept getting its splash re-armed by every boot-sequence text update and was never told the session ended — only the immersive Activity's own instance got reset on exit. Added a ClearBootingSplash global event, broadcast from every existing reset call site (exitSteamApp, onWindowMapped, onGameLaunchError), so any subscribed MainViewModel instance clears itself regardless of which Activity triggered the reset.

Recording

Type of Change

  • Bug fix
  • Performance / stability improvement
  • Compatibility improvements
  • Other (requires prior approval)

Checklist

  • If I have access to #code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.
  • This change aligns with the current project scope (core functionality, stability, or performance). If not, it has been explicitly approved beforehand.
  • I have attached a recording of the change.
  • I have read and agree to the contribution guidelines in CONTRIBUTING.md.

Summary by cubic

Adds a Meta Quest immersive VR launch mode with native OpenXR and zero‑copy rendering, plus native Android build install/launch so supported games run as real Android apps. Also adds Android platform selection and filtering, and updates depot selection and size display to respect Android builds.

  • New Features

    • Quest immersive mode: native OpenXR via org.khronos.openxr:openxr_loader_for_android, zero‑copy GL/Vulkan bridges using AHardwareBuffer, Touch→Xbox mapping, passthrough, Immersive quick‑menu tab, and ImmersiveXrActivity. Ships prebuilt libxrimmersive.so.
    • Android builds: per‑game platform switch (Windows/Android), install/launch via AndroidGameLauncher, Android‑only library filter, and Android‑aware depot filtering, language resolution, and size display.
  • Bug Fixes

    • Cleared stuck “Launching game...” splash via a global ClearBootingSplash event.
    • Android containers: skip Wine overrides, propagate install result, clean staged APKs, and fix an uninstall race/freeze.
    • UI/logic: remove persisted Android filter on Modern/ModernXr builds; fix an Edit Container main‑thread block.
    • Tests: OS parsing now includes Android; added Android‑mode depot language selection coverage.

Written for commit cd2e4bb. Summary will update on new commits.

Review in cubic

@moi952
moi952 requested a review from utkarshdalal as a code owner July 21, 2026 13:13
@moi952
moi952 marked this pull request as draft July 21, 2026 13:13
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 58299c53-be29-49d5-accb-fc236d941d2d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

30 issues found across 54 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/utils/AndroidGameLauncher.kt">

<violation number="1" location="app/src/main/java/app/gamenative/utils/AndroidGameLauncher.kt:80">
P2: OBB files copied to `/Android/obb/$packageName` are never removed when installation is cancelled or the Android game is uninstalled. This leaves potentially large expansion files permanently on shared storage after `SteamService.deleteApp()` removes the depot; cleanup should cover the OBB destination on both paths.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/utils/AndroidGameLauncher.kt:144">
P2: A missing or unreadable downloaded APK is treated as proof that no system app needs removal, so callers proceed to delete GameNative's install record while a previously installed Android package may remain. Preserving the resolved package name or blocking deletion when package resolution is unavailable would avoid orphaning the app.</violation>

<violation number="3" location="app/src/main/java/app/gamenative/utils/AndroidGameLauncher.kt:206">
P2: Destroying or recreating the host Activity while the uninstall dialog is open can leave `requestUninstall()` suspended forever because its raw activity-result registration is not lifecycle-bound, while the caller's coroutine is not cancelled with the Activity. A lifecycle-aware launcher or explicit destruction/recreation recovery would ensure the uninstall flow always completes or returns cancellation.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/component/dialog/GameManagerDialog.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/component/dialog/GameManagerDialog.kt:118">
P2: Opening the game manager can block the UI while this platform check synchronously loads every container configuration on the main dispatcher. Resolving the Android-platform flag and depot lookup on `Dispatchers.IO` would avoid dialog jank for users with many containers.</violation>
</file>

<file name="app/src/main/cpp/xrimmersive/xr_immersive.h">

<violation number="1" location="app/src/main/cpp/xrimmersive/xr_immersive.h:113">
P2: The Vulkan direct-render path reimports the shared buffer on every frame: `DirectVulkanBridge` calls this API for each present, and the native implementation treats every call as a buffer change even when the pointer is identical. Keeping the current image when the buffer identity is unchanged (and only marking it changed on an actual replacement) would avoid per-frame EGL image creation/destruction and the resulting frame-time regressions.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/enums/AppFilter.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/enums/AppFilter.kt:88">
P2: Selecting Android with no app-type filter active shows an empty library: `getAppType()` does not represent `ANDROID`, while `onFilterApps` requires every Steam item to match the resulting empty `currentFilter`. Treat Android as a non-type filter and make the type predicate pass when no type is selected, or prevent clearing the last app-type filter.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt">

<violation number="1">
P1: The game can freeze on the last Vulkan frame after screen effects switch the renderer out of scanout: `directRenderActive` remains true after scanout teardown, so this branch keeps skipping PixelCopy. Reset the active state when scanout is disabled or make the bridge report scanout loss before selecting this fast path.</violation>

<violation number="2">
P1: GL immersive sessions can show no game frame when HardwareBuffer import or FBO setup fails: attaching the bridge unconditionally disables PixelCopy before direct rendering is confirmed. Keeping PixelCopy active until `directRenderActive` becomes true, or exposing an explicit bridge-ready state, preserves the intended fallback.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt:393">
P2: Android install-state refreshes can block the UI because this override synchronously scans the game directory and parses the APK from the main-thread refresh path. Moving package lookup to an IO-backed state/cache would avoid jank during download, install, and package-broadcast refreshes.</violation>
</file>

<file name="app/src/main/java/app/gamenative/service/SteamService.kt">

<violation number="1" location="app/src/main/java/app/gamenative/service/SteamService.kt:2249">
P2: Android games that require OBB files can be installed without their game data and then fail to start: `copyObbFiles` swallows the placement failure, while this line proceeds with the install prompt. The Android install path should use a storage mechanism that can place OBBs or surface the package as unsupported before prompting.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/service/SteamService.kt:2249">
P2: Android installation or game launch can be silently blocked when a download finishes while the app is backgrounded, because this completion path starts the installer/game from the service's application context. Routing the action through a visible activity or a notification/PendingIntent would preserve the install flow.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/component/QuickMenu.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/component/QuickMenu.kt:1653">
P2: Repeated A activation on a focused choice chip can use stale state because the bypass stores the callback from the focus-change composition only. Updating the registered callback whenever `onClick` changes (preferably via `rememberUpdatedState`) keeps immersive activation aligned with the current UI state.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/ui/component/QuickMenu.kt:1859">
P1: Immersive slider adjustments can become stuck after the first tick: the registered `(onDecrease, onIncrease)` pair captures the values from the recomposition that locked the row, but the effect never refreshes it when those lambdas change. Register the latest callbacks (for example with `rememberUpdatedState`, or by including both callbacks in the effect keys) so each native press uses current state.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt:1416">
P2: A fast Play tap after changing the checkbox can launch through the previous mode because persistence is fire-and-forget, and rapid toggles can leave the setting written out of order. Serializing or awaiting this save before launch would ensure the mode used by `MainViewModel.launchApp()` matches the selected UI state.</violation>
</file>

<file name="app/src/main/cpp/xrimmersive/jni_bridge.cpp">

<violation number="1" location="app/src/main/cpp/xrimmersive/jni_bridge.cpp:114">
P2: Frames with a row stride larger than `width * 4` will be corrupted because `info.stride` is ignored before `submitFrame()` treats the pixels as tightly packed. Copy each row into a contiguous buffer, or pass the stride through the session API and handle it during upload.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/screen/xr/DirectGLBridge.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/xr/DirectGLBridge.kt:46">
P1: After the GLSurfaceView recreates its EGL context, this size-only fast path reuses stale texture/FBO names and leaves the immersive GL target invalid. The bridge needs to be reset or re-imported from the renderer's context-creation lifecycle, otherwise immersive output can remain black until the surface size changes.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/ui/screen/xr/DirectGLBridge.kt:108">
P1: The XR render thread can sample the shared buffer while the GLRenderer thread still has queued writes, producing stale or partially rendered frames. The direct path needs an explicit cross-context synchronization mechanism, such as an EGL fence carried to the consumer, before handing the buffer to OpenXR.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/component/ScreenEffectsPanel.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/component/ScreenEffectsPanel.kt:1306">
P2: Immersive A activation still cannot reliably toggle the shader options: `ScreenEffectToggleRow` has no focused `reportActivate` callback, so it uses the synthetic DPAD/A fallback that this bypass was added to avoid. The toggle rows would benefit from the same focused activation registration as `ScreenEffectRadioRow`.</violation>
</file>

<file name="app/src/main/AndroidManifest.xml">

<violation number="1" location="app/src/main/AndroidManifest.xml:143">
P2: A Quest density or screen-layout update while immersive mode is active will recreate `ImmersiveXrActivity` because those qualifiers are not handled here, interrupting the activity-owned OpenXR session and game presentation. Including `density|screenLayout` (with any required UI rebind handling) would preserve the immersive session across these configuration changes.</violation>
</file>

<file name="app/src/main/java/com/winlator/renderer/GLRenderer.java">

<violation number="1" location="app/src/main/java/com/winlator/renderer/GLRenderer.java:164">
P2: With a GL screen effect enabled, immersive output bypasses the configured effect chain and the compositor reads an unpopulated scene buffer, producing incorrect or blank post-processed output. The XR bridge needs to wrap the final effect output, or effect rendering needs an XR-aware target path instead of rebinding inside `drawScene()`.</violation>

<violation number="2" location="app/src/main/java/com/winlator/renderer/GLRenderer.java:168">
P1: After leaving immersive mode, the normal GL surface can remain cropped or rendered with the XR target dimensions because the XR size override survives bridge detachment. Clearing the render-target override whenever the bridge is removed would restore the normal surface dimensions and transform.</violation>
</file>

<file name="app/src/main/java/com/winlator/renderer/VulkanRenderer.java">

<violation number="1" location="app/src/main/java/com/winlator/renderer/VulkanRenderer.java:470">
P1: Immersive output can switch to the wrong window whenever another mapped Vulkan window issues Present: this hook forwards every presented window's AHB into the session's single shared-game-buffer slot. Restrict the bridge to the game's primary/full-frame drawable, or pass a source identity, so overlays and child windows cannot replace the game frame.</violation>

<violation number="2" location="app/src/main/java/com/winlator/renderer/VulkanRenderer.java:470">
P1: The XR render thread can sample this AHardwareBuffer while the Vulkan producer is still writing or reusing it, causing tearing or corrupt frames: the new bridge publishes only the raw pointer and does not propagate the Present completion fence. Propagating and waiting on the producer fence, or otherwise synchronizing ownership before publishing the buffer to OpenXR, would make the zero-copy path safe.</violation>
</file>

<file name="app/src/main/java/com/winlator/container/Container.java">

<violation number="1" location="app/src/main/java/com/winlator/container/Container.java:97">
P2: Duplicating a container silently disables its saved immersive launch preference because the new field is not included in `ContainerManager.duplicateContainer`. Preserving this per-container setting during duplication would keep the duplicated game’s launch behavior consistent.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/screen/xr/XrNative.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/xr/XrNative.kt:20">
P2: This JNI entry point requires an Activity, but its Kotlin signature accepts any Context. Typing the parameter as `Activity` (or validating/casting it before the native call) would prevent callers from passing an application context that cannot satisfy OpenXR's `applicationActivity` requirement.</violation>
</file>

<file name="app/build.gradle.kts">

<violation number="1" location="app/build.gradle.kts:368">
P2: Every legacy, modern, and non-XR APK now resolves and packages the OpenXR AAR even though immersive mode is limited to the Quest XR path, increasing non-immersive artifacts and exposing unnecessary OpenXR metadata. Scoping this to `modernXrImplementation` would preserve the claimed non-immersive footprint.

(Based on your team's feedback about avoiding unnecessary or broken build dependencies.) [b5ac66e0-cccc-4d8b-b90a-a5fce1209c46]</violation>

<violation number="2" location="app/build.gradle.kts:368">
P1: The added AAR introduces a second, different `libopenxr_loader.so` at the same APK path as the checked-in loader, so native-library merging/packaging will fail or select an arbitrary loader. Keeping only one loader source—by removing the checked-in copy when consuming the AAR, or otherwise excluding the AAR's JNI payload while retaining its Prefab metadata—avoids the conflict.</violation>
</file>

<file name="app/src/main/cpp/xrimmersive/xr_immersive.cpp">

<violation number="1" location="app/src/main/cpp/xrimmersive/xr_immersive.cpp:75">
P2: XR pointer-mode double-clicks can be lost when polling does not align with the frame that generated the edge. Accumulating `pointerModeToggled` in the same way as `quickMenuClicked` would ensure each toggle reaches Kotlin once.</violation>

<violation number="2" location="app/src/main/cpp/xrimmersive/xr_immersive.cpp:109">
P1: The local AHardwareBuffer pointer is borrowed after the mutex is released, so a concurrent buffer replacement can release the last reference before EGL imports it and cause use-after-free or a driver crash. Acquiring a separate local reference before unlocking, or retaining ownership until the import completes, would make this cross-thread handoff safe.</violation>

<violation number="3" location="app/src/main/cpp/xrimmersive/xr_immersive.cpp:174">
P1: A swapchain-acquire failure still submits a quad layer referencing an image that was never acquired, which can make `xrEndFrame()` fail and destabilize the XR frame loop. Propagating render/acquire success and submitting a zero-layer frame when rendering cannot proceed would preserve the OpenXR frame lifecycle.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

@@ -0,0 +1,1758 @@
package app.gamenative.ui.screen.xr

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: GL immersive sessions can show no game frame when HardwareBuffer import or FBO setup fails: attaching the bridge unconditionally disables PixelCopy before direct rendering is confirmed. Keeping PixelCopy active until directRenderActive becomes true, or exposing an explicit bridge-ready state, preserves the intended fallback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt:

<comment>GL immersive sessions can show no game frame when HardwareBuffer import or FBO setup fails: attaching the bridge unconditionally disables PixelCopy before direct rendering is confirmed. Keeping PixelCopy active until `directRenderActive` becomes true, or exposing an explicit bridge-ready state, preserves the intended fallback.</comment>

Comment thread app/build.gradle.kts

// 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.
implementation("org.khronos.openxr:openxr_loader_for_android:1.1.61")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The added AAR introduces a second, different libopenxr_loader.so at the same APK path as the checked-in loader, so native-library merging/packaging will fail or select an arbitrary loader. Keeping only one loader source—by removing the checked-in copy when consuming the AAR, or otherwise excluding the AAR's JNI payload while retaining its Prefab metadata—avoids the conflict.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/build.gradle.kts, line 368:

<comment>The added AAR introduces a second, different `libopenxr_loader.so` at the same APK path as the checked-in loader, so native-library merging/packaging will fail or select an arbitrary loader. Keeping only one loader source—by removing the checked-in copy when consuming the AAR, or otherwise excluding the AAR's JNI payload while retaining its Prefab metadata—avoids the conflict.</comment>

<file context>
@@ -348,6 +363,10 @@ 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.
+    implementation("org.khronos.openxr:openxr_loader_for_android:1.1.61")
+
     // Winlator
</file context>

Comment thread app/src/main/java/com/winlator/renderer/GLRenderer.java
Comment thread app/build.gradle.kts

// 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.
implementation("org.khronos.openxr:openxr_loader_for_android:1.1.61")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Every legacy, modern, and non-XR APK now resolves and packages the OpenXR AAR even though immersive mode is limited to the Quest XR path, increasing non-immersive artifacts and exposing unnecessary OpenXR metadata. Scoping this to modernXrImplementation would preserve the claimed non-immersive footprint.

(Based on your team's feedback about avoiding unnecessary or broken build dependencies.) [b5ac66e0-cccc-4d8b-b90a-a5fce1209c46]

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/build.gradle.kts, line 368:

<comment>Every legacy, modern, and non-XR APK now resolves and packages the OpenXR AAR even though immersive mode is limited to the Quest XR path, increasing non-immersive artifacts and exposing unnecessary OpenXR metadata. Scoping this to `modernXrImplementation` would preserve the claimed non-immersive footprint.

(Based on your team's feedback about avoiding unnecessary or broken build dependencies.) [b5ac66e0-cccc-4d8b-b90a-a5fce1209c46]</comment>

<file context>
@@ -348,6 +363,10 @@ 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.
+    implementation("org.khronos.openxr:openxr_loader_for_android:1.1.61")
+
     // Winlator
</file context>


// Get Downloadable Depots
val allPossibleDownloadableDepots = SteamService.getDownloadableDepots(gameId)
val allPossibleDownloadableDepots = SteamService.getDownloadableDepots(gameId, wantAndroid = SteamService.isAndroidPlatform(gameId))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Opening the game manager can block the UI while this platform check synchronously loads every container configuration on the main dispatcher. Resolving the Android-platform flag and depot lookup on Dispatchers.IO would avoid dialog jank for users with many containers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/component/dialog/GameManagerDialog.kt, line 118:

<comment>Opening the game manager can block the UI while this platform check synchronously loads every container configuration on the main dispatcher. Resolving the Android-platform flag and depot lookup on `Dispatchers.IO` would avoid dialog jank for users with many containers.</comment>

<file context>
@@ -115,7 +115,7 @@ fun GameManagerDialog(
 
         // Get Downloadable Depots
-        val allPossibleDownloadableDepots = SteamService.getDownloadableDepots(gameId)
+        val allPossibleDownloadableDepots = SteamService.getDownloadableDepots(gameId, wantAndroid = SteamService.isAndroidPlatform(gameId))
         downloadableDepots.putAll(allPossibleDownloadableDepots)
 
</file context>
Suggested change
val allPossibleDownloadableDepots = SteamService.getDownloadableDepots(gameId, wantAndroid = SteamService.isAndroidPlatform(gameId))
val allPossibleDownloadableDepots = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
SteamService.getDownloadableDepots(gameId, wantAndroid = SteamService.isAndroidPlatform(gameId))
}

InputSnapshot result = snapshot_;
// Rising-edge flags are consumed on read.
snapshot_.quickMenuClicked = false;
snapshot_.pointerModeToggled = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: XR pointer-mode double-clicks can be lost when polling does not align with the frame that generated the edge. Accumulating pointerModeToggled in the same way as quickMenuClicked would ensure each toggle reaches Kotlin once.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/cpp/xrimmersive/xr_immersive.cpp, line 75:

<comment>XR pointer-mode double-clicks can be lost when polling does not align with the frame that generated the edge. Accumulating `pointerModeToggled` in the same way as `quickMenuClicked` would ensure each toggle reaches Kotlin once.</comment>

<file context>
@@ -0,0 +1,1218 @@
+    InputSnapshot result = snapshot_;
+    // Rising-edge flags are consumed on read.
+    snapshot_.quickMenuClicked = false;
+    snapshot_.pointerModeToggled = false;
+    return result;
+}
</file context>

void *pixels = nullptr;
if (AndroidBitmap_lockPixels(env, bitmap, &pixels) != ANDROID_BITMAP_RESULT_SUCCESS) return;

handle->session->submitFrame(static_cast<const uint8_t *>(pixels), static_cast<int32_t>(info.width),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Frames with a row stride larger than width * 4 will be corrupted because info.stride is ignored before submitFrame() treats the pixels as tightly packed. Copy each row into a contiguous buffer, or pass the stride through the session API and handle it during upload.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/cpp/xrimmersive/jni_bridge.cpp, line 114:

<comment>Frames with a row stride larger than `width * 4` will be corrupted because `info.stride` is ignored before `submitFrame()` treats the pixels as tightly packed. Copy each row into a contiguous buffer, or pass the stride through the session API and handle it during upload.</comment>

<file context>
@@ -0,0 +1,222 @@
+    void *pixels = nullptr;
+    if (AndroidBitmap_lockPixels(env, bitmap, &pixels) != ANDROID_BITMAP_RESULT_SUCCESS) return;
+
+    handle->session->submitFrame(static_cast<const uint8_t *>(pixels), static_cast<int32_t>(info.width),
+                                  static_cast<int32_t>(info.height));
+
</file context>

moi952 added 7 commits July 23, 2026 11:43
Steam now lets some games be installed as a native Android app instead
of the usual Windows build run through Wine (ahead of Valve's Steam
Frame headset). This adds a per-game Version choice in Edit Container:
picking Android downloads, installs, and launches the game as a real
Android app, bypassing Wine entirely for that game.
…, add Android-mode depot language test coverage
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant