diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index ab134ec..a9f4428 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -11,3 +11,5 @@ jobs: with: python-version: '3.12' - run: python scripts/validate.py + - run: python tools/riot-input-offline-check.py + - run: python tools/riot-runtime-cleanup-offline-check.py diff --git a/docs/SETUP.md b/docs/SETUP.md index 432623f..1b5a89a 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -18,6 +18,8 @@ The build uses Unix Makefiles because the incremental runtime helpers read CMake The supported build is x86-64 only (`TARGET_i386=OFF`). The patched OpenDirectory uses Objective-C ivars that the 32-bit fragile ABI cannot compile. Keep the empty `CMAKE_BUILD_TYPE` used by `scripts/build.sh`: selecting `Release` introduces `NDEBUG` while objc4 defines `OBJC_IS_DEBUG_BUILD=1`, causing a debug-macro mismatch. The base patch requests the macOS 10.15 Objective-C runtime code generation to avoid undefined `__NSArray0__struct` / `__NSDictionary0__struct` symbols with newer Clang. Indium handles both the LLVM 22 and LLVM 23 branch opcode names. +`scripts/build.sh` compiles the whole tree with `-O3 -march=native -mtune=generic`, so the binaries only run on CPUs with the build machine's instruction set. Keep `-mtune=generic`: clang 23 tuning for AMD Zen (`-mtune=native` or `-mtune=znver*`) miscompiles Darwin thread-local variables, which crashes launchd at boot (see [troubleshooting](TROUBLESHOOTING.md#launchd-crashes-at-boot-after-changing-compiler-flags)). Two kernel files keep `-O0` because they depend on the unoptimized stack layout: `bsdthread_register.c` and `sigexc.c`. + Linux 5.14 or newer is required for `MADV_POPULATE_WRITE`, used when Darwin's allocator reuses lazily reclaimable pages. The memory installer checks this requirement; do not bypass it. The runtime installer requires native `pkg-config` entries for `x11`, `x11-xcb`, `xcb-shm` and `xext`, plus the base Darling SDK. @@ -69,7 +71,7 @@ sh build/runtime/tools/build-riot-libs-runtime.sh python3 build/runtime/tools/install-riot-libs-runtime.py ``` -The builders recompile libsystem_c (618 of its 641 objects), libc++ and libc++abi (38 and 18) and the libsystem_platform string primitives at `-O2`, each object with its own configured defines, includes and per-file options, and relink with the configured link line. `-fno-strict-aliasing` is added for this old BSD C and no `-DNDEBUG`, `-ffast-math` or LTO is. Files whose correctness depends on frame layout, on running in a forked child, or on the floating-point environment keep their configured flags: the fork paths and the stack guard handler in libc and the frame-pointer backtrace walker. libsystem_m is not rebuilt: its sources are built at `-O0` upstream, and an `-O2` rebuild made the Riot client UI render a black window. The libkqueue objects libsystem_c also links belong to the kernel library's target and are left alone. Each builder stops while a League game process is running, because compiling would skew the FPS the user measures. +The builders recompile libsystem_c (618 of its 641 objects), libc++ and libc++abi (38 and 18) and the libsystem_platform string primitives at `-O3 -march=native -mtune=generic`, each object with its own configured defines, includes and per-file options, and relink with the configured link line. `-fno-strict-aliasing` is added for this old BSD C and no `-DNDEBUG`, `-ffast-math` or LTO is. Files whose correctness depends on frame layout, on running in a forked child, or on the floating-point environment keep their configured flags: the fork paths and the stack guard handler in libc and the frame-pointer backtrace walker. libsystem_m is not rebuilt: its sources are built at `-O0` upstream, and an `-O2` rebuild made the Riot client UI render a black window. The libkqueue objects libsystem_c also links belong to the kernel library's target and are left alone. Each builder stops while a League game process is running, because compiling would skew the FPS the user measures. `scripts/install-runtime.sh` builds and installs these overlays, then runs `tools/check-darwin-tlv-calls.py` on everything in `RuntimeLibraries` and `RuntimeFrameworks` and fails if any binary has the thread-local miscompile. The installer writes the four files into `~/.darling/Users//RuntimeLibraries`, the directory the launcher puts in `DYLD_LIBRARY_PATH`; dyld searches those directories by leaf name before the install path, which is why an overlay there replaces the prefix and SDK copies of `libsystem_c.dylib`, `libc++.1.dylib`, `libc++abi.dylib` and `libsystem_platform.dylib` without touching either. Every replaced file is backed up first (`build/libs-runtime-backups`), the staged file is verified against the built artifact, and the rename is atomic. The installer never stops a process, so a running guest keeps the library it already loaded; exit the game and both clients and restart the namespace for activation. One command restores the previous state: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 75b1c4d..b450146 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -156,6 +156,20 @@ A separate multi-window path stopped the active async presenter when another dif - **Prebuilt Swift file contains Git LFS pointer text:** install Git LFS and fetch the original upstream assets; pointer text is not a usable library. - **Submodule fetch stops with no output right after `src/external/sqlite`:** the next submodule is `src/external/swift`, whose pinned `.lfsconfig` points at the retired `git-lfs.darlinghq.org`. The checkout is blocked on that host, not on sqlite. `prepare.py` redirects that submodule's LFS requests to GitHub. To finish a fetch that was interrupted before any patches were applied, run `git -C build/darling-src -c lfs.url=https://github.com/darlinghq/darling-swift.git/info/lfs submodule update --init --recursive --progress`, then re-run `./scripts/build.sh`. Do not run that submodule update once `prepare.py` has applied patches: it resets every submodule working tree to the pinned revision and discards the patches. +## launchd crashes at boot after changing compiler flags + +`darling shell` fails with `Error connecting to shellspawn` and `/tmp/lolsig.log` shows `LOLSIGFAIL pid=1 sig=11 code=1 addr=0x0` in `memmove`, called from `snprintf` in libkqueue's `epoll_event_dump`. The binaries were tuned for AMD Zen: clang 23 with `-mtune=znver*` (also selected by `-march=native` or `-mtune=native` on Zen) loads a thread-local variable's descriptor into a register other than `%rdi`, but dyld's `tlv_get_addr` reads `%rdi`, so every `__thread` and `thread_local` access returns NULL. Rebuild with `-mtune=generic` (the default in `scripts/build.sh` and the overlay builders), and check any binary with: + +```sh +python3 tools/check-darwin-tlv-calls.py ... +``` + +It prints each thread-local access that calls its thunk without the descriptor in `%rdi` and exits 1. A plain `darling shell` boot does not load the `RuntimeLibraries` overlays; to boot the way the launcher does, run `env DYLD_LIBRARY_PATH=/Users//RuntimeLibraries DYLD_FRAMEWORK_PATH=/Users//RuntimeFrameworks darling shell /usr/bin/true`. + +## Base install refuses to continue while Darling is not running + +`sudo cmake --install` prints `Darling currently running for , shutting it down...` several times and then `Darling launchd PID … remains alive after shutdown`. A guest launchd outlived its darlingserver, so `darling shutdown` sees no container to stop. `python3 build/runtime/tools/ensure-darling-runtime.py` stops such a launchd (your own, only when no darlingserver runs); `scripts/run.sh` and `scripts/install-runtime.sh` run it too. + ## Lower fight FPS than Windows The game code itself runs natively under Darling: the x86-64 slice of the universal binary executes unchanged, picks its instruction-set paths with `cpuid` on the real CPU, and sizes its worker pool from the real logical CPU count. What differs from Windows is the path around it. Every Metal call goes through Objective-C, the Metal wrapper, Indium and Vulkan instead of a native Direct3D driver, and kernel services go through the emulation and darlingserver. In an ARAM fight the game's own code now takes most of a frame; see [status](STATUS.md#game-fps-2026-10-1314) for measurements. @@ -232,7 +246,7 @@ 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. `python3 tools/riot-input-offline-check.py` checks the shipped patch on the host without fetching Darling. Add `--xvfb /path/to/Xvfb` to exercise real Xorg protocol on an isolated virtual display; it never uses your desktop display. For Xorg/XLibre (issue #17), relative capture now starts from a fresh `XQueryPointer` snapshot and ignores older queued motion. Warp echoes match both their X request serial and position, so a real motion crossing the anchor cannot be mistaken for an echo. Application warp targets are clipped to the grabbed window in root coordinates, including on a secondary monitor; events, `mouseLocation`, and rewarps share that target. Queued motion before a pending warp does not generate another rewarp. A failed pointer query leaves relative capture suspended, and focus loss releases the grab until the window receives keyboard focus again. Host regression tests cover these paths, but League on the tested Hyprland setup never enters this mode; in-game confirmation on XLibre is still required. 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. @@ -247,3 +261,11 @@ render { ``` `hyprctl keyword render:direct_scanout 0` applies it until Hyprland restarts. + +### Black layer over a running Metal game + +The X11 Chromium presenter searches shared Core Animation frame slots by surface size. Native League game windows now bypass that presenter, so a same-size client frame cannot create a presentation child over the game's Metal drawable. This also disables the remote-frame polling timer for the game while preserving Chromium client presentation. + +`tools/riot-remote-frame-ownership-check.m` exercises the cached-frame path without creating a display or window. `tools/build-riot-x11-runtime.py` builds this probe. For an unidentified overlay, `python3 tools/trace-riot-windows.py --output build/riot-window-trace.jsonl` passively records Riot/League windows, including unmanaged windows and child stacking, for one hour. It requires the host `python-xlib` package and does not send input. + +If a black fullscreen game has only its normal Metal child and becomes visible after removing and restoring fullscreen, inspect the initial fullscreen transition. Automatic fullscreen now requires the window to cover an active monitor, so temporary 1x1 startup windows and ordinary windowed sizes are not promoted. Explicit Cocoa fullscreen requests are preserved. `riot-fullscreen-geometry-check` (built by the X11 builder) checks the policy against active XRandR monitor geometry using only read requests; the next game launch is still required to validate the startup sequence. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 28e958b..6c39e87 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2242,10 +2242,10 @@ 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..c683f96a 100644 +index 18189404..12cde429 100644 --- a/AppKit/X11.backend/X11Display.h +++ b/AppKit/X11.backend/X11Display.h -@@ -10,13 +10,14 @@ +@@ -10,13 +10,19 @@ #import #import #import @@ -2257,11 +2257,16 @@ index 18189404..c683f96a 100644 #endif -@class X11Cursor; -+@class X11Cursor, X11Window; ++@class X11Cursor, X11Window, NSWindow; ++ ++/* darling-lol: League's in-game window (not the Riot or League client), recognized by process ++ * name or by its "League of Legends (TM) Client" title. window may be None before creation. */ ++BOOL X11IsLeagueGameWindow(Display *display, Window window, NSWindow *delegate); ++BOOL X11RootRectCoversMonitor(Display *display, NSRect rect); @interface X11Display : NSDisplay { Display *_display; -@@ -36,19 +37,43 @@ +@@ -36,19 +42,43 @@ NSTimeInterval lastClickTimeStamp; int clickCount; X11Cursor *_blankCursor, *_defaultCursor; @@ -2307,7 +2312,7 @@ index 18189404..c683f96a 100644 +- (NSRect) frameForRootRect: (NSRect) rect; @end diff --git a/AppKit/X11.backend/X11Display.m b/AppKit/X11.backend/X11Display.m -index 5ae29bf4..25056329 100644 +index 5ae29bf4..cc166d41 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -2609,8 +2614,11 @@ index 5ae29bf4..25056329 100644 lastFocusedWindow = nil; lastClickTimeStamp = 0.0; -@@ -147,15 +419,18 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, +@@ -145,17 +417,21 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, + } + - (void) dealloc { ++ X11RelativeMouseEnd(&_relativeMouse); [_blankCursor release]; [_defaultCursor release]; + [_definedCursor release]; @@ -2631,7 +2639,7 @@ index 5ae29bf4..25056329 100644 if (_cfSocket != NULL) CFRelease(_cfSocket); #endif -@@ -172,6 +447,56 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, +@@ -172,6 +448,56 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, return _display; } @@ -2688,7 +2696,7 @@ index 5ae29bf4..25056329 100644 - (void) _enableDetectableAutoRepeat { int major = XkbMajorVersion, minor = XkbMinorVersion; XkbStateRec state; -@@ -190,6 +515,35 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, +@@ -190,6 +516,35 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, [_lastScreens release]; _lastScreens = nil; } @@ -2724,7 +2732,7 @@ index 5ae29bf4..25056329 100644 } - (NSArray *) screens { -@@ -201,25 +555,60 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, +@@ -201,25 +556,60 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, int eventBase, errorBase; @@ -2795,7 +2803,7 @@ index 5ae29bf4..25056329 100644 nsscreen = [[[NSScreen alloc] initWithFrame: frame -@@ -228,18 +617,17 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, +@@ -228,18 +618,17 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, Atom actualType; unsigned long nitems, bytesAfter; int actualFormat; @@ -2817,7 +2825,7 @@ index 5ae29bf4..25056329 100644 } else { nsscreen = [[[NSScreen alloc] initWithFrame: NSZeroRect visibleFrame: NSZeroRect] -@@ -247,25 +635,35 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, +@@ -247,25 +636,35 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, } [nsscreen setCgDirectDisplayID: (i + 1)]; @@ -2864,7 +2872,7 @@ index 5ae29bf4..25056329 100644 } static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { -@@ -536,12 +934,53 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -536,12 +935,109 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return &layout->layout; } @@ -2875,6 +2883,62 @@ index 5ae29bf4..25056329 100644 + * button, key, crossing or focus event so the next question after a state change is always asked for real. */ +#include +#include ++static BOOL rclolHasGameTitle(NSString *title) { ++ return title && [title rangeOfString:@"(TM) Client" options:NSCaseInsensitiveSearch].location != NSNotFound; ++} ++ ++BOOL X11IsLeagueGameWindow(Display *display, Window window, NSWindow *delegate) { ++ /* Tooltips, menus and other panels are not the game window even in the game process: a ++ * tooltip treated as the game was made managed and EWMH fullscreen, a black screen on top. */ ++ static Class panelClass, toolTipClass; ++ if (!panelClass) { ++ panelClass = NSClassFromString(@"NSPanel"); ++ toolTipClass = NSClassFromString(@"NSToolTipWindow"); ++ } ++ if (delegate && ((panelClass && [delegate isKindOfClass: panelClass]) || ++ (toolTipClass && [delegate isKindOfClass: toolTipClass]))) ++ return NO; ++ static int gameProcess = -1; ++ if (gameProcess < 0) { ++ NSString *name = [[NSProcessInfo processInfo] processName]; ++ gameProcess = name && [name rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound ++ && [name rangeOfString:@"Ux" options:NSCaseInsensitiveSearch].location == NSNotFound ++ && [name rangeOfString:@"Riot" options:NSCaseInsensitiveSearch].location == NSNotFound ++ && [name rangeOfString:@"Client" options:NSCaseInsensitiveSearch].location == NSNotFound; ++ } ++ if (gameProcess) return YES; ++ if ([delegate respondsToSelector:@selector(title)] && rclolHasGameTitle([delegate title])) return YES; ++ if (window == None) return NO; ++ XTextProperty prop; ++ BOOL titled = NO; ++ if (XGetWMName(display, window, &prop) && prop.value) { ++ titled = rclolHasGameTitle([NSString stringWithUTF8String:(const char *)prop.value]); ++ XFree(prop.value); ++ } ++ return titled; ++} ++ ++/* Automatic fullscreen is valid only after the game has supplied its final monitor geometry. ++ * Process identity alone also matches its temporary 1x1 startup window. */ ++BOOL X11RootRectCoversMonitor(Display *display, NSRect rect) { ++ if (rect.size.width < 3 || rect.size.height < 3) return NO; ++ BOOL covers = NO; ++ XRRScreenResources *resources = XRRGetScreenResourcesCurrent(display, DefaultRootWindow(display)); ++ if (resources) { ++ for (int i = 0; i < resources->ncrtc; ++i) { ++ XRRCrtcInfo *crtc = XRRGetCrtcInfo(display, resources, resources->crtcs[i]); ++ if (crtc) { ++ if (crtc->mode && fabs(rect.origin.x - crtc->x) <= 2 && fabs(rect.origin.y - crtc->y) <= 2 && ++ fabs(rect.size.width - crtc->width) <= 2 && fabs(rect.size.height - crtc->height) <= 2) ++ covers = YES; ++ XRRFreeCrtcInfo(crtc); ++ } ++ } ++ XRRFreeScreenResources(resources); ++ } ++ return covers; ++} ++ +static BOOL rclolFresh(uint64_t *stamp) { + static mach_timebase_info_data_t tb; + if (tb.denom == 0) mach_timebase_info(&tb); @@ -2920,7 +2984,7 @@ index 5ae29bf4..25056329 100644 } - (NSPasteboard *) pasteboardWithName: (NSString *) name { -@@ -675,6 +1114,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -675,6 +1171,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { blue: 0.60f alpha: 1.0f]; @@ -2936,7 +3000,7 @@ index 5ae29bf4..25056329 100644 NSLog(@"missing color for %@", colorName); return [NSColor redColor]; } -@@ -688,6 +1136,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -688,6 +1193,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } - (void) hideCursor { @@ -2947,7 +3011,7 @@ index 5ae29bf4..25056329 100644 if (!_blankCursor) _blankCursor = [[X11Cursor alloc] initBlank]; [self setCursor: _blankCursor]; -@@ -695,6 +1147,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -695,6 +1204,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { - (void) unhideCursor { NSCursor *current = [NSCursor currentCursor]; @@ -2958,7 +3022,7 @@ index 5ae29bf4..25056329 100644 if (current != nil) { [current push]; [current pop]; -@@ -747,12 +1203,28 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -747,12 +1260,28 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { - (void) setCursor: (id) cursor { CGWindow *window = [[NSApp keyWindow] platformWindow]; @@ -2992,7 +3056,7 @@ index 5ae29bf4..25056329 100644 } } -@@ -921,15 +1393,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -921,15 +1450,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } - (NSPoint) mouseLocation { @@ -3024,7 +3088,7 @@ index 5ae29bf4..25056329 100644 } - (void) setWindow: (id) window forID: (XID) i { -@@ -948,6 +1427,19 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -948,6 +1484,19 @@ 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; @@ -3044,7 +3108,7 @@ index 5ae29bf4..25056329 100644 } } -@@ -962,20 +1454,85 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -962,20 +1511,86 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { { #ifndef DARLING [[NSRunLoop currentRunLoop] addInputSource: _inputSource forMode: mode]; @@ -3119,15 +3183,16 @@ index 5ae29bf4..25056329 100644 + slice = next > now ? next - now : 0; + } + if (result == nil && slice > 0) { -+ /* darling-lol perf: poll() the X11 connection fd instead of blind -+ * nanosleep. This wakes within <0.1ms when an input event arrives -+ * on the socket, eliminating up to 4ms of input latency jitter. */ -+ int xfd = ConnectionNumber(_display); -+ struct pollfd pfd = { .fd = xfd, .events = POLLIN }; -+ int timeout_ms = (int)(slice * 1000.0); -+ if (timeout_ms < 0) timeout_ms = 0; ++ /* darling-lol perf: wait on the X11 connection fd instead of a blind ++ * sleep, so an input event arriving on the socket ends the wait early. ++ * ppoll keeps the nanosecond deadline: poll's millisecond timeout ++ * truncates sub-millisecond slices to 0 and turns the wait into a spin. */ ++ struct pollfd pfd = { .fd = ConnectionNumber(_display), .events = POLLIN }; ++ struct { long seconds, nanoseconds; } delay = { ++ 0, (long)(slice * 1000000000.0) ++ }; + extern long __lolsys_syscall(long, long, long, long, long, long, int); -+ __lolsys_syscall((long)&pfd, 1, timeout_ms, 0, 0, 0, 7 /* Linux poll */); ++ __lolsys_syscall((long)&pfd, 1, (long)&delay, 0, 8, 0, 271 /* Linux ppoll */); + } + } + return result; @@ -3135,7 +3200,7 @@ index 5ae29bf4..25056329 100644 } - (NSEventModifierFlags) modifierFlagsForState: (unsigned int) state { -@@ -1010,17 +1567,96 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1010,17 +1625,96 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return result; } @@ -3232,7 +3297,7 @@ index 5ae29bf4..25056329 100644 NSEventModifierFlags modifierFlags = [self modifierFlagsForState: ev->xkey.state]; char buf[20] = {0}; KeySym keySym; -@@ -1030,7 +1666,11 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1030,7 +1724,11 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; if (ev->type == KeyPress) { @@ -3245,7 +3310,7 @@ index 5ae29bf4..25056329 100644 buf[strLen] = 0; } else { // Xutf8LookupString() may not be used with KeyRelease -@@ -1038,6 +1678,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1038,6 +1736,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { buf[strLen] = 0; } @@ -3264,31 +3329,12 @@ index 5ae29bf4..25056329 100644 id str = [[NSString alloc] initWithCString: buf encoding: NSUTF8StringEncoding]; NSPoint pos = -@@ -1077,6 +1729,51 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1077,6 +1787,32 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ButtonPress:; -+ if (window) { -+ Window win = [window windowHandle]; -+ XSetInputFocus(_display, win, RevertToPointerRoot, ev->xbutton.time); -+ if (window->_xic) -+ XSetICFocus(window->_xic); -+ Atom activeAtom = XInternAtom(_display, "_NET_ACTIVE_WINDOW", False); -+ XClientMessageEvent act = {0}; -+ act.type = ClientMessage; -+ act.window = win; -+ act.message_type = activeAtom; -+ act.format = 32; -+ act.data.l[0] = 1; -+ act.data.l[1] = ev->xbutton.time; -+ XSendEvent(_display, DefaultRootWindow(_display), False, -+ SubstructureNotifyMask | SubstructureRedirectMask, -+ (XEvent *)&act); -+ XFlush(_display); -+ if (!_confinedWindow) { -+ [self updateMouseConfinementForWindow: window]; -+ } -+ } ++ if (window && !_confinedWindow) ++ [self updateMouseConfinementForWindow: window]; + if (ev->xbutton.button >= Button4 && ev->xbutton.button <= 7) { + pos = [self locationInWindow: window x: ev->xbutton.x y: ev->xbutton.y + rootX: ev->xbutton.x_root rootY: ev->xbutton.y_root]; @@ -3316,7 +3362,7 @@ index 5ae29bf4..25056329 100644 NSTimeInterval now = [[NSDate date] timeIntervalSinceReferenceDate]; if (now - lastClickTimeStamp < [self doubleClickInterval]) { -@@ -1086,8 +1783,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1086,8 +1822,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } lastClickTimeStamp = now; @@ -3327,7 +3373,7 @@ index 5ae29bf4..25056329 100644 switch (ev->xbutton.button) { case Button1: -@@ -1096,10 +1793,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1096,10 +1832,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseDown; break; @@ -3338,7 +3384,7 @@ index 5ae29bf4..25056329 100644 default: type = NSOtherMouseDown; } -@@ -1113,14 +1806,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1113,14 +1845,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: 0.0]; @@ -3365,7 +3411,7 @@ index 5ae29bf4..25056329 100644 CGFloat deltaY = 0.0; switch (ev->xbutton.button) { -@@ -1130,14 +1831,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1130,14 +1870,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseUp; break; @@ -3380,7 +3426,7 @@ index 5ae29bf4..25056329 100644 default: type = NSOtherMouseUp; } -@@ -1151,7 +1844,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1151,7 +1883,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: deltaY]; @@ -3391,23 +3437,10 @@ index 5ae29bf4..25056329 100644 [self postEvent: event atStart: NO]; break; -@@ -1160,49 +1855,106 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1160,49 +1894,95 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { // ev->xmotion.x, ev->xmotion.y, ev->xmotion.x_root, // ev->xmotion.y_root); -+ /* darling-lol: Coalesce pending MotionNotify events in the X11 queue to eliminate input lag. -+ * Only coalesce in absolute mode: relative mode requires every motion event and warp echo. */ -+ if (!_relativeMouse.active) { -+ XEvent nextEv; -+ while (XCheckTypedWindowEvent(_display, ev->xmotion.window, MotionNotify, &nextEv)) { -+ *ev = nextEv; -+ } -+ } -+ rclolRootX = ev->xmotion.x_root; -+ rclolRootY = ev->xmotion.y_root; -+ rclolPointerState = ev->xmotion.state; -+ rclolPointerStamp = mach_absolute_time(); -+ + if (_confinedWindow == [window windowHandle] && _confinedWidth > 0 && _confinedHeight > 0) { + if (ev->xmotion.x < 0) ev->xmotion.x = 0; + else if (ev->xmotion.x > _confinedWidth) ev->xmotion.x = _confinedWidth; @@ -3451,15 +3484,17 @@ index 5ae29bf4..25056329 100644 + BOOL warpsHonoured = !_isXwayland || _cursorHidden; + rclolLastPos = NSMakePoint(_relativeMouse.physicalX, _relativeMouse.physicalY); + X11RelativeMouseMotion relative = X11RelativeMouseHandleMotion( -+ &_relativeMouse, ev->xmotion.x_root, ev->xmotion.y_root, warpsHonoured); ++ &_relativeMouse, ev->xmotion.x_root, ev->xmotion.y_root, warpsHonoured, ev->xmotion.serial); + 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 long serial = NextRequest(_display); + unsigned queued = [self warpPointerToRootX: relative.warpX y: relative.warpY]; -+ X11RelativeMouseExpectEcho(&_relativeMouse, relative.warpX, relative.warpY, queued); ++ if (!X11RelativeMouseExpectEcho(&_relativeMouse, relative.warpX, relative.warpY, queued, serial)) ++ [self suspendRelativeMouse]; /* Allocation failure must not turn an echo into input. */ + } + if (!relative.post) { + if (rclolTraceOn()) @@ -3524,7 +3559,7 @@ index 5ae29bf4..25056329 100644 mouseEventWithType: type location: pos modifierFlags: -@@ -1211,28 +1963,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +1991,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -3585,7 +3620,7 @@ index 5ae29bf4..25056329 100644 NSLog(@"FocusIn"); if ([delegate attachedSheet]) { [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; -@@ -1243,28 +2025,61 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +2053,62 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -3613,13 +3648,14 @@ index 5ae29bf4..25056329 100644 + rclolFocusDetailName(ev->xfocus.detail), _relativeRequested, _relativeMouse.active, + (unsigned long) _confinedWindow, + [NSApp isActive]); -+ if (ev->xfocus.detail == NotifyPointer || ev->xfocus.detail == NotifyInferior || ev->xfocus.mode == NotifyGrab || ev->xfocus.mode == NotifyUngrab) { -+ /* Pointer boundary crossing or grab transition in XWayland: do NOT release confinement or deactivate window */ -+ break; -+ } NSLog(@"FocusOut"); ++ /* A key released while focus is elsewhere is never reported; always resync modifiers. */ + [self reconcileModifierKeys:NULL window:delegate]; + _lastKeySym = 0; ++ /* Focus moved to a child of this window, or this is a PointerRoot pointer crossing: the ++ * window still owns the keyboard, so keep it active and keep the confinement. */ ++ if (ev->xfocus.detail == NotifyInferior || ev->xfocus.detail == NotifyPointer) ++ break; [delegate platformWindowDeactivated: window checkForAppDeactivation: NO]; lastFocusedWindow = nil; @@ -3649,7 +3685,7 @@ index 5ae29bf4..25056329 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +2116,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +2145,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -3677,10 +3713,16 @@ index 5ae29bf4..25056329 100644 break; case MapRequest: -@@ -1317,10 +2144,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1317,10 +2173,21 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ConfigureNotify: ++ if (_relativeMouse.active && ev->xconfigure.window == _relativeWindow) { ++ /* Reconfiguration can make Xorg move a confined pointer by itself. Take a new snapshot ++ * and discard the previous geometry's pending motion/echoes before resuming. */ ++ [self suspendRelativeMouse]; ++ [self beginRelativeMouseWithWindow: NULL pick: NULL]; ++ } + if (ev->xconfigure.window == _confinedWindow) { + _confinedWidth = ev->xconfigure.width; + _confinedHeight = ev->xconfigure.height; @@ -3693,7 +3735,7 @@ index 5ae29bf4..25056329 100644 break; case ConfigureRequest: -@@ -1344,6 +2176,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2211,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -3702,7 +3744,7 @@ index 5ae29bf4..25056329 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1399,7 +2233,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1399,7 +2268,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; default: @@ -3713,7 +3755,7 @@ index 5ae29bf4..25056329 100644 [self _invalidateRRCache]; break; } -@@ -1433,6 +2269,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1433,6 +2304,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); @@ -3724,12 +3766,12 @@ index 5ae29bf4..25056329 100644 return 0; } -@@ -1476,54 +2316,285 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2351,312 @@ 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. */ ++ * Returns the number of queued events. Callers capture NextRequest before this method to identify the echo. */ +- (unsigned) warpPointerToRootX: (int) x y: (int) y { + XWarpPointer(_display, None, DefaultRootWindow(_display), 0, 0, 0, 0, x, y); + XSync(_display, False); @@ -3758,11 +3800,18 @@ index 5ae29bf4..25056329 100644 + _relativeMouse.virtualY, _relativeMouse.physicalX, _relativeMouse.physicalY, + !_isXwayland || _cursorHidden, (unsigned long) _confinedWindow, rclolRootX, rclolRootY, + [NSApp isActive]); ++ /* Xorg clips warps to confine_to. Report that same target to the game, otherwise mouseLocation, ++ * button events and the next rewarp disagree at the window edge (including on secondary monitors). */ ++ if (_relativeMouse.active && (!_isXwayland || _cursorHidden)) ++ X11RelativeMouseClamp(&_relativeMouse, &position.x, &position.y); + 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]); ++ unsigned long serial = NextRequest(_display); ++ unsigned queued = [self warpPointerToRootX: x y: y]; ++ if (!X11RelativeMouseWarp(&_relativeMouse, position.x, position.y, queued, serial)) ++ [self suspendRelativeMouse]; + return; + } NSLog(@"Warp to: x=%f, y=%f\n", position.x, position.y); @@ -3770,8 +3819,8 @@ index 5ae29bf4..25056329 100644 - 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 { @@ -3801,79 +3850,9 @@ index 5ae29bf4..25056329 100644 + if (data) XFree(data); + BOOL rclolEwmhFullscreen = fullscreen; + -+ /* League's borderless fullscreen does not set the EWMH fullscreen bit. -+ * Recognize a borderless game window, multi-monitor setups, and GNOME Wayland. */ -+ NSWindow *delegate = [window delegate]; -+ BOOL isBorderless = delegate ? !([delegate styleMask] & NSTitledWindowMask) : YES; -+ NSString *procName = [[NSProcessInfo processInfo] processName]; -+ BOOL isUx = (procName && [procName rangeOfString:@"Ux" options:NSCaseInsensitiveSearch].location != NSNotFound); -+ BOOL isRiot = (procName && [procName rangeOfString:@"Riot" options:NSCaseInsensitiveSearch].location != NSNotFound); -+ BOOL isClient = (procName && [procName rangeOfString:@"Client" options:NSCaseInsensitiveSearch].location != NSNotFound); -+ BOOL isGame = NO; -+ if (!isUx && !isRiot && !isClient && procName && [procName rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound) { -+ isGame = YES; -+ } -+ NSString *winTitle = [delegate respondsToSelector:@selector(title)] ? [delegate title] : nil; -+ if (winTitle && [winTitle rangeOfString:@"(TM) Client" options:NSCaseInsensitiveSearch].location != NSNotFound) { -+ isGame = YES; -+ } -+ if (!isGame) { -+ XTextProperty prop; -+ if (XGetWMName(_display, win, &prop) && prop.value) { -+ NSString *xtitle = [NSString stringWithUTF8String:(const char *)prop.value]; -+ if (xtitle && [xtitle rangeOfString:@"(TM) Client" options:NSCaseInsensitiveSearch].location != NSNotFound) { -+ isGame = YES; -+ } -+ XFree(prop.value); -+ } -+ } -+ -+ if (isBorderless && isGame) { -+ if (!fullscreen) { -+ fullscreen = YES; -+ /* Inform GNOME Shell / Mutter / EWMH to make this window fullscreen. -+ * This hides the GNOME top bar, sizes the window to fill the entire monitor, -+ * and enables Wayland pointer confinement (zwp_confined_pointer_v1). */ -+ XClientMessageEvent cme = {0}; -+ cme.type = ClientMessage; -+ cme.window = win; -+ cme.message_type = state; -+ cme.format = 32; -+ cme.data.l[0] = 1; /* _NET_WM_STATE_ADD */ -+ cme.data.l[1] = (long)fs; -+ cme.data.l[2] = 0; -+ cme.data.l[3] = 1; /* normal application */ -+ cme.data.l[4] = 0; -+ XSendEvent(_display, DefaultRootWindow(_display), False, -+ SubstructureNotifyMask | SubstructureRedirectMask, -+ (XEvent *)&cme); -+ -+ /* darling-lol: Tell compositor NEVER to bypass composition (bypass=2). -+ * Direct scanout of this parent window produces a black screen because -+ * OpenGL/Metal renders into the child X11SubWindow. */ -+ unsigned long bypass = 2; -+ XChangeProperty(_display, win, XInternAtom(_display, "_NET_WM_BYPASS_COMPOSITOR", False), -+ XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&bypass, 1); -+ /* Ensure window is marked active in Mutter so top bar is hidden and pointer is confined */ -+ Atom activeAtom = XInternAtom(_display, "_NET_ACTIVE_WINDOW", False); -+ XClientMessageEvent act = {0}; -+ act.type = ClientMessage; -+ act.window = win; -+ act.message_type = activeAtom; -+ act.format = 32; -+ act.data.l[0] = 1; -+ act.data.l[1] = CurrentTime; -+ XSendEvent(_display, DefaultRootWindow(_display), False, -+ SubstructureNotifyMask | SubstructureRedirectMask, -+ (XEvent *)&act); -+ XRaiseWindow(_display, win); -+ XSetInputFocus(_display, win, RevertToPointerRoot, CurrentTime); -+ XFlush(_display); -+ if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT promoted to EWMH FULLSCREEN and ACTIVE for window %lu\n",win); -+ } -+ } -+ -+ /* Check focus to determine whether mouse confinement should be active. */ ++ /* Only the window that holds keyboard focus may take the pointer. Asking the WM to activate ++ * or focus it from here would fight the user's alt-tab: this runs on every focus, map, ++ * configure and _NET_WM_STATE change. */ + Window focus; int revert; + XGetInputFocus(_display, &focus, &revert); + Window ancestor = focus; @@ -3883,10 +3862,58 @@ index 5ae29bf4..25056329 100644 + if (children) XFree(children); + ancestor=parent; + } -+ if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT focus=%lu target=%lu ancestor=%lu fullscreen=%d\n",focus,win,ancestor,fullscreen); -+ -+ BOOL isFocused = (ancestor == win || focus == win || (isGame && ([NSApp isActive] || focus == PointerRoot || focus == None || focus == DefaultRootWindow(_display)))); -+ if ((!fullscreen && !isGame) || attrs.map_state != IsViewable || !isFocused) { ++ if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT focus=%lu target=%lu ancestor=%lu\n",focus,win,ancestor); ++ 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; ++ } ++ /* League's borderless fullscreen does not set the EWMH fullscreen bit. ++ * Recognize a borderless window covering one monitor, including monitor 2. */ ++ BOOL borderless = !([[window delegate] styleMask] & NSTitledWindowMask); ++ if (!fullscreen && borderless) { ++ Window child; int x, y; ++ XTranslateCoordinates(_display, win, DefaultRootWindow(_display), 0, 0, &x, &y, &child); ++ fullscreen = X11RootRectCoversMonitor(_display, NSMakeRect(x, y, attrs.width, attrs.height)); ++ } ++ /* GNOME keeps its top bar and dock above a borderless window that merely covers the monitor. ++ * Ask the WM once per window for real EWMH fullscreen on League's game window, and never let ++ * the compositor scan out this parent window directly: Metal/OpenGL draw into a child window. */ ++ static Window rclolPromotedWindow; ++ if (fullscreen && borderless && !rclolEwmhFullscreen && win != rclolPromotedWindow && X11IsLeagueGameWindow(_display, win, [window delegate])) { ++ rclolPromotedWindow = win; ++ fullscreen = YES; ++ unsigned long bypass = 2; /* _NET_WM_BYPASS_COMPOSITOR: never */ ++ XChangeProperty(_display, win, XInternAtom(_display, "_NET_WM_BYPASS_COMPOSITOR", False), ++ XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&bypass, 1); ++ XClientMessageEvent cme = {0}; ++ cme.type = ClientMessage; ++ cme.window = win; ++ cme.message_type = state; ++ cme.format = 32; ++ cme.data.l[0] = 1; /* _NET_WM_STATE_ADD */ ++ cme.data.l[1] = (long)fs; ++ cme.data.l[3] = 1; /* source: normal application */ ++ XSendEvent(_display, DefaultRootWindow(_display), False, ++ SubstructureNotifyMask | SubstructureRedirectMask, (XEvent *)&cme); ++ /* Mutter only hides its top bar over the active fullscreen window. The window already ++ * holds focus here, so marking it active and raising it cannot steal focus. */ ++ XClientMessageEvent act = {0}; ++ act.type = ClientMessage; ++ act.window = win; ++ act.message_type = XInternAtom(_display, "_NET_ACTIVE_WINDOW", False); ++ act.format = 32; ++ act.data.l[0] = 1; /* source: normal application */ ++ act.data.l[1] = CurrentTime; ++ XSendEvent(_display, DefaultRootWindow(_display), False, ++ SubstructureNotifyMask | SubstructureRedirectMask, (XEvent *)&act); ++ XRaiseWindow(_display, win); ++ XFlush(_display); ++ if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT requested EWMH fullscreen for window %lu\n",win); ++ } ++ if (!fullscreen || attrs.map_state != IsViewable) { + if (rclolTraceOn()) + rclolTraceRated(&rclolConfineChannel, "no confinement window=%lu fullscreen=%d ewmh=%d titled=%d size=%dx%d map_state=%d %s", + (unsigned long) win, fullscreen, rclolEwmhFullscreen, @@ -3915,13 +3942,31 @@ index 5ae29bf4..25056329 100644 + rclolTraceRated(&rclolConfineChannel, "confine window=%lu fullscreen=%d ewmh=%d size=%dx%d XGrabPointer=%d(%s)", + (unsigned long) win, fullscreen, rclolEwmhFullscreen, attrs.width, attrs.height, status, + rclolGrabResultName(status)); -+ if (status == GrabSuccess || status == AlreadyGrabbed) { ++ /* AlreadyGrabbed means another client holds the pointer: nothing is confined. */ ++ if (status == GrabSuccess) { + _confinedWindow = win; + _confinedWidth = attrs.width; + _confinedHeight = attrs.height; + } - } - ++} ++ ++/* Cache the actual confine_to rectangle, including its root offset. XWarpPointer clips to this ++ * rectangle, not to the primary monitor; refresh on resize/move rather than on every motion. */ ++- (BOOL) updateRelativeMouseBounds { ++ XWindowAttributes attrs; ++ Window child; ++ int x, y; ++ if (!XGetWindowAttributes(_display, _relativeWindow, &attrs) || ++ !XTranslateCoordinates(_display, _relativeWindow, DefaultRootWindow(_display), 0, 0, &x, &y, &child)) ++ return NO; ++ _relativeMouse.minX = MAX(0, x); ++ _relativeMouse.minY = MAX(0, y); ++ _relativeMouse.maxX = MIN(DisplayWidth(_display, DefaultScreen(_display)) - 1, x + attrs.width - 1); ++ _relativeMouse.maxY = MIN(DisplayHeight(_display, DefaultScreen(_display)) - 1, y + attrs.height - 1); ++ _relativeMouse.bounded = _relativeMouse.maxX >= _relativeMouse.minX && _relativeMouse.maxY >= _relativeMouse.minY; ++ return _relativeMouse.bounded; ++} ++ +/* Takes the relative-mode pointer grab on the key (else main) window, starting with the virtual cursor at the + * current pointer position. Returns the XGrabPointer result, or -1 without a window. */ +- (int) beginRelativeMouseWithWindow: (NSWindow **) outWindow pick: (const char **) outPick { @@ -3937,14 +3982,30 @@ index 5ae29bf4..25056329 100644 + Window win = xwin ? [xwin windowHandle] : None; + if (win == None) { + NSLog(@"Relative mouse mode: no window to grab the pointer for\n"); ++ [self suspendRelativeMouse]; + return -1; + } ++ Window focus; int revert; ++ XGetInputFocus(_display, &focus, &revert); ++ Window ancestor = focus; ++ while (ancestor != win && ancestor != None && ancestor != PointerRoot && ancestor != DefaultRootWindow(_display)) { ++ Window root, parent, *children = NULL; unsigned count; ++ if (!XQueryTree(_display, ancestor, &root, &parent, &children, &count)) break; ++ if (children) XFree(children); ++ ancestor = parent; ++ } ++ if (ancestor != win) { ++ [self suspendRelativeMouse]; ++ return -1; /* FocusIn retries the preserved relative-mode request. */ ++ } ++ if (_relativeMouse.active && _relativeWindow == win) return GrabSuccess; + /* FocusChangeMask is not valid in a pointer grab. This grab replaces any confinement grab. */ + const unsigned int mask = ButtonPressMask | ButtonReleaseMask | PointerMotionMask; + int result = XGrabPointer(_display, win, False, mask, GrabModeAsync, + GrabModeAsync, win, None, CurrentTime); + if (result != GrabSuccess) { + NSLog(@"XGrabPointer() failed with error %d for window %lu\n", result, win); ++ [self suspendRelativeMouse]; + return result; + } + NSLog(@"XGrabPointer() succeeded for window %lu\n", win); @@ -3952,14 +4013,22 @@ index 5ae29bf4..25056329 100644 + _confinedWidth = 0; + _confinedHeight = 0; + Window root, child; -+ int rootX = rclolRootX, rootY = rclolRootY, windowX, windowY; ++ int rootX, rootY, windowX, windowY; + unsigned int state; ++ unsigned long snapshotSerial = NextRequest(_display); + if (!XQueryPointer(_display, win, &root, &child, &rootX, &rootY, &windowX, &windowY, &state)) { -+ rootX = rclolRootX; -+ rootY = rclolRootY; ++ XUngrabPointer(_display, CurrentTime); ++ X11RelativeMouseEnd(&_relativeMouse); ++ _relativeWindow = None; ++ return -1; /* Never start relative mode from an uninitialized or stale cache. */ + } + X11RelativeMouseBegin(&_relativeMouse, rootX, rootY); ++ _relativeMouse.firstSerial = snapshotSerial; + _relativeWindow = win; ++ if (![self updateRelativeMouseBounds]) { ++ [self suspendRelativeMouse]; ++ return -1; ++ } + rclolPointerStamp = 0; + return result; +} @@ -4243,10 +4312,10 @@ index 0e91a5d1..1e4cec1b 100644 - (void) selectionNotify: (XSelectionEvent *) event { diff --git a/AppKit/X11.backend/X11RelativeMouse.h b/AppKit/X11.backend/X11RelativeMouse.h new file mode 100644 -index 00000000..7fc39cc4 +index 0000000..e558e1f --- /dev/null +++ b/AppKit/X11.backend/X11RelativeMouse.h -@@ -0,0 +1,147 @@ +@@ -0,0 +1,183 @@ +/* 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 @@ -4254,8 +4323,8 @@ index 00000000..7fc39cc4 + * 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; ++ * application's) is followed by a MotionNotify at its target; those echoes are recognised by request serial ++ * and 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 @@ -4267,11 +4336,13 @@ index 00000000..7fc39cc4 +#define X11_RELATIVE_MOUSE_H + +#include ++#include + -+#define X11_RELATIVE_MOUSE_MAX_ECHOES 8 ++#define X11_RELATIVE_MOUSE_INITIAL_ECHOES 8 + +typedef struct { + int x, y; ++ unsigned long serial; /* X request that generated the echo; coordinates alone also match real motion */ + unsigned remaining; /* X events (of any type) still to be handled before this echo is given up */ +} X11RelativeMouseEcho; + @@ -4279,8 +4350,12 @@ index 00000000..7fc39cc4 + 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 long firstSerial; /* ignore motion queued before the initial XQueryPointer snapshot */ ++ bool bounded; ++ int minX, minY, maxX, maxY; /* inclusive root coordinates of the confine_to window */ + unsigned echoCount; -+ X11RelativeMouseEcho echoes[X11_RELATIVE_MOUSE_MAX_ECHOES]; /* oldest first */ ++ unsigned echoCapacity; ++ X11RelativeMouseEcho *echoes; /* oldest first; never discard a pending warp echo on overflow */ +} X11RelativeMouse; + +typedef struct { @@ -4296,7 +4371,13 @@ index 00000000..7fc39cc4 + return (int) value; +} + ++/* Initialize storage to zero before the first Begin; End releases the pending-echo allocation. */ +static inline void X11RelativeMouseBegin(X11RelativeMouse *mouse, int rootX, int rootY) { ++ free(mouse->echoes); ++ mouse->echoes = NULL; ++ mouse->echoCapacity = 0; ++ mouse->firstSerial = 0; ++ mouse->bounded = false; + mouse->active = true; + mouse->virtualX = rootX; + mouse->virtualY = rootY; @@ -4306,36 +4387,53 @@ index 00000000..7fc39cc4 +} + +static inline void X11RelativeMouseEnd(X11RelativeMouse *mouse) { ++ free(mouse->echoes); ++ mouse->echoes = NULL; ++ mouse->echoCapacity = 0; + 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; ++static inline bool X11RelativeMouseExpectEcho(X11RelativeMouse *mouse, int x, int y, unsigned queuedEvents, ++ unsigned long serial) { ++ if (!queuedEvents) return true; + if (mouse->echoCount) { + X11RelativeMouseEcho *last = &mouse->echoes[mouse->echoCount - 1]; -+ if (last->x == x && last->y == y) { ++ if (last->x == x && last->y == y && last->serial == serial) { + if (last->remaining < queuedEvents) last->remaining = queuedEvents; -+ return; ++ return true; + } + } -+ 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--; ++ if (mouse->echoCount == mouse->echoCapacity) { ++ unsigned capacity = mouse->echoCapacity ? mouse->echoCapacity * 2 : X11_RELATIVE_MOUSE_INITIAL_ECHOES; ++ if (capacity <= mouse->echoCapacity) return false; ++ X11RelativeMouseEcho *echoes = realloc(mouse->echoes, (size_t)capacity * sizeof(*echoes)); ++ if (!echoes) return false; ++ mouse->echoes = echoes; ++ mouse->echoCapacity = capacity; + } -+ mouse->echoes[mouse->echoCount++] = (X11RelativeMouseEcho) {x, y, queuedEvents}; ++ mouse->echoes[mouse->echoCount++] = (X11RelativeMouseEcho) {x, y, serial, queuedEvents}; ++ return true; +} + +/* 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) { ++static inline void X11RelativeMouseClamp(const X11RelativeMouse *mouse, double *x, double *y) { ++ if (!mouse->bounded) return; ++ if (*x < mouse->minX) *x = mouse->minX; ++ if (*x > mouse->maxX) *x = mouse->maxX; ++ if (*y < mouse->minY) *y = mouse->minY; ++ if (*y > mouse->maxY) *y = mouse->maxY; ++} ++ ++static inline bool X11RelativeMouseWarp(X11RelativeMouse *mouse, double x, double y, unsigned queuedEvents, ++ unsigned long serial) { + mouse->virtualX = x; + mouse->virtualY = y; -+ X11RelativeMouseExpectEcho(mouse, X11RelativeMouseWarpCoordinate(x), X11RelativeMouseWarpCoordinate(y), -+ queuedEvents); ++ return X11RelativeMouseExpectEcho(mouse, X11RelativeMouseWarpCoordinate(x), X11RelativeMouseWarpCoordinate(y), ++ queuedEvents, serial); +} + +/* One X event has been handled: age the expected echoes. */ @@ -4351,11 +4449,15 @@ index 00000000..7fc39cc4 + +/* 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) ++ bool warpsHonoured, unsigned long serial) +{ + X11RelativeMouseMotion result = {false, false, 0.0, 0.0, false, 0, 0}; ++ if (serial < mouse->firstSerial) { ++ X11RelativeMouseTick(mouse); ++ return result; ++ } + for (unsigned i = 0; i < mouse->echoCount; ++i) -+ if (mouse->echoes[i].x == rootX && mouse->echoes[i].y == rootY) { ++ if (mouse->echoes[i].serial == serial && 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]; @@ -4376,7 +4478,10 @@ index 00000000..7fc39cc4 + int warpX = X11RelativeMouseWarpCoordinate(mouse->virtualX); + int warpY = X11RelativeMouseWarpCoordinate(mouse->virtualY); + if (warpsHonoured && (rootX != warpX || rootY != warpY)) { -+ result.rewarp = true; ++ /* More physical motion may already be queued before our latest warp. That warp will ++ * re-anchor it; issuing another one for each queued event creates an echo backlog. */ ++ bool pending = mouse->echoCount && serial < mouse->echoes[mouse->echoCount - 1].serial; ++ result.rewarp = !pending; + result.warpX = warpX; + result.warpY = warpY; + } @@ -4446,7 +4551,7 @@ index bfe3839a..dbc974a0 100644 - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m -index f4925b66..c288eb38 100644 +index f4925b66..e827c28e 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -38,6 +38,34 @@ @@ -4493,42 +4598,25 @@ index f4925b66..c288eb38 100644 BOOL isPanel = [delegate isKindOfClass: [NSPanel class]]; if (isPanel && _styleMask & NSDocModalWindowMask) _styleMask = NSBorderlessWindowMask; -@@ -193,8 +221,32 @@ static NSData *makeWindowIcon() { +@@ -193,8 +221,15 @@ static NSData *makeWindowIcon() { XSetWindowAttributes xattr; unsigned long xattr_mask; - xattr.override_redirect = - _styleMask == NSBorderlessWindowMask ? True : False; -+ NSString *procName = [[NSProcessInfo processInfo] processName]; -+ BOOL isUx = (procName && [procName rangeOfString:@"Ux" options:NSCaseInsensitiveSearch].location != NSNotFound); -+ BOOL isRiot = (procName && [procName rangeOfString:@"Riot" options:NSCaseInsensitiveSearch].location != NSNotFound); -+ BOOL isClient = (procName && [procName rangeOfString:@"Client" options:NSCaseInsensitiveSearch].location != NSNotFound); -+ BOOL isGame = NO; -+ if (!isUx && !isRiot && !isClient && procName && [procName rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound) { -+ isGame = YES; -+ } -+ NSString *winTitle = [delegate respondsToSelector:@selector(title)] ? [delegate title] : nil; -+ if (winTitle && [winTitle rangeOfString:@"(TM) Client" options:NSCaseInsensitiveSearch].location != NSNotFound) { -+ isGame = YES; -+ } -+ -+ BOOL isMenuOrPopup = NO; -+ if (delegate) { -+ if ([delegate isKindOfClass:NSClassFromString(@"NSMenuWindow")] || -+ [delegate isKindOfClass:NSClassFromString(@"NSPopUpWindow")] || -+ [delegate isKindOfClass:NSClassFromString(@"NSToolTipPanel")]) { -+ isMenuOrPopup = YES; -+ } -+ } ++ BOOL isGame = X11IsLeagueGameWindow(_display, None, delegate); + + // A borderless game is still a focusable application window. Leaving it + // override-redirect makes XWayland bypass compositor keyboard activation. + // Menus and nonactivating popups keep their unmanaged behavior. -+ xattr.override_redirect = (!isGame && _styleMask == NSBorderlessWindowMask && isMenuOrPopup) ? True : False; ++ xattr.override_redirect = !isGame && _styleMask == NSBorderlessWindowMask && ++ ([delegate isKindOfClass:[NSMenuWindow class]] || ++ [delegate isKindOfClass:[NSPopUpWindow class]] || ++ ![delegate canBecomeKeyWindow]) ? True : False; xattr_mask = CWOverrideRedirect | CWColormap; xattr.colormap = cmap; -@@ -206,6 +258,12 @@ static NSData *makeWindowIcon() { +@@ -206,6 +241,12 @@ static NSData *makeWindowIcon() { (_visualInfo == NULL) ? CopyFromParent : _visualInfo->visual, xattr_mask, &xattr); @@ -4541,18 +4629,14 @@ index f4925b66..c288eb38 100644 [self syncDelegateProperties]; Atom atm = XInternAtom(_display, "WM_DELETE_WINDOW", False); -@@ -217,6 +275,19 @@ static NSData *makeWindowIcon() { +@@ -217,6 +258,15 @@ static NSData *makeWindowIcon() { .res_class = (char *) name}; XSetClassHint(_display, _window, &classHint); -+ if (isGame && (_styleMask == NSBorderlessWindowMask || !(_styleMask & NSTitledWindowMask))) { -+ Atom state = XInternAtom(_display, "_NET_WM_STATE", False); -+ Atom fs = XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); -+ XChangeProperty(_display, _window, state, XA_ATOM, 32, PropModeReplace, -+ (const unsigned char *)&fs, 1); -+ /* darling-lol: Tell compositor NEVER to bypass composition (bypass=2). -+ * Direct scanout of this parent window produces a black screen because -+ * OpenGL/Metal renders into the child X11SubWindow. */ ++ if (isGame && !(_styleMask & NSTitledWindowMask)) { ++ /* darling-lol: never let the compositor scan out this parent window ++ * directly (bypass=2): Metal/OpenGL draw into a child window, so direct ++ * scanout shows black. EWMH fullscreen is requested in setStyleMask. */ + unsigned long bypass = 2; + XChangeProperty(_display, _window, XInternAtom(_display, "_NET_WM_BYPASS_COMPOSITOR", False), + XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&bypass, 1); @@ -4561,19 +4645,21 @@ index f4925b66..c288eb38 100644 XSetWindowBackgroundPixmap(_display, _window, None); _isModal = [delegate isSheet] || [NSApp modalWindow] == delegate; -@@ -266,6 +337,11 @@ static NSData *makeWindowIcon() { +@@ -266,6 +316,13 @@ static NSData *makeWindowIcon() { [self setWindowIcon]; -+ /* Poll the host-visible remote CA frame on the AppKit/main runloop. */ -+ _remoteFrameTimer = [[NSTimer scheduledTimerWithTimeInterval: 1.0 / 60.0 -+ target: self selector: @selector(presentRemoteCAFrame:) -+ userInfo: nil repeats: YES] retain]; ++ /* Chromium windows consume cross-process CA frames. League's native Metal window owns its ++ * drawable; a same-size client surface must never be raised above that drawable. */ ++ if (!isGame) ++ _remoteFrameTimer = [[NSTimer scheduledTimerWithTimeInterval: 1.0 / 60.0 ++ target: self selector: @selector(presentRemoteCAFrame:) ++ userInfo: nil repeats: YES] retain]; + return self; } -@@ -287,16 +363,33 @@ static NSData *makeWindowIcon() { +@@ -287,16 +344,32 @@ static NSData *makeWindowIcon() { // Make resizable sh->flags = 0; } else { @@ -4581,13 +4667,10 @@ index f4925b66..c288eb38 100644 - sh->flags = PMinSize | PMaxSize; - sh->min_width = sh->max_width = _frame.size.width; - sh->min_height = sh->max_height = _frame.size.height; -+ NSString *procName = [[NSProcessInfo processInfo] processName]; -+ BOOL isGame = (procName && [procName rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound && -+ [procName rangeOfString:@"Ux" options:NSCaseInsensitiveSearch].location == NSNotFound); -+ if (isGame || mask == NSBorderlessWindowMask) { -+ sh->flags = PMinSize; -+ sh->min_width = _frame.size.width; -+ sh->min_height = _frame.size.height; ++ if (X11IsLeagueGameWindow(_display, _window, _delegate)) { ++ // A fixed-size window cannot be made EWMH fullscreen by Mutter, ++ // and a stale minimum would block switching to a smaller mode. ++ sh->flags = 0; + } else { + // Make non-resizable + sh->flags = PMinSize | PMaxSize; @@ -4600,18 +4683,20 @@ index f4925b66..c288eb38 100644 XFree(sh); } -+ NSString *procName = [[NSProcessInfo processInfo] processName]; -+ BOOL isGame = (procName && [procName rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound && -+ [procName rangeOfString:@"Ux" options:NSCaseInsensitiveSearch].location == NSNotFound); ++ BOOL isGame = X11IsLeagueGameWindow(_display, _window, _delegate); ++ /* Do not put a temporary startup window into fullscreen before its native drawable has ++ * the final geometry. A real monitor-sized borderless window is promoted after mapping ++ * and focus in updateMouseConfinementForWindow; explicit Cocoa fullscreen is preserved. */ ++ BOOL monitorSized = isGame && X11RootRectCoversMonitor(_display, _frame); + BOOL wasFullscreen = (_styleMask & NSWindowStyleMaskFullScreen) != 0 || -+ (isGame && (_styleMask == NSBorderlessWindowMask || !(_styleMask & NSTitledWindowMask))); ++ (monitorSized && !(_styleMask & NSTitledWindowMask)); + BOOL makeFullscreen = (mask & NSWindowStyleMaskFullScreen) != 0 || -+ (isGame && (mask == NSBorderlessWindowMask || !(mask & NSTitledWindowMask))); ++ (monitorSized && !(mask & NSTitledWindowMask)); + if (!_mapped) { long states[2]; int states_cnt = 0; -@@ -304,7 +397,7 @@ static NSData *makeWindowIcon() { +@@ -304,7 +377,7 @@ static NSData *makeWindowIcon() { states[states_cnt++] = (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); } @@ -4620,7 +4705,7 @@ index f4925b66..c288eb38 100644 states[states_cnt++] = (long) XInternAtom( _display, "_NET_WM_STATE_FULLSCREEN", False); } -@@ -312,20 +405,25 @@ static NSData *makeWindowIcon() { +@@ -312,20 +385,25 @@ static NSData *makeWindowIcon() { XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, 32, PropModeReplace, (const unsigned char *) states, states_cnt); @@ -4649,7 +4734,7 @@ index f4925b66..c288eb38 100644 } } -@@ -338,7 +436,6 @@ static NSData *makeWindowIcon() { +@@ -338,7 +416,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -4657,7 +4742,7 @@ index f4925b66..c288eb38 100644 struct { unsigned long flags; unsigned long functions; -@@ -358,6 +455,7 @@ static NSData *makeWindowIcon() { +@@ -358,6 +435,7 @@ static NSData *makeWindowIcon() { if (!_mapped) { XMapWindow(_display, _window); _mapped = YES; @@ -4665,7 +4750,7 @@ index f4925b66..c288eb38 100644 } } -@@ -377,15 +475,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +455,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -4698,7 +4783,7 @@ index f4925b66..c288eb38 100644 // TODO: background color } -@@ -393,6 +503,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +483,28 @@ static NSData *makeWindowIcon() { // This is essentially dealloc; we release our contexts // and windows, but unlike dealloc, this method can be called // several times, so set everything to nil/NULL/0. @@ -4727,7 +4812,7 @@ index f4925b66..c288eb38 100644 [_context release]; _context = nil; -@@ -421,6 +553,388 @@ static NSData *makeWindowIcon() { +@@ -421,6 +533,392 @@ static NSData *makeWindowIcon() { } } @@ -4744,6 +4829,10 @@ index f4925b66..c288eb38 100644 +- (BOOL) presentRemoteCAFrame: (NSTimer *) timer { + static unsigned char *pixels; + static size_t pixelCapacity; ++ /* Slots are selected by dimensions, not by window ownership. A fullscreen client slot can ++ * match the game and create a black presentation child above Metal's X11SubWindow. Keep ++ * the native game renderer authoritative, including calls from openGLFlushBuffer/expose. */ ++ if (X11IsLeagueGameWindow(_display, _window, _delegate)) return NO; + if (!_mapped) return NO; + double now = CFAbsoluteTimeGetCurrent(); + /* X geometry queries are synchronous round trips. Cache stable targets @@ -5116,7 +5205,7 @@ index f4925b66..c288eb38 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +984,32 @@ static NSData *makeWindowIcon() { +@@ -470,14 +968,29 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -5135,10 +5224,7 @@ index f4925b66..c288eb38 100644 + // Nonresizable windows can be created at 1x1 and enlarged before display. + // Refresh the constraints so a managing compositor sees the current size. + if (!(_styleMask & NSWindowStyleMaskResizable)) { -+ NSString *procName = [[NSProcessInfo processInfo] processName]; -+ BOOL isGame = (procName && [procName rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound && -+ [procName rangeOfString:@"Ux" options:NSCaseInsensitiveSearch].location == NSNotFound); -+ if (!isGame && _styleMask != NSBorderlessWindowMask) { ++ if (!X11IsLeagueGameWindow(_display, _window, _delegate)) { + XSizeHints hints = {0}; + hints.flags = PMinSize | PMaxSize; + hints.min_width = hints.max_width = frame.size.width; @@ -5150,7 +5236,7 @@ index f4925b66..c288eb38 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1020,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1001,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -5176,26 +5262,29 @@ index f4925b66..c288eb38 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1094,18 @@ static NSData *makeWindowIcon() { +@@ -543,6 +1075,21 @@ static NSData *makeWindowIcon() { - (void) makeKey { [self ensureMapped]; XRaiseWindow(_display, _window); -+ Atom activeAtom = XInternAtom(_display, "_NET_ACTIVE_WINDOW", False); -+ XEvent act = {0}; -+ act.xclient.type = ClientMessage; -+ act.xclient.window = _window; -+ act.xclient.message_type = activeAtom; -+ act.xclient.format = 32; -+ act.xclient.data.l[0] = 1; -+ act.xclient.data.l[1] = CurrentTime; -+ XSendEvent(_display, DefaultRootWindow(_display), False, -+ SubstructureRedirectMask | SubstructureNotifyMask, &act); -+ XSetInputFocus(_display, _window, RevertToPointerRoot, CurrentTime); -+ XFlush(_display); ++ /* darling-lol: League's game window is managed (not override-redirect), so keyboard focus is ++ * the WM's to give. Ask for activation once when the game makes it key; the WM's focus-stealing ++ * policy still applies, unlike a direct XSetInputFocus. */ ++ if (X11IsLeagueGameWindow(_display, _window, _delegate)) { ++ XEvent act = {0}; ++ act.xclient.type = ClientMessage; ++ act.xclient.window = _window; ++ act.xclient.message_type = XInternAtom(_display, "_NET_ACTIVE_WINDOW", False); ++ act.xclient.format = 32; ++ act.xclient.data.l[0] = 1; /* source: normal application */ ++ act.xclient.data.l[1] = CurrentTime; ++ XSendEvent(_display, DefaultRootWindow(_display), False, ++ SubstructureRedirectMask | SubstructureNotifyMask, &act); ++ XFlush(_display); ++ } } - (void) makeMain { -@@ -559,7 +1122,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1106,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -5225,7 +5314,7 @@ index f4925b66..c288eb38 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -615,6 +1200,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1184,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -5238,7 +5327,7 @@ index f4925b66..c288eb38 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1243,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1227,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -5247,7 +5336,7 @@ index f4925b66..c288eb38 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1257,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1241,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -5255,7 +5344,7 @@ index f4925b66..c288eb38 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,14 +1302,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,14 +1286,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } @@ -9328,7 +9417,7 @@ index d6f86b31..687f05d5 100644 static void glDebugCallback(GLenum source, GLenum type, GLuint id, GLenum severity, GLsizei length, const GLchar* message, const void* context) { diff --git a/QuartzCore/CAMetalLayer.mm b/QuartzCore/CAMetalLayer.mm -index b81e2e14..f675d78d 100644 +index b81e2e14..63533444 100644 --- a/QuartzCore/CAMetalLayer.mm +++ b/QuartzCore/CAMetalLayer.mm @@ -25,7 +25,79 @@ @@ -9477,7 +9566,7 @@ index b81e2e14..f675d78d 100644 @implementation CAMetalLayerInternal #if DARLING_METAL_ENABLED -@@ -363,23 +494,43 @@ static void reportGLErrors(void) { +@@ -363,12 +494,15 @@ static void reportGLErrors(void) { - (id)nextDrawable { std::shared_ptr drawable = nullptr; @@ -9488,35 +9577,12 @@ index b81e2e14..f675d78d 100644 NSDate* endTime = [NSDate dateWithTimeIntervalSinceNow: 1]; while (_usableDrawablesBitmap == 0) { -- if (_allowsNextDrawableTimeout) { -- if (![_drawableCondition waitUntilDate: endTime]) { -- break; + // No drawable is free: this thread really blocks here (the wait is what the pool starvation costs the game). + Indium::Waits::Block block("nextDrawable"); -+ if ([NSThread isMainThread]) { -+ if (_queuedDrawableCount > 0 && self->_context) { -+ [_drawableCondition unlock]; -+ [self->_context render]; -+ [self->_context flush]; -+ [_drawableCondition lock]; -+ if (_usableDrawablesBitmap != 0) break; - } -+ [_drawableCondition unlock]; -+ CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.001, YES); -+ [_drawableCondition lock]; -+ if (_usableDrawablesBitmap != 0) break; -+ if (CACurrentMediaTime() >= waitStart + 1.0) break; - } else { -- [_drawableCondition wait]; -+ if (_allowsNextDrawableTimeout) { -+ if (![_drawableCondition waitUntilDate: endTime]) { -+ break; -+ } -+ } else { -+ [_drawableCondition wait]; -+ } - } - } + if (_allowsNextDrawableTimeout) { + if (![_drawableCondition waitUntilDate: endTime]) { + break; +@@ -380,6 +514,8 @@ static void reportGLErrors(void) { if (_usableDrawablesBitmap == 0) { // timeout reached @@ -9525,7 +9591,7 @@ index b81e2e14..f675d78d 100644 [_drawableCondition unlock]; return nil; } -@@ -400,6 +551,8 @@ static void reportGLErrors(void) { +@@ -400,6 +536,8 @@ static void reportGLErrors(void) { } _usableDrawablesBitmap &= ~(1u << drawableID); @@ -9534,7 +9600,7 @@ index b81e2e14..f675d78d 100644 drawable = _drawables[drawableID]; -@@ -410,20 +563,42 @@ static void reportGLErrors(void) { +@@ -410,20 +548,36 @@ static void reportGLErrors(void) { - (void)queuePresent: (NSUInteger)drawableID { @@ -9552,7 +9618,7 @@ index b81e2e14..f675d78d 100644 - [self display]; + // Present only after the Vulkan/GL synchronization has been prepared, on + // the window thread. display alone does not drive the layer compositor. -+ void (^renderBlock)(void) = ^{ ++ dispatch_async(dispatch_get_main_queue(), ^{ + if (intervalSample) intervalSample->renderStart.store(presentIntervalNow()); + darlingMetalTrace("present-block-runs", 0, 0); + CFTimeInterval blockStart = CACurrentMediaTime(); @@ -9563,13 +9629,7 @@ index b81e2e14..f675d78d 100644 + [self->_context flush]; + if (intervalSample) intervalSample->flushDone.store(presentIntervalNow(), std::memory_order_release); + darlingMetalTiming("mainBlockFlush", CACurrentMediaTime() - renderDone); -+ }; -+ -+ if ([NSThread isMainThread]) { -+ renderBlock(); -+ } else { -+ dispatch_async(dispatch_get_main_queue(), renderBlock); -+ } ++ }); } - (void)releaseDrawable: (NSUInteger)drawableID @@ -9578,7 +9638,7 @@ index b81e2e14..f675d78d 100644 [_drawableCondition lock]; _usableDrawablesBitmap |= 1u << drawableID; [_drawableCondition signal]; -@@ -455,7 +630,7 @@ static void reportGLErrors(void) { +@@ -455,7 +609,7 @@ static void reportGLErrors(void) { } _usableDrawablesBitmap = 0; @@ -9587,7 +9647,7 @@ index b81e2e14..f675d78d 100644 // can't create new drawables [_drawableCondition unlock]; return; -@@ -506,6 +681,10 @@ static void reportGLErrors(void) { +@@ -506,6 +660,10 @@ static void reportGLErrors(void) { glTextureParameteri(_tex, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTextureParameteri(_tex, GL_TEXTURE_WRAP_S, GL_REPEAT); glTextureParameteri(_tex, GL_TEXTURE_WRAP_T, GL_REPEAT); @@ -9598,7 +9658,7 @@ index b81e2e14..f675d78d 100644 reportGLErrors(); } } -@@ -516,6 +695,10 @@ static void reportGLErrors(void) { +@@ -516,6 +674,10 @@ static void reportGLErrors(void) { - (void)prepareRender { std::shared_ptr drawable = nullptr; @@ -9609,7 +9669,7 @@ index b81e2e14..f675d78d 100644 [_drawableCondition lock]; if (_queuedDrawableCount > 0) { -@@ -528,11 +711,62 @@ static void reportGLErrors(void) { +@@ -528,11 +690,62 @@ static void reportGLErrors(void) { if (drawable) { // wait for the drawable to be fully rendered before copying it to the render texture // (this is a GPU-side wait) @@ -9672,7 +9732,7 @@ index b81e2e14..f675d78d 100644 } @synchronized(self) { -@@ -575,6 +809,11 @@ static void reportGLErrors(void) { +@@ -575,6 +788,11 @@ static void reportGLErrors(void) { { [super _setContext: context]; @@ -9684,7 +9744,7 @@ index b81e2e14..f675d78d 100644 if (_tex == 0) { // create our textures now using the context's CGLContext CGLContextObj prev = CGLGetCurrentContext(); -@@ -589,10 +828,15 @@ static void reportGLErrors(void) { +@@ -589,10 +807,15 @@ static void reportGLErrors(void) { glTextureParameteri(_tex, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTextureParameteri(_tex, GL_TEXTURE_WRAP_S, GL_REPEAT); glTextureParameteri(_tex, GL_TEXTURE_WRAP_T, GL_REPEAT); diff --git a/patches/darling.patch b/patches/darling.patch index bd28532..7e50093 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -3557,7 +3557,7 @@ diff --git a/src/frameworks/CoreVideo/src/CVDisplayLink.mm b/src/frameworks/Core index 3e9cba226..99e0a5494 100644 --- a/src/frameworks/CoreVideo/src/CVDisplayLink.mm +++ b/src/frameworks/CoreVideo/src/CVDisplayLink.mm -@@ -1,38 +1,884 @@ +@@ -1,38 +1,887 @@ #include +#include +#include @@ -4381,7 +4381,7 @@ index 3e9cba226..99e0a5494 100644 + if (!cvIntervalNS) { + const char *hz = getenv("RCLOL_REFRESH_HZ"); + int rate = hz ? atoi(hz) : 0; -+ cvIntervalNS = (rate > 0 && rate <= 1000) ? 1000000000ull / (uint64_t)rate : (1000000000ull / 144ull); ++ cvIntervalNS = (rate > 0 && rate <= 1000) ? 1000000000ull / (uint64_t)rate : (1000000000ull / 60ull); + } + for (;;) { + deadline.nsec += cvIntervalNS; @@ -4453,7 +4453,7 @@ index 3e9cba226..99e0a5494 100644 *displayLinkOut = (CVDisplayLinkRef) self; return kCVReturnSuccess; } -@@ -41,35 +881,128 @@ CVReturn CVDisplayLinkStart(CVDisplayLinkRef displayLink) +@@ -41,35 +890,128 @@ CVReturn CVDisplayLinkStart(CVDisplayLinkRef displayLink) { if (!displayLink) return kCVReturnInvalidArgument; @@ -4593,7 +4593,7 @@ index 3e9cba226..99e0a5494 100644 return kCVReturnSuccess; } -@@ -91,7 +1024,7 @@ CVReturn CVDisplayLinkSetCurrentCGDisplayFromOpenGLContext(CVDisplayLinkRef disp +@@ -91,7 +1033,7 @@ CVReturn CVDisplayLinkSetCurrentCGDisplayFromOpenGLContext(CVDisplayLinkRef disp CGDirectDisplayID displayID = [window.screen cgDirectDisplayID]; NSMutableDictionary* self = (NSMutableDictionary*) displayLink; @@ -4602,7 +4602,7 @@ index 3e9cba226..99e0a5494 100644 forKey: kDirectDisplayArray]; return kCVReturnSuccess; } -@@ -117,14 +1050,31 @@ CVReturn CVDisplayLinkCreateWithCGDisplay( +@@ -117,14 +1059,31 @@ CVReturn CVDisplayLinkCreateWithCGDisplay( CGDirectDisplayID displayID, CV_RETURNS_RETAINED_PARAMETER CVDisplayLinkRef CV_NULLABLE * CV_NONNULL displayLinkOut ) { @@ -6257,7 +6257,7 @@ index deca16b87..d75d5e22d 100644 set_property(TARGET OpenGL APPEND_STRING PROPERTY LINK_FLAGS diff --git a/src/frameworks/OpenGL/OpenGL.c b/src/frameworks/OpenGL/OpenGL.c -index ffd7fe054..a74ffc63f 100644 +index ffd7fe054..1ff212d34 100644 --- a/src/frameworks/OpenGL/OpenGL.c +++ b/src/frameworks/OpenGL/OpenGL.c @@ -2,11 +2,22 @@ @@ -6734,7 +6734,7 @@ index ffd7fe054..a74ffc63f 100644 free(context); } -@@ -383,9 +608,331 @@ CGLError CGLUnlockContext(CGLContextObj context) { +@@ -383,9 +608,330 @@ CGLError CGLUnlockContext(CGLContextObj context) { return kCGLNoError; } @@ -6957,8 +6957,7 @@ index ffd7fe054..a74ffc63f 100644 + if(__lolsys_syscall(1,(long)&now,0,0,0,0,228)==0){ + uint64_t current=(uint64_t)now.tv_sec*1000000000ull+now.tv_nsec; + uint64_t deadline=ctx->native_next_present_ns; -+ static uint64_t paceNS = 0; -+ if (!paceNS) { const char *hz = getenv("RCLOL_REFRESH_HZ"); int r = hz ? atoi(hz) : 0; paceNS = (r > 0 && r <= 1000) ? 1000000000ull / (uint64_t)r : 1000000ull; if (paceNS < 1000000ull) paceNS = 1000000ull; } ++ uint64_t paceNS=nativeRefreshIntervalNS(); + if(!deadline || (current>deadline && current-deadline>paceNS))deadline=current; + if(deadline>current){ + struct timespec wake={deadline/1000000000ull,deadline%1000000000ull}; @@ -7068,7 +7067,7 @@ index ffd7fe054..a74ffc63f 100644 } CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, const GLint *value) { -@@ -394,8 +939,9 @@ CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, c +@@ -394,8 +940,9 @@ CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, c if (parameter == kCGLCPSwapInterval) { @@ -7079,7 +7078,7 @@ index ffd7fe054..a74ffc63f 100644 if (success) context->swap_interval = v; return success ? kCGLNoError : kCGLBadValue; -@@ -417,14 +963,19 @@ CGLError CGLGetParameter(CGLContextObj context, CGLContextParameter parameter, G +@@ -417,14 +964,19 @@ CGLError CGLGetParameter(CGLContextObj context, CGLContextParameter parameter, G return kCGLNoError; } @@ -8429,12 +8428,26 @@ new file mode 100644 +} diff --git a/src/frameworks/OpenGL/CGLAsyncPresent.inc b/src/frameworks/OpenGL/CGLAsyncPresent.inc new file mode 100644 +index 000000000..e5363c06a --- /dev/null +++ b/src/frameworks/OpenGL/CGLAsyncPresent.inc -@@ -0,0 +1,197 @@ +@@ -0,0 +1,204 @@ +/* Bounded GPU-only staging: the producer never waits for display cadence. + * Three textures cover the currently displayed, pending, and writing frames. + * GPU fences order both the completed producer copy and subsequent reuse. */ ++ ++/* darling-lol: software refresh period for the paced paths, from RCLOL_REFRESH_HZ ++ * (1-1000 Hz). Unset or out of range keeps the previous 60 Hz period. */ ++static uint64_t nativeRefreshIntervalNS(void){ ++ static uint64_t interval; ++ if(!interval){ ++ const char *hz=getenv("RCLOL_REFRESH_HZ"); ++ long rate=hz?strtol(hz,NULL,10):0; ++ interval=(rate>0&&rate<=1000)?1000000000ull/(uint64_t)rate:16666667ull; ++ } ++ return interval; ++} ++ +struct DarlingAsyncPresent { + CGLContextObj owner; + pthread_mutex_t mutex; @@ -8537,15 +8550,7 @@ new file mode 100644 + /* Native VSync owns the refresh cadence; avoid a second clock that + * can miss the compositor's next refresh after a variable swap wait. */ + if(nativeVSync)continue; -+ /* darling-lol perf: use dynamic refresh interval from RCLOL_REFRESH_HZ -+ * or default to uncapped (1ms minimum interval) when VSync is off. */ -+ static uint64_t refreshNS = 0; -+ if (!refreshNS) { -+ const char *hz = getenv("RCLOL_REFRESH_HZ"); -+ int rate = hz ? atoi(hz) : 0; -+ refreshNS = (rate > 0 && rate <= 1000) ? 1000000000ull / (uint64_t)rate : 1000000ull; -+ if (refreshNS < 1000000ull) refreshNS = 1000000ull; -+ } ++ uint64_t refreshNS=nativeRefreshIntervalNS(); + deadline += refreshNS; + uint64_t now=nativeMonotonicNS(); + /* Preserve the refresh phase across small scheduling delays. Reset only diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index c61e985..63b9a2c 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -1931,7 +1931,7 @@ index 23fd2d3..1a48b81 100644 + return _gpuAddress; }; diff --git a/src/indium/command-buffer.cpp b/src/indium/command-buffer.cpp -index 43f4a84..46a4f72 100644 +index 43f4a84..aaa1acb 100644 --- a/src/indium/command-buffer.cpp +++ b/src/indium/command-buffer.cpp @@ -1,3 +1,8 @@ @@ -2268,7 +2268,7 @@ index 43f4a84..46a4f72 100644 auto encoder = std::make_shared(shared_from_this(), descriptor); { std::scoped_lock lock(_mutex); -@@ -91,10 +354,237 @@ std::shared_ptr Indium::PrivateCommandBuffer::com +@@ -91,10 +354,239 @@ std::shared_ptr Indium::PrivateCommandBuffer::com return computeCommandEncoder(ComputePassDescriptor { {}, dispatchType }); }; @@ -2508,7 +2508,7 @@ index 43f4a84..46a4f72 100644 for (const auto& encoder: _commandEncoders) { if (auto renderEncoder = std::dynamic_pointer_cast(encoder)) { -@@ -110,16 +600,28 @@ void Indium::PrivateCommandBuffer::commit() { +@@ -110,16 +602,28 @@ void Indium::PrivateCommandBuffer::commit() { } } @@ -2538,7 +2538,7 @@ index 43f4a84..46a4f72 100644 // for the event loop auto timelineSemaphore = _privateDevice->getWrappedTimelineSemaphore(); -@@ -138,14 +640,49 @@ void Indium::PrivateCommandBuffer::commit() { +@@ -138,14 +642,49 @@ void Indium::PrivateCommandBuffer::commit() { readWriteTextures.insert(readWriteTextures.end(), readWrite.begin(), readWrite.end()); } @@ -2591,7 +2591,7 @@ index 43f4a84..46a4f72 100644 } std::vector signalInfos; -@@ -202,11 +739,13 @@ void Indium::PrivateCommandBuffer::commit() { +@@ -202,11 +741,13 @@ void Indium::PrivateCommandBuffer::commit() { auto privateTexture = std::dynamic_pointer_cast(texture); const auto& presentSema = presentationSemaphores[i]; @@ -2610,7 +2610,7 @@ index 43f4a84..46a4f72 100644 handleTextureSemaphores(privateTexture); } -@@ -215,7 +754,15 @@ void Indium::PrivateCommandBuffer::commit() { +@@ -215,7 +756,15 @@ void Indium::PrivateCommandBuffer::commit() { // but it also leaves the resources for this command buffer tied up, essentially becoming a memory leak. // i've observed this in the cube example, and it happens more than once (because the example display semaphore is exhausted and never signaled). // UPDATE: upon further testing, it seems that this only occurs when the view is off-screen/hidden. weird. @@ -2627,7 +2627,7 @@ index 43f4a84..46a4f72 100644 { std::unique_lock lock(self->_mutex); self->_completed = true; -@@ -234,8 +781,47 @@ void Indium::PrivateCommandBuffer::commit() { +@@ -234,8 +783,47 @@ void Indium::PrivateCommandBuffer::commit() { // the handlers may have captured a reference to us (from their surrounding scope), so clear out handlers so those references go away self->_scheduledHandlers.clear(); self->_completedHandlers.clear(); @@ -2675,7 +2675,7 @@ index 43f4a84..46a4f72 100644 commandBufferInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO; commandBufferInfo.commandBuffer = _commandBuffer; -@@ -252,23 +838,41 @@ void Indium::PrivateCommandBuffer::commit() { +@@ -252,23 +840,41 @@ void Indium::PrivateCommandBuffer::commit() { // the Device constructor tries to choose command queues that support as many operations as possible, but it's possible // that a particular device only supports certain operations on certain queues (e.g. maybe it only supports transfer operations // on an exclusive queue that doesn't support graphics or compute). @@ -2722,7 +2722,7 @@ index 43f4a84..46a4f72 100644 }; void Indium::PrivateCommandBuffer::presentDrawable(std::shared_ptr drawable) { -@@ -307,9 +911,12 @@ void Indium::PrivateCommandBuffer::addCompletedHandler(std::function 0 ? (time_t)(option_time / 1000000) : 0, -+ .tv_nsec = option_time > 0 ? (long)((option_time % 1000000) * 1000) : 1000000 ++ .tv_sec = option_time / 1000, ++ .tv_nsec = (long) (option_time % 1000) * 1000000 }; +- // Sleep for 1ms +- __linux_nanosleep(&tv, &tv); + + /* darling-lol: opt-in trace (host file /tmp/rclol-select-trace) of who calls thread_switch and with which option: + * every 50th call writes thread id, option, time and up to 6 caller addresses (frame-pointer walk) to @@ -5937,18 +5954,16 @@ index b998456..2783a4a 100644 + } + } + -- // Sleep for 1ms -- __linux_nanosleep(&tv, &tv); -+ /* darling-lol perf: cooperative yield (option_time == 0) should not sleep -+ * for a full millisecond — that is longer than many frame budgets at high -+ * refresh rates. Use sched_yield for cooperative yields, and only sleep -+ * when a timed wait was explicitly requested. */ -+ if (option_time == 0) { -+ __linux_sched_yield_site("THRSWT"); -+ } else { ++ /* darling-lol perf: a plain yield (option_time == 0) only gives up the CPU; sleeping a ++ * full millisecond there is longer than a frame budget at high refresh rates. Timed ++ * switches (spin locks pass 1 ms) keep sleeping for the requested time. */ ++ if (option_time == 0) ++ __linux_sched_yield_site("THRSW"); ++ else + __linux_nanosleep(&tv, &tv); -+ } + // TODO: we could implement this with yield_to() in LKM + return KERN_SUCCESS; diff --git a/libsyscall/CMakeLists.txt b/libsyscall/CMakeLists.txt index 57cebad..3014255 100755 --- a/libsyscall/CMakeLists.txt diff --git a/scripts/build.sh b/scripts/build.sh index 4a5a35a..6113553 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -12,9 +12,12 @@ python3 scripts/prepare.py # darling-lol perf: inject -O3 and -march=native even with empty build type, # so the base Darling runtime is not compiled at -O0. The empty build type # is required by objc4's debug-ness macro check. +# -mtune=generic, not native: clang 23 tuning for AMD Zen (znver*) passes the thread-local +# variable descriptor in the wrong register on Darwin targets, so every __thread access +# returns NULL (launchd crashed in libkqueue's epoll_event_dump). cmake -S build/darling-src -B build/darling-src/build -G 'Unix Makefiles' \ -DCMAKE_BUILD_TYPE= -DTARGET_i386=OFF -DCMAKE_INSTALL_PREFIX=/usr/local -DENABLE_METAL=ON \ - -DCMAKE_C_FLAGS="-O3 -march=native -mtune=native -pipe" \ - -DCMAKE_CXX_FLAGS="-O3 -march=native -mtune=native -pipe" + -DCMAKE_C_FLAGS="-O3 -march=native -mtune=generic -pipe" \ + -DCMAKE_CXX_FLAGS="-O3 -march=native -mtune=generic -pipe" cmake --build build/darling-src/build --parallel "${JOBS:-$(nproc)}" echo 'Build complete. Install the base runtime with: sudo cmake --install build/darling-src/build' diff --git a/scripts/install-runtime.sh b/scripts/install-runtime.sh index 278244e..ad26e21 100755 --- a/scripts/install-runtime.sh +++ b/scripts/install-runtime.sh @@ -5,6 +5,8 @@ repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" python3 scripts/configure.py if [ -d build/venv/bin ]; then PATH="$repo/build/venv/bin:$PATH"; export PATH; fi +# A server killed by the base install leaves a stale .init.pid that makes darling shell fail. +python3 build/runtime/tools/ensure-darling-runtime.py # Initialize the normal user-owned prefix before installing overrides. darling shell /usr/bin/true # The prefix has no /private/tmp of its own, so the guest falls through to the root-owned @@ -20,7 +22,7 @@ if [ -f build/runtime/tools/build-riot-libs-runtime.sh ]; then python3 build/runtime/tools/install-riot-libs-runtime.py fi python3 build/runtime/tools/install-darling-system-profiler.py -# darling-lol: install GNOME application launcher and icon. -if [ -f build/runtime/tools/riot-desktop-entry.py ]; then - python3 build/runtime/tools/riot-desktop-entry.py || true -fi +# Every guest process loads these overlays; refuse a set the compiler broke (check-darwin-tlv-calls.py). +find "$HOME/.darling/Users/$(id -un)/RuntimeLibraries" "$HOME/.darling/Users/$(id -un)/RuntimeFrameworks" \ + -type f ! -name '*.before-*' ! -name '*.rt-*' ! -name '*.llvm*' -size +4k -print0 | + xargs -0 python3 build/runtime/tools/check-darwin-tlv-calls.py diff --git a/scripts/installer.sh b/scripts/installer.sh index 0d19df5..c7520eb 100755 --- a/scripts/installer.sh +++ b/scripts/installer.sh @@ -20,5 +20,4 @@ if [ ! -d "$prefix/Users/Shared/Riot Games/Riot Client.app/Contents/MacOS" ]; th echo "$prefix/Users//Library/Logs/Riot Games/Install League of Legends $region/Launcher" >&2 exit 1 fi -python3 build/runtime/tools/riot-desktop-entry.py || true exec sh scripts/run.sh diff --git a/scripts/validate.py b/scripts/validate.py index a944813..df0584b 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -6,7 +6,7 @@ from common import ROOT subprocess.run([sys.executable,str(ROOT/'scripts/configure.py')],check=True) try: files=subprocess.check_output(['git','-C',str(ROOT),'ls-files','-z'],stderr=subprocess.DEVNULL).decode().split('\0') -except Exception: +except (OSError, subprocess.CalledProcessError): files=[str(p.relative_to(ROOT)) for p in ROOT.rglob('*') if p.is_file() and not any(part.startswith('.') or part in ['build','__pycache__'] for part in p.relative_to(ROOT).parts)] errors=[] secret_patterns=[r'gh[pousr]_[A-Za-z0-9]{30,}',r'AKIA[0-9A-Z]{16}',r'eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}',r'-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----'] @@ -31,6 +31,10 @@ manifest=json.loads((ROOT/'upstream/manifest.json').read_text()) for c in manifest['components']: if not re.fullmatch('[0-9a-f]{40}',c['revision']):errors.append(c['path']+': invalid pinned revision') if not (ROOT/c['patch']).is_file():errors.append(c['path']+': patch missing') + else: + parsed=subprocess.run(['git','apply','--numstat','-'],input=(ROOT/c['patch']).read_text(), + text=True,capture_output=True) + if parsed.returncode:errors.append(c['patch']+': '+parsed.stderr.strip()) if errors: print('\n'.join(errors));raise SystemExit(1) print('PASS: tracked source audit, rendered Python/shell syntax and',len(manifest['components']),'pinned patch entries') diff --git a/tools/build-riot-appkit-runtime.py b/tools/build-riot-appkit-runtime.py index d2e5833..c4a677d 100644 --- a/tools/build-riot-appkit-runtime.py +++ b/tools/build-riot-appkit-runtime.py @@ -15,7 +15,7 @@ link = shlex.split((framework / 'CMakeFiles/AppKit.dir/link.txt').read_text()) for name in ['NSResponder', 'NSApplication', 'NSWindow', 'NSDisplay', 'NSView', 'NSThemeFrame', 'NSTextInput', 'NSBitmapImageRep', 'NSEvent.subproj/NSEvent_mouse']: obj = project / f'build/riot-{Path(name).name}.o' subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], - '-O3', '-march=native', '-mtune=native', '-Wno-macro-redefined', '-c', + '-O3', '-march=native', '-mtune=generic', '-Wno-macro-redefined', '-c', str(project / f'build/darling-src/src/external/cocotron/AppKit/{name}.m'), '-o', str(obj)], check=True) link[link.index(f'CMakeFiles/AppKit.dir/{name}.m.o')] = str(obj) @@ -24,7 +24,7 @@ subprocess.run(link, cwd=framework, check=True) for name in ['riot-event-coalescing-check', 'riot-scroll-event-check', 'riot-appkit-pump-check', 'riot-window-layout-check']: probe = project / f'build/{name}.o' subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], - '-O3', '-march=native', '-mtune=native', '-fblocks', '-Wno-macro-redefined', '-c', + '-O3', '-march=native', '-mtune=generic', '-fblocks', '-Wno-macro-redefined', '-c', str(project / f'tools/{name}.m'), '-o', str(probe)], check=True) subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', '-isysroot', '@DARLING_SDK@', str(probe), '-lSystem', diff --git a/tools/build-riot-cxx-optimized.py b/tools/build-riot-cxx-optimized.py index 00e354c..191780e 100644 --- a/tools/build-riot-cxx-optimized.py +++ b/tools/build-riot-cxx-optimized.py @@ -26,7 +26,7 @@ def build(lib, objdir, linkdir, output): jobs.append((rel, obj)); link[i] = str(obj) def compile_one(job): rel, obj = job - subprocess.run(['clang++', *flags['CXX_DEFINES'], *flags['CXX_INCLUDES'], *flags['CXX_FLAGS'], '-O3', '-march=native', '-mtune=native', '-fomit-frame-pointer', '-DNDEBUG', + subprocess.run(['clang++', *flags['CXX_DEFINES'], *flags['CXX_INCLUDES'], *flags['CXX_FLAGS'], '-O3', '-march=native', '-mtune=generic', '-DNDEBUG', '-c', str(src / rel), '-o', str(obj)], check=True) with ThreadPoolExecutor(max_workers=os.cpu_count() or 8) as pool: list(pool.map(compile_one, jobs)) diff --git a/tools/build-riot-cxx-runtime.py b/tools/build-riot-cxx-runtime.py index 572724e..51370fa 100644 --- a/tools/build-riot-cxx-runtime.py +++ b/tools/build-riot-cxx-runtime.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Rebuild libc++ and libc++abi with -O2. +"""Rebuild libc++ and libc++abi with -O3 -march=native -mtune=generic. The CMake tree has no build type, so both libraries are clang's -O0 output: the game imports 131 functions from libc++.1.dylib (out-of-line std::string, @@ -54,7 +54,7 @@ def build(library, object_dir, link_dir, output): relative, obj = job cxx_flags = [f for f in flags["CXX_FLAGS"] if f not in ("-g", "-O0", "-O1", "-O2")] subprocess.run(["clang++", *flags["CXX_DEFINES"], *flags["CXX_INCLUDES"], *cxx_flags, - "-O3", "-march=native", "-mtune=native", "-fomit-frame-pointer", "-fno-strict-aliasing", + "-O3", "-march=native", "-mtune=generic", "-fno-strict-aliasing", "-c", str(source / relative), "-o", str(obj)], check=True, capture_output=True) diff --git a/tools/build-riot-kqueue-runtime.py b/tools/build-riot-kqueue-runtime.py index 53ccfb4..397c2ae 100644 --- a/tools/build-riot-kqueue-runtime.py +++ b/tools/build-riot-kqueue-runtime.py @@ -19,6 +19,7 @@ def configured_flags(directory): if line.startswith('C_') and ' = ' in line: key,value=line.split(' = ',1) result[key]=[re.sub(r'/usr/lib/clang/\d+/include',resource_dir+'/include',v) for v in shlex.split(value)] + if key == 'C_FLAGS': result[key].append('-mtune=generic') return result kqueue_flags=configured_flags(build/'src/external/libkqueue/CMakeFiles/kqueue.dir') emulation_flags=configured_flags(kernel/'emulation/CMakeFiles/emulation.dir') @@ -29,8 +30,8 @@ syscall_flags=configured_flags(kernel/'libsyscall/CMakeFiles/libsyscall.dir') kqueue_flags={**kqueue_flags,'C_FLAGS':[*kqueue_flags['C_FLAGS'],'-O2','-fno-strict-aliasing']} kqueue_sources=[value.split('/kqueue.dir/',1)[1][:-2] for value in objects if '/kqueue.dir/' in value and value.endswith('.c.o')] assert kqueue_sources, 'Configured kqueue objects missing' -# The configured emulation target is a plain development build: its flags.make -# carries no -O flag, so every object recompiled with `emulation_flags` is -O0. +# The configured emulation flags carry scripts/build.sh's -O3; the -O2 appended below +# comes last and wins, and the stack-layout-dependent files below pin -O0. # These files are on the per-call path of the guest (BSD trap dispatch, read/ # write, network, kqueue, psynch/ulock, clocks, madvise), so rebuild them # optimized. -fno-strict-aliasing matches the other optimized rebuilds in this @@ -56,13 +57,16 @@ repairs += [('src/xnu_syscall/bsd/impl/'+name,repair_flags, # configured forced-include header, without changing unrelated kernel objects. emulation_root=project/'build/darling-src/src/external/xnu/darling/src/libsystem_kernel/emulation' rpc_flags={**emulation_flags,'C_FLAGS':[*emulation_flags['C_FLAGS'],'-O2','-fno-omit-frame-pointer']} -# bsdthread_register.c keeps emulation_flags (-O0): its pthread/wqueue entry +# bsdthread_register.c stays at -O0: its pthread/wqueue entry # wrappers jump into _thread_start/_start_wqthread through inline assembly that # sets %rsp from the thread argument, i.e. it depends on the stack layout. # Signal entry with stack-layout-dependent frame walking stays unoptimized on -# purpose (emulation_flags): it runs on the fault/suspend signal stacks. -repairs += [('src/xnu_syscall/bsd/impl/bsdthread/bsdthread_register.c',emulation_flags,emulation_root)] -repairs += [('src/linux_premigration/signal/sigexc.c',emulation_flags,emulation_root)] +# purpose: it runs on the fault/suspend signal stacks. +# The configured flags are optimized now (scripts/build.sh), so pin -O0 explicitly, as the +# emulation CMakeLists does for these two files. +unoptimized_flags={**emulation_flags,'C_FLAGS':[*emulation_flags['C_FLAGS'],'-O0']} +repairs += [('src/xnu_syscall/bsd/impl/bsdthread/bsdthread_register.c',unoptimized_flags,emulation_root)] +repairs += [('src/linux_premigration/signal/sigexc.c',unoptimized_flags,emulation_root)] repairs += [('src/xnu_syscall/mach/impl/mach_traps.c',rpc_flags,emulation_root)] # User-space clock (per-CPU TSC calibration), its gettimeofday entry and the cached kern.boottime. repairs += [('src/linux_premigration/ext/clock_gettime.c',rpc_flags,emulation_root)] diff --git a/tools/build-riot-libc-runtime.py b/tools/build-riot-libc-runtime.py index 1ff53c6..ea28cc6 100644 --- a/tools/build-riot-libc-runtime.py +++ b/tools/build-riot-libc-runtime.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Rebuild libsystem_c (libc) with -O2. +"""Rebuild libsystem_c (libc) with -O3 -march=native -mtune=generic. The CMake tree is configured with an empty build type, so the installed libsystem_c.dylib is clang's -O0 output except for the three string files @@ -7,7 +7,7 @@ libplatform marks optimized. The game imports 122 functions from this library (stdio, string, stdlib, locale, time) and it is on the path of every frame; this builder takes each object's own compile command out of the generated build.make, keeps every define, include and per-file option it has, replaces -the optimization level with -O2 and relinks the result with the configured +the optimization level with -O3 and relinks the result with the configured link line. -fno-strict-aliasing matches what the existing optimized rebuilds use for this ten-year-old BSD C. @@ -75,7 +75,7 @@ def source_of(head, relative): def compile_command(target_folder, source, object_path): """The configured compile command for one object, with the object path and - the dependency flags taken out and -O2 -fno-strict-aliasing added.""" + the dependency flags taken out and -O3 -fno-strict-aliasing added.""" for line in (target_folder / "build.make").read_text().splitlines(): if "$(C_DEFINES)" not in line or " -c " not in line or str(source) not in line: continue @@ -101,7 +101,7 @@ def compile_command(target_folder, source, object_path): continue cleaned.append(token) cleaned = [t for t in cleaned if t not in ("-g", "-O0", "-O1", "-O2")] - cleaned.extend(["-O3", "-march=native", "-mtune=native", "-fno-strict-aliasing", "-fomit-frame-pointer"]) + cleaned.extend(["-O3", "-march=native", "-mtune=generic", "-fno-strict-aliasing"]) return cleaned return None diff --git a/tools/build-riot-libs-runtime.sh b/tools/build-riot-libs-runtime.sh index efdf3c6..609f593 100755 --- a/tools/build-riot-libs-runtime.sh +++ b/tools/build-riot-libs-runtime.sh @@ -1,7 +1,7 @@ #!/bin/sh # Build every overlay of the optimized library batch, in order. # -# Each builder recompiles the component's own objects at -O2 with the flags +# Each builder recompiles the component's own objects at -O3 -march=native -mtune=generic with the flags # the configured build gives them and relinks with the configured link line; # none of them runs a full cmake build. The builders stop while a League game # process is running, because a compile job would skew the FPS the user is diff --git a/tools/build-riot-platform-strings.py b/tools/build-riot-platform-strings.py index a4a1732..848c6d5 100644 --- a/tools/build-riot-platform-strings.py +++ b/tools/build-riot-platform-strings.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Optimize configured string helpers, preserving every other platform object. -Every src/string/generic source is recompiled at -O2 -fno-builtin (and +Every src/string/generic source is recompiled at -O3 -fno-builtin (and -fno-strict-aliasing for this old BSD C) with the object library's own defines and includes, then relinked into build/riot-libsystem_platform.dylib. The AVX2 and REP paths for memcpy/memmove/memset/bzero/memcmp/memchr live in those same @@ -28,7 +28,7 @@ for original in list(link): relative=original.split('platform_obj.dir/',1)[1].removesuffix('.o') obj=root/('build/riot-platform-'+Path(relative).name+'.o') c_flags = [f for f in flags['C_FLAGS'] if f not in ('-g', '-O0', '-O1', '-O2')] - subprocess.run(['clang',*flags['C_DEFINES'],*flags['C_INCLUDES'],*c_flags,'-O3','-march=native','-mtune=native','-fomit-frame-pointer','-fno-builtin','-fno-strict-aliasing','-c',str(root/'build/darling-src/src/external/libplatform'/relative),'-o',str(obj)],check=True) + subprocess.run(['clang',*flags['C_DEFINES'],*flags['C_INCLUDES'],*c_flags,'-O3','-march=native','-mtune=generic','-fno-builtin','-fno-strict-aliasing','-c',str(root/'build/darling-src/src/external/libplatform'/relative),'-o',str(obj)],check=True) link[link.index(original)]=str(obj) recompiled+=1 link[link.index('-o')+1]=str(root/'build/riot-libsystem_platform.dylib') diff --git a/tools/build-riot-x11-runtime.py b/tools/build-riot-x11-runtime.py index df1cd82..3068865 100644 --- a/tools/build-riot-x11-runtime.py +++ b/tools/build-riot-x11-runtime.py @@ -16,7 +16,7 @@ for line in (framework / 'CMakeFiles/X11_backend.dir/flags.make').read_text().sp link = shlex.split((framework / 'CMakeFiles/X11_backend.dir/link.txt').read_text()) for name in ('X11Display', 'X11Window'): obj = project / f'build/riot-{name}.o' - subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], '-O3', '-march=native', '-mtune=native', '-Wno-macro-redefined', '-c', str(project / f'build/darling-src/src/external/cocotron/AppKit/X11.backend/{name}.m'), '-o', str(obj)], check=True) + subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], '-O3', '-march=native', '-mtune=generic', '-Wno-macro-redefined', '-c', str(project / f'build/darling-src/src/external/cocotron/AppKit/X11.backend/{name}.m'), '-o', str(obj)], check=True) link[link.index(f'CMakeFiles/X11_backend.dir/X11.backend/{name}.m.o')] = str(obj) link[link.index('-o') + 1] = str(project / 'build/riot-X11') subprocess.run(link, cwd=framework, check=True) @@ -25,9 +25,27 @@ print('Built isolated X11 backend') # Exercise actual backend modifier transitions without compositor shortcuts. probe = project / 'build/riot-modifier-check.o' subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], - '-O3', '-march=native', '-mtune=native', '-Wno-macro-redefined', '-c', str(project / 'tools/riot-modifier-check.m'), + '-O3', '-march=native', '-mtune=generic', '-Wno-macro-redefined', '-c', str(project / 'tools/riot-modifier-check.m'), '-o', str(probe)], check=True) subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-framework', 'AppKit', '-framework', 'Foundation', '-lobjc', '-o', str(project / 'build/riot-modifier-check')], check=True) + +# Verify that cached Chromium presentation cannot cover a native game drawable. +probe = project / 'build/riot-remote-frame-ownership-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O3', '-march=native', '-mtune=generic', '-UNDEBUG', '-Wno-macro-redefined', '-c', + str(project / 'tools/riot-remote-frame-ownership-check.m'), '-o', str(probe)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', + '-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', + '-framework', 'Foundation', '-framework', 'CoreFoundation', + '-o', str(project / 'build/riot-remote-frame-ownership-check')], check=True) + +probe = project / 'build/riot-fullscreen-geometry-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O3', '-march=native', '-mtune=generic', '-UNDEBUG', '-Wno-macro-redefined', '-c', + str(project / 'tools/riot-fullscreen-geometry-check.m'), '-o', str(probe)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', + '-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', '-framework', 'Foundation', + '-o', str(project / 'build/riot-fullscreen-geometry-check')], check=True) diff --git a/tools/check-darwin-tlv-calls.py b/tools/check-darwin-tlv-calls.py new file mode 100644 index 0000000..c23a4f2 --- /dev/null +++ b/tools/check-darwin-tlv-calls.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Fail if a Mach-O binary calls a thread-local variable's thunk without its descriptor in %rdi. + +dyld's tlv_get_addr reads the descriptor from %rdi. clang 23 tuning for AMD Zen (-mtune=znver*, +also implied by -march=native on Zen) can load the descriptor into another register and call +through it, leaving %rdi unrelated: the thunk then returns NULL and the first write crashes. +Usage: check-darwin-tlv-calls.py BINARY... ; exits 1 and lists every bad access it finds. +""" +import re +import subprocess +import sys + + +def thread_vars_ranges(path): + """Address ranges of the __thread_vars sections, which hold the TLV descriptors.""" + output = subprocess.run(['llvm-objdump', '--macho', '--section-headers', path], + capture_output=True, text=True, check=True).stdout + ranges = [] + for line in output.splitlines(): + fields = line.split() + if len(fields) >= 4 and fields[1] == '__thread_vars': + start, size = int(fields[3], 16), int(fields[2], 16) + ranges.append((start, start + size)) + return ranges + + +def bad_accesses(path): + with open(path, 'rb') as binary: + if binary.read(4) not in (b'\xcf\xfa\xed\xfe', b'\xca\xfe\xba\xbe'): + return [] + ranges = thread_vars_ranges(path) + if not ranges: + return [] + disassembly = subprocess.run(['llvm-objdump', '-d', '--no-show-raw-insn', path], + capture_output=True, text=True, check=True).stdout + load = re.compile(r'(?:leaq|movq)\s+-?0x[0-9a-f]+\(%rip\), %(\w+)\s+#+ 0x([0-9a-f]+)') + found, function, pending = [], '?', {} + for line in disassembly.splitlines(): + if line.endswith('>:'): + function, pending = line.split('<', 1)[-1][:-2], {} + continue + match = load.search(line) + if match and any(start <= int(match[2], 16) < end for start, end in ranges): + pending[match[1]] = line.strip() + continue + call = re.search(r'callq\s+\*\(%(\w+)\)', line) + if call and call[1] in pending: + if call[1] != 'rdi': + found.append('%s: %s | %s' % (function, pending[call[1]], line.strip())) + pending.pop(call[1]) + return found + + +status = 0 +for binary in sys.argv[1:]: + problems = bad_accesses(binary) + if problems: + status = 1 + print('%s: %d thread-local access(es) without the descriptor in %%rdi' % (binary, len(problems))) + for problem in problems[:20]: + print(' ' + problem) +sys.exit(status) diff --git a/tools/ensure-darling-runtime.py b/tools/ensure-darling-runtime.py index 981c0a9..5e131b4 100644 --- a/tools/ensure-darling-runtime.py +++ b/tools/ensure-darling-runtime.py @@ -1,60 +1,96 @@ #!/usr/bin/env python3 -"""Clear only a dead Darling server's stale PID marker and buffers before startup.""" +"""Clear a dead Darling server's stale PID marker, and stale shared buffers once no server runs.""" import os from pathlib import Path -import subprocess +import signal import time - -prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))) -marker = prefix / '.init.pid' - -server_running = False -if marker.exists(): +def recover_marker(prefix): + marker = prefix / '.init.pid' try: pid = int(marker.read_text().strip()) - if pid > 0: - status = Path('/proc') / str(pid) / 'status' - try: - state = next(line.split()[1] for line in status.read_text().splitlines() if line.startswith('State:')) - dead = state == 'Z' - except FileNotFoundError: - dead = True - except (OSError, StopIteration): - dead = False - - if dead: - os.replace(marker, prefix / f'.init.pid.stale-{pid}-{time.time_ns()}') - print(f'Recovered stale Darling server marker for PID {pid}', flush=True) - else: - server_running = True except (OSError, ValueError): - pass + pid = 0 + if pid > 0: + status = Path('/proc') / str(pid) / 'status' + try: + state = next(line.split()[1] for line in status.read_text().splitlines() if line.startswith('State:')) + dead = state == 'Z' + except FileNotFoundError: + dead = True + except (OSError, StopIteration): + dead = False + if dead: + os.replace(marker, prefix / f'.init.pid.stale-{pid}-{time.time_ns()}') + print(f'Recovered stale Darling server marker for PID {pid}', flush=True) -# Only clean up stale Riot temporary files and shared memory frames if the server is NOT running -if not server_running: - try: - subprocess.run(['killall', '-q', 'chrome_crashpad_handler'], check=False) - except Exception: - pass +def any_server_running(proc_root=Path('/proc')): + """True if any darlingserver runs, for any prefix: /dev/shm buffers are not per prefix.""" + for proc in proc_root.iterdir(): + if not proc.name.isdigit(): + continue + try: + if (proc / 'comm').read_text().strip() == 'darlingserver': + return True + except PermissionError: + # A hidden process might be a server; shared buffers are global, so fail closed. + return True + except OSError: + pass + return False - try: - tmp_dir = prefix / 'private/tmp' - if tmp_dir.is_dir(): - for f in tmp_dir.glob('.com.riotgames.*'): - try: - f.unlink(missing_ok=True) - except OSError: - pass - except OSError: - pass +def stop_orphaned_launchd(proc_root=Path('/proc')): + """A guest launchd that outlived its darlingserver cannot be shut down with `darling shutdown` + and makes the base install (tools/shutdown-user.sh) refuse to continue.""" + uid = os.getuid() + for proc in proc_root.iterdir(): + if not proc.name.isdigit(): + continue + try: + if proc.stat().st_uid != uid or (proc / 'cmdline').read_bytes().rstrip(b'\0') != b'/sbin/launchd': + continue + os.kill(int(proc.name), signal.SIGKILL) + print(f'Stopped orphaned Darling launchd PID {proc.name}', flush=True) + except (OSError, ValueError): + pass - try: - shm_dir = Path('/dev/shm') - if shm_dir.is_dir(): - for f in shm_dir.glob('darling-*'): - try: - f.unlink(missing_ok=True) - except OSError: - pass - except OSError: - pass +def stop_riot_crashpad(proc_root=Path('/proc')): + """A Riot crash handler outliving its server holds the old session's ports; spare every other app's.""" + uid = os.getuid() + for proc in proc_root.iterdir(): + if not proc.name.isdigit(): + continue + try: + if not (proc / 'comm').read_text().startswith('chrome_crashpad') or proc.stat().st_uid != uid: + continue + argv = (proc / 'cmdline').read_bytes().split(b'\0') + executable = argv[0].decode(errors='replace') + # Match the executable's bundle, not arbitrary arguments (a browser may mention Riot). + if any(bundle in executable for bundle in ( + '/Riot Client.app/', '/League of Legends.app/', '/League of Legends (TM) Client.app/' + )): + os.kill(int(proc.name), signal.SIGTERM) + except (OSError, ValueError): + pass + +def remove_owned(paths): + uid = os.getuid() + for path in paths: + try: + if path.is_file() and path.stat().st_uid == uid: + path.unlink() + except OSError: + pass + +def main(): + prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))) + recover_marker(prefix) + # Frame and present buffers left by a killed session confuse the next one's publishers. + if not any_server_running(): + stop_orphaned_launchd() + stop_riot_crashpad() + remove_owned((prefix / 'private/tmp').glob('.com.riotgames.*')) + remove_owned(Path('/dev/shm').glob('darling-*')) + + +if __name__ == '__main__': + main() diff --git a/tools/install-riot-gpu-runtime.sh b/tools/install-riot-gpu-runtime.sh index 68f887f..d7baf18 100755 --- a/tools/install-riot-gpu-runtime.sh +++ b/tools/install-riot-gpu-runtime.sh @@ -34,6 +34,8 @@ python3 tools/stop-darling-runtime.py # Host fileports own SCM_RIGHTS descriptors and must die with their last sender. if sudo -n true 2>/dev/null; then sudo make -C "$project_dir/build/darling-src/build/src/external/darlingserver" install -j12 +else + echo 'Skipped the darlingserver install (needs sudo); the base install already ships it: sudo cmake --install build/darling-src/build' >&2 fi # Electron's window-visibility correction lives in the AppKit overlay # (NSWindow -occlusionState, cocotron.patch); no Riot binary is modified here. diff --git a/tools/install-riot-presentation-runtime.sh b/tools/install-riot-presentation-runtime.sh index e57e173..d0078f8 100644 --- a/tools/install-riot-presentation-runtime.sh +++ b/tools/install-riot-presentation-runtime.sh @@ -12,11 +12,11 @@ make -C "$build_dir/src/external/cfnetwork/src" CFNetwork -j8 make -C "$build_dir/src/external/configd/SystemConfiguration.fproj" SystemConfiguration -j8 make -C "$build_dir/src/external/cocotron/QuartzCore" QuartzCore -j8 make -C "$build_dir/src/external/cocotron/AppKit" AppKit X11_backend -j8 -cc -shared -fPIC -O3 -march=native -mtune=native -Wall -Wextra \ +cc -shared -fPIC -O3 -march=native -mtune=generic -Wall -Wextra \ "-Wl,-soname,$project_dir/build/libdarling-xshm.so" \ "$project_dir/tools/m58f299-xshm.c" -o "$project_dir/build/libdarling-xshm.next.so" \ -pthread $(pkg-config --cflags --libs x11 x11-xcb xcb-shm xext) -clang -target x86_64-apple-macosx11.0 -fuse-ld=lld -dynamiclib -nostdlib -O3 -march=native -mtune=native \ +clang -target x86_64-apple-macosx11.0 -fuse-ld=lld -dynamiclib -nostdlib -O3 -march=native -mtune=generic \ -isysroot @DARLING_SDK@ \ -I"$source_dir/src/startup/mldr/elfcalls" \ "-DDARLING_XSHM_HOST_LIBRARY=\"$project_dir/build/libdarling-xshm.so\"" \ diff --git a/tools/riot-desktop-entry.py b/tools/riot-desktop-entry.py index 477b575..1626937 100755 --- a/tools/riot-desktop-entry.py +++ b/tools/riot-desktop-entry.py @@ -173,7 +173,7 @@ StartupNotify=false target_desktop_dirs.append(d) except Exception: pass - for cand in [HOST_HOME / "Desktop", HOST_HOME / "Asztal"]: + for cand in [HOST_HOME / "Desktop"]: if cand.is_dir() and cand not in target_desktop_dirs: target_desktop_dirs.append(cand) diff --git a/tools/riot-display-geometry-check.m b/tools/riot-display-geometry-check.m index a457890..c576662 100644 --- a/tools/riot-display-geometry-check.m +++ b/tools/riot-display-geometry-check.m @@ -111,7 +111,7 @@ int main(void) { assert(relative->virtualX == 3940 && relative->virtualY == 1010); NSPoint location = [d mouseLocation]; assert(location.x == 100 && location.y == 1960); - X11RelativeMouseMotion motion = X11RelativeMouseHandleMotion(relative,3907,1009,false); + X11RelativeMouseMotion motion = X11RelativeMouseHandleMotion(relative,3907,1009,false,NextRequest([d display])); assert(motion.deltaX == 7 && motion.deltaY == 9); X11RelativeMouseEnd(relative); assert(CGWarpMouseCursorPosition(CGPointMake(100,200)) == 0); diff --git a/tools/riot-fullscreen-geometry-check.m b/tools/riot-fullscreen-geometry-check.m new file mode 100644 index 0000000..08bb282 --- /dev/null +++ b/tools/riot-fullscreen-geometry-check.m @@ -0,0 +1,46 @@ +/* Read-only XRandR geometry probe: never creates, maps or changes a window. */ +#import +#include +#include +#include +#include +#include +int main(int argc, char **argv) { + @autoreleasepool { + assert(argc == 2); + void *backend = dlopen(argv[1], RTLD_LAZY); + assert(backend); + BOOL (*covers)(Display *, NSRect) = (BOOL (*)(Display *, NSRect))dlsym(backend,"X11RootRectCoversMonitor"); + assert(covers); + assert(!covers(NULL, NSMakeRect(0,0,1,1))); + assert(!covers(NULL, NSMakeRect(0,0,0,0))); + void *xlib = dlopen("/usr/lib/native/libX11.dylib", RTLD_LAZY); + void *randr = dlopen("/usr/lib/native/libXRandR.dylib", RTLD_LAZY); + assert(xlib && randr); + Display *(*openDisplay)(const char *) = dlsym(xlib,"XOpenDisplay"); + int (*closeDisplay)(Display *) = dlsym(xlib,"XCloseDisplay"); + Window (*rootWindow)(Display *) = dlsym(xlib,"XDefaultRootWindow"); + XRRScreenResources *(*getResources)(Display *,Window) = dlsym(randr,"XRRGetScreenResourcesCurrent"); + XRRCrtcInfo *(*getCrtc)(Display *,XRRScreenResources *,RRCrtc) = dlsym(randr,"XRRGetCrtcInfo"); + void (*freeCrtc)(XRRCrtcInfo *) = dlsym(randr,"XRRFreeCrtcInfo"); + void (*freeResources)(XRRScreenResources *) = dlsym(randr,"XRRFreeScreenResources"); + assert(openDisplay && closeDisplay && rootWindow && getResources && getCrtc && freeCrtc && freeResources); + Display *d = openDisplay(NULL); assert(d); + XRRScreenResources *resources = getResources(d,rootWindow(d)); assert(resources); + unsigned checked = 0; + for (int i=0; incrtc; ++i) { + XRRCrtcInfo *c = getCrtc(d,resources,resources->crtcs[i]); assert(c); + if (c->mode) { + assert(covers(d,NSMakeRect(c->x,c->y,c->width,c->height))); + assert(!covers(d,NSMakeRect(c->x,c->y,20,20))); + assert(!covers(d,NSMakeRect(c->x,c->y,c->width/2,c->height/2))); + ++checked; + } + freeCrtc(c); + } + freeResources(resources); closeDisplay(d); + assert(checked); + printf("FULLSCREEN_GEOMETRY startup_rejected=1 windowed_rejected=1 active_monitors=%u\n",checked); + } + return 0; +} diff --git a/tools/riot-input-offline-check.py b/tools/riot-input-offline-check.py new file mode 100644 index 0000000..c498730 --- /dev/null +++ b/tools/riot-input-offline-check.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Test the shipped relative-mouse implementation without fetching Darling or opening X11.""" +import argparse +import os +import time +from pathlib import Path +import shlex +import subprocess +import tempfile + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--xvfb', help='also exercise Xorg protocol on this private Xvfb executable') +args = parser.parse_args() +root = Path(__file__).resolve().parent.parent +name = 'AppKit/X11.backend/X11RelativeMouse.h' +patch = (root / 'patches/cocotron.patch').read_text() +section = patch.split(f'diff --git a/{name} b/{name}\n', 1)[1].split('\ndiff --git ', 1)[0] +# This header is a new file: all of its source lines are additions after the first hunk header. +body = section.split('@@\n', 1)[1] +assert all(line.startswith('+') for line in body.splitlines()), 'relative mouse header must be a complete added file' +with tempfile.TemporaryDirectory(prefix='darling-input-check-') as folder: + folder = Path(folder) + header = folder / 'X11RelativeMouse.h' + header.write_text('\n'.join(line[1:] for line in body.splitlines()) + '\n') + binary = folder / 'relative-mouse-check' + subprocess.run([*shlex.split(os.environ.get('CC', 'cc')), '-std=c99', '-Wall', '-Wextra', '-Werror', + f'-DX11_RELATIVE_MOUSE_HEADER="{header}"', str(root / 'tools/test-x11-relative-mouse.c'), + '-o', str(binary)], check=True) + subprocess.run([str(binary)], check=True) + + if args.xvfb: + protocol = folder / 'relative-mouse-xorg-check' + subprocess.run([*shlex.split(os.environ.get('CC', 'cc')), '-std=c99', '-Wall', '-Wextra', '-Werror', + f'-DX11_RELATIVE_MOUSE_HEADER="{header}"', str(root / 'tools/test-x11-relative-mouse-xorg.c'), + '-lX11', '-o', str(protocol)], check=True) + # -displayfd probes low display numbers and can unlink an existing Xwayland socket. + # Pick a high unused display and let Xvfb's normal lock handling protect it instead. + number = next(n for n in range(1000, 1100) + if not Path(f'/tmp/.X{n}-lock').exists() + and not Path(f'/tmp/.X11-unix/X{n}').exists() + and not Path(f'/tmp/.X11-unix/X{n}_').exists()) + private_display = ':' + str(number) + server = subprocess.Popen([args.xvfb, private_display, '-screen', '0', '3840x1080x24', + '-nolisten', 'tcp', '-ac', '-noreset'], + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 15 + while not Path(f'/tmp/.X11-unix/X{number}').is_socket(): + if server.poll() is not None: + raise RuntimeError('private Xvfb failed: ' + server.stderr.read().decode()) + if time.monotonic() >= deadline: + raise RuntimeError('private Xvfb did not become ready') + time.sleep(0.02) + subprocess.run([str(protocol), private_display], check=True, timeout=20) + finally: + server.terminate() + server.communicate(timeout=10) diff --git a/tools/riot-remote-frame-ownership-check.m b/tools/riot-remote-frame-ownership-check.m new file mode 100644 index 0000000..81d7130 --- /dev/null +++ b/tools/riot-remote-frame-ownership-check.m @@ -0,0 +1,50 @@ +/* No display connection or window is created. Exercise the real backend with a cached remote + * frame to ensure a client slot cannot take ownership of League's native Metal drawable. */ +#import +#import +#import +#include +#include +#include + +@interface FrameOwner : NSObject +@property(copy) NSString *title; +- (void)platformWindowDidInvalidateCGContext:(id)window; +@end +@implementation FrameOwner +@synthesize title; +- (void)platformWindowDidInvalidateCGContext:(id)window {} +@end +@interface NSObject (RemoteFrameProbe) +- (BOOL)presentRemoteCAFrame:(id)timer; +@end +static void *slot(id object, const char *name) { + Ivar ivar = class_getInstanceVariable([object class], name); + assert(ivar); + return (char *)object + ivar_getOffset(ivar); +} +int main(int argc, char **argv) { + @autoreleasepool { + assert(argc == 2); + void *backend = dlopen(argv[1], RTLD_LAZY); + if (!backend) { fprintf(stderr,"%s\n",dlerror()); return 1; } + Class cls = NSClassFromString(@"X11Window"); + assert(cls); + id native = [cls alloc]; /* Deliberately skip X11 initialization: cached-frame path needs no display. */ + FrameOwner *owner = [FrameOwner new]; + *(id *)slot(native,"_delegate") = owner; + *(BOOL *)slot(native,"_mapped") = YES; + *(BOOL *)slot(native,"_remoteFrameValid") = YES; + *(unsigned *)slot(native,"_remoteFrameTargetCount") = 1; + *(double *)slot(native,"_remoteFrameTargetRefresh") = CFAbsoluteTimeGetCurrent() + 60; + *(double *)slot(native,"_remoteFrameSelectionRefresh") = CFAbsoluteTimeGetCurrent() + 60; + owner.title = @"Riot Client"; + assert([native presentRemoteCAFrame:nil]); /* Client's cached presentation remains usable. */ + owner.title = @"League of Legends (TM) Client"; + assert(![native presentRemoteCAFrame:nil]); /* Same cached frame cannot cover the game. */ + [native release]; + [owner release]; + puts("REMOTE_FRAME_OWNERSHIP client_preserved=1 native_game_protected=1"); + } + return 0; +} diff --git a/tools/riot-runtime-cleanup-offline-check.py b/tools/riot-runtime-cleanup-offline-check.py new file mode 100644 index 0000000..f9bc9fa --- /dev/null +++ b/tools/riot-runtime-cleanup-offline-check.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Check stale-session cleanup against fake processes; never signal a real process.""" +import importlib.util +import os +from pathlib import Path +import tempfile +from unittest.mock import patch, Mock, MagicMock + +spec = importlib.util.spec_from_file_location('cleanup', Path(__file__).with_name('ensure-darling-runtime.py')) +cleanup = importlib.util.module_from_spec(spec) +spec.loader.exec_module(cleanup) + +with tempfile.TemporaryDirectory(prefix='darling-cleanup-check-') as folder: + root = Path(folder) + def process(pid, comm, argv): + proc = root / str(pid) + proc.mkdir() + (proc / 'comm').write_text(comm + '\n') + (proc / 'cmdline').write_bytes(b'\0'.join(arg.encode() for arg in argv) + b'\0') + return proc + browser = process(101, 'chrome_crashpad', ['/opt/google/chrome/chrome_crashpad_handler', '--annotation=riot']) + riot = process(102, 'chrome_crashpad', ['/Applications/Riot Client.app/Contents/Frameworks/chrome_crashpad_handler']) + launchd = process(103, 'launchd', ['/sbin/launchd']) + (launchd / 'cmdline').write_bytes(b'/sbin/launchd' + b'\0' * 8) + process(104, 'unrelated', ['/usr/bin/unrelated', '/sbin/launchd']) + with patch.object(cleanup.os, 'kill') as kill: + cleanup.stop_riot_crashpad(root) + assert kill.call_args_list == [((102, cleanup.signal.SIGTERM),)] + kill.reset_mock() + cleanup.stop_orphaned_launchd(root) + assert kill.call_args_list == [((103, cleanup.signal.SIGKILL),)] + assert not cleanup.any_server_running(root) + process(105, 'darlingserver', ['darlingserver', '--prefix=another-prefix']) + assert cleanup.any_server_running(root) + hidden = MagicMock() + hidden.name = '106' + (hidden / 'comm').read_text.side_effect = PermissionError() + fake_root = Mock() + fake_root.iterdir.return_value = [hidden] + assert cleanup.any_server_running(fake_root) + # Foreign-owned Riot processes and buffers must survive even when no server runs. + with patch.object(Path, 'stat', return_value=Mock(st_uid=os.getuid() + 1)), patch.object(cleanup.os, 'kill') as kill: + cleanup.stop_riot_crashpad(root) + cleanup.stop_orphaned_launchd(root) + kill.assert_not_called() + buffer = root / 'darling-buffer' + buffer.write_text('stale') + with patch.object(Path, 'stat', return_value=Mock(st_uid=os.getuid() + 1, st_mode=0o100600)): + cleanup.remove_owned([buffer]) + assert buffer.exists() + cleanup.remove_owned([buffer]) + assert not buffer.exists() + # The entry point must preserve all sessions while ANY prefix has a server. + with patch.object(cleanup, 'recover_marker'), patch.object(cleanup, 'any_server_running', return_value=True), \ + patch.object(cleanup, 'stop_riot_crashpad') as crashpad, \ + patch.object(cleanup, 'stop_orphaned_launchd') as orphan, patch.object(cleanup, 'remove_owned') as remove: + cleanup.main() + crashpad.assert_not_called() + orphan.assert_not_called() + remove.assert_not_called() +print('RUNTIME_CLEANUP_OFFLINE pass=1 browser_preserved=1 other_prefix_preserved=1 foreign_owner_preserved=1 hidden_process_preserved=1 orphan_recovered=1') diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index fd29d04..74b480c 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -61,8 +61,16 @@ else fi fi # --- darling-lol perf: scheduling & driver latency hints --- -# Refresh rate for display link, async presenter, and frame pacing (default 144Hz) -export RCLOL_REFRESH_HZ="${RCLOL_REFRESH_HZ:-144}" +# Refresh rate for display link, async presenter, and frame pacing: the primary monitor's +# current mode (the first monitor without a primary); unset falls back to 60 Hz. +if [ -z "${RCLOL_REFRESH_HZ-}" ] && command -v xrandr >/dev/null 2>&1; then + RCLOL_REFRESH_HZ=$(xrandr --current 2>/dev/null | awk ' + / connected/ { primary = / primary / } + /\*/ { for (i = 2; i <= NF; i++) if ($i ~ /\*/) { rate = $i; sub(/[*+]+/, "", rate) + if (primary) { print int(rate + 0.5); found = 1; exit } else if (first == "") first = int(rate + 0.5) } } + END { if (!found && first != "") print first }') +fi +if [ -n "${RCLOL_REFRESH_HZ-}" ]; then export RCLOL_REFRESH_HZ; fi # Vulkan WSI & Mesa optimizations export MESA_NO_ERROR="${MESA_NO_ERROR:-1}" # NVIDIA proprietary driver: limit pre-rendered frames. diff --git a/tools/test-x11-relative-mouse-xorg.c b/tools/test-x11-relative-mouse-xorg.c new file mode 100644 index 0000000..8edd740 --- /dev/null +++ b/tools/test-x11-relative-mouse-xorg.c @@ -0,0 +1,96 @@ +/* Protocol regression on a PRIVATE Xvfb server, never the user's DISPLAY. */ +#ifndef X11_RELATIVE_MOUSE_HEADER +#define X11_RELATIVE_MOUSE_HEADER "../build/darling-src/src/external/cocotron/AppKit/X11.backend/X11RelativeMouse.h" +#endif +#include X11_RELATIVE_MOUSE_HEADER +#include +#include +#include + +static XEvent nextMotion(Display *d) { + XEvent event; + do { XNextEvent(d, &event); } while (event.type != MotionNotify); + return event; +} +static void movePhysical(Display *other, int x, int y) { + XWarpPointer(other, None, DefaultRootWindow(other), 0, 0, 0, 0, x, y); + XSync(other, False); +} +static X11RelativeMouseMotion handle(X11RelativeMouse *mouse, XEvent event) { + return X11RelativeMouseHandleMotion(mouse, event.xmotion.x_root, event.xmotion.y_root, true, + event.xmotion.serial); +} +static unsigned warp(Display *d, int x, int y) { + XWarpPointer(d, None, DefaultRootWindow(d), 0, 0, 0, 0, x, y); + XSync(d, False); + return XEventsQueued(d, QueuedAlready); +} +int main(int argc, char **argv) { + assert(argc == 2); /* The runner supplies the dedicated server explicitly. */ + Display *d = XOpenDisplay(argv[1]), *other = XOpenDisplay(argv[1]); + assert(d && other); + Window win = XCreateSimpleWindow(d, DefaultRootWindow(d), 1920, 24, 1920, 1056, 0, 0, 0); + XSelectInput(d, win, PointerMotionMask); + XMapWindow(d, win); + XSync(d, False); + movePhysical(other, 2500, 400); + XSync(d, True); + assert(XGrabPointer(d, win, False, PointerMotionMask, GrabModeAsync, GrabModeAsync, + win, None, CurrentTime) == GrabSuccess); + /* Leave an older motion queued while taking the fresh grab snapshot. */ + movePhysical(other, 2510, 400); + Window root, child; int rx, ry, wx, wy; unsigned state; + unsigned long snapshot = NextRequest(d); + assert(XQueryPointer(d, win, &root, &child, &rx, &ry, &wx, &wy, &state)); + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, rx, ry); m.firstSerial = snapshot; + assert(rx == 2510 && ry == 400); + X11RelativeMouseMotion r = handle(&m, nextMotion(d)); + assert(!r.post && !r.rewarp && m.physicalX == 2510); + + movePhysical(other, 2520, 395); + r = handle(&m, nextMotion(d)); + assert(r.post && r.deltaX == 10 && r.deltaY == -5 && r.rewarp); + unsigned long serial = NextRequest(d); + unsigned queued = warp(d, r.warpX, r.warpY); + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, queued, serial); + XEvent echo = nextMotion(d); + assert(echo.xmotion.serial == serial); + r = handle(&m, echo); + assert(r.echo && !r.post && m.physicalX == 2510); + puts("PASS: real Xorg grab snapshot and warp echo request serial"); + + /* Queue real motion across the rewarp target BEFORE requesting a rewarp. */ + movePhysical(other, 2530, 400); + movePhysical(other, 2510, 400); + serial = NextRequest(d); + queued = warp(d, 2510, 400); + X11RelativeMouseExpectEcho(&m, 2510, 400, queued, serial); + r = handle(&m, nextMotion(d)); + assert(r.post && !r.echo && r.deltaX == 20 && !r.rewarp); + r = handle(&m, nextMotion(d)); + assert(r.post && !r.echo && r.deltaX == -20); + r = handle(&m, nextMotion(d)); + assert(r.echo && !r.post); + puts("PASS: real queued motion crossing the anchor is not swallowed as an echo"); + + m.bounded = true; m.minX = 1920; m.minY = 24; m.maxX = 3839; m.maxY = 1079; + double x = 3900, y = -100; + X11RelativeMouseClamp(&m, &x, &y); + /* Send the raw target here to verify the X server clips it exactly as the backend does. */ + serial = NextRequest(d); queued = warp(d, 3900, -100); + X11RelativeMouseWarp(&m, x, y, queued, serial); + assert(XQueryPointer(d, win, &root, &child, &rx, &ry, &wx, &wy, &state)); + assert(rx == 3839 && ry == 24 && m.virtualX == rx && m.virtualY == ry); + r = handle(&m, nextMotion(d)); + assert(r.echo && !r.post); + movePhysical(other, 3830, 30); + r = handle(&m, nextMotion(d)); + assert(r.post && r.deltaX == -9 && r.deltaY == 6); + puts("PASS: real Xorg confinement on a secondary monitor agrees with the virtual cursor"); + X11RelativeMouseEnd(&m); + XUngrabPointer(d, CurrentTime); + XDestroyWindow(d, win); + XCloseDisplay(other); XCloseDisplay(d); + return 0; +} diff --git a/tools/test-x11-relative-mouse.c b/tools/test-x11-relative-mouse.c index 489701c..21dfbdc 100644 --- a/tools/test-x11-relative-mouse.c +++ b/tools/test-x11-relative-mouse.c @@ -1,4 +1,7 @@ -#include "../build/darling-src/src/external/cocotron/AppKit/X11.backend/X11RelativeMouse.h" +#ifndef X11_RELATIVE_MOUSE_HEADER +#define X11_RELATIVE_MOUSE_HEADER "../build/darling-src/src/external/cocotron/AppKit/X11.backend/X11RelativeMouse.h" +#endif +#include X11_RELATIVE_MOUSE_HEADER #include #include @@ -6,7 +9,7 @@ * 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); + return X11RelativeMouseHandleMotion(m, x, y, honoured, 0); } static void xorgHonouredWarps(void) { @@ -16,22 +19,22 @@ static void xorgHonouredWarps(void) { 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); + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 1, 0); /* 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); + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 1, 0); /* 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 */ + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 2, 0); /* 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 */ + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 1, 0); /* same target: merged, not a second echo */ assert(m.echoCount == 1); r = motion(&m, 500, 400, true); assert(r.echo && m.echoCount == 0); @@ -43,8 +46,8 @@ static void xorgHonouredWarps(void) { 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 */ + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 1, 0); + X11RelativeMouseWarp(&m, 524.0, 400.0, 2, 0); /* game: mouseLocation + 2 * delta */ assert(m.virtualX == 524.0 && m.physicalX == 512); r = motion(&m, 500, 400, true); assert(r.echo && m.physicalX == 500); @@ -52,6 +55,7 @@ static void xorgHonouredWarps(void) { 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); + X11RelativeMouseEnd(&m); puts("PASS: Xorg honoured warps rewarp to the frozen cursor and drop their echoes"); } @@ -61,7 +65,7 @@ static void xwaylandVisibleCursor(void) { /* 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); + X11RelativeMouseWarp(&m, 820.0, 450.0, 1, 0); /* 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); @@ -70,18 +74,19 @@ static void xwaylandVisibleCursor(void) { 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); + X11RelativeMouseWarp(&m, 700.0, 400.0, 1, 0); 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); + X11RelativeMouseEnd(&m); 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 */ + X11RelativeMouseWarp(&m, 150.75, 120.5, 0, 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. */ @@ -89,6 +94,7 @@ static void gameWarpUpdatesVirtual(void) { 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); + X11RelativeMouseEnd(&m); puts("PASS: application warps while relative move the virtual cursor that mouseLocation returns"); } @@ -101,39 +107,109 @@ static void deltaSign(void) { /* Absolute motion in Cocoa window coordinates (bottom-up): moving down lowers y. */ X11AbsoluteMouseDelta(10, 300, 14, 290, &dx, &dy); assert(dx == 4 && dy == 10); + X11RelativeMouseEnd(&m); 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); + X11RelativeMouseWarp(&m, 40, 40, 3, 0); 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); + X11RelativeMouseEnd(&m); puts("PASS: ungrab clears expected echoes and a new grab starts from the current pointer"); } -static void echoQueueBound(void) { +static void echoQueueGrowth(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); + for (int i = 1; i <= X11_RELATIVE_MOUSE_INITIAL_ECHOES + 3; ++i) + X11RelativeMouseWarp(&m, i, 0, 100, 0); + assert(m.echoCount == X11_RELATIVE_MOUSE_INITIAL_ECHOES + 3 && m.echoes[0].x == 1); 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"); + assert(r.echo && m.echoCount == X11_RELATIVE_MOUSE_INITIAL_ECHOES - 3 && m.echoes[0].x == 7); + X11RelativeMouseEnd(&m); + puts("PASS: echo backlog grows without discarding warps and is consumed in order"); +} + +static void serialsSeparateMotionFromEchoes(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 500, 400); + m.firstSerial = 100; + /* Events queued before the grab snapshot must not turn into a jump from that snapshot. */ + X11RelativeMouseMotion r = X11RelativeMouseHandleMotion(&m, 0, 0, true, 99); + assert(!r.post && !r.rewarp && m.physicalX == 500 && m.physicalY == 400); + X11RelativeMouseExpectEcho(&m, 500, 400, 4, 110); + /* Real pre-warp motion happens to cross the anchor. Coordinates alone would discard it. */ + m.physicalX = 510; + r = X11RelativeMouseHandleMotion(&m, 500, 400, true, 105); + assert(r.post && !r.echo && r.deltaX == -10 && m.echoCount == 1); + r = X11RelativeMouseHandleMotion(&m, 500, 400, true, 110); + assert(r.echo && !r.post && m.echoCount == 0); + /* A later real event at the same target must not be swallowed either. */ + X11RelativeMouseExpectEcho(&m, 520, 400, 2, 120); + r = X11RelativeMouseHandleMotion(&m, 520, 400, true, 121); + assert(r.post && !r.echo && r.deltaX == 20); + X11RelativeMouseEnd(&m); + puts("PASS: grab snapshot and warp request serials distinguish queued motion from echoes"); +} + +static void queuedMotionDoesNotMultiplyWarps(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 500, 400); + X11RelativeMouseMotion r = X11RelativeMouseHandleMotion(&m, 510, 400, true, 100); + assert(r.post && r.deltaX == 10 && r.rewarp); + X11RelativeMouseExpectEcho(&m, 500, 400, 4, 110); + r = X11RelativeMouseHandleMotion(&m, 520, 400, true, 101); + assert(r.post && r.deltaX == 10 && !r.rewarp); + r = X11RelativeMouseHandleMotion(&m, 530, 400, true, 102); + assert(r.post && r.deltaX == 10 && !r.rewarp && m.echoCount == 1); + r = X11RelativeMouseHandleMotion(&m, 500, 400, true, 110); + assert(r.echo && !r.post); + r = X11RelativeMouseHandleMotion(&m, 502, 401, true, 111); + assert(r.post && r.deltaX == 2 && r.deltaY == 1 && r.rewarp); + X11RelativeMouseEnd(&m); + puts("PASS: queued physical motion uses one pending rewarp without losing deltas"); +} + +static void confinedWarpAndApplicationReanchor(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 2500, 400); + /* A second monitor at +1920, with a 24-pixel panel above the game. */ + m.bounded = true; m.minX = 1920; m.minY = 24; m.maxX = 3839; m.maxY = 1079; + double x = 3900, y = -100; + X11RelativeMouseClamp(&m, &x, &y); + assert(x == 3839 && y == 24); + X11RelativeMouseWarp(&m, x, y, 3, 210); + /* Another application warp must preserve both outstanding echoes, even at the same position. */ + X11RelativeMouseWarp(&m, x, y, 4, 220); + assert(m.echoCount == 2); + X11RelativeMouseMotion r = X11RelativeMouseHandleMotion(&m, 3839, 24, true, 210); + assert(r.echo && !r.post && m.echoCount == 1); + r = X11RelativeMouseHandleMotion(&m, 3839, 24, true, 220); + assert(r.echo && !r.post && m.echoCount == 0); + r = X11RelativeMouseHandleMotion(&m, 3830, 30, true, 221); + assert(r.post && r.deltaX == -9 && r.deltaY == 6); + assert(r.rewarp && r.warpX == 3839 && r.warpY == 24); + assert(m.virtualX == 3839 && m.virtualY == 24); + X11RelativeMouseEnd(&m); + puts("PASS: confined application warps share one position with events and rewarping on a secondary monitor"); } int main(void) { + serialsSeparateMotionFromEchoes(); + queuedMotionDoesNotMultiplyWarps(); + confinedWarpAndApplicationReanchor(); xorgHonouredWarps(); xwaylandVisibleCursor(); gameWarpUpdatesVirtual(); deltaSign(); ungrabRestores(); - echoQueueBound(); + echoQueueGrowth(); return 0; } diff --git a/tools/trace-riot-windows.py b/tools/trace-riot-windows.py new file mode 100644 index 0000000..b7da875 --- /dev/null +++ b/tools/trace-riot-windows.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Passively record Riot/League X11 window lifetimes, geometry and child stacking (requires python-xlib).""" +import argparse +from datetime import datetime +import json +from pathlib import Path +import select +import time +from Xlib import X, display, error + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--duration', type=float, default=3600) + args = parser.parse_args() + connection = display.Display() + root = connection.screen().root + watched, known = set(), {} + connection.set_error_handler(lambda exc, request: None) # Windows may disappear between queries. + atoms = {name: connection.intern_atom(name) for name in ( + '_NET_WM_PID', '_NET_WM_NAME', '_NET_WM_STATE', '_NET_WM_WINDOW_TYPE')} + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('a', buffering=1) as output: + def emit(record): + output.write(json.dumps({'time': datetime.now().astimezone().isoformat(), **record}) + '\n') + def property_value(window, name): + value = window.get_full_property(atoms[name], X.AnyPropertyType) + if value is None: + return None + if name == '_NET_WM_NAME': + return bytes(value.value).decode(errors='replace') + return list(map(int, value.value)) + def scan(window, event, inherited=False, depth=0): + if depth > 64: + return + try: + if window.id not in watched: + window.change_attributes(event_mask=X.SubstructureNotifyMask | X.StructureNotifyMask | X.PropertyChangeMask) + watched.add(window.id) + tree = window.query_tree() + cls = window.get_wm_class() + relevant = inherited or any(word in str(cls).lower() for word in ('riot', 'league')) + if relevant: + attrs, geom = window.get_attributes(), window.get_geometry() + point = root.translate_coords(window, 0, 0) + state = { + 'window': hex(window.id), 'parent': hex(tree.parent.id), + 'class': cls, 'title': property_value(window, '_NET_WM_NAME') or window.get_wm_name(), + 'pid': property_value(window, '_NET_WM_PID'), + 'size': [geom.width, geom.height], 'root_position': [point.x, point.y], + 'mapped': attrs.map_state, 'override_redirect': bool(attrs.override_redirect), + 'state': [connection.get_atom_name(n) for n in property_value(window, '_NET_WM_STATE') or []], + 'type': [connection.get_atom_name(n) for n in property_value(window, '_NET_WM_WINDOW_TYPE') or []], + 'children_bottom_to_top': [hex(child.id) for child in tree.children], + } + if known.get(window.id) != state or event in ('MapNotify', 'UnmapNotify'): + emit({'event': event, **state}) + known[window.id] = state + for child in tree.children: + scan(child, event, relevant, depth + 1) + except (error.XError, OSError, AttributeError, ValueError): + pass + emit({'event': 'trace-start', 'display': connection.get_display_name()}) + scan(root, 'initial') + connection.flush() + deadline = time.monotonic() + args.duration + while time.monotonic() < deadline: + if not connection.pending_events(): + select.select([connection.fileno()], [], [], min(1, max(0, deadline - time.monotonic()))) + while connection.pending_events(): + event = connection.next_event() + window = getattr(event, 'window', None) + if window is None: + continue + if event.type == X.DestroyNotify: + if window.id in known: + emit({'event': 'DestroyNotify', **known.pop(window.id)}) + watched.discard(window.id) + else: + # Recover the owning top-level so new descendants inherit the Riot/League filter. + ancestor = window + try: + for _ in range(64): + parent = ancestor.query_tree().parent + if not hasattr(parent, 'id') or parent.id == root.id or parent.id == 0: + break + ancestor = parent + scan(ancestor, type(event).__name__) + except error.XError: + pass + connection.flush() + emit({'event': 'trace-end'}) + connection.close() + + +if __name__ == '__main__': + main()