fix(input): mouse_dx/dy report no motion on the first frame or across a cursor-mode change

The delta was this frame's position minus the last, and the last started at 0,0, so the
first frame reported the cursor's whole distance from the corner as motion. A cursor-mode
change did the same, switching between a locked cursor's virtual reticle and the real
cursor. Maroon Lake's camera adds mouse_dy to its pitch and came up pointing at the ground.

examples/library/input_mouse_rebase.ludic covers both cases, plus ordinary motion and
re-setting the mode already in force.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-14 15:35:26 +03:00
parent dc9138846f
commit 28e8769a8b
4 changed files with 62 additions and 0 deletions

View file

@ -0,0 +1,8 @@
bump: patch
type: fix
**`Input.mouse_dx` / `mouse_dy` no longer jump on the first frame or when the cursor mode changes.**
The delta was the position minus the previous frame's, and the previous position started at 0,0,
so the first frame reported the cursor's whole distance from the corner as motion, and switching
between a locked cursor's virtual reticle and the real cursor did the same. A camera that adds
`mouse_dy` to its pitch came up pointing at the ground with nobody touching the mouse. Both frames
now report no motion; every other frame is unchanged, windowed or headless, on macOS and Windows.

View file

@ -0,0 +1,44 @@
# input_mouse_rebase.ludic — Input.mouse_dx/dy report no motion on the first frame and on the
# frame a cursor-mode change lands, and ordinary motion on every other frame.
#
# The delta is this frame's position minus the last. Before any position has been read the "last"
# one is 0,0, and across a mode change it belongs to another source (a locked cursor's virtual
# reticle against the real cursor) - both used to come out as a jump the size of the screen.
# Driven headless by injection:
# bin/ludic examples/library/input_mouse_rebase.ludic -> 0 0 5 -2 0 0 4 3 6 0 0 0
program InputMouseRebase {
entry {
# the first frame: the cursor is already at 300,400, which is not 300,400 of motion
Input.set_mouse(300, 400, 0, 0)
Input.poll()
print(Input.mouse_dx()) # 0
print(Input.mouse_dy()) # 0
# ordinary motion
Input.set_mouse(305, 398, 0, 0)
Input.poll()
print(Input.mouse_dx()) # 5
print(Input.mouse_dy()) # -2
# a mode change: the next position is from another source
Input.cursor_mode(2)
Input.set_mouse(900, 100, 0, 0)
Input.poll()
print(Input.mouse_dx()) # 0
print(Input.mouse_dy()) # 0
Input.set_mouse(904, 103, 0, 0)
Input.poll()
print(Input.mouse_dx()) # 4
print(Input.mouse_dy()) # 3
# asking for the mode already in force is not a change
Input.cursor_mode(2)
Input.set_mouse(910, 103, 0, 0)
Input.poll()
print(Input.mouse_dx()) # 6
print(Input.mouse_dy()) # 0
# and back: no jump from the reticle to the real cursor
Input.cursor_mode(0)
Input.set_mouse(10, 10, 0, 0)
Input.poll()
print(Input.mouse_dx()) # 0
print(Input.mouse_dy()) # 0
}
}

View file

@ -226,6 +226,10 @@ var in_mdy: int = 0
var in_rdx: int = 0 # the raw motion the platform reports while captured
var in_rdy: int = 0
var in_cursor_mode: int = 0
# the next commit reports no mouse delta: true before the first position is read (the previous one
# is not a position, it is 0,0) and after a cursor-mode change (the position source switches between
# the virtual reticle and the real cursor, which are unrelated points)
var in_mouse_rebase: bool = true
var in_mbtn: int = 0 # button bitmask (bit 0 left, 1 right, 2 middle)
var in_wheel: int = 0 # wheel delta this frame
# gamepads: connected flag, button bitmask, and IN_AXES fixed axes each
@ -341,6 +345,10 @@ function input_device_commit(k: int, replaying: int) -> void {
in_mdy = in_my - in_my0
# captured (mode 2): the cursor is a clamped reticle, the motion is the raw delta
if is_windowed() and in_cursor_mode == 2 { in_mdx = in_rdx; in_mdy = in_rdy }
# No motion on the first frame or across a cursor-mode change. The previous position there is
# 0,0 or a point in another coordinate source, so the difference is the cursor's whole distance
# from it: a camera that adds mouse_dy to its pitch came up pointing at the ground.
if in_mouse_rebase { in_mdx = 0; in_mdy = 0; in_mouse_rebase = false }
in_mx0 = in_mx
in_my0 = in_my
}
@ -445,6 +453,7 @@ enum CursorMode { Normal, Hidden, Locked, Confined } # Input.cursor_mode(mode:
enum PadButton { A, B, X, Y, LeftShoulder, RightShoulder, Back, Start } # Input.bind_pad(button:) / pad_button
enum MouseButton { Left, Right, Middle } # Input.mouse_down(button:)
function input_cursor_mode(mode: int) -> void {
if mode != in_cursor_mode { in_mouse_rebase = true }
in_cursor_mode = mode
if is_windowed() { win_cursor_mode(mode) }
}

View file

@ -675,6 +675,7 @@ function cmd_dev_test() -> int {
# deterministic input record/replay — fed one key per poll from stdin.
feat_case("library/input_actions", " xwa", "1 0 1 1 0 1 0", "input_actions.ludic (#7 action maps + rebinding + deterministic replay)")
feat_case("library/input_device", "", "1 1 0 1 0 1 71 -71 5 1 3 1 2 1 0 0 1", "input_device.ludic (#50 multi-key held state + analog axis/vector + mouse + gamepad/touch + full-state replay)")
feat_case("library/input_mouse_rebase", "", "0 0 5 -2 0 0 4 3 6 0 0 0", "input_mouse_rebase.ludic (mouse_dx/dy report no motion on the first frame or across a cursor-mode change)")
feat_case("library/input_manager", "", "1 1 1 0 1 0 0 1", "input_manager.ludic (#83 Input Manager: default bindings + device-agnostic actions across keyboard/pad + on-press/on-release edges)")
feat_case("library/input_auto", "llq", "2", "input_auto.ludic (#83 the frame loop auto-commits the device layer: Input.active fires with no manual Input.poll)")
feat_case("library/input_edge", "xkkxq", "0 11 1 100 0", "input_edge.ludic (#87 Input.key_pressed/key_released edges fire on the transition frame; no double-commit under the frame loop)")