--- a/src/MacSrc/OpenGL.cc	2026-07-25 07:10:06.189412183 +0200
+++ b/src/MacSrc/OpenGL.cc	2026-07-25 07:12:05.356301052 +0200
@@ -36,6 +36,7 @@
     if (loc >= 0)
         glUniform1f(loc, size);
 }
+#include "xr_session.h"
 #endif // __ANDROID__
 
 extern "C" {
@@ -382,6 +383,21 @@
 #endif
     opengl_resize(width, height);
 
+#ifdef __ANDROID__
+    // Bring up the OpenXR session now that the shared GL context above is
+    // current (xr_init() reuses it via eglGetCurrentContext/Display - see
+    // xr_session.c) - sized to the game's own logical resolution, not the
+    // physical window, since the swapchain is composited by the XR runtime
+    // rather than upscaled by us into a physical-window-sized viewport (see
+    // opengl_swap_and_restore()/android_composite_software_frame() below).
+    // Failure here (e.g. no OpenXR runtime installed) just means the
+    // immersive path never activates - SDLDraw() falls back to the plain
+    // window-present path for the rest of the run.
+    int xr_logical_width, xr_logical_height;
+    SDL_RenderGetLogicalSize(renderer, &xr_logical_width, &xr_logical_height);
+    xr_init(xr_logical_width, xr_logical_height);
+#endif
+
     // Now make the palettes
     opaquePalette = SDL_AllocPalette(256);
     transparentPalette = SDL_AllocPalette(256);
@@ -498,7 +514,109 @@
     *y_scale = output_height / screen_height;
 }
 
+#ifdef __ANDROID__
+// Shared by opengl_swap_and_restore()'s UI-overlay blit and
+// android_composite_software_frame() below - draws an SDL_Surface as a
+// fullscreen textured quad via gl4es. SDL's own renderer can't be used
+// here: it's tied to the window's own EGL surface, not whatever XR
+// swapchain framebuffer the caller already bound via xr_frame_begin().
+static void android_draw_surface_as_quad(SDL_Surface *ui, bool blend) {
+    SDL_Surface *uiRgba = SDL_ConvertSurfaceFormat(ui, SDL_PIXELFORMAT_RGBA32, 0);
+    if (uiRgba == nullptr)
+        return;
+
+    glUseProgram(textureShaderProgram.shaderProgram);
+    GLint tcAttrib = textureShaderProgram.tcAttrib;
+    GLint lightAttrib = textureShaderProgram.lightAttrib;
+    glUniform1i(textureShaderProgram.uniNightSight, false);
+    glUniformMatrix4fv(textureShaderProgram.uniView, 1, false, IdentityMatrix);
+    glUniformMatrix4fv(textureShaderProgram.uniProj, 1, false, IdentityMatrix);
+
+    // Persistent texture, reused every call instead of a fresh
+    // gen/upload/delete each frame.
+    static GLuint s_uiTexture = 0;
+    if (s_uiTexture == 0)
+        glGenTextures(1, &s_uiTexture);
+    bind_texture(s_uiTexture);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, uiRgba->w, uiRgba->h, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+                 uiRgba->pixels);
+
+    set_blend_mode(blend);
+
+    // Real per-vertex arrays, not glBegin/glVertexAttrib/glVertex3f
+    // immediate mode: glVertexAttrib*() is GLES2's *constant*-attribute
+    // API - without an enabled vertex array it sets one value for the
+    // whole draw call, not a fresh value per vertex. gl4es's immediate-mode
+    // emulation only reproduces desktop GL's per-vertex-capture behavior
+    // for attribute 0 (position, driven directly by glVertex3f), not for
+    // custom attributes like this shader's texcoords/light.
+    //
+    // V is flipped (1 at the bottom, 0 at the top): GL texture v=0
+    // addresses the first row of data passed to glTexImage2D, but uiRgba
+    // (an SDL surface) stores its rows top-down.
+    struct QuadVertex {
+        float x, y, z;
+        float u, v;
+        float light;
+    };
+    static const QuadVertex quadVerts[4] = {
+        {1.0f, -1.0f, 0.0f, 1.0f, 1.0f, 1.0f},
+        {1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f},
+        {-1.0f, -1.0f, 0.0f, 0.0f, 1.0f, 1.0f},
+        {-1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f},
+    };
+    glEnableVertexAttribArray(0);
+    glEnableVertexAttribArray(tcAttrib);
+    glEnableVertexAttribArray(lightAttrib);
+    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(QuadVertex), &quadVerts[0].x);
+    glVertexAttribPointer(tcAttrib, 2, GL_FLOAT, GL_FALSE, sizeof(QuadVertex), &quadVerts[0].u);
+    glVertexAttribPointer(lightAttrib, 1, GL_FLOAT, GL_FALSE, sizeof(QuadVertex),
+                           &quadVerts[0].light);
+    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
+    // The engine's other rendering uses glBegin/glVertexAttrib/glVertex3f
+    // immediate mode for attribute 0, which depends on it not having an
+    // enabled array bound.
+    glDisableVertexAttribArray(0);
+    glDisableVertexAttribArray(tcAttrib);
+    glDisableVertexAttribArray(lightAttrib);
+    glFlush();
+
+    SDL_FreeSurface(uiRgba);
+}
+
+void android_composite_software_frame(SDL_Surface *ui) {
+    // Deliberately no SDL_GL_MakeCurrent() here - see the comment in
+    // opengl_swap_and_restore() below for why.
+    glClear(GL_COLOR_BUFFER_BIT);
+    android_draw_surface_as_quad(ui, false);
+}
+#endif
+
 void opengl_swap_and_restore(SDL_Surface *ui) {
+#ifdef __ANDROID__
+    // No SDL_GL_MakeCurrent() here on Android, deliberately - unlike the
+    // desktop path below. init_opengl() already made (window, context)
+    // current once; re-asserting it every frame turned out to reset
+    // gl4es's own internal "current framebuffer" bookkeeping back to
+    // whatever it associates with that context/surface pair (observed
+    // on-device: the XR swapchain image reliably received our real
+    // glClear/bind from xr_frame_begin(), but every actual gl4es-routed
+    // draw call after a fresh SDL_GL_MakeCurrent() here kept landing
+    // somewhere else - the quad stayed permanently black). Confirmed this
+    // exact class of gl4es/OpenXR interaction against RTCWQuest (Team
+    // Beef Studios), a shipped gl4es+Khronos-OpenXR-loader Quest port:
+    // it makes its EGL context current exactly once at init and never
+    // again per-frame, for the same reason.
+    //
+    // xr_frame_begin() (called from SDLDraw()) already bound the XR
+    // swapchain's framebuffer and a full-size viewport - no physical-
+    // window letterboxing needed here either, unlike the desktop/2D-panel
+    // path below, since the swapchain is sized to exactly the game's
+    // logical resolution (see xr_init()).
+    glClear(GL_COLOR_BUFFER_BIT);
+#else
     // restore the view backup (without HUD overlay) for incremental
     // updates in the subsequent frame
     SDL_GL_MakeCurrent(window, context);
@@ -510,6 +628,7 @@
     // Set the drawable area for the 3d view
     glViewport(phys_offset_x * x_hdpi_scale, phys_offset_y * y_hdpi_scale, phys_width * x_hdpi_scale,
                phys_height * y_hdpi_scale);
+#endif
     set_blend_mode(false);
 
     // Bind and setup our general shader program
@@ -552,6 +671,11 @@
     if (err != GL_NO_ERROR)
         ERROR("OpenGL error: %i", err);
 
+#ifdef __ANDROID__
+    // Blit the UI canvas over the 3d view (see android_draw_surface_as_quad()
+    // above - SDL's renderer can't target the XR swapchain framebuffer).
+    android_draw_surface_as_quad(ui, true);
+#else
     // Blit the UI canvas over the 3d view
     SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, ui);
     SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_BLEND);
@@ -561,6 +685,7 @@
 
     // Finally, swap to the screen
     SDL_RenderPresent(renderer);
+#endif
 }
 
 void toggle_opengl() {
--- a/src/MacSrc/OpenGL.h	2026-07-19 13:11:51.000000000 +0200
+++ b/src/MacSrc/OpenGL.h	2026-07-25 07:10:06.353410581 +0200
@@ -22,6 +22,15 @@
 void opengl_swap_and_restore(SDL_Surface *ui);
 void opengl_change_palette();
 
+#ifdef __ANDROID__
+// Composites a plain software-rendered frame (the same content SDLDraw's
+// non-OpenGL fallback would otherwise present via SDL_RenderCopy) into the
+// XR swapchain image already bound by xr_frame_begin() - see Shock.c's
+// SDLDraw(), which needs this for the splash screen/cutscenes/menus that
+// render this way instead of through opengl_swap_and_restore().
+void android_composite_software_frame(SDL_Surface *ui);
+#endif
+
 void opengl_set_viewport(int x, int y, int width, int height);
 int opengl_draw_tmap(int n, g3s_phandle *vp, grs_bitmap *bm);
 int opengl_light_tmap(int n, g3s_phandle *vp, grs_bitmap *bm);
--- a/src/MacSrc/Shock.c	2026-07-25 07:10:06.181412261 +0200
+++ b/src/MacSrc/Shock.c	2026-07-25 07:10:06.353410581 +0200
@@ -30,6 +30,10 @@
 #include <math.h>
 #include <SDL.h>
 
+#ifdef __ANDROID__
+#include "xr_session.h"
+#endif
+
 #include "InitMac.h"
 #include "Modding.h"
 #include "OpenGL.h"
@@ -301,6 +305,21 @@
 }
 
 void SDLDraw() {
+#ifdef __ANDROID__
+    // The true once-a-frame present hook for the immersive OpenXR path -
+    // unlike opengl_swap_and_restore() below, this runs every frame
+    // regardless of should_opengl_swap() (e.g. also during the splash
+    // screen/cutscenes/menus, which render through the plain software
+    // path below, not the OpenGL one). xr_poll_events() needs exactly that
+    // - called unconditionally - to ever observe the session reach a
+    // running state in the first place.
+    xr_poll_events();
+    if (!xr_frame_begin()) {
+        xr_frame_end();
+        return;
+    }
+#endif
+
     if (should_opengl_swap()) {
         // We want the UI background to be transparent!
         sdlPalette->colors[255].a = 0x00;
@@ -310,14 +329,27 @@
 
         // Set the palette back, and we are done
         sdlPalette->colors[255].a = 0xff;
+#ifdef __ANDROID__
+        xr_frame_end();
+#endif
         return;
     }
 
+#ifdef __ANDROID__
+    // Same software-rendered frame the desktop path below would present
+    // via SDL_RenderCopy/SDL_RenderPresent, composited into the XR
+    // swapchain image xr_frame_begin() bound above instead - SDL's
+    // renderer is tied to the window's own EGL surface, not that FBO.
+    android_composite_software_frame(drawSurface);
+    xr_frame_end();
+    return;
+#endif
+
     // Clear the screen!
     SDL_RenderClear(renderer);
     SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, drawSurface);
 
-    // Blit to the screen by drawing the surface 
+    // Blit to the screen by drawing the surface
     SDL_Rect srcRect = {0, 0, gScreenWide, gScreenHigh};
     SDL_RenderCopy(renderer, texture, &srcRect, NULL);
     SDL_DestroyTexture(texture);
