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
@@ -2,10 +2,12 @@ package de.ladkau.questshock;
import android.Manifest;
import android.app.AlertDialog;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.os.Bundle;
import android.util.Log;
import android.view.SurfaceHolder;
import androidx.core.app.ActivityCompat;
import java.io.File;
import java.io.FileOutputStream;
@@ -13,16 +15,26 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import org.libsdl.app.SDLActivity;
import org.libsdl.app.SDLSurface;
/**
* Everything here runs before super.onCreate() (which is what loads the
* native libraries and eventually calls Shockolate's own main()) - see
* android/engine-patches/ for why: engine/ itself is never modified, so
* this Java-side setup is the only place left to prepare
* /sdcard/questshock/ the way Shockolate's plain relative-path file I/O
* ("res/data/...", confirmed via grep - none of it goes through SDL_RWops,
* so Android's asset-manager fallback for SDL_RWFromFile doesn't apply
* here) expects to find it.
* Prepares /sdcard/questshock/ the way Shockolate's plain relative-path
* file I/O ("res/data/...", confirmed via grep - none of it goes through
* SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile
* doesn't apply here) expects to find it - see android/engine-patches/ for
* why this is done from Java instead of patching engine/ itself, which is
* never modified.
*
* 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 {
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).
private static native void nativeChdir(String path);
private Bundle mSavedInstanceState;
@Override
protected String[] getLibraries() {
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
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)
!= PackageManager.PERMISSION_GRANTED) {
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
// fails deep inside startup (init_popups(), which doesn't NULL-check
// the load) as a hard native crash instead of a message. Catch the
// missing-data case here instead, before super.onCreate() ever
// starts Shockolate's native main().
// missing-data case here instead, before Shockolate's native
// SDL_main ever starts.
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();
return;
}
// chdir() is process-wide, not per-thread - already in effect for
// 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);
super.onCreate(mSavedInstanceState);
// Assets confirmed present - safe to let the native engine start now.
SDLActivity.mBrokenLibraries = false;
}
private static boolean isNonEmptyDir(File dir) {