Add a language option to the VR menu, and fix a crash switching to it
build / build (push) Successful in 2m22s
build / build (push) Successful in 2m22s
- 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
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user