diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp
index 1cf3a84c..be7c952d 100644
--- a/gpu/vk/vk_menu.cpp
+++ b/gpu/vk/vk_menu.cpp
@@ -332,6 +332,10 @@ const char* hn_mic(int v, int variant) {
return missing ? "Not plugged in: Default stands in until it is back." : "This microphone, by its name. A change applies at once.";
}
const char* tx_rumble(int v) { return v ? tx_percent(v) : "Off"; }
+const char* tx_stick_dead_zone(int v) {
+ static const char* t[] = {"Default (24%)", "5%", "10%", "15%", "20%", "30%"};
+ return (unsigned)v < 6 ? t[v] : t[0];
+}
// (hookshot) Audio output: the speakers the game mixes for, read once at its start (host/nn_audio.c au_menu_output)
const char* tx_audio_output(int v) {
static const char* t[] = {"Auto", "Stereo", "5.1"};
@@ -497,7 +501,7 @@ Row rows[] = {
"What the ocarina hears when the game's microphone option is on (playing an instrument, or humming).", hn_mic, false,
true},
- // Controls (14 rows)
+ // Controls (15 rows)
{TAB_CONTROLS, "Controller in background", &opts.pad_background, 0, 1, 1, tx_onoff,
"Whether the controller plays the game while another window has the focus.", hn_background},
{TAB_CONTROLS, "Mouse camera", &opts.mouse_native, 0, 1, 1, tx_mousecam, "How the captured mouse turns the camera.",
@@ -511,6 +515,9 @@ Row rows[] = {
"Inverts horizontal look (left/right) on the right analog stick.", nullptr, false, true},
{TAB_CONTROLS, "Invert camera Y", &opts.invert_cam_y, 0, 1, 1, tx_onoff,
"Inverts vertical look (up/down) on the right analog stick.", nullptr, false, true},
+ {TAB_CONTROLS, "Stick dead zone", &opts.stick_dead_zone, 0, 5, 1, tx_stick_dead_zone,
+ "How far a stick moves before the game sees it, on each axis. Lower for a precise stick, higher for one that drifts. "
+ "The game's own dead zone still follows.", nullptr, false, true},
{TAB_CONTROLS, "Rumble strength", &opts.rumble, 0, 150, 25, tx_rumble,
"How strongly the controller rumbles, in percent of the game's rumble. Off turns it off.", nullptr, false, true},
{TAB_CONTROLS, "Motion controls", &opts.gyro, 0, 1, 1, tx_gyro,
diff --git a/gpu/vk/vk_menu.h b/gpu/vk/vk_menu.h
index ee5e1206..7a8f545f 100644
--- a/gpu/vk/vk_menu.h
+++ b/gpu/vk/vk_menu.h
@@ -33,6 +33,7 @@ struct Options {
int invert_cam_x = 0; // right-stick camera horizontal inversion: 0 normal, 1 inverted
int invert_cam_y = 0; // right-stick camera vertical inversion: 0 normal, 1 inverted
int rumble = 100; // the controllers' rumble in percent of the game's: 0 Off, 25 to 150 (host/pad_sdl.c)
+ int stick_dead_zone = 0; // a stick axis below it is 0: 0 XInput's 7849 (~24%), 1..5 5% to 30% (host/nn_hid.c)
int pad_layout = 0; // 0 Nintendo (default), 1 Xbox / PC, 2 Swap Face Buttons, 3 Custom
int gyro = 1; // motion controls from a pad's gyroscope (host/pad_sdl.c, host/hos_motion.h); on by default
int gyro_sens_x = 100; // the motion aim's horizontal sensitivity in percent
diff --git a/gpu/vk/vk_replay.cpp b/gpu/vk/vk_replay.cpp
index e6b315d9..649e08f3 100644
--- a/gpu/vk/vk_replay.cpp
+++ b/gpu/vk/vk_replay.cpp
@@ -3731,6 +3731,7 @@ void fill_replay_table(MkwVkRenderer& t) {
t.nvn_register_texture = register_texture_queued;
t.nvn_event_signal = event_signal_queued;
t.option = [](int id) -> int { // (3: the fixed path's scale in 1/10000, vk_menu.cpp; DLAA: full scale, vk_dlss.cpp)
+ if (id == 11) return opts.stick_dead_zone;
if (id == HOS_OPT_CHEAT_HEARTS) return menu_heart_protection();
if (id == HOS_OPT_CHEAT_FLIGHT) return menu_player_flight();
if (id == HOS_OPT_CHEAT_ACTION) return menu_take_cheat_action();
diff --git a/host/nn_hid.c b/host/nn_hid.c
index 64180e08..387e8fd1 100644
--- a/host/nn_hid.c
+++ b/host/nn_hid.c
@@ -496,6 +496,15 @@ void hos_bind(int set, int button, int slot, int code) {
static volatile LONG pad_raw_now; /* the gamepads' inputs at the last read, 1 << HOS_PAD_* */
uint32_t hos_pad_raw(void) { return (uint32_t)pad_raw_now; }
+/* A stick axis cut at the dead zone, as XInput's and SDL's pads both are (pad_sdl.c): zone is the Insert menu's "Stick
+ dead zone" (option 11), 0 XInput's own 7849 (~24%, the only one before issue 752), 1 to 5 5%, 10%, 15%,
+ 20%, 30%. Per axis, as before: the game's own dead zone still follows */
+int32_t hos_stick_axis(int16_t v, int zone) {
+ static const int32_t cut[6] = {7849, 1638, 3277, 4915, 6553, 9830};
+ const int32_t a = v < -32767 ? -32767 : v, t = cut[(unsigned)zone < 6 ? zone : 0];
+ return (a > t || a < -t) ? a : 0;
+}
+
static void pads_read(uint64_t *buttons, int32_t stick[4]) {
static int64_t next_read, retry[XUSER_MAX_COUNT];
static int was_connected[XUSER_MAX_COUNT];
@@ -555,11 +564,9 @@ static void pads_read(uint64_t *buttons, int32_t stick[4]) {
if (xs.Gamepad.bRightTrigger > 64) raw |= 1ULL << HOS_PAD_RTRIGGER;
const int16_t thumb[4] = {xs.Gamepad.sThumbLX, xs.Gamepad.sThumbLY, xs.Gamepad.sThumbRX, xs.Gamepad.sThumbRY};
int32_t v[4];
- for (int i = 0; i < 4; i++) {
- const int a = thumb[i] < -32767 ? -32767 : thumb[i];
- v[i] = (a > 7849 || a < -7849) ? a : 0;
- }
int rx_option(int id, int otherwise);
+ const int zone = rx_option(11, 0); /* (the menu's Stick dead zone) */
+ for (int i = 0; i < 4; i++) v[i] = hos_stick_axis(thumb[i], zone);
if (rx_option(16, 0)) v[2] = -v[2];
if (rx_option(17, 0)) v[3] = -v[3];
stick_max(s, v);
diff --git a/host/pad_sdl.c b/host/pad_sdl.c
index 3e3419a3..1f8e5cad 100644
--- a/host/pad_sdl.c
+++ b/host/pad_sdl.c
@@ -70,10 +70,11 @@ static HosMotionSample motion_hist[HOS_MOTION_HISTORY];
static int motion_head, motion_count, motion_live;
static volatile LONG gyro_status; /* 0 no controller, 1 controllers but none with a gyro, 2 one with a gyro */
-static int32_t stick_value(int16_t v, int flip) {
- int32_t a = v < -32767 ? -32767 : v;
- if (flip) a = -a;
- return (a > 7849 || a < -7849) ? a : 0; /* XInput's dead zone, as nn_hid.c uses */
+int32_t hos_stick_axis(int16_t v, int zone); /* nn_hid.c: the menu's Stick dead zone, as XInput's pads */
+
+static int32_t stick_value(int16_t v, int flip, int zone) {
+ const int32_t a = hos_stick_axis(v, zone);
+ return flip ? -a : a;
}
/* SDL's controller support started; 0 when it could not be */
@@ -328,8 +329,11 @@ static void pads_poll(void) {
if (hos_sdl_pad_axis(gc, AX_RT) > 8000) b |= 1ULL << HOS_PAD_RTRIGGER;
const int inv_x = rx_option(16, 0);
const int inv_y = rx_option(17, 0);
- const int32_t v[4] = {stick_value(hos_sdl_pad_axis(gc, AX_LX), 0), stick_value(hos_sdl_pad_axis(gc, AX_LY), 1),
- stick_value(hos_sdl_pad_axis(gc, AX_RX), inv_x), stick_value(hos_sdl_pad_axis(gc, AX_RY), inv_y ? 0 : 1)};
+ const int z = rx_option(11, 0); /* (the menu's Stick dead zone: nn_hid.c's hos_stick_axis) */
+ const int32_t v[4] = {stick_value(hos_sdl_pad_axis(gc, AX_LX), 0, z),
+ stick_value(hos_sdl_pad_axis(gc, AX_LY), 1, z),
+ stick_value(hos_sdl_pad_axis(gc, AX_RX), inv_x, z),
+ stick_value(hos_sdl_pad_axis(gc, AX_RY), inv_y ? 0 : 1, z)};
for (int k = 0; k < 4; k++)
if ((v[k] < 0 ? -v[k] : v[k]) > (s[k] < 0 ? -s[k] : s[k])) s[k] = v[k];
{ /* (motion: the pad last used; a stick counts by which way it is pushed, not by how far) */
diff --git a/readme.md b/readme.md
index aa30efd3..3fcaabc5 100644
--- a/readme.md
+++ b/readme.md
@@ -239,6 +239,7 @@ choices and starts the game again, so progress since your last save is lost.
| | Invert mouse vertical | Off, On | Off |
| | Invert camera X | Off, On | Off |
| | Invert camera Y | Off, On | Off |
+| | Stick dead zone | Default (24%), 5%, 10%, 15%, 20%, 30% | Default (24%) |
| | Rumble strength | Off, 25% to 150%, in steps of 25 | 100% |
| | Motion controls | Off, On | On |
| | Motion sensitivity X | 10% to 300% | 100% |
@@ -293,6 +294,8 @@ choices and starts the game again, so progress since your last save is lost.
mouse still need the game's window.
- **Mouse camera:** Native turns the camera with the mouse one to one, as in a PC game. As the stick uses the mouse in
place of the right stick. The - and = keys change the sensitivity in game too.
+- **Stick dead zone:** how far a stick moves before it counts, on each axis. Default is XInput's (about 24%); lower suits
+ a precise stick, higher one that drifts. The game keeps a dead zone of its own after it.
- **Rumble strength** scales the controller's rumble: 100% is the game's own, Off turns it off. The game's HD rumble
goes to a controller's heavy and light motors by its frequency.
- **Motion controls:** the controller's gyroscope aims where the game uses motion, as on the Switch. It works with
diff --git a/tests/run_all.py b/tests/run_all.py
index 3aee3872..2ec0e832 100644
--- a/tests/run_all.py
+++ b/tests/run_all.py
@@ -46,6 +46,7 @@
"tests/test_taau_env_lookup.py": ["--cxx", "g++"],
"tests/test_target_overlap.py": ["--cxx", "g++"],
"tests/test_menu_hit.py": ["--cxx", "g++"],
+ "tests/test_stick_dead_zone.py": ["--cc", "gcc", "--cxx", "g++"],
"tests/test_menu_restart.py": ["--cxx", "g++", "--cc", "gcc"],
"tests/test_output_resolution.py": ["--cxx", "{cxx}"],
"tests/test_widescreen_shapes.py": ["--cc", "gcc"],
diff --git a/tests/test_stick_dead_zone.py b/tests/test_stick_dead_zone.py
new file mode 100644
index 00000000..0b1c22ee
--- /dev/null
+++ b/tests/test_stick_dead_zone.py
@@ -0,0 +1,162 @@
+"""The sticks' dead zone is the player's: the Insert menu's "Stick dead zone" row (stick_dead_zone in
+hookshot_options.ini), issue 752.
+
+Both pad paths, SDL (host/pad_sdl.c) and XInput (host/nn_hid.c), cut each axis at XInput's 7849 (~24%), a lot on a
+worn or a precise stick. They now cut through one function, host/nn_hid.c's hos_stick_axis, whose threshold is the
+row's (option 11): Default (7849, the same output as before for every input), 5%, 10%, 15%, 20%, 30%.
+The function, pad_sdl.c's stick_value and the menu's row, label and ini parsing are compiled alone (gcc, g++) and
+checked over every int16 input.
+
+python -I -B tests/test_stick_dead_zone.py [--cc gcc] [--cxx g++]
+"""
+import argparse
+import re
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+THRESHOLDS = [7849, 1638, 3277, 4915, 6553, 9830] # (Default, then 5% ... 30% of 32767)
+
+
+def block(text, marker):
+ start = text.index(marker)
+ end = text.index("{", start) + 1
+ depth = 1
+ while depth:
+ depth += (text[end] == "{") - (text[end] == "}")
+ end += 1
+ return text[start:end]
+
+
+def check(cond, what):
+ if not cond:
+ raise SystemExit("FAIL: " + what)
+
+
+def run(compiler, src, name, tmp, flags):
+ path = Path(tmp) / name
+ path.write_text(src, encoding="utf-8")
+ exe = Path(tmp) / (name + ".exe")
+ subprocess.run([compiler, *flags, "-Wall", "-Werror", "-o", str(exe), str(path)], check=True)
+ r = subprocess.run([str(exe)], capture_output=True, text=True)
+ sys.stdout.write(r.stdout)
+ check(r.returncode == 0, name + ": " + r.stderr.strip())
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument("--cc", default="gcc")
+ p.add_argument("--cxx", default="g++")
+ a = p.parse_args()
+ hid = (ROOT / "host" / "nn_hid.c").read_text(encoding="utf-8")
+ sdl = (ROOT / "host" / "pad_sdl.c").read_text(encoding="utf-8")
+ api = (ROOT / "gpu" / "vk" / "vk_api.h").read_text(encoding="utf-8")
+ replay = (ROOT / "gpu" / "vk" / "vk_replay.cpp").read_text(encoding="utf-8")
+ menu = (ROOT / "gpu" / "vk" / "vk_menu.cpp").read_text(encoding="utf-8")
+ header = (ROOT / "gpu" / "vk" / "vk_menu.h").read_text(encoding="utf-8")
+ readme = (ROOT / "readme.md").read_text(encoding="utf-8")
+
+ check(not re.search(r"HOS_OPT_\w+ = 11\b", api), "a HOS_OPT_ enum also takes option 11")
+ check("int32_t hos_stick_axis(" in hid, "nn_hid.c has no hos_stick_axis")
+ pads = block(hid, "static void pads_read(")
+ check("rx_option(11, 0)" in pads and "hos_stick_axis(thumb[i], zone)" in pads,
+ "XInput's sticks do not go through hos_stick_axis with the row's zone (option 11)")
+ check("7849" not in pads, "XInput's sticks still cut at 7849 themselves")
+ poll = block(sdl, "static void pads_poll")
+ check("rx_option(11, 0)" in poll, "SDL's sticks do not read the row (option 11)")
+ check(poll.count("stick_value(") == 4, "SDL's four axes do not all go through stick_value")
+ value = block(sdl, "static int32_t stick_value(")
+ check("hos_stick_axis(" in value and "7849" not in value, "pad_sdl.c's stick_value cuts on its own")
+ option = replay[replay.index("t.option = "):]
+ option = option[:option.index("};") + 2]
+ check("if (id == 11) return opts.stick_dead_zone;" in option, "the host cannot see the menu's row (option 11)")
+ check("int stick_dead_zone = 0;" in header, "the option's default is not 0 (Default)")
+ row = re.search(r'\{TAB_CONTROLS, "Stick dead zone", &opts\.stick_dead_zone, 0, 5, 1, tx_stick_dead_zone,\s*"[^}]*\},',
+ menu)
+ check(row is not None, "the Insert menu has no Stick dead zone row (0 to 5)")
+ check(row.group(0).rstrip().endswith("nullptr, false, true},"),
+ "the row is not preset_exempt: changing the dead zone would set the quality preset to Custom")
+ check("| | Stick dead zone | Default (24%), 5%, 10%, 15%, 20%, 30% | Default (24%) |" in readme,
+ "readme.md's option table has no Stick dead zone row")
+
+ c = r'''#include
+#include
+#include
+#define CHECK(x) do { if (!(x)) { fprintf(stderr, "FAIL:%d %s\n", __LINE__, #x); exit(1); } } while (0)
+''' + block(hid, "int32_t hos_stick_axis(") + "\n" + block(sdl, "static int32_t stick_value(") + r'''
+/* (before issue 752: nn_hid.c's XInput cut, and pad_sdl.c's, which flips first) */
+static int32_t old_xinput(int16_t t) { const int a = t < -32767 ? -32767 : t; return (a > 7849 || a < -7849) ? a : 0; }
+static int32_t old_sdl(int16_t v, int flip) {
+ int32_t a = v < -32767 ? -32767 : v;
+ if (flip) a = -a;
+ return (a > 7849 || a < -7849) ? a : 0;
+}
+int main(void) {
+ static const int32_t t[6] = {''' + ", ".join(map(str, THRESHOLDS)) + r'''};
+ static const int bad[] = {-1, 6, 99, -2147483647 - 1};
+ for (int32_t i = -32768; i <= 32767; i++) {
+ const int16_t v = (int16_t)i;
+ CHECK(hos_stick_axis(v, 0) == old_xinput(v));
+ for (int k = 0; k < 4; k++) CHECK(hos_stick_axis(v, bad[k]) == old_xinput(v)); /* (out of range: Default) */
+ CHECK(stick_value(v, 0, 0) == old_sdl(v, 0) && stick_value(v, 1, 0) == old_sdl(v, 1));
+ for (int z = 0; z < 6; z++) {
+ const int32_t a = i < -32767 ? -32767 : i, want = a > t[z] || a < -t[z] ? a : 0;
+ CHECK(hos_stick_axis(v, z) == want);
+ CHECK(stick_value(v, 0, z) == want && stick_value(v, 1, z) == -want);
+ }
+ }
+ for (int z = 0; z < 6; z++)
+ CHECK(hos_stick_axis(t[z], z) == 0 && hos_stick_axis(t[z] + 1, z) == t[z] + 1 && hos_stick_axis(-t[z], z) == 0 &&
+ hos_stick_axis(-t[z] - 1, z) == -t[z] - 1 && hos_stick_axis(-32768, z) == -32767);
+ printf("stick: Default is the old cut for all 65536 inputs, both paths; 5 other thresholds exact\n");
+ return 0;
+}
+'''
+
+ init = block(menu, "void menu_init(")
+ loop = block(init, "for (Row& r : rows) {")
+ check("ini_key(r)" in loop, "menu_init's options loop no longer reads rows by ini_key")
+ cxx = r'''#include
+#include
+#include
+#include
+#include
+#define CHECK(x) do { if (!(x)) { fprintf(stderr, "FAIL:%d %s\n", __LINE__, #x); exit(1); } } while (0)
+enum MenuTab { TAB_CONTROLS };
+struct Options { int output_res = 0, resolution = 0, upscale = 0, dlss = 0, stick_dead_zone = 0; } opts;
+static int output_choice(int v) { return v; }
+''' + block(menu, "struct Row {") + ";\n" + block(menu, "const char* tx_stick_dead_zone(") + "\n" + \
+ "Row rows[] = {" + row.group(0) + "};\n" + block(menu, "const char* ini_key(") + r'''
+static int parse(const char* text) {
+ opts.stick_dead_zone = 0;
+ char line[80];
+ snprintf(line, sizeof line, "%s", text);
+ char* eq = strchr(line, '=');
+ *eq = 0;
+ ''' + loop + r'''
+ return opts.stick_dead_zone;
+}
+int main() {
+ CHECK(!strcmp(ini_key(rows[0]), "stick_dead_zone"));
+ CHECK(rows[0].preset_exempt && !rows[0].restart);
+ CHECK(parse("stick_dead_zone=0\n") == 0 && parse("stick_dead_zone=3\n") == 3 && parse("stick_dead_zone=5\n") == 5);
+ CHECK(parse("stick_dead_zone=9\n") == 5 && parse("stick_dead_zone=-4\n") == 0 && parse("stick_dead_zone=x\n") == 0);
+ CHECK(parse("stick_deadzone=3\n") == 0);
+ static const char* want[] = {"Default (24%)", "5%", "10%", "15%", "20%", "30%"};
+ for (int v = 0; v < 6; v++) CHECK(!strcmp(rows[0].text(v), want[v]));
+ CHECK(!strcmp(rows[0].text(-1), want[0]) && !strcmp(rows[0].text(6), want[0]));
+ (void)output_choice(0);
+ printf("menu: stick_dead_zone parses and clamps to 0..5; labels Default (24%%) ... 30%%\n");
+ return 0;
+}
+'''
+ with tempfile.TemporaryDirectory(prefix="stick-dead-zone-") as tmp:
+ run(a.cc, c, "stick.c", tmp, ["-std=c11", "-O1"])
+ run(a.cxx, cxx, "menu.cpp", tmp, ["-std=c++17"])
+ print("ok")
+
+
+if __name__ == "__main__":
+ main()