// 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 "xr_input.h" #include "xr_session.h" #include "Prefs.h" #include "fullscrn.h" #include "lg.h" #include "tools.h" #define QS_CONFIG_FILENAME "questshock.config.json" // Bundled with the app (QuestShockActivity.java extracts it fresh on every // launch) and read only at startup, only if QS_CONFIG_FILENAME doesn't // exist yet - never touched by Save/Load Configuration. #define QS_CONFIG_DEFAULT_FILENAME "questshock.config.default.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; } // Parses a JSON number at the cursor into *out - unlike qs_json_skip_number() // above (which every other option so far has only ever needed, since every // existing option is a string enum), this one actually extracts the value. // Same scan-then-copy-then-parse approach as qs_json_parse_string(), needed // because the source buffer isn't NUL-terminated at an arbitrary offset, so // strtof() can't just be pointed at the cursor directly. static bool qs_json_parse_number(QsJsonParser *p, float *out) { if (p->error) return false; 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++; size_t len = (size_t)(p->p - start); if (len == 0 || len >= 64) { qs_json_fail(p); return false; } char buf[64]; memcpy(buf, start, len); buf[len] = '\0'; char *endptr = NULL; *out = strtof(buf, &endptr); if (endptr == buf) { 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); } // Same "skip if wrong type" behavior as qs_json_take_string_field() above, // for a numeric field - sets *has to true only on success, since (unlike // the string groups, where an empty string means "not present") 0.0f is a // perfectly valid value and can't double as an absent-sentinel. static void qs_json_take_number_field(QsJsonParser *p, float *out, bool *has) { if (!p->error && p->p < p->end && (isdigit((unsigned char)*p->p) || *p->p == '-')) { if (qs_json_parse_number(p, out)) *has = true; } 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 // The virtual screen's physical width (meters) for each of the two // screen profiles - see qs_screen_profile_notify_loop_mode()'s own // comment. Like QsParsedPlayerPosition below (and unlike renderer/ // resolution above), these are plain numbers, not string enums, and // need their own has_* bools since 0.0f is a valid value. float initial_screen_width_m, initial_screen_distance_m; float game_screen_width_m, game_screen_distance_m; bool has_initial_screen_width_m, has_initial_screen_distance_m; bool has_game_screen_width_m, has_game_screen_distance_m; // Each of the two options (width/distance) is offered as 5 preset // buttons in the Video Options tab, generated as offset + step*index // (index 0..4) rather than a fixed table - see qs_preset_value()'s // own comment. Shared by both "Initial Screen" and "Game Screen" // (both columns' preset buttons show the same 5 values per row) - // config-file-only (no UI control): the buttons are for adjusting // each profile's own current *value*, this offset/step pair is for // reshaping what those buttons themselves offer. float screen_width_offset_m, screen_width_step_m; float screen_distance_offset_m, screen_distance_step_m; bool has_screen_width_offset_m, has_screen_width_step_m; bool has_screen_distance_offset_m, has_screen_distance_step_m; } QsParsedVideoOptions; typedef struct { char language[16]; // "" = not present in the file } QsParsedGeneral; typedef struct { float x, z, yaw_degrees; // Unlike every string-valued option above, 0.0f is a perfectly valid // value here and can't double as "not present in the file" - see // qs_json_take_number_field()'s own comment. bool has_x, has_z, has_yaw_degrees; } QsParsedPlayerPosition; // One string slot per remappable action, indexed by // xr_input_action_index_from_name(). This file does not list the actions // itself; xr_input.c owns them. Sized above the current action count. #define QS_MAX_PLAYER_CONTROL_ACTIONS 96 typedef struct { char bindings[QS_MAX_PLAYER_CONTROL_ACTIONS][24]; // "" = not present in the file } QsParsedPlayerControls; typedef struct { QsParsedVideoOptions video_options; bool has_video_options; QsParsedGeneral general; bool has_general; QsParsedPlayerPosition player_position; bool has_player_position; QsParsedPlayerControls player_controls; bool has_player_controls; } 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 if (strcmp(key, "initial_screen_width_m") == 0) qs_json_take_number_field(p, &out->initial_screen_width_m, &out->has_initial_screen_width_m); else if (strcmp(key, "game_screen_width_m") == 0) qs_json_take_number_field(p, &out->game_screen_width_m, &out->has_game_screen_width_m); else if (strcmp(key, "initial_screen_distance_m") == 0) qs_json_take_number_field(p, &out->initial_screen_distance_m, &out->has_initial_screen_distance_m); else if (strcmp(key, "game_screen_distance_m") == 0) qs_json_take_number_field(p, &out->game_screen_distance_m, &out->has_game_screen_distance_m); else if (strcmp(key, "screen_width_offset_m") == 0) qs_json_take_number_field(p, &out->screen_width_offset_m, &out->has_screen_width_offset_m); else if (strcmp(key, "screen_width_step_m") == 0) qs_json_take_number_field(p, &out->screen_width_step_m, &out->has_screen_width_step_m); else if (strcmp(key, "screen_distance_offset_m") == 0) qs_json_take_number_field(p, &out->screen_distance_offset_m, &out->has_screen_distance_offset_m); else if (strcmp(key, "screen_distance_step_m") == 0) qs_json_take_number_field(p, &out->screen_distance_step_m, &out->has_screen_distance_step_m); else 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_player_position_field(const char *key, QsJsonParser *p, void *ctx) { QsParsedPlayerPosition *out = (QsParsedPlayerPosition *)ctx; if (strcmp(key, "x") == 0) qs_json_take_number_field(p, &out->x, &out->has_x); else if (strcmp(key, "z") == 0) qs_json_take_number_field(p, &out->z, &out->has_z); else if (strcmp(key, "yaw_degrees") == 0) qs_json_take_number_field(p, &out->yaw_degrees, &out->has_yaw_degrees); else qs_json_skip_value(p); } // Looks up `key` in xr_input.c's action-name list instead of a fixed strcmp // chain. static void qs_parse_player_controls_field(const char *key, QsJsonParser *p, void *ctx) { QsParsedPlayerControls *out = (QsParsedPlayerControls *)ctx; int index; if (xr_input_action_index_from_name(key, &index) && index < QS_MAX_PLAYER_CONTROL_ACTIONS) qs_json_take_string_field(p, out->bindings[index], sizeof(out->bindings[index])); 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 if (strcmp(key, "player_position") == 0 && !p->error && p->p < p->end && *p->p == '{') out->has_player_position = qs_json_parse_object(p, qs_parse_player_position_field, &out->player_position); else if (strcmp(key, "player_controls") == 0 && !p->error && p->p < p->end && *p->p == '{') out->has_player_controls = qs_json_parse_object(p, qs_parse_player_controls_field, &out->player_controls); else qs_json_skip_value(p); } // Reads and parses `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_named(const char *filename, QsParsedConfig *out) { memset(out, 0, sizeof(*out)); FILE *f = fopen(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", filename); memset(out, 0, sizeof(*out)); return false; } return true; } static bool qs_config_read_file(QsParsedConfig *out) { return qs_config_read_file_named(QS_CONFIG_FILENAME, out); } // =========================================================================== // 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 } // =========================================================================== // Video Options: screen profile (initial screen / game screen) // =========================================================================== // Live-appliable: xr_session_set_game_screen_shape() (xr_session.c) is a // plain state setter with no XR calls, same category as // xr_session_set_player_transform() - safe to call live or at startup. #define QS_SCREEN_PROFILE_REQUIRES_RESTART false // mainloop.h's own loop-mode #defines - declared locally rather than // #include "mainloop.h", which pulls in an `extern LGRegion *_current_view;` // this file can't resolve (LGRegion isn't visible via any header this file // already includes) - same "avoid a header with declarations this file has // no other reason to need" reasoning as screen_draw()/language_change() // above. These two values must stay in sync with mainloop.h's own // SETUP_LOOP/CUTSCENE_LOOP. #define QS_SETUP_LOOP 4 #define QS_CUTSCENE_LOOP 6 // Each of the two options (width/distance) offers 5 preset buttons in // the Video Options tab, generated as offset+step*index (index 0..4) // rather than a fixed table - see qs_preset_value() below. Shared by // both "Initial Screen" and "Game Screen" (both columns' preset buttons // show the same 5 values per row), config-file-only (no UI control): // unlike the four *_screen_width_m-style current-value statics below // (button-driven, live-appliable, one pair per profile), these only // reshape what the buttons themselves offer. #define QS_NUM_SCREEN_PRESETS 5 static float s_screen_width_offset_m = 1.0f; static float s_screen_width_step_m = 0.6f; static float s_screen_distance_offset_m = 1.0f; static float s_screen_distance_step_m = 0.5f; // Generates preset index `index`'s value (index 0..QS_NUM_SCREEN_PRESETS-1) // for a given offset/step pair - e.g. offset 1.0/step 0.6 generates // 1.0, 1.6, 2.2, 2.8, 3.4 (this feature's original width presets). static float qs_preset_value(float offset, float step, int index) { return offset + step * (float)index; } // Inverse of qs_preset_value() - returns -1 (a legitimate result here, // unlike kResolutions'-style "shouldn't happen" fallbacks - a hand-edited // config file can set any value in between presets) if the current value // doesn't exactly match one of the 5 presets this offset/step pair // generates. static int qs_preset_index(float offset, float step, float value) { for (int i = 0; i < QS_NUM_SCREEN_PRESETS; i++) if (qs_preset_value(offset, step, i) == value) return i; return -1; } // 1.6m/2.0m (qs_preset_value() index 1/2 of the shared offset/step // defaults above) for both profiles - the game screen's own original, // pre-this-feature hardcoded shape (QUAD_DISTANCE_METERS in xr_session.c), // so a config file/build that never touches these renders identically to // before until the player opts in. static float s_initial_screen_width_m = 1.6f; static float s_initial_screen_distance_m = 2.0f; static float s_game_screen_width_m = 1.6f; static float s_game_screen_distance_m = 2.0f; // Which profile xr_session.c's game-screen shape was last set to - so // qs_screen_profile_notify_loop_mode() can skip re-applying anything on a // same-profile transition (e.g. game -> automap -> game), and so a preset // button tap while the *other* profile is active knows to just update the // stored value without touching the live shape (see // qs_config_handle_event()'s own screen-profile branches). static bool s_screen_profile_is_game = false; static float qs_clamp_screen_width(float width_m) { if (width_m < 0.2f) return 0.2f; if (width_m > 10.0f) return 10.0f; return width_m; } static float qs_clamp_screen_distance(float distance_m) { if (distance_m < 0.5f) return 0.5f; if (distance_m > 10.0f) return 10.0f; return distance_m; } static void qs_screen_profile_reapply(void) { if (s_screen_profile_is_game) xr_session_set_game_screen_shape(s_game_screen_width_m, 0.0f, s_game_screen_distance_m); else xr_session_set_game_screen_shape(s_initial_screen_width_m, 0.0f, s_initial_screen_distance_m); } // Called directly from the engine (see android/engine-patches/ // 22-android-screen-profile.patch's hook into mainloop.c's // loopmode_switch(), and qs_config_load_startup() below for the initial // call) - runs on the engine's own thread already, so unlike every other // action in this file (all UI-thread-button-triggered), no cross-thread // SDL-event dispatch is needed here. void qs_screen_profile_notify_loop_mode(short new_mode) { bool isGame = new_mode != QS_SETUP_LOOP && new_mode != QS_CUTSCENE_LOOP; if (isGame == s_screen_profile_is_game) return; s_screen_profile_is_game = isGame; qs_screen_profile_reapply(); } static bool qs_apply_initial_screen_width(float width_m) { width_m = qs_clamp_screen_width(width_m); bool changed = s_initial_screen_width_m != width_m; s_initial_screen_width_m = width_m; if (changed && !s_screen_profile_is_game) qs_screen_profile_reapply(); return changed; } static bool qs_apply_game_screen_width(float width_m) { width_m = qs_clamp_screen_width(width_m); bool changed = s_game_screen_width_m != width_m; s_game_screen_width_m = width_m; if (changed && s_screen_profile_is_game) qs_screen_profile_reapply(); return changed; } static bool qs_apply_initial_screen_distance(float distance_m) { distance_m = qs_clamp_screen_distance(distance_m); bool changed = s_initial_screen_distance_m != distance_m; s_initial_screen_distance_m = distance_m; if (changed && !s_screen_profile_is_game) qs_screen_profile_reapply(); return changed; } static bool qs_apply_game_screen_distance(float distance_m) { distance_m = qs_clamp_screen_distance(distance_m); bool changed = s_game_screen_distance_m != distance_m; s_game_screen_distance_m = distance_m; if (changed && s_screen_profile_is_game) qs_screen_profile_reapply(); return changed; } // =========================================================================== // 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 } // =========================================================================== // General: player position // =========================================================================== // Live-appliable: xr_session_set_player_transform() (xr_session.c) is a // plain state setter with no XR API calls of its own - safe to call before // xr_init() has even run (which is how startup seeding works here, unlike // every other option: nothing in xr_session.c exists yet to "apply" this // to at startup, so the value is simply stored and picked up once // xr_init() creates its own play space - see that function's own comment) // as well as live, mid-session. #define QS_PLAYER_POSITION_REQUIRES_RESTART false // Applies only the fields actually present in *parsed, leaving the other // axis/yaw untouched - e.g. a hand-edited file with just `"yaw_degrees"` // shouldn't reset x/z to 0. Always live-appliable (see above) - the `live` // parameter argument that qs_apply_renderer()/qs_apply_resolution()/ // qs_apply_language() take doesn't apply here, so unlike those this takes // no such argument. static bool qs_apply_player_position(const QsParsedPlayerPosition *parsed) { float x, z, yaw_degrees; xr_session_get_player_transform(&x, &z, &yaw_degrees); bool changed = false; if (parsed->has_x && parsed->x != x) { x = parsed->x; changed = true; } if (parsed->has_z && parsed->z != z) { z = parsed->z; changed = true; } if (parsed->has_yaw_degrees && parsed->yaw_degrees != yaw_degrees) { yaw_degrees = parsed->yaw_degrees; changed = true; } if (changed) xr_session_set_player_transform(x, z, yaw_degrees); return changed; } // =========================================================================== // General: player controls // =========================================================================== // Live-appliable: xr_input_set_action_binding() (xr_input.c) just writes // into the per-frame lookup table xr_input_sync_and_draw() already reads // every frame - no restart distinction needed, same as player_position. #define QS_PLAYER_CONTROLS_REQUIRES_RESTART false // Applies only the actions present in *parsed and leaves the rest untouched. // Invalid raw-input names (a typo, or a symmetric form on the wrong action - // see xr_input_set_action_binding()) are WARN+skipped per action. static bool qs_apply_player_controls(const QsParsedPlayerControls *parsed) { bool changed = false; int count = xr_input_action_count(); if (count > QS_MAX_PLAYER_CONTROL_ACTIONS) count = QS_MAX_PLAYER_CONTROL_ACTIONS; for (int i = 0; i < count; i++) { if (parsed->bindings[i][0] == '\0') continue; int before = xr_input_get_action_binding(i); if (!xr_input_set_action_binding(i, parsed->bindings[i])) { WARN("%s: player_controls.%s=\"%s\" is invalid - ignoring", QS_CONFIG_FILENAME, xr_input_action_name(i), parsed->bindings[i]); continue; } if (xr_input_get_action_binding(i) != before) changed = true; } return changed; } // =========================================================================== // Read-only queries for the Video Options tab (see qs_config.h) // =========================================================================== bool qs_config_current_renderer_is_opengl(void) { return gShockPrefs.doUseOpenGL; } int qs_config_current_resolution_index(void) { for (size_t i = 0; i < QS_NUM_RESOLUTIONS; i++) if (kResolutions[i].mode == mode_id) return (int)i; return 3; // shouldn't happen - 640x480, matches qs_current_resolution()'s own fallback } int qs_config_current_language(void) { return gShockPrefs.goLanguage; } // -1 (no exact preset match) is a legitimate result for all four of these - // see qs_preset_index()'s own comment. int qs_config_current_initial_screen_width_index(void) { return qs_preset_index(s_screen_width_offset_m, s_screen_width_step_m, s_initial_screen_width_m); } int qs_config_current_initial_screen_distance_index(void) { return qs_preset_index(s_screen_distance_offset_m, s_screen_distance_step_m, s_initial_screen_distance_m); } int qs_config_current_game_screen_width_index(void) { return qs_preset_index(s_screen_width_offset_m, s_screen_width_step_m, s_game_screen_width_m); } int qs_config_current_game_screen_distance_index(void) { return qs_preset_index(s_screen_distance_offset_m, s_screen_distance_step_m, s_game_screen_distance_m); } // Returns the value preset button `index` (0..QS_NUM_SCREEN_PRESETS-1) // currently shows for a given option - shared by both profiles' columns // (see the offset/step statics' own comment) - so MenuOverlay.java can // label its preset buttons correctly even after a config-file offset/step // change. Out-of-range index clamps into range rather than returning a // sentinel: unlike the index getters above, there's no "not present" // concept here - every index 0..4 always has a value. static int qs_clamp_preset_index(int index) { if (index < 0) return 0; if (index >= QS_NUM_SCREEN_PRESETS) return QS_NUM_SCREEN_PRESETS - 1; return index; } float qs_config_screen_width_preset_value(int index) { return qs_preset_value(s_screen_width_offset_m, s_screen_width_step_m, qs_clamp_preset_index(index)); } float qs_config_screen_distance_preset_value(int index) { return qs_preset_value(s_screen_distance_offset_m, s_screen_distance_step_m, qs_clamp_preset_index(index)); } // =========================================================================== // 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) { 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.has_initial_screen_width_m) { bool changed = qs_apply_initial_screen_width(parsed->video_options.initial_screen_width_m); if (changed && QS_SCREEN_PROFILE_REQUIRES_RESTART) qs_append_restart_reason(restart_needed, restart_needed_size, "initial_screen_width_m"); } if (parsed->video_options.has_game_screen_width_m) { bool changed = qs_apply_game_screen_width(parsed->video_options.game_screen_width_m); if (changed && QS_SCREEN_PROFILE_REQUIRES_RESTART) qs_append_restart_reason(restart_needed, restart_needed_size, "game_screen_width_m"); } if (parsed->video_options.has_initial_screen_distance_m) { bool changed = qs_apply_initial_screen_distance(parsed->video_options.initial_screen_distance_m); if (changed && QS_SCREEN_PROFILE_REQUIRES_RESTART) qs_append_restart_reason(restart_needed, restart_needed_size, "initial_screen_distance_m"); } if (parsed->video_options.has_game_screen_distance_m) { bool changed = qs_apply_game_screen_distance(parsed->video_options.game_screen_distance_m); if (changed && QS_SCREEN_PROFILE_REQUIRES_RESTART) qs_append_restart_reason(restart_needed, restart_needed_size, "game_screen_distance_m"); } // The four offset/step fields below only reshape what the preset // buttons offer (see their own statics' comment) - no live-reapply // or restart consideration needed, just a direct assignment. if (parsed->video_options.has_screen_width_offset_m) s_screen_width_offset_m = parsed->video_options.screen_width_offset_m; if (parsed->video_options.has_screen_width_step_m) s_screen_width_step_m = parsed->video_options.screen_width_step_m; if (parsed->video_options.has_screen_distance_offset_m) s_screen_distance_offset_m = parsed->video_options.screen_distance_offset_m; if (parsed->video_options.has_screen_distance_step_m) s_screen_distance_step_m = parsed->video_options.screen_distance_step_m; } 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"); } } if (parsed->has_player_position) { bool changed = qs_apply_player_position(&parsed->player_position); if (changed && QS_PLAYER_POSITION_REQUIRES_RESTART) qs_append_restart_reason(restart_needed, restart_needed_size, "player_position"); } if (parsed->has_player_controls) { bool changed = qs_apply_player_controls(&parsed->player_controls); if (changed && QS_PLAYER_CONTROLS_REQUIRES_RESTART) qs_append_restart_reason(restart_needed, restart_needed_size, "player_controls"); } } 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, " \"initial_screen_width_m\": %f,\n", s_initial_screen_width_m); fprintf(f, " \"initial_screen_distance_m\": %f,\n", s_initial_screen_distance_m); fprintf(f, " \"game_screen_width_m\": %f,\n", s_game_screen_width_m); fprintf(f, " \"game_screen_distance_m\": %f,\n", s_game_screen_distance_m); fprintf(f, " \"screen_width_offset_m\": %f,\n", s_screen_width_offset_m); fprintf(f, " \"screen_width_step_m\": %f,\n", s_screen_width_step_m); fprintf(f, " \"screen_distance_offset_m\": %f,\n", s_screen_distance_offset_m); fprintf(f, " \"screen_distance_step_m\": %f\n", s_screen_distance_step_m); fprintf(f, " },\n"); fprintf(f, " \"general\": {\n"); fprintf(f, " \"language\": \"%s\"\n", qs_current_language_name()); fprintf(f, " },\n"); { int count = xr_input_action_count(); fprintf(f, " \"player_controls\": {\n"); for (int i = 0; i < count; i++) fprintf(f, " \"%s\": \"%s\"%s\n", xr_input_action_name(i), xr_input_get_action_binding_name(i), i + 1 < count ? "," : ""); fprintf(f, " },\n"); } { float x, z, yaw_degrees; xr_session_get_player_transform(&x, &z, &yaw_degrees); fprintf(f, " \"player_position\": {\n"); fprintf(f, " \"x\": %f,\n", x); fprintf(f, " \"z\": %f,\n", z); fprintf(f, " \"yaw_degrees\": %f\n", yaw_degrees); 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; } // 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) return; jclass menuOverlayClass = qs_find_class(env, activity, "de.ladkau.questshock.MenuOverlay"); if (!menuOverlayClass) return; 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); 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); } // Non-cancelable, single-"Exit"-button dialog (MenuOverlay.java's // showFatalControllerBindingError()) - see qs_config_report_fatal_input_error()'s // own comment (qs_config.h) on when/why this fires. void qs_config_report_fatal_input_error(const char *profile_display_name) { char message[256]; snprintf(message, sizeof(message), "Controller support failed to initialize: the OpenXR runtime rejected the \"%s\" " "interaction profile's bindings. This is an app bug, not something you can fix.", profile_display_name); qs_call_dialog_method("showFatalControllerBindingError", message); } // Shared by qs_config_notify_control_capture_result() below - resolves // MenuOverlay.onControlCaptureResult(int, String), called once per changed // row (up to twice per capture - see this function's own header comment). static void qs_call_control_capture_result(int action_index, const char *raw_name) { 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 method = (*env)->GetStaticMethodID(env, menuOverlayClass, "onControlCaptureResult", "(ILjava/lang/String;)V"); if (!method) { (*env)->ExceptionClear(env); return; } jstring rawNameStr = raw_name ? (*env)->NewStringUTF(env, raw_name) : NULL; (*env)->CallStaticVoidMethod(env, menuOverlayClass, method, (jint)action_index, rawNameStr); if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); } void qs_config_notify_control_capture_result(int action_index, const char *raw_name, int displaced_action_index, const char *displaced_raw_name) { qs_call_control_capture_result(action_index, raw_name); if (displaced_action_index >= 0) qs_call_control_capture_result(displaced_action_index, displaced_raw_name); } // =========================================================================== // Public API (see qs_config.h) // =========================================================================== 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; static Uint32 s_reset_position_event_type; static Uint32 s_set_initial_screen_width_event_type; static Uint32 s_set_initial_screen_distance_event_type; static Uint32 s_set_game_screen_width_event_type; static Uint32 s_set_game_screen_distance_event_type; static Uint32 s_begin_control_capture_event_type; static Uint32 s_cancel_control_capture_event_type; static Uint32 s_reset_player_controls_event_type; static Uint32 s_menu_visibility_event_type; static Uint32 s_cheat_event_type; void qs_config_load_startup(void) { // Registers the custom SDL event types qs_config_request_save()/_load()/ // _set_renderer()/_set_resolution()/_set_language()/_reset_position()/ // the four _set_*_screen_*() screen-profile setters/the three // control-capture/reset requests 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(15); 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; s_reset_position_event_type = base + 5; s_set_initial_screen_width_event_type = base + 6; s_set_game_screen_width_event_type = base + 7; s_set_initial_screen_distance_event_type = base + 8; s_set_game_screen_distance_event_type = base + 9; s_begin_control_capture_event_type = base + 10; s_cancel_control_capture_event_type = base + 11; s_reset_player_controls_event_type = base + 12; s_menu_visibility_event_type = base + 13; s_cheat_event_type = base + 14; QsParsedConfig parsed; bool haveConfig = qs_config_read_file(&parsed); if (!haveConfig) haveConfig = qs_config_read_file_named(QS_CONFIG_DEFAULT_FILENAME, &parsed); if (haveConfig) qs_config_apply_parsed(&parsed, /*live=*/false, NULL, 0); // A saved player_position only means anything if all three fields are // present (a partial one - e.g. just "yaw_degrees" from a hand-edited // file - isn't a complete, sane transform) - xr_input.c's own // automatic startup recenter (xr_input_recenter_player_to_screen(), // via g_pending_startup_recenter) is the fallback for a missing file, // a missing player_position group, or an incomplete one; skip it here // once a complete saved position was actually loaded, so it doesn't // immediately override what was just applied above. if (parsed.has_player_position && parsed.player_position.has_x && parsed.player_position.has_z && parsed.player_position.has_yaw_degrees) xr_input_skip_startup_recenter(); // The engine's own startup state is always QS_SETUP_LOOP (mainloop.c: // `short _current_loop = SETUP_LOOP;`, Shock.c's main() never changes // it before entering mainloop()), matching s_screen_profile_is_game's // own default (false/"initial") - so calling // qs_screen_profile_notify_loop_mode() here would see "no change" and // skip actually applying anything (its whole point is to skip // redundant re-applies on a same-profile transition, but there's no // "previous" profile yet this early). Call the apply step directly // instead, unconditionally, so whichever profile's shape was just // loaded/defaulted above actually reaches xr_session.c before the // first frame renders. qs_screen_profile_reapply(); } 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); } void qs_config_request_set_renderer(bool opengl) { SDL_Event event; SDL_zero(event); event.type = s_set_renderer_event_type; event.user.code = opengl ? 1 : 0; SDL_PushEvent(&event); } void qs_config_request_set_resolution(int resolution_index) { SDL_Event event; SDL_zero(event); event.type = s_set_resolution_event_type; event.user.code = 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); } void qs_config_request_reset_position(void) { SDL_Event event; SDL_zero(event); event.type = s_reset_position_event_type; SDL_PushEvent(&event); } // Screen profile preset buttons (Video Options tab) - index into the // shared width/distance offset+step-generated presets (see // qs_preset_value()'s own comment), same enqueue-only reasoning as every // other button in this file. void qs_config_request_set_initial_screen_width(int preset_index) { SDL_Event event; SDL_zero(event); event.type = s_set_initial_screen_width_event_type; event.user.code = preset_index; SDL_PushEvent(&event); } void qs_config_request_set_game_screen_width(int preset_index) { SDL_Event event; SDL_zero(event); event.type = s_set_game_screen_width_event_type; event.user.code = preset_index; SDL_PushEvent(&event); } void qs_config_request_set_initial_screen_distance(int preset_index) { SDL_Event event; SDL_zero(event); event.type = s_set_initial_screen_distance_event_type; event.user.code = preset_index; SDL_PushEvent(&event); } void qs_config_request_set_game_screen_distance(int preset_index) { SDL_Event event; SDL_zero(event); event.type = s_set_game_screen_distance_event_type; event.user.code = preset_index; SDL_PushEvent(&event); } // Controller Options tab (see qs_config.h) - same enqueue-only reasoning as // every button above. void qs_config_request_begin_control_capture(int action_index) { SDL_Event event; SDL_zero(event); event.type = s_begin_control_capture_event_type; event.user.code = action_index; SDL_PushEvent(&event); } void qs_config_request_cancel_control_capture(void) { SDL_Event event; SDL_zero(event); event.type = s_cancel_control_capture_event_type; SDL_PushEvent(&event); } void qs_config_request_cheat(int cheat_id) { SDL_Event event; SDL_zero(event); event.type = s_cheat_event_type; event.user.code = cheat_id; SDL_PushEvent(&event); } void qs_config_notify_menu_visibility(bool visible) { SDL_Event event; SDL_zero(event); event.type = s_menu_visibility_event_type; event.user.code = visible ? 1 : 0; SDL_PushEvent(&event); } void qs_config_request_reset_player_controls(void) { SDL_Event event; SDL_zero(event); event.type = s_reset_player_controls_event_type; SDL_PushEvent(&event); } // hkeyfunc.c - the same toggle the game's own "pause" hotkey ('p') calls, // which also handles the mouse capture/cursor/sound side of pausing. extern uchar game_paused; extern uchar pause_game_func(ushort keycode, uint32_t context, intptr_t data); // True only while a pause this file caused is (as far as it knows) still in // effect - see qs_config_notify_menu_visibility(). static bool s_menu_paused_game = false; // s_screen_profile_is_game is "a game is running" (anything but the setup/ // main-menu and cutscene loops, see qs_screen_profile_notify_loop_mode()). static void qs_apply_menu_pause(bool menu_visible) { if (menu_visible) { if (s_screen_profile_is_game && !game_paused) { pause_game_func(0, 0, 0); s_menu_paused_game = true; } } else if (s_menu_paused_game) { s_menu_paused_game = false; // Only toggle back if it's still paused - the game may have // unpaused itself (or the player did) while the menu was open, and // toggling then would pause it instead. if (game_paused) pause_game_func(0, 0, 0); } } // hkeyfunc.c - the engine's own cheat hotkey handlers, and the refill added by // android/engine-patches/26-android-cheat-refill.patch. extern uchar toggle_giveall_func(ushort keycode, uint32_t context, intptr_t data); extern uchar toggle_physics_func(ushort keycode, uint32_t context, intptr_t data); extern uchar toggle_up_level_func(ushort keycode, uint32_t context, intptr_t data); extern uchar toggle_down_level_func(ushort keycode, uint32_t context, intptr_t data); extern void qs_cheat_refill(int what); extern uchar physics_running; #define QS_CHEAT_GIVE_ALL 0 #define QS_CHEAT_PHYSICS 1 #define QS_CHEAT_LEVEL_UP 2 #define QS_CHEAT_LEVEL_DOWN 3 #define QS_CHEAT_UNLIMITED_HEALTH 4 #define QS_CHEAT_UNLIMITED_ENERGY 5 #define QS_CHEAT_UNLIMITED_AMMO 6 #define QS_CHEAT_UNLIMITED_ITEMS 7 // Bits of s_cheat_unlimited; the values match qs_cheat_refill() in patch 26. #define QS_CHEAT_MASK_HEALTH 1 #define QS_CHEAT_MASK_ENERGY 2 #define QS_CHEAT_MASK_AMMO 4 #define QS_CHEAT_MASK_ITEMS 8 static int s_cheat_unlimited = 0; int qs_config_cheat_unlimited_mask(void) { return s_cheat_unlimited; } bool qs_config_cheat_physics_running(void) { return physics_running != 0; } void qs_config_frame_tick(void) { if (s_cheat_unlimited && s_screen_profile_is_game) qs_cheat_refill(s_cheat_unlimited); } static void qs_apply_cheat(int cheat_id) { if (cheat_id >= QS_CHEAT_UNLIMITED_HEALTH && cheat_id <= QS_CHEAT_UNLIMITED_ITEMS) { static const struct { int mask; const char *on, *off; } kUnlimited[] = { {QS_CHEAT_MASK_HEALTH, "Unlimited health on", "Unlimited health off"}, {QS_CHEAT_MASK_ENERGY, "Unlimited energy on", "Unlimited energy off"}, {QS_CHEAT_MASK_AMMO, "Unlimited ammo on", "Unlimited ammo off"}, {QS_CHEAT_MASK_ITEMS, "Unlimited items on", "Unlimited items off"}, }; const int i = cheat_id - QS_CHEAT_UNLIMITED_HEALTH; s_cheat_unlimited ^= kUnlimited[i].mask; message_info((s_cheat_unlimited & kUnlimited[i].mask) ? kUnlimited[i].on : kUnlimited[i].off); return; } // The engine's own handlers assume a running game (player state, levels). if (!s_screen_profile_is_game) { message_info("Cheats need a running game"); return; } switch (cheat_id) { case QS_CHEAT_GIVE_ALL: toggle_giveall_func(0, 0, 0); break; case QS_CHEAT_PHYSICS: toggle_physics_func(0, 0, 0); break; case QS_CHEAT_LEVEL_UP: toggle_up_level_func(0, 0, 0); break; case QS_CHEAT_LEVEL_DOWN: toggle_down_level_func(0, 0, 0); break; } } 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; } if (event->type == s_set_renderer_event_type) { qs_apply_renderer(event->user.code ? "opengl" : "software", /*live=*/true); return true; } if (event->type == s_set_resolution_event_type) { int index = event->user.code; if (index >= 0 && (size_t)index < QS_NUM_RESOLUTIONS) 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; } if (event->type == s_reset_position_event_type) { // Re-anchors the position onto the player's current real head position // and keeps their facing (see xr_input_reset_player_position()). Does // not read or write questshock.config.json. xr_input_reset_player_position(); message_info("Recentered"); return true; } if (event->type == s_set_initial_screen_width_event_type) { int index = event->user.code; if (index >= 0 && index < QS_NUM_SCREEN_PRESETS) qs_apply_initial_screen_width( qs_preset_value(s_screen_width_offset_m, s_screen_width_step_m, index)); return true; } if (event->type == s_set_game_screen_width_event_type) { int index = event->user.code; if (index >= 0 && index < QS_NUM_SCREEN_PRESETS) qs_apply_game_screen_width( qs_preset_value(s_screen_width_offset_m, s_screen_width_step_m, index)); return true; } if (event->type == s_set_initial_screen_distance_event_type) { int index = event->user.code; if (index >= 0 && index < QS_NUM_SCREEN_PRESETS) qs_apply_initial_screen_distance( qs_preset_value(s_screen_distance_offset_m, s_screen_distance_step_m, index)); return true; } if (event->type == s_set_game_screen_distance_event_type) { int index = event->user.code; if (index >= 0 && index < QS_NUM_SCREEN_PRESETS) qs_apply_game_screen_distance( qs_preset_value(s_screen_distance_offset_m, s_screen_distance_step_m, index)); return true; } if (event->type == s_begin_control_capture_event_type) { xr_input_capture_begin(event->user.code); return true; } if (event->type == s_cancel_control_capture_event_type) { xr_input_capture_cancel(); return true; } if (event->type == s_cheat_event_type) { qs_apply_cheat(event->user.code); return true; } if (event->type == s_menu_visibility_event_type) { qs_apply_menu_pause(event->user.code != 0); return true; } if (event->type == s_reset_player_controls_event_type) { // Built-in defaults first (they cover every action), then // questshock.config.default.json's player_controls group, if it has // one, overrides the actions it specifies. xr_input_reset_player_controls(); QsParsedConfig defaultParsed; if (qs_config_read_file_named(QS_CONFIG_DEFAULT_FILENAME, &defaultParsed) && defaultParsed.has_player_controls) qs_apply_player_controls(&defaultParsed.player_controls); message_info("Controller bindings reset to defaults"); return true; } return false; }