Keep the relative-mode cursor on the visible pointer when warps are not honoured

League enters relative mouse mode on GNOME (Xwayland) and inside gamescope, but
keeps a visible cursor and warps it every frame at its own mouse speed. Xwayland
cannot move a visible pointer, so those warps never took effect: the virtual
cursor, where clicks land, drifted hundreds of pixels from the cursor on screen
and never reached the screen edges, which also stopped edge panning.

Where warps are not honoured, the virtual cursor now follows the physical
pointer and the game's warps leave it in place. With a hidden cursor, or on a
server that honours warps, nothing changes. The host model test gains a case
replaying the drift from an in-game trace.

Tested manually in the practice tool under gamescope 3.16.31 on GNOME 51 with
AMD RADV: clicks land on the cursor and edge panning works.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DevWebeloperandClaude Opus 5.5 committed 2026-10-17 00:49:53 +03:00
1 parent d7a51ede17
commit adf74bf726
3 files changed
+62 -19

No files matched your search

+3 -1
View File
@@ -232,7 +232,9 @@ The X11 backend previously called `XSync` after every cursor-shape update, waiti
A later input trace showed League setting the cursor about 190 times and unhiding it about 100 times per second, each time sending the same cursor to the X server. The backend now skips the update when the window already has that cursor. The effect on latency has not been measured.
The backend's relative mouse mode (`CGAssociateMouseAndMouseCursorPosition(false)`) previously warped the pointer to the window center, treated the game's own cursor warps as motion and reported `deltaY` upside down. It now follows macOS: the cursor stays where it is, events carry the physical motion as deltas (positive `deltaY` downwards, also for ordinary motion), the game's warps move the cursor without a delta, and `+[NSEvent mouseLocation]` returns that cursor. On Xwayland a warp takes effect only while the cursor is hidden, so with a visible cursor the pointer is not held in place. `tools/test-x11-relative-mouse.c` checks this logic on the host (`cc tools/test-x11-relative-mouse.c && ./a.out` after `scripts/prepare.py`). League on the tested Hyprland setup never enters this mode, so it has not been confirmed in game.
The backend's relative mouse mode (`CGAssociateMouseAndMouseCursorPosition(false)`) previously warped the pointer to the window center, treated the game's own cursor warps as motion and reported `deltaY` upside down. It now follows macOS: the cursor stays where it is, events carry the physical motion as deltas (positive `deltaY` downwards, also for ordinary motion), the game's warps move the cursor without a delta, and `+[NSEvent mouseLocation]` returns that cursor. On Xwayland a warp takes effect only while the cursor is hidden, so with a visible cursor the pointer is not held in place. `tools/test-x11-relative-mouse.c` checks this logic on the host (`cc tools/test-x11-relative-mouse.c && ./a.out` after `scripts/prepare.py`). League on the tested Hyprland setup never enters this mode.
On GNOME (Xwayland) and inside gamescope, League does enter it, but keeps a visible cursor and warps it each frame at its own mouse speed (about half the physical motion in a local trace). Those warps cannot move a visible Xwayland pointer, so the virtual cursor, where clicks land, drifted hundreds of pixels from the cursor on screen and never reached the screen edges, which stopped edge panning. Where warps are not honoured, the virtual cursor now follows the physical pointer and the game's warps leave it in place; with a hidden cursor nothing changes. A manual practice-tool test on 2026-10-17 (GNOME 51, gamescope 3.16.31, AMD RADV) confirmed clicks on the cursor and edge panning in gamescope. The plain GNOME session, an NVIDIA driver and a real match were not retested.
For a manual comparison, keep the scene, FPS cap, display refresh and compositor settings the same. Record whether the delay affects the cursor itself, camera movement, or actions after clicking, and compare the in-game VSync setting on and off. Include the desktop/compositor, X11 or Xwayland session, GPU/driver, monitor refresh and fullscreen/windowed mode in the report. Do not automate game input. The fullscreen cursor-shape problem below is a separate known symptom.
+23 -10
View File
@@ -2170,7 +2170,7 @@ index 18189404..5b3a34bc 100644
+- (NSRect) frameForRootRect: (NSRect) rect;
@end
diff --git a/AppKit/X11.backend/X11Display.m b/AppKit/X11.backend/X11Display.m
index 5ae29bf4..86a647ff 100644
index 5ae29bf4..af9c4e44 100644
--- a/AppKit/X11.backend/X11Display.m
+++ b/AppKit/X11.backend/X11Display.m
@@ -48,12 +48,271 @@
@@ -3499,7 +3499,7 @@ index 5ae29bf4..86a647ff 100644
return 0;
}
@@ -1476,54 +2245,218 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top,
@@ -1476,54 +2245,220 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top,
return frame;
}
@@ -3536,8 +3536,10 @@ index 5ae29bf4..86a647ff 100644
+ int x = X11RelativeMouseWarpCoordinate(position.x), y = X11RelativeMouseWarpCoordinate(position.y);
+ if (_relativeMouse.active) {
+ /* CGWarpMouseCursorPosition moves the disassociated cursor without producing a delta: the virtual
+ * cursor follows and the warp's echo is dropped. Not honoured on Xwayland with a visible cursor. */
+ X11RelativeMouseWarp(&_relativeMouse, position.x, position.y, [self warpPointerToRootX: x y: y]);
+ * cursor follows and the warp's echo is dropped. Not honoured on Xwayland with a visible cursor, where
+ * the virtual cursor keeps tracking the pointer the user sees. */
+ X11RelativeMouseWarp(&_relativeMouse, position.x, position.y, [self warpPointerToRootX: x y: y],
+ !_isXwayland || _cursorHidden);
+ return;
+ }
NSLog(@"Warp to: x=%f, y=%f\n", position.x, position.y);
@@ -9803,10 +9805,10 @@ index 00000000..a51a9241
+#endif
diff --git a/AppKit/X11.backend/X11RelativeMouse.h b/AppKit/X11.backend/X11RelativeMouse.h
new file mode 100644
index 00000000..7fc39cc4
index 00000000..d34c1bcc
--- /dev/null
+++ b/AppKit/X11.backend/X11RelativeMouse.h
@@ -0,0 +1,147 @@
@@ -0,0 +1,158 @@
+/* darling-lol: decision logic for macOS-style relative mouse mode in the X11 backend.
+ *
+ * While CGAssociateMouseAndMouseCursorPosition(false) is in effect, macOS freezes the cursor: physical motion
@@ -9820,7 +9822,10 @@ index 00000000..7fc39cc4
+ *
+ * On Xwayland a warp only reaches the compositor while the X cursor is hidden. With a visible cursor the echo
+ * still arrives, but the next real motion reports the unmoved physical pointer, so honoured=false keeps the last
+ * physical position across echoes and never warps back.
+ * physical position across echoes and never warps back. The cursor the user sees is then the physical pointer,
+ * so the virtual cursor follows it and application warps do not move it: League keeps a visible cursor while
+ * relative on GNOME and gamescope and warps it at its own mouse speed, which otherwise left the virtual cursor
+ * (where clicks land) hundreds of pixels from the visible one and kept it off the screen edges.
+ *
+ * Plain C without X11 or Objective-C dependencies, so tools/test-x11-relative-mouse.c can run it on the host. */
+#ifndef X11_RELATIVE_MOUSE_H
@@ -9889,11 +9894,15 @@ index 00000000..7fc39cc4
+ mouse->echoes[mouse->echoCount++] = (X11RelativeMouseEcho) {x, y, queuedEvents};
+}
+
+/* CGWarpMouseCursorPosition while relative: the virtual cursor moves to the target; the physical position is
+ * only updated when the echo arrives (and only if the warp was honoured). */
+static inline void X11RelativeMouseWarp(X11RelativeMouse *mouse, double x, double y, unsigned queuedEvents) {
+/* CGWarpMouseCursorPosition while relative: where warps are honoured the virtual cursor moves to the target and
+ * the physical position is updated when the echo arrives. Otherwise the pointer stays put, and so does the
+ * virtual cursor that tracks it; the echo is still expected so it is not taken for motion. */
+static inline void X11RelativeMouseWarp(X11RelativeMouse *mouse, double x, double y, unsigned queuedEvents,
+ bool warpsHonoured) {
+ if (warpsHonoured) {
+ mouse->virtualX = x;
+ mouse->virtualY = y;
+ }
+ X11RelativeMouseExpectEcho(mouse, X11RelativeMouseWarpCoordinate(x), X11RelativeMouseWarpCoordinate(y),
+ queuedEvents);
+}
@@ -9932,6 +9941,10 @@ index 00000000..7fc39cc4
+ result.deltaY = rootY - mouse->physicalY;
+ mouse->physicalX = rootX;
+ mouse->physicalY = rootY;
+ if (!warpsHonoured) {
+ mouse->virtualX = rootX;
+ mouse->virtualY = rootY;
+ }
+ result.post = result.deltaX != 0.0 || result.deltaY != 0.0;
+ int warpX = X11RelativeMouseWarpCoordinate(mouse->virtualX);
+ int warpY = X11RelativeMouseWarpCoordinate(mouse->virtualY);
+36 -8
View File
@@ -44,7 +44,7 @@ static void xorgHonouredWarps(void) {
r = motion(&m, 512, 400, true);
assert(r.deltaX == 12 && r.rewarp);
X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 1);
X11RelativeMouseWarp(&m, 524.0, 400.0, 2); /* game: mouseLocation + 2 * delta */
X11RelativeMouseWarp(&m, 524.0, 400.0, 2, true); /* game: mouseLocation + 2 * delta */
assert(m.virtualX == 524.0 && m.physicalX == 512);
r = motion(&m, 500, 400, true);
assert(r.echo && m.physicalX == 500);
@@ -58,19 +58,22 @@ static void xorgHonouredWarps(void) {
static void xwaylandVisibleCursor(void) {
X11RelativeMouse m = {0};
X11RelativeMouseBegin(&m, 800, 450);
/* Warps are not honoured: no rewarp, the pointer moves freely, deltas follow it. */
/* Warps are not honoured: no rewarp, the pointer moves freely, deltas follow it and so does the virtual
* cursor, since the visible cursor is the pointer. */
X11RelativeMouseMotion r = motion(&m, 810, 450, false);
assert(r.post && r.deltaX == 10 && !r.rewarp);
X11RelativeMouseWarp(&m, 820.0, 450.0, 1);
assert(m.virtualX == 810.0 && m.virtualY == 450.0);
X11RelativeMouseWarp(&m, 820.0, 450.0, 1, false);
assert(m.virtualX == 810.0 && m.virtualY == 450.0); /* the pointer did not move, so neither does the cursor */
/* The echo at the target is dropped without moving the physical position... */
r = motion(&m, 820, 450, false);
assert(r.echo && !r.post && m.physicalX == 810 && m.physicalY == 450);
/* ...so the next real motion, at the real absolute position, gives the true delta. */
r = motion(&m, 812, 453, false);
assert(r.post && r.deltaX == 2 && r.deltaY == 3 && !r.rewarp);
assert(m.virtualX == 820.0 && m.virtualY == 450.0);
assert(m.virtualX == 812.0 && m.virtualY == 453.0);
/* An expected echo that never arrives expires after the queued events and cannot eat a later motion. */
X11RelativeMouseWarp(&m, 700.0, 400.0, 1);
X11RelativeMouseWarp(&m, 700.0, 400.0, 1, false);
X11RelativeMouseTick(&m); /* some other X event was the one queued */
assert(m.echoCount == 0);
r = motion(&m, 700, 400, false);
@@ -78,10 +81,34 @@ static void xwaylandVisibleCursor(void) {
puts("PASS: Xwayland visible cursor keeps the physical position across unhonoured warp echoes");
}
/* League on GNOME and gamescope: relative with a visible cursor, warping its cursor at about half the physical
* motion each frame (from an in-game trace). Clicks land at the virtual cursor, so it must stay on the visible
* pointer and reach the screen edges, where League pans the camera. */
static void leagueVisibleCursorWarps(void) {
X11RelativeMouse m = {0};
X11RelativeMouseBegin(&m, 2888, 30);
int x = 2888, y = 30;
for (int step = 0; step < 40; ++step) {
x -= 20;
y += 16;
X11RelativeMouseMotion r = motion(&m, x, y, false);
assert(r.post && r.deltaX == -20 && r.deltaY == 16 && !r.rewarp);
double gameX = m.virtualX + r.deltaX / 2, gameY = m.virtualY + r.deltaY / 2;
X11RelativeMouseWarp(&m, gameX, gameY, 1, false);
r = motion(&m, (int) gameX, (int) gameY, false); /* the unhonoured warp's echo */
assert(r.echo && !r.post);
assert(m.virtualX == x && m.virtualY == y);
}
/* The pointer stops at the right edge of a 3440-pixel screen; the cursor is there too. */
X11RelativeMouseMotion r = motion(&m, 3439, 700, false);
assert(r.post && m.virtualX == 3439 && m.virtualY == 700);
puts("PASS: League's visible-cursor warps leave the virtual cursor on the pointer, up to the screen edge");
}
static void gameWarpUpdatesVirtual(void) {
X11RelativeMouse m = {0};
X11RelativeMouseBegin(&m, 100, 100);
X11RelativeMouseWarp(&m, 150.75, 120.5, 0); /* no event: the pointer was already there */
X11RelativeMouseWarp(&m, 150.75, 120.5, 0, true); /* no event: the pointer was already there */
assert(m.virtualX == 150.75 && m.virtualY == 120.5 && m.echoCount == 0);
assert(m.physicalX == 100 && m.physicalY == 100);
/* mouseLocation reports the virtual cursor, Cocoa bottom-up global, for a 1080-pixel screen. */
@@ -107,7 +134,7 @@ static void deltaSign(void) {
static void ungrabRestores(void) {
X11RelativeMouse m = {0};
X11RelativeMouseBegin(&m, 10, 10);
X11RelativeMouseWarp(&m, 40, 40, 3);
X11RelativeMouseWarp(&m, 40, 40, 3, true);
X11RelativeMouseEnd(&m);
assert(!m.active && m.echoCount == 0);
X11RelativeMouseBegin(&m, 60, 70);
@@ -121,7 +148,7 @@ static void echoQueueBound(void) {
X11RelativeMouse m = {0};
X11RelativeMouseBegin(&m, 0, 0);
for (int i = 1; i <= X11_RELATIVE_MOUSE_MAX_ECHOES + 3; ++i)
X11RelativeMouseWarp(&m, i, 0, 100);
X11RelativeMouseWarp(&m, i, 0, 100, true);
assert(m.echoCount == X11_RELATIVE_MOUSE_MAX_ECHOES && m.echoes[0].x == 4);
X11RelativeMouseMotion r = motion(&m, 6, 0, true);
assert(r.echo && m.echoCount == X11_RELATIVE_MOUSE_MAX_ECHOES - 3 && m.echoes[0].x == 7);
@@ -131,6 +158,7 @@ static void echoQueueBound(void) {
int main(void) {
xorgHonouredWarps();
xwaylandVisibleCursor();
leagueVisibleCursorWarps();
gameWarpUpdatesVirtual();
deltaSign();
ungrabRestores();