From fcb465a45afe34527d62463f281d55abf0992b2f Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:23:32 -0600 Subject: [PATCH] Input relay: send mouse motion at most every 4 ms The relay sent the helper one "move" per SYN_REPORT, so a 1000 Hz mouse sent 1000 datagrams a second to a helper whose loop runs every 8 ms, and each one went through a dozen sscanf and strncmp tests in the helper before reaching the move handler. In a 200 ms test at 1000 Hz, 149 reports now make 45 moves with the same total. flush() on a report now sends only once 4 ms have passed since the last move; tick() sends the rest when due, and the select timeout shrinks to match. Buttons and the gaze keys still flush first, unconditionally, so a click lands where the pointer was. In the helper, "move" is now tested first in the command dispatch, and its handling is one lambda. Co-Authored-By: Claude Opus 5.5 --- docs/design.md | 2 +- input/input-relay.py | 25 +++++++++---- pointer/helper/ft-pointer.cpp | 69 ++++++++++++++++++++--------------- 3 files changed, 59 insertions(+), 37 deletions(-) diff --git a/docs/design.md b/docs/design.md index 8da91b6..27c77ec 100644 --- a/docs/design.md +++ b/docs/design.md @@ -153,7 +153,7 @@ SteamVR opens every input device only when it starts. When a Bluetooth mouse sle Keyboards aren't grabbed by default, because a grabbed keyboard's keys went into a virtual keyboard nothing typed from; the relay forwards them to ft-screens instead. -The relay never waits on the pointer helper. Its socket to the helper used to block, so when the helper stalled (a layout placement or `grabprobe` holds it for seconds, and the gaze service fills its socket 90 times a second meanwhile), the whole relay stopped with it: keyboards, the volume keys, and pausing. Now what the helper doesn't take waits in order and goes out on the next loops. Mouse moves add up into one while they wait, and a scroll notch is dropped, since scrolling seconds late is no use; presses and releases are kept, so no button stays down. +The relay never waits on the pointer helper. Its socket to the helper used to block, so when the helper stalled (a layout placement or `grabprobe` holds it for seconds, and the gaze service fills its socket 90 times a second meanwhile), the whole relay stopped with it: keyboards, the volume keys, and pausing. Now what the helper doesn't take waits in order and goes out on the next loops. Mouse moves add up into one while they wait, and a scroll notch is dropped, since scrolling seconds late is no use; presses and releases are kept, so no button stays down. Mouse motion goes to the helper at most every 4 ms, rather than once per report, which from a 1000 Hz mouse was 1000 datagrams a second to a helper that runs every 8 ms; a button sends the motion before it first, so the click lands where the pointer was. An ungrabbed keyboard reaches both sides at once. In VR, gamescope reads every input device itself (the SteamOS build's `InputStealer`, libinput with udev hotplug, so new devices too) and types into its focused app, and ft-screens types the same keys into the desktop. So Space in the desktop also paused Spotify on the dashboard. Typing now follows the last click. ft-screens sees clicks on its own screens, from the mouse or a controller. A click anywhere else is only visible for the mouse: overlay apps get SteamVR's `OverlayFocusChanged` (which panel the laser is on) but no controller button events, so the pointer helper reports the panel under the dot on each left press. ft-screens tells the relay where typing goes every second, from an unbound socket so the relay's replies can't loop back into its control socket, and the relay grabs pass-through keyboards while it's the desktop. A grab waits until the keyboard has no key down, so no key stays held on either side, and the relay lets go if ft-screens stops reporting. A program that reads every keyboard for a hotkey (a dictation tool, say) loses a grabbed keyboard. Repeating the keys on another input device doesn't work: gamescope reads that device too, whether it's the relay's virtual keyboard or one created later, and every Space, typed or dictated, paused Spotify again. So with `SHARE_KEYS=1` the relay sends a grabbed keyboard's keys to `@frametop_keys` as datagrams (`key `). It's off by default, because the relay can't tell who is listening: abstract sockets have no permissions, and any local process that binds the name first gets every key typed into the desktop, passwords included. A listener should accept only its own user (`SO_PASSCRED`) and skip any keyboard of its own that the relay grabs too. diff --git a/input/input-relay.py b/input/input-relay.py index 64c2e62..4a63aab 100755 --- a/input/input-relay.py +++ b/input/input-relay.py @@ -461,6 +461,7 @@ class Pointer: RESUME_PAUSE = 1.5 # mouse idle this long, then moving again, re-claims the laser WAKE_WINDOW = 1.0 # seconds in which WAKE_COUNTS of motion must add up QUEUE_MAX = 512 # commands kept while the helper is behind (see send) + MOVE_EVERY = 0.004 # mouse motion goes to the helper at most this often (see flush) def __init__(self, sensitivity, idle, wake_counts=40): # Never blocks (see send): a stalled helper must not stall the keyboard, volume keys and pausing. @@ -474,6 +475,7 @@ class Pointer: self.active = False self.last_used = 0.0 self.dx = self.dy = 0 + self.move_at = 0.0 # motion last went to the helper then self.scroll_until = None self.claim_at = None # when to press the claim button self.claim_release = None @@ -637,11 +639,16 @@ class Pointer: self.sensitivity *= 1.25 if name == "sens_up" else 0.8 log(f"sensitivity {self.sensitivity:.4f} deg/count") - def flush(self): - if self.dx or self.dy: - # Mouse right turns the ray right (negative yaw); mouse down tilts it down. - self._move(-self.dx * self.sensitivity, -self.dy * self.sensitivity) - self.dx = self.dy = 0 + def flush(self, now=None): + """Send the motion so far. With now (a mouse's SYN_REPORT), only once MOVE_EVERY has passed + since the last: a 1000 Hz mouse sent the helper, which runs every 8 ms, 1000 datagrams a + second. tick() sends the rest when it's due; a button sends it first, so it lands there.""" + if not (self.dx or self.dy) or (now is not None and now - self.move_at < self.MOVE_EVERY): + return + self.move_at = time.monotonic() if now is None else now + # Mouse right turns the ray right (negative yaw); mouse down tilts it down. + self._move(-self.dx * self.sensitivity, -self.dy * self.sensitivity) + self.dx = self.dy = 0 def dashboard(self, now=None): """Toggle the SteamVR dashboard with the virtual controller's system button. @@ -657,6 +664,7 @@ class Pointer: def tick(self, now): self.drain() + self.flush(now) if self.system_at is not None and now >= self.system_at: self.send("btn system 1") self.system_at = None @@ -681,7 +689,10 @@ class Pointer: def timeout(self): pending = (self.scroll_until, self.claim_at, self.claim_release, self.system_at, self.system_release) - return 0.02 if self.queue or any(t is not None for t in pending) else 0.5 + wait = 0.02 if self.queue or any(t is not None for t in pending) else 0.5 + if self.dx or self.dy: # motion held back by flush + wait = max(0.0, min(wait, self.move_at + self.MOVE_EVERY - time.monotonic())) + return wait def stand_down(self): """Frametop is pausing: a pulse under way ends now, and the pointer lets go.""" @@ -1389,7 +1400,7 @@ def main(): mouse.emit(etype, code, value) elif etype == EV_SYN and code == SYN_REPORT: if pointer: - pointer.flush() + pointer.flush(now) mouse.sync() keyboard.sync() diff --git a/pointer/helper/ft-pointer.cpp b/pointer/helper/ft-pointer.cpp index 3acb9cb..521c702 100644 --- a/pointer/helper/ft-pointer.cpp +++ b/pointer/helper/ft-pointer.cpp @@ -1356,6 +1356,36 @@ int main() { return msg; }; + // "move " from the relay. + auto mouseMove = [&](double a, double b) { + // A move held back (POINTER_GAZE_MOUSE_MOVE=held) only wakes the pointer: it isn't using + // the mouse, so a drifting mouse doesn't keep the gaze from taking the pointer back. + const bool heldBack = mouseMoveHeld(); + if (!heldBack) lastMouse = Clock::now(); + // Any mouse input wakes the pointer (after a controller took over, or a helper restart). + if (!active) wake(Clock::now()); + if (heldBack) return; + if (tilting) { + tiltYaw += a; + tiltPitch = std::clamp(tiltPitch + b, -80.0, 80.0); + return; + } + lastMove = Clock::now(); // the dot shows while the mouse moves it (gaze mode) + if (gazeOn && gazeOwns) { + // The mouse takes the pointer from the gaze, from where the gaze left it. + gazeOwns = false; + nudging = haveHead && Clock::now() - gz.at < std::chrono::milliseconds(200); + nudgeRawHy = gz.rhy, nudgeRawHp = gz.rhp, nudgeHead = lastHead; + nudgeAt = Clock::now(), nudgeMoved = 0; + } + if (nudging || aimHeld) nudgeMoved += std::hypot(a, b); + if (!anchored) recenter = true; + yaw += a; + while (yaw > 180) yaw -= 360; + while (yaw < -180) yaw += 360; + pitch = std::clamp(pitch + b, -85.0, 85.0); + }; + std::printf("ft-pointer running: free distance %.2f m, dot %.2f deg\n", freeDistance, cursorDeg); std::fflush(stdout); @@ -1421,6 +1451,12 @@ int main() { if (senderLen > offsetof(sockaddr_un, sun_path)) sendto(out, msg.data(), msg.size(), 0, reinterpret_cast(&sender), senderLen); }; + // Mouse moves first, the most frequent command. + double mx, my; + if (std::strncmp(buf, "move ", 5) == 0 && std::sscanf(buf + 5, "%lf %lf", &mx, &my) == 2) { + mouseMove(mx, my); + continue; + } // The gaze, from ft-gazed: not mouse input, it never wakes the pointer. double g[4]; if (std::sscanf(buf, "gz %lf %lf %lf %lf", &g[0], &g[1], &g[2], &g[3]) == 4) { @@ -1509,16 +1545,11 @@ int main() { reply(gazeOn ? "ok on" : "ok off"); continue; } - const bool mouseInput = std::strncmp(buf, "move", 4) == 0 || std::strncmp(buf, "btn", 3) == 0 || - std::strncmp(buf, "scroll", 6) == 0; - // A move held back (POINTER_GAZE_MOUSE_MOVE=held) only wakes the pointer: it isn't using - // the mouse, so a drifting mouse doesn't keep the gaze from taking the pointer back. - const bool moveHeldBack = std::strncmp(buf, "move", 4) == 0 && mouseMoveHeld(); - if (mouseInput && !moveHeldBack) lastMouse = Clock::now(); + // (Moves were taken first, above.) + const bool mouseInput = std::strncmp(buf, "btn", 3) == 0 || std::strncmp(buf, "scroll", 6) == 0; + if (mouseInput) lastMouse = Clock::now(); // Any mouse input wakes the pointer (after a controller took over, or a helper restart). if (!active && mouseInput) wake(Clock::now()); - if (moveHeldBack) continue; - double a, b; char key[128]; double px, py, pz, pyaw, ppitch, proll = 0, pgrab = -1; if (std::sscanf(buf, "grabprobe %127s", key) == 1) { @@ -1577,27 +1608,7 @@ int main() { tilting = swallowedRight = false; continue; } - if (tilting && std::sscanf(buf, "move %lf %lf", &a, &b) == 2) { - tiltYaw += a; - tiltPitch = std::clamp(tiltPitch + b, -80.0, 80.0); - continue; - } - if (std::sscanf(buf, "move %lf %lf", &a, &b) == 2) { - lastMove = Clock::now(); // the dot shows while the mouse moves it (gaze mode) - if (gazeOn && gazeOwns) { - // The mouse takes the pointer from the gaze, from where the gaze left it. - gazeOwns = false; - nudging = haveHead && Clock::now() - gz.at < std::chrono::milliseconds(200); - nudgeRawHy = gz.rhy, nudgeRawHp = gz.rhp, nudgeHead = lastHead; - nudgeAt = Clock::now(), nudgeMoved = 0; - } - if (nudging || aimHeld) nudgeMoved += std::hypot(a, b); - if (!anchored) recenter = true; - yaw += a; - while (yaw > 180) yaw -= 360; - while (yaw < -180) yaw += 360; - pitch = std::clamp(pitch + b, -85.0, 85.0); - } else if (std::strncmp(buf, "recenter", 8) == 0) { + if (std::strncmp(buf, "recenter", 8) == 0) { recenter = true; } else if (std::strncmp(buf, "reload", 6) == 0) { loadConfig();