From 426fb75ec84fe6155966f2061c05fb3bd002e7b8 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:14:06 +0200 Subject: [PATCH] Emulate macOS relative mouse mode in the X11 backend and skip redundant cursor updates CGAssociateMouseAndMouseCursorPosition(false) used to warp the pointer to the window center, freeze the reported position there and treat the game's own CGWarpMouseCursorPosition calls as motion. On Xwayland, which ignores warps while the cursor is visible, this left League's cursor stuck in the middle. The backend now keeps a frozen virtual cursor: physical motion only produces deltas (deltaY positive downwards), application warps move the virtual cursor and their echoes are dropped, mouseLocation returns it, and the pointer is only warped back where the server honours warps. The grab no longer uses the invalid FocusChangeMask and is released and retaken across focus changes. setCursor: no longer re-sends XDefineCursor/XFlush when the window already has that cursor; League set it about 190 times per second. Also carries the opt-in input trace (/tmp/rclol-input-trace in the guest). Co-Authored-By: Claude Opus 5.5 --- docs/TROUBLESHOOTING.md | 4 + patches/cocotron.patch | 949 +++++++++++++++++++++++++++++--- tools/test-x11-relative-mouse.c | 139 +++++ 3 files changed, 1022 insertions(+), 70 deletions(-) create mode 100644 tools/test-x11-relative-mouse.c diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index c04aa46..75b1c4d 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -230,6 +230,10 @@ These are host settings, not game settings. Run on AC power, and use a performan The X11 backend previously called `XSync` after every cursor-shape update, waiting for an X server reply on the input thread. It now uses `XFlush` to send the change immediately without waiting. This removes an avoidable round trip during cursor updates. A local manual retest on 2026-10-16 found that the mouse problem no longer occurred with VSync enabled. Input-to-display latency has not been measured, and confirmation from the original reporter is still pending. Rebuild and install with `./scripts/update.sh` after exiting the game and both clients. +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. + 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. ## Cursor stops changing shape in fullscreen diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 74426f1..00a9399 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2107,10 +2107,17 @@ index bdc97bdf..139c91eb 100644 _cursor = XcursorLibraryLoadCursor(display, "left_ptr"); return self; diff --git a/AppKit/X11.backend/X11Display.h b/AppKit/X11.backend/X11Display.h -index 18189404..dd6215c9 100644 +index 18189404..0eff2187 100644 --- a/AppKit/X11.backend/X11Display.h +++ b/AppKit/X11.backend/X11Display.h -@@ -16,7 +16,7 @@ +@@ -10,13 +10,14 @@ + #import + #import + #import ++#import "X11RelativeMouse.h" + + #ifdef DARLING + #import #import #endif @@ -2119,10 +2126,21 @@ index 18189404..dd6215c9 100644 @interface X11Display : NSDisplay { Display *_display; -@@ -37,7 +37,10 @@ +@@ -36,8 +37,20 @@ + NSTimeInterval lastClickTimeStamp; int clickCount; X11Cursor *_blankCursor, *_defaultCursor; - BOOL _cursorGrabbed; +- BOOL _cursorGrabbed; ++ /* Relative mouse mode: requested by CGAssociateMouseAndMouseCursorPosition(false); _relativeMouse.active ++ * while its pointer grab on _relativeWindow is held (released on focus loss, retaken on focus). */ ++ BOOL _relativeRequested; ++ Window _relativeWindow; ++ X11RelativeMouse _relativeMouse; ++ BOOL _isXwayland; ++ /* The cursor last defined by setCursor:, retained so its X id cannot be freed and reused while cached. */ ++ Window _definedCursorWindow; ++ X11Cursor *_definedCursor; ++ BOOL _cursorHidden; + Window _confinedWindow; + unsigned int _otherButtonsDown; KeySym _lastKeySym; @@ -2130,7 +2148,7 @@ index 18189404..dd6215c9 100644 int _rrEventBase; NSArray* _lastScreens; -@@ -45,7 +48,9 @@ +@@ -45,7 +58,9 @@ XIM _xim; } @@ -2141,10 +2159,10 @@ index 18189404..dd6215c9 100644 - (void) setWindow: (id) window forID: (XID) i; diff --git a/AppKit/X11.backend/X11Display.m b/AppKit/X11.backend/X11Display.m -index 5ae29bf4..358e606b 100644 +index 5ae29bf4..024d3ec2 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m -@@ -48,15 +48,17 @@ +@@ -48,12 +48,271 @@ #import "X11KeySymToUCS.h" #import #import @@ -2156,13 +2174,267 @@ index 5ae29bf4..358e606b 100644 #import #import +#include ++ ++/* darling-lol: passive, opt-in input trace for diagnosing relative mouse mode (e.g. League on Xwayland). ++ * Enabled only when the file /tmp/rclol-input-trace exists (checked once per process); lines are appended to ++ * /tmp/rclol-input-trace.log with the pid and a monotonic millisecond timestamp. It records pointer grabs, ++ * warps, cursor visibility, motion handling and pointer-position queries, and never changes behaviour. When ++ * disabled the cost is one load and branch; when enabled no X requests are added except the one-time startup ++ * extension queries. High-rate sources log a few lines verbatim and are then summarised once per second. ++ * Never logs key codes, text or window titles. Independent of the older RCLOL_INPUT_TRACE stderr logging. */ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++static int rclolTraceState = -1; ++static FILE *rclolTraceFile; ++static pthread_once_t rclolTraceOnce = PTHREAD_ONCE_INIT; ++static pthread_mutex_t rclolTraceLock = PTHREAD_MUTEX_INITIALIZER; ++static unsigned rclolGrabGeneration; ++ ++static void rclolTraceInit(void) { ++ int on = 0; ++ if (access("/tmp/rclol-input-trace", F_OK) == 0) { ++ rclolTraceFile = fopen("/tmp/rclol-input-trace.log", "a"); ++ if (rclolTraceFile) { ++ setvbuf(rclolTraceFile, NULL, _IOLBF, 0); ++ on = 1; ++ } ++ } ++ __atomic_store_n(&rclolTraceState, on, __ATOMIC_RELEASE); ++} ++ ++static inline BOOL rclolTraceOn(void) { ++ int state = __atomic_load_n(&rclolTraceState, __ATOMIC_ACQUIRE); ++ if (__builtin_expect(state >= 0, 1)) ++ return state; ++ pthread_once(&rclolTraceOnce, rclolTraceInit); ++ return __atomic_load_n(&rclolTraceState, __ATOMIC_ACQUIRE); ++} ++ ++static unsigned long long rclolTraceNowMs(void) { ++ static mach_timebase_info_data_t tb; ++ if (tb.denom == 0) mach_timebase_info(&tb); ++ return (unsigned long long) ((__uint128_t) mach_absolute_time() * tb.numer / tb.denom / 1000000u); ++} ++ ++static void rclolTraceLine(const char *format, ...) __attribute__((format(printf, 1, 2))); ++static void rclolTraceLine(const char *format, ...) { ++ char message[640]; ++ va_list args; ++ va_start(args, format); ++ vsnprintf(message, sizeof message, format, args); ++ va_end(args); ++ fprintf(rclolTraceFile, "pid=%d t=%llums %s\n", (int) getpid(), rclolTraceNowMs(), message); ++} ++ ++/* A rate-limited source: the first `limit` calls are logged verbatim, later ones are counted and summarised ++ * (count and last message) once at least a second has passed. */ ++typedef struct { ++ const char *name; ++ unsigned limit, verbatim, pending; ++ unsigned long long windowStart; ++ char last[320]; ++} RclolTraceChannel; ++#define RCLOL_CHANNEL(n, l) { n, l, 0, 0, 0, "" } ++static RclolTraceChannel rclolWarpAppChannel = RCLOL_CHANNEL("warp[app]", 20); ++static RclolTraceChannel rclolWarpRewarpChannel = RCLOL_CHANNEL("warp[motion-rewarp]", 20); ++static RclolTraceChannel rclolHideChannel = RCLOL_CHANNEL("hideCursor", 20); ++static RclolTraceChannel rclolUnhideChannel = RCLOL_CHANNEL("unhideCursor", 20); ++static RclolTraceChannel rclolSetCursorChannel = RCLOL_CHANNEL("setCursor", 20); ++static RclolTraceChannel rclolFocusChannel = RCLOL_CHANNEL("focus", 60); ++static RclolTraceChannel rclolConfineChannel = RCLOL_CHANNEL("confinement", 60); ++static RclolTraceChannel rclolErrorChannel = RCLOL_CHANNEL("xerror", 40); ++static RclolTraceChannel *rclolChannels[] = { ++ &rclolWarpAppChannel, &rclolWarpRewarpChannel, &rclolHideChannel, ++ &rclolUnhideChannel, &rclolSetCursorChannel, &rclolFocusChannel, &rclolConfineChannel, &rclolErrorChannel, ++}; ++ ++static struct { ++ unsigned verbatim, events, echoes, rewarps, relative, absolute; ++ unsigned long long windowStart; ++ unsigned long window; ++ int x, y, rootX, rootY, grabbed; ++ double dx, dy, lastX, lastY; ++} rclolMotion; ++ ++static struct { ++ unsigned verbatim, calls, hits, queries, failures; ++ unsigned long long windowStart; ++ double x, y; ++ int rootX, rootY; ++} rclolLocation; ++ ++static void rclolTraceFlushChannelLocked(RclolTraceChannel *channel, unsigned long long now) { ++ if (channel->pending) ++ rclolTraceLine("%s summary: %u calls in %llums, last: %s", channel->name, channel->pending, ++ now - channel->windowStart, channel->last); ++ channel->pending = 0; ++ channel->windowStart = now; ++} ++ ++static void rclolTraceTickLocked(unsigned long long now, BOOL force) { ++ for (size_t i = 0; i < sizeof rclolChannels / sizeof *rclolChannels; ++i) ++ if (rclolChannels[i]->pending && (force || now - rclolChannels[i]->windowStart >= 1000)) ++ rclolTraceFlushChannelLocked(rclolChannels[i], now); ++ if (rclolMotion.events && (force || now - rclolMotion.windowStart >= 1000)) { ++ rclolTraceLine("motion summary: %u events (%u warp-echo, %u relative+rewarp, %u relative, %u absolute) in " ++ "%llums window=%lu relative=%d last ev=%d,%d root=%d,%d delta=%.1f,%.1f last=%.1f,%.1f", ++ rclolMotion.events, rclolMotion.echoes, rclolMotion.rewarps, rclolMotion.relative, ++ rclolMotion.absolute, ++ now - rclolMotion.windowStart, rclolMotion.window, rclolMotion.grabbed, rclolMotion.x, ++ rclolMotion.y, rclolMotion.rootX, rclolMotion.rootY, rclolMotion.dx, rclolMotion.dy, ++ rclolMotion.lastX, rclolMotion.lastY); ++ rclolMotion.events = rclolMotion.echoes = rclolMotion.rewarps = rclolMotion.relative = 0; ++ rclolMotion.absolute = 0; ++ } ++ if (rclolLocation.calls && (force || now - rclolLocation.windowStart >= 1000)) { ++ rclolTraceLine("mouseLocation summary: %u calls (%u cache hits, %u queries, %u failed) in %llums " ++ "last=%.1f,%.1f root=%d,%d", ++ rclolLocation.calls, rclolLocation.hits, rclolLocation.queries, rclolLocation.failures, ++ now - rclolLocation.windowStart, rclolLocation.x, rclolLocation.y, rclolLocation.rootX, ++ rclolLocation.rootY); ++ rclolLocation.calls = rclolLocation.hits = rclolLocation.queries = rclolLocation.failures = 0; ++ } ++} ++ ++static void rclolTraceRated(RclolTraceChannel *channel, const char *format, ...) __attribute__((format(printf, 2, 3))); ++static void rclolTraceRated(RclolTraceChannel *channel, const char *format, ...) { ++ char message[320]; ++ va_list args; ++ va_start(args, format); ++ vsnprintf(message, sizeof message, format, args); ++ va_end(args); ++ pthread_mutex_lock(&rclolTraceLock); ++ unsigned long long now = rclolTraceNowMs(); ++ if (channel->verbatim < channel->limit) { ++ channel->verbatim++; ++ rclolTraceLine("%s #%u gen=%u: %s", channel->name, channel->verbatim, rclolGrabGeneration, message); ++ } else { ++ if (!channel->pending) channel->windowStart = now; ++ channel->pending++; ++ memcpy(channel->last, message, sizeof message); ++ } ++ rclolTraceTickLocked(now, NO); ++ pthread_mutex_unlock(&rclolTraceLock); ++} ++ ++/* Each grabMouse: call starts a new generation: flush pending summaries and re-arm the verbatim budgets for ++ * warps, motion and mouseLocation so the loop's first steps after every grab change are visible. */ ++static void rclolTraceGrabChanged(void) { ++ pthread_mutex_lock(&rclolTraceLock); ++ rclolTraceTickLocked(rclolTraceNowMs(), YES); ++ rclolGrabGeneration++; ++ rclolWarpAppChannel.verbatim = rclolWarpRewarpChannel.verbatim = 0; ++ rclolMotion.verbatim = rclolLocation.verbatim = 0; ++ pthread_mutex_unlock(&rclolTraceLock); ++} ++ ++/* pos/last are the window-local event position and previous position for absolute motion, and the virtual cursor ++ * and previous physical pointer (root coordinates) for relative motion. */ ++static void rclolTraceMotion(XEvent *ev, BOOL grabbed, CGPoint pos, CGPoint last, CGFloat dx, CGFloat dy, ++ const char *decision, int forwarded) ++{ ++ pthread_mutex_lock(&rclolTraceLock); ++ unsigned long long now = rclolTraceNowMs(); ++ if (rclolMotion.verbatim < 30) { ++ rclolMotion.verbatim++; ++ rclolTraceLine("motion #%u gen=%u: window=%lu ev=%d,%d root=%d,%d pos=%.1f,%.1f last=%.1f,%.1f " ++ "delta=%.1f,%.1f relative=%d decision=%s forwarded=%d send_event=%d time=%lu state=0x%x", ++ rclolMotion.verbatim, rclolGrabGeneration, (unsigned long) ev->xany.window, ev->xmotion.x, ++ ev->xmotion.y, ev->xmotion.x_root, ev->xmotion.y_root, pos.x, pos.y, last.x, last.y, dx, dy, ++ grabbed, decision, forwarded, ev->xany.send_event, (unsigned long) ev->xmotion.time, ++ ev->xmotion.state); ++ } ++ if (!rclolMotion.events) rclolMotion.windowStart = now; ++ rclolMotion.events++; ++ if (!strcmp(decision, "echo")) rclolMotion.echoes++; ++ else if (!strcmp(decision, "relative+rewarp")) rclolMotion.rewarps++; ++ else if (!strncmp(decision, "relative", 8)) rclolMotion.relative++; ++ else rclolMotion.absolute++; ++ rclolMotion.window = ev->xany.window; ++ rclolMotion.x = ev->xmotion.x; ++ rclolMotion.y = ev->xmotion.y; ++ rclolMotion.rootX = ev->xmotion.x_root; ++ rclolMotion.rootY = ev->xmotion.y_root; ++ rclolMotion.grabbed = grabbed; ++ rclolMotion.dx = dx; ++ rclolMotion.dy = dy; ++ rclolMotion.lastX = last.x; ++ rclolMotion.lastY = last.y; ++ rclolTraceTickLocked(now, NO); ++ pthread_mutex_unlock(&rclolTraceLock); ++} ++ ++static void rclolTraceMouseLocation(NSPoint result, BOOL ok, BOOL hit, int rootX, int rootY, BOOL grabbed) { ++ pthread_mutex_lock(&rclolTraceLock); ++ unsigned long long now = rclolTraceNowMs(); ++ const char *source = grabbed ? "virtual" : !ok ? "query-failed" : hit ? "cache-hit" : "XQueryPointer"; ++ if (rclolLocation.verbatim < 30) { ++ rclolLocation.verbatim++; ++ rclolTraceLine("mouseLocation #%u gen=%u: -> %.1f,%.1f root=%d,%d source=%s relative=%d", ++ rclolLocation.verbatim, rclolGrabGeneration, result.x, result.y, rootX, rootY, source, ++ grabbed); ++ } ++ if (!rclolLocation.calls) rclolLocation.windowStart = now; ++ rclolLocation.calls++; ++ if (!ok) rclolLocation.failures++; ++ else if (hit) rclolLocation.hits++; ++ else rclolLocation.queries++; ++ rclolLocation.x = result.x; ++ rclolLocation.y = result.y; ++ rclolLocation.rootX = rootX; ++ rclolLocation.rootY = rootY; ++ rclolTraceTickLocked(now, NO); ++ pthread_mutex_unlock(&rclolTraceLock); ++} ++ ++static void rclolTraceStartup(Display *display) { ++ if (!rclolTraceOn()) return; ++ int opcode, event, error; ++ Bool xwayland = XQueryExtension(display, "XWAYLAND", &opcode, &event, &error); ++ int xiOpcode = 0, xiEvent = 0, xiError = 0; ++ Bool xi = XQueryExtension(display, "XInputExtension", &xiOpcode, &xiEvent, &xiError); ++ int screen = DefaultScreen(display); ++ rclolTraceLine("startup: input trace enabled; vendor=\"%s\" release=%d protocol=%d.%d xwayland=%d " ++ "XInputExtension=%d opcode=%d (libXi not linked; version not queried) screens=%d default=%d " ++ "size=%dx%d (%dx%dmm) RCLOL_INPUT_TRACE=%d", ++ ServerVendor(display), VendorRelease(display), ProtocolVersion(display), ++ ProtocolRevision(display), xwayland, xi, xiOpcode, ScreenCount(display), screen, ++ DisplayWidth(display, screen), DisplayHeight(display, screen), ++ DisplayWidthMM(display, screen), DisplayHeightMM(display, screen), ++ getenv("RCLOL_INPUT_TRACE") != NULL); ++} ++ ++static const char *rclolGrabResultName(int result) { ++ switch (result) { ++ case GrabSuccess: return "GrabSuccess"; ++ case AlreadyGrabbed: return "AlreadyGrabbed"; ++ case GrabInvalidTime: return "GrabInvalidTime"; ++ case GrabNotViewable: return "GrabNotViewable"; ++ case GrabFrozen: return "GrabFrozen"; ++ default: return "not-called"; ++ } ++} ++ ++static const char *rclolFocusModeName(int mode) { ++ static const char *names[] = {"Normal", "Grab", "Ungrab", "WhileGrabbed"}; ++ return mode >= 0 && mode < 4 ? names[mode] : "?"; ++} ++ ++static const char *rclolFocusDetailName(int detail) { ++ static const char *names[] = {"Ancestor", "Virtual", "Inferior", "Nonlinear", "NonlinearVirtual", ++ "Pointer", "PointerRoot", "DetailNone"}; ++ return detail >= 0 && detail < 8 ? names[detail] : "?"; ++} void NSColorSetCatalogColor(NSColorListName catalogName, NSColorName colorName, NSColor *color); - @implementation X11Display - - static int errorHandler(Display *display, XErrorEvent *errorEvent) { -@@ -75,6 +81,15 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, +@@ -75,6 +334,15 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, - init { if (self = [super init]) { @@ -2178,9 +2450,22 @@ index 5ae29bf4..358e606b 100644 _display = XOpenDisplay(NULL); if (_display == NULL) { -@@ -148,14 +163,16 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, +@@ -136,6 +404,10 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, + if (XRRQueryExtension(_display, &_rrEventBase, &errorBase)) { + XRRSelectInput(_display, DefaultRootWindow(_display), RRScreenChangeNotifyMask | RRCrtcChangeNotifyMask | RROutputChangeNotifyMask | RROutputPropertyNotifyMask); + } ++ /* Xwayland forwards pointer warps to the compositor only while the X cursor is hidden. */ ++ int xwaylandOpcode, xwaylandEvent, xwaylandError; ++ _isXwayland = XQueryExtension(_display, "XWAYLAND", &xwaylandOpcode, &xwaylandEvent, &xwaylandError); ++ rclolTraceStartup(_display); + + lastFocusedWindow = nil; + lastClickTimeStamp = 0.0; +@@ -147,15 +419,18 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, + - (void) dealloc { [_blankCursor release]; [_defaultCursor release]; ++ [_definedCursor release]; - XCloseIM(_xim); + if (_xim) @@ -2198,7 +2483,7 @@ index 5ae29bf4..358e606b 100644 if (_cfSocket != NULL) CFRelease(_cfSocket); #endif -@@ -172,6 +189,56 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, +@@ -172,6 +447,56 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, return _display; } @@ -2255,7 +2540,7 @@ index 5ae29bf4..358e606b 100644 - (void) _enableDetectableAutoRepeat { int major = XkbMajorVersion, minor = XkbMinorVersion; XkbStateRec state; -@@ -536,12 +603,52 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -536,12 +861,52 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return &layout->layout; } @@ -2310,7 +2595,7 @@ index 5ae29bf4..358e606b 100644 } - (NSPasteboard *) pasteboardWithName: (NSString *) name { -@@ -675,6 +782,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -675,6 +1040,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { blue: 0.60f alpha: 1.0f]; @@ -2326,16 +2611,63 @@ index 5ae29bf4..358e606b 100644 NSLog(@"missing color for %@", colorName); return [NSColor redColor]; } -@@ -754,4 +870,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -688,6 +1062,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + } + + - (void) hideCursor { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolHideChannel, "blankExisted=%d relative=%d/%d hidden=%d confined=%lu", ++ _blankCursor != nil, _relativeRequested, _relativeMouse.active, _cursorHidden, ++ (unsigned long) _confinedWindow); + if (!_blankCursor) + _blankCursor = [[X11Cursor alloc] initBlank]; + [self setCursor: _blankCursor]; +@@ -695,6 +1073,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + + - (void) unhideCursor { + NSCursor *current = [NSCursor currentCursor]; ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolUnhideChannel, "path=%s current=%p relative=%d/%d hidden=%d", ++ current ? "push-pop" : "default", current, _relativeRequested, _relativeMouse.active, ++ _cursorHidden); + if (current != nil) { + [current push]; + [current pop]; +@@ -747,12 +1129,28 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + + - (void) setCursor: (id) cursor { + CGWindow *window = [[NSApp keyWindow] platformWindow]; +- if (window != nil) { +- Window w = [(X11Window *) window windowHandle]; +- Cursor c = ((X11Cursor *) cursor).cursor; +- ++ Window w = window ? [(X11Window *) window windowHandle] : None; ++ Cursor c = ((X11Cursor *) cursor).cursor; ++ /* unhideCursor re-sets the current cursor and games call it every frame: only send actual changes. The ++ * defined cursor stays retained, so its X id cannot be freed and handed to a different cursor meanwhile. */ ++ BOOL unchanged = w != None && w == _definedCursorWindow && c == _definedCursor.cursor; ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolSetCursorChannel, "cursor=%p blank=%d default=%d xcursor=%lu keyWindow=%d xwin=%lu applied=%d relative=%d/%d", ++ cursor, cursor != nil && cursor == _blankCursor, cursor != nil && cursor == _defaultCursor, ++ (unsigned long) c, [NSApp keyWindow] != nil, (unsigned long) w, w != None && !unchanged, ++ _relativeRequested, _relativeMouse.active); ++ if (w != None && !unchanged) { XDefineCursor(_display, w, c); - XSync(_display, False); + /* Send cursor changes immediately without waiting for an X server + * reply on the input thread. X requests on this connection remain + * ordered; no server result is consumed here. */ + XFlush(_display); ++ [cursor retain]; ++ [_definedCursor release]; ++ _definedCursor = cursor; ++ _definedCursorWindow = w; ++ /* initBlank's empty-mask cursor counts as hidden for Xwayland's pointer warp emulation. */ ++ _cursorHidden = cursor != nil && cursor == _blankCursor && c != None; } } -@@ -921,15 +1040,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + +@@ -921,15 +1319,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } - (NSPoint) mouseLocation { @@ -2346,14 +2678,44 @@ index 5ae29bf4..358e606b 100644 - - XQueryPointer(_display, root, &root, &child, &root_x, &root_y, &win_x, - &win_y, &mask); -+ rclolQueryPointer(_display); ++ if (_relativeMouse.active) { ++ /* The cursor is disassociated: report the frozen (virtual) position, as macOS does. */ ++ NSPoint result = NSMakePoint(_relativeMouse.virtualX, ++ DisplayHeight(_display, DefaultScreen(_display)) - _relativeMouse.virtualY); ++ if (rclolTraceOn()) ++ rclolTraceMouseLocation(result, YES, YES, (int) _relativeMouse.virtualX, (int) _relativeMouse.virtualY, YES); ++ return result; ++ } ++ uint64_t rclolStampBefore = rclolPointerStamp; ++ BOOL rclolQueried = rclolQueryPointer(_display); int height = DisplayHeight(_display, DefaultScreen(_display)); - return NSMakePoint(root_x, height - root_y); -+ return NSMakePoint(rclolRootX, height - rclolRootY); ++ NSPoint result = NSMakePoint(rclolRootX, height - rclolRootY); ++ if (rclolTraceOn()) ++ rclolTraceMouseLocation(result, rclolQueried, rclolStampBefore != 0 && rclolPointerStamp == rclolStampBefore, ++ rclolRootX, rclolRootY, NO); ++ return result; } - (void) setWindow: (id) window forID: (XID) i { -@@ -962,20 +1075,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -948,6 +1353,16 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + // retaining the window causes it to keep popping back up endlessly even after dismissing it + if (lastFocusedWindow && [[lastFocusedWindow platformWindow] windowHandle] == i) + lastFocusedWindow = nil; ++ if (_definedCursorWindow == i) { ++ _definedCursorWindow = None; ++ [_definedCursor release]; ++ _definedCursor = nil; ++ } ++ /* The server released any grab on a destroyed window. */ ++ if (_confinedWindow == i) ++ _confinedWindow = None; ++ if (_relativeWindow == i) ++ [self suspendRelativeMouse]; + } + } + +@@ -962,20 +1377,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { { #ifndef DARLING [[NSRunLoop currentRunLoop] addInputSource: _inputSource forMode: mode]; @@ -2441,7 +2803,7 @@ index 5ae29bf4..358e606b 100644 } - (NSEventModifierFlags) modifierFlagsForState: (unsigned int) state { -@@ -1010,17 +1185,93 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1010,17 +1487,96 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return result; } @@ -2512,6 +2874,9 @@ index 5ae29bf4..358e606b 100644 + default: + break; + } ++ /* Expected warp echoes are among the events queued when the warp was synced; motion ages them itself. */ ++ if (_relativeMouse.active && ev->type != MotionNotify) ++ X11RelativeMouseTick(&_relativeMouse); switch (ev->type) { case KeyPress: @@ -2535,7 +2900,7 @@ index 5ae29bf4..358e606b 100644 NSEventModifierFlags modifierFlags = [self modifierFlagsForState: ev->xkey.state]; char buf[20] = {0}; KeySym keySym; -@@ -1038,6 +1289,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1038,6 +1594,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { buf[strLen] = 0; } @@ -2554,12 +2919,13 @@ index 5ae29bf4..358e606b 100644 id str = [[NSString alloc] initWithCString: buf encoding: NSUTF8StringEncoding]; NSPoint pos = -@@ -1077,6 +1340,29 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1077,6 +1645,30 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ButtonPress:; + if (ev->xbutton.button >= Button4 && ev->xbutton.button <= 7) { -+ pos = [window transformPoint:NSMakePoint(ev->xbutton.x, ev->xbutton.y)]; ++ pos = [self locationInWindow: window x: ev->xbutton.x y: ev->xbutton.y ++ rootX: ev->xbutton.x_root rootY: ev->xbutton.y_root]; + CGFloat wheelX = ev->xbutton.button == 6 ? 1.0 : + ev->xbutton.button == 7 ? -1.0 : 0.0; + CGFloat wheelY = ev->xbutton.button == Button4 ? 1.0 : @@ -2584,7 +2950,18 @@ index 5ae29bf4..358e606b 100644 NSTimeInterval now = [[NSDate date] timeIntervalSinceReferenceDate]; if (now - lastClickTimeStamp < [self doubleClickInterval]) { -@@ -1096,10 +1382,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1086,8 +1678,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + } + lastClickTimeStamp = now; + +- pos = [window +- transformPoint: NSMakePoint(ev->xbutton.x, ev->xbutton.y)]; ++ pos = [self locationInWindow: window x: ev->xbutton.x y: ev->xbutton.y ++ rootX: ev->xbutton.x_root rootY: ev->xbutton.y_root]; + + switch (ev->xbutton.button) { + case Button1: +@@ -1096,10 +1688,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseDown; break; @@ -2595,7 +2972,7 @@ index 5ae29bf4..358e606b 100644 default: type = NSOtherMouseDown; } -@@ -1113,14 +1395,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1113,14 +1701,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: 0.0]; @@ -2607,11 +2984,14 @@ index 5ae29bf4..358e606b 100644 break; case ButtonRelease: +- pos = [window +- transformPoint: NSMakePoint(ev->xbutton.x, ev->xbutton.y)]; +- + // One scroll event per notch, sent on press; releases are not clicks. + if (ev->xbutton.button >= Button4 && ev->xbutton.button <= 7) break; - pos = [window - transformPoint: NSMakePoint(ev->xbutton.x, ev->xbutton.y)]; - ++ pos = [self locationInWindow: window x: ev->xbutton.x y: ev->xbutton.y ++ rootX: ev->xbutton.x_root rootY: ev->xbutton.y_root]; ++ + if (ev->xbutton.button == Button2 || ev->xbutton.button >= 8) { + unsigned b = ev->xbutton.button == Button2 ? 2 : ev->xbutton.button - 5; + if (b < 32) _otherButtonsDown &= ~(1u << b); @@ -2619,7 +2999,7 @@ index 5ae29bf4..358e606b 100644 CGFloat deltaY = 0.0; switch (ev->xbutton.button) { -@@ -1130,14 +1420,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1130,14 +1726,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseUp; break; @@ -2634,7 +3014,7 @@ index 5ae29bf4..358e606b 100644 default: type = NSOtherMouseUp; } -@@ -1151,11 +1433,13 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1151,7 +1739,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: deltaY]; @@ -2645,11 +3025,70 @@ index 5ae29bf4..358e606b 100644 [self postEvent: event atStart: NO]; break; - case MotionNotify:; { - // NSLog(@"MotionNotify, x=%d, y=%d, xroot=%d, yroot=%d\n", - // ev->xmotion.x, ev->xmotion.y, ev->xmotion.x_root, - // ev->xmotion.y_root); -@@ -1195,14 +1479,29 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1164,45 +1754,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + pos = [window + transformPoint: NSMakePoint(ev->xmotion.x, ev->xmotion.y)]; + +- CGFloat deltaX = pos.x - lastMotionPos.x; +- CGFloat deltaY = pos.y - lastMotionPos.y; +- +- // NSLog(@"cursorGrabbed=%d, deltaX=%f, deltaY=%f\n", +- // _cursorGrabbed, deltaX, deltaY); +- if (_cursorGrabbed) { +- if (pos.x != lastMotionPos.x || pos.y != lastMotionPos.y) { +- CGPoint globalPos = [window transformPoint: lastMotionPos]; +- // NSLog(@"last known pos in window: x=%f, y=%f", +- // globalPos.x, globalPos.y); +- CGRect frame = [window transformFrame: [window frame]]; +- +- globalPos.x += frame.origin.x; +- globalPos.y += frame.origin.y; +- +- [self warpMouse: globalPos]; +- +- pos.x = lastMotionPos.x; +- pos.y = lastMotionPos.y; +- } else { +- // This is an event generated by [self warpMouse:] above ++ double deltaX, deltaY; ++ CGPoint rclolEventPos = pos, rclolLastPos = lastMotionPos; ++ const char *rclolDecision = "absolute"; ++ ++ if (_relativeMouse.active) { ++ /* The cursor is disassociated (CGAssociateMouseAndMouseCursorPosition(false)): physical motion ++ * only produces deltas and the event reports the frozen virtual cursor. Where warps are honoured ++ * the pointer is put back under it, so the visible cursor stays frozen as on macOS. */ ++ BOOL warpsHonoured = !_isXwayland || _cursorHidden; ++ rclolLastPos = NSMakePoint(_relativeMouse.physicalX, _relativeMouse.physicalY); ++ X11RelativeMouseMotion relative = X11RelativeMouseHandleMotion( ++ &_relativeMouse, ev->xmotion.x_root, ev->xmotion.y_root, warpsHonoured); ++ rclolEventPos = NSMakePoint(_relativeMouse.virtualX, _relativeMouse.virtualY); ++ if (relative.rewarp) { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolWarpRewarpChannel, "target=%d,%d from=%d,%d honoured=%d", ++ relative.warpX, relative.warpY, ev->xmotion.x_root, ev->xmotion.y_root, ++ warpsHonoured); ++ unsigned queued = [self warpPointerToRootX: relative.warpX y: relative.warpY]; ++ X11RelativeMouseExpectEcho(&_relativeMouse, relative.warpX, relative.warpY, queued); ++ } ++ if (!relative.post) { ++ if (rclolTraceOn()) ++ rclolTraceMotion(ev, YES, rclolEventPos, rclolLastPos, relative.deltaX, relative.deltaY, ++ relative.echo ? "echo" : "relative-still", -1); + break; + } ++ deltaX = relative.deltaX; ++ deltaY = relative.deltaY; ++ pos = [self locationInWindow: window x: ev->xmotion.x y: ev->xmotion.y ++ rootX: ev->xmotion.x_root rootY: ev->xmotion.y_root]; ++ rclolDecision = relative.rewarp ? "relative+rewarp" : "relative"; + } else { +- [window setLastKnownCursorPosition: pos]; ++ X11AbsoluteMouseDelta(lastMotionPos.x, lastMotionPos.y, pos.x, pos.y, &deltaX, &deltaY); + } ++ [window setLastKnownCursorPosition: pos]; + + type = NSMouseMoved; + [self pointerMoved]; if (ev->xmotion.state & Button1Mask) { @@ -2663,6 +3102,11 @@ index 5ae29bf4..358e606b 100644 - if (type == NSMouseMoved && ![delegate acceptsMouseMovedEvents]) - break; ++ if (rclolTraceOn()) ++ rclolTraceMotion(ev, _relativeMouse.active, rclolEventPos, rclolLastPos, deltaX, deltaY, rclolDecision, ++ type != NSMouseMoved || [delegate acceptsMouseMovedEvents] || ++ getenv("RCLOL_FORWARD_MOUSE_MOVED") != NULL); ++ + if (inputTrace) { + static NSTimeInterval lastMotionTrace; + NSTimeInterval now = [NSDate timeIntervalSinceReferenceDate]; @@ -2671,7 +3115,7 @@ index 5ae29bf4..358e606b 100644 + NSLog(@"LOLINPUT motion window=%lu mapped=%@ pos=%.0f,%.0f delta=%.0f,%.0f state=%u grabbed=%d forward=%d", + (unsigned long)ev->xany.window, [delegate class], + pos.x, pos.y, deltaX, deltaY, ev->xmotion.state, -+ _cursorGrabbed, type != NSMouseMoved || [delegate acceptsMouseMovedEvents] || ++ _relativeMouse.active, type != NSMouseMoved || [delegate acceptsMouseMovedEvents] || + getenv("RCLOL_FORWARD_MOUSE_MOVED") != NULL); + } + } @@ -2679,11 +3123,18 @@ index 5ae29bf4..358e606b 100644 - event = [NSEvent + if (type != NSMouseMoved || [delegate acceptsMouseMovedEvents] || + getenv("RCLOL_FORWARD_MOUSE_MOVED")) { ++ /* Relative motion is all in the deltas: keep those of the queued events coalesced below. */ ++ if (_relativeMouse.active) ++ for (NSEvent *queued in _eventQueue) ++ if ([queued type] == type) { ++ deltaX += [queued deltaX]; ++ deltaY += [queued deltaY]; ++ } + event = [NSEvent mouseEventWithType: type location: pos modifierFlags: -@@ -1211,25 +1510,40 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +1838,49 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -2706,18 +3157,19 @@ index 5ae29bf4..358e606b 100644 case EnterNotify: + [self updateMouseConfinementForWindow:window]; NSLog(@"EnterNotify"); - if (!_cursorGrabbed) +- if (!_cursorGrabbed) ++ if (!_relativeMouse.active) [window setLastKnownCursorPosition: [window transformPoint: NSMakePoint( ev->xcrossing.x, ev->xcrossing.y)]]; -+ if (!_cursorGrabbed && ev->xcrossing.detail != NotifyInferior) ++ if (!_relativeMouse.active && ev->xcrossing.detail != NotifyInferior) + [delegate platformWindowSetCursorEvent: window]; break; case LeaveNotify: NSLog(@"LeaveNotify"); -+ if (!_cursorGrabbed && ev->xcrossing.detail != NotifyInferior) { ++ if (!_relativeMouse.active && ev->xcrossing.detail != NotifyInferior) { + [window setLastKnownCursorPosition: + [window transformPoint: NSMakePoint(ev->xcrossing.x, ev->xcrossing.y)]]; + [delegate platformWindowSetCursorEvent: window]; @@ -2725,7 +3177,16 @@ index 5ae29bf4..358e606b 100644 break; case FocusIn: -@@ -1243,28 +1557,42 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolFocusChannel, "FocusIn window=%lu known=%d mode=%s detail=%s relative=%d/%d confined=%lu appActive=%d sheet=%d", ++ (unsigned long) ev->xany.window, window != nil, rclolFocusModeName(ev->xfocus.mode), ++ rclolFocusDetailName(ev->xfocus.detail), _relativeRequested, _relativeMouse.active, ++ (unsigned long) _confinedWindow, ++ [NSApp isActive], [delegate attachedSheet] != nil); + NSLog(@"FocusIn"); + if ([delegate attachedSheet]) { + [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; +@@ -1243,28 +1891,55 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -2736,18 +3197,33 @@ index 5ae29bf4..358e606b 100644 lastFocusedWindow = delegate; if (window != nil) XSetICFocus(window->_xic); ++ /* Relative mode released its grab on focus loss; take it again. */ ++ if (_relativeRequested && !_relativeMouse.active) ++ [self beginRelativeMouseWithWindow: NULL pick: NULL]; + [self updateMouseConfinementForWindow:window]; ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolFocusChannel, "FocusIn done window=%lu relative=%d/%d confined=%lu appActive=%d", ++ (unsigned long) ev->xany.window, _relativeRequested, _relativeMouse.active, ++ (unsigned long) _confinedWindow, [NSApp isActive]); break; case FocusOut: ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolFocusChannel, "FocusOut window=%lu known=%d mode=%s detail=%s relative=%d/%d confined=%lu appActive=%d", ++ (unsigned long) ev->xany.window, window != nil, rclolFocusModeName(ev->xfocus.mode), ++ rclolFocusDetailName(ev->xfocus.detail), _relativeRequested, _relativeMouse.active, ++ (unsigned long) _confinedWindow, ++ [NSApp isActive]); NSLog(@"FocusOut"); + [self reconcileModifierKeys:NULL window:delegate]; + _lastKeySym = 0; [delegate platformWindowDeactivated: window checkForAppDeactivation: NO]; lastFocusedWindow = nil; - if (_cursorGrabbed) - [self grabMouse: NO]; +- if (_cursorGrabbed) +- [self grabMouse: NO]; ++ /* Give the pointer back to other clients; relative mode stays requested and resumes on FocusIn. */ ++ [self suspendRelativeMouse]; + if (_confinedWindow) { + XUngrabPointer(_display, CurrentTime); + _confinedWindow = None; @@ -2768,7 +3244,7 @@ index 5ae29bf4..358e606b 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +1629,20 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +1976,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -2782,6 +3258,9 @@ index 5ae29bf4..358e606b 100644 + if(window && ev->xany.window == [window windowHandle] && + [delegate respondsToSelector: @selector(_setVisible:)]) { + BOOL visible = ev->type == MapNotify; ++ /* The server released a grab whose window became unviewable. */ ++ if (!visible && ev->xany.window == _relativeWindow) ++ [self suspendRelativeMouse]; + [self updateMouseConfinementForWindow:window]; + if([delegate isVisible] != visible) { + [delegate _setVisible: visible]; @@ -2793,7 +3272,7 @@ index 5ae29bf4..358e606b 100644 break; case MapRequest: -@@ -1321,6 +1658,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1321,6 +2008,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { [delegate platformWindow: window frameChanged: [window frame] didSize: YES]; @@ -2801,7 +3280,7 @@ index 5ae29bf4..358e606b 100644 break; case ConfigureRequest: -@@ -1344,6 +1682,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2032,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -2810,15 +3289,71 @@ index 5ae29bf4..358e606b 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1483,6 +1823,64 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, - XSync(_display, False); +@@ -1433,6 +2123,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + NSLog(@"Request code: %d:%d", errorEvent->request_code, + errorEvent->minor_code); + NSLog(@"Error code: %d", errorEvent->error_code); ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolErrorChannel, "request=%d:%d error=%d resource=%lu serial=%lu", ++ errorEvent->request_code, errorEvent->minor_code, errorEvent->error_code, ++ (unsigned long) errorEvent->resourceid, (unsigned long) errorEvent->serial); + return 0; } -+/* Confinement preserves absolute motion and edge panning. Relative-mode -+ * capture remains separate: it deliberately warps to the center. */ +@@ -1476,54 +2170,214 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, + return frame; + } + ++/* Moves the X pointer and waits for the server, so the warp's MotionNotify (if any) is already queued on return. ++ * Returns the number of queued events, the window in which that echo must arrive. */ ++- (unsigned) warpPointerToRootX: (int) x y: (int) y { ++ XWarpPointer(_display, None, DefaultRootWindow(_display), 0, 0, 0, 0, x, y); ++ XSync(_display, False); ++ rclolPointerStamp = 0; ++ return (unsigned) XEventsQueued(_display, QueuedAlready); ++} ++ ++/* Window-local Cocoa location of a pointer event at (x, y) in window, root (rootX, rootY). While relative this is ++ * the virtual cursor: macOS reports the frozen cursor position, not where the physical pointer went. */ ++- (NSPoint) locationInWindow: (X11Window *) window x: (int) x y: (int) y rootX: (int) rootX rootY: (int) rootY { ++ if (_relativeMouse.active) ++ return [window transformPoint: NSMakePoint(_relativeMouse.virtualX - (rootX - x), ++ _relativeMouse.virtualY - (rootY - y))]; ++ return [window transformPoint: NSMakePoint(x, y)]; ++} ++ + - (void) warpMouse: (NSPoint) position { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolWarpAppChannel, "target=%.1f,%.1f relative=%d/%d virtual=%.1f,%.1f physical=%d,%d " ++ "honoured=%d confined=%lu cachedRoot=%d,%d appActive=%d", ++ position.x, position.y, _relativeRequested, _relativeMouse.active, _relativeMouse.virtualX, ++ _relativeMouse.virtualY, _relativeMouse.physicalX, _relativeMouse.physicalY, ++ !_isXwayland || _cursorHidden, (unsigned long) _confinedWindow, rclolRootX, rclolRootY, ++ [NSApp isActive]); ++ 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]); ++ return; ++ } + NSLog(@"Warp to: x=%f, y=%f\n", position.x, position.y); +- XWarpPointer(_display, None, DefaultRootWindow(_display), 0, 0, 0, 0, +- position.x, position.y); +- XSync(_display, False); ++ [self warpPointerToRootX: x y: y]; + } + ++/* Confinement preserves absolute motion and edge panning. Relative mode holds ++ * its own grab and is left alone while requested. */ +- (void) updateMouseConfinementForWindow:(X11Window *)window { -+ if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT check window=%p delegate=%p focused=%p relative=%d\n",window,[window delegate],lastFocusedWindow,_cursorGrabbed); -+ if (!window || _cursorGrabbed) return; ++ if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT check window=%p delegate=%p focused=%p relative=%d\n",window,[window delegate],lastFocusedWindow,_relativeRequested); ++ if (!window || _relativeRequested) { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolConfineChannel, "skip: %s confined=%lu", !window ? "window=nil" : "relative mode requested", ++ (unsigned long) _confinedWindow); ++ return; ++ } + Window win = [window windowHandle]; + Window focus; int revert; + XGetInputFocus(_display, &focus, &revert); @@ -2830,9 +3365,19 @@ index 5ae29bf4..358e606b 100644 + ancestor=parent; + } + if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT focus=%lu target=%lu ancestor=%lu\n",focus,win,ancestor); -+ if (ancestor != win) return; ++ if (ancestor != win) { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolConfineChannel, "skip: not focused window=%lu focus=%lu ancestor=%lu confined=%lu", ++ (unsigned long) win, (unsigned long) focus, (unsigned long) ancestor, ++ (unsigned long) _confinedWindow); ++ return; ++ } + XWindowAttributes attrs; -+ if (!XGetWindowAttributes(_display, win, &attrs)) return; ++ if (!XGetWindowAttributes(_display, win, &attrs)) { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolConfineChannel, "skip: XGetWindowAttributes failed window=%lu", (unsigned long) win); ++ return; ++ } + BOOL fullscreen = NO; + Atom actual; int format; unsigned long count, remaining; unsigned char *data = NULL; + Atom state = XInternAtom(_display, "_NET_WM_STATE", False); @@ -2842,6 +3387,7 @@ index 5ae29bf4..358e606b 100644 + for (unsigned long i=0; i%d requested=%d confined %lu->%lu appActive=%d virtual=%.1f,%.1f xwayland=%d " ++ "hidden=%d cachedRoot=%d,%d", ++ rclolGrabGeneration, doGrab, rclolPick, rclolWindow, ++ rclolWindow ? object_getClassName(rclolWindow) : "-", ++ (unsigned long) (rclolWindow ? [(X11Window *) [rclolWindow platformWindow] windowHandle] : None), ++ rclolResult, doGrab ? rclolGrabResultName(rclolResult) : "ungrab", rclolRelativeBefore, ++ _relativeMouse.active, _relativeRequested, (unsigned long) rclolConfinedBefore, ++ (unsigned long) _confinedWindow, [NSApp isActive], _relativeMouse.virtualX, ++ _relativeMouse.virtualY, _isXwayland, _cursorHidden, rclolRootX, rclolRootY); } + + @end diff --git a/AppKit/X11.backend/X11Pasteboard.h b/AppKit/X11.backend/X11Pasteboard.h index 411903cc..4b196afd 100644 --- a/AppKit/X11.backend/X11Pasteboard.h @@ -8671,3 +9327,156 @@ index 00000000..a51a9241 + va_end(copy); +} +#endif +diff --git a/AppKit/X11.backend/X11RelativeMouse.h b/AppKit/X11.backend/X11RelativeMouse.h +new file mode 100644 +index 00000000..7fc39cc4 +--- /dev/null ++++ b/AppKit/X11.backend/X11RelativeMouse.h +@@ -0,0 +1,147 @@ ++/* 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 ++ * only produces deltas, events report the frozen cursor position, and CGWarpMouseCursorPosition moves the cursor ++ * without producing a delta. Here the frozen cursor is the "virtual" position (X root coordinates). Core X motion ++ * still moves the real pointer, so deltas are measured against the last physical position, and where the server ++ * honours warps the pointer is warped back to the virtual position after each motion. Every warp (ours or the ++ * application's) is followed by a MotionNotify at its target; those echoes are recognised by position and ++ * dropped. Warps are synchronous (XSync), so an echo can only be among the events already queued at that moment; ++ * an expected echo expires once that many events have been handled. ++ * ++ * 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. ++ * ++ * 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 ++#define X11_RELATIVE_MOUSE_H ++ ++#include ++ ++#define X11_RELATIVE_MOUSE_MAX_ECHOES 8 ++ ++typedef struct { ++ int x, y; ++ unsigned remaining; /* X events (of any type) still to be handled before this echo is given up */ ++} X11RelativeMouseEcho; ++ ++typedef struct { ++ bool active; /* relative motion handling is on (the pointer grab is held) */ ++ double virtualX, virtualY; /* the frozen macOS cursor, X root coordinates */ ++ int physicalX, physicalY; /* last physical pointer position, X root coordinates */ ++ unsigned echoCount; ++ X11RelativeMouseEcho echoes[X11_RELATIVE_MOUSE_MAX_ECHOES]; /* oldest first */ ++} X11RelativeMouse; ++ ++typedef struct { ++ bool post; /* deliver a mouse moved/dragged event */ ++ bool echo; /* the event was the echo of a warp */ ++ double deltaX, deltaY; /* NSEvent convention: positive deltaY is downwards */ ++ bool rewarp; /* warp the pointer back to (warpX, warpY) */ ++ int warpX, warpY; ++} X11RelativeMouseMotion; ++ ++/* X11 warps take integer coordinates; conversions truncate like the original implicit double-to-int argument. */ ++static inline int X11RelativeMouseWarpCoordinate(double value) { ++ return (int) value; ++} ++ ++static inline void X11RelativeMouseBegin(X11RelativeMouse *mouse, int rootX, int rootY) { ++ mouse->active = true; ++ mouse->virtualX = rootX; ++ mouse->virtualY = rootY; ++ mouse->physicalX = rootX; ++ mouse->physicalY = rootY; ++ mouse->echoCount = 0; ++} ++ ++static inline void X11RelativeMouseEnd(X11RelativeMouse *mouse) { ++ mouse->active = false; ++ mouse->echoCount = 0; ++} ++ ++/* Record a warp to (x, y) that was just sent and synced. queuedEvents is the number of X events queued after the ++ * sync; zero means the warp produced no event (the pointer was already there). */ ++static inline void X11RelativeMouseExpectEcho(X11RelativeMouse *mouse, int x, int y, unsigned queuedEvents) { ++ if (!queuedEvents) return; ++ if (mouse->echoCount) { ++ X11RelativeMouseEcho *last = &mouse->echoes[mouse->echoCount - 1]; ++ if (last->x == x && last->y == y) { ++ if (last->remaining < queuedEvents) last->remaining = queuedEvents; ++ return; ++ } ++ } ++ if (mouse->echoCount == X11_RELATIVE_MOUSE_MAX_ECHOES) { ++ for (unsigned i = 1; i < mouse->echoCount; ++i) ++ mouse->echoes[i - 1] = mouse->echoes[i]; ++ mouse->echoCount--; ++ } ++ 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) { ++ mouse->virtualX = x; ++ mouse->virtualY = y; ++ X11RelativeMouseExpectEcho(mouse, X11RelativeMouseWarpCoordinate(x), X11RelativeMouseWarpCoordinate(y), ++ queuedEvents); ++} ++ ++/* One X event has been handled: age the expected echoes. */ ++static inline void X11RelativeMouseTick(X11RelativeMouse *mouse) { ++ unsigned kept = 0; ++ for (unsigned i = 0; i < mouse->echoCount; ++i) ++ if (mouse->echoes[i].remaining > 1) { ++ mouse->echoes[kept] = mouse->echoes[i]; ++ mouse->echoes[kept++].remaining--; ++ } ++ mouse->echoCount = kept; ++} ++ ++/* A MotionNotify at root position (rootX, rootY) while relative. Ages the expected echoes. */ ++static inline X11RelativeMouseMotion X11RelativeMouseHandleMotion(X11RelativeMouse *mouse, int rootX, int rootY, ++ bool warpsHonoured) ++{ ++ X11RelativeMouseMotion result = {false, false, 0.0, 0.0, false, 0, 0}; ++ for (unsigned i = 0; i < mouse->echoCount; ++i) ++ if (mouse->echoes[i].x == rootX && mouse->echoes[i].y == rootY) { ++ /* Warps are echoed in order; earlier expected echoes that did not arrive never will. */ ++ for (unsigned j = i + 1; j < mouse->echoCount; ++j) ++ mouse->echoes[j - i - 1] = mouse->echoes[j]; ++ mouse->echoCount -= i + 1; ++ if (warpsHonoured) { ++ mouse->physicalX = rootX; ++ mouse->physicalY = rootY; ++ } ++ result.echo = true; ++ X11RelativeMouseTick(mouse); ++ return result; ++ } ++ result.deltaX = rootX - mouse->physicalX; ++ result.deltaY = rootY - mouse->physicalY; ++ mouse->physicalX = rootX; ++ mouse->physicalY = rootY; ++ result.post = result.deltaX != 0.0 || result.deltaY != 0.0; ++ int warpX = X11RelativeMouseWarpCoordinate(mouse->virtualX); ++ int warpY = X11RelativeMouseWarpCoordinate(mouse->virtualY); ++ if (warpsHonoured && (rootX != warpX || rootY != warpY)) { ++ result.rewarp = true; ++ result.warpX = warpX; ++ result.warpY = warpY; ++ } ++ X11RelativeMouseTick(mouse); ++ return result; ++} ++ ++/* NSEvent deltas for absolute (non-relative) motion between two positions in Cocoa window coordinates, which ++ * are bottom-up while NSEvent's deltaY is positive downwards. */ ++static inline void X11AbsoluteMouseDelta(double lastX, double lastY, double x, double y, double *deltaX, ++ double *deltaY) ++{ ++ *deltaX = x - lastX; ++ *deltaY = lastY - y; ++} ++ ++#endif diff --git a/tools/test-x11-relative-mouse.c b/tools/test-x11-relative-mouse.c new file mode 100644 index 0000000..489701c --- /dev/null +++ b/tools/test-x11-relative-mouse.c @@ -0,0 +1,139 @@ +#include "../build/darling-src/src/external/cocotron/AppKit/X11.backend/X11RelativeMouse.h" +#include +#include + +/* Host model of the X11 backend's relative mouse mode (CGAssociateMouseAndMouseCursorPosition(false)). + * A warp's echo is simulated as the MotionNotify the server queues at the target; `queued` is the number of + * X events queued after the backend's XSync. */ +static X11RelativeMouseMotion motion(X11RelativeMouse *m, int x, int y, bool honoured) { + return X11RelativeMouseHandleMotion(m, x, y, honoured); +} + +static void xorgHonouredWarps(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 500, 400); + /* Physical motion: delta reported, the cursor stays frozen, the pointer is warped back. */ + X11RelativeMouseMotion r = motion(&m, 510, 395, true); + assert(r.post && !r.echo && r.deltaX == 10 && r.deltaY == -5); + assert(r.rewarp && r.warpX == 500 && r.warpY == 400); + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 1); + /* The rewarp's echo is consumed and is not a delta back to the centre. */ + r = motion(&m, 500, 400, true); + assert(!r.post && r.echo && !r.rewarp && m.echoCount == 0); + assert(m.physicalX == 500 && m.physicalY == 400); + r = motion(&m, 503, 404, true); + assert(r.post && r.deltaX == 3 && r.deltaY == 4 && r.rewarp); + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 1); + + /* A real motion queued before the echo still measures against the last physical position. */ + X11RelativeMouseBegin(&m, 500, 400); + r = motion(&m, 520, 400, true); + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 2); /* the next motion was already queued */ + r = motion(&m, 530, 400, true); + assert(r.post && r.deltaX == 10 && r.deltaY == 0 && r.rewarp); + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 1); /* same target: merged, not a second echo */ + assert(m.echoCount == 1); + r = motion(&m, 500, 400, true); + assert(r.echo && m.echoCount == 0); + r = motion(&m, 499, 400, true); + assert(r.post && r.deltaX == -1); + + /* The application warps from the event it was given while our rewarp's echo is still queued: + * both echoes are dropped in order and neither cancels the motion. */ + X11RelativeMouseBegin(&m, 500, 400); + 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 */ + assert(m.virtualX == 524.0 && m.physicalX == 512); + r = motion(&m, 500, 400, true); + assert(r.echo && m.physicalX == 500); + r = motion(&m, 524, 400, true); + assert(r.echo && m.physicalX == 524 && m.echoCount == 0); + r = motion(&m, 530, 401, true); + assert(r.post && r.deltaX == 6 && r.deltaY == 1 && r.rewarp && r.warpX == 524 && r.warpY == 400); + puts("PASS: Xorg honoured warps rewarp to the frozen cursor and drop their echoes"); +} + +static void xwaylandVisibleCursor(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 800, 450); + /* Warps are not honoured: no rewarp, the pointer moves freely, deltas follow it. */ + X11RelativeMouseMotion r = motion(&m, 810, 450, false); + assert(r.post && r.deltaX == 10 && !r.rewarp); + X11RelativeMouseWarp(&m, 820.0, 450.0, 1); + /* 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); + /* An expected echo that never arrives expires after the queued events and cannot eat a later motion. */ + X11RelativeMouseWarp(&m, 700.0, 400.0, 1); + X11RelativeMouseTick(&m); /* some other X event was the one queued */ + assert(m.echoCount == 0); + r = motion(&m, 700, 400, false); + assert(r.post && !r.echo && r.deltaX == -112 && r.deltaY == -53); + puts("PASS: Xwayland visible cursor keeps the physical position across unhonoured warp echoes"); +} + +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 */ + 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. */ + double cocoaX = m.virtualX, cocoaY = 1080 - m.virtualY; + assert(cocoaX == 150.75 && cocoaY == 959.5); + X11RelativeMouseMotion r = motion(&m, 101, 100, true); + assert(r.post && r.deltaX == 1 && r.rewarp && r.warpX == 150 && r.warpY == 120); + puts("PASS: application warps while relative move the virtual cursor that mouseLocation returns"); +} + +static void deltaSign(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 0, 0); + X11RelativeMouseMotion r = motion(&m, 0, 7, false); + assert(r.deltaY == 7); /* X root y grows downwards: positive deltaY means down, as on macOS */ + double dx, dy; + /* Absolute motion in Cocoa window coordinates (bottom-up): moving down lowers y. */ + X11AbsoluteMouseDelta(10, 300, 14, 290, &dx, &dy); + assert(dx == 4 && dy == 10); + puts("PASS: deltaY is positive downwards for relative and absolute motion"); +} + +static void ungrabRestores(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 10, 10); + X11RelativeMouseWarp(&m, 40, 40, 3); + X11RelativeMouseEnd(&m); + assert(!m.active && m.echoCount == 0); + X11RelativeMouseBegin(&m, 60, 70); + assert(m.active && m.virtualX == 60 && m.virtualY == 70 && m.physicalX == 60 && m.physicalY == 70); + X11RelativeMouseMotion r = motion(&m, 40, 40, true); + assert(r.post && !r.echo && r.deltaX == -20 && r.deltaY == -30); + puts("PASS: ungrab clears expected echoes and a new grab starts from the current pointer"); +} + +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); + 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); + puts("PASS: expected echoes are bounded and consumed in order"); +} + +int main(void) { + xorgHonouredWarps(); + xwaylandVisibleCursor(); + gameWarpUpdatesVirtual(); + deltaSign(); + ungrabRestores(); + echoQueueBound(); + return 0; +}