Add remappable controller controls with a Controller Options tab
build / build (push) Successful in 2m14s

Add a player_controls config group that maps game actions to controller
inputs, and a Controller Options menu tab to rebind them in-headset.

- 45 actions: movement, click/double-click, jump, fire, and the engine's own
  keyboard hotkeys (posture, save/load, weapon/MFD select, etc.), sent to the
  engine as synthetic key events. Cheat and dead hotkeys are not exposed.
- Press-to-capture rebinding with per-input exclusivity, Reset to Defaults
  (built-in defaults, then questshock.config.default.json), and new defaults:
  left stick moves/strafes, right stick turns.
- Controller profiles for Touch, Vive and generic OpenXR
  (res/controller_profiles/), matched to the active interaction profile at
  runtime, with a fatal dialog if a profile's bindings are rejected at
  startup.
- The menu's thumbstick locomotion is disabled while the Controller Options
  tab is open or a capture is active. A scrollable list with a tap-to-jump
  scroll bar handles the long action list.
- Opening the menu pauses a running game and resumes it on close.
- Reset Position now keeps the player's facing instead of turning the screen
  to the current gaze.
- Play the intro video in the game's language (engine patch 23), falling back
  to English.
- Watchdog and shared-key handling for stuck movement keys, plus key-edge
  logging.
This commit is contained in:
ml
2026-09-20 06:08:50 +02:00
parent e1e6a006d4
commit c24b7f15cb
21 changed files with 2489 additions and 392 deletions
+217 -15
View File
@@ -369,6 +369,14 @@ typedef struct {
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;
@@ -376,6 +384,8 @@ typedef struct {
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) {
@@ -428,6 +438,17 @@ static void qs_parse_player_position_field(const char *key, QsJsonParser *p, voi
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 == '{')
@@ -437,6 +458,9 @@ static void qs_parse_top_level_field(const char *key, QsJsonParser *p, void *ctx
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);
}
@@ -874,6 +898,38 @@ static bool qs_apply_player_position(const QsParsedPlayerPosition *parsed) {
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)
// ===========================================================================
@@ -1013,6 +1069,11 @@ static void qs_config_apply_parsed(const QsParsedConfig *parsed, bool live, char
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) {
@@ -1037,6 +1098,14 @@ static bool qs_config_write_file(void) {
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);
@@ -1117,6 +1186,52 @@ static void qs_show_restart_dialog(const char *restart_needed) {
// 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)
// ===========================================================================
@@ -1131,16 +1246,21 @@ 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;
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 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);
// 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(14);
s_save_event_type = base;
s_load_event_type = base + 1;
s_set_renderer_event_type = base + 2;
@@ -1151,6 +1271,10 @@ void qs_config_load_startup(void) {
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;
QsParsedConfig parsed;
bool haveConfig = qs_config_read_file(&parsed);
@@ -1266,6 +1390,65 @@ void qs_config_request_set_game_screen_distance(int 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_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);
}
}
bool qs_config_handle_event(const SDL_Event *event) {
if (event->type == s_save_event_type) {
if (qs_config_write_file())
@@ -1305,15 +1488,10 @@ bool qs_config_handle_event(const SDL_Event *event) {
return true;
}
if (event->type == s_reset_position_event_type) {
// Deliberately not xr_session_set_player_transform(0,0,0) - see
// xr_input_recenter_player_to_screen()'s own comment: a "return to
// (0,0,0)" reset only undoes thumbstick locomotion, not any real,
// physical drift/rotation since the session started, which is what
// actually made this feel "slightly off" - recentering onto the
// player's current real head pose fixes both at once, and is
// unrelated to questshock.config.json (this event doesn't read or
// write the config file at all, just like this one never has).
xr_input_recenter_player_to_screen();
// 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;
}
@@ -1345,5 +1523,29 @@ bool qs_config_handle_event(const SDL_Event *event) {
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_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;
}