From c60abba2e502ca325af245588f455cabba803efb Mon Sep 17 00:00:00 2001 From: chrg6 Date: Wed, 11 Nov 2026 17:28:07 -0500 Subject: [PATCH] Count a gamepad axis only once it has been seen at rest. A device that reports an axis held from the start (a virtual pad nobody holds, a trigger or pedal mapped onto a stick, a stick resting far off its middle) turned the camera or moved Link on its own, and since pad_active counted it as pressed, it could take the gamepad role from the controller in the player's hands whenever that one was idle, and keep it. input.c now reads every axis through pad_axis: 0 until the axis has been within a quarter of its range of rest (a trigger's rest is 0), then the device's value. The game's sticks and triggers, the menu's stick readout and the choice of gamepad all use it. A stick the player holds at the start counts once it has been let go. Checked: the runtime compiles; new tests/pad_rest_check.py (pad_axis cut from input.c, SDL stood in for) passes: a held axis reads 0 for 100 polls, a released one reads the device, and axes and gamepads keep separate states; tests/mouse_stick_check.py still passes. Not run with a real device. Co-Authored-By: Claude Opus 5.5 --- docs/building.md | 3 ++ runtime/input.c | 46 +++++++++++++++------ tests/pad_rest_check.py | 90 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 tests/pad_rest_check.py diff --git a/docs/building.md b/docs/building.md index a7a3bb74..c4c73f5d 100644 --- a/docs/building.md +++ b/docs/building.md @@ -452,6 +452,9 @@ A change to the mouse camera's stick deflection (`runtime/input_map.c` `input_mo deflection at each camera speed: a slow movement is divided by the factor, a fast flick reaches full tilt. No game files. +A change to how a gamepad's axes are read (`runtime/input.c` `pad_axis`: an axis counts once it has been seen at rest) +passes `python tests\pad_rest_check.py`, which builds that function with stand-ins for SDL. No game files. + A change to which render passes count as drawn frame after frame (`runtime/vk_target_frames.h`: a draw into one is skipped while its pipeline builds, a draw into any other waits) passes `python tests\target_frames_check.py`, which builds the header alone and replays an area change after a loading screen from a player's log: the scene, probe and diff --git a/runtime/input.c b/runtime/input.c index bda39037..e1547b95 100644 --- a/runtime/input.c +++ b/runtime/input.c @@ -498,16 +498,13 @@ static int sdl_enter(void) { static void sdl_leave(void) { plat_mutex_unlock(&sdl_lock); } -static void stick(SDL_GamepadAxis ax, SDL_GamepadAxis ay, float dead, int32_t out[2]) { - input_pad_stick(SDL_GetGamepadAxis(pad, ax), -(int32_t)SDL_GetGamepadAxis(pad, ay), dead, out); -} - // Every connected gamepad is kept open; `pad` is the one the game reads (and the one that rumbles and feeds motion): // the first one found, then whichever one the player presses (pad_follow). Taking only the first gamepad SDL lists // left the game deaf when that one was a device nobody holds: a virtual pad of Steam Input or DS4Windows, a wheel, a // receiver's idle second slot, listed before the controller in the player's hands. enum { MAX_PADS = 8 }; static SDL_Gamepad *pads[MAX_PADS]; +static uint8_t pad_rest[MAX_PADS]; // per open gamepad, bit a: axis a has been seen at rest (pad_axis) static int npads; static int pad_pressed_logged; // the primary's first press was logged (once per primary) @@ -552,7 +549,8 @@ static void scan_pads(void) { for (int k = 0; k < 4; k++) plat_xchg32(&pad_labels[k], PAD_LABEL_NONE); } SDL_CloseGamepad(pads[i]); - pads[i] = pads[--npads]; + --npads; + pads[i] = pads[npads], pad_rest[i] = pad_rest[npads]; } int n = 0; SDL_JoystickID *ids = SDL_GetGamepads(&n); @@ -562,6 +560,7 @@ static void scan_pads(void) { if (open) continue; SDL_Gamepad *p = SDL_OpenGamepad(ids[i]); if (!p) { rt_log("input: gamepad %u could not be opened (%s)", (unsigned)ids[i], SDL_GetError()); continue; } + pad_rest[npads] = 0; pads[npads++] = p; if (pad) rt_log("input: gamepad \"%s\" connected (the game switches to it at its first press)", SDL_GetGamepadName(p)); } @@ -573,12 +572,35 @@ static void scan_pads(void) { } } -// Under lock: 1 when p has a button down, a trigger pulled or a stick past half way (a drifting stick does not count). +// Under lock: axis a of open gamepad p, or 0 until the axis has been seen at rest (within a quarter of its range of the +// middle; a trigger's rest is 0). A device that reports an axis held from the start (a virtual pad nobody holds, a +// trigger or pedal mapped onto a stick, a stick that rests far off its middle) would otherwise turn the camera on its +// own, and, counted as pressed, take the gamepad role from the controller in the player's hands for good (pad_follow). +// A stick the player holds at the start counts once it has been let go. +enum { AXIS_REST = 8192 }; +static int16_t pad_axis(SDL_Gamepad *p, SDL_GamepadAxis a) { + int16_t v = SDL_GetGamepadAxis(p, a); + for (int i = 0; i < npads; i++) { + if (pads[i] != p) continue; + if (pad_rest[i] >> a & 1) return v; + if (abs(v) >= AXIS_REST) return 0; + pad_rest[i] |= (uint8_t)(1u << a); + return v; + } + return v; +} + +static void stick(SDL_GamepadAxis ax, SDL_GamepadAxis ay, float dead, int32_t out[2]) { + input_pad_stick(pad_axis(pad, ax), -(int32_t)pad_axis(pad, ay), dead, out); +} + +// Under lock: 1 when p has a button down, a trigger pulled or a stick past half way (a drifting stick does not count, +// nor an axis not yet seen at rest). static int pad_active(SDL_Gamepad *p) { for (int b = 0; b < SDL_GAMEPAD_BUTTON_COUNT; b++) if (SDL_GetGamepadButton(p, (SDL_GamepadButton)b)) return 1; for (int a = 0; a < SDL_GAMEPAD_AXIS_COUNT; a++) - if (abs(SDL_GetGamepadAxis(p, (SDL_GamepadAxis)a)) > PAD_USED) return 1; + if (abs(pad_axis(p, (SDL_GamepadAxis)a)) > PAD_USED) return 1; return 0; } @@ -738,8 +760,8 @@ static int pad_physical(uint8_t pd[PAD_COUNT]) { pad_follow(); if (!pad || !SDL_GamepadConnected(pad)) return 0; for (int p = PAD_SOUTH; p < PAD_ZL; p++) pd[p] = SDL_GetGamepadButton(pad, pad_sdl[p]); - pd[PAD_ZL] = SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) > TRIGGER_ON; - pd[PAD_ZR] = SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) > TRIGGER_ON; + pd[PAD_ZL] = pad_axis(pad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) > TRIGGER_ON; + pd[PAD_ZR] = pad_axis(pad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) > TRIGGER_ON; for (int p = PAD_ZR + 1; p < PAD_COUNT; p++) pd[p] = SDL_GetGamepadButton(pad, pad_sdl[p]); // false without paddles if (!pad_pressed_logged) { // once per primary: the log then shows that its presses arrive for (int p = PAD_SOUTH; p < PAD_COUNT && !pad_pressed_logged; p++) @@ -883,10 +905,8 @@ int input_pad_stick_percent(double pct[2]) { } else #endif if (sdl_pad) { // the game's poll keeps the state fresh (sdl_enter) - pct[0] = input_stick_percent(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX), - SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTY)); - pct[1] = input_stick_percent(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_RIGHTX), - SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_RIGHTY)); + pct[0] = input_stick_percent(pad_axis(pad, SDL_GAMEPAD_AXIS_LEFTX), pad_axis(pad, SDL_GAMEPAD_AXIS_LEFTY)); + pct[1] = input_stick_percent(pad_axis(pad, SDL_GAMEPAD_AXIS_RIGHTX), pad_axis(pad, SDL_GAMEPAD_AXIS_RIGHTY)); ok = 1; } if (in) sdl_leave(); diff --git a/tests/pad_rest_check.py b/tests/pad_rest_check.py new file mode 100644 index 00000000..5ead782c --- /dev/null +++ b/tests/pad_rest_check.py @@ -0,0 +1,90 @@ +"""A gamepad axis counts only once it has been seen at rest (runtime/input.c pad_axis): builds pad_axis, cut from +input.c by its text, with stand-ins for the SDL calls it makes, and checks that + + - an axis held from the start (a trigger mapped onto a stick, a virtual pad that reports a held axis) reads 0, and + keeps reading 0 while it stays held; + - once it has been at rest it reads what the device reports, held or not; + - each axis and each gamepad has its own state. + +No game files, no SDL. Usage: python tests/pad_rest_check.py [--cc ] +""" +import argparse +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MSYS_BIN = Path(r'C:\msys64\ucrt64\bin') + +HARNESS = r''' +#include +#include +#include +typedef struct SDL_Gamepad { int16_t axis[6]; } SDL_Gamepad; +typedef enum { SDL_GAMEPAD_AXIS_LEFTX, SDL_GAMEPAD_AXIS_LEFTY, SDL_GAMEPAD_AXIS_RIGHTX, SDL_GAMEPAD_AXIS_RIGHTY, + SDL_GAMEPAD_AXIS_LEFT_TRIGGER, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER } SDL_GamepadAxis; +static int16_t SDL_GetGamepadAxis(SDL_Gamepad *p, SDL_GamepadAxis a) { return p->axis[a]; } +enum { MAX_PADS = 8 }; +static SDL_Gamepad *pads[MAX_PADS]; +static uint8_t pad_rest[MAX_PADS]; +static int npads; +%s +static int fails; +#define CHECK(cond, ...) do { if (!(cond)) { printf("FAIL: " __VA_ARGS__); putchar('\n'); fails++; } } while (0) +int main(void) { + SDL_Gamepad real = {{0}}, ghost = {{0}}; + pads[0] = &ghost, pads[1] = &real, npads = 2; + ghost.axis[SDL_GAMEPAD_AXIS_RIGHTX] = 32767; // held from the start, for good + real.axis[SDL_GAMEPAD_AXIS_RIGHTX] = 20000; // held at the start by the player + for (int i = 0; i < 100; i++) { + CHECK(pad_axis(&ghost, SDL_GAMEPAD_AXIS_RIGHTX) == 0, "a held axis read %%d at poll %%d", pad_axis(&ghost, SDL_GAMEPAD_AXIS_RIGHTX), i); + CHECK(pad_axis(&real, SDL_GAMEPAD_AXIS_RIGHTX) == 0, "the player's held stick read before release"); + } + CHECK(pad_axis(&real, SDL_GAMEPAD_AXIS_LEFTX) == 0 && pad_axis(&real, SDL_GAMEPAD_AXIS_LEFTY) == 0, "resting axes"); + real.axis[SDL_GAMEPAD_AXIS_LEFTX] = -30000; + CHECK(pad_axis(&real, SDL_GAMEPAD_AXIS_LEFTX) == -30000, "an axis seen at rest reads the device: %%d", + pad_axis(&real, SDL_GAMEPAD_AXIS_LEFTX)); + real.axis[SDL_GAMEPAD_AXIS_RIGHTX] = 3000; // let go (drift below the rest band) + CHECK(pad_axis(&real, SDL_GAMEPAD_AXIS_RIGHTX) == 3000, "a released stick reads the device"); + real.axis[SDL_GAMEPAD_AXIS_RIGHTX] = 32767; + CHECK(pad_axis(&real, SDL_GAMEPAD_AXIS_RIGHTX) == 32767, "and then reads full tilt"); + CHECK(pad_axis(&ghost, SDL_GAMEPAD_AXIS_RIGHTX) == 0, "another gamepad's axis keeps its own state"); + ghost.axis[SDL_GAMEPAD_AXIS_LEFT_TRIGGER] = 32767; + CHECK(pad_axis(&ghost, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) == 0, "a trigger held from the start"); + SDL_Gamepad stray = {{32767}}; + CHECK(pad_axis(&stray, SDL_GAMEPAD_AXIS_LEFTX) == 32767, "a gamepad not open is read as it is"); + printf(fails ? "pad rest: %%d failures\n" : "pad rest: ok\n", fails); + return fails != 0; +} +''' + + +def cut(text, start, end_marker): + a = text.index(start) + b = text.index(end_marker, a) + return text[a:b] + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--cc', help='the C compiler (default: MSYS2 UCRT64 clang on Windows, clang elsewhere)') + args = ap.parse_args() + win = os.name == 'nt' + cc = args.cc or (str(MSYS_BIN / 'clang.exe') if win and MSYS_BIN.exists() else 'clang') + env = dict(os.environ) + if win and MSYS_BIN.exists(): + env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '') + src = (ROOT / 'runtime' / 'input.c').read_text(encoding='utf-8') + body = cut(src, 'enum { AXIS_REST', '\nstatic void stick(') + with tempfile.TemporaryDirectory(prefix='nx2navi-pad-rest-') as tmp: + c = Path(tmp) / 'check.c' + c.write_text(HARNESS % body, encoding='utf-8') + exe = Path(tmp) / ('check.exe' if win else 'check') + subprocess.check_call([cc, '-O2', '-Wall', '-Wno-unused-function', str(c), '-o', str(exe)], env=env) + return subprocess.call([str(exe)]) + + +if __name__ == '__main__': + sys.exit(main())