Add a QuestShock startup splash; rename the app and data folder
build / build (push) Successful in 2m35s

This commit is contained in:
ml
2026-09-20 16:49:28 +02:00
parent 5d7564c3b5
commit 9586c58a67
17 changed files with 245 additions and 30 deletions
+3 -2
View File
@@ -107,6 +107,7 @@ def stageEngineTask = tasks.register("stageEngine", Exec) {
// Staged into assets/ by prepare-android-project.sh.
inputs.file("${repoRoot}/res/questshock.config.default.json")
inputs.dir("${repoRoot}/res/controller_profiles")
inputs.file("${repoRoot}/res/splash.png")
outputs.dir("${repoRoot}/build/android-engine")
outputs.dir("${repoRoot}/build/android-gl4es")
outputs.dir("${repoRoot}/build/android-prebuilt")
@@ -138,7 +139,7 @@ android {
// 29, not compileSdk's 34, is deliberate - see build-image/Dockerfile's
// comment on ANDROID_PLATFORM_VERSION: paired with
// requestLegacyExternalStorage in the manifest, this keeps
// /sdcard/questshock/ a plain, unrestricted shared folder instead of
// /sdcard/QuestShock/ a plain, unrestricted shared folder instead of
// Android 11+ scoped storage, on the Quest 2/3/3S/Pro headsets this
// targets - not just whatever's the current API level today.
minSdkVersion 24
@@ -199,7 +200,7 @@ android {
"-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;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c;${projectDir}/src/main/cpp/xr_mouse.c;${projectDir}/src/main/cpp/qs_config.c"
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c;${projectDir}/src/main/cpp/xr_session.c;${projectDir}/src/main/cpp/xr_input.c;${projectDir}/src/main/cpp/xr_overlay.c;${projectDir}/src/main/cpp/xr_swapchain.c;${projectDir}/src/main/cpp/xr_mouse.c;${projectDir}/src/main/cpp/qs_config.c;${projectDir}/src/main/cpp/qs_splash.c"
abiFilters 'arm64-v8a'
}
}
+9 -7
View File
@@ -38,6 +38,11 @@
OpenXR session to actually start. -->
<meta-data android:name="com.oculus.vr.focusaware" android:value="true" />
<!-- Meta-specific: the headsets this app is built for. Other OpenXR
runtimes ignore it. -->
<meta-data android:name="com.oculus.supportedDevices"
android:value="quest2|questpro|quest3|quest3s" />
<activity android:name="de.ladkau.questshock.QuestShockActivity"
android:label="@string/app_name"
android:alwaysRetainTaskState="true"
@@ -51,13 +56,10 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- The vendor-neutral, Khronos-defined way to declare an
immersive OpenXR entry point (vs. e.g. Meta's own
com.oculus.intent.category.VR) - see this project's
"favor OpenXR" rule in the README. If Horizon OS still
launches this as a flat panel on-device, the next thing
to try is adding a com.oculus.supportedDevices meta-data
listing the target Quest models - not confirmed
necessary from static analysis alone. -->
immersive OpenXR entry point, plus Meta's own VR
category, which Horizon OS looks for to treat the app as
a VR app. Other runtimes ignore the Meta one. -->
<category android:name="com.oculus.intent.category.VR" />
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
</intent-filter>
<intent-filter>
+1 -1
View File
@@ -10,7 +10,7 @@ extern "C" {
// Persistent, human-editable JSON config file - see CONFIG.md at the repo
// root for the documented schema. Lives at a plain relative path
// ("questshock.config.json") since the whole engine already runs with its
// cwd chdir()'d to the user-facing /sdcard/questshock/ folder (see
// cwd chdir()'d to the user-facing /sdcard/QuestShock/ folder (see
// QuestShockActivity.java's nativeChdir()) before main() ever runs - the
// same way prefs.txt/keybinds.txt (Prefs.c) already do.
//
+110
View File
@@ -0,0 +1,110 @@
#include "qs_splash.h"
#include <SDL.h>
#include <android/log.h>
#include <stdbool.h>
#include <string.h>
#include "xr_session.h"
#define QS_SPLASH_HOLD_MS 2500
#define QS_SPLASH_FADE_MS 500
#define QS_SPLASH_TOTAL_MS (QS_SPLASH_HOLD_MS + 2 * QS_SPLASH_FADE_MS)
// The XR session only starts once frames are polled, so the display time is
// counted from the first submitted frame; this bounds the wait for it.
#define QS_SPLASH_SESSION_TIMEOUT_MS 5000
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "QuestShock", __VA_ARGS__)
extern void pump_events(void);
// OpenGL.cc (engine patch 11): draws a software frame into the XR game swapchain.
extern void android_composite_software_frame(SDL_Surface *ui);
static SDL_Surface *s_image = NULL;
void qs_splash_set_image(const uint32_t *argb, int width, int height) {
if (s_image != NULL) {
SDL_FreeSurface(s_image);
s_image = NULL;
}
if (argb == NULL || width <= 0 || height <= 0)
return;
s_image = SDL_CreateRGBSurfaceWithFormat(0, width, height, 32, SDL_PIXELFORMAT_ARGB8888);
if (s_image == NULL)
return;
for (int y = 0; y < height; y++)
memcpy((uint8_t *)s_image->pixels + (size_t)y * s_image->pitch, argb + (size_t)y * width,
(size_t)width * sizeof(uint32_t));
SDL_SetSurfaceBlendMode(s_image, SDL_BLENDMODE_BLEND);
}
// 0-255 brightness at `elapsed` ms after the first frame.
static int qs_splash_level(uint32_t elapsed) {
uint32_t edge = elapsed < QS_SPLASH_TOTAL_MS - elapsed ? elapsed : QS_SPLASH_TOTAL_MS - elapsed;
if (edge >= QS_SPLASH_FADE_MS)
return 255;
return (int)(255 * edge / QS_SPLASH_FADE_MS);
}
void qs_splash_show(void) {
if (s_image == NULL) {
LOGI("Startup splash: no image");
return;
}
int width, height;
xr_get_game_swapchain_size(&width, &height);
if (width <= 0 || height <= 0) {
LOGI("Startup splash: no XR game swapchain");
return;
}
// The image is stretched over the whole swapchain image, so it is
// letterboxed into a canvas of that size to keep its aspect ratio.
SDL_Surface *canvas =
SDL_CreateRGBSurfaceWithFormat(0, width, height, 32, SDL_PIXELFORMAT_RGBA32);
if (canvas == NULL)
return;
const float scale = SDL_min((float)width / s_image->w, (float)height / s_image->h);
SDL_Rect dst;
dst.w = (int)(s_image->w * scale);
dst.h = (int)(s_image->h * scale);
dst.x = (width - dst.w) / 2;
dst.y = (height - dst.h) / 2;
const uint32_t waitStart = SDL_GetTicks();
uint32_t shownStart = 0;
uint32_t elapsed = 0;
int drawnLevel = -1;
bool done = false;
while (!done) {
pump_events();
xr_poll_events();
if (xr_frame_begin()) {
if (shownStart == 0)
shownStart = SDL_GetTicks();
elapsed = SDL_GetTicks() - shownStart;
if (elapsed >= QS_SPLASH_TOTAL_MS) {
elapsed = QS_SPLASH_TOTAL_MS;
done = true;
}
const int level = qs_splash_level(elapsed);
if (level != drawnLevel) {
drawnLevel = level;
SDL_FillRect(canvas, NULL, SDL_MapRGB(canvas->format, 0, 0, 0));
SDL_SetSurfaceColorMod(s_image, level, level, level);
SDL_BlitScaled(s_image, NULL, canvas, &dst);
}
android_composite_software_frame(canvas);
} else {
SDL_Delay(10);
if (shownStart == 0 && SDL_GetTicks() - waitStart > QS_SPLASH_SESSION_TIMEOUT_MS)
done = true;
}
xr_frame_end();
}
LOGI("Startup splash: %s after %u ms", shownStart != 0 ? "shown" : "XR session did not start",
(unsigned)(SDL_GetTicks() - waitStart));
SDL_FreeSurface(canvas);
}
+15
View File
@@ -0,0 +1,15 @@
// Startup splash: the QuestShock title, shown on the game screen before the
// engine's own splash sequence.
#pragma once
#include <stdint.h>
// Stores a copy of the splash image (straight-alpha ARGB, one int per pixel).
// Called from JNI before the engine starts; replaces any earlier image. Without
// an image qs_splash_show() does nothing.
void qs_splash_set_image(const uint32_t *argb, int width, int height);
// Engine thread only (called from engine patch 27). Fades the image in and out
// on the game screen and returns when done, or after a few seconds if the XR
// session never starts.
void qs_splash_show(void);
@@ -11,6 +11,7 @@
#include <SDL.h>
#include "qs_config.h"
#include "qs_splash.h"
#include "xr_input.h"
#include "xr_overlay.h"
#include "xr_session.h"
@@ -22,6 +23,15 @@ Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass cla
(*env)->ReleaseStringUTFChars(env, path, cpath);
}
JNIEXPORT void JNICALL Java_de_ladkau_questshock_QuestShockActivity_nativeSetSplashImage(
JNIEnv *env, jclass clazz, jintArray pixels, jint width, jint height) {
jint *data = (*env)->GetIntArrayElements(env, pixels, NULL);
if (data == NULL)
return;
qs_splash_set_image((const uint32_t *)data, width, height);
(*env)->ReleaseIntArrayElements(env, pixels, data, JNI_ABORT);
}
// KeyboardOverlay's hand-built on-screen keyboard (see
// KeyboardOverlay.handleCharKeyDown()/handleCharKeyUp()) has no real
// Android IME session behind it, so printable characters can't reach the
+5
View File
@@ -871,6 +871,11 @@ void xr_get_game_quad_extent(float *distance_m, float *half_width_m, float *half
}
}
void xr_get_game_swapchain_size(int *width, int *height) {
*width = g_game_swapchain.width;
*height = g_game_swapchain.height;
}
void xr_get_game_resolution(int *width, int *height) {
// Not g_game_swapchain.width/height: that's the fixed physical size of
// the swapchain image the game is composited into (see xr_init()) -
+6
View File
@@ -102,6 +102,12 @@ void xr_session_set_game_screen_shape(float width_m, float curve_deg, float dist
// swapchain.
void xr_get_game_resolution(int *width, int *height);
// Pixel size of the game quad's swapchain image, fixed at launch (0, 0 before
// xr_init()). The quad's aspect ratio is this size, so full-quad content
// drawn outside the engine's own frame path (the startup splash) is laid out
// against it.
void xr_get_game_swapchain_size(int *width, int *height);
// Exponentially-smoothed frame rate/frame time, updated once per real
// rendered frame in xr_frame_end() - read by MenuOverlay's header (see
// questshock_native.c's nativeGetFps()/nativeGetFrameTimeMs()). 0 before
@@ -5,6 +5,8 @@ import android.app.AlertDialog;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Bundle;
import android.util.Log;
import android.view.SurfaceHolder;
@@ -18,7 +20,7 @@ import org.libsdl.app.SDLActivity;
import org.libsdl.app.SDLSurface;
/**
* Prepares /sdcard/questshock/ the way Shockolate's plain relative-path
* 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
@@ -40,7 +42,7 @@ public class QuestShockActivity extends SDLActivity {
private static final String TAG = "QuestShock";
private static final int PERMISSION_REQUEST_STORAGE = 1;
// Package-visible: MenuOverlay reads controller_profiles/ from here.
static final String GAME_DIR = "/sdcard/questshock";
static final String GAME_DIR = "/sdcard/QuestShock";
// Loaded here (rather than waiting for SDLActivity's own
// getLibraries()/loadLibraries(), which only runs from super.onCreate())
@@ -58,6 +60,9 @@ public class QuestShockActivity extends SDLActivity {
// has no public chdir() (confirmed against the actual API 34 stub jar).
private static native void nativeChdir(String path);
// Straight-alpha ARGB pixels of the startup splash (qs_splash.c).
private static native void nativeSetSplashImage(int[] pixels, int width, int height);
@Override
protected String[] getLibraries() {
return new String[] {
@@ -187,11 +192,31 @@ public class QuestShockActivity extends SDLActivity {
// before clearing mBrokenLibraries below so the engine can never
// start pre-chdir.
nativeChdir(GAME_DIR);
loadSplashImage();
// Assets confirmed present - safe to let the native engine start now.
SDLActivity.mBrokenLibraries = false;
}
// The engine shows this at startup (qs_splash.c); without it there is no splash.
private void loadSplashImage() {
try (InputStream in = getAssets().open("splash.png")) {
Bitmap bitmap = BitmapFactory.decodeStream(in);
if (bitmap == null) {
Log.e(TAG, "Could not decode splash.png");
return;
}
int width = bitmap.getWidth();
int height = bitmap.getHeight();
int[] pixels = new int[width * height];
bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
bitmap.recycle();
nativeSetSplashImage(pixels, width, height);
} catch (IOException e) {
Log.e(TAG, "Could not read splash.png", e);
}
}
private static boolean isNonEmptyDir(File dir) {
String[] entries = dir.list();
return entries != null && entries.length > 0;
@@ -219,7 +244,7 @@ public class QuestShockActivity extends SDLActivity {
+ "merging or extraction required.\n\n"
+ "Then relaunch.\n\n"
+ "(These instructions are also in GET_ASSETS_QUEST.txt in the "
+ "questshock folder on this device, if you need to read them again.)")
+ "QuestShock folder on this device, if you need to read them again.)")
.setCancelable(false)
.setPositiveButton("Exit", (dialog, which) -> finish())
.create()
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">System Shock</string>
<string name="app_name">QuestShock</string>
</resources>