--- 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 #include +#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);