Three bugs on this port presented identically - geometry submitted, vertex data correct, indices in range, matrices correct, on screen, unclipped, no GL error, nothing drawn - and all three were gl4es state calls being dropped. The framebuffer binding, the viewport, and finally the cull face, which cost most of a session on its own. The underlying fact is that gl4es keeps its own copy of GL state. It answers glGetIntegerv for GL_CULL_FACE_MODE, GL_FRONT_FACE and GL_MODELVIEW_MATRIX from its own tables rather than the driver, so reading state back through qgl and comparing it against the engine compares the engine with a copy of itself and agrees by construction. And it returns early from glCullFace, glDepthMask, glDepthFunc, glBlendFunc, glColorMask, glViewport, glScissor and several glEnable caps whenever the request matches that copy - so once it drifts, the engine can never correct it. It asks for the right value, gl4es calls it redundant, the driver never hears, and nothing raises an error. docs/markdown/04-coding/04-gl4es.md records what to do about it: resolve glGetIntegerv out of libGLESv3 to ask the hardware; route the VR layer's state calls through gl4es, with the three exceptions that must not be; and where a value has to land regardless, name a throwaway first so the real one cannot match a stale cache. It also records the two rules that are not about gl4es and cost as much: a probe must be shown to reach the thing it measures - three of ours had blind spots exactly where the fault was - and look at the frame, because one capture settled what six rounds of description could not. Linked from the Android build doc, since that is where someone hitting this will be. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.4 KiB
Building for Android
This covers building OpenMoHAA as an Android APK. It targets arm64-v8a and
was brought up against a Meta Quest 3, but nothing here is headset specific —
a phone or tablet builds the same way.
VR itself is not part of this. What you get is the ordinary game running as a normal Android app.
The gl4es boundary. On Android the fixed function renderer runs on top of gl4es, which keeps its own copy of GL state that can disagree with the driver. If you are debugging anything that looks like the geometry is correct and nothing is drawn, read the gl4es boundary first.
What you need
| Android NDK | r26 or later |
| Android SDK | platform 34, build-tools 34 |
| JDK | 17 (required by the Android Gradle Plugin) |
| Gradle | 8.0 or later |
| CMake | 3.25 or later |
| flex, bison | on the host, to generate the script parser |
Set ANDROID_NDK_HOME and ANDROID_HOME before you start.
1. Build the engine
Gradle does not drive the native build; CMake does, and gradle only packages the result. Configure with the NDK toolchain file:
cmake -B build-android-arm64 -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a \
-DANDROID_PLATFORM=android-29 \
-DANDROID_STL=c++_shared \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SERVER=OFF
cmake --build build-android-arm64 -j$(nproc)
ANDROID_STL=c++_shared matters: the APK ships several libraries that use the
C++ standard library, and they have to share one copy of it.
SDL2, OpenAL Soft and gl4es have no Android binaries to fall back on, so all
are downloaded and built from source. To build against trees you already have,
pass -DSDL2_SOURCE_PATH=..., -DOPENAL_SOURCE_PATH=... and
-DGL4ES_SOURCE_PATH=....
Several defaults change on Android, in cmake/platforms/android.cmake:
renderergl1is the renderer, withUSE_GL4ES=ONunderneath it. renderergl1 is fixed function, which is what this game's art was authored for, and gl4es translates its OpenGL 1.x calls to the OpenGL ES the device actually has. renderergl2 speaks GLES directly and was the first thing to run here, but it spends tens of milliseconds of CPU per eye binding GLSL programs for lighting this content has no maps to feed. It is still reachable with-DBUILD_RENDERER_GL1=OFF -DBUILD_RENDERER_GL2=ON -DUSE_GL4ES=OFF.USE_RENDERER_DLOPEN=OFF— the renderer is linked into the client rather than loaded from a path the Android linker may object to.USE_HTTP=OFF— only used for downloading content from a server.BUILD_GAME_QVMS=OFF— the QVM toolchain is not part of this build.
The build stages everything the APK needs into
build-android-arm64/apk-libs/arm64-v8a/.
2. Build the APK
cd misc/android
gradle assembleDebug
The result is misc/android/app/build/outputs/apk/debug/app-debug.apk.
If your build tree is somewhere else, point gradle at it:
gradle assembleDebug -PnativeLibsDir=/path/to/build/apk-libs
Gradle picks up SDL's org.libsdl.app Java classes from the SDL tree CMake
built against — it reads the path out of android-build.properties in the
build directory. They have to match the libSDL2.so that was linked, so do
not point them at a different SDL.
3. Install and add the game data
adb install -r misc/android/app/build/outputs/apk/debug/app-debug.apk
No game data ships with the app. It goes in the app's own external files directory, which is also where the engine writes its config and saves:
/sdcard/Android/data/org.openmoh.openmohaa/files/
Copy your retail main/ directory there:
adb shell mkdir -p /sdcard/Android/data/org.openmoh.openmohaa/files/main
adb push main/pak0.pk3 /sdcard/Android/data/org.openmoh.openmohaa/files/main/
Directory permissions
adb push cannot create directories under an app's data directory — it
fails with secure_mkdirs failed: Operation not permitted partway through the
transfer. Create them first, in one go:
cd /path/to/mohaa/main
adb shell mkdir -p $(find . -type d | sed 's|^\./|/sdcard/Android/data/org.openmoh.openmohaa/files/main/|' | tr '\n' ' ')
adb push --sync . /sdcard/Android/data/org.openmoh.openmohaa/files/main/
adb shell chmod -R 775 /sdcard/Android/data/org.openmoh.openmohaa/files
chmod has to come after adb push, or push fails with
remote fchown failed.
Only push directories the game reads. A directory created by
adb shell mkdir belongs to shell, not to the app, and no amount of
chmod'ing changes that — 775 lets the app read through it, but the app is
other there and still cannot write. Pre-creating main/configs or
main/save therefore leaves the game unable to save its config or your
progress, reporting only Couldn't write omconfig.cfg. Leave those two out
and the engine creates them itself, owned by the app and writable:
adb shell rm -rf /sdcard/Android/data/org.openmoh.openmohaa/files/main/configs \
/sdcard/Android/data/org.openmoh.openmohaa/files/main/save
The failure mode to watch for is silence: files pushed by adb are 644 and world readable, so game data loads perfectly while writes fail, which reads as "the data is fine".
4. Run it
Launch it from the device. Do not use adb shell am start; on a Quest it
intermittently strands the headset in Meta's loading environment.
Logging
Everything the engine prints goes to logcat under the openmohaa tag:
adb logcat -s openmohaa
stdout and stderr are redirected there too, so output from the C library
and from third party code is not lost.
A crash reports its own stack: bionic has no <execinfo.h>, so the backtrace
is walked with _Unwind_Backtrace and resolved through dladdr
(code/sys/new/sys_android_new.c). This matters because tombstoned does not
reliably put a backtrace in logcat, and when it does not, that handler is the
only account of where the engine died. When even that is silent,
adb shell dumpsys activity exit-info still reports the signal.
Verify that logging works before you rely on it. A crash handler that has been quietly displaced reports nothing, which is indistinguishable from a crash that never happened.
How it fits together
SDLActivity loads libSDL2.so and libopenmohaa.so, then calls SDL_main
in the latter — which is the engine's main, renamed by SDL_main.h. The
engine then dlopens the game modules by name.
Those modules ship inside the APK as libgame.so and libcgame.so rather
than beside the game data. The prefix is not cosmetic: Android extracts only
lib*.so out of an APK, and having them in the native library directory means
the loader finds them by name, with no path to get wrong. DLL_PREFIX in
q_platform.h is what keeps the engine's name for them in step.
| Library | Loaded by |
|---|---|
libSDL2.so |
SDLActivity, by name |
libopenmohaa.so |
SDLActivity, then SDL_main is called in it |
libopenal.so, libc++_shared.so |
the linker, as dependencies |
libgame.so, libcgame.so |
the engine, at runtime |
Dedicated server
The server builds too, and needs neither SDL nor a renderer:
cmake -B build-android-server \
-DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-29 \
-DBUILD_SERVER=ON -DBUILD_CLIENT=OFF
It produces an ordinary omohaaded executable rather than an APK.