The flat cross-shaped reticle used while aiming at the game quad is
drawn directly into the game quad's own texture, so it only ever made
sense there - while the menu launcher or keyboard was open, there was
no visual feedback at all until the ray was precisely on target,
making both panels hard to aim at.
Add a second, thin XrCompositionLayerQuad per hand: a billboarded
"laser beam" ribbon from the controller to wherever that hand's ray
currently crosses the plane of whichever overlay (keyboard, then menu)
claims it that frame, oriented via a view-space head pose so it reads
as a line from the viewer's eye regardless of angle, colored per hand
(cyan left, amber right) and clamped to 2m so grazing angles don't
produce an absurdly long beam. Built from data xr_input.c's existing
per-hand hit-testing already computes; the game-quad reticle itself is
untouched.
Expand the on-screen keyboard to a full US layout (letters, digits,
punctuation, Shift layer, Tab, arrows, F1-F12, one-shot Ctrl/Alt
modifiers), make it a persistent, movable, closeable panel via a
title-bar drag handle, and lower its opacity.
Previously the keyboard was just a second panel swapped into the same
quad as the menu launcher, which made the launcher translucent too and
made the keyboard and menu mutually exclusive. Extract the shared
swapchain/JNI/touch-dispatch plumbing into two generic, reusable
modules - xr_swapchain.c (swapchain + per-image FBO setup) and
xr_overlay.c (an off-screen Android View rendered into its own OpenXR
quad, hit-tested via a laser pointer) - and make the menu launcher and
keyboard two independent XrOverlay instances instead of one. The
controller's menu button now only opens/closes the launcher; the
launcher's "Keyboard" button only opens the keyboard - so the keyboard
can stay up while picking something from the menu, and only the
keyboard's own Close button hides it.
xr_menu.c/.h are gone, fully absorbed into xr_overlay.c. On the Java
side, OverlayPanel.java carries the shared off-screen-render/touch/
cursor plumbing that MenuOverlay and the new KeyboardOverlay both
subclass.
MenuOverlay's key grid forwards every press to the game as real input:
non-printable keys (Esc/Enter/Backspace) as an SDLActivity.onNativeKeyDown()/
onNativeKeyUp() pair, and printable keys (letters, Space) as a synthesized
SDL_TEXTINPUT event pushed directly from native code, since the engine's
pump_events() only picks up printable ASCII from that event type, not from
SDL_KEYDOWN. Confirmed on-device, including that Space now correctly skips
the intro cutscene alongside Esc/Enter.
Replaces the single shared, side-by-side swapchain (game + menu content
packed into one image, needing viewport/scissor juggling to keep the
menu's own glClear from bleeding into the game's region) with two fully
independent XrSwapchainState instances, each sized to exactly its own
content and acquired/released on its own within the same
xrBeginFrame/xrEndFrame pair - an ordinary multi-layer OpenXR setup.
The shared-swapchain design was originally adopted to work around what
looked like a Horizon OS compositor limitation with independent
swapchains, but that was diagnosed before the real root cause of the
"menu shows the game's content" bug was found (gl4es's fpe.c
unconditionally substituting its own shader onto the menu's draw call -
see README's Debugging notes). Since the actual fix routes the menu's
blit through a real, non-gl4es glBlitFramebuffer() call - orthogonal to
how many swapchains exist - splitting back onto two swapchains works
fine and removes the GL_SCISSOR_TEST workaround and sub-rectangle
offset math entirely. Confirmed on-device: both quads render correctly,
hit-testing and 90 FPS unaffected.
Also adds a visible cursor to the menu quad itself: xr_input.c now
tracks whichever hand's aim ray hits the menu each frame and forwards it
to a new MenuOverlay.nativeUpdateCursor(), which draws a small dot at
that position (composited through the same Bitmap the menu's own
content already goes through) - previously there was no visual feedback
at all showing where you were pointing before pulling the trigger.
- xr_input.c/h: an OpenXR action set (aim pose, trigger/select click, a
menu-toggle button) plus ray/quad hit-testing, and a visible
laser-pointer line drawn from the controller toward the hit point.
- xr_menu.c/h + MenuOverlay.java: a second composition-layer quad,
toggled by a controller button, showing an off-screen, never-attached
Android View tree (one "Keyboard" button so far) driven by synthetic
touch events computed from the laser ray's hit UV.
- Migrate gl4es from a prebuilt binary baked into the Docker build-image
to a vendored source snapshot (android/gl4es-src/) compiled from
source at APK build time via CMake add_subdirectory(), patched through
a new android/gl4es-patches/ (mirrors the existing engine-patches/
pattern). This was needed to track down and fix a bug hit while
building the menu: gl4es's fixed-pipeline emulation (fpe.c) was
unconditionally substituting its own shader onto the menu's draw call
(fpe_ReleventState() always sets alphafunc to a nonzero sentinel, so
its fpe_IsEmpty() check could never see the state as empty), making
the menu quad show the game's own rendering instead of its own
content. Fixed by routing the menu's blit through a real
glBlitFramebuffer() call instead of a shader-based draw, which gl4es's
fpe.c has nothing to intercept - documented in README.md's new
"Debugging notes" section.
- Renumber android/engine-patches/ to close the gap left by removing an
unrelated diagnostic-only patch, and strip investigation-journal
comments and dead diagnostic code (temporary tracing, env-var probes)
left over from finding the bug above.
- Restructure README.md into numbered sections, document every
engine/gl4es patch, add Android Studio dev/testing-cycle instructions,
and generalize Quest-specific wording to any OpenXR headset. Update
NOTICE.txt to match (gl4es is now vendored/patched source, not a
prebuilt binary; add the OpenXR-SDK loader).
- New android/app/src/main/cpp/xr_session.c (+.h): owns the OpenXR
instance/session/local-space/swapchain and the per-frame
xrWaitFrame/xrBeginFrame/xrEndFrame loop, submitting the game's
existing flat render as a single head-tracked XrCompositionLayerQuad.
No stereo rendering or controller input yet - that's steps B/C/D of
the plan.
- 11-android-openxr-cmake.patch: links the OpenXR loader as a build
dependency, staged into jniLibs the same way as gl4es/SDL2.
build-image/Dockerfile and prepare-android-project.sh build and stage
it.
- 12-android-openxr-present.patch: redirects OpenGL.cc's final present
step (opengl_swap_and_restore / SDLDraw's software path) into the XR
swapchain FBO instead of the window, on Android only. Two real gl4es
bugs had to be worked around to get pixels on screen at all:
- gl4es's own glBindFramebuffer errors on an FBO id it didn't create
itself, even though the real driver-level bind succeeds - fixed by
creating the swapchain FBO container via gl4es's own
glGenFramebuffers/glBindFramebuffer, and only using the real
(dlsym'd) driver call for attaching OpenXR's foreign swapchain
texture, which gl4es's own attach can't handle.
- gl4es's glBegin/glVertexAttrib/glVertex3f immediate-mode emulation
only captures a fresh per-vertex value for attribute 0 (position,
driven directly by glVertex3f) - custom attributes like this
shader's texcoords/light are GLES2's *constant*-attribute API and
applied once for the whole draw, not per vertex, silently
collapsing the UI-overlay quad to a single sampled texel. Fixed by
switching that one draw call to real vertex arrays
(glVertexAttribPointer/glDrawArrays).
Also flips the V texcoord to match SDL's top-down row order against
GL's texture convention, and reuses a persistent texture object
instead of a fresh gen/upload/delete every frame.
- AndroidManifest.xml: declares the immersive-HMD intent category and
focus-aware metadata, drops the 2D-panel layout hint.
- README: documents the new OpenXR immersive mode.
<layout> defaultWidth/defaultHeight/minWidth/minHeight/gravity hint -
Quest's Home shell otherwise defaults a freshly-launched 2D panel to a
portrait shape, cropping the game's 4:3 640x480 content down to a
sliver.
- The actual root cause of the remaining crop (game rendering into one
corner of an otherwise correctly-sized panel), found via on-device
logcat and the new diagnostics below rather than guesswork:
ChangeScreenSize() (engine/src/MacSrc/ShockBitmap.c) calls
SDL_SetWindowSize() whenever the game sets its video mode. That's a
desktop-only operation in effect - Android has no SetWindowSize driver
hook, so SDL's generic layer instead overwrites its own cached window
size to the game's internal resolution (640x480) and synthesizes a
resize event from that, desyncing SDL's notion of the window size from
the real, unchanged Android surface (e.g. 1600x1200). Both SDL's own
renderer viewport and the engine's custom GL viewport then scale
against that corrupted cached size. 10-android-no-window-resize.patch
skips the desktop-only SDL_SetWindowFullscreen/SetWindowSize/
SetWindowPosition calls on Android, keeping the legitimate
SDL_RenderSetLogicalSize/offscreen-bitmap setup.
- 06-android-resize-event.patch: also react to SDL_WINDOWEVENT_RESIZED
in the engine's event loop, not just SIZE_CHANGED - Android's SDL video
backend never sends SIZE_CHANGED for surface-driven resizes, only
RESIZED, an independent gap worth closing regardless of the bug above.
- 08/09-android-logcat-*.patch: route the engine's existing log.c output
(previously plain fprintf(stderr,...), never actually captured by
logcat on this build) through __android_log_vprint instead, so every
existing INFO/DEBUG/WARN/ERROR call site becomes visible for on-device
debugging. This is what made the diagnostic below (and everything
since) observable at all.
- 07-android-size-diagnostics.patch: one-time startup log comparing
SDL_GetWindowSize/SDL_GL_GetDrawableSize/SDL_GetRendererOutputSize -
the evidence that actually pinned down the SDL_SetWindowSize bug above.
- QuestShockActivity.java: add a diagnostic onSizeChanged() log on
GameSurface, used to rule out a later Android-side panel relayout as
the cause before finding the real one.
- QuestShockActivity now actually blocks the native engine from starting
when game data is missing, closing three gaps found via on-device
testing: super.onCreate() must run unconditionally first (Android
throws SuperNotCalledException otherwise); SDLActivity.mBrokenLibraries
is now set provisionally before the storage-permission check, since
onWindowFocusChanged() closing the permission dialog could otherwise
start the engine before the async onRequestPermissionsResult() callback
ran; and a new GameSurface (SDLSurface subclass) closes the actual gap
that let the init_popups NULL-deref crash through even with
mBrokenLibraries set - SDLSurface.surfaceChanged() starts the native
thread directly without ever checking that flag.
- Force Android to use SDL2's openslES audio backend instead of AAudio
(android/engine-patches/05-android-audio-driver.patch): AAudio only
allows one open playback device at a time, but the engine opens two
(cutscene audio via SDL_OpenAudioDevice, SFX/MIDI via Mix_OpenAudio),
hitting an assertion failure on real hardware.
- Add 16 KB ELF page-size alignment (-Wl,-z,max-page-size=16384) to every
Android shared library - the four prebuilts (SDL2, SDL2_mixer,
fluidsynth-lite, gl4es, in build-image/Dockerfile) and the engine's own
libmain.so (build.gradle) - matching Google's Play Store requirement
for Android 15+ and clearing Android Studio's compatibility warning.
- Add a stageEngine Gradle task that automatically re-stages the patched
engine/ copy and prebuilt libraries before any Android Studio build
(hooked into preBuild, with proper up-to-date checking), so source/
patch changes can't silently go stale in the build/android-engine
scratch copy - previously a manual, easy-to-forget step. Skips
automatically inside the build-image container so make apk/CI are
unaffected.
- patchelf gl4es's embedded SONAME to libGL.so so AGP's jniLibs packaging
(which drops any file not literally named "*.so") and the dynamic
linker's NEEDED-entry resolution (by embedded SONAME, not filename)
finally agree - fixes the "library \"libGL.so.1\" not found" crash seen
on real Quest hardware.
- QuestShockActivity now checks that res/data and res/sound exist before
starting the native engine, showing an explanatory dialog instead of
crashing on init_popups' unchecked NULL resource load when a fresh
install has no game data copied in yet.
- dist/questshock-<version>-android-arm64.apk is now versioned from the
same git-tag-or-dev-placeholder scheme as the desktop tarball
(build-image/version.sh, shared by both via the Makefile and
build-apk.sh).
- build-image/prepare-android-project.sh (prep logic extracted out of
build-apk.sh) can now stage android/ for a native build directly in
Android Studio (--host-paths), exporting the prebuilt SDL2/SDL2_mixer/
fluidsynth-lite/gl4es libraries and writing host-resolvable paths,
instead of only ever building inside the Docker image.
- Corrected GET_ASSETS.txt/GET_ASSETS_QUEST.txt, which wrongly described
merging res/pc/hd and res/pc/cdrom trees out of the raw installer's
sshock.kpf - an already-installed copy's res/data res/sound can just
be copied directly, with extract_assets.sh only needed from the raw
installer.
immediate-mode GL (glBegin/glVertex3f/glEnd and friends) from scratch on
top of GLES, since GLES has none of it. That code compiled but was never
actually exercised - no on-screen verification was possible without real
Quest hardware - making it the biggest unverified risk in the port.
Replace it with GL4ES (MIT-licensed, prebuilt into the build image),
a mature desktop-GL-on-GLES translation library used by other
Quest/Android game ports for exactly this problem. This cuts
04-android-opengl-es-render.patch by half, deleting the hand-written
vertex-accumulation emulation entirely; alpha test, point sprites, and
point size stay on their existing GLES-native fixes rather than trusting
GL4ES's shakier custom-shader interop for those.
Bump build-image/VERSION to 3 - a fresh image with GL4ES cross-compiled
for arm64-v8a was built and make apk verified successfully against it.