Files
questshock/android/app/src/main/cpp/qs_config.c
T
ml 70c43240bc
build / build (push) Successful in 3m42s
Add a Cheats menu tab with engine cheat buttons and unlimited-resource switches
The new Cheats tab exposes the engine's own cheat handlers as buttons:
give all items and weapons, toggle physics, next level and previous
level. Physics shows its state through the button highlight. The buttons
need a running game and show a message otherwise.

It also has four independent session-only switches for unlimited health,
energy, ammo and items (grenades and drugs). While a switch is on, its
resource is topped up every frame by qs_cheat_refill() from the new
engine patch 26. The switches are not saved to the config.
2026-09-20 14:59:33 +02:00

1637 lines
66 KiB
C

// 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 <ctype.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <SDL.h>
#include <jni.h>
#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;
}