Add configurable game/initial screen width and distance, with a bundled first-run config
build / build (push) Successful in 2m41s

- Video Options tab offers width and distance presets independently for
  the Initial Screen (splash/intro/menu) and Game Screen (gameplay)
  profiles, with their preset step/offset configurable via
  questshock.config.json.
- Ships res/questshock.config.default.json as the initial config on
  first run (or whenever questshock.config.json doesn't exist yet),
  refreshed from the APK on every launch without touching a user's own
  saved config.
This commit is contained in:
ml
2026-09-06 09:00:45 +02:00
parent 5fe4cdf7f8
commit e1e6a006d4
14 changed files with 1052 additions and 91 deletions
+377 -14
View File
@@ -28,6 +28,10 @@
#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
@@ -330,6 +334,27 @@ static void qs_json_take_number_field(QsJsonParser *p, float *out, bool *has) {
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 {
@@ -359,6 +384,26 @@ static void qs_parse_video_options_field(const char *key, QsJsonParser *p, void
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);
}
@@ -396,13 +441,13 @@ static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx
qs_json_skip_value(p);
}
// Reads and parses QS_CONFIG_FILENAME into *out. Returns false (with *out
// zeroed) if the file doesn't exist, is empty, or fails to parse as valid
// JSON - logging a WARN only for the last case, since a missing file is the
// 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(QsParsedConfig *out) {
static bool qs_config_read_file_named(const char *filename, QsParsedConfig *out) {
memset(out, 0, sizeof(*out));
FILE *f = fopen(QS_CONFIG_FILENAME, "rb");
FILE *f = fopen(filename, "rb");
if (!f)
return false;
fseek(f, 0, SEEK_END);
@@ -425,13 +470,17 @@ static bool qs_config_read_file(QsParsedConfig *out) {
bool ok = qs_json_parse_object(&parser, qs_parse_top_level_field, out) && !parser.error;
free(buf);
if (!ok) {
WARN("%s: malformed JSON - ignoring file", QS_CONFIG_FILENAME);
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
// ===========================================================================
@@ -531,6 +580,149 @@ static const char *qs_current_resolution(void) {
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
// ===========================================================================
@@ -697,6 +889,45 @@ int qs_config_current_resolution_index(void) {
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
// ===========================================================================
@@ -734,6 +965,41 @@ static void qs_config_apply_parsed(const QsParsedConfig *parsed, bool live, char
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') {
@@ -758,7 +1024,15 @@ static bool qs_config_write_file(void) {
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, " \"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());
@@ -853,24 +1127,36 @@ 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;
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()
// 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(6);
// _set_renderer()/_set_resolution()/_set_language()/_reset_position()/
// the four _set_*_screen_*() screen-profile setters 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(10);
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;
QsParsedConfig parsed;
if (qs_config_read_file(&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
@@ -884,6 +1170,19 @@ void qs_config_load_startup(void) {
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) {
@@ -931,6 +1230,42 @@ void qs_config_request_reset_position(void) {
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);
}
bool qs_config_handle_event(const SDL_Event *event) {
if (event->type == s_save_event_type) {
if (qs_config_write_file())
@@ -982,5 +1317,33 @@ bool qs_config_handle_event(const SDL_Event *event) {
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;
}
return false;
}