diff --git a/README.md b/README.md index b8dcc39..b69fda0 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ An open source project to play the classic 1994 System Shock on a VR headset, built on top of [Shockolate](https://github.com/Interrupt/systemshock), a cross-platform port of the original game. +## Design principles + +- **Standalone-headset-only.** The game must run entirely on the headset + itself - no PC required, whether tethered or streamed (not PCVR). Any + feature or dependency that assumes a host PC is out of scope. +- **OpenXR-first.** Favor OpenXR-standard APIs over vendor-specific ones + (e.g. Meta's Oculus Mobile SDK) wherever there's a choice, so the port + isn't locked to Meta Quest and can work across other standalone, + Android-based OpenXR headsets (e.g. Pico) too. + ## Layout - `engine/` - a vendored snapshot of the Shockolate engine source. Built @@ -88,11 +98,14 @@ resulting tarball to dl.ladkau.de. ## Playing on Meta Quest -`make apk` builds `dist/questshock--android-arm64.apk` - a plain -(non-VR) Android app that runs as a flat, floating panel in the Quest's -Home environment, same as any other sideloaded Android app. It's not a -head-tracked 6DoF VR port (that's a much larger, separate undertaking); -play with a Bluetooth mouse/keyboard connected to the headset. +`make apk` builds `dist/questshock--android-arm64.apk` - an +immersive OpenXR app (see `android/app/src/main/cpp/xr_session.c`): the +game's own rendering is unchanged (still a flat, 2D render, no stereo 3D +scene), but instead of running as a Home-hosted 2D panel it's now shown as +a single head-tracked quad floating in front of the viewer in an otherwise +empty space. There's no controller-driven interaction yet (that's ongoing +work - a laser-pointer-driven menu and on-screen keyboard); for now, play +with a Bluetooth mouse/keyboard connected to the headset same as before. 1. Install the APK with [SideQuest](https://sidequestvr.com/) (or `adb install`). @@ -121,7 +134,7 @@ To have Android Studio compile and deploy `android/` itself instead: ``` This stages everything `make apk` normally stages (a scratch, patched -copy of `engine/`; the Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es +copy of `engine/`; the Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es/openxr prebuilts; bundled assets) - the same as `build-apk.sh`'s own prep step - except it writes `android/engine.properties` with paths that resolve on your host filesystem, and additionally exports the prebuilt libraries @@ -151,6 +164,10 @@ The Quest build also bundles [GL4ES](https://github.com/ptitSeb/gl4es) image), which translates the engine's desktop-style OpenGL calls into GLES/EGL and is MIT-licensed. +It also bundles the Khronos Group's own [OpenXR-SDK loader]( +https://github.com/KhronosGroup/OpenXR-SDK) (`lib/arm64-v8a/libopenxr_loader.so`, +prebuilt unmodified into the build image), which is Apache 2.0-licensed. + The vendored engine snapshot in `engine/` is [Shockolate](https://github.com/Interrupt/systemshock), which is licensed under the **GNU GPLv3** (see `engine/LICENSE`) - it is included unchanged diff --git a/android/app/build.gradle b/android/app/build.gradle index 060758e..652ac99 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -115,15 +115,23 @@ android { // build-image/Dockerfile's ANDROID_16KB_LDFLAGS, which does // the same for the prebuilt SDL2/SDL2_mixer/fluidsynth-lite/ // gl4es .so's this links against. + // ANDROID_EXTRA_SOURCES is a semicolon-separated CMake list, + // not shell-split - AGP passes each "arguments" string + // straight through as one argv element to cmake, so the + // ';' below survives intact. ANDROID_EXTRA_INCLUDE_DIRS lets + // engine/'s own OpenGL.cc find "xr_session.h" (see + // android/engine-patches/11-android-openxr-cmake.patch, + // which adds it to include_directories()). arguments "-DENABLE_SDL2=ON", "-DENABLE_SOUND=BUNDLED", "-DENABLE_FLUIDSYNTH=BUNDLED", \ "-DANDROID_PREBUILT_DIR=${prebuiltDir}", \ + "-DANDROID_EXTRA_INCLUDE_DIRS=${projectDir}/src/main/cpp", \ "-DCMAKE_PREFIX_PATH=${prebuiltDir}/sdl2", \ "-DCMAKE_FIND_ROOT_PATH=${prebuiltDir}/sdl2", \ "-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=BOTH", \ "-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \ "-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \ "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384", \ - "-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c" + "-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c" abiFilters 'arm64-v8a' } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0079e54..2b8b7e2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,11 @@ + + + + + + + - - diff --git a/android/app/src/main/cpp/xr_session.c b/android/app/src/main/cpp/xr_session.c new file mode 100644 index 0000000..30837e3 --- /dev/null +++ b/android/app/src/main/cpp/xr_session.c @@ -0,0 +1,456 @@ +#include "xr_session.h" + +#include +#include +#include + +#include +#include + +#include +#include + +#define XR_USE_PLATFORM_ANDROID 1 +#define XR_USE_GRAPHICS_API_OPENGL_ES 1 +#include +#include + +#include + +#define TAG "QuestShock" +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) + +// Fixed pose/size for the single game quad - 2m in front of the local space +// origin, sized to preserve the game's own aspect ratio at a comfortable +// visual angle. No per-eye view/projection work is needed for a quad layer +// at all - the compositor handles reprojecting it per eye itself, which is +// exactly why this milestone doesn't need xrLocateViews or any stereo +// rendering (see the plan's step A notes). +#define QUAD_DISTANCE_METERS 2.0f +#define QUAD_WIDTH_METERS 1.6f + +// Up to this many swapchain images/FBOs - real runtimes report small counts +// (2-4); this is just a fixed upper bound for the cache arrays below. +#define MAX_SWAPCHAIN_IMAGES 8 + +static XrInstance g_instance = XR_NULL_HANDLE; +static XrSystemId g_system_id = XR_NULL_SYSTEM_ID; +static XrSession g_session = XR_NULL_HANDLE; +static XrSpace g_local_space = XR_NULL_HANDLE; +static XrSessionState g_session_state = XR_SESSION_STATE_UNKNOWN; +// Distinct from g_session_state - true from a successful xrBeginSession +// until xrEndSession/loss/exit. Many runtimes only advance the *state* +// (e.g. READY -> SYNCHRONIZED -> VISIBLE -> FOCUSED) in response to the +// app actually calling xrWaitFrame/xrBeginFrame/xrEndFrame continuously +// after xrBeginSession - gating frame submission on already having +// reached SYNCHRONIZED+ (as this used to) is a chicken-and-egg deadlock, +// since the runtime never progresses past READY without the frame loop +// running in the first place. Frame calls just need the session to have +// begun, per the standard OpenXR sample pattern - not any particular +// later state. +static bool g_session_running = false; + +static XrSwapchain g_game_swapchain = XR_NULL_HANDLE; +static int g_game_width = 0; +static int g_game_height = 0; +static GLuint g_swapchain_fbos[MAX_SWAPCHAIN_IMAGES]; +static uint32_t g_swapchain_image_count = 0; + +static XrTime g_predicted_display_time = 0; +static bool g_frame_should_render = false; +static bool g_have_acquired_image = false; + +// This file's GL calls otherwise resolve to gl4es (the only GL symbol +// provider linked into this binary - see android/engine-patches/ +// 02-android-opengl-es.patch), which is fine for anything shared with the +// engine's own gl4es-routed rendering. But the swapchain images OpenXR +// hands us are real driver texture objects gl4es never created itself, +// and gl4es's own glFramebufferTexture2D can't attach a texture it has no +// tracked metadata for. So the FBO *container* is created via gl4es's own +// glGenFramebuffers/glBindFramebuffer (so gl4es recognizes the id as its +// own and its own per-frame glBindFramebuffer succeeds), while the +// texture-attach step - the specifically foreign part - goes through the +// real driver directly, via dlsym against libGLESv2.so. +typedef void (*PFNQSFRAMEBUFFERTEXTURE2D)(GLenum, GLenum, GLenum, GLuint, GLint); +typedef GLenum (*PFNQSCHECKFRAMEBUFFERSTATUS)(GLenum); + +static PFNQSFRAMEBUFFERTEXTURE2D real_glFramebufferTexture2D; +static PFNQSCHECKFRAMEBUFFERSTATUS real_glCheckFramebufferStatus; + +static bool xr_load_real_gles(void) { + void *lib = dlopen("libGLESv2.so", RTLD_NOW | RTLD_LOCAL); + if (lib == NULL) { + LOGE("XR: dlopen(libGLESv2.so) failed: %s", dlerror()); + return false; + } + real_glFramebufferTexture2D = (PFNQSFRAMEBUFFERTEXTURE2D)dlsym(lib, "glFramebufferTexture2D"); + real_glCheckFramebufferStatus = + (PFNQSCHECKFRAMEBUFFERSTATUS)dlsym(lib, "glCheckFramebufferStatus"); + return real_glFramebufferTexture2D && real_glCheckFramebufferStatus; +} + +static bool xr_check(XrResult result, const char *what) { + if (XR_SUCCEEDED(result)) + return true; + char resultString[XR_MAX_RESULT_STRING_SIZE] = {0}; + if (g_instance != XR_NULL_HANDLE) + xrResultToString(g_instance, result, resultString); + LOGE("XR: %s failed: %s (%d)", what, resultString[0] ? resultString : "?", (int)result); + return false; +} + +static PFN_xrVoidFunction xr_get_proc(const char *name) { + PFN_xrVoidFunction fn = NULL; + // xrGetInstanceProcAddr itself works with a NULL instance for the + // handful of functions (like xrInitializeLoaderKHR) that must be called + // before an instance exists. + if (!xr_check(xrGetInstanceProcAddr(g_instance, name, &fn), name)) + return NULL; + return fn; +} + +// The Android OpenXR loader needs the JavaVM/context before anything else - +// separate from XR_KHR_android_create_instance below, which only affects +// xrCreateInstance itself. Skipping this is a common cause of +// xrCreateInstance silently failing to find a runtime on Android. +static bool xr_initialize_android_loader(JavaVM *vm, jobject activity) { + PFN_xrInitializeLoaderKHR initializeLoader = + (PFN_xrInitializeLoaderKHR)xr_get_proc("xrInitializeLoaderKHR"); + if (initializeLoader == NULL) { + LOGE("XR: xrInitializeLoaderKHR not available - no Android OpenXR loader present?"); + return false; + } + + XrLoaderInitInfoAndroidKHR loaderInitInfo = {XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR}; + loaderInitInfo.applicationVM = vm; + loaderInitInfo.applicationContext = activity; + return xr_check(initializeLoader((const XrLoaderInitInfoBaseHeaderKHR *)&loaderInitInfo), + "xrInitializeLoaderKHR"); +} + +// eglGetCurrentDisplay()/eglGetCurrentContext() hand us the display/context, +// but not the EGLConfig they were created with - eglQueryContext's +// EGL_CONFIG_ID plus eglChooseConfig is the standard way to recover it (the +// same trick Khronos' own hello_xr sample uses). +static EGLConfig xr_get_current_egl_config(EGLDisplay display, EGLContext context) { + EGLint configId = 0; + eglQueryContext(display, context, EGL_CONFIG_ID, &configId); + + EGLint attribs[] = {EGL_CONFIG_ID, configId, EGL_NONE}; + EGLConfig config = NULL; + EGLint numConfigs = 0; + eglChooseConfig(display, attribs, &config, 1, &numConfigs); + return config; +} + +static bool xr_create_instance_and_session(int game_width, int game_height) { + JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv(); + jobject activity = (jobject)SDL_AndroidGetActivity(); + if (env == NULL || activity == NULL) { + LOGE("XR: no JNIEnv/Activity from SDL - can't init OpenXR"); + return false; + } + JavaVM *vm = NULL; + (*env)->GetJavaVM(env, &vm); + + if (!xr_initialize_android_loader(vm, activity)) + return false; + + const char *extensions[] = { + XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME, + XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME, + }; + + XrInstanceCreateInfoAndroidKHR androidInfo = {XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR}; + androidInfo.applicationVM = vm; + androidInfo.applicationActivity = activity; + + XrInstanceCreateInfo createInfo = {XR_TYPE_INSTANCE_CREATE_INFO}; + createInfo.next = &androidInfo; + createInfo.enabledExtensionCount = sizeof(extensions) / sizeof(extensions[0]); + createInfo.enabledExtensionNames = extensions; + strncpy(createInfo.applicationInfo.applicationName, "QuestShock", + XR_MAX_APPLICATION_NAME_SIZE - 1); + strncpy(createInfo.applicationInfo.engineName, "Shockolate", XR_MAX_ENGINE_NAME_SIZE - 1); + createInfo.applicationInfo.applicationVersion = 1; + createInfo.applicationInfo.engineVersion = 1; + createInfo.applicationInfo.apiVersion = XR_CURRENT_API_VERSION; + + if (!xr_check(xrCreateInstance(&createInfo, &g_instance), "xrCreateInstance")) + return false; + + XrSystemGetInfo systemInfo = {XR_TYPE_SYSTEM_GET_INFO}; + systemInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY; + if (!xr_check(xrGetSystem(g_instance, &systemInfo, &g_system_id), "xrGetSystem")) + return false; + + // Required by spec before creating an OpenGL ES-backed session, even + // though we don't gate on the returned min/max API version here. + PFN_xrGetOpenGLESGraphicsRequirementsKHR getGLESRequirements = + (PFN_xrGetOpenGLESGraphicsRequirementsKHR)xr_get_proc("xrGetOpenGLESGraphicsRequirementsKHR"); + if (getGLESRequirements == NULL) + return false; + XrGraphicsRequirementsOpenGLESKHR glesRequirements = {XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR}; + if (!xr_check(getGLESRequirements(g_instance, g_system_id, &glesRequirements), + "xrGetOpenGLESGraphicsRequirementsKHR")) + return false; + + EGLDisplay display = eglGetCurrentDisplay(); + EGLContext context = eglGetCurrentContext(); + if (display == EGL_NO_DISPLAY || context == EGL_NO_CONTEXT) { + LOGE("XR: no current EGL display/context - call xr_init() after init_opengl()"); + return false; + } + + XrGraphicsBindingOpenGLESAndroidKHR graphicsBinding = { + XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR}; + graphicsBinding.display = display; + graphicsBinding.config = xr_get_current_egl_config(display, context); + graphicsBinding.context = context; + + XrSessionCreateInfo sessionInfo = {XR_TYPE_SESSION_CREATE_INFO}; + sessionInfo.next = &graphicsBinding; + sessionInfo.systemId = g_system_id; + if (!xr_check(xrCreateSession(g_instance, &sessionInfo, &g_session), "xrCreateSession")) + return false; + + XrReferenceSpaceCreateInfo spaceInfo = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO}; + spaceInfo.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL; + spaceInfo.poseInReferenceSpace.orientation.w = 1.0f; + if (!xr_check(xrCreateReferenceSpace(g_session, &spaceInfo, &g_local_space), + "xrCreateReferenceSpace")) + return false; + + // Prefer a plain linear 8-bit format over GL_SRGB8_ALPHA8: the source + // SDL surface pixels are already sRGB-encoded (as ordinary 8-bit image + // data conventionally is) and get uploaded/sampled as plain linear + // GL_RGBA with no decode step anywhere in this path, matching the + // desktop SDL_RenderCopy path this replaces - an sRGB swapchain format + // would auto-gamma-encode on write and double-encode already-encoded + // data. + uint32_t formatCount = 0; + xrEnumerateSwapchainFormats(g_session, 0, &formatCount, NULL); + int64_t *formats = (int64_t *)malloc(sizeof(int64_t) * formatCount); + xrEnumerateSwapchainFormats(g_session, formatCount, &formatCount, formats); + int64_t chosenFormat = formats[0]; + for (uint32_t i = 0; i < formatCount; i++) { + LOGI("XR: swapchain format[%u] = 0x%llx", i, (unsigned long long)formats[i]); + if (formats[i] == GL_RGBA8) { + chosenFormat = GL_RGBA8; + break; + } + } + free(formats); + + XrSwapchainCreateInfo swapchainInfo = {XR_TYPE_SWAPCHAIN_CREATE_INFO}; + swapchainInfo.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_SAMPLED_BIT; + swapchainInfo.format = chosenFormat; + swapchainInfo.sampleCount = 1; + swapchainInfo.width = (uint32_t)game_width; + swapchainInfo.height = (uint32_t)game_height; + swapchainInfo.faceCount = 1; + swapchainInfo.arraySize = 1; + swapchainInfo.mipCount = 1; + if (!xr_check(xrCreateSwapchain(g_session, &swapchainInfo, &g_game_swapchain), + "xrCreateSwapchain")) + return false; + g_game_width = game_width; + g_game_height = game_height; + + uint32_t imageCount = 0; + xrEnumerateSwapchainImages(g_game_swapchain, 0, &imageCount, NULL); + if (imageCount > MAX_SWAPCHAIN_IMAGES) { + LOGE("XR: swapchain reports %u images, only room for %d", imageCount, + MAX_SWAPCHAIN_IMAGES); + return false; + } + // Zero-initialized, not just `.type` set per element - these structs + // also carry a `next` field the runtime may read, and an + // uninitialized stack array would leave it as garbage. + XrSwapchainImageOpenGLESKHR images[MAX_SWAPCHAIN_IMAGES] = {0}; + for (uint32_t i = 0; i < imageCount; i++) + images[i].type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR; + if (!xr_check(xrEnumerateSwapchainImages(g_game_swapchain, imageCount, &imageCount, + (XrSwapchainImageBaseHeader *)images), + "xrEnumerateSwapchainImages")) + return false; + g_swapchain_image_count = imageCount; + + if (!xr_load_real_gles()) { + LOGE("XR: couldn't resolve real GLES FBO functions via dlsym"); + return false; + } + + // Wraps each swapchain-provided texture in its own framebuffer, matching + // how OpenGL.cc's own CreateFrameBuffer() wraps backupBuffer - just + // without a depth/stencil attachment, since the final composite draw + // (see opengl_swap_and_restore) never needs one. The Gen/Bind calls are + // gl4es's own (linked, not dlsym'd) - see the comment above + // real_glFramebufferTexture2D for why. + bool all_complete = true; + for (uint32_t i = 0; i < imageCount; i++) { + glGenFramebuffers(1, &g_swapchain_fbos[i]); + glBindFramebuffer(GL_FRAMEBUFFER, g_swapchain_fbos[i]); + real_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, + images[i].image, 0); + GLenum status = real_glCheckFramebufferStatus(GL_FRAMEBUFFER); + if (status != GL_FRAMEBUFFER_COMPLETE) { + LOGE("XR: swapchain FBO %u incomplete: 0x%x", i, status); + all_complete = false; + } + } + glBindFramebuffer(GL_FRAMEBUFFER, 0); + if (!all_complete) + return false; + + LOGI("XR: instance/session/swapchain ready (%dx%d, %u images)", game_width, game_height, + imageCount); + return true; +} + +bool xr_init(int game_width, int game_height) { + if (!xr_create_instance_and_session(game_width, game_height)) { + LOGE("XR: bring-up failed - falling back to the normal window present path"); + xr_shutdown(); + return false; + } + return true; +} + +void xr_poll_events(void) { + if (g_instance == XR_NULL_HANDLE) + return; + + while (true) { + XrEventDataBuffer event = {XR_TYPE_EVENT_DATA_BUFFER}; + XrResult result = xrPollEvent(g_instance, &event); + if (result == XR_EVENT_UNAVAILABLE) + break; + if (!xr_check(result, "xrPollEvent")) + break; + + if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) { + XrEventDataSessionStateChanged *stateEvent = (XrEventDataSessionStateChanged *)&event; + g_session_state = stateEvent->state; + LOGI("XR: session state -> %d", (int)g_session_state); + + if (g_session_state == XR_SESSION_STATE_READY) { + XrSessionBeginInfo beginInfo = {XR_TYPE_SESSION_BEGIN_INFO}; + beginInfo.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + g_session_running = xr_check(xrBeginSession(g_session, &beginInfo), "xrBeginSession"); + } else if (g_session_state == XR_SESSION_STATE_STOPPING) { + xr_check(xrEndSession(g_session), "xrEndSession"); + g_session_running = false; + } else if (g_session_state == XR_SESSION_STATE_EXITING || + g_session_state == XR_SESSION_STATE_LOSS_PENDING) { + g_session_running = false; + } + } + } +} + +bool xr_is_session_running(void) { return g_session_running; } + +bool xr_frame_begin(void) { + g_have_acquired_image = false; + if (!xr_is_session_running()) + return false; + + XrFrameWaitInfo waitInfo = {XR_TYPE_FRAME_WAIT_INFO}; + XrFrameState frameState = {XR_TYPE_FRAME_STATE}; + if (!xr_check(xrWaitFrame(g_session, &waitInfo, &frameState), "xrWaitFrame")) + return false; + g_predicted_display_time = frameState.predictedDisplayTime; + g_frame_should_render = frameState.shouldRender; + + XrFrameBeginInfo beginInfo = {XR_TYPE_FRAME_BEGIN_INFO}; + if (!xr_check(xrBeginFrame(g_session, &beginInfo), "xrBeginFrame")) + return false; + + if (!g_frame_should_render) + return false; + + uint32_t imageIndex = 0; + XrSwapchainImageAcquireInfo acquireInfo = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; + if (!xr_check(xrAcquireSwapchainImage(g_game_swapchain, &acquireInfo, &imageIndex), + "xrAcquireSwapchainImage")) + return false; + + XrSwapchainImageWaitInfo waitImageInfo = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO}; + waitImageInfo.timeout = XR_INFINITE_DURATION; + if (!xr_check(xrWaitSwapchainImage(g_game_swapchain, &waitImageInfo), "xrWaitSwapchainImage")) + return false; + + // gl4es's own (linked, not dlsym'd) bind - this targets an FBO id + // gl4es itself created (see xr_create_instance_and_session()), so its + // own "current FBO" bookkeeping updates correctly and its immediate- + // mode draw calls in android_draw_surface_as_quad() land in the right + // place. + glBindFramebuffer(GL_FRAMEBUFFER, g_swapchain_fbos[imageIndex]); + glViewport(0, 0, g_game_width, g_game_height); + + g_have_acquired_image = true; + return true; +} + +void xr_frame_end(void) { + if (g_session == XR_NULL_HANDLE) + return; + + if (g_have_acquired_image) { + XrSwapchainImageReleaseInfo releaseInfo = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; + xr_check(xrReleaseSwapchainImage(g_game_swapchain, &releaseInfo), + "xrReleaseSwapchainImage"); + } + + if (!xr_is_session_running()) + return; + + XrCompositionLayerQuad quad = {XR_TYPE_COMPOSITION_LAYER_QUAD}; + quad.space = g_local_space; + quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH; + quad.subImage.swapchain = g_game_swapchain; + quad.subImage.imageRect.extent.width = g_game_width; + quad.subImage.imageRect.extent.height = g_game_height; + quad.pose.orientation.w = 1.0f; + quad.pose.position.z = -QUAD_DISTANCE_METERS; + quad.size.width = QUAD_WIDTH_METERS; + quad.size.height = QUAD_WIDTH_METERS * (float)g_game_height / (float)g_game_width; + + const XrCompositionLayerBaseHeader *layers[1] = {(XrCompositionLayerBaseHeader *)&quad}; + + XrFrameEndInfo endInfo = {XR_TYPE_FRAME_END_INFO}; + endInfo.displayTime = g_predicted_display_time; + endInfo.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; + endInfo.layerCount = g_have_acquired_image ? 1 : 0; + endInfo.layers = g_have_acquired_image ? layers : NULL; + xr_check(xrEndFrame(g_session, &endInfo), "xrEndFrame"); +} + +void xr_shutdown(void) { + for (uint32_t i = 0; i < g_swapchain_image_count; i++) { + if (g_swapchain_fbos[i] != 0) + glDeleteFramebuffers(1, &g_swapchain_fbos[i]); + } + g_swapchain_image_count = 0; + + if (g_game_swapchain != XR_NULL_HANDLE) + xrDestroySwapchain(g_game_swapchain); + g_game_swapchain = XR_NULL_HANDLE; + + if (g_local_space != XR_NULL_HANDLE) + xrDestroySpace(g_local_space); + g_local_space = XR_NULL_HANDLE; + + if (g_session != XR_NULL_HANDLE) + xrDestroySession(g_session); + g_session = XR_NULL_HANDLE; + + if (g_instance != XR_NULL_HANDLE) + xrDestroyInstance(g_instance); + g_instance = XR_NULL_HANDLE; + + g_session_state = XR_SESSION_STATE_UNKNOWN; + g_session_running = false; +} diff --git a/android/app/src/main/cpp/xr_session.h b/android/app/src/main/cpp/xr_session.h new file mode 100644 index 0000000..375b472 --- /dev/null +++ b/android/app/src/main/cpp/xr_session.h @@ -0,0 +1,56 @@ +// Minimal OpenXR bring-up for questshock's immersive Quest build. Keeps the +// game's own rendering untouched (see android/engine-patches/ +// 12-android-openxr-present.patch) - this module only owns the OpenXR +// instance/session/swapchain and the final "submit a quad layer instead of +// presenting to a window" step. No stereo rendering, no controller input +// yet (see the plan's steps B/C/D for those) - the game composite is shown +// as a single flat quad floating in front of the viewer. +#ifndef QUESTSHOCK_XR_SESSION_H +#define QUESTSHOCK_XR_SESSION_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Call once, right after init_opengl() (OpenGL.cc) has created and made +// current the GL context SDL/gl4es already use - creates the OpenXR +// instance/session sharing that same EGL display/context, plus one +// swapchain sized to the game's logical resolution (game_width/height, i.e. +// grd_cap->w/h - see Shock.c's InitSDL()). Returns false if OpenXR bring-up +// failed (e.g. no runtime installed) - callers should fall back to the +// existing window-present path in that case. +bool xr_init(int game_width, int game_height); + +// Pumps XR session-state events. Call once per frame, before +// xr_frame_begin(). Must still be called even when xr_init() returned +// false (no-op in that case). +void xr_poll_events(void); + +// True once the session has reached a state where frames are expected +// (XR_SESSION_STATE_SYNCHRONIZED or later) - i.e. it's safe/required to +// start calling xr_frame_begin()/xr_frame_end() each frame. +bool xr_is_session_running(void); + +// Begins the XR frame (xrWaitFrame/xrBeginFrame) and, if the runtime wants +// this frame rendered, acquires the next swapchain image and binds its +// framebuffer as the current render target - ready for the caller to draw +// into exactly as it would have drawn to the default framebuffer. Returns +// true if the caller should draw this frame; xr_frame_end() must be called +// unconditionally afterward either way (a begun XR frame must always be +// ended, rendered or not). +bool xr_frame_begin(void); + +// Releases the swapchain image (if one was acquired this frame) and +// submits it as a single XrCompositionLayerQuad positioned in front of the +// local reference space's origin, then ends the XR frame. +void xr_frame_end(void); + +void xr_shutdown(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/android/engine-patches/11-android-openxr-cmake.patch b/android/engine-patches/11-android-openxr-cmake.patch new file mode 100644 index 0000000..c80471e --- /dev/null +++ b/android/engine-patches/11-android-openxr-cmake.patch @@ -0,0 +1,39 @@ +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -64,6 +64,19 @@ + endif(WIN32) + endif(ENABLE_OPENGL) + ++# OpenXR loader (Android only for now - see android/app/src/main/cpp/ ++# xr_session.c, which submits the game's existing render as a composition ++# layer instead of presenting to a normal window/surface). EGL is linked ++# explicitly too - xr_session.c calls eglGetCurrentDisplay/Context/ ++# QueryContext/ChooseConfig directly (to share the GL context gl4es/SDL ++# already made), and unlike GL itself (where gl4es's own libGL.so already ++# provides every symbol the engine or xr_session.c call), there's no other ++# EGL provider in this link. ++if(ANDROID) ++ set(OPENXR_INCLUDE_DIRS ${ANDROID_PREBUILT_DIR}/openxr/include) ++ set(OPENXR_LIBRARIES ${ANDROID_PREBUILT_DIR}/openxr/lib/libopenxr_loader.so EGL) ++endif(ANDROID) ++ + if(ENABLE_SDL2 MATCHES "ON") + find_package(SDL2 REQUIRED) + if(SDL2_FOUND) +@@ -109,6 +122,8 @@ + ${SDL2_MIXER_INCLUDE_DIRS} + ${FLUIDSYNTH_INCLUDE_DIRS} + ${OPENGL_INCLUDE_DIRS} ++ ${OPENXR_INCLUDE_DIRS} ++ ${ANDROID_EXTRA_INCLUDE_DIRS} + ) + + if(NOT WIN32) +@@ -439,6 +454,7 @@ + ${SDL2_MIXER_LIBRARIES} + ${FLUIDSYNTH_LIBRARIES} + ${OPENGL_LIBRARIES} ++ ${OPENXR_LIBRARIES} + ${ALSA_LIBRARIES} + $<$:log> + ) diff --git a/android/engine-patches/12-android-openxr-present.patch b/android/engine-patches/12-android-openxr-present.patch new file mode 100644 index 0000000..6c2bd4d --- /dev/null +++ b/android/engine-patches/12-android-openxr-present.patch @@ -0,0 +1,252 @@ +--- 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); diff --git a/build-image/Dockerfile b/build-image/Dockerfile index 3f5f41b..f292c98 100644 --- a/build-image/Dockerfile +++ b/build-image/Dockerfile @@ -57,6 +57,14 @@ ARG ANDROID_16KB_LDFLAGS="-Wl,-z,max-page-size=16384" # desktop-style immediate-mode OpenGL calls into real GLES/EGL calls, so # engine/src/MacSrc/OpenGL.cc needs no immediate-mode rewrite on Android. ARG ANDROID_GL4ES_VERSION=1.1.6 +# Khronos' own OpenXR loader (https://github.com/KhronosGroup/OpenXR-SDK) - +# the vendor-neutral loader, not Meta's redistributed copy, per this +# project's "favor OpenXR" rule (see README's Design principles). Only the +# loader is built (BUILD_LOADER=ON, everything else off) - the actual XR +# runtime implementation is resolved on-device by Horizon OS at load time, +# nothing else needs bundling. Verify this is still the latest release tag +# at https://github.com/KhronosGroup/OpenXR-SDK/releases when bumping. +ARG ANDROID_OPENXR_VERSION=1.1.42 ENV DEBIAN_FRONTEND=noninteractive @@ -272,6 +280,26 @@ RUN git clone --branch "v${ANDROID_GL4ES_VERSION}" --depth 1 \ && cp -a gl4es-android/include/. /opt/prebuilt/android/gl4es/include/ \ && rm -rf gl4es-android build-gl4es-android +# OpenXR loader, for the immersive VR mode (android/app/src/main/cpp/ +# xr_session.c and friends) - see android/engine-patches/ +# 11-android-openxr-cmake.patch for how the engine links it. +RUN git clone --branch "release-${ANDROID_OPENXR_VERSION}" --depth 1 \ + https://github.com/KhronosGroup/OpenXR-SDK.git openxr-sdk-android \ + && rm -rf openxr-sdk-android/.git \ + && cmake -S openxr-sdk-android -B build-openxr-android \ + -DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \ + -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \ + -DCMAKE_INSTALL_PREFIX=/opt/prebuilt/android/openxr \ + -DBUILD_LOADER=ON -DBUILD_TESTS=OFF -DBUILD_API_LAYERS=OFF \ + -DBUILD_CONFORMANCE_TESTS=OFF -DBUILD_WITH_SYSTEM_JSONCPP=OFF \ + -DDYNAMIC_LOADER=ON \ + -DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \ + && cmake --build build-openxr-android -j"$(nproc)" --target openxr_loader \ + && cmake --install build-openxr-android \ + && mkdir -p /opt/prebuilt/android/openxr/include \ + && cp -a openxr-sdk-android/include/. /opt/prebuilt/android/openxr/include/ \ + && rm -rf openxr-sdk-android build-openxr-android + COPY build-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY build-image/build-engine.sh /usr/local/bin/build-engine.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-engine.sh diff --git a/build-image/prepare-android-project.sh b/build-image/prepare-android-project.sh index 55ece99..7f23770 100755 --- a/build-image/prepare-android-project.sh +++ b/build-image/prepare-android-project.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Prepares android/ to be built: a scratch, patched copy of engine/ (see # android/engine-patches/ - engine/ itself is never modified), the -# Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es prebuilts staged into -# jniLibs, bundled assets, and android/engine.properties (engineDir/ +# Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es/openxr prebuilts staged +# into jniLibs, bundled assets, and android/engine.properties (engineDir/ # prebuiltDir, read by android/app/build.gradle). Always run inside this # image (via ../run-image.sh, or directly if already inside it) - it # reads from /opt/prebuilt/android/*, which only exists there. @@ -75,12 +75,12 @@ cp -a "$ANDROID_DIR/gles-shaders/." "$ASSETS_DIR/shaders/" cp "/opt/prebuilt/soundfont/default.sf2" "$ASSETS_DIR/res/soundfont.sf2" cp "$REPO_ROOT/res/assets/GET_ASSETS_QUEST.txt" "$ASSETS_DIR/GET_ASSETS_QUEST.txt" -echo "== Staging prebuilt Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es .so's into jniLibs ==" +echo "== Staging prebuilt Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es/openxr .so's into jniLibs ==" JNI_LIBS_DIR="$ANDROID_DIR/app/src/main/jniLibs/arm64-v8a" rm -rf "$ANDROID_DIR/app/src/main/jniLibs" mkdir -p "$JNI_LIBS_DIR" find /opt/prebuilt/android/sdl2/lib /opt/prebuilt/android/sdl2_mixer/lib /opt/prebuilt/android/fluidsynth-lite/lib \ - /opt/prebuilt/android/gl4es/lib \ + /opt/prebuilt/android/gl4es/lib /opt/prebuilt/android/openxr/lib \ -name '*.so' -exec cp -a {} "$JNI_LIBS_DIR/" \; echo "== Done ==" diff --git a/res/screenshots/de.ladkau.questshock-20260724-212015.jpg b/res/screenshots/de.ladkau.questshock-20260724-212015.jpg new file mode 100644 index 0000000..07ad286 Binary files /dev/null and b/res/screenshots/de.ladkau.questshock-20260724-212015.jpg differ diff --git a/res/screenshots/de.ladkau.questshock-20260724-220906.jpg b/res/screenshots/de.ladkau.questshock-20260724-220906.jpg new file mode 100644 index 0000000..3fa656f Binary files /dev/null and b/res/screenshots/de.ladkau.questshock-20260724-220906.jpg differ diff --git a/res/screenshots/de.ladkau.questshock-20260725-065635.jpg b/res/screenshots/de.ladkau.questshock-20260725-065635.jpg new file mode 100644 index 0000000..2704884 Binary files /dev/null and b/res/screenshots/de.ladkau.questshock-20260725-065635.jpg differ diff --git a/res/screenshots/de.ladkau.questshock-20260725-070344.jpg b/res/screenshots/de.ladkau.questshock-20260725-070344.jpg new file mode 100755 index 0000000..f5cfc20 Binary files /dev/null and b/res/screenshots/de.ladkau.questshock-20260725-070344.jpg differ