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 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-11 17:28:07 -05:00
parent ec2e05e3a3
commit c60abba2e5
3 changed files with 126 additions and 13 deletions

View File

@@ -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

View File

@@ -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();

90
tests/pad_rest_check.py Normal file
View File

@@ -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 <clang>]
"""
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 <stdint.h>
#include <stdio.h>
#include <stdlib.h>
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())