From c0619a55ab8c7600069986f0debc1ef89ae1b96e Mon Sep 17 00:00:00 2001 From: ml Date: Sat, 5 Sep 2026 08:09:12 +0200 Subject: [PATCH] Add a language option to the VR menu, and fix a crash switching to it - New "Language" row on the Main Menu tab (English/French/German), backed by a new "general" group in questshock.config.json - Fixes a device-reproduced crash: switching language used to call language_dealfunc(), whose render_run()/opanel_redraw() repaint assumes the game's own in-game options panel is on screen - crashes (SIGSEGV) when triggered from our independent VR overlay instead. Now calls language_change() directly, which is all that's actually needed to reload resources and refresh the inventory/MFD/side-icon UI - Fixes a second crash: this data set ships frnstrng.res/gerstrng.res but not mfdfrn.res/mfdger.res, and language_change() closes the current resource files before discovering the new ones don't exist - leaving no valid resource file open and crashing on the next lookup. Now checks both files exist before ever switching, refusing the change (with an in-headset popup) instead of corrupting resource state - extract_assets.sh can now also pull mfdfrn.res/mfdger.res out of a System Shock: Classic installer (which includes them, unlike Enhanced Edition) if one is present in res/assets/ - entirely optional - Documented all of the above in README.md/CONFIG.md --- .gitignore | 1 + CONFIG.md | 27 ++- README.md | 15 ++ android/app/src/main/cpp/qs_config.c | 214 ++++++++++++++++-- android/app/src/main/cpp/qs_config.h | 24 +- android/app/src/main/cpp/questshock_native.c | 7 + .../de/ladkau/questshock/MenuOverlay.java | 108 ++++++++- .../java/de/ladkau/questshock/Strings.java | 4 + res/assets/extract_assets.sh | 70 +++++- 9 files changed, 422 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index f25c8d5..0110ac3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # Game assets are not included in the repo /res/assets/setup_system_shock_enhanced* +/res/assets/setup_system_shock_classic* /res/assets/*.zip /res/assets/ss_ee/ diff --git a/CONFIG.md b/CONFIG.md index b124931..b6a8105 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -29,7 +29,7 @@ startup, in addition to (and independent of) the engine's own `prefs.txt`. load and so applies every group in the file, including the restart-required ones. - (Both options currently documented below are live-appliable, so this + (Every option currently documented below is 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 @@ -48,6 +48,9 @@ startup, in addition to (and independent of) the engine's own `prefs.txt`. "video_options": { "renderer": "opengl", "resolution": "1024x768" + }, + "general": { + "language": "english" } } ``` @@ -69,3 +72,25 @@ The game's internal rendering resolution. - **Type**: enum - **Possible values**: `"320x200"`, `"320x400"`, `"640x400"`, `"640x480"`, `"1024x768"` - **Restart required?**: No (live-appliable) + +## General (`general`) + +### `language` + +The game's UI/text language - both the game's own resource strings and this +project's own menu/keyboard chrome text (title bar, buttons, etc.). + +- **Type**: enum +- **Possible values**: `"english"`, `"french"`, `"german"` +- **Restart required?**: No (live-appliable) - applies the same way the + game's own in-game options menu applies a language change (reloads the + language-specific resource files and refreshes the inventory/MFD/side-icon + UI immediately). +- **Note**: French and German both need their own full resource pack + (`frnstrng.res`/`mfdfrn.res`, `gerstrng.res`/`mfdger.res` under + `res/data/`) - if any of those files aren't present, the switch is + refused (logged, `questshock.config.json` and the current language are + left unchanged) rather than leaving the game with no valid resource file + open. See README.md section 3 ("Game assets") for where `mfdfrn.res`/ + `mfdger.res` come from - the GOG Enhanced Edition installer alone doesn't + include them. diff --git a/README.md b/README.md index 9f1dbfb..c1ed3e0 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,21 @@ package`/`make apk` pick it up the same way either way. This one copy of game assets feeds both the desktop build and the Android/Quest build below. +**Note on French/German**: the GOG Enhanced Edition installer only ships +English MFD (Multi-Function Display - the small in-game screens showing +the map, inventory, etc.) art resources (`mfdart.res`) - it doesn't include +`mfdfrn.res`/`mfdger.res`, which French/German need in addition to their +own text resources (`frnstrng.res`/`gerstrng.res`, which the installer +*does* include). Without those two files, switching to French/German (via +`questshock.config.json`'s `general.language` or the in-headset menu's +Language row - see [CONFIG.md](CONFIG.md)) is refused with an in-headset +popup rather than applied, since doing so anyway used to crash the game. If +you also own **System Shock: Classic** on gog.com (which does include both +files), drop its offline installer into `res/assets/` alongside the +Enhanced Edition one and `extract_assets.sh` pulls them out of it too, +entirely optionally - it still runs fine with just the Enhanced Edition +installer. + ## 4. Desktop build Builds and runs Questshock natively on the desktop (your dev machine, or diff --git a/android/app/src/main/cpp/qs_config.c b/android/app/src/main/cpp/qs_config.c index a79813b..e8791f9 100644 --- a/android/app/src/main/cpp/qs_config.c +++ b/android/app/src/main/cpp/qs_config.c @@ -284,9 +284,15 @@ typedef struct { char resolution[16]; // "" = not present in the file } QsParsedVideoOptions; +typedef struct { + char language[16]; // "" = not present in the file +} QsParsedGeneral; + typedef struct { QsParsedVideoOptions video_options; bool has_video_options; + QsParsedGeneral general; + bool has_general; } QsParsedConfig; static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void *ctx) { @@ -299,10 +305,20 @@ static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void qs_json_skip_value(p); } +static void qs_parse_general_field(const char *key, QsJsonParser *p, void *ctx) { + QsParsedGeneral *out = (QsParsedGeneral *)ctx; + if (strcmp(key, "language") == 0) + qs_json_take_string_field(p, out->language, sizeof(out->language)); + 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 if (strcmp(key, "general") == 0 && !p->error && p->p < p->end && *p->p == '{') + out->has_general = qs_json_parse_object(p, qs_parse_general_field, &out->general); else qs_json_skip_value(p); } @@ -442,6 +458,117 @@ static const char *qs_current_resolution(void) { return "640x480"; // shouldn't happen - safe fallback for Save } +// =========================================================================== +// General: language +// =========================================================================== + +// Live-appliable: language_change() (below) is the resource-reload half of +// what the game's own options menu calls when the player changes language +// there (see wrapper.c's language_dealfunc(), wired up via +// multi_init(..., REF_STR_Language, ...)) - closes/reopens the +// language-specific resource files, updates which_lang/QUESTVAR, and +// refreshes the inventory/MFD/side-icon panels' own language-dependent +// text/art. Deliberately NOT calling language_dealfunc() itself: its extra +// render_run()/opanel_redraw() repaint step assumes the game's own +// in-game options-panel widget is the thing currently on screen, which +// isn't true when this is triggered from our independent VR overlay menu - +// confirmed by a device crash (SIGSEGV, null pointer) inside render_run()'s +// 3D-view frustum-clipping code when called this way. The game's normal +// per-frame render loop already redraws everything anyway. +#define QS_LANGUAGE_REQUIRES_RESTART false + +// wrapper.h declares this one, but including the whole header pulls in +// other prototypes referencing types this file has no other reason to need +// - same reasoning as screen_draw() above. +extern void language_change(uchar lang); + +// gamestrn.c/init.c - declared locally rather than pulling in their +// headers, same reasoning as language_change() above. language_change() +// itself indexes these two by lang (0/1/2) to reopen the string/MFD-art +// resource files for the new language - but it does so destructively, +// closing the *current* (working) files first, with no fallback if the +// reopen then fails. qs_language_resources_available() below checks both +// files exist *before* ever calling language_change(), so a language whose +// resource pack wasn't fully installed can't leave the game with no valid +// resource file open at all. +extern char *language_files[]; +extern char *mfdart_files[]; + +static const struct { + const char *name; + short language; +} kLanguages[] = { + {"english", 0}, + {"french", 1}, + {"german", 2}, +}; +#define QS_NUM_LANGUAGES (sizeof(kLanguages) / sizeof(kLanguages[0])) + +// Confirmed necessary on a real device: this project's own Android data set +// ships frnstrng.res/gerstrng.res (language_files[]) but not +// mfdfrn.res/mfdger.res (mfdart_files[]) - selecting French or German used +// to call language_change(), which closes the working English resource +// files before discovering the new ones don't exist, leaving no valid +// resource file open at all and crashing (SIGBUS, null resource pointer) +// the next time any resource lookup happened. +static bool qs_language_resources_available(short lang) { + FILE *f = fopen(language_files[lang], "rb"); + if (!f) + return false; + fclose(f); + f = fopen(mfdart_files[lang], "rb"); + if (!f) + return false; + fclose(f); + return true; +} + +// Defined further down, alongside qs_show_restart_dialog() (same +// find-class-via-classloader JNI plumbing) - forward-declared here since +// qs_apply_language() above is defined earlier in the file. +static void qs_show_message_dialog(const char *message); + +static bool qs_apply_language(const char *value, bool live) { + for (size_t i = 0; i < QS_NUM_LANGUAGES; i++) { + if (strcmp(value, kLanguages[i].name) != 0) + continue; + if (!qs_language_resources_available(kLanguages[i].language)) { + WARN("%s: general.language=\"%s\" - resource files for this language aren't installed " + "- not switching", + QS_CONFIG_FILENAME, value); + // Only when there's a menu to show it in - live=false is the + // startup path (qs_config_load_startup()), before the menu + // overlay exists; qs_show_message_dialog() would just silently + // no-op there anyway (MenuOverlay.instance is still null), but + // there's no point trying. + if (live) { + char message[256]; + snprintf(message, sizeof(message), + "Can't switch to %s: its resource files (%s, %s) aren't installed in " + "res/data/ - see CONFIG.md.", + value, language_files[kLanguages[i].language], + mfdart_files[kLanguages[i].language]); + qs_show_message_dialog(message); + } + return false; + } + bool changed = gShockPrefs.goLanguage != kLanguages[i].language; + gShockPrefs.goLanguage = kLanguages[i].language; + if (changed && live) + language_change((uchar)kLanguages[i].language); + return changed; + } + WARN("%s: general.language=\"%s\" is invalid - ignoring", QS_CONFIG_FILENAME, value); + return false; +} + +static const char *qs_current_language_name(void) { + for (size_t i = 0; i < QS_NUM_LANGUAGES; i++) + if (kLanguages[i].language == gShockPrefs.goLanguage) + return kLanguages[i].name; + return "english"; // shouldn't happen - safe fallback for Save +} + // =========================================================================== // Read-only queries for the Video Options tab (see qs_config.h) // =========================================================================== @@ -483,17 +610,24 @@ static void qs_config_apply_parsed(const QsParsedConfig *parsed, bool live, char 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->has_video_options) { + 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"); + } } - 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"); + if (parsed->has_general) { + if (parsed->general.language[0] != '\0') { + bool changed = qs_apply_language(parsed->general.language, live); + if (changed && QS_LANGUAGE_REQUIRES_RESTART) + qs_append_restart_reason(restart_needed, restart_needed_size, "language"); + } } } @@ -507,6 +641,9 @@ static bool qs_config_write_file(void) { 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, " \"general\": {\n"); + fprintf(f, " \"language\": \"%s\"\n", qs_current_language_name()); fprintf(f, " }\n"); fprintf(f, "}\n"); fclose(f); @@ -538,7 +675,10 @@ static jclass qs_find_class(JNIEnv *env, jobject activity, const char *name) { return result; } -static void qs_show_restart_dialog(const char *restart_needed) { +// Shared by qs_show_restart_dialog() and qs_show_message_dialog() below - +// both call a MenuOverlay static method taking a single String and +// returning void, differing only in which one. +static void qs_call_dialog_method(const char *java_method_name, const char *message) { JNIEnv *env = (JNIEnv *)SDL_AndroidGetJNIEnv(); jobject activity = (jobject)SDL_AndroidGetActivity(); if (!env || !activity) @@ -548,24 +688,34 @@ static void qs_show_restart_dialog(const char *restart_needed) { if (!menuOverlayClass) return; - jmethodID showRestartDialog = - (*env)->GetStaticMethodID(env, menuOverlayClass, "showRestartDialog", "(Ljava/lang/String;)V"); - if (!showRestartDialog) { + jmethodID method = + (*env)->GetStaticMethodID(env, menuOverlayClass, java_method_name, "(Ljava/lang/String;)V"); + if (!method) { (*env)->ExceptionClear(env); return; } + jstring messageStr = (*env)->NewStringUTF(env, message); + (*env)->CallStaticVoidMethod(env, menuOverlayClass, method, messageStr); + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionClear(env); +} + +static void qs_show_restart_dialog(const char *restart_needed) { 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); + qs_call_dialog_method("showRestartDialog", message); } +// Same restart-confirmation dialog view (MenuOverlay.java's +// showMessageDialog()), minus its Restart Now button - for a plain +// informational/warning message with nothing to restart into, currently +// only qs_apply_language()'s missing-resource-files warning above. +static void qs_show_message_dialog(const char *message) { qs_call_dialog_method("showMessageDialog", message); } + // =========================================================================== // Public API (see qs_config.h) // =========================================================================== @@ -574,19 +724,21 @@ static Uint32 s_save_event_type; static Uint32 s_load_event_type; static Uint32 s_set_renderer_event_type; static Uint32 s_set_resolution_event_type; +static Uint32 s_set_language_event_type; void qs_config_load_startup(void) { // Registers the custom SDL event types qs_config_request_save()/_load()/ - // _set_renderer()/_set_resolution() 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(4); + // _set_renderer()/_set_resolution()/_set_language() 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(5); s_save_event_type = base; s_load_event_type = base + 1; s_set_renderer_event_type = base + 2; s_set_resolution_event_type = base + 3; + s_set_language_event_type = base + 4; QsParsedConfig parsed; if (qs_config_read_file(&parsed)) @@ -623,6 +775,14 @@ void qs_config_request_set_resolution(int resolution_index) { SDL_PushEvent(&event); } +void qs_config_request_set_language(int language_index) { + SDL_Event event; + SDL_zero(event); + event.type = s_set_language_event_type; + event.user.code = language_index; + SDL_PushEvent(&event); +} + bool qs_config_handle_event(const SDL_Event *event) { if (event->type == s_save_event_type) { if (qs_config_write_file()) @@ -655,5 +815,11 @@ bool qs_config_handle_event(const SDL_Event *event) { qs_apply_resolution(kResolutions[index].name, /*live=*/true); return true; } + if (event->type == s_set_language_event_type) { + int index = event->user.code; + if (index >= 0 && (size_t)index < QS_NUM_LANGUAGES) + qs_apply_language(kLanguages[index].name, /*live=*/true); + return true; + } return false; } diff --git a/android/app/src/main/cpp/qs_config.h b/android/app/src/main/cpp/qs_config.h index ddcf311..087cf53 100644 --- a/android/app/src/main/cpp/qs_config.h +++ b/android/app/src/main/cpp/qs_config.h @@ -49,13 +49,20 @@ void qs_config_request_load(void); void qs_config_request_set_renderer(bool opengl); void qs_config_request_set_resolution(int resolution_index); -// Returns true and handles the event if it's one of the four custom events -// qs_config_request_save()/_load()/_set_renderer()/_set_resolution() -// enqueue - called from pump_events() (sdl_events.c, see +// Same cross-thread-dispatch reasoning as the two above - called from the +// Main Menu tab's Language row (MenuOverlay.java). language_index is 0 +// English/1 French/2 German (gShockPrefs.goLanguage's own numbering, +// Prefs.h). Applies live immediately, the same way the game's own options +// menu applies a language change (see qs_config.c's qs_apply_language()). +void qs_config_request_set_language(int language_index); + +// Returns true and handles the event if it's one of the five custom events +// qs_config_request_save()/_load()/_set_renderer()/_set_resolution()/ +// _set_language() 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 four custom types have even been registered. +// including before the five custom types have even been registered. bool qs_config_handle_event(const SDL_Event *event); // Read-only queries for the Video Options tab's own selection highlight @@ -73,9 +80,12 @@ int qs_config_current_resolution_index(void); // gShockPrefs.goLanguage (0 English/1 French/2 German, Prefs.h) - read by // MenuOverlay.java's/KeyboardOverlay.java's periodic header-refresh timer // to pick which language's chrome text (Strings.java) to display, so a -// language change made mid-session via the game's own options menu is -// picked up without needing to reopen either panel. Same -// off-thread-scalar-read reasoning as the two getters above. +// language change made mid-session via the game's own options menu (or our +// own Language row, or Load Configuration) is picked up without needing to +// reopen either panel; also doubles as the Main Menu tab's Language row's +// own selection highlight, same as qs_config_current_renderer_is_opengl()/ +// qs_config_current_resolution_index() above. Same off-thread-scalar-read +// reasoning as those two getters. int qs_config_current_language(void); #ifdef __cplusplus diff --git a/android/app/src/main/cpp/questshock_native.c b/android/app/src/main/cpp/questshock_native.c index 5ead13b..f1ca9b3 100644 --- a/android/app/src/main/cpp/questshock_native.c +++ b/android/app/src/main/cpp/questshock_native.c @@ -118,6 +118,13 @@ Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetResolution(JNIEnv *env, jc qs_config_request_set_resolution(resolutionIndex); } +// MenuOverlay's Main Menu tab's Language row - same enqueue-only reasoning as above. +JNIEXPORT void JNICALL +Java_de_ladkau_questshock_MenuOverlay_nativeRequestSetLanguage(JNIEnv *env, jclass clazz, + jint languageIndex) { + qs_config_request_set_language(languageIndex); +} + // Read-only queries - safe to call directly (no enqueue needed, see // qs_config.h's own comment on each of these). JNIEXPORT jboolean JNICALL 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 b1e794e..d0da2bd 100644 --- a/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java +++ b/android/app/src/main/java/de/ladkau/questshock/MenuOverlay.java @@ -29,8 +29,10 @@ import java.util.Locale; * version, FPS/frame-time stats, refreshed on a periodic timer), a content * area holding one tab's content at a time, and a bottom tab bar. Tab 1 * ("Main Menu") is the original "Keyboard"/"Save Configuration"/"Load - * Configuration" row plus the restart-confirmation view (shown instead of - * that row - see CONFIG.md and qs_config.c's *_REQUIRES_RESTART comments). + * Configuration" row plus a Language row, with a dialog view (shown instead + * of that content) doubling as both the restart-confirmation prompt (see + * CONFIG.md and qs_config.c's *_REQUIRES_RESTART comments) and a plain + * informational/warning popup (showMessageDialog() below). * Tab 2 ("Video Options") has live-appliable controls for the config file's * video_options group, mirroring the same buttons' selection to whatever's * actually active (queried fresh whenever the tab is shown, and on the same @@ -55,6 +57,10 @@ public class MenuOverlay extends OverlayPanel { private static final long STATS_REFRESH_MS = 500; private static final String[] RESOLUTION_LABELS = {"320x200", "320x400", "640x400", "640x480", "1024x768"}; + // Order must match qs_config.c's kLanguages[]/gShockPrefs.goLanguage's + // own numbering (Prefs.h: 0 English/1 French/2 German) - also the keys + // this same language's own Strings.java translation is filed under. + private static final String[] LANGUAGE_KEYS = {"english", "french", "german"}; private static MenuOverlay instance; @@ -79,7 +85,10 @@ public class MenuOverlay extends OverlayPanel { private Button videoOptionsTabButton; // Main Menu tab's own two states (only one VISIBLE at a time, within - // mainMenuTab) + // mainMenuTab) - mainMenuContent is the normal state (button row + + // Language row); restartDialog replaces it when Load Configuration + // finds a restart-required change. + private LinearLayout mainMenuContent; private LinearLayout buttonRow; private LinearLayout restartDialog; private TextView restartMessageView; @@ -89,6 +98,10 @@ public class MenuOverlay extends OverlayPanel { private Button dismissButton; private Button restartNowButton; + // Main Menu tab's Language row + private TextView languageLabel; + private Button[] languageButtons; + // Video Options tab controls private TextView rendererLabel; private TextView resolutionLabel; @@ -187,6 +200,10 @@ public class MenuOverlay extends OverlayPanel { private View buildMainMenuTab() { FrameLayout tab = new FrameLayout(activity); + mainMenuContent = new LinearLayout(activity); + mainMenuContent.setOrientation(LinearLayout.VERTICAL); + mainMenuContent.setGravity(Gravity.CENTER); + buttonRow = new LinearLayout(activity); buttonRow.setOrientation(LinearLayout.HORIZONTAL); buttonRow.setGravity(Gravity.CENTER); @@ -196,10 +213,32 @@ public class MenuOverlay extends OverlayPanel { v -> nativeRequestSaveConfig()); loadButton = addRowButton(buttonRow, Strings.get("load_configuration", currentLanguage), v -> nativeRequestLoadConfig()); - FrameLayout.LayoutParams buttonRowParams = new FrameLayout.LayoutParams( + mainMenuContent.addView(buttonRow, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + + languageLabel = new TextView(activity); + languageLabel.setTextColor(0xFFFFFFFF); + languageLabel.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18); + languageLabel.setGravity(Gravity.CENTER); + languageLabel.setPadding(0, 24, 0, 0); + languageLabel.setText(Strings.get("language", currentLanguage)); + mainMenuContent.addView(languageLabel); + + LinearLayout languageRow = new LinearLayout(activity); + languageRow.setOrientation(LinearLayout.HORIZONTAL); + languageButtons = new Button[LANGUAGE_KEYS.length]; + for (int i = 0; i < LANGUAGE_KEYS.length; i++) { + final int index = i; + languageButtons[i] = addSelectableButton(languageRow, Strings.get(LANGUAGE_KEYS[i], currentLanguage), + v -> nativeRequestSetLanguage(index)); + } + mainMenuContent.addView(languageRow, new LinearLayout.LayoutParams( + 600, LinearLayout.LayoutParams.WRAP_CONTENT)); + + FrameLayout.LayoutParams mainMenuContentParams = new FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT); - buttonRowParams.gravity = Gravity.CENTER; - tab.addView(buttonRow, buttonRowParams); + mainMenuContentParams.gravity = Gravity.CENTER; + tab.addView(mainMenuContent, mainMenuContentParams); restartMessageView = new TextView(activity); restartMessageView.setTextColor(0xFFFFFFFF); @@ -272,12 +311,17 @@ public class MenuOverlay extends OverlayPanel { tabBar.setOrientation(LinearLayout.HORIZONTAL); tabBar.setBackgroundColor(0xFF000080); + // Selectable background (like the Video Options tab's own Renderer/ + // Resolution buttons) so the currently-active tab stays visibly + // highlighted, not just hovered/pressed - see the setSelected() + // calls in switchTab() below. mainMenuTabButton = new Button(activity); mainMenuTabButton.setText(Strings.get("main_menu", currentLanguage)); - mainMenuTabButton.setBackground(buttonBackground()); + mainMenuTabButton.setBackground(selectableButtonBackground()); mainMenuTabButton.setTextColor(buttonTextColors()); mainMenuTabButton.setOnClickListener(v -> switchTab(mainMenuTab)); mainMenuTabButton.setOnTouchListener(CLICK_ON_TOUCH_UP); + mainMenuTabButton.setSelected(true); registerHoverable(mainMenuTabButton); tabBar.addView(mainMenuTabButton, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)); @@ -288,7 +332,7 @@ public class MenuOverlay extends OverlayPanel { videoOptionsTabButton = new Button(activity); videoOptionsTabButton.setText(Strings.get("video_options", currentLanguage)); - videoOptionsTabButton.setBackground(buttonBackground()); + videoOptionsTabButton.setBackground(selectableButtonBackground()); videoOptionsTabButton.setTextColor(buttonTextColors()); videoOptionsTabButton.setOnClickListener(v -> switchTab(videoOptionsTab)); videoOptionsTabButton.setOnTouchListener(CLICK_ON_TOUCH_UP); @@ -306,9 +350,13 @@ public class MenuOverlay extends OverlayPanel { currentTab.setVisibility(View.GONE); tab.setVisibility(View.VISIBLE); currentTab = tab; + mainMenuTabButton.setSelected(tab == mainMenuTab); + videoOptionsTabButton.setSelected(tab == videoOptionsTab); resetHover(); if (tab == videoOptionsTab) { refreshVideoOptionsSelection(); + } else if (tab == mainMenuTab) { + refreshLanguageSelection(); } relayoutAndRedraw(); } @@ -389,6 +437,18 @@ public class MenuOverlay extends OverlayPanel { } } + // Same reasoning as refreshVideoOptionsSelection() above, for the Main + // Menu tab's Language row - nativeGetLanguage() is the same getter + // refreshStats() already polls to pick the menu/keyboard chrome text's + // own language, so this reflects a language change made any way (our + // own Language row, the game's own options menu, or Load Configuration). + private void refreshLanguageSelection() { + int language = nativeGetLanguage(); + for (int i = 0; i < languageButtons.length; i++) { + languageButtons[i].setSelected(i == language); + } + } + // Polled every STATS_REFRESH_MS while this instance exists (started // from the constructor) rather than only while the menu is actually // visible - simpler than plumbing a new native->Java "menu became @@ -423,6 +483,8 @@ public class MenuOverlay extends OverlayPanel { if (currentTab == videoOptionsTab) { refreshVideoOptionsSelection(); + } else if (currentTab == mainMenuTab) { + refreshLanguageSelection(); } // The stats text's width changes every tick (digit count, "ms" @@ -461,6 +523,10 @@ public class MenuOverlay extends OverlayPanel { softwareButton.setText(Strings.get("software", currentLanguage)); // openglButton's "OpenGL" label is never translated - see // buildVideoOptionsTab()'s own comment on it. + languageLabel.setText(Strings.get("language", currentLanguage)); + for (int i = 0; i < languageButtons.length; i++) { + languageButtons[i].setText(Strings.get(LANGUAGE_KEYS[i], currentLanguage)); + } } // "Dismiss" on the restart dialog - the live-appliable subset was @@ -468,7 +534,7 @@ public class MenuOverlay extends OverlayPanel { // 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); + mainMenuContent.setVisibility(View.VISIBLE); restartDialog.setVisibility(View.GONE); resetHover(); relayoutAndRedraw(); @@ -506,6 +572,8 @@ public class MenuOverlay extends OverlayPanel { // Video Options tab's controls - same enqueue-only reasoning as above. private static native void nativeRequestSetRenderer(boolean opengl); private static native void nativeRequestSetResolution(int resolutionIndex); + // Main Menu tab's Language row - same enqueue-only reasoning as above. + private static native void nativeRequestSetLanguage(int languageIndex); // Read-only queries - safe to call directly, see qs_config.h's own // comment on each of these. @@ -545,7 +613,27 @@ public class MenuOverlay extends OverlayPanel { } instance.activity.runOnUiThread(() -> { instance.restartMessageView.setText(message); - instance.buttonRow.setVisibility(View.GONE); + instance.restartNowButton.setVisibility(View.VISIBLE); + instance.mainMenuContent.setVisibility(View.GONE); + instance.restartDialog.setVisibility(View.VISIBLE); + instance.resetHover(); + instance.relayoutAndRedraw(); + }); + } + + // Called from qs_config.c's qs_show_message_dialog(), same thread/ + // dispatch reasoning as showRestartDialog() above - a plain + // informational/warning popup with nothing to restart into (currently + // only qs_apply_language()'s missing-resource-files warning), so it + // reuses the same dialog view minus the Restart Now button. + public static void showMessageDialog(final String message) { + if (instance == null) { + return; + } + instance.activity.runOnUiThread(() -> { + instance.restartMessageView.setText(message); + instance.restartNowButton.setVisibility(View.GONE); + instance.mainMenuContent.setVisibility(View.GONE); instance.restartDialog.setVisibility(View.VISIBLE); instance.resetHover(); instance.relayoutAndRedraw(); diff --git a/android/app/src/main/java/de/ladkau/questshock/Strings.java b/android/app/src/main/java/de/ladkau/questshock/Strings.java index 586cc7b..ce104d1 100644 --- a/android/app/src/main/java/de/ladkau/questshock/Strings.java +++ b/android/app/src/main/java/de/ladkau/questshock/Strings.java @@ -44,6 +44,10 @@ final class Strings { put("renderer", "Renderer", "Moteur de rendu", "Renderer"); put("resolution", "Resolution", "Résolution", "Auflösung"); put("software", "Software", "Logiciel", "Software"); + put("language", "Language", "Langue", "Sprache"); + put("english", "English", "Anglais", "Englisch"); + put("french", "French", "Français", "Französisch"); + put("german", "German", "Allemand", "Deutsch"); // "OpenGL" is a proper/brand name - not translated, same in every // language, so it has no entry here; call sites just use it as a // literal. diff --git a/res/assets/extract_assets.sh b/res/assets/extract_assets.sh index 92d5cdf..9beb526 100755 --- a/res/assets/extract_assets.sh +++ b/res/assets/extract_assets.sh @@ -13,10 +13,23 @@ # inside it in sshock.kpf, which is just a zip file. Its res/pc/hd/ and # res/pc/cdrom/ trees together hold every classic-game data/sound file # Shockolate's res/ folder expects (res/pc/hd wins the couple of -# filenames present in both: intro.res, objprop.dat). Needs innoextract -# and unzip; if they're not installed locally, this script does the -# extraction inside a throwaway Ubuntu container instead (needs docker in -# that case, nothing else). +# filenames present in both: intro.res, objprop.dat), EXCEPT +# mfdfrn.res/mfdger.res (French/German MFD art - see CONFIG.md/README.md), +# which that installer doesn't include at all - see "Optional: French/ +# German MFD art" below for where those instead come from. Needs +# innoextract and unzip; if they're not installed locally, this script +# does the extraction inside a throwaway Ubuntu container instead (needs +# docker in that case, nothing else). +# +# Optional: French/German MFD art +# The Enhanced Edition installer's data set doesn't include +# mfdfrn.res/mfdger.res, so French/German are otherwise missing their +# MFD (Multi-Function Display) art and the game refuses to switch to +# them (see CONFIG.md). System Shock: Classic (also gog.com) does +# include both files. If you also own it, drop its offline installer +# (e.g. setup_system_shock_classic_2.0.0.3.exe) in this directory too - +# entirely optional, this script still runs fine without it - and this +# script pulls just those two files out of it into ss_ee/data/ as well. set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")" @@ -27,6 +40,7 @@ fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; } shopt -s nullglob installers=(setup_system_shock_enhanced_edition*.exe) +classic_installers=(setup_system_shock_classic*.exe) shopt -u nullglob case "${#installers[@]}" in 0) fail "no setup_system_shock_enhanced_edition*.exe found in $ASSETS_DIR - buy System Shock: Enhanced Edition on gog.com, download the offline installer, and drop it here" ;; @@ -35,6 +49,15 @@ case "${#installers[@]}" in esac INSTALLER="${installers[0]}" +# Optional - see "Optional: French/German MFD art" above. Empty (not +# missing/failed) when absent; only a genuine ambiguity (more than one +# candidate file) is a hard failure. +case "${#classic_installers[@]}" in + 0) CLASSIC_INSTALLER="" ;; + 1) CLASSIC_INSTALLER="${classic_installers[0]}" ;; + *) fail "multiple setup_system_shock_classic*.exe files found in $ASSETS_DIR - keep only one: ${classic_installers[*]}" ;; +esac + # Extracts sshock.kpf from $1 (an installer .exe) and assembles $2 (an # ss_ee output directory) from its res/pc/{hd,cdrom} trees. Defined as a # function so the exact same logic can run either natively or, via @@ -69,9 +92,38 @@ run_extraction() { cp -a "$work/kpf/res/pc/hd/sound/." "$out_dir/sound/" } +# Extracts mfdfrn.res/mfdger.res from $1 (a System Shock: Classic installer +# .exe) into $2 (an existing ss_ee output directory's data/ subfolder) - +# see "Optional: French/German MFD art" above. Unlike the Enhanced Edition +# installer, Classic's data files sit directly in the Inno Setup package +# (no intermediate zip), so this is just a targeted innoextract --include, +# no unzip step needed. Same declare -f-into-docker structure as +# run_extraction() above. +run_classic_mfd_extraction() { + set -euo pipefail + local installer="$1" out_dir="$2" + work="$(mktemp -d)" + trap 'rm -rf "${work:-}"' EXIT + + echo "== Extracting mfdfrn.res/mfdger.res from classic installer ==" + innoextract --silent \ + --include 'app/SSHOCK/DATA/MFDFRN.RES' --include 'app/SSHOCK/DATA/MFDGER.RES' \ + -d "$work/classic" "$installer" + + # Original DOS-era filenames are uppercase; Shockolate's resource + # loader expects the lowercase names already used for every other + # res/data/*.res file (case-sensitive on Linux, unlike the DOS/Windows + # filesystems these came from). + cp "$work/classic/app/SSHOCK/DATA/MFDFRN.RES" "$out_dir/data/mfdfrn.res" + cp "$work/classic/app/SSHOCK/DATA/MFDGER.RES" "$out_dir/data/mfdger.res" +} + if command -v innoextract >/dev/null 2>&1 && command -v unzip >/dev/null 2>&1; then echo "== Using local innoextract/unzip ==" run_extraction "$ASSETS_DIR/$INSTALLER" "$OUT_DIR" + if [ -n "$CLASSIC_INSTALLER" ]; then + run_classic_mfd_extraction "$ASSETS_DIR/$CLASSIC_INSTALLER" "$OUT_DIR" + fi else echo "== innoextract/unzip not found locally - using a throwaway docker container ==" command -v docker >/dev/null 2>&1 \ @@ -86,12 +138,18 @@ else apt-get update -qq >/dev/null apt-get install -y -qq innoextract unzip >/dev/null $(declare -f run_extraction) + $(declare -f run_classic_mfd_extraction) run_extraction '/assets/$INSTALLER' '/assets/ss_ee' + $([ -n "$CLASSIC_INSTALLER" ] && echo "run_classic_mfd_extraction '/assets/$CLASSIC_INSTALLER' '/assets/ss_ee'") chown -R \"\$HOST_UID:\$HOST_GID\" /assets/ss_ee " fi echo "== Done ==" echo "Assets extracted to $OUT_DIR" -echo "Note: mfdfrn.res/mfdger.res (French/German MFD art) are not present in this" -echo "installer and are only needed for those localizations - not for English play." +if [ -n "$CLASSIC_INSTALLER" ]; then + echo "Also extracted mfdfrn.res/mfdger.res (French/German MFD art) from $CLASSIC_INSTALLER." +else + echo "Note: mfdfrn.res/mfdger.res (French/German MFD art) are not present - only needed for" + echo "French/German (see CONFIG.md); drop a System Shock: Classic installer here too to get them." +fi