diff --git a/CONFIG.md b/CONFIG.md new file mode 100644 index 0000000..b124931 --- /dev/null +++ b/CONFIG.md @@ -0,0 +1,71 @@ +# Persistent configuration (`questshock.config.json`) + +QuestShock can load a persistent, human-editable configuration file at +startup, in addition to (and independent of) the engine's own `prefs.txt`. + +- **Location**: `/sdcard/questshock/questshock.config.json` - the same + folder you already copy `res/data`/`res/sound` into (see README.md + section 5.1). +- **Format**: plain JSON, grouped by concern. Each top-level key is a + *group*; each key inside a group is one *option*. +- **Loading**: read automatically at app startup, before the splash screen, + if the file exists. If it doesn't exist, nothing changes - the engine + behaves exactly as it did before this file existed (driven by `prefs.txt` + as usual). +- **Saving**: never automatic. In-headset, open the menu (controller menu + button) and press **Save Configuration** to write the file from your + current live settings, overwriting it if present. Press **Load + Configuration** to re-read the file and apply it without restarting the + app. +- **Live vs. restart-required**: every option below is tagged as either + *live-appliable* (applying it while already playing takes effect + immediately) or *restart-required*. Pressing **Load Configuration** + applies every live-appliable change straight away; if the file also + changes something restart-required, a dialog lists exactly which + option(s) need it and offers: + - **Dismiss** - keep playing with the live-appliable changes already + applied; the restart-required option(s) stay as they were. + - **Restart Now** - relaunches the app, which re-runs this same startup + load and so applies every group in the file, including the + restart-required ones. + + (Both options currently documented below are live-appliable, so this + dialog won't actually appear yet - it's built in for options added in a + future version that do need a restart.) +- Unrecognized groups/keys are ignored (harmless if you're editing the file + in advance for a future version). An invalid value for a *specific* + option (see each option's possible values below) is logged and that one + option is skipped - every other valid option in the file still applies. + A file that isn't valid JSON at all is ignored entirely, with nothing + applied. +- `questshock.config.json` and `prefs.txt` are fully independent - loading + or saving one never reads or writes the other. + +## Example + +```json +{ + "video_options": { + "renderer": "opengl", + "resolution": "1024x768" + } +} +``` + +## Video Options (`video_options`) + +### `renderer` + +Which renderer the game uses to draw the 3D view. + +- **Type**: enum +- **Possible values**: `"software"`, `"opengl"` +- **Restart required?**: No (live-appliable) + +### `resolution` + +The game's internal rendering resolution. + +- **Type**: enum +- **Possible values**: `"320x200"`, `"320x400"`, `"640x400"`, `"640x480"`, `"1024x768"` +- **Restart required?**: No (live-appliable) diff --git a/README.md b/README.md index 1c3aa17..9f1dbfb 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,11 @@ debugging enabled.) `/sdcard/questshock/res/`. 4. Launch it again. +`/sdcard/questshock/` also holds an optional persistent settings file, +`questshock.config.json`, loaded automatically at startup and saved/loaded +from the in-headset menu - see [CONFIG.md](CONFIG.md) for the documented +options. + ### 5.2. Building natively in Android Studio `make apk` always compiles the engine and links the APK inside the @@ -508,6 +513,11 @@ each one does: goes through the OpenXR swapchain's own FBOs, set up separately in `xr_swapchain.c`) and is also functionally dead there regardless - skipped entirely, Android-only. +- **`21-android-config.patch`** - hooks up the persistent JSON config file + (see [CONFIG.md](CONFIG.md), `qs_config.c`): one call to + `qs_config_load_startup()` in `Shock.c`'s `main()`, right after + `LoadPrefs()`, and one check in `sdl_events.c`'s `pump_events()` to drain + the custom SDL events the menu's Save/Load Configuration buttons enqueue. `android/gl4es-patches/`: diff --git a/android/app/build.gradle b/android/app/build.gradle index 6f91496..05fd060 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -171,7 +171,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" + "-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" abiFilters 'arm64-v8a' } } diff --git a/android/app/src/main/cpp/qs_config.c b/android/app/src/main/cpp/qs_config.c new file mode 100644 index 0000000..fb114b9 --- /dev/null +++ b/android/app/src/main/cpp/qs_config.c @@ -0,0 +1,613 @@ +// Persistent, human-editable JSON config file - see qs_config.h and +// CONFIG.md (repo root) for the documented schema and behavior. This file +// deliberately touches engine internals directly (gShockPrefs, mode_id, +// change_svga_screen_mode(), message_info()) - unlike xr_session.c/ +// xr_input.c/xr_swapchain.c, which stay purely XR/SDL-facing and never +// reach into engine state this way. It's still compiled into the same +// target as the rest of the engine (see android/app/build.gradle's +// ANDROID_EXTRA_SOURCES, same as those files), so engine headers resolve +// via the same include_directories() every engine .c file already uses. + +#include +#include +#include +#include +#include + +#include +#include + +#include "qs_config.h" + +#include "Prefs.h" +#include "fullscrn.h" +#include "lg.h" +#include "tools.h" + +#define QS_CONFIG_FILENAME "questshock.config.json" + +// =========================================================================== +// Minimal JSON parser +// +// Just enough to read this file's own schema (nested one level: group -> +// string-valued option) while safely skipping over anything it doesn't +// recognize - unknown keys, wrong-typed values, arrays, nested objects - so +// a hand-edited file with extra/future content never breaks parsing of the +// parts this version does care about. No third-party JSON library exists +// anywhere in this repo; this matches the project's own existing style of a +// small hand-rolled parser for its persisted files (see Prefs.c's prefs.txt/ +// keybinds.txt parsing). +// +// `error` is sticky: once any function below sets it, every other parser +// function becomes a no-op returning false, so a syntax error anywhere +// cleanly aborts the whole parse rather than continuing from a +// cursor position left in an indeterminate spot by the failure. +// =========================================================================== + +typedef struct { + const char *p; + const char *end; + bool error; +} QsJsonParser; + +static void qs_json_fail(QsJsonParser *p) { p->error = true; } + +static void qs_json_skip_ws(QsJsonParser *p) { + while (!p->error && p->p < p->end && isspace((unsigned char)*p->p)) + p->p++; +} + +static bool qs_json_expect(QsJsonParser *p, char c) { + if (p->error || p->p >= p->end || *p->p != c) { + qs_json_fail(p); + return false; + } + p->p++; + return true; +} + +// Parses (and unescapes \" \\ \/ \n \t \r \b \f) a JSON string literal at +// the cursor into out, NUL-terminated, truncated (not treated as an error) +// if longer than out_size - 1 bytes. out may be NULL (e.g. when skipping a +// string value we don't care about) - the bytes are simply discarded. +// \uXXXX escapes aren't supported (not needed for anything this file writes +// or a realistic hand edit) and fail the parse like any other malformed +// input. +static bool qs_json_parse_string(QsJsonParser *p, char *out, size_t out_size) { + if (p->error || !qs_json_expect(p, '"')) + return false; + size_t n = 0; + while (p->p < p->end && *p->p != '"') { + char c = *p->p; + if (c == '\\') { + p->p++; + if (p->p >= p->end) { + qs_json_fail(p); + return false; + } + switch (*p->p) { + case '"': + c = '"'; + break; + case '\\': + c = '\\'; + break; + case '/': + c = '/'; + break; + case 'n': + c = '\n'; + break; + case 't': + c = '\t'; + break; + case 'r': + c = '\r'; + break; + case 'b': + c = '\b'; + break; + case 'f': + c = '\f'; + break; + default: + qs_json_fail(p); + return false; + } + } + if (out && n + 1 < out_size) + out[n++] = c; + p->p++; + } + if (p->p >= p->end) { + qs_json_fail(p); + return false; + } + p->p++; // closing quote + if (out) + out[n] = '\0'; + return true; +} + +static bool qs_json_skip_number(QsJsonParser *p) { + const char *start = p->p; + if (p->p < p->end && *p->p == '-') + p->p++; + while (p->p < p->end && (isdigit((unsigned char)*p->p) || *p->p == '.' || *p->p == 'e' || + *p->p == 'E' || *p->p == '+' || *p->p == '-')) + p->p++; + if (p->p == start) { + qs_json_fail(p); + return false; + } + return true; +} + +static bool qs_json_skip_literal(QsJsonParser *p, const char *literal) { + size_t len = strlen(literal); + if ((size_t)(p->end - p->p) < len || strncmp(p->p, literal, len) != 0) { + qs_json_fail(p); + return false; + } + p->p += len; + return true; +} + +static bool qs_json_skip_value(QsJsonParser *p); + +typedef void (*QsJsonFieldCallback)(const char *key, QsJsonParser *p, void *ctx); + +// Parses a JSON object (cursor at '{'). For each "key": value pair, calls +// field_cb with the cursor positioned right after the colon - field_cb must +// fully consume that value itself, one way or another (see +// qs_json_take_string_field() below), since this function only ever +// advances past the key + colon on its own. +static bool qs_json_parse_object(QsJsonParser *p, QsJsonFieldCallback field_cb, void *ctx) { + if (p->error || !qs_json_expect(p, '{')) + return false; + qs_json_skip_ws(p); + if (!p->error && p->p < p->end && *p->p == '}') { + p->p++; + return true; + } + while (!p->error) { + char key[64]; + if (!qs_json_parse_string(p, key, sizeof(key))) + return false; + qs_json_skip_ws(p); + if (!qs_json_expect(p, ':')) + return false; + qs_json_skip_ws(p); + field_cb(key, p, ctx); + qs_json_skip_ws(p); + if (p->error || p->p >= p->end) { + qs_json_fail(p); + return false; + } + if (*p->p == ',') { + p->p++; + qs_json_skip_ws(p); + continue; + } + if (*p->p == '}') { + p->p++; + return true; + } + qs_json_fail(p); + return false; + } + return false; +} + +static bool qs_json_skip_array(QsJsonParser *p) { + if (p->error || !qs_json_expect(p, '[')) + return false; + qs_json_skip_ws(p); + if (!p->error && p->p < p->end && *p->p == ']') { + p->p++; + return true; + } + while (!p->error) { + if (!qs_json_skip_value(p)) + return false; + qs_json_skip_ws(p); + if (p->error || p->p >= p->end) { + qs_json_fail(p); + return false; + } + if (*p->p == ',') { + p->p++; + qs_json_skip_ws(p); + continue; + } + if (*p->p == ']') { + p->p++; + return true; + } + qs_json_fail(p); + return false; + } + return false; +} + +// field_cb for qs_json_parse_object() when it's only being used to skip a +// nested object we don't recognize - every field's value is itself skipped. +static void qs_json_skip_field(const char *key, QsJsonParser *p, void *ctx) { + (void)key; + (void)ctx; + qs_json_skip_value(p); +} + +static bool qs_json_skip_value(QsJsonParser *p) { + if (p->error) + return false; + qs_json_skip_ws(p); + if (p->p >= p->end) { + qs_json_fail(p); + return false; + } + switch (*p->p) { + case '"': + return qs_json_parse_string(p, NULL, 0); + case '{': + return qs_json_parse_object(p, qs_json_skip_field, NULL); + case '[': + return qs_json_skip_array(p); + case 't': + return qs_json_skip_literal(p, "true"); + case 'f': + return qs_json_skip_literal(p, "false"); + case 'n': + return qs_json_skip_literal(p, "null"); + default: + return qs_json_skip_number(p); + } +} + +// Tries to parse the value at the cursor as a string into out (truncating +// safely if too long); if it isn't a string at all, skips it instead so the +// parse can continue past this field regardless of a wrong-typed value in +// the file. Either way, fully consumes exactly one value. +static void qs_json_take_string_field(QsJsonParser *p, char *out, size_t out_size) { + if (!p->error && p->p < p->end && *p->p == '"') + qs_json_parse_string(p, out, out_size); + else + qs_json_skip_value(p); +} + +// =========================================================================== +// Schema: "video_options" group +// =========================================================================== + +typedef struct { + char renderer[16]; // "" = not present in the file + char resolution[16]; // "" = not present in the file +} QsParsedVideoOptions; + +typedef struct { + QsParsedVideoOptions video_options; + bool has_video_options; +} QsParsedConfig; + +static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void *ctx) { + QsParsedVideoOptions *out = (QsParsedVideoOptions *)ctx; + if (strcmp(key, "renderer") == 0) + qs_json_take_string_field(p, out->renderer, sizeof(out->renderer)); + else if (strcmp(key, "resolution") == 0) + qs_json_take_string_field(p, out->resolution, sizeof(out->resolution)); + else + qs_json_skip_value(p); +} + +static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx) { + QsParsedConfig *out = (QsParsedConfig *)ctx; + if (strcmp(key, "video_options") == 0 && !p->error && p->p < p->end && *p->p == '{') + out->has_video_options = qs_json_parse_object(p, qs_parse_video_options_field, &out->video_options); + else + qs_json_skip_value(p); +} + +// Reads and parses QS_CONFIG_FILENAME into *out. Returns false (with *out +// zeroed) if the file doesn't exist, is empty, or fails to parse as valid +// JSON - logging a WARN only for the last case, since a missing file is the +// normal/expected state, not an error. +static bool qs_config_read_file(QsParsedConfig *out) { + memset(out, 0, sizeof(*out)); + FILE *f = fopen(QS_CONFIG_FILENAME, "rb"); + if (!f) + return false; + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + if (size <= 0) { + fclose(f); + return false; + } + char *buf = (char *)malloc((size_t)size); + if (!buf) { + fclose(f); + return false; + } + size_t got = fread(buf, 1, (size_t)size, f); + fclose(f); + + QsJsonParser parser = {buf, buf + got, false}; + qs_json_skip_ws(&parser); + bool ok = qs_json_parse_object(&parser, qs_parse_top_level_field, out) && !parser.error; + free(buf); + if (!ok) { + WARN("%s: malformed JSON - ignoring file", QS_CONFIG_FILENAME); + memset(out, 0, sizeof(*out)); + return false; + } + return true; +} + +// =========================================================================== +// Video Options: renderer +// =========================================================================== + +// Already proven live-safe by the existing Ctrl+G renderer-toggle hotkey +// (toggle_opengl(), OpenGL.cc), which does nothing more than flip this same +// field - every render-mode check elsewhere in the engine reads +// gShockPrefs.doUseOpenGL directly, every frame, so no extra "apply" step is +// needed beyond setting it. +#define QS_RENDERER_REQUIRES_RESTART false + +static bool qs_apply_renderer(const char *value, bool live) { + (void)live; + bool want_opengl; + if (strcmp(value, "opengl") == 0) { + want_opengl = true; + } else if (strcmp(value, "software") == 0) { + want_opengl = false; + } else { + WARN("%s: video_options.renderer=\"%s\" is invalid (expected \"software\" or \"opengl\") - ignoring", + QS_CONFIG_FILENAME, value); + return false; + } + bool changed = gShockPrefs.doUseOpenGL != want_opengl; + gShockPrefs.doUseOpenGL = want_opengl; + return changed; +} + +static const char *qs_current_renderer(void) { return gShockPrefs.doUseOpenGL ? "opengl" : "software"; } + +// =========================================================================== +// Video Options: resolution +// =========================================================================== + +// Live-appliable: change_svga_screen_mode() (below) handles a real mode_id +// change fully, including reallocating the screen buffer - see +// qs_apply_resolution()'s own comment for the one extra step that takes +// (screen_draw()) that a naive call site doesn't get for free. +#define QS_RESOLUTION_REQUIRES_RESTART false + +// mode_id (fullscrn.c) is the index change_svga_screen_mode() actually +// reads - matches the same field Prefs.c's own SavePrefs()/SetShockGlobals() +// read/write for gShockPrefs.doVideoMode. +extern short mode_id; + +// screen.c - declared locally (game_screen.h's other declarations pull in +// UI-region types this file has no other reason to need) rather than +// #include "game_screen.h", matching how mode_id above is declared. +extern errtype screen_draw(void); + +static const struct { + const char *name; + short mode; +} kResolutions[] = { + {"320x200", 0}, {"320x400", 1}, {"640x400", 2}, {"640x480", 3}, {"1024x768", 4}, +}; +#define QS_NUM_RESOLUTIONS (sizeof(kResolutions) / sizeof(kResolutions[0])) + +static bool qs_apply_resolution(const char *value, bool live) { + for (size_t i = 0; i < QS_NUM_RESOLUTIONS; i++) { + if (strcmp(value, kResolutions[i].name) != 0) + continue; + bool changed = mode_id != kResolutions[i].mode; + gShockPrefs.doVideoMode = kResolutions[i].mode; + mode_id = kResolutions[i].mode; + if (changed && live) { + change_svga_screen_mode(); + // change_svga_screen_mode() only reallocates the screen buffer + // (gr_alloc_screen()/gr_set_screen(), fullscrn.c) when mode_id + // actually differs from its own last-applied value + // (convert_use_mode) - true here, since changed is set above. + // screen.c's screen_start() (the normal "enter the 3D game + // screen" path, and the only other caller that hits this same + // branch) always calls screen_draw() immediately afterward to + // repaint the background/MFD/side-icon/inventory/status chrome + // onto whatever buffer is now current; nothing else does, so a + // mid-session mode-index change like this one otherwise leaves + // that chrome blank (all still functional/redrawing correctly + // frame to frame - automap content, inventory grid values, + // etc. - just missing the static art around them) until the + // next full mode transition. screen_draw() itself doesn't + // assume any particular UI mode is active (automap/inventory/ + // main view), so it's safe to call regardless of which one the + // resolution change happened during. + screen_draw(); + } + return changed; + } + WARN("%s: video_options.resolution=\"%s\" is invalid - ignoring", QS_CONFIG_FILENAME, value); + return false; +} + +static const char *qs_current_resolution(void) { + for (size_t i = 0; i < QS_NUM_RESOLUTIONS; i++) + if (kResolutions[i].mode == mode_id) + return kResolutions[i].name; + return "640x480"; // shouldn't happen - safe fallback for Save +} + +// =========================================================================== +// Apply / Save orchestration +// =========================================================================== + +static void qs_append_restart_reason(char *buf, size_t buf_size, const char *name) { + if (!buf || buf_size == 0) + return; + size_t len = strlen(buf); + if (len > 0 && len + 2 < buf_size) { + strcat(buf, ", "); + len += 2; + } + if (len < buf_size - 1) + strncat(buf, name, buf_size - len - 1); +} + +// Applies every option present in *parsed (empty string = not present in +// the file - left untouched). live=false is the startup path (no restart +// distinction applies that early - see qs_config_load_startup()); live=true +// is the mid-session "Load Configuration" button path, where restart_needed +// (may be NULL) collects a human-readable, comma-separated list of every +// option that both actually changed and is flagged *_REQUIRES_RESTART. +static void qs_config_apply_parsed(const QsParsedConfig *parsed, bool live, char *restart_needed, + size_t restart_needed_size) { + if (restart_needed && restart_needed_size > 0) + restart_needed[0] = '\0'; + if (!parsed->has_video_options) + return; + if (parsed->video_options.renderer[0] != '\0') { + bool changed = qs_apply_renderer(parsed->video_options.renderer, live); + if (changed && QS_RENDERER_REQUIRES_RESTART) + qs_append_restart_reason(restart_needed, restart_needed_size, "renderer"); + } + if (parsed->video_options.resolution[0] != '\0') { + bool changed = qs_apply_resolution(parsed->video_options.resolution, live); + if (changed && QS_RESOLUTION_REQUIRES_RESTART) + qs_append_restart_reason(restart_needed, restart_needed_size, "resolution"); + } +} + +static bool qs_config_write_file(void) { + FILE *f = fopen(QS_CONFIG_FILENAME, "w"); + if (!f) { + WARN("%s: failed to open for writing", QS_CONFIG_FILENAME); + return false; + } + fprintf(f, "{\n"); + fprintf(f, " \"video_options\": {\n"); + fprintf(f, " \"renderer\": \"%s\",\n", qs_current_renderer()); + fprintf(f, " \"resolution\": \"%s\"\n", qs_current_resolution()); + fprintf(f, " }\n"); + fprintf(f, "}\n"); + fclose(f); + return true; +} + +// =========================================================================== +// Native -> Java: restart-required dialog +// =========================================================================== + +// Mirrors xr_overlay.c's own xr_overlay_find_class() helper (also file-local +// there) - FindClass() from this thread can't see app classes on its own +// since this thread was attached to the JVM via SDL_AndroidGetJNIEnv() +// rather than spawned from Java; the activity's own ClassLoader can. +static jclass qs_find_class(JNIEnv *env, jobject activity, const char *name) { + jclass activityClass = (*env)->GetObjectClass(env, activity); + jmethodID getClassLoader = + (*env)->GetMethodID(env, activityClass, "getClassLoader", "()Ljava/lang/ClassLoader;"); + jobject classLoader = (*env)->CallObjectMethod(env, activity, getClassLoader); + jclass classLoaderClass = (*env)->FindClass(env, "java/lang/ClassLoader"); + jmethodID loadClass = + (*env)->GetMethodID(env, classLoaderClass, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;"); + jstring className = (*env)->NewStringUTF(env, name); + jclass result = (jclass)(*env)->CallObjectMethod(env, classLoader, loadClass, className); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + return NULL; + } + return result; +} + +static void qs_show_restart_dialog(const char *restart_needed) { + JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv(); + jobject activity = (jobject)SDL_AndroidGetActivity(); + if (!env || !activity) + return; + + jclass menuOverlayClass = qs_find_class(env, activity, "de.ladkau.questshock.MenuOverlay"); + if (!menuOverlayClass) + return; + + jmethodID showRestartDialog = + (*env)->GetStaticMethodID(env, menuOverlayClass, "showRestartDialog", "(Ljava/lang/String;)V"); + if (!showRestartDialog) { + (*env)->ExceptionClear(env); + return; + } + + char message[256]; + snprintf(message, sizeof(message), + "The following changes require a restart to take effect: %s. Everything else has " + "already been applied.", + restart_needed); + jstring messageStr = (*env)->NewStringUTF(env, message); + (*env)->CallStaticVoidMethod(env, menuOverlayClass, showRestartDialog, messageStr); + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); +} + +// =========================================================================== +// Public API (see qs_config.h) +// =========================================================================== + +static Uint32 s_save_event_type; +static Uint32 s_load_event_type; + +void qs_config_load_startup(void) { + // Registers the custom SDL event types qs_config_request_save()/_load() + // enqueue - done here, once, on the engine's own thread and before the + // menu/its buttons could possibly exist yet, rather than lazily from + // those functions themselves (which run on Android's UI thread and + // could otherwise race each other doing this lazily). + Uint32 base = SDL_RegisterEvents(2); + s_save_event_type = base; + s_load_event_type = base + 1; + + QsParsedConfig parsed; + if (qs_config_read_file(&parsed)) + qs_config_apply_parsed(&parsed, /*live=*/false, NULL, 0); +} + +void qs_config_request_save(void) { + SDL_Event event; + SDL_zero(event); + event.type = s_save_event_type; + SDL_PushEvent(&event); +} + +void qs_config_request_load(void) { + SDL_Event event; + SDL_zero(event); + event.type = s_load_event_type; + SDL_PushEvent(&event); +} + +bool qs_config_handle_event(const SDL_Event *event) { + if (event->type == s_save_event_type) { + if (qs_config_write_file()) + message_info("Configuration saved"); + else + message_info("Failed to save configuration"); + return true; + } + if (event->type == s_load_event_type) { + QsParsedConfig parsed; + if (!qs_config_read_file(&parsed)) { + message_info("No configuration file to load"); + return true; + } + char restart_needed[128]; + qs_config_apply_parsed(&parsed, /*live=*/true, restart_needed, sizeof(restart_needed)); + if (restart_needed[0] != '\0') + qs_show_restart_dialog(restart_needed); + else + message_info("Configuration loaded"); + return true; + } + return false; +} diff --git a/android/app/src/main/cpp/qs_config.h b/android/app/src/main/cpp/qs_config.h new file mode 100644 index 0000000..2c60803 --- /dev/null +++ b/android/app/src/main/cpp/qs_config.h @@ -0,0 +1,52 @@ +#ifndef QUESTSHOCK_QS_CONFIG_H +#define QUESTSHOCK_QS_CONFIG_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// 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 +// QuestShockActivity.java's nativeChdir()) before main() ever runs - the +// same way prefs.txt/keybinds.txt (Prefs.c) already do. +// +// Deliberately independent of prefs.txt: this file is never read or written +// as a side effect of prefs.txt load/save, and vice versa. + +// Parses the config file if it exists (silent no-op if it doesn't - this +// never creates the file itself) and applies every recognized option +// directly into gShockPrefs/mode_id. Call once, early in main() - see +// android/engine-patches/21-android-config.patch - after LoadPrefs() (so the +// config file can override prefs.txt) and before init_all() (so there's no +// live-vs-restart distinction to worry about: nothing is initialized yet, so +// every field takes full effect as part of this same startup). +void qs_config_load_startup(void); + +// Called from the "Save Configuration"/"Load Configuration" button JNI +// handlers (questshock_native.c), which run on Android's UI thread. These +// only enqueue a custom SDL event - the actual file I/O and any +// gShockPrefs/mode_id/message_info() work happens on the engine's own SDL +// main thread instead (see qs_config_handle_event() below), matching this +// codebase's existing convention for crossing this exact thread boundary +// (xr_session.c's xr_poll_events(), questshock_native.c's SDL_TEXTINPUT +// push for keyboard input). +void qs_config_request_save(void); +void qs_config_request_load(void); + +// Returns true and handles the event if it's one of the two custom events +// qs_config_request_save()/_load() enqueue - called from pump_events() +// (sdl_events.c, see android/engine-patches/21-android-config.patch) on the +// engine's own thread, once per polled event, before its normal switch on +// well-known SDL_* event types. Returns false (no-op) for any other event +// type, including before the two custom types have even been registered. +bool qs_config_handle_event(const SDL_Event *event); + +#ifdef __cplusplus +} +#endif + +#endif // QUESTSHOCK_QS_CONFIG_H diff --git a/android/app/src/main/cpp/questshock_native.c b/android/app/src/main/cpp/questshock_native.c index 470a136..2aacd47 100644 --- a/android/app/src/main/cpp/questshock_native.c +++ b/android/app/src/main/cpp/questshock_native.c @@ -10,6 +10,7 @@ #include +#include "qs_config.h" #include "xr_overlay.h" #include "xr_session.h" @@ -88,3 +89,17 @@ JNIEXPORT void JNICALL Java_de_ladkau_questshock_MenuOverlay_nativeShowKeyboard(JNIEnv *env, jclass clazz) { xr_overlay_set_visible(xr_session_get_keyboard_overlay(), true); } + +// MenuOverlay's "Save Configuration"/"Load Configuration" buttons - see +// qs_config.h for why these only enqueue an event rather than doing the +// actual file/engine-state work here directly (this runs on Android's UI +// thread, not the engine's own). +JNIEXPORT void JNICALL +Java_de_ladkau_questshock_MenuOverlay_nativeRequestSaveConfig(JNIEnv *env, jclass clazz) { + qs_config_request_save(); +} + +JNIEXPORT void JNICALL +Java_de_ladkau_questshock_MenuOverlay_nativeRequestLoadConfig(JNIEnv *env, jclass clazz) { + qs_config_request_load(); +} diff --git a/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java b/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java index a80152a..3758553 100644 --- a/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java +++ b/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java @@ -1,19 +1,28 @@ package de.ladkau.questshock; import android.app.Activity; +import android.content.Intent; import android.view.Gravity; import android.view.View; import android.widget.Button; import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; /** * The small main menu launcher quad (see OverlayPanel for the shared off- - * screen-render/touch/cursor plumbing this builds on) - just a "Keyboard" - * button for now. Toggled by the controller's menu button (see - * xr_input.c); its "Keyboard" click only opens the (fully independent) - * keyboard overlay quad via nativeShowKeyboard() - it never touches this - * panel's own visibility, so both can be shown together (see - * KeyboardOverlay for the keyboard itself). + * screen-render/touch/cursor plumbing this builds on). Toggled by the + * controller's menu button (see xr_input.c); none of its buttons ever touch + * this panel's own visibility, so it can stay open alongside whatever they + * open/do. + * + * Two top-level children share this panel, only one visible at a time: + * buttonRow (the normal "Keyboard"/"Save Configuration"/"Load Configuration" + * row) and restartDialog (shown only when "Load Configuration" applies at + * least one live-appliable change but finds another flagged requires-restart + * - see CONFIG.md and qs_config.c's comments on the *_REQUIRES_RESTART + * flags). "Keyboard" opens the (fully independent) keyboard overlay quad via + * nativeShowKeyboard() - see KeyboardOverlay for the keyboard itself. */ public class MenuOverlay extends OverlayPanel { // Matches xr_session.c's MENU_WIDTH/MENU_HEIGHT swapchain size - fixed, @@ -24,6 +33,17 @@ public class MenuOverlay extends OverlayPanel { private static MenuOverlay instance; + // Kept so relayoutAndRedraw() (see its own comment) can re-measure/re-layout the + // same View object OverlayPanel's own private "root" field also points + // at - toggling a child's visibility on this permanently-unattached + // tree doesn't get a layout pass for free the way it would on a real + // window (nothing ever schedules one), so this class has to force one + // itself on every toggle. + private View root; + private LinearLayout buttonRow; + private LinearLayout restartDialog; + private TextView restartMessageView; + private MenuOverlay(Activity activity) { super(activity, WIDTH, HEIGHT); } @@ -32,22 +52,109 @@ public class MenuOverlay extends OverlayPanel { protected View buildContent() { FrameLayout panel = new FrameLayout(activity); panel.setBackgroundColor(0xFF202020); - Button keyboardButton = new Button(activity); - keyboardButton.setText("Keyboard"); - keyboardButton.setOnClickListener(v -> nativeShowKeyboard()); - keyboardButton.setOnTouchListener(CLICK_ON_TOUCH_UP); - FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(300, 100); - lp.gravity = Gravity.CENTER; - panel.addView(keyboardButton, lp); + + buttonRow = new LinearLayout(activity); + buttonRow.setOrientation(LinearLayout.HORIZONTAL); + buttonRow.setGravity(Gravity.CENTER); + addRowButton(buttonRow, "Keyboard", v -> nativeShowKeyboard()); + addRowButton(buttonRow, "Save Configuration", v -> nativeRequestSaveConfig()); + addRowButton(buttonRow, "Load Configuration", v -> nativeRequestLoadConfig()); + FrameLayout.LayoutParams buttonRowParams = + new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT); + buttonRowParams.gravity = Gravity.CENTER; + panel.addView(buttonRow, buttonRowParams); + + restartMessageView = new TextView(activity); + restartMessageView.setTextColor(0xFFFFFFFF); + restartMessageView.setGravity(Gravity.CENTER); + restartMessageView.setPadding(16, 8, 16, 16); + + LinearLayout restartButtonRow = new LinearLayout(activity); + restartButtonRow.setOrientation(LinearLayout.HORIZONTAL); + restartButtonRow.setGravity(Gravity.CENTER); + addRowButton(restartButtonRow, "Dismiss", v -> showButtonRow()); + addRowButton(restartButtonRow, "Restart Now", v -> restartApp()); + + restartDialog = new LinearLayout(activity); + restartDialog.setOrientation(LinearLayout.VERTICAL); + restartDialog.setGravity(Gravity.CENTER); + restartDialog.setVisibility(View.GONE); + restartDialog.addView(restartMessageView, + new LinearLayout.LayoutParams(WIDTH - 32, LinearLayout.LayoutParams.WRAP_CONTENT)); + restartDialog.addView(restartButtonRow); + FrameLayout.LayoutParams restartDialogParams = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT); + panel.addView(restartDialog, restartDialogParams); + + root = panel; return panel; } + // Forces a fresh measure/layout pass over the whole tree before + // redrawing - required after any visibility toggle (showButtonRow()/ + // showRestartDialog() below), since restartDialog starts GONE and most + // ViewGroups (FrameLayout included) skip measuring/laying out GONE + // children entirely; without this, the first time it's ever shown it + // would draw with a stale/zero layout from having never been measured. + // Mirrors the exact measure()/layout() calls OverlayPanel's own + // constructor already makes once, up front, on the same View object. + private void relayoutAndRedraw() { + int widthSpec = View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY); + int heightSpec = View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY); + root.measure(widthSpec, heightSpec); + root.layout(0, 0, WIDTH, HEIGHT); + redrawContent(); + } + + private void addRowButton(LinearLayout row, String text, View.OnClickListener onClick) { + Button button = new Button(activity); + button.setText(text); + button.setOnClickListener(onClick); + button.setOnTouchListener(CLICK_ON_TOUCH_UP); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, 100, 1f); + lp.setMargins(8, 8, 8, 8); + row.addView(button, lp); + } + + // "Dismiss" on the restart dialog - the live-appliable subset was + // already applied (on the SDL thread, before this dialog was ever + // shown - see qs_config.c's qs_config_handle_event()), so this is + // purely a local UI action, no native call needed. + private void showButtonRow() { + buttonRow.setVisibility(View.VISIBLE); + restartDialog.setVisibility(View.GONE); + relayoutAndRedraw(); + } + + // "Restart Now" on the restart dialog - relaunches the whole app via the + // standard Android self-relaunch pattern (kill this process, start a + // fresh one from the launcher intent) rather than trying to tear down + // and re-init the engine's own OpenXR/session/swapchain/audio state in + // place. The fresh process re-runs qs_config_load_startup() from + // scratch, applying every group unconditionally. Purely a + // Java/Activity/process action - no native call needed. + private void restartApp() { + Intent intent = activity.getPackageManager().getLaunchIntentForPackage(activity.getPackageName()); + if (intent == null) { + return; + } + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); + activity.startActivity(intent); + Runtime.getRuntime().exit(0); + } + // Opens the keyboard overlay quad (questshock_native.c -> // xr_overlay_set_visible(xr_session_get_keyboard_overlay(), true)) - // see KeyboardOverlay for the panel itself and its own Close button, // the only way to hide it again. private static native void nativeShowKeyboard(); + // "Save Configuration"/"Load Configuration" - see qs_config.h for why + // these just enqueue work for the engine's own thread rather than + // touching engine state directly from here (the UI thread). + private static native void nativeRequestSaveConfig(); + private static native void nativeRequestLoadConfig(); + // Called from xr_overlay.c's xr_overlay_init_jni(), on the render // thread, right after it resolves this class via the activity's own // ClassLoader (plain FindClass() can't see app classes from a thread @@ -58,6 +165,23 @@ public class MenuOverlay extends OverlayPanel { } } + // Called from qs_config.c's qs_show_restart_dialog(), on the engine's + // own SDL thread (not the UI thread - hence runOnUiThread() here, + // matching OverlayPanel's own dispatchTouch()/dispatchUpdateCursor()) + // after "Load Configuration" applies at least one live-appliable change + // but finds another that's flagged requires-restart. + public static void showRestartDialog(final String message) { + if (instance == null) { + return; + } + instance.activity.runOnUiThread(() -> { + instance.restartMessageView.setText(message); + instance.buttonRow.setVisibility(View.GONE); + instance.restartDialog.setVisibility(View.VISIBLE); + instance.relayoutAndRedraw(); + }); + } + public static byte[] nativeTakePixelsIfDirty() { return instance == null ? null : instance.takePixelsIfDirty(); } diff --git a/android/engine-patches/21-android-config.patch b/android/engine-patches/21-android-config.patch new file mode 100644 index 0000000..410751d --- /dev/null +++ b/android/engine-patches/21-android-config.patch @@ -0,0 +1,54 @@ +--- a/src/MacSrc/Shock.c ++++ b/src/MacSrc/Shock.c +@@ -31,6 +31,7 @@ + #include + + #ifdef __ANDROID__ ++#include "qs_config.h" + #include "xr_session.h" + #endif + +@@ -109,6 +110,15 @@ + LoadHotkeyKeybinds(); + LoadMoveKeybinds(); + ++#ifdef __ANDROID__ ++ // Persistent, human-editable JSON config (questshock.config.json) - see ++ // qs_config.h/CONFIG.md. After prefs.txt is loaded (above) so this file ++ // can override it, before init_all() below so there's no live-vs- ++ // restart distinction to worry about yet - every field just takes full ++ // effect as part of this same startup. ++ qs_config_load_startup(); ++#endif ++ + // Process some startup arguments + + bool show_splash = !CheckArgument("-nosplash"); +--- a/src/Libraries/INPUT/Source/sdl_events.c ++++ b/src/Libraries/INPUT/Source/sdl_events.c +@@ -29,6 +29,10 @@ + #include + #include + ++#ifdef __ANDROID__ ++#include "qs_config.h" ++#endif ++ + extern SDL_Window *window; + extern SDL_Renderer *renderer; + +@@ -481,6 +485,14 @@ + SDL_Event ev; + + while (SDL_PollEvent(&ev)) { ++#ifdef __ANDROID__ ++ // Custom event types qs_config_request_save()/_load() enqueue from ++ // the "Save Configuration"/"Load Configuration" menu buttons (see ++ // qs_config.h) - handled here, on this thread, rather than directly ++ // from those JNI calls (which run on Android's UI thread). ++ if (qs_config_handle_event(&ev)) ++ continue; ++#endif + switch (ev.type) { + case SDL_QUIT: + // a bit hacky at this place, but this would allow exiting the game via the window's [x] button diff --git a/res/screenshots/gallery_012.jpg b/res/screenshots/gallery_012.jpg new file mode 100644 index 0000000..5d41107 Binary files /dev/null and b/res/screenshots/gallery_012.jpg differ diff --git a/res/sketch_main_menu.svg b/res/sketch_main_menu.svg new file mode 100644 index 0000000..bf3ff44 --- /dev/null +++ b/res/sketch_main_menu.svg @@ -0,0 +1,132 @@ + + + + + + + + + + Menu content here + Questshock (v3.0.2) + [FPS: 70] + + Main Menu + Video Options + + + +