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.