From 919aa7b2a880579d30eaf53d2bb3d35bf5056765 Mon Sep 17 00:00:00 2001 From: Michael Schienbein <7663497+mooki000@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:48:50 -0500 Subject: [PATCH] Let the mouse camera in stick mode reach the game's top turn speed at a camera speed above 1. With the mouse camera in stick mode and a camera speed above 1, the mouse could never turn faster than a full stick does at camera speed 1, however high the mouse sensitivity was set. The mouse's counts were limited to full tilt first and divided by the camera speed afterwards, so at camera speed 1.5 the most the mouse could send was two thirds of full tilt, which the game's own factor brought back to the old top speed. The division now comes before the limit (runtime/input_map.c input_mouse_stick): a slow movement turns as far as before, and a fast flick reaches full tilt, which is the game's top speed with the camera speed in it. Direct mouse mode, the default, does not use this code. A camera speed of 1 or less behaves as before. From pull request #424. tests/mouse_stick_check.py is new here: it builds input_map.c alone and checks the deflection at each camera speed without the game. Co-Authored-By: Claude Fable 5.2 --- docs/building.md | 5 ++++ docs/controls.md | 2 +- nx2navi.ini.example | 3 +- runtime/camera_speed.c | 3 +- runtime/cfg_probe.c | 16 ++++++---- runtime/input.c | 8 ++--- runtime/input_map.c | 6 ++-- runtime/input_map.h | 5 +++- tests/mouse_stick_check.c | 61 ++++++++++++++++++++++++++++++++++++++ tests/mouse_stick_check.py | 35 ++++++++++++++++++++++ 10 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 tests/mouse_stick_check.c create mode 100644 tests/mouse_stick_check.py diff --git a/docs/building.md b/docs/building.md index 3da1c6ca..da13975a 100644 --- a/docs/building.md +++ b/docs/building.md @@ -322,6 +322,11 @@ A change to what the library warm logs when the driver's cache of stage librarie `runtime/vk_pipeline.c` `lw_main`) passes `python tests\warm_hint_check.py`, which builds the header alone and checks the added text by GPU vendor and by why the cache was cold. No game files. +A change to the mouse camera's stick deflection (`runtime/input_map.c` `input_mouse_stick`) passes +`python tests\mouse_stick_check.py`, which builds that file with the rest of the runtime stubbed and checks the +deflection at each camera speed: a slow movement is divided by the factor, a fast flick reaches full tilt. No game +files. + The lifter (`recomp/`) and the CPU headers (`runtime/cpu.h`, `runtime/simd.h`) are checked against Unicorn. Every lifter change passes these, all run with `.venv`: diff --git a/docs/controls.md b/docs/controls.md index a5f00305..7e467309 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -108,7 +108,7 @@ bind_Y = pad:north, key:mouse_l ## Mouse camera -Mouse motion (raw input) becomes right-stick deflection. Each presented frame takes the counts moved since the last frame, divides by the frame time and sets the deflection to `counts per second * NX2NAVI_MOUSE_SENS / 3000` of full, held for that frame, so the turn rate does not depend on the frame rate. This is not mouselook: the game's stick curve and dead zone still apply, so very slow movements do nothing and a fast flick saturates. The mouse deflection competes with the gamepad and arrow keys by the larger-deflection rule. +Mouse motion (raw input) becomes right-stick deflection. Each presented frame takes the counts moved since the last frame, divides by the frame time and sets the deflection to `counts per second * NX2NAVI_MOUSE_SENS / 3000` of full, held for that frame, so the turn rate does not depend on the frame rate. This is not mouselook: the game's stick curve and dead zone still apply, so very slow movements do nothing and a fast flick saturates. With a camera speed other than 1 (`NX2NAVI_STICK_SENS`) the deflection is divided by that factor before it is limited to full tilt: the turn for a given movement stays the same, and the fastest turn a flick reaches is the game's top speed with the factor in it. The mouse deflection competes with the gamepad and arrow keys by the larger-deflection rule. `NX2NAVI_MOUSE_SENS` (0 to 10, default 1; higher turns faster, 0 turns the mouse camera and the cursor capture off). While the window is the foreground window the cursor is hidden and confined to the client area. It is released on focus loss, minimise, while F11 changes the window, during a size or move loop and while a text entry is open. Esc releases it by hand: the cursor shows, the mouse camera and mouse buttons stop, and the window can be moved, minimised or closed. A mouse click inside the client area captures it again; that click does not reach the game. Activating the window by a click on its title bar or border does not capture, so it can be dragged or closed right away; Alt+Tab or a click in the client area captures as before. `NX2NAVI_INPUT=none` disables the capture too. diff --git a/nx2navi.ini.example b/nx2navi.ini.example index 2ab095cc..eb75b735 100644 --- a/nx2navi.ini.example +++ b/nx2navi.ini.example @@ -533,7 +533,8 @@ # the stick reads centred, and the rest of its travel is scaled to start there. # stick_deadzone = 8 # Right-stick camera speed (0.1 to 3): multiplies the game's own camera turn rates, top speed included, at each of the -# five camera sensitivities in the game's Options. Keys bound to the right stick follow it; the mouse keeps mouse_sens. +# five camera sensitivities in the game's Options. Keys bound to the right stick follow it; the mouse keeps mouse_sens +# for its turn per movement, and in stick mode the fastest turn it can reach rises and falls with this. # A change while the game runs takes effect at once (the first one of a run within a few seconds). # stick_sens = 1 # The dead zone of one stick, in place of stick_deadzone for that stick (0 to 50; unset = stick_deadzone). The Controls diff --git a/runtime/camera_speed.c b/runtime/camera_speed.c index db2b2fe7..6a2c11af 100644 --- a/runtime/camera_speed.c +++ b/runtime/camera_speed.c @@ -4,7 +4,8 @@ // PlayerCameraParam.bgyml inside Pack/Actor/PlayerCamera.pack.zs. romfs.c serves a copy of that pack with every entry // multiplied (cam_pack_build), so the setting raises the camera's top speed as well as its speed at a partial tilt, at // any of the five sensitivities. The mouse camera reaches the game as a right-stick deflection (input.c, mouse_camera), -// which divides it by the same factor (cam_speed_applied), so NX2NAVI_MOUSE_SENS alone sets the mouse's speed. +// divided by the same factor (cam_speed_applied) before it is limited to full tilt (input_mouse_stick), so +// NX2NAVI_MOUSE_SENS alone sets the mouse's turn per movement, and its top speed is the game's with the factor in it. // // The pack is a SARC archive compressed with the dictionary pack.zsdic of Pack/ZsDic.pack.zs. A float in a BYML array // is stored in the array itself, so the multiplied values take the bytes of the old ones and no offset moves. diff --git a/runtime/cfg_probe.c b/runtime/cfg_probe.c index 44c40088..a10cd1d5 100644 --- a/runtime/cfg_probe.c +++ b/runtime/cfg_probe.c @@ -330,18 +330,22 @@ int probe_cfg(void) { CHECK(st[0] == -32767 && st[1] == 0, "keys: left arrow is right stick left"); input_map_stick(dn, 0, st); CHECK(st[0] == 0 && st[1] == 0, "keys: arrows leave the left stick alone"); - input_mouse_stick(30, 0, 1.0 / 60, 1, st); + input_mouse_stick(30, 0, 1.0 / 60, 1, 1, st); CHECK(st[0] == 32767 * 1800 / 3000 && st[1] == 0, "mouse: 30 counts in 1/60 s is 60% right"); - input_mouse_stick(0, -10, 1.0 / 60, 1, st); + input_mouse_stick(0, -10, 1.0 / 60, 1, 1, st); CHECK(st[0] == 0 && st[1] > 0, "mouse: moving up is stick up"); - input_mouse_stick(30, 0, 1.0 / 30, 1, st); + input_mouse_stick(30, 0, 1.0 / 30, 1, 1, st); CHECK(st[0] == 32767 * 900 / 3000, "mouse: the same counts over twice the time are half the deflection"); - input_mouse_stick(15, 0, 1.0 / 60, 2, st); + input_mouse_stick(15, 0, 1.0 / 60, 2, 1, st); CHECK(st[0] == 32767 * 1800 / 3000, "mouse: doubling sensitivity halves the counts needed (15 counts at 2 = 30 at 1)"); - input_mouse_stick(1000, 1000, 1.0 / 60, 1, st); + input_mouse_stick(1000, 1000, 1.0 / 60, 1, 1, st); CHECK((int64_t)st[0] * st[0] + (int64_t)st[1] * st[1] <= 32768ll * 32768, "mouse: clamped to the unit circle"); - input_mouse_stick(30, 0, 1.0 / 60, 0, st); + input_mouse_stick(30, 0, 1.0 / 60, 0, 1, st); CHECK(st[0] == 0 && st[1] == 0, "mouse: sensitivity 0 is off"); + input_mouse_stick(30, 0, 1.0 / 60, 1, 1.5f, st); + CHECK(st[0] == 13107 && st[1] == 0, "mouse: a camera speed of 1.5 divides the deflection, so the turn per count stays"); + input_mouse_stick(1000, 0, 1.0 / 60, 1, 1.5f, st); + CHECK(st[0] == 32767 && st[1] == 0, "mouse: a fast flick reaches full tilt at a camera speed of 1.5 too"); input_pad_stick(3000, -2000, 0.15f, st); CHECK(st[0] == 0 && st[1] == 0, "pad stick: an 11% drift inside a 15% dead zone reads centred"); input_pad_stick(4000, -3000, 0, st); diff --git a/runtime/input.c b/runtime/input.c index 1b429cda..24936611 100644 --- a/runtime/input.c +++ b/runtime/input.c @@ -368,17 +368,15 @@ static void mouse_camera(int32_t out[2], int selector) { if (fr != frame) { double dt = age < 0.004 ? 0.004 : age > 0.1 ? 0.1 : age; ikb_mouse_take(&dx, &dy); - input_mouse_stick(dx, dy, dt, mouse_sens_get(), held); + input_mouse_stick(dx, dy, dt, mouse_sens_get(), cam_speed_applied(), held); frame = fr; t0 = now; } else if (age > 0.1) { held[0] = held[1] = 0; // frames stopped: do not hold a deflection } } - double k = cam_speed_applied(), x = held[0] / k, y = held[1] / k, len = sqrt(x * x + y * y); - if (len > STICK_MAX) x *= STICK_MAX / len, y *= STICK_MAX / len; - out[0] = (int32_t)lround(x); - out[1] = (int32_t)lround(y); + out[0] = held[0]; + out[1] = held[1]; } static float stick_dead[2] = {-1, -1}; // input_pad_deadzone of the left and right stick; live (cfg_version) diff --git a/runtime/input_map.c b/runtime/input_map.c index 2b55ca87..29875062 100644 --- a/runtime/input_map.c +++ b/runtime/input_map.c @@ -349,10 +349,10 @@ void input_map_stick(const uint8_t down[KEY_COUNT], int stick, int32_t out[2]) { out[1] = dy * m; } -void input_mouse_stick(int32_t dx, int32_t dy, double dt_s, float sens, int32_t out[2]) { +void input_mouse_stick(int32_t dx, int32_t dy, double dt_s, float sens, float cam, int32_t out[2]) { out[0] = out[1] = 0; - if (!(dt_s > 0) || !(sens > 0)) return; - double k = STICK_MAX * (double)sens / (MOUSE_FULL_CPS * dt_s); + if (!(dt_s > 0) || !(sens > 0) || !(cam > 0)) return; + double k = STICK_MAX * (double)sens / (MOUSE_FULL_CPS * dt_s * cam); double x = dx * k, y = -dy * k, len = sqrt(x * x + y * y); if (len > STICK_MAX) { x *= STICK_MAX / len; y *= STICK_MAX / len; } out[0] = (int32_t)lrint(x); diff --git a/runtime/input_map.h b/runtime/input_map.h index 024bfd32..299f4823 100644 --- a/runtime/input_map.h +++ b/runtime/input_map.h @@ -117,8 +117,11 @@ int input_map_held(const uint8_t down[KEY_COUNT], int action); // unit circle. void input_map_stick(const uint8_t down[KEY_COUNT], int stick, int32_t out[2]); // Mouse motion in raw counts over dt_s seconds to right-stick deflection: full deflection at 3000 / sens counts/s. +// `cam` is the factor the game multiplies the right stick's turn rates by (cam_speed_applied). The deflection is +// divided by it, so the turn per count stays what sens says, and only then clamped: a clamp taken first would hold the +// mouse's fastest turn at the game's unmultiplied top speed, however high the factor. // Clamped to the unit circle, up (negative dy) is +y. -void input_mouse_stick(int32_t dx, int32_t dy, double dt_s, float sens, int32_t out[2]); +void input_mouse_stick(int32_t dx, int32_t dy, double dt_s, float sens, float cam, int32_t out[2]); // A gamepad stick (x, y in -32768..32767, up is +y) through a round dead zone of `dead` (fraction of full deflection, // 0..0.5): inside the circle the stick reads 0, and the rest of the travel is scaled to start at 0 at the edge, so a // drifting stick rests at 0 and full deflection still reaches full. The result stays inside the unit square. diff --git a/tests/mouse_stick_check.c b/tests/mouse_stick_check.c new file mode 100644 index 00000000..50028ba2 --- /dev/null +++ b/tests/mouse_stick_check.c @@ -0,0 +1,61 @@ +// The mouse camera in stick mode (runtime/input_map.c input_mouse_stick): raw counts to a right-stick deflection with +// the camera speed taken out of it. The game multiplies the right stick's turn rates by the camera speed, so the +// deflection is divided by it, and the division comes before the limit to full tilt: a slow movement turns as far as +// at camera speed 1, and a fast flick reaches full tilt, which the game turns into its top speed with the factor in +// it. No game files. tests/mouse_stick_check.py builds and runs this with runtime/input_map.c. +#include +#include +#include +#include +#include "input_map.h" + +// what input_map.c needs from the rest of the runtime; none of it is reached from input_mouse_stick +const char *cfg_get(const char *key) { (void)key; return NULL; } +double cfg_double(const char *key) { (void)key; return 0; } +void rt_log(const char *fmt, ...) { (void)fmt; } + +#define FULL 32767 + +static int fails; +#define CHECK(cond, ...) do { if (!(cond)) { printf("FAIL: " __VA_ARGS__); putchar('\n'); fails++; } } while (0) + +int main(void) { + static const float cams[] = {0.1f, 0.5f, 1.0f, 1.5f, 2.0f, 3.0f}; + int32_t st[2], one[2]; + for (unsigned i = 0; i < sizeof cams / sizeof *cams; i++) { + float cam = cams[i]; + // a slow movement: the deflection is the one at camera speed 1 divided by the factor (while that is below full + // tilt), so the game's multiplication gives the same turn + for (int counts = 1; counts <= 40; counts += 3) { + input_mouse_stick(counts, 0, 1.0 / 60, 1, 1, one); + input_mouse_stick(counts, 0, 1.0 / 60, 1, cam, st); + double at_one = FULL * (counts * 60.0) / 3000.0; // full tilt at 3000 counts a second + double want = at_one / cam > FULL ? FULL : at_one / cam; + CHECK(fabs(one[0] - at_one) <= 0.5, "camera speed 1, %d counts: %d, expected %.1f", counts, one[0], at_one); + CHECK(fabs(st[0] - want) <= 0.5 && st[1] == 0, "camera speed %.1f, %d counts: %d, expected %.1f", cam, counts, + st[0], want); + } + // a fast flick reaches full tilt at every camera speed, in each direction + input_mouse_stick(5000, 0, 1.0 / 60, 1, cam, st); + CHECK(st[0] == FULL && st[1] == 0, "camera speed %.1f: a flick right gives %d %d", cam, st[0], st[1]); + input_mouse_stick(0, 5000, 1.0 / 60, 1, cam, st); + CHECK(st[0] == 0 && st[1] == -FULL, "camera speed %.1f: a flick down gives %d %d", cam, st[0], st[1]); + input_mouse_stick(-5000, -5000, 1.0 / 60, 1, cam, st); + double len = sqrt((double)st[0] * st[0] + (double)st[1] * st[1]); + CHECK(st[0] < 0 && st[1] > 0 && st[0] == -st[1] && len <= FULL + 1.0 && len >= FULL - 2.0, + "camera speed %.1f: a diagonal flick gives %d %d", cam, st[0], st[1]); + } + // the case that was wrong: at camera speed 1.5 a flick stopped at two thirds of full tilt, the top speed of camera + // speed 1 + input_mouse_stick(80, 0, 1.0 / 60, 1, 1.5f, st); + CHECK(st[0] == FULL, "camera speed 1.5, 80 counts in 1/60 s: %d, expected full tilt", st[0]); + input_mouse_stick(30, 0, 1.0 / 60, 1, 1.5f, st); + CHECK(st[0] == 13107, "camera speed 1.5, 30 counts in 1/60 s: %d, expected 13107", st[0]); + // nothing to divide by: no deflection + input_mouse_stick(30, 0, 1.0 / 60, 1, 0, st); + CHECK(st[0] == 0 && st[1] == 0, "camera speed 0 gives %d %d", st[0], st[1]); + input_mouse_stick(30, 0, 1.0 / 60, 1, -1, st); + CHECK(st[0] == 0 && st[1] == 0, "a negative camera speed gives %d %d", st[0], st[1]); + if (!fails) puts("mouse stick: the camera speed divides the deflection before the limit to full tilt"); + return fails != 0; +} diff --git a/tests/mouse_stick_check.py b/tests/mouse_stick_check.py new file mode 100644 index 00000000..c5e3455d --- /dev/null +++ b/tests/mouse_stick_check.py @@ -0,0 +1,35 @@ +"""Build and run tests/mouse_stick_check.c with runtime/input_map.c: the mouse camera's stick deflection at each camera +speed. No game files. + +Usage: python tests/mouse_stick_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') + + +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', '') + with tempfile.TemporaryDirectory(prefix='nx2navi-mouse-stick-') as tmp: + exe = Path(tmp) / ('check.exe' if win else 'check') + subprocess.check_call([cc, '-O2', '-Wall', '-fno-strict-aliasing', '-I', str(ROOT / 'runtime'), + str(ROOT / 'tests' / 'mouse_stick_check.c'), str(ROOT / 'runtime' / 'input_map.c'), + *([] if win else ['-lm']), '-o', str(exe)], env=env) + return subprocess.run([str(exe)], env=env).returncode + + +if __name__ == '__main__': + sys.exit(main())