Fix missing-assets detection race, 16 KB page alignment, and Android audio backend
build / build (push) Successful in 2m11s

- 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.
This commit is contained in:
ml
2026-07-23 19:20:22 +02:00
parent ac640762d7
commit 4e47e0a989
8 changed files with 157 additions and 15 deletions
+49
View File
@@ -27,6 +27,49 @@ if (prebuiltDir == null) {
def questshockVersionName = project.hasProperty('questshockVersionName') ? project.property('questshockVersionName') : '0.0.0-dev' def questshockVersionName = project.hasProperty('questshockVersionName') ? project.property('questshockVersionName') : '0.0.0-dev'
def questshockVersionCode = project.hasProperty('questshockVersionCode') ? project.property('questshockVersionCode').toInteger() : 1 def questshockVersionCode = project.hasProperty('questshockVersionCode') ? project.property('questshockVersionCode').toInteger() : 1
// Auto-refreshes the staged, patched engine/ copy and prebuilt libraries
// (see build-image/prepare-android-project.sh --host-paths, and the
// engineDir/prebuiltDir properties read above) so a plain Android Studio
// build/run can never silently compile against a stale scratch copy after
// engine/ or android/engine-patches/ change - previously a manual, easy to
// forget re-run. inputs/outputs are declared so Gradle skips the (Docker-
// invoking, not free) step entirely when nothing relevant actually changed,
// keeping pure-Java edit/run cycles fast.
def repoRoot = file("${projectDir}/../..")
def stageEngineTask = tasks.register("stageEngine", Exec) {
group = "build setup"
description = "Refreshes the patched engine/ scratch copy and prebuilt libraries for a native Android Studio build (build-image/prepare-android-project.sh --host-paths)."
workingDir repoRoot
commandLine "./run-image.sh", "bash", "build-image/prepare-android-project.sh", "--host-paths"
// /opt/prebuilt/android only ever exists inside the build-image
// container itself (baked in at image-build time, never on the host -
// see build-image/Dockerfile). Its presence means this build is
// build-apk.sh's own gradlew call, running INSIDE that container, which
// already staged everything itself in container mode before invoking
// gradlew - re-running this task there would try to `docker run` from
// inside a container with no docker socket, breaking make apk/CI. Only
// a genuine host-side Android Studio build (where this path is absent)
// needs this task.
onlyIf { !file('/opt/prebuilt/android').isDirectory() }
inputs.dir("${repoRoot}/engine")
inputs.dir("${projectDir}/../engine-patches")
inputs.file("${repoRoot}/build-image/Dockerfile")
outputs.dir("${repoRoot}/build/android-engine")
outputs.dir("${repoRoot}/build/android-prebuilt")
outputs.file("${projectDir}/../engine.properties")
}
// preBuild is what every variant's compile/native-build tasks already
// transitively depend on, regardless of AGP version's exact CMake task
// naming - the simplest reliable hook to run before any of them.
afterEvaluate {
tasks.named("preBuild").configure {
dependsOn stageEngineTask
}
}
android { android {
namespace "de.ladkau.questshock" namespace "de.ladkau.questshock"
// compileSdk/buildToolsVersion/ndkVersion must all match what // compileSdk/buildToolsVersion/ndkVersion must all match what
@@ -67,6 +110,11 @@ android {
// which would otherwise miss our custom-installed SDL2 (see // which would otherwise miss our custom-installed SDL2 (see
// build-image/Dockerfile's SDL2_mixer build, which hit the // build-image/Dockerfile's SDL2_mixer build, which hit the
// exact same thing). // exact same thing).
// -Wl,-z,max-page-size=16384: NDK 26 doesn't 16 KB-align ELF
// LOAD segments by default (only automatic in NDK 28+) - see
// 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.
arguments "-DENABLE_SDL2=ON", "-DENABLE_SOUND=BUNDLED", "-DENABLE_FLUIDSYNTH=BUNDLED", \ arguments "-DENABLE_SDL2=ON", "-DENABLE_SOUND=BUNDLED", "-DENABLE_FLUIDSYNTH=BUNDLED", \
"-DANDROID_PREBUILT_DIR=${prebuiltDir}", \ "-DANDROID_PREBUILT_DIR=${prebuiltDir}", \
"-DCMAKE_PREFIX_PATH=${prebuiltDir}/sdl2", \ "-DCMAKE_PREFIX_PATH=${prebuiltDir}/sdl2", \
@@ -74,6 +122,7 @@ android {
"-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=BOTH", \ "-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=BOTH", \
"-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \ "-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \
"-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=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"
abiFilters 'arm64-v8a' abiFilters 'arm64-v8a'
} }
@@ -2,10 +2,12 @@ package de.ladkau.questshock;
import android.Manifest; import android.Manifest;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.content.Context;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.content.res.AssetManager; import android.content.res.AssetManager;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.view.SurfaceHolder;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
@@ -13,16 +15,26 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import org.libsdl.app.SDLActivity; import org.libsdl.app.SDLActivity;
import org.libsdl.app.SDLSurface;
/** /**
* Everything here runs before super.onCreate() (which is what loads the * Prepares /sdcard/questshock/ the way Shockolate's plain relative-path
* native libraries and eventually calls Shockolate's own main()) - see * file I/O ("res/data/...", confirmed via grep - none of it goes through
* android/engine-patches/ for why: engine/ itself is never modified, so * SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile
* this Java-side setup is the only place left to prepare * doesn't apply here) expects to find it - see android/engine-patches/ for
* /sdcard/questshock/ the way Shockolate's plain relative-path file I/O * why this is done from Java instead of patching engine/ itself, which is
* ("res/data/...", confirmed via grep - none of it goes through SDL_RWops, * never modified.
* so Android's asset-manager fallback for SDL_RWFromFile doesn't apply *
* here) expects to find it. * super.onCreate() (SDLActivity's) must always run first and unconditionally
* - Android throws SuperNotCalledException otherwise, checked right after
* onCreate() returns, regardless of what this subclass does afterwards.
* SDLActivity's own onCreate() only loads libraries, sets up JNI and the
* surface - the actual native SDL_main thread doesn't start until later, from
* one of several lifecycle paths (onResume(), onWindowFocusChanged(), and
* SDLSurface.surfaceChanged() - see GameSurface below for why that last one
* needs its own fix) - so it's safe to do our own checks (and set
* SDLActivity.mBrokenLibraries, which most - but not all - of those paths
* gate on) afterwards.
*/ */
public class QuestShockActivity extends SDLActivity { public class QuestShockActivity extends SDLActivity {
private static final String TAG = "QuestShock"; private static final String TAG = "QuestShock";
@@ -45,8 +57,6 @@ public class QuestShockActivity extends SDLActivity {
// has no public chdir() (confirmed against the actual API 34 stub jar). // has no public chdir() (confirmed against the actual API 34 stub jar).
private static native void nativeChdir(String path); private static native void nativeChdir(String path);
private Bundle mSavedInstanceState;
@Override @Override
protected String[] getLibraries() { protected String[] getLibraries() {
return new String[] { return new String[] {
@@ -57,9 +67,52 @@ public class QuestShockActivity extends SDLActivity {
}; };
} }
// SDLSurface.surfaceChanged() (org/libsdl/app/, vendored from SDL2's own
// template) starts the native SDL thread directly - unlike onResume()/
// onWindowFocusChanged(), it never checks SDLActivity.mBrokenLibraries
// first. Since surfaceChanged() fires on essentially every launch
// regardless of that flag, setting mBrokenLibraries alone (see onCreate()/
// setUpGameDirAndContinue()) does NOT actually stop the engine from
// starting - confirmed on-device: the missing-assets crash still happened
// with mBrokenLibraries set, from exactly this path. Route through a
// subclass that adds the missing check instead of patching the vendored
// file directly.
private static class GameSurface extends SDLSurface {
GameSurface(Context context) {
super(context);
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
if (SDLActivity.mBrokenLibraries) {
return;
}
super.surfaceChanged(holder, format, width, height);
}
}
@Override
protected SDLSurface createSDLSurface(Context context) {
return new GameSurface(context);
}
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
mSavedInstanceState = savedInstanceState; super.onCreate(savedInstanceState);
if (SDLActivity.mBrokenLibraries) {
// SDLActivity's own onCreate() already showed its "SDL Error"
// dialog for this - nothing left for us to do.
return;
}
// Provisionally block the native engine from starting - cleared only
// once setUpGameDirAndContinue() confirms game data is present. Must
// happen before requestPermissions() below: the storage-permission
// dialog closing can fire onWindowFocusChanged(true) - which starts
// the native SDLThread - before the async onRequestPermissionsResult()
// callback (which is what actually calls setUpGameDirAndContinue())
// gets a chance to run, so mBrokenLibraries has to already be true
// going into that race, not set afterwards.
SDLActivity.mBrokenLibraries = true;
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) if (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) { != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, ActivityCompat.requestPermissions(this,
@@ -102,19 +155,28 @@ public class QuestShockActivity extends SDLActivity {
// and, on a fresh install with no game data copied in yet, that // and, on a fresh install with no game data copied in yet, that
// fails deep inside startup (init_popups(), which doesn't NULL-check // fails deep inside startup (init_popups(), which doesn't NULL-check
// the load) as a hard native crash instead of a message. Catch the // the load) as a hard native crash instead of a message. Catch the
// missing-data case here instead, before super.onCreate() ever // missing-data case here instead, before Shockolate's native
// starts Shockolate's native main(). // SDL_main ever starts.
if (!isNonEmptyDir(new File(gameDir, "res/data")) || !isNonEmptyDir(new File(gameDir, "res/sound"))) { if (!isNonEmptyDir(new File(gameDir, "res/data")) || !isNonEmptyDir(new File(gameDir, "res/sound"))) {
// mBrokenLibraries is already true (set in onCreate()) - leave it
// that way. Every path that starts the native SDL_main thread
// (onWindowFocusChanged(), resumeNativeThread(), etc.) already
// checks this flag before doing anything native, so this
// reliably prevents the engine from starting without having to
// duplicate all of SDLActivity's own lifecycle guards ourselves.
showMissingAssetsDialog(); showMissingAssetsDialog();
return; return;
} }
// chdir() is process-wide, not per-thread - already in effect for // chdir() is process-wide, not per-thread - already in effect for
// every thread (including the one that will run Shockolate's own // every thread (including the one that will run Shockolate's own
// SDL_main) by the time super.onCreate() below starts it. // SDL_main) by the time the native thread actually starts. Done
// before clearing mBrokenLibraries below so the engine can never
// start pre-chdir.
nativeChdir(GAME_DIR); nativeChdir(GAME_DIR);
super.onCreate(mSavedInstanceState); // Assets confirmed present - safe to let the native engine start now.
SDLActivity.mBrokenLibraries = false;
} }
private static boolean isNonEmptyDir(File dir) { private static boolean isNonEmptyDir(File dir) {
@@ -0,0 +1,20 @@
--- a/src/MacSrc/Shock.c
+++ b/src/MacSrc/Shock.c
@@ -162,6 +162,17 @@
void InitSDL() {
SDL_SetHint(SDL_HINT_NO_SIGNAL_HANDLERS, "1");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl");
+#ifdef __ANDROID__
+ // SDL2 2.28.5's AAudio backend (Android's default since API 26) only
+ // ever allows one open non-capture (playback) device at a time - it
+ // keeps a single static handle and asserts on a second open
+ // ('SDL_assert((audioDevice == NULL) || iscapture)' in
+ // src/audio/aaudio/SDL_aaudio.c). SDLSound.c opens two: one directly via
+ // SDL_OpenAudioDevice() for cutscene audio, one via Mix_OpenAudio() for
+ // SFX/MIDI. The older OpenSL ES backend has no such limitation, so force
+ // it instead of AAudio.
+ SDL_SetHint(SDL_HINT_AUDIODRIVER, "openslES");
+#endif
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER | SDL_INIT_AUDIO) < 0) {
DEBUG("%s: Init failed", __FUNCTION__);
}
+11
View File
@@ -46,6 +46,13 @@ ARG ANDROID_COMPILE_SDK_VERSION=34
ARG ANDROID_BUILD_TOOLS_VERSION=34.0.0 ARG ANDROID_BUILD_TOOLS_VERSION=34.0.0
ARG ANDROID_NDK_VERSION=26.1.10909125 ARG ANDROID_NDK_VERSION=26.1.10909125
ARG ANDROID_CMAKE_VERSION=3.22.1 ARG ANDROID_CMAKE_VERSION=3.22.1
# NDK 26 doesn't yet default to 16 KB-aligned ELF LOAD segments (that only
# became automatic in NDK 28+) - Google requires this for Play Store
# submissions targeting Android 15+ as of Nov 2025, and it's cheap/harmless
# to do regardless of Play Store status. Passed to every Android shared-lib
# CMake configure below, and to the engine's own build via
# android/app/build.gradle's externalNativeBuild.cmake.arguments.
ARG ANDROID_16KB_LDFLAGS="-Wl,-z,max-page-size=16384"
# GL4ES (https://github.com/ptitSeb/gl4es) - translates the engine's # GL4ES (https://github.com/ptitSeb/gl4es) - translates the engine's
# desktop-style immediate-mode OpenGL calls into real GLES/EGL calls, so # desktop-style immediate-mode OpenGL calls into real GLES/EGL calls, so
# engine/src/MacSrc/OpenGL.cc needs no immediate-mode rewrite on Android. # engine/src/MacSrc/OpenGL.cc needs no immediate-mode rewrite on Android.
@@ -182,6 +189,7 @@ RUN curl -sSLO "https://www.libsdl.org/release/SDL2-${ANDROID_SDL2_VERSION}.tar.
-DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \ -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \
-DCMAKE_INSTALL_PREFIX=/opt/prebuilt/android/sdl2 -DBUILD_SHARED_LIBS=ON \ -DCMAKE_INSTALL_PREFIX=/opt/prebuilt/android/sdl2 -DBUILD_SHARED_LIBS=ON \
-DSDL_STATIC=OFF \ -DSDL_STATIC=OFF \
-DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \
&& cmake --build build-sdl2-android -j"$(nproc)" \ && cmake --build build-sdl2-android -j"$(nproc)" \
&& cmake --install build-sdl2-android \ && cmake --install build-sdl2-android \
&& rm -rf "SDL2-${ANDROID_SDL2_VERSION}" "SDL2-${ANDROID_SDL2_VERSION}.tar.gz" build-sdl2-android && rm -rf "SDL2-${ANDROID_SDL2_VERSION}" "SDL2-${ANDROID_SDL2_VERSION}.tar.gz" build-sdl2-android
@@ -207,6 +215,7 @@ RUN curl -sSLO "https://www.libsdl.org/projects/SDL_mixer/release/SDL2_mixer-${A
-DSDL2MIXER_FLAC=OFF -DSDL2MIXER_GME=OFF -DSDL2MIXER_MOD=OFF \ -DSDL2MIXER_FLAC=OFF -DSDL2MIXER_GME=OFF -DSDL2MIXER_MOD=OFF \
-DSDL2MIXER_MP3=OFF -DSDL2MIXER_MIDI=OFF -DSDL2MIXER_OPUS=OFF -DSDL2MIXER_VORBIS=OFF \ -DSDL2MIXER_MP3=OFF -DSDL2MIXER_MIDI=OFF -DSDL2MIXER_OPUS=OFF -DSDL2MIXER_VORBIS=OFF \
-DSDL2MIXER_WAVPACK=OFF \ -DSDL2MIXER_WAVPACK=OFF \
-DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \
&& cmake --build build-sdl2mixer-android -j"$(nproc)" \ && cmake --build build-sdl2mixer-android -j"$(nproc)" \
&& cmake --install build-sdl2mixer-android \ && cmake --install build-sdl2mixer-android \
&& rm -rf "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}" "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}.tar.gz" build-sdl2mixer-android && rm -rf "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}" "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}.tar.gz" build-sdl2mixer-android
@@ -222,6 +231,7 @@ RUN git clone https://github.com/EtherTyper/fluidsynth-lite.git fluidsynth-lite-
&& cmake -S fluidsynth-lite-android -B build-fluidsynth-android \ && cmake -S fluidsynth-lite-android -B build-fluidsynth-android \
-DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \ -DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \
-DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \ -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \
-DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \
&& cmake --build build-fluidsynth-android -j"$(nproc)" \ && cmake --build build-fluidsynth-android -j"$(nproc)" \
&& mkdir -p /opt/prebuilt/android/fluidsynth-lite/lib /opt/prebuilt/android/fluidsynth-lite/include \ && mkdir -p /opt/prebuilt/android/fluidsynth-lite/lib /opt/prebuilt/android/fluidsynth-lite/include \
&& cp -a build-fluidsynth-android/src/libfluidsynth.so* /opt/prebuilt/android/fluidsynth-lite/lib/ \ && cp -a build-fluidsynth-android/src/libfluidsynth.so* /opt/prebuilt/android/fluidsynth-lite/lib/ \
@@ -254,6 +264,7 @@ RUN git clone --branch "v${ANDROID_GL4ES_VERSION}" --depth 1 \
-DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \ -DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \
-DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \ -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \
-DANDROID=ON -DUSE_ANDROID_LOG=ON -DSTATICLIB=OFF \ -DANDROID=ON -DUSE_ANDROID_LOG=ON -DSTATICLIB=OFF \
-DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \
&& cmake --build build-gl4es-android -j"$(nproc)" \ && cmake --build build-gl4es-android -j"$(nproc)" \
&& mkdir -p /opt/prebuilt/android/gl4es/lib /opt/prebuilt/android/gl4es/include \ && mkdir -p /opt/prebuilt/android/gl4es/lib /opt/prebuilt/android/gl4es/include \
&& cp -a gl4es-android/lib/libGL.so.1 /opt/prebuilt/android/gl4es/lib/libGL.so \ && cp -a gl4es-android/lib/libGL.so.1 /opt/prebuilt/android/gl4es/lib/libGL.so \
Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB