From 5787155a4a7b9416c8017a3b554b1c9d1394b41e Mon Sep 17 00:00:00 2001 From: danalec Date: Tue, 25 Aug 2026 23:56:48 -0300 Subject: [PATCH 1/3] linux/input: optionally emulate absolute mouse as relative motion Some compositors implement assistive features that only track relative pointer motion; Wayland's PointerMotionAbsolute moves the cursor but such features do not follow it (e.g. the COSMIC screen magnifier focal point, pop-os/cosmic-comp#2760). Add an absolute_mouse_as_relative option (disabled by default) that converts absolute client coordinates into relative deltas. The first absolute event still anchors the cursor absolutely. Subsequent events are turned into relative deltas driven by dead reckoning. The KMS capture path publishes the real cursor-plane position every frame; the estimate snaps to it while the client is idle, and a coordinate saturated at the client's own surface edge targets the matching host edge so movement cannot stall mid-screen. On backends without cursor feedback the conversion still works from dead reckoning alone. --- docs/configuration.md | 29 ++++++ src/config.cpp | 2 + src/config.h | 1 + src/input.cpp | 172 ++++++++++++++++++++++++++++++++- src/platform/common.h | 24 +++++ src/platform/linux/kmsgrab.cpp | 27 ++++++ 6 files changed, 254 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index d55dc380f82..efbaf560d93 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -691,6 +691,35 @@ editing the `conf` file in a text editor. Use the examples as reference. +### absolute_mouse_as_relative + + + + + + + + + + + + + + +
Description + When enabled, absolute mouse positions from Moonlight clients are converted to relative motion. +
+ The first event still places the cursor absolutely; subsequent events are emitted as relative deltas, + using the cursor position captured from the KMS cursor plane (when available) to stay in sync. +
+ This can be useful on compositors whose assistive features (e.g. screen magnifiers) only track relative + pointer motion. +
Default@code{} + disabled + @endcode
Example@code{} + absolute_mouse_as_relative = enabled + @endcode
+ ### keybindings diff --git a/src/config.cpp b/src/config.cpp index de3979ee4a9..53c1c8b85db 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -861,6 +861,7 @@ namespace config { true, // always send scancodes true, // high resolution scrolling true, // native pen/touch support + false, // absolute mouse as relative (opt-in) }; /** @@ -1803,6 +1804,7 @@ namespace config { bool_f(vars, "high_resolution_scrolling", input.high_resolution_scrolling); bool_f(vars, "native_pen_touch", input.native_pen_touch); + bool_f(vars, "absolute_mouse_as_relative", input.absolute_mouse_as_relative); bool_f(vars, "notify_pre_releases", sunshine.notify_pre_releases); bool_f(vars, "system_tray", sunshine.system_tray); diff --git a/src/config.h b/src/config.h index adc4e84219a..f92998b7c5e 100644 --- a/src/config.h +++ b/src/config.h @@ -290,6 +290,7 @@ namespace config { bool high_resolution_scrolling; ///< Enable high-resolution mouse-wheel events. bool native_pen_touch; ///< Enable native pen and touch injection. + bool absolute_mouse_as_relative; ///< Emulate absolute client mouse input as relative motion (for compositors whose assistive features only track relative motion). }; namespace flag { diff --git a/src/input.cpp b/src/input.cpp index bac39561448..8af25e19a50 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -42,6 +42,14 @@ constexpr int WHEEL_DELTA = 120; ///< Standard Windows wheel delta used to norm using namespace std::literals; +namespace platf { + kms_cursor_feedback_t& + kms_cursor_feedback() { + static kms_cursor_feedback_t fb; + return fb; + } +} + namespace input { constexpr auto MAX_GAMEPADS = std::min((std::size_t) platf::MAX_GAMEPADS, sizeof(std::int16_t) * 8); ///< Maximum gamepads representable by the active gamepad mask. @@ -271,6 +279,20 @@ namespace input { input::touch_port_t touch_port; ///< Touch coordinate bounds for the current stream. + /// State of the absolute->relative mouse conversion (absolute_mouse_as_relative). + struct abs_mouse_t { + bool initialized = false; ///< Whether the absolute baseline is set. + float frac_x = 0; ///< Accumulated fractional X delta. + float frac_y = 0; ///< Accumulated fractional Y delta. + float host_x = 0; ///< Dead-reckoning estimate of the host cursor X, touch-port pixels. + float host_y = 0; ///< Dead-reckoning estimate of the host cursor Y, touch-port pixels. + std::uint64_t seq_last = 0; ///< Cursor-feedback sequence seen by the previous event. + float last_raw_x = 0; ///< Previous raw client X (idle detection). + float last_raw_y = 0; ///< Previous raw client Y (idle detection). + std::chrono::steady_clock::time_point last_client_move {}; ///< Last time the client coordinates changed. + }; + abs_mouse_t abs_mouse; ///< Absolute->relative mouse conversion state. + int32_t accumulated_vscroll_delta; ///< Accumulated vscroll delta. int32_t accumulated_hscroll_delta; ///< Accumulated hscroll delta. }; @@ -778,6 +800,149 @@ namespace input { return {multiply_polar_by_cartesian_scalar(major, angle, scalar), multiply_polar_by_cartesian_scalar(minor, angle + (M_PI / 2), scalar)}; } + /// Client mouse event data for the absolute->relative conversion. + struct abs_mouse_event_t { + std::pair tpcoords; ///< Client coordinates mapped to touch-port pixels. + float x; ///< Raw client X on the client surface. + float y; ///< Raw client Y on the client surface. + float width; ///< Client surface width. + float height; ///< Client surface height. + }; + + /** + * @brief Resync the dead-reckoning estimate with the real cursor position. + * + * The KMS capture path publishes the cursor-plane position once per captured + * frame. The feedback is only consumed when trustworthy: while the client is + * idle (every in-flight move has landed), or on an axis saturated at the + * client's own surface edge (the estimate may be mis-anchored there, and any + * feedback lag only overshoots toward the edge, where the compositor clamps). + * + * @param input The input context. + * @param client_idle Whether the client coordinates have been quiet lately. + * @param sat_x Whether the X axis is saturated on the client surface. + * @param sat_y Whether the Y axis is saturated on the client surface. + * @param port_w Touch-port width in pixels. + * @param port_h Touch-port height in pixels. + */ + static void + abs_mouse_sync_estimate(const std::shared_ptr &input, bool client_idle, bool sat_x, bool sat_y, float port_w, float port_h) { + const auto &fb = platf::kms_cursor_feedback(); + + const auto seq = fb.seq.load(); + if (seq == 0 || seq == input->abs_mouse.seq_last) { + return; + } + input->abs_mouse.seq_last = seq; + + const auto phys_w = static_cast(fb.desktop_w.load()); + const auto phys_h = static_cast(fb.desktop_h.load()); + const auto logical_w = static_cast(fb.logical_w.load()); + const auto logical_h = static_cast(fb.logical_h.load()); + if (phys_w <= 0.0f || phys_h <= 0.0f || logical_w <= 0.0f || logical_h <= 0.0f) { + return; + } + + // Desktop physical pixels -> compositor logical pixels. + const auto real_x = static_cast(fb.x.load()) * (logical_w / phys_w); + const auto real_y = static_cast(fb.y.load()) * (logical_h / phys_h); + + if (client_idle) { + input->abs_mouse.host_x = std::clamp(real_x, 0.0f, port_w - 1.0f); + input->abs_mouse.host_y = std::clamp(real_y, 0.0f, port_h - 1.0f); + } + else { + if (sat_x) input->abs_mouse.host_x = std::clamp(real_x, 0.0f, port_w - 1.0f); + if (sat_y) input->abs_mouse.host_y = std::clamp(real_y, 0.0f, port_h - 1.0f); + } + } + + /** + * @brief Emulate relative mouse movement from absolute coordinates. + * + * Absolute motion arrives as PointerMotionAbsolute in the compositor, which + * moves the cursor but does NOT update assistive features that only track + * relative motion, e.g. the COSMIC screen magnifier focal point + * (pop-os/cosmic-comp #2760). The first event anchors the cursor absolutely; + * subsequent events are converted to relative deltas. + * + * Dead reckoning is the only thing in the smooth motion path (1:1, no + * latency); the estimate is resynced with the real cursor position while the + * client is idle. Phantom "walls" (movement blocked in one direction until + * pushed back) come from the client saturating a coordinate in its own + * surface while the host cursor sits mid-screen, so a saturated axis simply + * targets the matching host edge — the primary loop itself drives the cursor + * there, continuously and idempotently. + * + * @param input The input context. + * @param abs_port Absolute-coordinate touch port. + * @param event The client mouse event (raw and touch-port coordinates). + * @param touch_port_dim_x Touch-port width in pixels. + * @param touch_port_dim_y Touch-port height in pixels. + */ + static void + abs_mouse_as_relative(const std::shared_ptr &input, const platf::touch_port_t &abs_port, const abs_mouse_event_t &event, + int touch_port_dim_x, int touch_port_dim_y) { + const auto port_w = static_cast(touch_port_dim_x); + const auto port_h = static_cast(touch_port_dim_y); + + const auto &x = event.x; + const auto &y = event.y; + + // Saturation bands on the client's own surface. + constexpr float kRawEdge = 8.0f; + const auto at_left = x <= kRawEdge; + const auto at_right = x >= event.width - kRawEdge; + const auto at_top = y <= kRawEdge; + const auto at_bottom = y >= event.height - kRawEdge; + + // Client target in touch-port units; a saturated axis targets the host edge. + auto target_x = std::clamp(event.tpcoords.first, 0.0f, port_w - 1.0f); + auto target_y = std::clamp(event.tpcoords.second, 0.0f, port_h - 1.0f); + if (at_left) target_x = 0.0f; + else if (at_right) target_x = port_w - 1.0f; + if (at_top) target_y = 0.0f; + else if (at_bottom) target_y = port_h - 1.0f; + + // Idle detection watches the client's own motion (raw coordinates), so a + // cursor pinned against its edge still counts as idle once it stops. + const auto now = std::chrono::steady_clock::now(); + if (std::fabs(x - input->abs_mouse.last_raw_x) > 0.01f || + std::fabs(y - input->abs_mouse.last_raw_y) > 0.01f) { + input->abs_mouse.last_client_move = now; + } + input->abs_mouse.last_raw_x = x; + input->abs_mouse.last_raw_y = y; + + if (!input->abs_mouse.initialized) { + platf::abs_mouse(platf_input, abs_port, event.tpcoords.first, event.tpcoords.second); + input->abs_mouse.initialized = true; + input->abs_mouse.host_x = target_x; + input->abs_mouse.host_y = target_y; + return; + } + + constexpr auto kIdleMs = std::chrono::milliseconds(150); + const auto client_idle = now - input->abs_mouse.last_client_move > kIdleMs; + abs_mouse_sync_estimate(input, client_idle, at_left || at_right, at_top || at_bottom, port_w, port_h); + + // Primary loop: drive the estimate toward the (possibly edge-overridden) + // target. Idempotent while resting inside the edge band. + input->abs_mouse.frac_x += target_x - input->abs_mouse.host_x; + input->abs_mouse.frac_y += target_y - input->abs_mouse.host_y; + + const auto delta_x = static_cast(input->abs_mouse.frac_x); + const auto delta_y = static_cast(input->abs_mouse.frac_y); + input->abs_mouse.frac_x -= delta_x; + input->abs_mouse.frac_y -= delta_y; + + if (delta_x || delta_y) { + platf::move_mouse(platf_input, delta_x, delta_y); + input->abs_mouse.host_x = std::clamp(input->abs_mouse.host_x + delta_x, 0.0f, port_w - 1.0f); + input->abs_mouse.host_y = std::clamp(input->abs_mouse.host_y + delta_y, 0.0f, port_h - 1.0f); + } + } + /** * @brief Forward a client input packet directly to the platform backend. * @@ -831,7 +996,12 @@ namespace input { touch_port_dim_y }; - platf::abs_mouse(platf_input, abs_port, tpcoords->first, tpcoords->second); + if (!config::input.absolute_mouse_as_relative) { + platf::abs_mouse(platf_input, abs_port, tpcoords->first, tpcoords->second); + return; + } + + abs_mouse_as_relative(input, abs_port, { *tpcoords, x, y, width, height }, touch_port_dim_x, touch_port_dim_y); } /** diff --git a/src/platform/common.h b/src/platform/common.h index dcb126878b9..219911f4f47 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -5,6 +5,7 @@ #pragma once // standard includes +#include #include #include #include @@ -74,6 +75,29 @@ namespace nvenc { } namespace platf { + // Real cursor position feedback published by the capture pipeline. + // The KMS backend reads the cursor plane position once per captured frame and + // publishes it here so the input path can close the loop of the abs->rel + // mouse conversion (see config: absolute_mouse_as_relative). Coordinates are + // in desktop physical pixels; the logical extents allow rescaling to the + // compositor's logical space. seq starts at 0 and is bumped on every update, + // so consumers can tell fresh values from stale ones. + struct kms_cursor_feedback_t { + std::atomic_int32_t x { -1 }; + std::atomic_int32_t y { -1 }; + std::atomic_int32_t desktop_w { 0 }; ///< Physical width of the streamed output. + std::atomic_int32_t desktop_h { 0 }; ///< Physical height of the streamed output. + std::atomic_int32_t logical_w { 0 }; ///< Logical width of the streamed output. + std::atomic_int32_t logical_h { 0 }; ///< Logical height of the streamed output. + std::atomic_uint64_t seq { 0 }; + }; + + /** + * @brief Access the process-wide cursor feedback instance. + */ + kms_cursor_feedback_t& + kms_cursor_feedback(); + // Limited by bits in activeGamepadMask constexpr auto MAX_GAMEPADS = 16; ///< Maximum number of simultaneously tracked gamepads. diff --git a/src/platform/linux/kmsgrab.cpp b/src/platform/linux/kmsgrab.cpp index 9575e2c6097..f8b19dcf12d 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -1251,6 +1251,33 @@ namespace platf { captured_cursor.dst_w = *prop_crtc_w; captured_cursor.dst_h = *prop_crtc_h; + // Publish the real cursor position for the abs->rel input conversion + // (see config: absolute_mouse_as_relative). Cursor-plane CRTC + // coordinates are CRTC-local physical pixels; add the output's desktop + // offset and publish the output extents so the consumer can rescale + // to logical touch-port units. + auto &cursor_fb = platf::kms_cursor_feedback(); + cursor_fb.x.store(offset_x + *prop_crtc_x); + cursor_fb.y.store(offset_y + *prop_crtc_y); + cursor_fb.desktop_w.store(width); + cursor_fb.desktop_h.store(height); + cursor_fb.logical_w.store(logical_width); + cursor_fb.logical_h.store(logical_height); + cursor_fb.seq.fetch_add(1); + + // Publish the real cursor position for the abs->rel input conversion + // (see config: absolute_mouse_as_relative). Cursor-plane CRTC + // coordinates are CRTC-local physical pixels; add the output's desktop + // offset and publish the output extents so the consumer can rescale + // to logical units. + platf::kms_cursor_x.store(offset_x + *prop_crtc_x, std::memory_order_relaxed); + platf::kms_cursor_y.store(offset_y + *prop_crtc_y, std::memory_order_relaxed); + platf::kms_desktop_w.store(width, std::memory_order_relaxed); + platf::kms_desktop_h.store(height, std::memory_order_relaxed); + platf::kms_logical_w.store(logical_width, std::memory_order_relaxed); + platf::kms_logical_h.store(logical_height, std::memory_order_relaxed); + platf::kms_cursor_seq.fetch_add(1, std::memory_order_release); + // We're technically cheating a bit here by assuming that we can detect // changes to the cursor plane via property adjustments. If this isn't // true, we'll really have to mmap() the dmabuf and draw that every time. From c37ba50cc590507cfbc78e0bbfd7f915d97eabca Mon Sep 17 00:00:00 2001 From: danalec Date: Thu, 27 Aug 2026 21:17:56 -0300 Subject: [PATCH 2/3] linux/kmsgrab: remove duplicated cursor feedback block (merge artifact) --- src/platform/linux/kmsgrab.cpp | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/platform/linux/kmsgrab.cpp b/src/platform/linux/kmsgrab.cpp index f8b19dcf12d..17e6e4e40fc 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -1265,19 +1265,6 @@ namespace platf { cursor_fb.logical_h.store(logical_height); cursor_fb.seq.fetch_add(1); - // Publish the real cursor position for the abs->rel input conversion - // (see config: absolute_mouse_as_relative). Cursor-plane CRTC - // coordinates are CRTC-local physical pixels; add the output's desktop - // offset and publish the output extents so the consumer can rescale - // to logical units. - platf::kms_cursor_x.store(offset_x + *prop_crtc_x, std::memory_order_relaxed); - platf::kms_cursor_y.store(offset_y + *prop_crtc_y, std::memory_order_relaxed); - platf::kms_desktop_w.store(width, std::memory_order_relaxed); - platf::kms_desktop_h.store(height, std::memory_order_relaxed); - platf::kms_logical_w.store(logical_width, std::memory_order_relaxed); - platf::kms_logical_h.store(logical_height, std::memory_order_relaxed); - platf::kms_cursor_seq.fetch_add(1, std::memory_order_release); - // We're technically cheating a bit here by assuming that we can detect // changes to the cursor plane via property adjustments. If this isn't // true, we'll really have to mmap() the dmabuf and draw that every time. From c747672931c1a230103bd9e8fcdf75d1da43b194 Mon Sep 17 00:00:00 2001 From: danalec Date: Thu, 27 Aug 2026 21:34:08 -0300 Subject: [PATCH 3/3] linux/input: fix abs->rel cursor feedback (output-local coords + seqlock) - Publish the cursor position relative to the captured output (no desktop offset); the consumer compares against touch-port coordinates, which are output-relative, so adding the offset mis-anchored the estimate on multi-monitor setups. - Make the feedback read/write consistent with a seqlock so the input path never observes a half-updated snapshot (position from one frame, extents from another). --- src/input.cpp | 38 ++++++++++++++++++++++++++-------- src/platform/common.h | 14 +++++++++---- src/platform/linux/kmsgrab.cpp | 12 ++++++----- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/input.cpp b/src/input.cpp index 8af25e19a50..21e4b18d909 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -829,23 +829,43 @@ namespace input { abs_mouse_sync_estimate(const std::shared_ptr &input, bool client_idle, bool sat_x, bool sat_y, float port_w, float port_h) { const auto &fb = platf::kms_cursor_feedback(); - const auto seq = fb.seq.load(); + // Seqlock read: retry while the writer is mid-update, and validate the + // sequence is unchanged after snapshotting the fields. + std::uint64_t seq; + std::int32_t cursor_x; + std::int32_t cursor_y; + std::int32_t phys_w; + std::int32_t phys_h; + std::int32_t logical_w; + std::int32_t logical_h; + for (;;) { + seq = fb.seq.load(); + if (seq & 1) { + continue; + } + cursor_x = fb.x.load(); + cursor_y = fb.y.load(); + phys_w = fb.desktop_w.load(); + phys_h = fb.desktop_h.load(); + logical_w = fb.logical_w.load(); + logical_h = fb.logical_h.load(); + if (seq == fb.seq.load()) { + break; + } + } + if (seq == 0 || seq == input->abs_mouse.seq_last) { return; } input->abs_mouse.seq_last = seq; - const auto phys_w = static_cast(fb.desktop_w.load()); - const auto phys_h = static_cast(fb.desktop_h.load()); - const auto logical_w = static_cast(fb.logical_w.load()); - const auto logical_h = static_cast(fb.logical_h.load()); - if (phys_w <= 0.0f || phys_h <= 0.0f || logical_w <= 0.0f || logical_h <= 0.0f) { + if (phys_w <= 0 || phys_h <= 0 || logical_w <= 0 || logical_h <= 0) { return; } - // Desktop physical pixels -> compositor logical pixels. - const auto real_x = static_cast(fb.x.load()) * (logical_w / phys_w); - const auto real_y = static_cast(fb.y.load()) * (logical_h / phys_h); + // Output-local physical pixels -> compositor logical pixels. + const auto real_x = static_cast(cursor_x) * (static_cast(logical_w) / static_cast(phys_w)); + const auto real_y = static_cast(cursor_y) * (static_cast(logical_h) / static_cast(phys_h)); if (client_idle) { input->abs_mouse.host_x = std::clamp(real_x, 0.0f, port_w - 1.0f); diff --git a/src/platform/common.h b/src/platform/common.h index 219911f4f47..08ca6e5a3a9 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -79,9 +79,15 @@ namespace platf { // The KMS backend reads the cursor plane position once per captured frame and // publishes it here so the input path can close the loop of the abs->rel // mouse conversion (see config: absolute_mouse_as_relative). Coordinates are - // in desktop physical pixels; the logical extents allow rescaling to the - // compositor's logical space. seq starts at 0 and is bumped on every update, - // so consumers can tell fresh values from stale ones. + // OUTPUT-LOCAL physical pixels (relative to the captured output, with no + // desktop offset); the logical extents allow rescaling to the compositor's + // logical space. The consumer compares against touch-port coordinates + // (0..port), so the position must stay relative to the output, not the + // desktop. + // + // seq doubles as a seqlock: the writer bumps it to an odd value, publishes + // the fields, then bumps it back to an even value. Readers must only consume + // a snapshot taken while seq is even and stable across the read. struct kms_cursor_feedback_t { std::atomic_int32_t x { -1 }; std::atomic_int32_t y { -1 }; @@ -89,7 +95,7 @@ namespace platf { std::atomic_int32_t desktop_h { 0 }; ///< Physical height of the streamed output. std::atomic_int32_t logical_w { 0 }; ///< Logical width of the streamed output. std::atomic_int32_t logical_h { 0 }; ///< Logical height of the streamed output. - std::atomic_uint64_t seq { 0 }; + std::atomic_uint64_t seq { 0 }; ///< Seqlock: odd while writing, even when consistent. }; /** diff --git a/src/platform/linux/kmsgrab.cpp b/src/platform/linux/kmsgrab.cpp index 17e6e4e40fc..eb271b4d5d3 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -1253,12 +1253,14 @@ namespace platf { // Publish the real cursor position for the abs->rel input conversion // (see config: absolute_mouse_as_relative). Cursor-plane CRTC - // coordinates are CRTC-local physical pixels; add the output's desktop - // offset and publish the output extents so the consumer can rescale - // to logical touch-port units. + // coordinates are output-local physical pixels (relative to the + // captured output, no desktop offset); publish them together with the + // output extents so the consumer can rescale to logical touch-port + // units. The fields are written under a seqlock (seq odd while writing). auto &cursor_fb = platf::kms_cursor_feedback(); - cursor_fb.x.store(offset_x + *prop_crtc_x); - cursor_fb.y.store(offset_y + *prop_crtc_y); + cursor_fb.seq.fetch_add(1); + cursor_fb.x.store(*prop_crtc_x); + cursor_fb.y.store(*prop_crtc_y); cursor_fb.desktop_w.store(width); cursor_fb.desktop_h.store(height); cursor_fb.logical_w.store(logical_width);