From 7bb4554e8f3a6750ba5be99c32394c928fcdfc78 Mon Sep 17 00:00:00 2001 From: Kalah <58551020+lucannez64@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:18:07 +0200 Subject: [PATCH 001/160] Update README with a showcase video Updated README with a showcase video --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 22b5505..e4a0b48 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # darling-lol +https://github.com/user-attachments/assets/140df3e0-3790-46b6-bab5-578139b84a7c + Experimental source patches and tools for running the **macOS League of Legends client on x86-64 Linux through Darling**. This is a development snapshot, not a finished launcher. Riot and League clients can launch and manually played ARAM games have run. Graphics, audio, input, startup crashes and memory reclamation have received compatibility fixes. On the Ryzen 5 5500 / RX 6600 PC, ARAM now averages about 118–135 FPS, with the slowest 10-second stretches around 106–119 (the higher figures with Hyprland's direct scanout off; fights were 70–80 FPS before the 2026-10-13/14 work, versus 170–190 on Windows). The effect of the memory changes on client RAM remains unmeasured. A fresh checkout has not been validated end to end on another machine. @@ -79,3 +81,5 @@ build/ local sources, outputs and rendered tools (ignored) ``` Darling/upstream components retain their existing licenses; see [third-party notes](NOTICE.md). Original integration tooling is distributed under GPL-3.0; patches retain the licensing of their target files. This project is not affiliated with Riot Games. + + From ac4a832ebc3dc95d624eb22010446004ce71a9af Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:50:25 +0200 Subject: [PATCH 002/160] Implement proc_listpids in the kernel emulation proc_info callnum 1 (proc_listpids) previously fell through to the unsupported-callnum path and returned ENOTSUP, so process enumeration (e.g. the Riot client's Vanguard kext probe) logged 'sys_proc_info(): Unsupported callnum: 1'. Walk /proc like _sysctl_proc and _proc_pidinfo_listthreads already do and mirror XNU's filter semantics for PROC_ALL_PIDS, PROC_PGRP_ONLY, PROC_PPID_ONLY, PROC_TTY_ONLY, PROC_UID_ONLY and PROC_RUID_ONLY, with XNU's size-query, ENOMEM and truncation contracts. --- patches/xnu.patch | 164 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 2 deletions(-) diff --git a/patches/xnu.patch b/patches/xnu.patch index 7b95368..482a3f6 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -3221,10 +3221,18 @@ index 0000000..479294a + return -ENOSYS; +} diff --git a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/proc_info.c b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/proc_info.c -index c2f70fb..af904ed 100644 +index c2f70fb..73c2a5a 100644 --- a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/proc_info.c +++ b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/proc_info.c -@@ -83,6 +83,38 @@ long sys_proc_info(uint32_t callnum, int32_t pid, uint32_t flavor, +@@ -35,6 +35,7 @@ + #define LINUX_PR_SET_NAME 15 + + static long _proc_pidinfo(int32_t pid, uint32_t flavor, uint64_t arg, void* buffer, int32_t bufsize); ++static long _proc_listpids(uint32_t type, uint32_t typeinfo, void* buffer, int32_t bufsize); + + extern __SIZE_TYPE__ strlen(const char *s); + extern void *memset(void *s, int c, __SIZE_TYPE__ n); +@@ -83,7 +84,42 @@ long sys_proc_info(uint32_t callnum, int32_t pid, uint32_t flavor, case 2: // proc_pidinfo return _proc_pidinfo(pid, flavor, arg, buffer, bufsize); case 3: // proc_pidfdinfo @@ -3261,8 +3269,160 @@ index c2f70fb..af904ed 100644 + return -ENOTSUP; + } case 1: // proc_listpids ++ // `pid` carries the process type and `flavor` carries the type ++ // info (e.g. the pgid, ppid or uid to match). ++ return _proc_listpids(pid, flavor, buffer, bufsize); default: __simple_printf("sys_proc_info(): Unsupported callnum: %d\n", + callnum); +@@ -827,3 +863,147 @@ bail: + close_internal(fd); + return count * sizeof(uint64_t); + } ++ ++// Returns whether `pid` should be included in a proc_listpids() query of the ++// given `type`, matching the semantics of XNU's bsd/kern/proc_info.c. ++static bool _proc_listpids_match(uint32_t type, uint32_t typeinfo, int32_t pid, ++ int32_t uid) ++{ ++ char path[64], stat[1024]; ++ char* statptr; ++ const char* elem; ++ int32_t ppid, pgrp, tty; ++ ++ switch (type) ++ { ++ case PROC_ALL_PIDS: ++ return true; ++ ++ case PROC_UID_ONLY: ++ case PROC_RUID_ONLY: ++ // Darling's container runs every guest process as our own ++ // user, which is also what _sysctl_proc() reports. ++ return typeinfo == (uint32_t) uid; ++ ++ case PROC_PGRP_ONLY: ++ case PROC_PPID_ONLY: ++ case PROC_TTY_ONLY: ++ break; ++ ++ default: ++ // PROC_KDBG_ONLY and anything else: no process carries the ++ // corresponding state under Darling. ++ return false; ++ } ++ ++ __simple_sprintf(path, "/proc/%d/stat", pid); ++ if (!read_string(path, stat, sizeof(stat))) ++ return false; ++ ++ statptr = stat; ++ skip_stat_elems(&statptr, 1); // pid ++ ++ if (!(elem = next_stat_elem(&statptr))) ++ return false; ++ // comm ++ ++ if (!(elem = next_stat_elem(&statptr))) ++ return false; ++ // state ++ ++ if (!(elem = next_stat_elem(&statptr))) ++ return false; ++ ppid = __simple_atoi(elem, NULL); ++ ++ if (!(elem = next_stat_elem(&statptr))) ++ return false; ++ pgrp = __simple_atoi(elem, NULL); ++ ++ if (type == PROC_PPID_ONLY) ++ return ppid == (int32_t) typeinfo; ++ ++ if (type == PROC_PGRP_ONLY) ++ return pgrp == (int32_t) typeinfo; ++ ++ // PROC_TTY_ONLY: the (Linux-encoded) controlling terminal device. ++ if (!(elem = next_stat_elem(&statptr))) ++ return false; ++ // session ++ ++ if (!(elem = next_stat_elem(&statptr))) ++ return false; ++ tty = __simple_atoi(elem, NULL); ++ ++ return tty != 0 && (uint32_t) tty == typeinfo; ++} ++ ++// Emulates XNU's proc_listpids(), i.e. proc_info(PROC_INFO_CALL_LISTPIDS). ++// Guest pids are Linux pids, so walking /proc gives the same set that the ++// other proc_info flavors and sysctl(KERN_PROC_*) already operate on. ++static long _proc_listpids(uint32_t type, uint32_t typeinfo, void* buffer, int32_t bufsize) ++{ ++ char dents[256]; ++ long basep = 0; ++ int fd, ret; ++ int32_t maxpids, count = 0; ++ static int uid = -1; ++ ++ if (type != PROC_ALL_PIDS && type != PROC_PGRP_ONLY && type != PROC_TTY_ONLY && ++ type != PROC_UID_ONLY && type != PROC_RUID_ONLY && ++ type != PROC_PPID_ONLY && type != PROC_KDBG_ONLY) ++ return -EINVAL; ++ ++ if (buffer && bufsize < (int32_t) sizeof(int)) ++ return -ENOMEM; ++ ++ // A null buffer asks for the size a caller should allocate. ++ maxpids = buffer ? (bufsize / (int32_t) sizeof(int)) : 0; ++ ++ if (uid == -1) ++ uid = sys_getuid(); ++ ++ fd = sys_open_nocancel("/proc", BSD_O_RDONLY | BSD_O_DIRECTORY, 0); ++ if (fd < 0) ++ return fd; ++ ++ while ((ret = sys_getdirentries(fd, (char*) dents, sizeof(dents), &basep)) > 0) ++ { ++ int pos = 0; ++ ++ while (pos < ret) ++ { ++ struct bsd_dirent* dent = (struct bsd_dirent*) &dents[pos]; ++ ++ pos += dent->d_reclen; ++ ++ if (dent->d_type != DT_DIR || !isdigit(dent->d_name[0])) ++ continue; ++ ++ int pid = __simple_atoi(dent->d_name, NULL); ++ ++ if (!_proc_listpids_match(type, typeinfo, pid, uid)) ++ continue; ++ ++ if (buffer) ++ { ++ // Truncate rather than overflow, like XNU does. ++ if (count >= maxpids) ++ goto bail; ++ ++ ((int*) buffer)[count] = pid; ++ } ++ ++ ++count; ++ } ++ } ++ ++bail: ++ close_internal(fd); ++ ++ // XNU reports a little extra headroom so that a size query followed by a ++ // fill call cannot come up short when processes appear in between. ++ if (!buffer) ++ return (long)(count + 20) * sizeof(int); ++ ++ return (long) count * sizeof(int); ++} diff --git a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c index 0a8f1e4..55aadbe 100644 --- a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c From 7d3905f3731dc33b6ad1f9543bf222741f3aecdf Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:44:43 +0200 Subject: [PATCH 003/160] Fix League client resizing and runtime compatibility Fix X11 presentation handoff after resizing, preserve NSView size callbacks, and correct content and backing-layer geometry. Add CoreText font enumeration for the language dropdown and rebuild/install the matching runtime overlays. Correct CarbonCore Gestalt version queries and roll back proc_listpids for Vanguard testing. Document that VAN -101 can still recur. Include accumulated CoreAudio stream and AudioUnit corrections. Add standalone font, version, resize and publisher-handoff regressions. Record manual confirmation of the language dropdown and window resizing; all 29 pinned patches and the source/script audit validate. --- docs/TESTING.md | 40 + docs/TROUBLESHOOTING.md | 57 ++ patches/cocotron.patch | 488 ++++++++++-- patches/darling.patch | 917 ++++++++++++++++++++--- patches/xnu.patch | 164 +--- tools/build-m58f336-quartzcore.py | 42 +- tools/build-riot-appkit-runtime.py | 2 +- tools/build-riot-carboncore-runtime.py | 29 + tools/build-riot-client-resize-check.py | 23 + tools/build-riot-coretext-runtime.py | 34 + tools/install-riot-appkit-runtime.sh | 44 ++ tools/install-riot-carboncore-runtime.sh | 39 + tools/install-riot-gpu-runtime.sh | 12 +- tools/riot-client-resize-check.m | 147 ++++ tools/riot-coretext-font-list-check.m | 53 ++ tools/riot-gestalt-version-check.cpp | 27 + tools/riot-window-layout-check.m | 93 +++ upstream/manifest.json | 7 +- 18 files changed, 1879 insertions(+), 339 deletions(-) create mode 100644 tools/build-riot-carboncore-runtime.py create mode 100644 tools/build-riot-client-resize-check.py create mode 100644 tools/build-riot-coretext-runtime.py create mode 100755 tools/install-riot-appkit-runtime.sh create mode 100755 tools/install-riot-carboncore-runtime.sh create mode 100644 tools/riot-client-resize-check.m create mode 100644 tools/riot-coretext-font-list-check.m create mode 100644 tools/riot-gestalt-version-check.cpp diff --git a/docs/TESTING.md b/docs/TESTING.md index 59acc11..7a78652 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -15,6 +15,46 @@ python3 tools/riot-foundation-installer-offline-check.py The patch check uses temporary base indexes and does not reset or modify the source checkout. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, and preserves Darling runtime library/framework paths. The memory-installer check imports the installer and exercises its policy (default source is the prepared artifact; the artifact is never a destination of a different library) in temporary directories. The Foundation-installer check does the same for the overlay installer: it seeds a temporary framework from a stub SDK, replaces `Versions/C/Foundation`, keeps the standard symlinks, stages a backup, treats a re-run as current and refuses a non-Mach-O source. The installer handoff mock covers 28 scenarios, including HTTP 409, interrupted verification and preserving active downloads. +## CoreText font-list and AppKit resize regressions + +After preparing the source and configuring the runtime tools: + +```sh +python3 build/runtime/tools/build-riot-coretext-runtime.py +python3 build/runtime/tools/build-riot-appkit-runtime.py +``` + +After installing and activating the runtime with both clients and the game closed, run the standalone probes (replace `me` and the checkout path): + +```sh +darling shell env DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks \ + /Volumes/SystemRoot/path/to/darling-lol/build/riot-coretext-font-list-check +darling shell env DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks \ + /Volumes/SystemRoot/path/to/darling-lol/build/riot-window-layout-check +``` + +Expected: `CORETEXT_FONT_LIST pass=1` with nonzero family/face counts (306/1124 on the development host), `VIEW_LAYER_RESIZE initial=1600x900 pass=1`, `NATIVE_RESIZE_ORDER size=1600x900 pass=1` `CONTENT_SIBLING_RESIZE ... pass=1` and `WINDOW_LAYOUT_CHECK ... pass=1`. + +The font probe reproduces Chromium's installed-family enumeration, mandatory family matching and localized-name lookup with a null language output. It checks ownership of the single-match result and that a nonexistent mandatory family produces no substitute. The resize probe checks backing-layer bounds immediately after frame/bounds changes, including a scaled view and a child layer, without relying on input or coordinate conversion to refresh them. It also verifies that native parent size is current during child layout, and that adding an overlay sibling after the content view does not redirect subsequent content resizing into that overlay. The old sibling selection left content at 1600×900 while stretching the 30×20 overlay to 1280×720; the corrected layout resizes the content and preserves the overlay. Before the fixes, these checks report a stale 1280×720 layer and native parent after a 1600×900 resize. The `FRAME_SIZE_HOOK` check uses a subclass whose `setFrameSize:` publishes its viewport size. It checks growth and shrinkage through parent autoresizing, direct `setFrame:`, and direct `setFrameSize:`. Before component-setter dispatch was corrected only the final direct size setter reached the subclass (one callback); afterward all four updates do, with current geometry visible inside the callback. No probe launches or contacts Riot/League. The user confirmed that the language dropdown and League window resizing work after the final X11 correction (2026-10-15). The practice-tool crash remains unresolved. + +### Presentation publisher handoff + +With clients closed, run `build/riot-client-resize-check --handoff` through Darling with the normal runtime overrides. This creates an owned window and two fixture frame slots. The live software owner first supplies a 1280×720 main frame, then switches to a 262×131 popup; another slot supplies the resized main frame without the software flag. The test samples its own window beyond the original frame extent and checks generation colors across growth and shrinkage. The old selector reports black at the 1600×900 sample; the correction passes all four sizes. Repeat with `RCLOL_XSHM=0` and with `DARLING_XSHM_LIBRARY` pointing to the normal transport to cover both paths. The fixtures remove their own frame slots on exit; they do not read client pixels or send input. + +### Pixels after a window resize + +`python3 build/runtime/tools/build-riot-client-resize-check.py` builds an independent AppKit window and IOSurface publisher. With clients closed and a display at least 1600×900, run it with the same framework/library overrides and `DARLING_XSHM_LIBRARY=/Users/me/libDarlingXShm.dylib`, `DARLING_CA_PRESENT_AT_COMMIT=1`, `DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER=1`. The default mode publishes software pixels. For the GPU path add `--gpu` and set `RCLOL_GPU_DIRECT_PRESENT=1 RCLOL_GPU_DIRECT_ASYNC=1` in the guest environment. + +The probe cycles through 1280×720, 1600×900, 1024×576 and 1280×720. Each stage changes the frame color and samples its own X11 window near the bottom-right corner, beyond the original frame's extent when growing. It waits up to 2.5 seconds for presentation and prints `RESIZE_PIXEL ... pass=1` for every stage. This verifies presented pixels rather than only view properties. Success does not reproduce League's own resize callbacks; the reported League UI failure remains open, with the opt-in [geometry trace](TROUBLESHOOTING.md#language-dropdown-closes-riot-or-client-resize-leaves-a-clipped-ui) needed to locate the remaining mismatch. + +## Legacy operating-system version query + +```sh +python3 build/runtime/tools/build-riot-carboncore-runtime.py +``` + +After installing and activating CarbonCore, run the independent `build/riot-gestalt-version-check` through Darling with the normal framework/library overrides. Expected on the current kernel: `GESTALT_VERSION product=14.7 version=14.7.0 packed=0x1470 architecture=10 pass=1`. It compares the separate version selectors against `kern.osproductversion`, checks packed BCD, confirms architecture remains a separate result, and covers null outputs and unknown selectors. The previous implementation returned 10 for every version component and the packed selector. The probe does not launch or contact Riot. Passing it establishes the API correction, not Vanguard compatibility. + ## Standalone guest probes Build and install the base/runtime first. Native probes are built by the incremental runtime scripts. With the default guest username, run a probe like this (replace `me` and the host checkout path): diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index ae16f0f..c46d3f0 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -62,6 +62,63 @@ LaunchServices dereferenced a nullable error-output pointer in six paths. The pa LaunchServices now opens `http`, `https` and `mailto` links on the Linux desktop itself. It loads the host's `libsystemd.so.0` and asks `xdg-desktop-portal` (`org.freedesktop.portal.OpenURI`) on the user session bus to open the link, so your default browser starts outside Darling and survives `darling shutdown`. Without a portal, it starts `xdg-open` in a transient systemd user unit instead. It also reports a handler for these schemes, since Electron checks for one before opening a link. Other schemes, control characters and URLs over 4096 bytes are rejected. If a link still does nothing, check the client's stderr for a `LaunchServices:` line and that `busctl --user status org.freedesktop.portal.Desktop` works on the host. Both LaunchServices and AppKit must be rebuilt. The hand-off passed the host-side D-Bus regression and opened the host browser from a guest through both `open(1)` and `NSWorkspace`; a link clicked in the Riot client is not yet confirmed. The null-pointer fix does not explain every unrelated button crash. +## Language dropdown closes Riot, or client resize leaves a clipped UI + +The [report in issue #1](https://github.com/lucannez64/darling-lol/issues/1#issuecomment-6194498757) contains an Electron renderer abort with `Symbol not found: _CTFontDescriptorCreateMatchingFontDescriptors`. CoreText now exports font-descriptor matching and localized-attribute lookup, and enumerates installed font families through fontconfig instead of returning a null stub. Localized lookup currently falls back to canonical font names. The standalone font-list check exercises Chromium's sequence, including mandatory family matching and retained results. + +AppKit also left backing-layer geometry at the old size until a coordinate conversion occurred. It now updates that geometry during frame/bounds changes, and invalidates parent transforms before child resize callbacks. Native parent geometry is updated before resizing child windows, whose Y positions depend on the parent's current height. Standalone regressions reproduce both stale-size defects and pass with the corrections. A follow-up test confirmed that the language dropdown is fixed, but League still displays a 1280×720 image inside a 1600×900 window. The geometry regressions alone do not establish a fix for that remaining resize failure. + +Rebuild and install the runtime using the [update instructions](SETUP.md#updating-an-existing-runtime), with the game and both clients closed. CoreText is now included in the GPU/runtime installer alongside AppKit; existing installations need this new override too. Until then, a locale can be selected at launch, for example `RCLOL_REGION=BR RCLOL_LOCALE=pt_BR ./scripts/run.sh`. + +Live inspection subsequently confirmed a 1600×900 outer League window with both its CoreAnimation child and presentation target still at 1280×720. `NSThemeFrame` selected the last non-menu/toolbar child as the content view. A standalone reproduction showed that an overlay added after the real content received the resize instead. Frame layout now identifies the window's actual content owner (including a containing wrapper), and applies ordinary autoresizing masks to other decorations. A hidden-window regression reproduces the old behavior with an overlay sibling and passes after the fix. The user retested with this build loaded; League still failed to resize. This correction therefore does not resolve the remaining client failure. + +The subsequent file trace showed the browser view reaching the new frame size without a renderer resize, and a later shrink left `RenderWidgetHostViewCocoa` at 1600×900 inside 1280×720 parents. Darling's `NSView setFrame:` directly updated geometry without dispatching `setFrameSize:`. [Chromium's Cocoa implementation](https://chromium.googlesource.com/chromium/src/+/114a1ef9e848ccebec516681bcf1786f85b5cbd6/content/browser/renderer_host/render_widget_host_view_mac.mm#2273) explicitly relies on this dispatch to notify the renderer. AppKit now calls the component setters for changed origins/sizes, with a private geometry updater to avoid recursion. A standalone subclass regression failed before this correction (one callback from four size changes) and passes after it (four callbacks, including parent autoresizing in both directions). The user retested with this AppKit build loaded and still saw clipping. Passive inspection then showed the native CoreAnimation child and a published frame at 1600×900, with the presentation target remaining at 1280×720. + +The X11 presentation selector also retained a software publisher across popup updates even after the window outgrew its old presentation target. If that publisher switched to a small popup and a different publisher supplied the resized main frame, the selector kept following the popup slot and never resized the presentation child. It now retains that owner only while the old presentation dimensions still match a native target. A two-publisher standalone pixel regression reproduces the black enlarged area on the old backend; the new backend passes 1280×720 → 1600×900 → 1024×576 → 1280×720 with and without the shared-memory transport. After installing this backend locally, the user confirmed that League window resizing works (2026-10-15). + +For the AppKit and X11 corrections, exit the game and both clients manually, then use the small update instead of rebuilding the entire runtime: + +```sh +python3 scripts/configure.py +python3 build/runtime/tools/build-riot-appkit-runtime.py +python3 build/runtime/tools/build-riot-x11-runtime.py +darling shutdown +sh build/runtime/tools/install-riot-appkit-runtime.sh +sh scripts/run.sh +``` + +The installer requires the existing per-user AppKit overlay, saves a checksum-named backup under `build/appkit-runtime-backups`, and atomically replaces each binary, including the paired X11 backend. It does not stop processes or change the system SDK. After launch, change League between 1280×720 and 1600×900 and verify both the rendered extent and click alignment. + +The installer previously preferred an existing `build/riot-QuartzCore` over its newly built `build/m336-QuartzCore`. It now installs the artifact it just rebuilt, and that builder recompiles all patched compositor and Metal presentation objects. This avoids silently installing an older optional probe build. + +If resize clipping recurs, launch with size-only runtime tracing and resize the client manually: + +```sh +mkdir -p build +env DARLING_UI_GEOMETRY_TRACE=1 sh scripts/run.sh 2>&1 \ + | rg --line-buffered '^UI_GEOMETRY ' | tee build/ui-geometry.log +``` + +Change from 1280×720 to 1600×900 and leave the client open at the broken size. AppKit also writes a bounded window/view hierarchy to the guest `/tmp/darling-ui-geometry.log` (host `~/.darling/private/tmp/darling-ui-geometry.log`). Read that file as well as `build/ui-geometry.log`: League redirects stderr, so the terminal capture alone can omit all of its resize events. The traces record window/view classes, identities, dimensions and autoresizing masks, layer scale and IOSurface dimensions at size changes. Tracing is disabled on ordinary launches. It uses the runtime's own geometry setters and surface publisher, with no debugger, client API access or input automation. + +The practice-tool exit in the same report remains unresolved: the supplied League client log records the game starting and exiting about two seconds later, but contains no game-process crash reason. These font and resize fixes do not establish a fix for that exit. Further diagnosis needs the game's own log/crash report or stderr from the affected machine. + +## Client reports macOS 10.10 and VAN -101 + +The legacy CarbonCore `Gestalt` implementation read a Darwin kernel release as though it were a macOS product version, then fell through into `gestaltSysArchitecture`, overwriting every result with `gestaltIntel` (10). The standalone probe reproduced `gestalt=10.10.10` even though `kern.osproductversion` was already `14.7`. Version selectors now read that existing product version, return before the architecture case, initialize missing patch versions to zero and implement the documented packed-BCD limits. Null outputs and unknown selectors return errors. + +After installing this correction and restarting locally, the client reported macOS 14.7 and the user confirmed that Play became available. Later launches failed again, including a launch without `DARLING_UI_GEOMETRY_TRACE`. The running client was verified to have the corrected CarbonCore binary loaded. Both successful and failed sessions reported macOS 14.7; successful sessions logged `MvgState: 0`, while failed sessions logged `MvgState: 5`, despite `MvgInit` and `MvgRegisterLogin` returning zero in both. These state values are observations, not a diagnosis of their undocumented meaning. The version correction is therefore insufficient to resolve VAN -101, and tracing is not required to reproduce the failure. Entering a match remains unverified. [Riot describes VAN -101](https://support.riotgames.com/en-us/riot/performance/error-van--101) as an unsupported system configuration and recommends a supported operating system or a support ticket with logs. No verified Darling compatibility fix for this failure is available in this snapshot. A temporary test rollback of commit `ac4a832` restores `proc_listpids` returning `ENOTSUP`; it was followed by both a successful launch and another VAN failure, so it has not established that commit as the cause. Tracing was enabled on the latter launch; its contribution remains unproven. + +Build the small update with `python3 build/runtime/tools/build-riot-carboncore-runtime.py`. After manually exiting the game and both clients: + +```sh +darling shutdown +sh build/runtime/tools/install-riot-carboncore-runtime.sh +sh scripts/run.sh +``` + +The installer adds a CarbonCore framework override and the matching absolute CoreServices subframework binary in the guest prefix, with checksum-named backups in `build/carboncore-runtime-backups`. It does not modify the SDK or restart processes. Both forms are needed because framework lookup and explicit subframework loading can take different paths. Ordinary full runtime installation includes this update too. + ## `LOLSIGFAIL … addr=0x143` every 10 seconds `iokitd` crashed in host glibc's `pthread_mutex_lock` and launchd restarted it every 10 seconds. It called `eglGetDisplay()` with an Xlib display, so Mesa picked the platform from `EGL_PLATFORM`. Wayland sessions often set that to `wayland`, and the first `darling` command passes its environment to every guest service, including `iokitd`. `scripts/run.sh` sets `EGL_PLATFORM=x11` and was unaffected, but `darling shell` from a Wayland terminal (including the one in `scripts/install-runtime.sh`) started the loop. `iokitd` now requests the X11 platform explicitly. The fix is part of the base install (`sudo cmake --install`); until then, start Darling with `EGL_PLATFORM=x11`. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 0391d0a..bfd0cf3 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -1271,10 +1271,42 @@ index 9177a986..9c3c5ca3 100644 @end diff --git a/AppKit/NSThemeFrame.m b/AppKit/NSThemeFrame.m -index 0ec827c0..6f780225 100644 +index 0ec827c0..a24cd9f5 100644 --- a/AppKit/NSThemeFrame.m +++ b/AppKit/NSThemeFrame.m -@@ -133,8 +133,10 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@@ -85,7 +85,14 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + - (void) resizeSubviewsWithOldSize: (NSSize) oldSize { + NSView *menuView = nil; + NSToolbarView *toolbarView = nil; +- NSView *contentView = nil; ++ /* Browser windows can add overlay/titlebar siblings after their content. ++ * Identify the window's actual content owner rather than choosing the ++ * last ordinary subview. Full-size content wrappers can own it indirectly. */ ++ NSView *contentView = [[self window] contentView]; ++ while (contentView && [contentView superview] && [contentView superview] != self) ++ contentView = [contentView superview]; ++ if ([contentView superview] != self) ++ contentView = nil; + + // tile the subviews, when/if we add titlebars and such do it here + for (NSView *view in _subviews) { +@@ -93,10 +100,13 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + menuView = view; + else if ([view isKindOfClass: [NSToolbarView class]]) + toolbarView = (NSToolbarView *) view; +- else +- contentView = view; + } + ++ // Other decorations still follow their autoresizing masks. ++ for (NSView *view in [[_subviews copy] autorelease]) ++ if (view != contentView && view != menuView && view != toolbarView) ++ [view resizeWithOldSuperviewSize: oldSize]; ++ + // subtracts menu height but not toolbar height + NSRect contentFrame = [[[self window] class] + contentRectForFrameRect: [self bounds] +@@ -133,8 +143,10 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ } - (void) mouseDown: (NSEvent *) event { @@ -1300,19 +1332,31 @@ index c3f5d056..dbd93174 100644 @"NSTouchBarItemIdentifierCharacterPicker"; diff --git a/AppKit/NSView.m b/AppKit/NSView.m -index 8ac2bbdc..7486ebf5 100644 +index 8ac2bbdc..08197fbc 100644 --- a/AppKit/NSView.m +++ b/AppKit/NSView.m -@@ -46,6 +46,8 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@@ -46,6 +46,12 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #import #import #import +#import +#import ++#include "NSGeometryTrace.h" ++#include ++#include ++#include @class IBMetricsTable; -@@ -955,6 +957,54 @@ static inline void buildTransformsIfNeeded(NSView *self) { +@@ -66,6 +72,7 @@ const NSViewFullScreenModeOptionKey NSFullScreenModeApplicationPresentationOptio + - (CGAffineTransform) transformToWindow; + - (CGAffineTransform) transformToLayer; + - (void) _trackingAreasChanged; ++- (void) _darlingSetFrame: (NSRect) frame; + @end + + @implementation NSView +@@ -955,6 +962,54 @@ static inline void buildTransformsIfNeeded(NSView *self) { CGPointApplyAffineTransform(point, toWindow), fromWindow); } @@ -1367,7 +1411,77 @@ index 8ac2bbdc..7486ebf5 100644 - (NSSize) convertSize: (NSSize) size fromView: (NSView *) viewOrNil { NSView *fromView = (viewOrNil != nil) ? viewOrNil : [[self window] _backgroundView]; -@@ -1254,7 +1304,11 @@ static inline void buildTransformsIfNeeded(NSView *self) { +@@ -1029,6 +1084,16 @@ static inline void buildTransformsIfNeeded(NSView *self) { + } + + - (void) setFrame: (NSRect) frame { ++ /* AppKit dispatches through the component setters. Subclasses use ++ * setFrameSize: to notify their renderer even when callers set a full ++ * frame (including autoresizing through resizeWithOldSuperviewSize:). */ ++ if (!NSEqualPoints(_frame.origin, frame.origin)) ++ [self setFrameOrigin: frame.origin]; ++ if (!NSEqualSizes(_frame.size, frame.size)) ++ [self setFrameSize: frame.size]; ++} ++ ++- (void) _darlingSetFrame: (NSRect) frame { + // Cocoa does not post the notification if the frames are equal + // Possible that resizeSubviewsWithOldSize is not called if the sizes are + // equal +@@ -1052,6 +1117,12 @@ static inline void buildTransformsIfNeeded(NSView *self) { + } + _frame = frame; + ++ /* Resize callbacks can convert child frames to window coordinates. They ++ * must see the new parent transform, and compositor submissions must see ++ * the new layer bounds even before the next coordinate conversion. */ ++ invalidateTransform(self); ++ configureLayerGeometry(self); ++ + [_window invalidateCursorRectsForView: self]; // this also invalidates + // tracking areas + +@@ -1068,6 +1139,14 @@ static inline void buildTransformsIfNeeded(NSView *self) { + + invalidateTransform(self); + ++ if (getenv("DARLING_UI_GEOMETRY_TRACE") && ++ (_frame.size.width >= 256 || _frame.size.height >= 256)) ++ DarlingGeometryTrace("UI_GEOMETRY view pid=%d class=%s parent=%s frame=%.0fx%.0f bounds=%.0fx%.0f mask=%lu autoresizes=%d\n", ++ getpid(), class_getName([self class]), ++ _superview ? class_getName([_superview class]) : "none", ++ _frame.size.width, _frame.size.height, _bounds.size.width, _bounds.size.height, ++ (unsigned long)_autoresizingMask, _autoresizesSubviews); ++ + if (_postsNotificationOnFrameChange) + [[NSNotificationCenter defaultCenter] + postNotificationName: NSViewFrameDidChangeNotification +@@ -1078,14 +1157,14 @@ static inline void buildTransformsIfNeeded(NSView *self) { + NSRect frame = _frame; + + frame.size = size; +- [self setFrame: frame]; ++ [self _darlingSetFrame: frame]; + } + + - (void) setFrameOrigin: (NSPoint) origin { + NSRect frame = [self frame]; + + frame.origin = origin; +- [self setFrame: frame]; ++ [self _darlingSetFrame: frame]; + } + + - (void) setFrameRotation: (CGFloat) angle { +@@ -1100,6 +1179,7 @@ static inline void buildTransformsIfNeeded(NSView *self) { + if (!NSEqualRects(bounds, _bounds)) { + _bounds = bounds; + invalidateTransform(self); ++ configureLayerGeometry(self); + + [_window invalidateCursorRectsForView: self]; // this also invalidates + // tracking areas +@@ -1254,7 +1334,11 @@ static inline void buildTransformsIfNeeded(NSView *self) { (NSComparisonResult (*)(id, id, void *)) compareFunction context: (void *) context { @@ -1380,7 +1494,7 @@ index 8ac2bbdc..7486ebf5 100644 } - (void) didAddSubview: (NSView *) subview { -@@ -1511,8 +1565,16 @@ static inline void buildTransformsIfNeeded(NSView *self) { +@@ -1511,8 +1595,16 @@ static inline void buildTransformsIfNeeded(NSView *self) { } - (NSTextInputContext *) inputContext { @@ -1399,7 +1513,7 @@ index 8ac2bbdc..7486ebf5 100644 } - (void) registerForDraggedTypes: (NSArray *) types { -@@ -1819,15 +1881,21 @@ static inline void buildTransformsIfNeeded(NSView *self) { +@@ -1819,15 +1911,21 @@ static inline void buildTransformsIfNeeded(NSView *self) { - (void) _createLayerContextIfNeeded { if ([_superview layer] == nil) { @@ -1423,7 +1537,7 @@ index 8ac2bbdc..7486ebf5 100644 [[_superview layer] addSublayer: _layer]; [self _createLayerContextIfNeeded]; } -@@ -1905,6 +1973,7 @@ static inline void buildTransformsIfNeeded(NSView *self) { +@@ -1905,6 +2003,7 @@ static inline void buildTransformsIfNeeded(NSView *self) { [[_superview layer] replaceSublayer: _layer with: value]; [_layer release]; _layer = value; @@ -1432,14 +1546,15 @@ index 8ac2bbdc..7486ebf5 100644 [_subviews diff --git a/AppKit/NSWindow.m b/AppKit/NSWindow.m -index d545120a..db2c12d9 100644 +index d545120a..b2845478 100644 --- a/AppKit/NSWindow.m +++ b/AppKit/NSWindow.m -@@ -45,6 +45,20 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@@ -45,6 +45,21 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #import #import #import +#import ++#include "NSGeometryTrace.h" +#include +#include + @@ -1456,7 +1571,7 @@ index d545120a..db2c12d9 100644 const NSNotificationName NSWindowDidBecomeKeyNotification = @"NSWindowDidBecomeKeyNotification"; -@@ -204,6 +218,25 @@ NSInteger NSBitsPerPixelFromDepth(NSWindowDepth depth) { +@@ -204,6 +219,25 @@ NSInteger NSBitsPerPixelFromDepth(NSWindowDepth depth) { @end @@ -1482,7 +1597,7 @@ index d545120a..db2c12d9 100644 @implementation NSWindow @synthesize identifier = _identifier; -@@ -218,7 +251,8 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -218,7 +252,8 @@ static BOOL _allowsAutomaticWindowTabbing; /* This method is Cococtron specific and can be override by subclasses, do not * change method name */ + (BOOL) hasMainMenuForStyleMask: (NSWindowStyleMask) styleMask { @@ -1492,7 +1607,7 @@ index d545120a..db2c12d9 100644 } /* This method is Cococtron specific and can be override by subclasses, do not -@@ -318,6 +352,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -318,6 +353,7 @@ static BOOL _allowsAutomaticWindowTabbing; [NSApplication sharedApplication]; } _styleMask = styleMask; @@ -1500,7 +1615,7 @@ index d545120a..db2c12d9 100644 _backingType = backing; _isDeferred = defer; -@@ -351,7 +386,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -351,7 +387,7 @@ static BOOL _allowsAutomaticWindowTabbing; [_backgroundView _setWindow: self]; [_backgroundView setNextResponder: self]; @@ -1509,7 +1624,7 @@ index d545120a..db2c12d9 100644 _contentView = [[NSView alloc] initWithFrame: contentViewFrame]; [_contentView setAutoresizesSubviews: YES]; [_contentView -@@ -399,6 +434,8 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -399,6 +435,8 @@ static BOOL _allowsAutomaticWindowTabbing; } - (void) dealloc { @@ -1518,7 +1633,51 @@ index d545120a..db2c12d9 100644 [[NSNotificationCenter defaultCenter] removeObserver: self]; [_childWindows release]; [_title release]; -@@ -1025,7 +1062,34 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -934,6 +972,22 @@ static BOOL _allowsAutomaticWindowTabbing; + return _animationContext; + } + ++/* Dump geometry only, including children which did not receive a resize. ++ * The node/depth bounds keep malformed or unexpectedly large trees bounded. */ ++static void traceGeometryTree(NSView *view, unsigned depth, unsigned *remaining) { ++ if (!view || depth > 12 || !*remaining) ++ return; ++ --*remaining; ++ NSRect frame = [view frame], bounds = [view bounds]; ++ DarlingGeometryTrace("UI_GEOMETRY tree pid=%d depth=%u view=%p class=%s parent=%p frame=%.0f,%.0f,%.0fx%.0f bounds=%.0f,%.0f,%.0fx%.0f mask=%lu autoresizes=%d layer=%d\n", ++ getpid(), depth, view, class_getName([view class]), [view superview], ++ frame.origin.x, frame.origin.y, frame.size.width, frame.size.height, ++ bounds.origin.x, bounds.origin.y, bounds.size.width, bounds.size.height, ++ (unsigned long)[view autoresizingMask], [view autoresizesSubviews], [view wantsLayer]); ++ for (NSView *child in [view subviews]) ++ traceGeometryTree(child, depth + 1, remaining); ++} ++ + - (void) setFrame: (NSRect) newFrame + display: (BOOL) display + animate: (BOOL) animate +@@ -944,8 +998,19 @@ static BOOL _allowsAutomaticWindowTabbing; + _frame = newFrame; + _makeSureIsOnAScreen = YES; + +- [_backgroundView setFrameSize: _frame.size]; ++ /* Native child windows convert their Y origin using the platform ++ * window's height. Update it before laying out/resizing those children. */ + [_platformWindow setFrame: _frame]; ++ [_backgroundView setFrameSize: _frame.size]; ++ ++ if (didSize && getenv("DARLING_UI_GEOMETRY_TRACE")) { ++ NSSize contentSize = [_contentView frame].size; ++ DarlingGeometryTrace("UI_GEOMETRY window pid=%d class=%s frame=%.0fx%.0f content=%.0fx%.0f style=%lu window=%p contentView=%p\n", ++ getpid(), class_getName([self class]), _frame.size.width, _frame.size.height, ++ contentSize.width, contentSize.height, (unsigned long)_styleMask, self, [self contentView]); ++ unsigned remaining = 128; ++ traceGeometryTree(_backgroundView, 0, &remaining); ++ } + + if (didSize) { + [self resetCursorRects]; +@@ -1025,7 +1090,34 @@ static BOOL _allowsAutomaticWindowTabbing; } - (void) setMovable: (BOOL) movable { @@ -1554,7 +1713,7 @@ index d545120a..db2c12d9 100644 } - (void) setBackingType: (NSBackingStoreType) value { -@@ -1234,6 +1298,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1234,6 +1326,7 @@ static BOOL _allowsAutomaticWindowTabbing; - (void) setIgnoresMouseEvents: (BOOL) value { _ignoresMouseEvents = value; @@ -1562,7 +1721,7 @@ index d545120a..db2c12d9 100644 } - (void) setAspectRatio: (NSSize) value { -@@ -1413,8 +1478,14 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1413,8 +1506,14 @@ static BOOL _allowsAutomaticWindowTabbing; } - (NSButton *) standardWindowButton: (NSWindowButton) value { @@ -1579,7 +1738,7 @@ index d545120a..db2c12d9 100644 } - (NSButtonCell *) defaultButtonCell { -@@ -1425,6 +1496,17 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1425,6 +1524,17 @@ static BOOL _allowsAutomaticWindowTabbing; return [_sheetContext sheet]; } @@ -1597,7 +1756,7 @@ index d545120a..db2c12d9 100644 - (id) windowController { return _windowController; } -@@ -1511,10 +1593,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1511,10 +1621,7 @@ static BOOL _allowsAutomaticWindowTabbing; return [_platformWindow isMiniaturized]; } @@ -1609,7 +1768,7 @@ index d545120a..db2c12d9 100644 - (BOOL) inLiveResize { return _isInLiveResize; -@@ -1561,6 +1640,117 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1561,6 +1668,117 @@ static BOOL _allowsAutomaticWindowTabbing; return point; } @@ -1727,7 +1886,7 @@ index d545120a..db2c12d9 100644 - (NSRect) frameRectForContentRect: (NSRect) contentRect { // hasMainMenu is an instance method so we can't just use the class method // frameRectForContentRect:styleMask: -@@ -1580,6 +1761,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1580,6 +1798,10 @@ static BOOL _allowsAutomaticWindowTabbing; } - (NSRect) contentRectForFrameRect: (NSRect) frameRect { @@ -1738,7 +1897,7 @@ index d545120a..db2c12d9 100644 NSRect result = [[NSDisplay currentDisplay] insetRect: frameRect forNativeWindowBorderWithStyle: [self styleMask]]; -@@ -2155,6 +2340,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2155,6 +2377,7 @@ static BOOL _allowsAutomaticWindowTabbing; - (void) orderWindow: (NSWindowOrderingMode) place relativeTo: (NSInteger) relativeTo { @@ -1746,7 +1905,7 @@ index d545120a..db2c12d9 100644 // The move notifications are sent under unknown conditions around // orderFront: in the Apple AppKit, we do them all the time here until it's // figured out. I suspect it is a side effect of off-screen windows being at -@@ -2208,6 +2394,8 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2208,6 +2431,8 @@ static BOOL _allowsAutomaticWindowTabbing; break; } @@ -1755,7 +1914,7 @@ index d545120a..db2c12d9 100644 [self postNotificationName: NSWindowDidMoveNotification]; } -@@ -2257,6 +2445,12 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2257,6 +2482,12 @@ static BOOL _allowsAutomaticWindowTabbing; } - (void) sendEvent: (NSEvent *) event { @@ -1768,7 +1927,7 @@ index d545120a..db2c12d9 100644 // Some events can cause our window to be destroyed // So make sure self lives at least through this current run loop... [[self retain] autorelease]; -@@ -2300,6 +2494,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2300,6 +2531,10 @@ static BOOL _allowsAutomaticWindowTabbing; case NSLeftMouseDown: { NSView *view = [_backgroundView hitTest: [event locationInWindow]]; @@ -1779,7 +1938,7 @@ index d545120a..db2c12d9 100644 if ([view acceptsFirstResponder]) { if ([view needsPanelToBecomeKey]) { -@@ -2307,6 +2505,27 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2307,6 +2542,27 @@ static BOOL _allowsAutomaticWindowTabbing; } } @@ -1807,7 +1966,7 @@ index d545120a..db2c12d9 100644 // Event goes to view, not first responder. [view mouseDown: event]; _mouseDownLocationInWindow = [event locationInWindow]; -@@ -2330,6 +2549,31 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2330,6 +2586,31 @@ static BOOL _allowsAutomaticWindowTabbing; _mouseDownLocationInWindow = NSMakePoint(NAN, NAN); break; @@ -1839,7 +1998,7 @@ index d545120a..db2c12d9 100644 case NSMouseMoved: { NSView *hit = [_backgroundView hitTest: [event locationInWindow]]; -@@ -2536,6 +2780,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2536,6 +2817,7 @@ static BOOL _allowsAutomaticWindowTabbing; } else { [[self platformWindow] hideWindow]; } @@ -1847,7 +2006,21 @@ index d545120a..db2c12d9 100644 } - (void) orderFront: (id) sender { -@@ -3260,8 +3505,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3064,6 +3346,13 @@ static BOOL _allowsAutomaticWindowTabbing; + if (didSize) { + // Don't redraw everything unless we really have to. + [_backgroundView setFrameSize: _frame.size]; ++ if (getenv("DARLING_UI_GEOMETRY_TRACE")) { ++ DarlingGeometryTrace("UI_GEOMETRY native_window pid=%d window=%p contentView=%p frame=%.0fx%.0f\n", ++ getpid(), self, [self contentView], ++ _frame.size.width, _frame.size.height); ++ unsigned remaining = 128; ++ traceGeometryTree(_backgroundView, 0, &remaining); ++ } + [_backgroundView setNeedsDisplay: YES]; + [self resetCursorRects]; + [self saveFrameUsingName: _autosaveFrameName]; +@@ -3260,8 +3549,7 @@ static BOOL _allowsAutomaticWindowTabbing; mouseWasInside && !mouseIsInside) { [exited addObject: area]; } @@ -1857,7 +2030,7 @@ index d545120a..db2c12d9 100644 [moved addObject: area]; } if (options & NSTrackingCursorUpdate && !mouseWasInside && -@@ -3459,7 +3703,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3459,7 +3747,7 @@ static BOOL _allowsAutomaticWindowTabbing; } - (CGSubWindow *) _createSubWindowWithFrame: (CGRect) frame { @@ -2957,7 +3130,7 @@ index bfe3839a..2521ffd5 100644 - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m -index f4925b66..8553a8b4 100644 +index f4925b66..67b084a0 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -38,6 +38,34 @@ @@ -3114,7 +3287,7 @@ index f4925b66..8553a8b4 100644 [_context release]; _context = nil; -@@ -421,6 +499,379 @@ static NSData *makeWindowIcon() { +@@ -421,6 +499,388 @@ static NSData *makeWindowIcon() { } } @@ -3280,9 +3453,18 @@ index f4925b66..8553a8b4 100644 + closedir(dir); + } + -+ /* Keep the software compositor owner across its other window updates. -+ * Otherwise a temporary size mismatch selects stale browser readback. */ -+ if (_remoteFrameSoftwarePublisher && _remoteFrameMapping && !bestSoftware) { ++ /* Keep a software owner across popup updates while the presented size ++ * still belongs to this window. After a real resize, retaining that owner ++ * can strand us on its popup slot even though a matching readback frame ++ * is ready from another publisher. Let normal candidate selection make ++ * that handoff when the old presentation no longer matches a target. */ ++ BOOL presentationMatchesTarget = NO; ++ for (unsigned i = 0; i < frameTargetCount; ++i) ++ if (_remoteFrameWidth == (unsigned)frameTargets[i].width && ++ _remoteFrameHeight == (unsigned)frameTargets[i].height) ++ presentationMatchesTarget = YES; ++ if (_remoteFrameSoftwarePublisher && _remoteFrameMapping && !bestSoftware && ++ presentationMatchesTarget) { + const char *pidText = strrchr(_remoteFrameMappingPath, '-'); + int publisher = pidText ? atoi(pidText + 1) : 0; + if (publisher > 0 && (kill(publisher, 0) == 0 || errno != ESRCH)) { @@ -3494,7 +3676,7 @@ index f4925b66..8553a8b4 100644 - (Window) windowHandle { return _window; } -@@ -478,6 +929,15 @@ static NSData *makeWindowIcon() { +@@ -478,6 +938,15 @@ static NSData *makeWindowIcon() { frame.size.width, frame.size.height); [self invalidateContextWithNewSize: frame.size]; _frame = frame; @@ -3510,7 +3692,7 @@ index f4925b66..8553a8b4 100644 } - (void) setHasShadow: (BOOL) value { -@@ -559,7 +1019,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1028,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -3540,7 +3722,7 @@ index f4925b66..8553a8b4 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -615,6 +1097,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1106,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -3553,7 +3735,7 @@ index f4925b66..8553a8b4 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -666,6 +1154,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1163,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -4587,7 +4769,7 @@ index e1112f40..3a4fa2d6 100644 COREGRAPHICS_EXPORT CGImageRef CGWindowListCreateImageFromArray(CGRect screenBounds, CFArrayRef windowArray, CGWindowImageOption imageOption); diff --git a/CoreText/CMakeLists.txt b/CoreText/CMakeLists.txt -index 1ec11620..08a238ca 100644 +index 1ec11620..af21f5b3 100644 --- a/CoreText/CMakeLists.txt +++ b/CoreText/CMakeLists.txt @@ -28,7 +28,6 @@ set(CoreText_sources @@ -4598,6 +4780,12 @@ index 1ec11620..08a238ca 100644 ) set_source_files_properties(${CoreText_sources} LANGUAGE C) +@@ -58,4 +57,5 @@ add_framework(CoreText + Onyx2D + # native + FreeType ++ fontconfig + ) diff --git a/CoreText/CTFont.m b/CoreText/CTFont.m index 94ddabf9..fa018d3d 100644 --- a/CoreText/CTFont.m @@ -5236,10 +5424,19 @@ index 94ddabf9..fa018d3d 100644 CFTypeID CTFontGetTypeID(void) diff --git a/CoreText/CTFontDescriptor.m b/CoreText/CTFontDescriptor.m -index 8866ce59..eb8a101c 100644 +index 8866ce59..62dcf226 100644 --- a/CoreText/CTFontDescriptor.m +++ b/CoreText/CTFontDescriptor.m -@@ -24,9 +24,23 @@ const CFStringRef kCTFontEnabledAttribute = CFSTR("NSCTFontEnabledAttribute"); +@@ -1,6 +1,7 @@ + #import + +-#include ++#import ++#import + + const CFStringRef kCTFontURLAttribute = CFSTR("NSCTFontFileURLAttribute"); + const CFStringRef kCTFontNameAttribute = CFSTR("NSFontNameAttribute"); +@@ -24,9 +25,103 @@ const CFStringRef kCTFontEnabledAttribute = CFSTR("NSCTFontEnabledAttribute"); const CFStringRef kCTFontFormatAttribute = CFSTR("NSCTFontFormatAttribute"); const CFStringRef kCTFontRegistrationScopeAttribute = CFSTR("NSCTFontRegistrationScopeAttribute"); const CFStringRef kCTFontPriorityAttribute = CFSTR("NSCTFontPriorityAttribute"); @@ -5264,17 +5461,99 @@ index 8866ce59..eb8a101c 100644 + if (value) + CFRetain(value); + return value; ++} ++ ++/* Enumerate actual installed faces, rather than returning the query itself. ++ * FcFontList does not substitute an unrelated font for a missing family. */ ++CFArrayRef CTFontDescriptorCreateMatchingFontDescriptors(CTFontDescriptorRef descriptor, ++ CFSetRef mandatoryAttributes) ++{ ++ if (!descriptor) ++ return NULL; ++ NSDictionary *query = (NSDictionary *)descriptor; ++ FcPattern *pattern = FcPatternCreate(); ++ const CFStringRef keys[] = { kCTFontFamilyNameAttribute, kCTFontNameAttribute, ++ kCTFontStyleNameAttribute, kCTFontDisplayNameAttribute }; ++ const char *properties[] = { FC_FAMILY, FC_POSTSCRIPT_NAME, FC_STYLE, FC_FULLNAME }; ++ for (unsigned i = 0; i < sizeof(keys) / sizeof(keys[0]); ++i) { ++ id value = [query objectForKey:(id)keys[i]]; ++ if ([value isKindOfClass:[NSString class]]) ++ FcPatternAddString(pattern, properties[i], (const FcChar8 *)[value UTF8String]); ++ } ++ FcObjectSet *objects = FcObjectSetBuild(FC_FAMILY, FC_POSTSCRIPT_NAME, ++ FC_STYLE, FC_FULLNAME, FC_FILE, NULL); ++ FcFontSet *fonts = FcFontList(O2FontSharedFontConfig(), pattern, objects); ++ CFMutableArrayRef matchesArray = CFArrayCreateMutable(NULL, 0, &kCFTypeArrayCallBacks); ++ for (int i = 0; fonts && i < fonts->nfont; ++i) { ++ NSMutableDictionary *attributes = [NSMutableDictionary dictionary]; ++ for (unsigned j = 0; j < sizeof(keys) / sizeof(keys[0]); ++j) { ++ FcChar8 *text; ++ if (FcPatternGetString(fonts->fonts[i], properties[j], 0, &text) == FcResultMatch) { ++ NSString *value = [NSString stringWithUTF8String:(const char *)text]; ++ if (value) [attributes setObject:value forKey:(id)keys[j]]; ++ } ++ } ++ FcChar8 *file; ++ if (FcPatternGetString(fonts->fonts[i], FC_FILE, 0, &file) == FcResultMatch) { ++ NSURL *url = [NSURL fileURLWithPath:[NSString stringWithUTF8String:(const char *)file]]; ++ if (url) [attributes setObject:url forKey:(id)kCTFontURLAttribute]; ++ } ++ /* Point size is a request, not an installed-face property. */ ++ id size = [query objectForKey:(id)kCTFontSizeAttribute]; ++ if (size) [attributes setObject:size forKey:(id)kCTFontSizeAttribute]; ++ BOOL matches = YES; ++ for (id key in (NSSet *)mandatoryAttributes) { ++ id expected = [query objectForKey:key]; ++ if (!expected || ![expected isEqual:[attributes objectForKey:key]]) { ++ matches = NO; ++ break; ++ } ++ } ++ if (matches) { ++ CTFontDescriptorRef match = CTFontDescriptorCreateWithAttributes((CFDictionaryRef)attributes); ++ CFArrayAppendValue(matchesArray, match); ++ CFRelease(match); ++ } ++ } ++ if (fonts) FcFontSetDestroy(fonts); ++ FcObjectSetDestroy(objects); ++ FcPatternDestroy(pattern); ++ return matchesArray; ++} ++ ++CTFontDescriptorRef CTFontDescriptorCreateMatchingFontDescriptor(CTFontDescriptorRef descriptor, ++ CFSetRef mandatoryAttributes) ++{ ++ CFArrayRef matches = CTFontDescriptorCreateMatchingFontDescriptors(descriptor, mandatoryAttributes); ++ CTFontDescriptorRef result = NULL; ++ if (matches) { ++ if (CFArrayGetCount(matches)) ++ result = (CTFontDescriptorRef)CFRetain(CFArrayGetValueAtIndex(matches, 0)); ++ CFRelease(matches); ++ } ++ return result; ++} ++ ++CFTypeRef CTFontDescriptorCopyLocalizedAttribute(CTFontDescriptorRef descriptor, ++ CFStringRef attribute, CFStringRef *language) ++{ ++ /* Fall back to the canonical name when localized font metadata is absent. ++ * A NULL language output is valid (Chromium uses it for family names). */ ++ if (language) *language = NULL; ++ return CTFontDescriptorCopyAttribute(descriptor, attribute); } diff --git a/CoreText/CTFontManager.m b/CoreText/CTFontManager.m -index 943e1b78..5c295cbf 100644 +index 943e1b78..049665fb 100644 --- a/CoreText/CTFontManager.m +++ b/CoreText/CTFontManager.m -@@ -1,17 +1,24 @@ +@@ -1,23 +1,43 @@ #import +#import +#import +#import +#import ++#import ++#import const CFStringRef kCTFontManagerRegisteredFontsChangedNotification = CFSTR("CTFontManagerFontChangedNotification"); @@ -5298,7 +5577,26 @@ index 943e1b78..5c295cbf 100644 } CFArrayRef CTFontManagerCopyAvailableFontFamilyNames(void) -@@ -25,3 +32,118 @@ bool CTFontManagerRegisterFontsForURL(CFURLRef fontURL, CTFontManagerScope scope + { +- printf("STUB %s\n", __PRETTY_FUNCTION__); +- return nil; ++ CFDictionaryRef empty = CFDictionaryCreate(NULL, NULL, NULL, 0, ++ &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); ++ CTFontDescriptorRef query = CTFontDescriptorCreateWithAttributes(empty); ++ CFArrayRef descriptors = CTFontDescriptorCreateMatchingFontDescriptors(query, NULL); ++ NSMutableSet *families = [NSMutableSet set]; ++ for (NSDictionary *descriptor in (NSArray *)descriptors) { ++ id family = [descriptor objectForKey:(id)kCTFontFamilyNameAttribute]; ++ if (family) [families addObject:family]; ++ } ++ CFRelease(query); ++ CFRelease(empty); ++ if (descriptors) CFRelease(descriptors); ++ return (CFArrayRef)[[[families allObjects] sortedArrayUsingSelector:@selector(compare:)] copy]; + } + + bool CTFontManagerRegisterFontsForURL(CFURLRef fontURL, CTFontManagerScope scope, CFErrorRef * error) +@@ -25,3 +45,118 @@ bool CTFontManagerRegisterFontsForURL(CFURLRef fontURL, CTFontManagerScope scope printf("STUB %s\n", __PRETTY_FUNCTION__); return false; } @@ -5575,9 +5873,15 @@ index 33dc18c6..78887dd8 100644 @end diff --git a/CoreText/include/CoreText/CTFontDescriptor.h b/CoreText/include/CoreText/CTFontDescriptor.h -index b2688960..61dfef1a 100644 +index b2688960..fee68509 100644 --- a/CoreText/include/CoreText/CTFontDescriptor.h +++ b/CoreText/include/CoreText/CTFontDescriptor.h +@@ -1,4 +1,4 @@ +-#import ++#import + #import + #import + @@ -24,6 +24,19 @@ CORETEXT_EXPORT const CFStringRef kCTFontEnabledAttribute; CORETEXT_EXPORT const CFStringRef kCTFontFormatAttribute; CORETEXT_EXPORT const CFStringRef kCTFontRegistrationScopeAttribute; @@ -5598,11 +5902,15 @@ index b2688960..61dfef1a 100644 CF_IMPLICIT_BRIDGING_ENABLED -@@ -39,5 +52,6 @@ typedef enum CTFontOrientation : uint32_t { +@@ -39,5 +52,10 @@ typedef enum CTFontOrientation : uint32_t { } CTFontOrientation; CORETEXT_EXPORT CFTypeRef CTFontDescriptorCopyAttribute(CTFontDescriptorRef descriptor, CFStringRef attribute); +CORETEXT_EXPORT CTFontDescriptorRef CTFontDescriptorCreateWithAttributes(CFDictionaryRef attributes); ++ ++CORETEXT_EXPORT CFArrayRef CTFontDescriptorCreateMatchingFontDescriptors(CTFontDescriptorRef descriptor, CFSetRef mandatoryAttributes); ++CORETEXT_EXPORT CTFontDescriptorRef CTFontDescriptorCreateMatchingFontDescriptor(CTFontDescriptorRef descriptor, CFSetRef mandatoryAttributes); ++CORETEXT_EXPORT CFTypeRef CTFontDescriptorCopyLocalizedAttribute(CTFontDescriptorRef descriptor, CFStringRef attribute, CFStringRef *language); CF_IMPLICIT_BRIDGING_DISABLED diff --git a/CoreText/include/CoreText/KTFont.h b/CoreText/include/CoreText/KTFont.h @@ -5957,10 +6265,10 @@ index 039d8c94..b518c961 100644 #import diff --git a/QuartzCore/CALayer.m b/QuartzCore/CALayer.m -index 5dcac115..7cc63b0b 100644 +index 5dcac115..5de4f03f 100644 --- a/QuartzCore/CALayer.m +++ b/QuartzCore/CALayer.m -@@ -1,8 +1,234 @@ +@@ -1,8 +1,243 @@ #import +#import +#import @@ -6032,6 +6340,15 @@ index 5dcac115..7cc63b0b 100644 + } + size_t width = IOSurfaceGetWidth(surface); + size_t height = IOSurfaceGetHeight(surface); ++ if (getenv("DARLING_UI_GEOMETRY_TRACE")) { ++ static __thread size_t previousWidth, previousHeight; ++ if (width != previousWidth || height != previousHeight) { ++ fprintf(stderr, "UI_GEOMETRY surface pid=%d pixels=%zux%zu route=%s\n", ++ getpid(), width, height, readback ? "gpu-readback" : "software"); ++ previousWidth = width; ++ previousHeight = height; ++ } ++ } + size_t rowBytes = IOSurfaceGetBytesPerRow(surface); + size_t dataSize = rowBytes * height; + void *base = IOSurfaceGetBaseAddress(surface); @@ -6195,7 +6512,7 @@ index 5dcac115..7cc63b0b 100644 NSString *const kCAFilterLinear = @"linear"; NSString *const kCAFilterNearest = @"nearest"; -@@ -61,13 +287,22 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; +@@ -61,13 +296,22 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; } - (void) setSublayers: (NSArray *) sublayers { @@ -6219,7 +6536,19 @@ index 5dcac115..7cc63b0b 100644 } - (id) delegate { -@@ -171,14 +406,73 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; +@@ -117,6 +361,11 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; + [self addAnimation: action forKey: @"bounds"]; + } + ++ if (!CGRectEqualToRect(_bounds, value) && getenv("DARLING_UI_GEOMETRY_TRACE") && ++ (value.size.width >= 256 || value.size.height >= 256)) ++ fprintf(stderr, "UI_GEOMETRY layer pid=%d class=%s bounds=%.0fx%.0f transform=%.3fx%.3f\n", ++ getpid(), class_getName([self class]), value.size.width, value.size.height, ++ _transform.m11, _transform.m22); + _bounds = value; + } + +@@ -171,14 +420,73 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; _opaque = value; } @@ -6293,7 +6622,19 @@ index 5dcac115..7cc63b0b 100644 } - (CATransform3D) transform { -@@ -236,6 +530,10 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; +@@ -186,6 +494,11 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; + } + + - (void) setTransform: (CATransform3D) value { ++ if (getenv("DARLING_UI_GEOMETRY_TRACE") && ++ (_bounds.size.width >= 256 || _bounds.size.height >= 256)) ++ fprintf(stderr, "UI_GEOMETRY transform pid=%d class=%s bounds=%.0fx%.0f scale=%.3fx%.3f\n", ++ getpid(), class_getName([self class]), _bounds.size.width, _bounds.size.height, ++ value.m11, value.m22); + _transform = value; + } + +@@ -236,6 +549,10 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; } - (void) dealloc { @@ -6304,7 +6645,7 @@ index 5dcac115..7cc63b0b 100644 [_sublayers release]; [_animations release]; [_minificationFilter release]; -@@ -258,6 +556,33 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; +@@ -258,6 +575,33 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; [self setSublayers: [_sublayers arrayByAddingObject: layer]]; } @@ -6338,7 +6679,7 @@ index 5dcac115..7cc63b0b 100644 - (void) replaceSublayer: (CALayer *) layer with: (CALayer *) other { NSMutableArray *layers = [_sublayers mutableCopy]; NSUInteger index = [_sublayers indexOfObjectIdenticalTo: layer]; -@@ -298,9 +623,14 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; +@@ -298,9 +642,14 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; } - (void) removeFromSuperlayer { @@ -6354,7 +6695,7 @@ index 5dcac115..7cc63b0b 100644 } - (void) setNeedsDisplay { -@@ -319,6 +649,19 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; +@@ -319,6 +668,19 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; [_context startTimerIfNeeded]; } @@ -6374,7 +6715,7 @@ index 5dcac115..7cc63b0b 100644 - (CAAnimation *) animationForKey: (NSString *) key { return [_animations objectForKey: key]; } -@@ -364,4 +707,119 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; +@@ -364,4 +726,119 @@ NSString *const kCAContentsFormatGray8Uint = @"Gray8"; _textureId = value; } @@ -7881,3 +8222,34 @@ index ad5e4f9d..dbd997aa 100644 - -@end \ No newline at end of file +diff --git a/AppKit/NSGeometryTrace.h b/AppKit/NSGeometryTrace.h +new file mode 100644 +index 00000000..a51a9241 +--- /dev/null ++++ b/AppKit/NSGeometryTrace.h +@@ -0,0 +1,25 @@ ++/* Opt-in runtime geometry diagnostics; never log view text or client data. */ ++#ifndef DARLING_NS_GEOMETRY_TRACE_H ++#define DARLING_NS_GEOMETRY_TRACE_H ++#include ++#include ++#include ++ ++static inline void DarlingGeometryTrace(const char *format, ...) { ++ if (!getenv("DARLING_UI_GEOMETRY_TRACE")) ++ return; ++ va_list args, copy; ++ va_start(args, format); ++ va_copy(copy, args); ++ vfprintf(stderr, format, args); ++ va_end(args); ++ /* Clients may redirect stderr to a non-log descriptor. Keep a copy in ++ * the guest's temporary directory so their resize events remain visible. */ ++ FILE *file = fopen("/tmp/darling-ui-geometry.log", "a"); ++ if (file) { ++ vfprintf(file, format, copy); ++ fclose(file); ++ } ++ va_end(copy); ++} ++#endif diff --git a/patches/darling.patch b/patches/darling.patch index 9b1c133..f1017ae 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -1,5 +1,5 @@ diff --git a/src/CoreAudio/AudioToolbox/AUComponent.cpp b/src/CoreAudio/AudioToolbox/AUComponent.cpp -index d48eec1ef..c86959d55 100644 +index d48eec1ef..1deb304e9 100644 --- a/src/CoreAudio/AudioToolbox/AUComponent.cpp +++ b/src/CoreAudio/AudioToolbox/AUComponent.cpp @@ -22,13 +22,16 @@ along with Darling. If not, see . @@ -88,6 +88,38 @@ index d48eec1ef..c86959d55 100644 + return status; } + OSStatus +@@ -121,7 +148,14 @@ AudioUnitGetParameter(AudioUnit inUnit, + AudioUnitElement inElement, + AudioUnitParameterValue * outValue) + { +- return dispatchCall(inUnit, kAudioUnitGetParameterSelect, inID, inScope, inElement, outValue); ++ OSStatus status = dispatchCall(inUnit, kAudioUnitGetParameterSelect, inID, inScope, inElement, outValue); ++ { ++ static unsigned count = 0; ++ if (rclolAudioTraceEnabled() && (count++ < 200 || count % 500 == 0)) ++ rclolAudioTrace("UNIT_GET_PARAM unit=%p id=%u scope=%u elem=%u value=%g status=%d\n", (void*) inUnit, ++ (unsigned) inID, (unsigned) inScope, (unsigned) inElement, (outValue && !status) ? (double) *outValue : 0.0, (int) status); ++ } ++ return status; + } + + OSStatus +@@ -132,7 +166,14 @@ AudioUnitSetParameter(AudioUnit inUnit, + AudioUnitParameterValue inValue, + UInt32 inBufferOffsetInFrames) + { +- return dispatchCall(inUnit, kAudioUnitSetParameterSelect, inID, inScope, inElement, inValue, inBufferOffsetInFrames); ++ OSStatus status = dispatchCall(inUnit, kAudioUnitSetParameterSelect, inID, inScope, inElement, inValue, inBufferOffsetInFrames); ++ { ++ static unsigned count = 0; ++ if (rclolAudioTraceEnabled() && (count++ < 200 || count % 500 == 0)) ++ rclolAudioTrace("UNIT_SET_PARAM unit=%p id=%u scope=%u elem=%u value=%g offset=%u status=%d\n", (void*) inUnit, ++ (unsigned) inID, (unsigned) inScope, (unsigned) inElement, (double) inValue, (unsigned) inBufferOffsetInFrames, (int) status); ++ } ++ return status; + } + OSStatus diff --git a/src/CoreAudio/AudioToolbox/AUGraph.cpp b/src/CoreAudio/AudioToolbox/AUGraph.cpp index ca20b90df..e90b37163 100644 @@ -643,7 +675,7 @@ index ca20b90df..e90b37163 100644 + return status; +} diff --git a/src/CoreAudio/AudioToolbox/AudioComponentManager.mm b/src/CoreAudio/AudioToolbox/AudioComponentManager.mm -index 5fa33817d..4c1f25a33 100644 +index 5fa33817d..8588e69d8 100644 --- a/src/CoreAudio/AudioToolbox/AudioComponentManager.mm +++ b/src/CoreAudio/AudioToolbox/AudioComponentManager.mm @@ -1,3 +1,4 @@ @@ -677,7 +709,7 @@ index 5fa33817d..4c1f25a33 100644 RegisteredComponent& regd = it->second; + static unsigned traceCount=0; -+ if(getenv("RCLOL_AUDIO_TRACE") && traceCount++<16) ++ if(rclolAudioTraceEnabled() && traceCount++<16) + rclolAudioTrace("AUDIO_COMPONENT version=%u subtype=%08x user_override=%d\n", + regd.version,regd.desc.componentSubType, + regd.bundlePath.find("DarlingRiotAudio.component")!=std::string::npos); @@ -685,10 +717,14 @@ index 5fa33817d..4c1f25a33 100644 { // Load the bundle diff --git a/src/CoreAudio/AudioToolbox/AudioConverter.cpp b/src/CoreAudio/AudioToolbox/AudioConverter.cpp -index 202121cd9..c40ba22c2 100644 +index 202121cd9..5672fcba1 100644 --- a/src/CoreAudio/AudioToolbox/AudioConverter.cpp +++ b/src/CoreAudio/AudioToolbox/AudioConverter.cpp -@@ -22,13 +22,35 @@ along with Darling. If not, see . +@@ -19,16 +19,39 @@ along with Darling. If not, see . + + #include "AudioConverter.h" + #include "AudioConverterImpl.h" ++#include "../AudioTrace.h" #include #include #include @@ -702,7 +738,7 @@ index 202121cd9..c40ba22c2 100644 +static std::unordered_set liveConverters; + +static bool audioTrace() { -+ static const bool enabled = std::getenv("RCLOL_AUDIO_TRACE") != nullptr; ++ static const bool enabled = rclolAudioTraceEnabled(); + return enabled; +} + @@ -726,7 +762,7 @@ index 202121cd9..c40ba22c2 100644 { // FIXME return AudioConverterNew(inSourceFormat, inDestinationFormat, outAudioConverter); -@@ -47,6 +69,15 @@ OSStatus AudioConverterReset(AudioConverterRef inAudioConverter) +@@ -47,6 +70,15 @@ OSStatus AudioConverterReset(AudioConverterRef inAudioConverter) OSStatus AudioConverterDispose(AudioConverterRef inAudioConverter) { @@ -881,7 +917,7 @@ index 000000000..6e85e8230 + return noErr; +} diff --git a/src/CoreAudio/CoreAudio/AudioHardware.cpp b/src/CoreAudio/CoreAudio/AudioHardware.cpp -index 7243e6a62..fb48486c6 100644 +index 7243e6a62..647c8aff7 100644 --- a/src/CoreAudio/CoreAudio/AudioHardware.cpp +++ b/src/CoreAudio/CoreAudio/AudioHardware.cpp @@ -1,3 +1,4 @@ @@ -944,25 +980,59 @@ index 7243e6a62..fb48486c6 100644 void AudioObjectShow(AudioObjectID inObjectID) { AudioHardwareImpl* obj = GetObject(inObjectID); -@@ -68,6 +101,9 @@ void AudioObjectShow(AudioObjectID inObjectID) +@@ -68,45 +101,94 @@ void AudioObjectShow(AudioObjectID inObjectID) Boolean AudioObjectHasProperty(AudioObjectID inObjectID, const AudioObjectPropertyAddress* inAddress) { + if((inObjectID&0xffff0000u)==0x44410000u) { -+ UInt32 size=0;return GetStreamPropertyData(inObjectID,inAddress,&size,nullptr)==noErr; ++ UInt32 size=0;bool has=GetStreamPropertyData(inObjectID,inAddress,&size,nullptr)==noErr; ++ rclolAudioTrace("HAS stream=%08x selector=%08x scope=%08x has=%d\n",inObjectID,inAddress->mSelector,inAddress->mScope,has); ++ return has; + } AudioHardwareImpl* obj = GetObject(inObjectID); - if (!obj) +- if (!obj) ++ if (!obj) { ++ rclolAudioTrace("HAS object=%u selector=%08x BADOBJECT\n",inObjectID,inAddress->mSelector); return 0; -@@ -89,24 +125,50 @@ OSStatus AudioObjectGetPropertyDataSize(AudioObjectID inObjectID, ++ } + +- return obj->hasProperty(inAddress); ++ bool has=obj->hasProperty(inAddress); ++ rclolAudioTrace("HAS object=%u selector=%08x scope=%08x elem=%u has=%d\n",inObjectID,inAddress->mSelector,inAddress->mScope,inAddress->mElement,has); ++ return has; + } + + OSStatus AudioObjectIsPropertySettable(AudioObjectID inObjectID, + const AudioObjectPropertyAddress* inAddress, Boolean* outIsSettable) + { + AudioHardwareImpl* obj = GetObject(inObjectID); +- if (!obj) ++ if (!obj) { ++ rclolAudioTrace("SETTABLE object=%08x selector=%08x BADOBJECT\n",inObjectID,inAddress->mSelector); + return kAudioHardwareBadObjectError; ++ } + +- return obj->isPropertySettable(inAddress, outIsSettable); ++ OSStatus status=obj->isPropertySettable(inAddress, outIsSettable); ++ rclolAudioTrace("SETTABLE object=%u selector=%08x scope=%08x elem=%u status=%d settable=%d\n",inObjectID,inAddress->mSelector,inAddress->mScope,inAddress->mElement,status,status?0:*outIsSettable); ++ return status; + } + + OSStatus AudioObjectGetPropertyDataSize(AudioObjectID inObjectID, const AudioObjectPropertyAddress* inAddress, UInt32 inQualifierDataSize, const void* inQualifierData, UInt32* outDataSize) { -+ if((inObjectID&0xffff0000u)==0x44410000u) -+ return GetStreamPropertyData(inObjectID,inAddress,outDataSize,nullptr); ++ if((inObjectID&0xffff0000u)==0x44410000u) { ++ OSStatus status=GetStreamPropertyData(inObjectID,inAddress,outDataSize,nullptr); ++ rclolAudioTrace("SIZE stream=%08x selector=%08x scope=%08x status=%d\n",inObjectID,inAddress->mSelector,inAddress->mScope,status); ++ return status; ++ } AudioHardwareImpl* obj = GetObject(inObjectID); - if (!obj) +- if (!obj) ++ if (!obj) { ++ rclolAudioTrace("SIZE object=%08x selector=%08x BADOBJECT\n",inObjectID,inAddress->mSelector); return kAudioHardwareBadObjectError; ++ } - return obj->getPropertyDataSize(inAddress, inQualifierDataSize, - inQualifierData, outDataSize); @@ -992,36 +1062,49 @@ index 7243e6a62..fb48486c6 100644 + *static_cast(value->mOutputData)=found; + value->mOutputDataSize=sizeof(found);*ioDataSize=sizeof(*value);return noErr; + } -+ if((inObjectID&0xffff0000u)==0x44410000u) -+ return GetStreamPropertyData(inObjectID,inAddress,ioDataSize,outData); ++ if((inObjectID&0xffff0000u)==0x44410000u) { ++ OSStatus status=GetStreamPropertyData(inObjectID,inAddress,ioDataSize,outData); ++ rclolAudioTrace("GET stream=%08x selector=%08x scope=%08x status=%d\n",inObjectID,inAddress->mSelector,inAddress->mScope,status); ++ return status; ++ } AudioHardwareImpl* obj = GetObject(inObjectID); - if (!obj) +- if (!obj) ++ if (!obj) { ++ rclolAudioTrace("GET object=%08x selector=%08x BADOBJECT\n",inObjectID,inAddress->mSelector); return kAudioHardwareBadObjectError; ++ } - return obj->getPropertyData(inAddress, inQualifierDataSize, inQualifierData, - ioDataSize, outData); + static unsigned traceCount=0; -+ if(getenv("RCLOL_AUDIO_TRACE") && traceCount++<100) ++ if(rclolAudioTraceEnabled() && traceCount++<100) + fprintf(stderr,"AUDIO_HARDWARE_GET object=%u property=%08x scope=%08x\n",inObjectID,inAddress->mSelector,inAddress->mScope); + OSStatus status=obj->getPropertyData(inAddress,inQualifierDataSize,inQualifierData,ioDataSize,outData); -+ rclolAudioTrace("GET object=%u selector=%08x scope=%08x status=%d size=%u\n",inObjectID,inAddress->mSelector,inAddress->mScope,status,status?0:*ioDataSize); ++ rclolAudioTrace("GET object=%u selector=%08x scope=%08x elem=%u status=%d size=%u\n",inObjectID,inAddress->mSelector,inAddress->mScope,inAddress->mElement,status,status?0:*ioDataSize); + return status; } OSStatus AudioObjectSetPropertyData(AudioObjectID inObjectID, -@@ -117,8 +179,9 @@ OSStatus AudioObjectSetPropertyData(AudioObjectID inObjectID, - if (!obj) +@@ -114,11 +196,15 @@ OSStatus AudioObjectSetPropertyData(AudioObjectID inObjectID, + const void* inQualifierData, UInt32 inDataSize, const void* inData) + { + AudioHardwareImpl* obj = GetObject(inObjectID); +- if (!obj) ++ if (!obj) { ++ rclolAudioTrace("SET object=%08x selector=%08x scope=%08x elem=%u BADOBJECT\n",inObjectID,inAddress->mSelector,inAddress->mScope,inAddress->mElement); return kAudioHardwareBadObjectError; ++ } - return obj->setPropertyData(inAddress, inQualifierDataSize, inQualifierData, - inDataSize, inData); + OSStatus status=obj->setPropertyData(inAddress,inQualifierDataSize,inQualifierData,inDataSize,inData); -+ rclolAudioTrace("SET object=%u selector=%08x scope=%08x status=%d\n",inObjectID,inAddress->mSelector,inAddress->mScope,status); ++ rclolAudioTrace("SET object=%u selector=%08x scope=%08x elem=%u size=%u value=%g status=%d\n",inObjectID,inAddress->mSelector,inAddress->mScope,inAddress->mElement,inDataSize, ++ (inData && inDataSize==sizeof(Float32))?(double)*(const Float32*)inData:0.0,status); + return status; } OSStatus AudioObjectAddPropertyListener(AudioObjectID inObjectID, -@@ -129,7 +192,9 @@ OSStatus AudioObjectAddPropertyListener(AudioObjectID inObjectID, +@@ -129,7 +215,9 @@ OSStatus AudioObjectAddPropertyListener(AudioObjectID inObjectID, if (!obj) return kAudioHardwareBadObjectError; @@ -1455,19 +1538,20 @@ index 85315086f..c43382686 100644 pa_sample_spec AudioHardwareImplPA::paSampleSpecForASBD(const AudioStreamBasicDescription& asbd, bool* convertSignedUnsigned) diff --git a/src/CoreAudio/CoreAudio/pulse/AudioHardwareImplPA.h b/src/CoreAudio/CoreAudio/pulse/AudioHardwareImplPA.h -index 8421491cd..3fe1c4e2c 100644 +index 8421491cd..03a530bfd 100644 --- a/src/CoreAudio/CoreAudio/pulse/AudioHardwareImplPA.h +++ b/src/CoreAudio/CoreAudio/pulse/AudioHardwareImplPA.h -@@ -38,6 +38,8 @@ public: +@@ -38,6 +38,9 @@ public: const void* inQualifierData, UInt32 inDataSize, const void* inData) override; void getPAContext(void (^cb)(pa_context*)); + void syncPA(void (^block)(void)); ++ dispatch_queue_t paQueue() const { return m_loop ? m_loop->queue() : nullptr; } + OSStatus stop(AudioDeviceIOProcID inProcID) override; static pa_sample_spec paSampleSpecForASBD(const AudioStreamBasicDescription& asbd, bool* convertSignedUnsigned = nullptr); protected: AudioHardwareStream* createStream(AudioDeviceIOProc callback, void* clientData) override; -@@ -49,4 +51,3 @@ private: +@@ -49,4 +52,3 @@ private: }; #endif @@ -1586,14 +1670,54 @@ index d6825f1d5..739b70011 100644 if (status != noErr) diff --git a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.cpp b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.cpp -index 278630f02..00db02f9c 100644 +index 278630f02..54b463c2f 100644 --- a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.cpp +++ b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.cpp -@@ -21,16 +21,17 @@ along with Darling. If not, see . +@@ -21,71 +21,318 @@ along with Darling. If not, see . #include "AudioHardwareImplPA.h" #include #include +#include ++#include "../../AudioTrace.h" ++ ++// Real CoreAudio calls an output IOProc once per IO cycle: evenly spaced at the buffer ++// period, with inOutputTime.mHostTime advancing by one buffer duration per call. Clients ++// such as Chromium's AUHALStream rely on that (they read the previous renderer buffer and ++// request the next one on every call), so PulseAudio's bursty write requests must not ++// drive the IOProc directly. Instead a dispatch timer on the PA queue ticks at the block ++// period and renders at most one block per tick (plus bounded catch-up after a late wakeup). ++ ++static const unsigned kPrefillBlocks = 8; // == tlength ++static const unsigned kMaxCatchUpBlocks = 2; // extra blocks after a late wakeup ++static const unsigned kServoWindowTicks = 32; ++ ++static bool rateLimited(unsigned& counter, unsigned head, unsigned every) { ++ if (!rclolAudioTraceEnabled()) return false; ++ unsigned n = counter++; ++ return n < head || n % every == 0; ++} ++static unsigned g_wcbEvents = 0, g_iopEvents = 0, g_tickEvents = 0, g_paceEvents = 0; ++static bool wcbShouldTrace() { return rateLimited(g_wcbEvents, 50, 1000); } ++static bool iopShouldTrace() { return rateLimited(g_iopEvents, 200, 500); } ++static bool tickShouldTrace() { return rateLimited(g_tickEvents, 200, 500); } ++static bool paceShouldTrace() { return rateLimited(g_paceEvents, 100, 100); } ++ ++static const mach_timebase_info_data_t& timebase() { ++ static mach_timebase_info_data_t tb = [] { ++ mach_timebase_info_data_t t = {0, 0}; ++ mach_timebase_info(&t); ++ if (!t.numer || !t.denom) t.numer = t.denom = 1; ++ return t; ++ }(); ++ return tb; ++} ++static uint64_t hostToNs(uint64_t host) { ++ return uint64_t((unsigned __int128) host * timebase().numer / timebase().denom); ++} ++static uint64_t nsToHost(uint64_t ns) { ++ return uint64_t((unsigned __int128) ns * timebase().denom / timebase().numer); ++} ++static uint64_t nowNs() { return hostToNs(mach_absolute_time()); } AudioHardwareStreamPAOutput::AudioHardwareStreamPAOutput(AudioHardwareImplPA* hw, AudioDeviceIOProc callback, void* clientData) : AudioHardwareStreamPA(hw, callback, clientData) @@ -1601,65 +1725,420 @@ index 278630f02..00db02f9c 100644 + initialize(); } - void AudioHardwareStreamPAOutput::paStreamWriteCB(pa_stream* s, size_t length, void* self) +-void AudioHardwareStreamPAOutput::paStreamWriteCB(pa_stream* s, size_t length, void* self) ++AudioHardwareStreamPAOutput::~AudioHardwareStreamPAOutput() { - AudioHardwareStreamPAOutput* This = static_cast(self); +- AudioHardwareStreamPAOutput* This = static_cast(self); - std::unique_lock l(This->m_stopMutex); ++ // Normally already done by stop(); the timer must never outlive this object. ++ static_cast(m_hw)->syncPA(^{ m_running = false; cancelTimer(); }); ++} - if (!This->m_running) +- if (!This->m_running) ++void AudioHardwareStreamPAOutput::stop() ++{ ++ // Runs on (or synchronously with) the PA queue, so no tick is in flight unless stop() ++ // was called from inside the IOProc, which renderBlock() handles via m_running. ++ // Cancellation guarantees the timer handler is never invoked again. ++ static_cast(m_hw)->syncPA(^{ m_running = false; cancelTimer(); }); ++ AudioHardwareStreamPA::stop(); ++} ++ ++void AudioHardwareStreamPAOutput::cancelTimer() ++{ ++ if (m_timer) { -@@ -55,17 +56,26 @@ void AudioHardwareStreamPAOutput::paStreamWriteCB(pa_stream* s, size_t length, v - - // std::cout << "AudioHardwareStreamPAOutput() length req=" << length << ", done=" << done << std::endl; - size_t rqsize = std::min(This->m_bufferSize, length-done); -- pa_stream_begin_write(This->m_stream, (void**) &abl->mBuffers[0].mData, &rqsize); -+ if(pa_stream_begin_write(This->m_stream,(void**)&abl->mBuffers[0].mData,&rqsize)<0 || !rqsize) break; - - abl->mBuffers[0].mDataByteSize = rqsize; -+ fake.mHostTime=mach_absolute_time(); -+ fake.mSampleTime=This->m_frames; -+ fake.mFlags=kAudioTimeStampHostTimeValid|kAudioTimeStampSampleTimeValid; - - // Call the client for more data - OSStatus status = This->m_callback(This->m_hw->id(), &fake, nullptr, nullptr, abl, &fake, This->m_clientData); -+ if (!This->m_running) { -+ // The client stopped/disconnected the stream during its IOProc. -+ pa_stream_cancel_write(s); -+ return; -+ } - if (status != noErr || !abl->mBuffers[0].mDataByteSize) - { - // std::cerr << "AudioDeviceIOProc returned " << status << ", corking...\n"; - - pa_stream_cork(This->m_stream, true, [](pa_stream*, int, void*) {}, nullptr); -+ pa_stream_cancel_write(This->m_stream); - break; - } - else -@@ -85,6 +95,7 @@ void AudioHardwareStreamPAOutput::paStreamWriteCB(pa_stream* s, size_t length, v - } - - done += abl->mBuffers[0].mDataByteSize; -+ if(This->m_hw->asbd().mBytesPerFrame) This->m_frames+=abl->mBuffers[0].mDataByteSize/This->m_hw->asbd().mBytesPerFrame; +- pa_stream_cork(This->m_stream, true, [](pa_stream*, int, void*) {}, nullptr); +- return; ++ dispatch_source_cancel(m_timer); ++ dispatch_release(m_timer); ++ m_timer = nullptr; ++ } ++} ++ ++uint64_t AudioHardwareStreamPAOutput::outputLatencyNs(size_t writable, bool* fromPA) ++{ ++ pa_usec_t usec = 0; ++ int negative = 0; ++ if (pa_stream_get_latency(m_stream, &usec, &negative) == 0) ++ { ++ *fromPA = true; ++ return negative ? 0 : uint64_t(usec) * 1000; } ++ // No timing info yet: at least account for what is queued in the PA buffer. ++ *fromPA = false; ++ const pa_buffer_attr* attr = pa_stream_get_buffer_attr(m_stream); ++ const pa_sample_spec* spec = pa_stream_get_sample_spec(m_stream); ++ if (!attr || !spec || writable == size_t(-1) || attr->tlength <= writable) ++ return 0; ++ return uint64_t(pa_bytes_to_usec(attr->tlength - writable, spec)) * 1000; ++} ++ ++AudioHardwareStreamPAOutput::RenderResult AudioHardwareStreamPAOutput::renderBlock(bool trace) ++{ ++ if (!m_running || m_corked || !m_stream || !m_blockBytes) ++ return RenderResult::Stopped; + +- // std::cout << "AudioHardwareStreamPAOutput::paStreamWriteCB()\n"; ++ const size_t writable = pa_stream_writable_size(m_stream); ++ if (writable == size_t(-1) || writable < m_blockBytes) ++ return RenderResult::NoSpace; + +- AudioTimeStamp fake = {0}; + AudioBufferList* abl = static_cast(alloca(sizeof(AudioBufferList) + sizeof(AudioBuffer))); ++ // Non-interleaved (planar) audio would have multiple buffers, but PA doesn't even support that AFAIK ++ abl->mNumberBuffers = 1; ++ abl->mBuffers[0].mNumberChannels = m_hw->asbd().mChannelsPerFrame; ++ ++ size_t rqsize = m_blockBytes; ++ if (pa_stream_begin_write(m_stream, (void**)&abl->mBuffers[0].mData, &rqsize) < 0 || !rqsize) ++ { ++ if (paceShouldTrace()) rclolAudioTrace("PA_OUT begin_write failed rqsize=%zu\n", rqsize); ++ return RenderResult::NoSpace; ++ } ++ if (rqsize < m_blockBytes) ++ { ++ // Never hand the client a partial block (real HALs use a fixed frame count). ++ if (paceShouldTrace()) rclolAudioTrace("PA_OUT granted rqsize=%zu < block=%zu: cancel\n", rqsize, m_blockBytes); ++ pa_stream_cancel_write(m_stream); ++ return RenderResult::NoSpace; ++ } ++ abl->mBuffers[0].mDataByteSize = m_blockBytes; ++ ++ bool latencyFromPA = false; ++ const uint64_t latencyNs = outputLatencyNs(writable, &latencyFromPA); ++ const double rate = m_hw->asbd().mSampleRate; ++ const double latencyFrames = rate > 0 ? double(latencyNs) * rate / 1e9 : 0; ++ const uint64_t nowHost = mach_absolute_time(); + +- size_t done = 0; ++ AudioTimeStamp now = {0}; ++ now.mHostTime = nowHost; ++ // Device position currently being played: the block about to be rendered starts ++ // latencyFrames after it, matching the host-time difference below. ++ now.mSampleTime = std::max(0.0, double(m_frames) - latencyFrames); ++ now.mRateScalar = 1.0; ++ now.mFlags = kAudioTimeStampHostTimeValid | kAudioTimeStampSampleTimeValid | kAudioTimeStampRateScalarValid; + +- while (done < length && This->m_running) ++ AudioTimeStamp output = now; ++ output.mHostTime = nowHost + nsToHost(latencyNs); ++ output.mSampleTime = double(m_frames); ++ ++ const double dHostMs = m_lastIOProcHost ? double(hostToNs(nowHost - m_lastIOProcHost)) / 1e6 : 0; ++ m_lastIOProcHost = nowHost; ++ ++ OSStatus status = m_callback(m_hw->id(), &now, nullptr, nullptr, abl, &output, m_clientData); ++ if (trace) ++ rclolAudioTrace("PA_OUT iop dHostMs=%.3f writable=%zu latencyMs=%.2f(%s) frames=%llu status=%d size=%u\n", ++ dHostMs, writable, double(latencyNs) / 1e6, latencyFromPA ? "pa" : "est", ++ (unsigned long long) m_frames, (int) status, (unsigned) abl->mBuffers[0].mDataByteSize); ++ ++ if (!m_running) + { +- // Non-interleaved (planar) audio would have multiple buffers, but PA doesn't even support that AFAIK +- abl->mNumberBuffers = 1; ++ // The client stopped/disconnected the stream during its IOProc. ++ pa_stream_cancel_write(m_stream); ++ return RenderResult::Stopped; ++ } ++ if (status != noErr || !abl->mBuffers[0].mDataByteSize) ++ { ++ if (paceShouldTrace()) rclolAudioTrace("PA_OUT IOProc failed/empty status=%d size=%u: CORK\n", (int) status, ++ (unsigned) abl->mBuffers[0].mDataByteSize); ++ pa_stream_cancel_write(m_stream); ++ pa_stream_cork(m_stream, true, [](pa_stream*, int, void*) {}, nullptr); ++ m_corked = true; ++ return RenderResult::Stopped; ++ } + +- abl->mBuffers[0].mNumberChannels = 2; +- // abl->mBuffers[0].mData = This->m_buffer; ++ if (m_convertSignedUnsigned) ++ transformSignedUnsigned(abl); + +- // std::cout << "AudioHardwareStreamPAOutput() length req=" << length << ", done=" << done << std::endl; +- size_t rqsize = std::min(This->m_bufferSize, length-done); +- pa_stream_begin_write(This->m_stream, (void**) &abl->mBuffers[0].mData, &rqsize); ++ // Always submit exactly one block so the sample clock stays block-aligned. ++ int rv = pa_stream_write(m_stream, abl->mBuffers[0].mData, m_blockBytes, nullptr, 0, PA_SEEK_RELATIVE); ++ if (rv != 0) ++ { ++ if (paceShouldTrace()) rclolAudioTrace("PA_OUT pa_stream_write rv=%d\n", rv); ++ return RenderResult::NoSpace; ++ } ++ m_frames += m_blockFrames; ++ return RenderResult::Written; ++} + +- abl->mBuffers[0].mDataByteSize = rqsize; ++uint64_t AudioHardwareStreamPAOutput::deadlineNs() const ++{ ++ return m_anchorNs + uint64_t(std::max(0.0, double(m_tickIndex) * m_periodNs - m_advanceNs)); ++} + +- // Call the client for more data +- OSStatus status = This->m_callback(This->m_hw->id(), &fake, nullptr, nullptr, abl, &fake, This->m_clientData); +- if (status != noErr || !abl->mBuffers[0].mDataByteSize) +- { +- // std::cerr << "AudioDeviceIOProc returned " << status << ", corking...\n"; ++void AudioHardwareStreamPAOutput::armTimer(uint64_t now) ++{ ++ if (!m_timer) ++ return; ++ const uint64_t deadline = deadlineNs(); ++ const int64_t delta = deadline > now ? int64_t(deadline - now) : 0; ++ dispatch_source_set_timer(m_timer, dispatch_time(DISPATCH_TIME_NOW, delta), DISPATCH_TIME_FOREVER, 100 * NSEC_PER_USEC); ++} + +- pa_stream_cork(This->m_stream, true, [](pa_stream*, int, void*) {}, nullptr); +- break; +- } +- else +- { +- if (This->m_convertSignedUnsigned) +- This->transformSignedUnsigned(abl); +- +- // std::cout << "AudioHardwareStreamPAOutput::paStreamWriteCB(): got " << abl->mBuffers[0].mDataByteSize << " bytes\n"; +- int rv = pa_stream_write(This->m_stream, abl->mBuffers[0].mData, abl->mBuffers[0].mDataByteSize, nullptr, 0, PA_SEEK_RELATIVE); +- if (rv != 0) +- { +- // std::cerr << "pa_stream_write() failed\n"; +- break; +- } +- //else +- // std::cout << "pa_stream_write() OK\n"; +- } ++void AudioHardwareStreamPAOutput::prefill() ++{ ++ if (!m_running || m_corked || m_primed || !m_stream || pa_stream_get_state(m_stream) != PA_STREAM_READY) ++ return; ++ ++ // The only burst: fill the target buffer once at start. ++ unsigned blocks = 0; ++ RenderResult r = RenderResult::Written; ++ while (blocks < kPrefillBlocks && (r = renderBlock(iopShouldTrace())) == RenderResult::Written) ++ ++blocks; ++ if (rclolAudioTraceEnabled()) ++ rclolAudioTrace("PA_OUT prefill blocks=%u result=%d periodMs=%.3f blockFrames=%u\n", blocks, (int) r, ++ m_periodNs / 1e6, m_blockFrames); ++ if (r == RenderResult::Stopped || !blocks) ++ return; ++ ++ m_primed = true; ++ m_anchorNs = nowNs(); ++ m_tickIndex = 1; ++ m_advanceNs = 0; ++ armTimer(m_anchorNs); ++} ++ ++void AudioHardwareStreamPAOutput::tick() ++{ ++ if (!m_running || m_corked || !m_timer) ++ return; ++ if (!m_primed) ++ { ++ prefill(); ++ return; ++ } ++ ++ uint64_t now = nowNs(); ++ uint64_t deadline = deadlineNs(); ++ if (now < deadline) ++ { ++ // Early wakeup (leeway); wait for the real deadline. ++ armTimer(now); ++ return; ++ } ++ ++ const bool trace = tickShouldTrace(); ++ const size_t writable = pa_stream_writable_size(m_stream); ++ if (writable != size_t(-1)) ++ m_windowMinWritable = std::min(m_windowMinWritable, writable); ++ ++ const uint64_t behind = uint64_t(double(now - deadline) / m_periodNs); ++ unsigned toRender = 1; ++ if (behind > kMaxCatchUpBlocks) ++ { ++ // Far behind (stalled queue, suspended process): don't burst, restart the clock. ++ if (paceShouldTrace()) ++ rclolAudioTrace("PA_OUT reanchor lateMs=%.3f behindBlocks=%llu\n", double(now - deadline) / 1e6, ++ (unsigned long long) behind); ++ m_anchorNs = now; ++ m_tickIndex = 0; ++ m_advanceNs = 0; ++ } ++ else if (behind) ++ { ++ toRender += unsigned(behind); ++ if (paceShouldTrace()) ++ rclolAudioTrace("PA_OUT catchup lateMs=%.3f blocks=%u\n", double(now - deadline) / 1e6, toRender); ++ } ++ ++ if (trace) ++ rclolAudioTrace("PA_OUT tick lateMs=%.3f writable=%zu render=%u\n", double(now - deadline) / 1e6, writable, toRender); ++ ++ for (unsigned i = 0; i < toRender; i++) ++ { ++ RenderResult r = renderBlock(iopShouldTrace()); ++ if (r == RenderResult::Stopped) ++ return; // also covers stop() from inside the IOProc (timer already cancelled) ++ if (r == RenderResult::NoSpace && paceShouldTrace()) ++ rclolAudioTrace("PA_OUT skip: writable=%zu < block=%zu\n", pa_stream_writable_size(m_stream), m_blockBytes); ++ // The IO cycle elapses either way; a skipped cycle means PA is consuming slower than us. ++ ++m_tickIndex; ++ } + +- done += abl->mBuffers[0].mDataByteSize; ++ // Drift servo: the PA sink clock isn't our clock. If the buffer stayed under-filled for a ++ // whole window (min writable >= 2 blocks), run ~6% faster for the next window. Each tick ++ // stays a single, evenly spaced IOProc call. ++ if (++m_windowTicks >= kServoWindowTicks) ++ { ++ const bool low = m_windowMinWritable != SIZE_MAX && m_windowMinWritable >= 2 * m_blockBytes; ++ const double step = low ? m_periodNs / 16 : 0; ++ if (step != m_servoStepNs && paceShouldTrace()) ++ rclolAudioTrace("PA_OUT servo minWritable=%zu step=%s\n", m_windowMinWritable, low ? "fast" : "off"); ++ m_servoStepNs = step; ++ m_windowMinWritable = SIZE_MAX; ++ m_windowTicks = 0; + } ++ m_advanceNs += m_servoStepNs; ++ ++ armTimer(nowNs()); ++} ++ ++void AudioHardwareStreamPAOutput::paStreamWriteCB(pa_stream* s, size_t length, void* self) ++{ ++ AudioHardwareStreamPAOutput* This = static_cast(self); ++ ++ if (!This->m_running) ++ { ++ if (wcbShouldTrace()) rclolAudioTrace("PA_WRITE_CB length=%zu not-running: corking\n", length); ++ pa_stream_cork(This->m_stream, true, [](pa_stream*, int, void*) {}, nullptr); ++ return; ++ } ++ ++ // Rendering is paced by the IO timer; the write request only triggers the initial prefill. ++ if (wcbShouldTrace()) ++ rclolAudioTrace("PA_WRITE_CB length=%zu writable=%zu primed=%d corked=%d\n", length, ++ pa_stream_writable_size(s), This->m_primed, This->m_corked); ++ if (!This->m_timer) ++ { ++ // No pacing timer could be created: fall back to rendering whole blocks on demand. ++ while (This->renderBlock(iopShouldTrace()) == RenderResult::Written) {} ++ return; ++ } ++ if (!This->m_primed) ++ This->prefill(); } -@@ -108,4 +119,3 @@ void AudioHardwareStreamPAOutput::start() - // pa_stream_cork(m_stream, false, [](pa_stream*, int, void*) {}, nullptr); + void AudioHardwareStreamPAOutput::start() +@@ -93,10 +340,44 @@ void AudioHardwareStreamPAOutput::start() + struct pa_buffer_attr battr; + + battr.fragsize = uint32_t(-1); +- battr.maxlength = uint32_t(m_bufferSize); +- battr.minreq = uint32_t(-1); +- battr.prebuf = battr.maxlength; +- battr.tlength = uint32_t(-1); ++ // Size the server buffer in whole IOProc blocks so every IO cycle can supply one. ++ uint32_t bpf = m_hw->asbd().mBytesPerFrame; ++ uint32_t block = bpf ? (m_hw->bufferSize() / bpf) * bpf : m_bufferSize; ++ if (!block) ++ block = m_bufferSize; ++ // ~46 ms target at 256 frames/44.1 kHz: a 2-block target underran, since each ++ // block waits on a cross-process round trip to the client's renderer. ++ battr.maxlength = block * 16; ++ battr.minreq = block; ++ battr.prebuf = block * 2; ++ battr.tlength = block * kPrefillBlocks; ++ ++ // Real HALs always call the IOProc with the same frame count (kAudioDevicePropertyBufferFrameSize). ++ m_blockFrames = bpf ? m_hw->bufferSize() / bpf : 0; ++ m_blockBytes = size_t(m_blockFrames) * bpf; ++ const double rate = m_hw->asbd().mSampleRate; ++ m_periodNs = rate > 0 ? double(m_blockFrames) * 1e9 / rate : 0; ++ ++ // start() runs on the PA queue, so the timer shares its serialization with all pa_* calls. ++ // Disabled: Darling's dispatch timers fire 3-35 ms late (one stalled 30 s), which starved ++ // PA into silence. Render on PA's write requests instead; the output timestamps still ++ // come from PA's latency, so blocks within one request get correctly spaced play times. ++ static const bool kUsePacingTimer = false; ++ dispatch_queue_t queue = static_cast(m_hw)->paQueue(); ++ if (kUsePacingTimer && queue && m_blockBytes && m_periodNs > 0) ++ { ++ m_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue); ++ if (m_timer) ++ { ++ dispatch_source_set_event_handler(m_timer, ^{ tick(); }); ++ // Resumed immediately (a suspended source can't be released); armed after prefill. ++ dispatch_source_set_timer(m_timer, DISPATCH_TIME_FOREVER, DISPATCH_TIME_FOREVER, 0); ++ dispatch_resume(m_timer); ++ } ++ } ++ if (rclolAudioTraceEnabled()) ++ rclolAudioTrace("PA_OUT start block=%zu frames=%u periodMs=%.3f timer=%d\n", m_blockBytes, m_blockFrames, ++ m_periodNs / 1e6, m_timer != nullptr); + + pa_stream_set_write_callback(m_stream, paStreamWriteCB, this); + +@@ -105,7 +386,5 @@ void AudioHardwareStreamPAOutput::start() + |PA_STREAM_AUTO_TIMING_UPDATE) /* PA_STREAM_START_CORKED */, + nullptr, nullptr); + +- // pa_stream_cork(m_stream, false, [](pa_stream*, int, void*) {}, nullptr); AudioHardwareStreamPA::start(); } - diff --git a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.h b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.h -index 735474128..a92e64cd5 100644 +index 735474128..8d73f9902 100644 --- a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.h +++ b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.h -@@ -26,9 +26,9 @@ class AudioHardwareStreamPAOutput : public AudioHardwareStreamPA +@@ -20,15 +20,46 @@ along with Darling. If not, see . + #ifndef AUDIOHARDWARESTREAMPAOUTPUT_H + #define AUDIOHARDWARESTREAMPAOUTPUT_H + #include "AudioHardwareStreamPA.h" ++#include ++#include + + class AudioHardwareStreamPAOutput : public AudioHardwareStreamPA + { public: AudioHardwareStreamPAOutput(AudioHardwareImplPA* hw, AudioDeviceIOProc callback, void* clientData); ++ ~AudioHardwareStreamPAOutput(); ++ void stop() override; private: ++ enum class RenderResult { Written, NoSpace, Stopped }; ++ + uint64_t m_frames = 0; void start() override; static void paStreamWriteCB(pa_stream* s, size_t length, void* self); ++ ++ // IO cycle pacing; everything below is only touched on the PA queue. ++ void cancelTimer(); ++ void prefill(); ++ void tick(); ++ void armTimer(uint64_t nowNs); ++ uint64_t deadlineNs() const; ++ RenderResult renderBlock(bool trace); ++ uint64_t outputLatencyNs(size_t writable, bool* fromPA); ++ ++ dispatch_source_t m_timer = nullptr; ++ size_t m_blockBytes = 0; ++ uint32_t m_blockFrames = 0; ++ double m_periodNs = 0; ++ bool m_primed = false; ++ bool m_corked = false; ++ // Deadline k is m_anchorNs + k * m_periodNs - m_advanceNs. ++ uint64_t m_anchorNs = 0; ++ uint64_t m_tickIndex = 0; ++ double m_advanceNs = 0; ++ // Drift servo: smallest writable size seen over the current window of ticks. ++ size_t m_windowMinWritable = SIZE_MAX; ++ unsigned m_windowTicks = 0; ++ double m_servoStepNs = 0; ++ uint64_t m_lastIOProcHost = 0; }; #endif /* AUDIOHARDWARESTREAMPA_H */ @@ -1886,10 +2365,10 @@ index 63f3545e2..4a2d8ed62 100644 }); } diff --git a/src/CoreAudio/CoreAudio/pulse/PADispatchMainLoop.h b/src/CoreAudio/CoreAudio/pulse/PADispatchMainLoop.h -index ff83d1e15..0d84a2e0d 100644 +index ff83d1e15..1e86f9e99 100644 --- a/src/CoreAudio/CoreAudio/pulse/PADispatchMainLoop.h +++ b/src/CoreAudio/CoreAudio/pulse/PADispatchMainLoop.h -@@ -30,6 +30,8 @@ public: +@@ -30,8 +30,12 @@ public: void suspend(); void resume(); @@ -1897,8 +2376,12 @@ index ff83d1e15..0d84a2e0d 100644 + void async(void (^block)(void)); pa_mainloop_api* getAPI(); ++ // The serial queue all pa_* calls and callbacks run on. ++ dispatch_queue_t queue() const { return m_queue; } private: -@@ -50,6 +52,7 @@ private: + static pa_io_event* io_new(pa_mainloop_api *a, int fd, pa_io_event_flags_t events, pa_io_event_cb_t cb, void *userdata); + static void io_enable(pa_io_event *e, pa_io_event_flags_t events); +@@ -50,6 +54,7 @@ private: static void defer_event_fire(void* context); private: dispatch_queue_t m_queue; @@ -1907,7 +2390,7 @@ index ff83d1e15..0d84a2e0d 100644 bool m_suspended = true; }; diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp -index fe4d1eaa1..172f751ba 100644 +index fe4d1eaa1..9321edcdf 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp @@ -1,3 +1,4 @@ @@ -1915,7 +2398,7 @@ index fe4d1eaa1..172f751ba 100644 /* This file is part of Darling. -@@ -19,6 +20,11 @@ along with Darling. If not, see . +@@ -19,6 +20,15 @@ along with Darling. If not, see . #include "AUHAL.h" #include @@ -1924,10 +2407,14 @@ index fe4d1eaa1..172f751ba 100644 +#include +#include +#include ++#include ++#include ++#include ++#include #pragma GCC visibility push(default) AUDIOCOMPONENT_ENTRY(AUOutputBaseFactory, AUHAL); -@@ -42,6 +48,37 @@ AUHAL::~AUHAL() +@@ -42,6 +52,37 @@ AUHAL::~AUHAL() Stop(); } @@ -1965,7 +2452,7 @@ index fe4d1eaa1..172f751ba 100644 bool AUHAL::CanScheduleParameters() const { return true; -@@ -49,7 +86,7 @@ bool AUHAL::CanScheduleParameters() const +@@ -49,7 +90,7 @@ bool AUHAL::CanScheduleParameters() const bool AUHAL::StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) { @@ -1974,11 +2461,11 @@ index fe4d1eaa1..172f751ba 100644 } OSStatus AUHAL::Version() -@@ -76,19 +113,26 @@ OSStatus AUHAL::Render(AudioUnitRenderActionFlags& ioActionFlags, const AudioTim +@@ -76,19 +117,26 @@ OSStatus AUHAL::Render(AudioUnitRenderActionFlags& ioActionFlags, const AudioTim OSStatus AUHAL::Start() { -+ if(getenv("RCLOL_AUDIO_TRACE")) rclolAudioTrace("AUDIO_UNIT_NATIVE_START output=%d input=%d\n",m_enableOutput,m_enableInput); ++ if(rclolAudioTraceEnabled()) rclolAudioTrace("AUDIO_UNIT_NATIVE_START output=%d input=%d\n",m_enableOutput,m_enableInput); if (m_running) return noErr; @@ -2004,7 +2491,7 @@ index fe4d1eaa1..172f751ba 100644 } if (m_enableInput) { -@@ -111,13 +155,15 @@ OSStatus AUHAL::Stop() +@@ -111,20 +159,42 @@ OSStatus AUHAL::Stop() { AudioDeviceStop(m_outputDevice, m_outputProcID); AudioDeviceDestroyIOProcID(m_outputDevice, m_outputProcID); @@ -2021,8 +2508,24 @@ index fe4d1eaa1..172f751ba 100644 return noErr; } -@@ -125,6 +171,10 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au ++// Volume/mute/gain related property selectors (HAL device properties and AUHAL parameters). ++static bool rclolVolumeRelated(UInt32 id) ++{ ++ switch (id) ++ { ++ case 'volm': case 'vold': case 'vdb#': case 'mute': case 'thru': ++ case 'pvol': case 'pvod': case 'mvol': case 'mbal': case 'span': ++ case 'svlm': case 'smut': case 'v2db': case 'db2v': ++ return true; ++ } ++ return false; ++} ++ + OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, AudioUnitElement inElement, const void* inData, UInt32 inDataSize) { ++ if (rclolAudioTraceEnabled() && rclolVolumeRelated(inID)) ++ rclolAudioTrace("AUHAL_SET_VOLPROP property=%08x scope=%u element=%u size=%u value=%g\n",inID,inScope,inElement,inDataSize, ++ (inData && inDataSize==sizeof(Float32))?(double)*(const Float32*)inData:0.0); switch (inID) { + case kAudioDevicePropertyBufferFrameSize: { @@ -2032,21 +2535,24 @@ index fe4d1eaa1..172f751ba 100644 case kAudioOutputUnitProperty_SetInputCallback: { ca_require(inDataSize == sizeof(AURenderCallbackStruct), InvalidPropertyValue); -@@ -176,7 +226,11 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -176,15 +246,23 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au return noErr; } } - return AUBase::SetProperty(inID, inScope, inElement, inData, inDataSize); + { + OSStatus status=AUBase::SetProperty(inID,inScope,inElement,inData,inDataSize); -+ if(status && getenv("RCLOL_AUDIO_TRACE")) rclolAudioTrace("AUDIO_UNIT_SET property=%u scope=%u element=%u status=%d\n",inID,inScope,inElement,status); ++ if(status && rclolAudioTraceEnabled()) rclolAudioTrace("AUDIO_UNIT_SET property=%u scope=%u element=%u status=%d\n",inID,inScope,inElement,status); + return status; + } InvalidPropertyValue: return kAudioUnitErr_InvalidPropertyValue; } -@@ -185,6 +239,8 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope + + OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope, AudioUnitElement inElement, UInt32& outDataSize, Boolean& outWritable) { ++ if (rclolAudioTraceEnabled() && rclolVolumeRelated(inID)) ++ rclolAudioTrace("AUHAL_INFO_VOLPROP property=%08x scope=%u element=%u\n",inID,inScope,inElement); switch (inID) { + case kAudioDevicePropertyBufferFrameSize: @@ -2054,18 +2560,20 @@ index fe4d1eaa1..172f751ba 100644 case kAudioOutputUnitProperty_SetInputCallback: { outDataSize = sizeof(AURenderCallbackStruct); -@@ -210,13 +266,20 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope +@@ -210,13 +288,22 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope return noErr; } } - return AUBase::GetPropertyInfo(inID, inScope, inElement, outDataSize, outWritable); + OSStatus status=AUBase::GetPropertyInfo(inID,inScope,inElement,outDataSize,outWritable); -+ if(status && getenv("RCLOL_AUDIO_TRACE")) rclolAudioTrace("AUDIO_UNIT_INFO property=%u scope=%u element=%u status=%d\n",inID,inScope,inElement,status); ++ if(status && rclolAudioTraceEnabled()) rclolAudioTrace("AUDIO_UNIT_INFO property=%u scope=%u element=%u status=%d\n",inID,inScope,inElement,status); + return status; } OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, AudioUnitElement inElement, void* outData) { ++ if (rclolAudioTraceEnabled() && rclolVolumeRelated(inID)) ++ rclolAudioTrace("AUHAL_GET_VOLPROP property=%08x scope=%u element=%u\n",inID,inScope,inElement); switch (inID) { + case kAudioDevicePropertyBufferFrameSize: { @@ -2076,7 +2584,7 @@ index fe4d1eaa1..172f751ba 100644 case kAudioOutputUnitProperty_SetInputCallback: { memcpy(outData, &m_outputAvailableCb, sizeof(m_outputAvailableCb)); -@@ -228,12 +291,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -228,12 +315,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au if (inElement == kOutputBus) { UInt32 value = m_enableOutput; @@ -2091,17 +2599,97 @@ index fe4d1eaa1..172f751ba 100644 } else return kAudioUnitErr_InvalidElement; -@@ -280,6 +343,9 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, +@@ -277,9 +364,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, + return This->doRecord(inNow, inInputData, inInputTime); + } + ++// Diagnostic only: classify each rendered block (see AUDIO_BLOCK_STATE). ++void AUHAL::traceBlock(const AudioTimeStamp* inNow, const AudioTimeStamp* inOutputTime, OSStatus result, ++ const CAStreamBasicDescription& desc, UInt32 nFrames, AudioBufferList* out) ++{ ++ static std::atomic stateLines(0); ++ static mach_timebase_info_data_t tb={0,0}; ++ if(!tb.denom) mach_timebase_info(&tb); ++ BlockTrace& t=m_blockTrace; ++ const unsigned long long cb=++t.callbacks; ++ if(result) { ++ if(++t.pullFailures<=50 || t.pullFailures%200==0) ++ rclolAudioTrace("AUDIO_PULL_FAIL unit=%p cb=%llu status=%d frames=%u count=%llu\n", ++ (void*)this,cb,(int)result,nFrames,t.pullFailures); ++ return; ++ } ++ if(!desc.IsFloat() || desc.mBitsPerChannel!=32 || !nFrames || !desc.mChannelsPerFrame) return; ++ const bool planar=desc.mFormatFlags&kAudioFormatFlagIsNonInterleaved; ++ const UInt32 ch=desc.mChannelsPerFrame; ++ const float* base=planar?(const float*)m_playbackSamples.data():(const float*)out->mBuffers[0].mData; ++ if(!base) return; ++ auto at=[&](UInt32 c,UInt32 f)->float { return planar?base[(size_t)c*nFrames+f]:base[(size_t)f*ch+c]; }; ++ float peak[2]={0,0}; double sq[2]={0,0}; bool halves[2]={true,true}; bool zero=true; ++ const UInt32 half=nFrames/2; ++ for(UInt32 c=0;c1?sqrt(sq[1]/nFrames):0; ++ bool same=false; ++ const size_t total=(size_t)nFrames*ch; ++ if(t.havePrev && t.prev.size()==total) { ++ same=true; ++ for(UInt32 c=0;cmSampleTime; ++ if(t.haveTime) { ++ dSample=sample-t.lastSample; ++ dHostMs=(double)(inOutputTime->mHostTime-t.lastHost)*tb.numer/tb.denom/1e6; ++ if(dHostMs>t.maxGapMs) t.maxGapMs=dHostMs; ++ } ++ t.haveTime=true; t.lastSample=sample; t.lastHost=inOutputTime->mHostTime; ++ const bool transition=state!=t.state; ++ if(transition && t.state>=0 && stateLines.fetch_add(1)<2000) ++ rclolAudioTrace("AUDIO_BLOCK_STATE unit=%p from=%s to=%s cb=%llu frames=%u peakL=%g peakR=%g rmsL=%g rmsR=%g\n", ++ (void*)this,names[t.state],names[state],cb,nFrames,peak[0],peak[1],rms0,rms1); ++ if(cb<=40 || cb%200==0 || transition) ++ rclolAudioTrace("AUDIO_BLOCK unit=%p cb=%llu state=%s frames=%u peakL=%g peakR=%g rmsL=%g rmsR=%g " ++ "halvesL=%d halvesR=%d same=%d zero=%d outSample=%.0f nowSample=%.0f dSample=%.0f outHost=%llu nowHost=%llu " ++ "dHostMs=%.3f maxGapMs=%.3f\n", ++ (void*)this,cb,names[state],nFrames,peak[0],peak[1],rms0,rms1,(int)halves[0],(int)halves[1],(int)same,(int)zero, ++ sample,inNow->mSampleTime,dSample,(unsigned long long)inOutputTime->mHostTime, ++ (unsigned long long)inNow->mHostTime,dHostMs,t.maxGapMs); ++ if(cb%2000==0) ++ rclolAudioTrace("AUDIO_BLOCK_SUMMARY unit=%p cb=%llu silent=%llu live=%llu repeat=%llu halves=%llu pullFail=%llu maxGapMs=%.3f\n", ++ (void*)this,cb,t.stateCounts[0],t.stateCounts[1],t.stateCounts[2],t.stateCounts[3],t.pullFailures,t.maxGapMs); ++ t.state=state; ++} ++ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutputData, const AudioTimeStamp* inOutputTime) { // std::cout << "AUHAL::DoPlayback()\n"; + static unsigned stackTraces=0; -+ if(getenv("RCLOL_AUDIO_TRACE") && stackTraces++<2) ++ if(rclolAudioTraceEnabled() && stackTraces++<2) + rclolAudioTrace("AUDIO_CALLBACK_STACK bytes=%zu\n",pthread_get_stacksize_np(pthread_self())); if (!HasInput(0)) { // std::cerr << "No connection\n"; -@@ -290,9 +356,44 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -290,9 +457,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp AudioUnitRenderActionFlags flags = kAudioUnitRenderAction_PreRender; const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Input, kOutputBus); @@ -2136,8 +2724,39 @@ index fe4d1eaa1..172f751ba 100644 + result=GetInput(kOutputBus)->PullInputWithBufferList(flags,*inNow,kOutputBus,nFrames,outOutputData); + } + ++ if(rclolAudioTraceEnabled()) traceBlock(inNow,inOutputTime,result,desc,nFrames,outOutputData); ++ ++ // Diagnostic: Chromium's audio source can start handing out one identical ++ // block forever (heard as static). Log the timestamp Darling supplied at the ++ // moment that begins, so we can tell whether our clock inputs look sane. ++ { ++ const unsigned char* produced; ++ size_t producedBytes; ++ if(desc.mFormatFlags&kAudioFormatFlagIsNonInterleaved) { ++ produced=m_playbackSamples.data(); ++ producedBytes=(size_t)nFrames*frameBytes; ++ } else { ++ produced=(const unsigned char*)outOutputData->mBuffers[0].mData; ++ producedBytes=outOutputData->mBuffers[0].mDataByteSize; ++ } ++ uint64_t sum=1469598103934665603ull; ++ for(size_t i=0;imHostTime,inOutputTime->mSampleTime, ++ (unsigned)inOutputTime->mFlags); ++ } else { ++ repeatRun=0; ++ } ++ lastSum=sum; ++ } ++ + static unsigned traceRenders=0; -+ if (getenv("RCLOL_AUDIO_TRACE") && traceRenders++ < 16) { ++ if (rclolAudioTraceEnabled() && traceRenders++ < 16) { + float peak=0; + if (!result && desc.IsFloat() && sampleBytes==4) { + const float* samples=static_cast(outOutputData->mBuffers[0].mData); @@ -2150,7 +2769,7 @@ index fe4d1eaa1..172f751ba 100644 // std::cout << "Bytes: " << outOutputData->mBuffers[0].mDataByteSize << std::endl; // m_auhalData.write((char*) outOutputData->mBuffers[0].mData, outOutputData->mBuffers[0].mDataByteSize); diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.h b/src/CoreAudio/CoreAudioComponent/AUHAL.h -index bc21bcbbb..190a0ffc8 100644 +index bc21bcbbb..e369f95d1 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.h +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.h @@ -25,6 +25,7 @@ along with Darling. If not, see . @@ -2171,7 +2790,15 @@ index bc21bcbbb..190a0ffc8 100644 bool CanScheduleParameters() const override; bool StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) override; OSStatus Version() override; -@@ -76,6 +80,11 @@ protected: +@@ -59,6 +63,7 @@ private: + void* inClientData); + + OSStatus doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutputData, const AudioTimeStamp* inOutputTime); ++ void traceBlock(const AudioTimeStamp* inNow, const AudioTimeStamp* inOutputTime, OSStatus result, const CAStreamBasicDescription& desc, UInt32 nFrames, AudioBufferList* out); + OSStatus doRecord(const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime); + + protected: +@@ -76,6 +81,24 @@ protected: // TODO: Prepare for non-interleaved audio std::unique_ptr m_buffer; size_t m_bufferSize = 0, m_bufferUsed = 0; @@ -2180,6 +2807,19 @@ index bc21bcbbb..190a0ffc8 100644 + // Keep planar conversion storage across callbacks and start/stop cycles. + std::vector m_playbackSamples; + std::vector m_playbackBufferList; ++ ++ // Diagnostic-only block statistics (touched only when audio tracing is on). ++ struct BlockTrace { ++ unsigned long long callbacks = 0, pullFailures = 0; ++ unsigned long long stateCounts[4] = {0,0,0,0}; ++ int state = -1; ++ std::vector prev; ++ bool havePrev = false; ++ bool haveTime = false; ++ double lastSample = 0; ++ unsigned long long lastHost = 0; ++ double maxGapMs = 0; ++ } m_blockTrace; }; #endif @@ -7241,16 +7881,39 @@ diff --git a/src/CoreAudio/AudioTrace.h b/src/CoreAudio/AudioTrace.h new file mode 100644 --- /dev/null +++ b/src/CoreAudio/AudioTrace.h -@@ -0,0 +1,14 @@ +@@ -0,0 +1,37 @@ +#ifndef RCLOL_AUDIO_TRACE_H +#define RCLOL_AUDIO_TRACE_H +#include +#include +#include ++#include ++#include +#include +// Opt-in API/status diagnostics only; never records audio samples or names. ++// Enabled by env RCLOL_AUDIO_TRACE or by the existence of /tmp/rclol-audio-trace ++// or /Users/Shared/rclol-audio-trace (the latter survives /tmp wipes at launch). ++// A negative result is re-checked at most once every ~2 seconds; a positive ++// result is cached for the life of the process. ++static inline bool rclolAudioTraceEnabled() { ++ static std::atomic enabled{false}; ++ static std::atomic nextCheckMs{0}; ++ if (enabled.load(std::memory_order_relaxed)) return true; ++ struct timespec ts; ++ clock_gettime(CLOCK_MONOTONIC, &ts); ++ long long nowMs = (long long) ts.tv_sec * 1000 + ts.tv_nsec / 1000000 + 1; ++ if (nowMs < nextCheckMs.load(std::memory_order_relaxed)) return false; ++ nextCheckMs.store(nowMs + 2000, std::memory_order_relaxed); ++ if (getenv("RCLOL_AUDIO_TRACE") != nullptr || ++ access("/tmp/rclol-audio-trace", F_OK) == 0 || ++ access("/Users/Shared/rclol-audio-trace", F_OK) == 0) { ++ enabled.store(true, std::memory_order_relaxed); ++ return true; ++ } ++ return false; ++} +static inline void rclolAudioTrace(const char* format, ...) { -+ if (!getenv("RCLOL_AUDIO_TRACE")) return; ++ if (!rclolAudioTraceEnabled()) return; + static FILE* file=[] { FILE* f=fopen("/tmp/rclol-audio-api.log","a"); if(f) setvbuf(f,nullptr,_IOLBF,0); return f; }(); + if(!file) return; + va_list args;va_start(args,format);fprintf(file,"pid=%d ",getpid());vfprintf(file,format,args);va_end(args); @@ -7344,7 +8007,7 @@ new file mode 100644 + OSStatus SetParameter(AudioUnitParameterID id, AudioUnitScope scope, AudioUnitElement element, + AudioUnitParameterValue gain, UInt32) override { + static std::atomic traceCount{0}; -+ if (getenv("RCLOL_AUDIO_TRACE") && traceCount++ < 64) ++ if (rclolAudioTraceEnabled() && traceCount++ < 64) + rclolAudioTrace("AUDIO_MATRIX_PARAM id=%u scope=%u element=%08x gain=%g\n",id,scope,element,gain); + std::lock_guard lock(mMutex); + if (!std::isfinite(gain)) return kAudioUnitErr_InvalidParameter; @@ -8801,3 +9464,67 @@ new file mode 100644 + api->sd_bus_flush_close_unref(bus); + return r < 0 ? r : 0; +} +diff --git a/src/frameworks/CoreServices/src/CarbonCore/Gestalt.cpp b/src/frameworks/CoreServices/src/CarbonCore/Gestalt.cpp +index dffdd4cb2..7430f587b 100644 +--- a/src/frameworks/CoreServices/src/CarbonCore/Gestalt.cpp ++++ b/src/frameworks/CoreServices/src/CarbonCore/Gestalt.cpp +@@ -25,6 +25,9 @@ along with Darling. If not, see . + + OSStatus Gestalt(uint32_t type, int* value) + { ++ if (!value) ++ return paramErr; ++ *value = 0; + switch (type) + { + case gestaltSystemVersion: +@@ -32,24 +35,31 @@ OSStatus Gestalt(uint32_t type, int* value) + case gestaltSystemVersionMinor: + case gestaltSystemVersionPatch: + { +- // We may be mixing the Darwin and OS version, but this API is deprecated anyway +- int name[2] = { CTL_KERN, KERN_OSRELEASE }; +- char version[32]; +- size_t version_len = sizeof(version) - 1; +- int maj, min, patch; +- +- sysctl(name, 2, version, &version_len, nullptr, 0); +- sscanf(version, "%d.%d.%d", &maj, &min, &patch); +- +- if (type == gestaltSystemVersion) +- *value = 0x100 * maj + 0x10 * min; +- else if (type == gestaltSystemVersionMajor) +- *value = maj; +- else if (type == gestaltSystemVersionMinor) +- *value = min; +- else if (type == gestaltSystemVersionPatch) +- *value = patch; +- } ++ // Darwin's kernel release is not the macOS product version. ++ char version[64] = {}; ++ size_t version_len = sizeof(version); ++ int major = 0, minor = 0, patch = 0; ++ if (sysctlbyname("kern.osproductversion", version, &version_len, nullptr, 0) != 0) ++ return gestaltUnknownErr; ++ version[sizeof(version) - 1] = '\0'; ++ if (sscanf(version, "%d.%d.%d", &major, &minor, &patch) < 1 || ++ major < 0 || major > 99 || minor < 0 || patch < 0) ++ return gestaltUnknownErr; ++ ++ if (type == gestaltSystemVersion) { ++ // Legacy packed BCD: minor and bug-fix components saturate at 9. ++ const int packedMajor = ((major / 10) << 4) | (major % 10); ++ *value = (packedMajor << 8) | ((minor > 9 ? 9 : minor) << 4) | ++ (patch > 9 ? 9 : patch); ++ } else if (type == gestaltSystemVersionMajor) ++ *value = major; ++ else if (type == gestaltSystemVersionMinor) ++ *value = minor; ++ else ++ *value = patch; ++ // Version selectors must not fall through to the architecture case. ++ return 0; ++ } + case gestaltSysArchitecture: + #ifdef __powerpc__ + *value = gestaltPowerPC; diff --git a/patches/xnu.patch b/patches/xnu.patch index 482a3f6..7b95368 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -3221,18 +3221,10 @@ index 0000000..479294a + return -ENOSYS; +} diff --git a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/proc_info.c b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/proc_info.c -index c2f70fb..73c2a5a 100644 +index c2f70fb..af904ed 100644 --- a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/proc_info.c +++ b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/proc_info.c -@@ -35,6 +35,7 @@ - #define LINUX_PR_SET_NAME 15 - - static long _proc_pidinfo(int32_t pid, uint32_t flavor, uint64_t arg, void* buffer, int32_t bufsize); -+static long _proc_listpids(uint32_t type, uint32_t typeinfo, void* buffer, int32_t bufsize); - - extern __SIZE_TYPE__ strlen(const char *s); - extern void *memset(void *s, int c, __SIZE_TYPE__ n); -@@ -83,7 +84,42 @@ long sys_proc_info(uint32_t callnum, int32_t pid, uint32_t flavor, +@@ -83,6 +83,38 @@ long sys_proc_info(uint32_t callnum, int32_t pid, uint32_t flavor, case 2: // proc_pidinfo return _proc_pidinfo(pid, flavor, arg, buffer, bufsize); case 3: // proc_pidfdinfo @@ -3269,160 +3261,8 @@ index c2f70fb..73c2a5a 100644 + return -ENOTSUP; + } case 1: // proc_listpids -+ // `pid` carries the process type and `flavor` carries the type -+ // info (e.g. the pgid, ppid or uid to match). -+ return _proc_listpids(pid, flavor, buffer, bufsize); default: __simple_printf("sys_proc_info(): Unsupported callnum: %d\n", - callnum); -@@ -827,3 +863,147 @@ bail: - close_internal(fd); - return count * sizeof(uint64_t); - } -+ -+// Returns whether `pid` should be included in a proc_listpids() query of the -+// given `type`, matching the semantics of XNU's bsd/kern/proc_info.c. -+static bool _proc_listpids_match(uint32_t type, uint32_t typeinfo, int32_t pid, -+ int32_t uid) -+{ -+ char path[64], stat[1024]; -+ char* statptr; -+ const char* elem; -+ int32_t ppid, pgrp, tty; -+ -+ switch (type) -+ { -+ case PROC_ALL_PIDS: -+ return true; -+ -+ case PROC_UID_ONLY: -+ case PROC_RUID_ONLY: -+ // Darling's container runs every guest process as our own -+ // user, which is also what _sysctl_proc() reports. -+ return typeinfo == (uint32_t) uid; -+ -+ case PROC_PGRP_ONLY: -+ case PROC_PPID_ONLY: -+ case PROC_TTY_ONLY: -+ break; -+ -+ default: -+ // PROC_KDBG_ONLY and anything else: no process carries the -+ // corresponding state under Darling. -+ return false; -+ } -+ -+ __simple_sprintf(path, "/proc/%d/stat", pid); -+ if (!read_string(path, stat, sizeof(stat))) -+ return false; -+ -+ statptr = stat; -+ skip_stat_elems(&statptr, 1); // pid -+ -+ if (!(elem = next_stat_elem(&statptr))) -+ return false; -+ // comm -+ -+ if (!(elem = next_stat_elem(&statptr))) -+ return false; -+ // state -+ -+ if (!(elem = next_stat_elem(&statptr))) -+ return false; -+ ppid = __simple_atoi(elem, NULL); -+ -+ if (!(elem = next_stat_elem(&statptr))) -+ return false; -+ pgrp = __simple_atoi(elem, NULL); -+ -+ if (type == PROC_PPID_ONLY) -+ return ppid == (int32_t) typeinfo; -+ -+ if (type == PROC_PGRP_ONLY) -+ return pgrp == (int32_t) typeinfo; -+ -+ // PROC_TTY_ONLY: the (Linux-encoded) controlling terminal device. -+ if (!(elem = next_stat_elem(&statptr))) -+ return false; -+ // session -+ -+ if (!(elem = next_stat_elem(&statptr))) -+ return false; -+ tty = __simple_atoi(elem, NULL); -+ -+ return tty != 0 && (uint32_t) tty == typeinfo; -+} -+ -+// Emulates XNU's proc_listpids(), i.e. proc_info(PROC_INFO_CALL_LISTPIDS). -+// Guest pids are Linux pids, so walking /proc gives the same set that the -+// other proc_info flavors and sysctl(KERN_PROC_*) already operate on. -+static long _proc_listpids(uint32_t type, uint32_t typeinfo, void* buffer, int32_t bufsize) -+{ -+ char dents[256]; -+ long basep = 0; -+ int fd, ret; -+ int32_t maxpids, count = 0; -+ static int uid = -1; -+ -+ if (type != PROC_ALL_PIDS && type != PROC_PGRP_ONLY && type != PROC_TTY_ONLY && -+ type != PROC_UID_ONLY && type != PROC_RUID_ONLY && -+ type != PROC_PPID_ONLY && type != PROC_KDBG_ONLY) -+ return -EINVAL; -+ -+ if (buffer && bufsize < (int32_t) sizeof(int)) -+ return -ENOMEM; -+ -+ // A null buffer asks for the size a caller should allocate. -+ maxpids = buffer ? (bufsize / (int32_t) sizeof(int)) : 0; -+ -+ if (uid == -1) -+ uid = sys_getuid(); -+ -+ fd = sys_open_nocancel("/proc", BSD_O_RDONLY | BSD_O_DIRECTORY, 0); -+ if (fd < 0) -+ return fd; -+ -+ while ((ret = sys_getdirentries(fd, (char*) dents, sizeof(dents), &basep)) > 0) -+ { -+ int pos = 0; -+ -+ while (pos < ret) -+ { -+ struct bsd_dirent* dent = (struct bsd_dirent*) &dents[pos]; -+ -+ pos += dent->d_reclen; -+ -+ if (dent->d_type != DT_DIR || !isdigit(dent->d_name[0])) -+ continue; -+ -+ int pid = __simple_atoi(dent->d_name, NULL); -+ -+ if (!_proc_listpids_match(type, typeinfo, pid, uid)) -+ continue; -+ -+ if (buffer) -+ { -+ // Truncate rather than overflow, like XNU does. -+ if (count >= maxpids) -+ goto bail; -+ -+ ((int*) buffer)[count] = pid; -+ } -+ -+ ++count; -+ } -+ } -+ -+bail: -+ close_internal(fd); -+ -+ // XNU reports a little extra headroom so that a size query followed by a -+ // fill call cannot come up short when processes appear in between. -+ if (!buffer) -+ return (long)(count + 20) * sizeof(int); -+ -+ return (long) count * sizeof(int); -+} diff --git a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c index 0a8f1e4..55aadbe 100644 --- a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c diff --git a/tools/build-m58f336-quartzcore.py b/tools/build-m58f336-quartzcore.py index dc372e1..f83589e 100644 --- a/tools/build-m58f336-quartzcore.py +++ b/tools/build-m58f336-quartzcore.py @@ -1,18 +1,28 @@ #!/usr/bin/env python3 -"""Build only the changed GPU frame publisher and relink QuartzCore.""" -import shlex,subprocess +"""Rebuild the patched QuartzCore compositor and Metal presentation objects.""" +import shlex +import subprocess from pathlib import Path -project=Path(__file__).resolve().parent.parent -framework=project/'build/darling-src/build/src/external/cocotron/QuartzCore' -flags={} -for line in (framework/'CMakeFiles/QuartzCore.dir/flags.make').read_text().splitlines(): - if line.startswith('C_') and ' = ' in line: - k,v=line.split(' = ',1);flags[k]=shlex.split(v) -link=shlex.split((framework/'CMakeFiles/QuartzCore.dir/link.txt').read_text()) -for name in ('CALayer','CALayerContext','CATransaction'): - obj=project/('build/m336-'+name+'.o') - subprocess.run(['clang',*flags['C_DEFINES'],*flags['C_INCLUDES'],*flags['C_FLAGS'],'-O2','-Wno-macro-redefined','-c',str(project/('build/darling-src/src/external/cocotron/QuartzCore/'+name+'.m')),'-o',str(obj)],check=True) - link[link.index('CMakeFiles/QuartzCore.dir/'+name+'.m.o')]=str(obj) -link[link.index('-o')+1]=str(project/'build/m336-QuartzCore') -subprocess.run(link,cwd=framework,check=True) -print('Built isolated QuartzCore frame publisher') + +project = Path(__file__).resolve().parent.parent +framework = project / 'build/darling-src/build/src/external/cocotron/QuartzCore' +flags = {} +for line in (framework / 'CMakeFiles/QuartzCore.dir/flags.make').read_text().splitlines(): + if line.startswith(('C_', 'CXX_')) and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +link = shlex.split((framework / 'CMakeFiles/QuartzCore.dir/link.txt').read_text()) +sources = ['CALayer.m', 'CALayerContext.m', 'CARenderer.m', 'CATransaction.m', + 'CATransactionGroup.m', 'CATransformLayer.m', 'CAMetalLayer.mm', 'CAMetalDrawable.mm'] +for source in sources: + kind = 'CXX' if source.endswith('.mm') else 'C' + obj = project / f'build/m336-{Path(source).stem}.o' + subprocess.run(['clang++' if kind == 'CXX' else 'clang', *flags[kind + '_DEFINES'], + *flags[kind + '_INCLUDES'], *flags[kind + '_FLAGS'], '-O2', + '-Wno-macro-redefined', '-c', + str(project / 'build/darling-src/src/external/cocotron/QuartzCore' / source), + '-o', str(obj)], check=True) + link[link.index(f'CMakeFiles/QuartzCore.dir/{source}.o')] = str(obj) +link[link.index('-o') + 1] = str(project / 'build/m336-QuartzCore') +subprocess.run(link, cwd=framework, check=True) +print('Built isolated QuartzCore compositor and Metal presentation') diff --git a/tools/build-riot-appkit-runtime.py b/tools/build-riot-appkit-runtime.py index 096976e..f4df0f7 100644 --- a/tools/build-riot-appkit-runtime.py +++ b/tools/build-riot-appkit-runtime.py @@ -21,7 +21,7 @@ for name in ['NSResponder', 'NSApplication', 'NSWindow', 'NSDisplay', 'NSView', link[link.index(f'CMakeFiles/AppKit.dir/{name}.m.o')] = str(obj) link[link.index('-o') + 1] = str(project / 'build/riot-AppKit') 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', 'riot-game-input-check', 'riot-mouse-input-check']: +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'], '-O2', '-fblocks', '-Wno-macro-redefined', '-c', diff --git a/tools/build-riot-carboncore-runtime.py b/tools/build-riot-carboncore-runtime.py new file mode 100644 index 0000000..2d76142 --- /dev/null +++ b/tools/build-riot-carboncore-runtime.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Rebuild CarbonCore's legacy Gestalt version query and its standalone probe.""" +from pathlib import Path +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +folder = root / 'build/darling-src/build/src/frameworks/CoreServices/src/CarbonCore' +flags = {} +for line in (folder / 'CMakeFiles/CarbonCore.dir/flags.make').read_text().splitlines(): + if line.startswith(('C_', 'CXX_')) and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +obj = root / 'build/riot-Gestalt.o' +subprocess.run(['clang++', *flags['CXX_DEFINES'], *flags['CXX_INCLUDES'], *flags['CXX_FLAGS'], + '-O2', '-Wno-macro-redefined', '-c', + str(root / 'build/darling-src/src/frameworks/CoreServices/src/CarbonCore/Gestalt.cpp'), + '-o', str(obj)], check=True) +link = shlex.split((folder / 'CMakeFiles/CarbonCore.dir/link.txt').read_text()) +link[link.index('CMakeFiles/CarbonCore.dir/Gestalt.cpp.o')] = str(obj) +link[link.index('-o') + 1] = str(root / 'build/riot-CarbonCore') +subprocess.run(link, cwd=folder, check=True) +probe = root / 'build/riot-gestalt-version-check.o' +subprocess.run(['clang++', *flags['CXX_DEFINES'], *flags['CXX_INCLUDES'], *flags['CXX_FLAGS'], + '-O2', '-Wno-macro-redefined', '-c', str(root / 'tools/riot-gestalt-version-check.cpp'), + '-o', str(probe)], check=True) +subprocess.run(['clang++', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', '@DARLING_SDK@', str(probe), str(root / 'build/riot-CarbonCore'), + '-lSystem', '-lc++', '-o', str(root / 'build/riot-gestalt-version-check')], check=True) diff --git a/tools/build-riot-client-resize-check.py b/tools/build-riot-client-resize-check.py new file mode 100644 index 0000000..03338fe --- /dev/null +++ b/tools/build-riot-client-resize-check.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Build an owned X11/IOSurface window that verifies pixels after resizing.""" +from pathlib import Path +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +folder = root / 'build/darling-src/build/src/external/cocotron/AppKit' +flags = {} +for line in (folder / 'CMakeFiles/X11_backend.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +obj = root / 'build/riot-client-resize-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-Wno-macro-redefined', '-c', str(root / 'tools/riot-client-resize-check.m'), + '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', '@DARLING_SDK@', str(obj), '-lSystem', '-lobjc', + '-framework', 'AppKit', '-framework', 'QuartzCore', '-framework', 'OpenGL', + '-framework', 'IOSurface', '-framework', 'CoreFoundation', '-framework', 'Foundation', + str(root / 'build/darling-src/build/src/native/libX11.dylib'), + '-o', str(root / 'build/riot-client-resize-check')], check=True) diff --git a/tools/build-riot-coretext-runtime.py b/tools/build-riot-coretext-runtime.py new file mode 100644 index 0000000..e4187b3 --- /dev/null +++ b/tools/build-riot-coretext-runtime.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Build CoreText's font-list compatibility override and standalone regression.""" +from pathlib import Path +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +folder = root / 'build/darling-src/build/src/external/cocotron/CoreText' +flags = {} +for line in (folder / 'CMakeFiles/CoreText.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +link = shlex.split((folder / 'CMakeFiles/CoreText.dir/link.txt').read_text()) +for name in ['CTFontDescriptor', 'CTFontManager']: + obj = root / f'build/riot-{name}.o' + subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-Wno-macro-redefined', '-c', + str(root / f'build/darling-src/src/external/cocotron/CoreText/{name}.m'), + '-o', str(obj)], check=True) + link[link.index(f'CMakeFiles/CoreText.dir/{name}.m.o')] = str(obj) +link[link.index('-o') + 1] = str(root / 'build/riot-CoreText') +fontconfig = str(root / 'build/darling-src/build/src/native/libfontconfig.dylib') +if not any(value.endswith('/libfontconfig.dylib') and not value.startswith('-') for value in link): + link.append(fontconfig) +subprocess.run(link, cwd=folder, check=True) +obj = root / 'build/riot-coretext-font-list-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-Wno-macro-redefined', '-c', str(root / 'tools/riot-coretext-font-list-check.m'), + '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', '@DARLING_SDK@', str(obj), str(root / 'build/riot-CoreText'), + '-lSystem', '-lobjc', '-framework', 'Foundation', '-framework', 'CoreFoundation', + '-o', str(root / 'build/riot-coretext-font-list-check')], check=True) diff --git a/tools/install-riot-appkit-runtime.sh b/tools/install-riot-appkit-runtime.sh new file mode 100755 index 0000000..18ecc50 --- /dev/null +++ b/tools/install-riot-appkit-runtime.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Install the rebuilt AppKit overlay and its paired X11 backend. +# Manually exit the game and both clients, then shut down Darling before use. +set -eu +project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +guest_home=${RIOT_GUEST_HOME:-$HOME/.darling@GUEST_HOME@} +framework="$guest_home/RuntimeFrameworks/AppKit.framework/Versions/C" +backup_dir=${RIOT_APPKIT_BACKUP_DIR:-$project_dir/build/appkit-runtime-backups} +# Preflight both artifacts and destinations before replacing either binary. +for name in AppKit X11; do + case "$name" in + AppKit) artifact=${RIOT_APPKIT_ARTIFACT:-$project_dir/build/riot-AppKit}; binary="$framework/AppKit";; + X11) artifact=${RIOT_X11_ARTIFACT:-$project_dir/build/riot-X11}; binary="$framework/Resources/Backends/X11.backend/Contents/MacOS/X11";; + esac + [ -f "$artifact" ] || { echo "Build $name first: $artifact" >&2; exit 1; } + [ -f "$binary" ] && [ ! -L "$binary" ] || { + echo "Expected an existing $name overlay: $binary" >&2; exit 1; + } +done +mkdir -p "$backup_dir" +for name in AppKit X11; do + case "$name" in + AppKit) artifact=${RIOT_APPKIT_ARTIFACT:-$project_dir/build/riot-AppKit}; binary="$framework/AppKit";; + X11) artifact=${RIOT_X11_ARTIFACT:-$project_dir/build/riot-X11}; binary="$framework/Resources/Backends/X11.backend/Contents/MacOS/X11";; + esac + if cmp -s "$artifact" "$binary"; then + echo "$name is already current." + continue + fi + old_hash=$(sha256sum "$binary" | cut -d ' ' -f 1) + backup="$backup_dir/$name-$old_hash" + if [ -e "$backup" ]; then + cmp -s "$binary" "$backup" || { echo "Backup verification failed: $backup" >&2; exit 1; } + else + cp -p "$binary" "$backup" + fi + staged=$(mktemp "$binary.staged.XXXXXX") + trap 'rm -f "$staged"' EXIT HUP INT TERM + install -m755 "$artifact" "$staged" + cmp -s "$artifact" "$staged" || { echo "Staged $name verification failed" >&2; exit 1; } + mv -f "$staged" "$binary" + echo "$name installed. Backup: $backup" +done +echo "Launch scripts/run.sh to use the new runtime." diff --git a/tools/install-riot-carboncore-runtime.sh b/tools/install-riot-carboncore-runtime.sh new file mode 100755 index 0000000..de9d6b0 --- /dev/null +++ b/tools/install-riot-carboncore-runtime.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Install the corrected legacy system-version API into the stopped guest prefix. +# Exit the game and both clients, then run darling shutdown before this script. +set -eu +project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +prefix_dir=${DPREFIX:-@DPREFIX@} +guest_home=${RIOT_GUEST_HOME:-$prefix_dir@GUEST_HOME@} +artifact=${RIOT_CARBONCORE_ARTIFACT:-$project_dir/build/riot-CarbonCore} +backup_dir=${RIOT_CARBONCORE_BACKUP_DIR:-$project_dir/build/carboncore-runtime-backups} +sdk_framework="@DARLING_SDK@/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/CarbonCore.framework" +framework="$guest_home/RuntimeFrameworks/CarbonCore.framework" +prefix_binary="$prefix_dir/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/CarbonCore.framework/Versions/A/CarbonCore" +[ -f "$artifact" ] || { echo "Build CarbonCore first: $artifact" >&2; exit 1; } +[ -f "$sdk_framework/Versions/A/CarbonCore" ] || { echo "Missing SDK CarbonCore framework" >&2; exit 1; } +mkdir -p "$guest_home/RuntimeFrameworks" "$backup_dir" +if [ ! -d "$framework" ]; then + cp -a "$sdk_framework" "$framework" +fi +for binary in "$framework/Versions/A/CarbonCore" "$prefix_binary"; do + if [ -L "$binary" ]; then echo "Expected a regular binary: $binary" >&2; exit 1; fi + if [ -f "$binary" ] && cmp -s "$artifact" "$binary"; then continue; fi + previous="$binary" + if [ ! -f "$previous" ]; then previous="$sdk_framework/Versions/A/CarbonCore"; fi + old_hash=$(sha256sum "$previous" | cut -d ' ' -f 1) + backup="$backup_dir/CarbonCore-$old_hash" + if [ -e "$backup" ]; then + cmp -s "$previous" "$backup" || { echo "Backup verification failed: $backup" >&2; exit 1; } + else + cp -p "$previous" "$backup" + fi + mkdir -p "$(dirname -- "$binary")" + staged=$(mktemp "$binary.staged.XXXXXX") + trap 'rm -f "$staged"' EXIT HUP INT TERM + install -m755 "$artifact" "$staged" + cmp -s "$artifact" "$staged" || { echo "Staged CarbonCore verification failed" >&2; exit 1; } + mv -f "$staged" "$binary" +done +echo "CarbonCore installed; backups: $backup_dir" +echo "Launch scripts/run.sh to activate it." diff --git a/tools/install-riot-gpu-runtime.sh b/tools/install-riot-gpu-runtime.sh index 2b1d35b..80b9461 100755 --- a/tools/install-riot-gpu-runtime.sh +++ b/tools/install-riot-gpu-runtime.sh @@ -14,6 +14,8 @@ python3 tools/build-m58f341-corevideo.py python3 tools/build-riot-x11-runtime.py python3 tools/build-riot-appkit-runtime.py python3 tools/build-riot-coregraphics-runtime.py +python3 tools/build-riot-coretext-runtime.py +python3 tools/build-riot-carboncore-runtime.py python3 tools/build-riot-corefoundation-runtime.py python3 tools/build-riot-kqueue-runtime.py python3 tools/build-riot-malloc-runtime.py @@ -31,7 +33,7 @@ python3 tools/stop-darling-runtime.py sudo make -C "$project_dir/build/darling-src/build/src/external/darlingserver" install -j12 # Electron's window-visibility correction lives in the AppKit overlay # (NSWindow -occlusionState, cocotron.patch); no Riot binary is modified here. -for name in IOSurface OpenGL QuartzCore CoreVideo CoreAudio AudioToolbox CoreGraphics CoreFoundation Metal; do +for name in IOSurface OpenGL QuartzCore CoreVideo CoreAudio AudioToolbox CoreGraphics CoreText CoreFoundation Metal; do framework="$guest_home/RuntimeFrameworks/$name.framework" if [ ! -d "$framework" ]; then cp -a "@DARLING_SDK@/System/Library/Frameworks/$name.framework" "$framework"; fi binary="$framework/Versions/A/$name" @@ -39,13 +41,13 @@ for name in IOSurface OpenGL QuartzCore CoreVideo CoreAudio AudioToolbox CoreGra case "$name" in IOSurface) source=build/m333-IOSurface;; OpenGL) source=build/m327-OpenGL;; - QuartzCore) - if [ -f build/riot-QuartzCore ]; then source=build/riot-QuartzCore; - else source=build/m336-QuartzCore; fi;; + # Use the artifact rebuilt by this invocation, not an older optional probe build. + QuartzCore) source=build/m336-QuartzCore;; CoreVideo) source=build/m341-CoreVideo;; CoreAudio) source=build/riot-CoreAudio;; AudioToolbox) source=build/riot-AudioToolbox;; CoreGraphics) source=build/riot-CoreGraphics;; + CoreText) source=build/riot-CoreText;; CoreFoundation) source=build/riot-CoreFoundation;; Metal) source=build/riot-Metal;; esac @@ -54,6 +56,8 @@ for name in IOSurface OpenGL QuartzCore CoreVideo CoreAudio AudioToolbox CoreGra # Dyld resolves Versions/A. Preserve the standard outer symlink as well. if [ ! -L "$framework/$name" ]; then rm -f "$framework/$name"; ln -s "Versions/Current/$name" "$framework/$name"; fi done +# Legacy version queries must also reach the corrected absolute subframework. +sh tools/install-riot-carboncore-runtime.sh # Re-exported GL symbols use the native library's absolute install name. # DYLD_LIBRARY_PATH must see this rebuilt wrapper as well as the framework copy. mkdir -p "$guest_home/RuntimeLibraries" diff --git a/tools/riot-client-resize-check.m b/tools/riot-client-resize-check.m new file mode 100644 index 0000000..b778026 --- /dev/null +++ b/tools/riot-client-resize-check.m @@ -0,0 +1,147 @@ +#import +#import +#import +#import +#import +#import +#import +#import +#include +#include +#include +#include +#include +#include + +@interface NSObject (NativeWindowID) +- (unsigned long)windowHandle; +@end +static void pump(double seconds) { + NSDate *end=[NSDate dateWithTimeIntervalSinceNow:seconds]; + while([end timeIntervalSinceNow]>0) { + NSEvent *e=[NSApp nextEventMatchingMask:NSAnyEventMask untilDate:[NSDate distantPast] inMode:NSDefaultRunLoopMode dequeue:YES]; + if(e)[NSApp sendEvent:e]; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.005]]; + } +} +static IOSurfaceRef surface(int width,int height,int generation) { + int dims[]={width,height,4,width*4,0x42475241}; + const void *keys[]={kIOSurfaceWidth,kIOSurfaceHeight,kIOSurfaceBytesPerElement,kIOSurfaceBytesPerRow,kIOSurfacePixelFormat},*values[5]; + for(int i=0;i<5;i++)values[i]=CFNumberCreate(NULL,kCFNumberIntType,&dims[i]); + CFDictionaryRef options=CFDictionaryCreate(NULL,keys,values,5,&kCFTypeDictionaryKeyCallBacks,&kCFTypeDictionaryValueCallBacks); + IOSurfaceRef s=IOSurfaceCreate(options);CFRelease(options);for(int i=0;i<5;i++)CFRelease(values[i]); + if(!s)return NULL; + unsigned char *pixels=IOSurfaceGetBaseAddress(s);size_t stride=IOSurfaceGetBytesPerRow(s); + for(int y=0;y=0 && ftruncate(fd,32+64u*1024u*1024u)==0; + static uint32_t sequence=0;sequence+=2; + FixtureFrame head={0x43414652u,sequence|1,width,height,(uint32_t)stride, + 0x42475241u|(software?0x80000000u:0),bytes}; + if(ok)ok=pwrite(fd,&head,sizeof(head),0)==sizeof(head) && + pwrite(fd,IOSurfaceGetBaseAddress(s),bytes,sizeof(head))==(ssize_t)bytes; + head.sequence=sequence; + if(ok)ok=pwrite(fd,&head,sizeof(head),0)==sizeof(head); + if(fd>=0)close(fd);CFRelease(s);return ok; +} +static int checkPublisherHandoff(void) { + char owner[128],readback[128]; + snprintf(owner,sizeof(owner),"/dev/shm/darling-ca-frame-%u-%d",getuid(),getpid()); + snprintf(readback,sizeof(readback),"/dev/shm/darling-ca-frame-%u-%d",getuid(),getpid()+100000000); + NSWindow *w=[[NSWindow alloc] initWithContentRect:NSMakeRect(0,0,1280,720) + styleMask:NSBorderlessWindowMask backing:NSBackingStoreBuffered defer:NO]; + [w setTitle:@"Darling standalone publisher handoff"]; + [w makeKeyAndOrderFront:nil]; + Display *d=XOpenDisplay(NULL);Window xid=[(id)[w platformWindow] windowHandle]; + BOOL pass=d!=NULL; + int sizes[][2]={{1280,720},{1600,900},{1024,576},{1280,720}}; + for(int n=0;pass && n<4;n++) { + int width=sizes[n][0],height=sizes[n][1]; + [w setContentSize:NSMakeSize(width,height)]; + if(n==0)pass=writeFixture(owner,width,height,n,YES); + else pass=writeFixture(owner,262,131,0,YES) && + writeFixture(readback,width,height,n,NO); + BOOL ok=NO;unsigned long pixel=0; + NSDate *deadline=[NSDate dateWithTimeIntervalSinceNow:3.0]; + while(pass && !ok && [deadline timeIntervalSinceNow]>0) { + pump(.05);XSync(d,False); + XImage *im=XGetImage(d,xid,0,0,width,height,AllPlanes,ZPixmap); + if(im){pixel=XGetPixel(im,width-8,height-8)&0xffffff; + ok=pixel==(((unsigned long)(40+n*40)<<16)|0xff);XDestroyImage(im);} + } + printf("PUBLISHER_HANDOFF size=%dx%d bottom_right=%06lx pass=%d\n",width,height,pixel,ok); + pass &=ok; + } + [w orderOut:nil];[w release];if(d)XCloseDisplay(d); + unlink(owner);unlink(readback);return pass?0:1; +} +int main(int argc, const char **argv) { + @autoreleasepool { + [NSApplication sharedApplication]; + if(argc>1 && strcmp(argv[1],"--handoff")==0)return checkPublisherHandoff(); + NSWindow *w=[[NSWindow alloc] initWithContentRect:NSMakeRect(0,0,1280,720) styleMask:NSBorderlessWindowMask backing:NSBackingStoreBuffered defer:NO]; + [w setTitle:@"Darling standalone resize pixels"]; + NSView *view=[w contentView];[view setWantsLayer:YES]; + [w makeKeyAndOrderFront:nil];pump(.15); + Display *d=XOpenDisplay(NULL);Window xid=[(id)[w platformWindow] windowHandle]; + void (*publish)(IOSurfaceRef)=dlsym(RTLD_DEFAULT,"DarlingCAPublishIOSurfacePixels"); + if(!d||!publish)return 2; + BOOL useGPU = argc > 1 && strcmp(argv[1], "--gpu") == 0; + IOSurfaceRef bootstrap=surface(1280,720,0); + [view.layer setContents:(id)bootstrap];publish(bootstrap);pump(.5);CFRelease(bootstrap); + CGLPixelFormatAttribute attrs[]={0};CGLPixelFormatObj format;GLint screens;CGLContextObj gpu; + GLuint texture=0; + if(useGPU) { + if(CGLChoosePixelFormat(attrs,&format,&screens)||CGLCreateContext(format,NULL,&gpu))return 4; + CGLSetCurrentContext(gpu);glGenTextures(1,&texture); + } + BOOL pass=YES; + int sizes[][2]={{1280,720},{1600,900},{1024,576},{1280,720}}; + for(int n=0;n<4;n++) { + int width=sizes[n][0],height=sizes[n][1]; + [w setContentSize:NSMakeSize(width,height)]; + IOSurfaceRef s=surface(width,height,n);if(!s)return 3; + if(useGPU) { + CGLSetCurrentContext(gpu);glBindTexture(GL_TEXTURE_RECTANGLE,texture); + if(CGLTexImageIOSurface2D(gpu,GL_TEXTURE_RECTANGLE,GL_RGBA8,width,height,GL_BGRA,GL_UNSIGNED_INT_8_8_8_8_REV,s,0))return 5; + } + for(int f=0;f<8;f++) { + if(useGPU)CGLSetCurrentContext(gpu); + [view.layer setContents:(id)s]; + if(!useGPU)publish(s); + [CATransaction flush];pump(.1); + } + BOOL ok=NO; + unsigned long pixel=0; + NSDate *deadline=[NSDate dateWithTimeIntervalSinceNow:2.5]; + do { + XSync(d,False); + XImage *im=XGetImage(d,xid,0,0,width,height,AllPlanes,ZPixmap); + if(im) { + pixel=XGetPixel(im,width-8,height-8)&0xffffff; + ok=pixel==(((unsigned long)(40+n*40)<<16)|0xff); + XDestroyImage(im); + } + if(!ok)pump(.05); + } while(!ok && [deadline timeIntervalSinceNow]>0); + printf("RESIZE_PIXEL mode=%s size=%dx%d view=%.0fx%.0f bottom_right=%06lx pass=%d\n", + useGPU?"gpu":"software",width,height,view.frame.size.width,view.frame.size.height,pixel,ok); + pass &=ok;CFRelease(s); + } + if(useGPU){CGLSetCurrentContext(gpu);glDeleteTextures(1,&texture);CGLSetCurrentContext(NULL);CGLReleaseContext(gpu);CGLReleasePixelFormat(format);} + [w orderOut:nil];[w release];XCloseDisplay(d); + return pass?0:1; + } +} diff --git a/tools/riot-coretext-font-list-check.m b/tools/riot-coretext-font-list-check.m new file mode 100644 index 0000000..1e0f537 --- /dev/null +++ b/tools/riot-coretext-font-list-check.m @@ -0,0 +1,53 @@ +#import +#import +#import +#include +#include + +static void verifyFontList(BOOL pass, const char *message) { + if (!pass) { fprintf(stderr, "CORETEXT_FONT_LIST FAIL: %s\n", message); exit(1); } +} + +int main(void) { + @autoreleasepool { + CFArrayRef families = CTFontManagerCopyAvailableFontFamilyNames(); + verifyFontList(families && CFArrayGetCount(families) > 0, "installed font families"); + const void *key = kCTFontFamilyNameAttribute; + CFSetRef mandatory = CFSetCreate(NULL, &key, 1, &kCFTypeSetCallBacks); + unsigned faces = 0; + for (NSString *family in (NSArray *)families) { + NSDictionary *attributes = [NSDictionary dictionaryWithObject:family forKey:(id)key]; + CTFontDescriptorRef query = CTFontDescriptorCreateWithAttributes((CFDictionaryRef)attributes); + CFArrayRef matches = CTFontDescriptorCreateMatchingFontDescriptors(query, mandatory); + verifyFontList(matches && CFArrayGetCount(matches) > 0, "family resolves to installed faces"); + for (CFIndex i = 0; i < CFArrayGetCount(matches); ++i) { + CTFontDescriptorRef match = (CTFontDescriptorRef)CFArrayGetValueAtIndex(matches, i); + CFStringRef name = CTFontDescriptorCopyAttribute(match, kCTFontFamilyNameAttribute); + verifyFontList(name && CFEqual(name, (CFStringRef)family), "mandatory family matches exactly"); + CFStringRef localized = CTFontDescriptorCopyLocalizedAttribute(match, kCTFontFamilyNameAttribute, NULL); + verifyFontList(localized && CFGetTypeID(localized) == CFStringGetTypeID(), "localized lookup with null output"); + CFRelease(localized); CFRelease(name); ++faces; + } + CTFontDescriptorRef first = CTFontDescriptorCreateMatchingFontDescriptor(query, mandatory); + verifyFontList(first != NULL, "single matching descriptor"); + CFRelease(query); CFRelease(matches); + /* The single-match result must outlive its temporary array. */ + CFTypeRef name = CTFontDescriptorCopyAttribute(first, kCTFontNameAttribute); + verifyFontList(name != NULL, "retained single descriptor and normalized PostScript name"); + CFRelease(name); CFRelease(first); + } + NSDictionary *missing = [NSDictionary dictionaryWithObject:@"DarlingMissingFont-7259AEB1" forKey:(id)key]; + CTFontDescriptorRef query = CTFontDescriptorCreateWithAttributes((CFDictionaryRef)missing); + CFArrayRef matches = CTFontDescriptorCreateMatchingFontDescriptors(query, mandatory); + verifyFontList(!matches || !CFArrayGetCount(matches), "missing mandatory family has no substitutes"); + verifyFontList(!CTFontDescriptorCreateMatchingFontDescriptor(query, mandatory), "no single match for missing font"); + CFStringRef language = CFSTR("sentinel"); + verifyFontList(!CTFontDescriptorCopyLocalizedAttribute(query, kCTFontStyleNameAttribute, &language) && !language, + "missing attribute initializes language output"); + if (matches) CFRelease(matches); + CFRelease(query); CFRelease(mandatory); + printf("CORETEXT_FONT_LIST pass=1 families=%ld faces=%u\n", CFArrayGetCount(families), faces); + CFRelease(families); + } + return 0; +} diff --git a/tools/riot-gestalt-version-check.cpp b/tools/riot-gestalt-version-check.cpp new file mode 100644 index 0000000..9277b8b --- /dev/null +++ b/tools/riot-gestalt-version-check.cpp @@ -0,0 +1,27 @@ +#include +#include +#include +#include + +int main() { + char product[64] = {}; + size_t size = sizeof(product); + int expectedMajor = 0, expectedMinor = 0, expectedPatch = 0; + if (sysctlbyname("kern.osproductversion", product, &size, nullptr, 0) || + sscanf(product, "%d.%d.%d", &expectedMajor, &expectedMinor, &expectedPatch) < 1) + return 2; + int major = -1, minor = -1, patch = -1, packed = -1, architecture = -1; + bool pass = !Gestalt(gestaltSystemVersionMajor, &major) && major == expectedMajor && + !Gestalt(gestaltSystemVersionMinor, &minor) && minor == expectedMinor && + !Gestalt(gestaltSystemVersionPatch, &patch) && patch == expectedPatch; + int expectedPacked = ((((expectedMajor / 10) << 4) | (expectedMajor % 10)) << 8) | + ((expectedMinor > 9 ? 9 : expectedMinor) << 4) | (expectedPatch > 9 ? 9 : expectedPatch); + pass &= !Gestalt(gestaltSystemVersion, &packed) && packed == expectedPacked; + pass &= !Gestalt(gestaltSysArchitecture, &architecture) && architecture == gestaltIntel; + pass &= Gestalt(gestaltSystemVersionMajor, nullptr) == paramErr; + int unknown = 123; + pass &= Gestalt(0, &unknown) == unimpErr && unknown == 0; + printf("GESTALT_VERSION product=%s version=%d.%d.%d packed=0x%x architecture=%d pass=%d\n", + product, major, minor, patch, packed, architecture, pass); + return pass ? 0 : 1; +} diff --git a/tools/riot-window-layout-check.m b/tools/riot-window-layout-check.m index 0484421..c65d0ae 100644 --- a/tools/riot-window-layout-check.m +++ b/tools/riot-window-layout-check.m @@ -1,6 +1,36 @@ #import +#import #include +@interface ResizeOrderView : NSView { +@public + NSSize nativeSizeDuringResize; +} +@end +@implementation ResizeOrderView +- (void)setFrame:(NSRect)frame { + if ([self window]) + nativeSizeDuringResize = [(id)[[self window] platformWindow] frame].size; + [super setFrame:frame]; +} +@end + +/* Browser compositors publish their new viewport from setFrameSize:, even + * when AppKit or their parent sets a complete frame rectangle. */ +@interface FrameSizeHookView : NSView { +@public + NSUInteger sizeCallbacks; + NSSize publishedSize; +} +@end +@implementation FrameSizeHookView +- (void)setFrameSize:(NSSize)size { + [super setFrameSize:size]; + sizeCallbacks++; + publishedSize = [self frame].size; +} +@end + int main(void) { @autoreleasepool { [NSApplication sharedApplication]; @@ -21,6 +51,69 @@ int main(void) { pass &= NSEqualSizes(changed, [ordinary frame].size); [full setFrame:NSMakeRect(20, 20, 900, 500) display:NO]; pass &= NSEqualSizes([[full contentView] frame].size, NSMakeSize(900, 500)); + /* Layer geometry must be current before any coordinate conversion or + * input event. Chromium submits compositor surfaces during resize. */ + NSView *view = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 1280, 720)]; + [view setWantsLayer:YES]; + [view setFrameSize:NSMakeSize(1600, 900)]; + NSSize layerSize = [[view layer] bounds].size; + BOOL layers = NSEqualSizes(layerSize, NSMakeSize(1600, 900)); + [view setBounds:NSMakeRect(10, 20, 800, 450)]; + layers &= NSEqualRects([[view layer] bounds], NSMakeRect(10, 20, 800, 450)); + [view setFrameSize:NSMakeSize(1280, 720)]; + layers &= NSEqualSizes([[view layer] bounds].size, NSMakeSize(640, 360)); + NSView *child = [[NSView alloc] initWithFrame:NSMakeRect(5, 6, 20, 30)]; + [view addSubview:child]; + [child setFrame:NSMakeRect(12, 18, 40, 50)]; + layers &= NSEqualRects([[child layer] frame], NSMakeRect(12, 18, 40, 50)); + printf("VIEW_LAYER_RESIZE initial=%.0fx%.0f pass=%d\n", layerSize.width, layerSize.height, layers); + pass &= layers; + NSWindow *native = [[NSWindow alloc] initWithContentRect:rect styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered defer:NO]; + ResizeOrderView *content = [[ResizeOrderView alloc] initWithFrame:rect]; + [native setContentView:content]; + [native platformWindow]; + [native setFrame:NSMakeRect(20, 20, 1600, 900) display:NO]; + BOOL nativeOrder = NSEqualSizes(content->nativeSizeDuringResize, NSMakeSize(1600, 900)); + printf("NATIVE_RESIZE_ORDER size=%.0fx%.0f pass=%d\n", content->nativeSizeDuringResize.width, + content->nativeSizeDuringResize.height, nativeOrder); + pass &= nativeOrder; + /* Chromium adds titlebar/overlay siblings after the actual content. + * Frame layout must not mistake the last sibling for the content. */ + NSView *overlay = [[NSView alloc] initWithFrame:NSMakeRect(4, 5, 30, 20)]; + [[[native contentView] superview] addSubview:overlay]; + [native setContentSize:NSMakeSize(1280, 720)]; + BOOL siblings = NSEqualSizes([[native contentView] frame].size, NSMakeSize(1280, 720)) && + NSEqualSizes([overlay frame].size, NSMakeSize(30, 20)); + printf("CONTENT_SIBLING_RESIZE content=%.0fx%.0f overlay=%.0fx%.0f pass=%d\n", + [[native contentView] frame].size.width, [[native contentView] frame].size.height, + [overlay frame].size.width, [overlay frame].size.height, siblings); + pass &= siblings; + NSView *host = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 1280, 720)]; + FrameSizeHookView *viewport = [[FrameSizeHookView alloc] + initWithFrame:NSMakeRect(0, 0, 1280, 720)]; + [viewport setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; + [host addSubview:viewport]; + [host setFrame:NSMakeRect(0, 0, 1600, 900)]; + BOOL hooks = viewport->sizeCallbacks == 1 && + NSEqualSizes(viewport->publishedSize, NSMakeSize(1600, 900)); + [host setFrame:NSMakeRect(0, 0, 1024, 576)]; + hooks &= viewport->sizeCallbacks == 2 && + NSEqualSizes(viewport->publishedSize, NSMakeSize(1024, 576)); + [viewport setFrame:NSMakeRect(3, 4, 800, 450)]; + hooks &= viewport->sizeCallbacks == 3 && + NSEqualSizes(viewport->publishedSize, NSMakeSize(800, 450)); + [viewport setFrameSize:NSMakeSize(640, 360)]; + hooks &= viewport->sizeCallbacks == 4 && + NSEqualSizes(viewport->publishedSize, NSMakeSize(640, 360)); + printf("FRAME_SIZE_HOOK callbacks=%lu published=%.0fx%.0f pass=%d\n", + (unsigned long)viewport->sizeCallbacks, viewport->publishedSize.width, + viewport->publishedSize.height, hooks); + pass &= hooks; + [viewport release]; [host release]; + [overlay release]; + [content release]; [native release]; + [child release]; [view release]; printf("WINDOW_LAYOUT_CHECK full=%.0fx%.0f changed=%.0fx%.0f pass=%d\n", size.width, size.height, changed.width, changed.height, pass); [ordinary release]; [full release]; diff --git a/upstream/manifest.json b/upstream/manifest.json index 016154d..63d753c 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -8,7 +8,7 @@ "revision": "60ba801decee7a00782f74f6be4c8ffb013f79ff", "repository": "https://github.com/darlinghq/darling.git", "patch": "patches/darling.patch", - "modified_files": 87, + "modified_files": 88, "new_files": [ "src/CoreAudio/CoreAudio/AudioConverterShim.c", "src/CoreAudio/AudioTrace.h", @@ -56,9 +56,10 @@ "revision": "c8d38d16a9f613d300157bebbab2b9501bc0c274", "repository": "https://github.com/darlinghq/darling-cocotron.git", "patch": "patches/cocotron.patch", - "modified_files": 103, + "modified_files": 104, "new_files": [ - "QuartzCore/include/QuartzCore/CATransformLayer.h" + "QuartzCore/include/QuartzCore/CATransformLayer.h", + "AppKit/NSGeometryTrace.h" ] }, { From 1828169e57a8c2be6ad3628114cd61033bc0593d Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:05:25 +0200 Subject: [PATCH 004/160] Implement the vDSP kernels used by WebAudio Darling's libvDSP was almost entirely void stubs. Chromium's Blink WebAudio on macOS uses vDSP_vsmul for any GainNode gain other than 0 or 1, so with the League client's master volume below 100% the master gain output buffer was never written: silence when starting below 100%, and the last block repeating as a buzz when lowering the volume. vDSP_vadd was also a stub, so simultaneous sounds were not summed and a new sound replaced the previous one. Implement every vDSP function the Chromium Embedded Framework imports: vadd, vsub, vmul, vsmul, vsadd, vsma, svesq, vclip, conv, ctoz, ztoc, zvmul, deq22, deq22D, create_fftsetup, destroy_fftsetup and fft_zrip, with Apple's argument order and scaling (forward real FFT is 2x the DFT, inverse unscaled). The FFT is in place and keeps only read-only twiddles in the setup, which callers share across threads. Build vDSP at -O2 because it runs on the realtime render thread, and drop the uncompiled basic.c. The runtime installer now builds libvDSP and installs it at its absolute path in the stopped prefix, keeping a .before-vdsp backup. Verified against naive references for sizes up to 2^15 on the host; confirmed manually that client sound is fixed at any volume. Fixes #4 Co-Authored-By: Claude Opus 5.5 --- patches/darling.patch | 837 ++++++++++++++++++++++++++++++ tools/install-riot-gpu-runtime.sh | 11 + 2 files changed, 848 insertions(+) diff --git a/patches/darling.patch b/patches/darling.patch index f1017ae..e6ac523 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -9528,3 +9528,840 @@ index dffdd4cb2..7430f587b 100644 case gestaltSysArchitecture: #ifdef __powerpc__ *value = gestaltPowerPC; +diff --git a/src/frameworks/Accelerate/vecLib/vDSP/CMakeLists.txt b/src/frameworks/Accelerate/vecLib/vDSP/CMakeLists.txt +index 6ef701609..9bdcace68 100644 +--- a/src/frameworks/Accelerate/vecLib/vDSP/CMakeLists.txt ++++ b/src/frameworks/Accelerate/vecLib/vDSP/CMakeLists.txt +@@ -7,7 +7,11 @@ set(DYLIB_CURRENT_VERSION "671.250.4") + add_darling_library(vDSP SHARED + src/vDSP.c + src/extrema.c ++ src/arith.c ++ src/fft.c + ) ++# WebAudio calls these on the realtime render thread. ++target_compile_options(vDSP PRIVATE -O2) + make_fat(vDSP) + target_link_libraries(vDSP system) + install(TARGETS vDSP DESTINATION libexec/darling/usr/lib) +diff --git a/src/frameworks/Accelerate/vecLib/vDSP/include/vDSP/vDSP.h b/src/frameworks/Accelerate/vecLib/vDSP/include/vDSP/vDSP.h +index c6b437c5b..88505eda4 100644 +--- a/src/frameworks/Accelerate/vecLib/vDSP/include/vDSP/vDSP.h ++++ b/src/frameworks/Accelerate/vecLib/vDSP/include/vDSP/vDSP.h +@@ -24,6 +24,51 @@ + typedef unsigned long vDSP_Length; + typedef long vDSP_Stride; + ++typedef struct DSPComplex { ++ float real; ++ float imag; ++} DSPComplex; ++ ++typedef struct DSPSplitComplex { ++ float *realp; ++ float *imagp; ++} DSPSplitComplex; ++ ++typedef int FFTDirection; ++typedef int FFTRadix; ++ ++enum { ++ kFFTDirection_Forward = 1, ++ kFFTDirection_Inverse = -1 ++}; ++ ++enum { ++ kFFTRadix2 = 0, ++ kFFTRadix3 = 1, ++ kFFTRadix5 = 2 ++}; ++ ++typedef struct OpaqueFFTSetup *FFTSetup; ++ ++void vDSP_vadd(const float *__A, vDSP_Stride __IA, const float *__B, vDSP_Stride __IB, float *__C, vDSP_Stride __IC, vDSP_Length __N); ++void vDSP_vsub(const float *__B, vDSP_Stride __IB, const float *__A, vDSP_Stride __IA, float *__C, vDSP_Stride __IC, vDSP_Length __N); ++void vDSP_vmul(const float *__A, vDSP_Stride __IA, const float *__B, vDSP_Stride __IB, float *__C, vDSP_Stride __IC, vDSP_Length __N); ++void vDSP_vsmul(const float *__A, vDSP_Stride __IA, const float *__B, float *__C, vDSP_Stride __IC, vDSP_Length __N); ++void vDSP_vsadd(const float *__A, vDSP_Stride __IA, const float *__B, float *__C, vDSP_Stride __IC, vDSP_Length __N); ++void vDSP_vsma(const float *__A, vDSP_Stride __IA, const float *__B, const float *__C, vDSP_Stride __IC, float *__D, vDSP_Stride __ID, vDSP_Length __N); ++void vDSP_svesq(const float *__A, vDSP_Stride __IA, float *__C, vDSP_Length __N); ++void vDSP_vclip(const float *__A, vDSP_Stride __IA, const float *__B, const float *__C, float *__D, vDSP_Stride __ID, vDSP_Length __N); ++void vDSP_maxmgv(const float *__A, vDSP_Stride __IA, float *__C, vDSP_Length __N); ++void vDSP_conv(const float *__A, vDSP_Stride __IA, const float *__F, vDSP_Stride __IF, float *__C, vDSP_Stride __IC, vDSP_Length __N, vDSP_Length __P); ++void vDSP_ctoz(const DSPComplex *__C, vDSP_Stride __IC, const DSPSplitComplex *__Z, vDSP_Stride __IZ, vDSP_Length __N); ++void vDSP_ztoc(const DSPSplitComplex *__Z, vDSP_Stride __IZ, DSPComplex *__C, vDSP_Stride __IC, vDSP_Length __N); ++void vDSP_zvmul(const DSPSplitComplex *__A, vDSP_Stride __IA, const DSPSplitComplex *__B, vDSP_Stride __IB, const DSPSplitComplex *__C, vDSP_Stride __IC, vDSP_Length __N, int __Conjugate); ++void vDSP_deq22(const float *__A, vDSP_Stride __IA, const float *__B, float *__C, vDSP_Stride __IC, vDSP_Length __N); ++void vDSP_deq22D(const double *__A, vDSP_Stride __IA, const double *__B, double *__C, vDSP_Stride __IC, vDSP_Length __N); ++FFTSetup vDSP_create_fftsetup(vDSP_Length __Log2n, FFTRadix __Radix); ++void vDSP_destroy_fftsetup(FFTSetup __setup); ++void vDSP_fft_zrip(FFTSetup __Setup, const DSPSplitComplex *__C, vDSP_Stride __IC, vDSP_Length __Log2N, FFTDirection __Direction); ++ + void* vDSP_DCT_CreateSetup(void); + void* vDSP_DCT_Execute(void); + void* vDSP_DFT_CreateSetup(void); +@@ -63,17 +108,11 @@ void* vDSP_biquadm_SetTargetsDouble(void); + void* vDSP_biquadm_SetTargetsSingle(void); + void* vDSP_blkman_window(void); + void* vDSP_blkman_windowD(void); +-void* vDSP_conv(void); + void* vDSP_convD(void); +-void* vDSP_create_fftsetup(void); + void* vDSP_create_fftsetupD(void); +-void* vDSP_ctoz(void); + void* vDSP_ctozD(void); +-void* vDSP_deq22(void); +-void* vDSP_deq22D(void); + void* vDSP_desamp(void); + void* vDSP_desampD(void); +-void* vDSP_destroy_fftsetup(void); + void* vDSP_destroy_fftsetupD(void); + void* vDSP_distancesq(void); + void* vDSP_distancesqD(void); +@@ -117,7 +156,6 @@ void* vDSP_fft_zop(void); + void* vDSP_fft_zopD(void); + void* vDSP_fft_zopt(void); + void* vDSP_fft_zoptD(void); +-void* vDSP_fft_zrip(void); + void* vDSP_fft_zripD(void); + void* vDSP_fft_zript(void); + void* vDSP_fft_zriptD(void); +@@ -194,7 +232,6 @@ void* vDSP_sve_svesq(void); + void* vDSP_sve_svesqD(void); + void* vDSP_svemg(void); + void* vDSP_svemgD(void); +-void* vDSP_svesq(void); + void* vDSP_svesqD(void); + void* vDSP_svs(void); + void* vDSP_svsD(void); +@@ -203,7 +240,6 @@ void* vDSP_vaamD(void); + void* vDSP_vabs(void); + void* vDSP_vabsD(void); + void* vDSP_vabsi(void); +-void* vDSP_vadd(void); + void* vDSP_vaddD(void); + void* vDSP_vaddi(void); + void* vDSP_vaddsub(void); +@@ -216,7 +252,6 @@ void* vDSP_vasm(void); + void* vDSP_vasmD(void); + void* vDSP_vavlin(void); + void* vDSP_vavlinD(void); +-void* vDSP_vclip(void); + void* vDSP_vclipD(void); + void* vDSP_vclipc(void); + void* vDSP_vclipcD(void); +@@ -316,7 +351,6 @@ void* vDSP_vmsa(void); + void* vDSP_vmsaD(void); + void* vDSP_vmsb(void); + void* vDSP_vmsbD(void); +-void* vDSP_vmul(void); + void* vDSP_vmulD(void); + void* vDSP_vnabs(void); + void* vDSP_vnabsD(void); +@@ -350,7 +384,6 @@ void* vDSP_vrsum(void); + void* vDSP_vrsumD(void); + void* vDSP_vrvrs(void); + void* vDSP_vrvrsD(void); +-void* vDSP_vsadd(void); + void* vDSP_vsaddD(void); + void* vDSP_vsaddi(void); + void* vDSP_vsbm(void); +@@ -364,7 +397,6 @@ void* vDSP_vsdivD(void); + void* vDSP_vsdivi(void); + void* vDSP_vsimps(void); + void* vDSP_vsimpsD(void); +-void* vDSP_vsma(void); + void* vDSP_vsmaD(void); + void* vDSP_vsmfix24(void); + void* vDSP_vsmfixu24(void); +@@ -374,7 +406,6 @@ void* vDSP_vsmsb(void); + void* vDSP_vsmsbD(void); + void* vDSP_vsmsma(void); + void* vDSP_vsmsmaD(void); +-void* vDSP_vsmul(void); + void* vDSP_vsmulD(void); + void* vDSP_vsort(void); + void* vDSP_vsortD(void); +@@ -385,7 +416,6 @@ void* vDSP_vsq(void); + void* vDSP_vsqD(void); + void* vDSP_vssq(void); + void* vDSP_vssqD(void); +-void* vDSP_vsub(void); + void* vDSP_vsubD(void); + void* vDSP_vswap(void); + void* vDSP_vswapD(void); +@@ -439,7 +469,6 @@ void* vDSP_zrvmul(void); + void* vDSP_zrvmulD(void); + void* vDSP_zrvsub(void); + void* vDSP_zrvsubD(void); +-void* vDSP_ztoc(void); + void* vDSP_ztocD(void); + void* vDSP_ztrans(void); + void* vDSP_ztransD(void); +@@ -467,7 +496,6 @@ void* vDSP_zvmmaa(void); + void* vDSP_zvmmaaD(void); + void* vDSP_zvmov(void); + void* vDSP_zvmovD(void); +-void* vDSP_zvmul(void); + void* vDSP_zvmulD(void); + void* vDSP_zvneg(void); + void* vDSP_zvnegD(void); +diff --git a/src/frameworks/Accelerate/vecLib/vDSP/src/arith.c b/src/frameworks/Accelerate/vecLib/vDSP/src/arith.c +new file mode 100644 +index 000000000..52550fb96 +--- /dev/null ++++ b/src/frameworks/Accelerate/vecLib/vDSP/src/arith.c +@@ -0,0 +1,226 @@ ++/* ++ This file is part of Darling. ++ ++ Copyright (C) 2026 Darling Developers ++ ++ Darling is free software: you can redistribute it and/or modify ++ it under the terms of the GNU General Public License as published by ++ the Free Software Foundation, either version 3 of the License, or ++ (at your option) any later version. ++ ++ Darling is distributed in the hope that it will be useful, ++ but WITHOUT ANY WARRANTY; without even the implied warranty of ++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ GNU General Public License for more details. ++ ++ You should have received a copy of the GNU General Public License ++ along with Darling. If not, see . ++*/ ++ ++#include ++ ++void vDSP_vadd(const float *__A, vDSP_Stride __IA, const float *__B, vDSP_Stride __IB, float *__C, vDSP_Stride __IC, vDSP_Length __N) ++{ ++ if (__IA == 1 && __IB == 1 && __IC == 1) ++ { ++ for (vDSP_Length i = 0; i < __N; i++) ++ __C[i] = __A[i] + __B[i]; ++ } ++ else ++ { ++ for (vDSP_Length i = 0; i < __N; i++) ++ __C[i * __IC] = __A[i * __IA] + __B[i * __IB]; ++ } ++} ++ ++// Apple's argument order: the subtrahend B comes first, C = A - B. ++void vDSP_vsub(const float *__B, vDSP_Stride __IB, const float *__A, vDSP_Stride __IA, float *__C, vDSP_Stride __IC, vDSP_Length __N) ++{ ++ if (__IA == 1 && __IB == 1 && __IC == 1) ++ { ++ for (vDSP_Length i = 0; i < __N; i++) ++ __C[i] = __A[i] - __B[i]; ++ } ++ else ++ { ++ for (vDSP_Length i = 0; i < __N; i++) ++ __C[i * __IC] = __A[i * __IA] - __B[i * __IB]; ++ } ++} ++ ++void vDSP_vmul(const float *__A, vDSP_Stride __IA, const float *__B, vDSP_Stride __IB, float *__C, vDSP_Stride __IC, vDSP_Length __N) ++{ ++ if (__IA == 1 && __IB == 1 && __IC == 1) ++ { ++ for (vDSP_Length i = 0; i < __N; i++) ++ __C[i] = __A[i] * __B[i]; ++ } ++ else ++ { ++ for (vDSP_Length i = 0; i < __N; i++) ++ __C[i * __IC] = __A[i * __IA] * __B[i * __IB]; ++ } ++} ++ ++void vDSP_vsmul(const float *__A, vDSP_Stride __IA, const float *__B, float *__C, vDSP_Stride __IC, vDSP_Length __N) ++{ ++ const float b = *__B; ++ ++ if (__IA == 1 && __IC == 1) ++ { ++ for (vDSP_Length i = 0; i < __N; i++) ++ __C[i] = __A[i] * b; ++ } ++ else ++ { ++ for (vDSP_Length i = 0; i < __N; i++) ++ __C[i * __IC] = __A[i * __IA] * b; ++ } ++} ++ ++void vDSP_vsadd(const float *__A, vDSP_Stride __IA, const float *__B, float *__C, vDSP_Stride __IC, vDSP_Length __N) ++{ ++ const float b = *__B; ++ ++ if (__IA == 1 && __IC == 1) ++ { ++ for (vDSP_Length i = 0; i < __N; i++) ++ __C[i] = __A[i] + b; ++ } ++ else ++ { ++ for (vDSP_Length i = 0; i < __N; i++) ++ __C[i * __IC] = __A[i * __IA] + b; ++ } ++} ++ ++void vDSP_vsma(const float *__A, vDSP_Stride __IA, const float *__B, const float *__C, vDSP_Stride __IC, float *__D, vDSP_Stride __ID, vDSP_Length __N) ++{ ++ const float b = *__B; ++ ++ if (__IA == 1 && __IC == 1 && __ID == 1) ++ { ++ for (vDSP_Length i = 0; i < __N; i++) ++ __D[i] = __A[i] * b + __C[i]; ++ } ++ else ++ { ++ for (vDSP_Length i = 0; i < __N; i++) ++ __D[i * __ID] = __A[i * __IA] * b + __C[i * __IC]; ++ } ++} ++ ++void vDSP_svesq(const float *__A, vDSP_Stride __IA, float *__C, vDSP_Length __N) ++{ ++ float sum = 0; ++ ++ for (vDSP_Length i = 0; i < __N; i++) ++ sum += __A[i * __IA] * __A[i * __IA]; ++ *__C = sum; ++} ++ ++void vDSP_vclip(const float *__A, vDSP_Stride __IA, const float *__B, const float *__C, float *__D, vDSP_Stride __ID, vDSP_Length __N) ++{ ++ const float low = *__B, high = *__C; ++ ++ for (vDSP_Length i = 0; i < __N; i++) ++ { ++ float v = __A[i * __IA]; ++ if (v < low) ++ v = low; ++ else if (v > high) ++ v = high; ++ __D[i * __ID] = v; ++ } ++} ++ ++// Correlation, or convolution when F points at the filter's last tap with a negative stride. ++void vDSP_conv(const float *__A, vDSP_Stride __IA, const float *__F, vDSP_Stride __IF, float *__C, vDSP_Stride __IC, vDSP_Length __N, vDSP_Length __P) ++{ ++ for (vDSP_Length n = 0; n < __N; n++) ++ { ++ const float *a = __A + n * __IA; ++ float sum = 0; ++ ++ for (vDSP_Length p = 0; p < __P; p++) ++ sum += a[p * __IA] * __F[p * __IF]; ++ __C[n * __IC] = sum; ++ } ++} ++ ++// IC counts floats, so 2 is a contiguous DSPComplex array. ++void vDSP_ctoz(const DSPComplex *__C, vDSP_Stride __IC, const DSPSplitComplex *__Z, vDSP_Stride __IZ, vDSP_Length __N) ++{ ++ const float *c = (const float *) __C; ++ ++ for (vDSP_Length i = 0; i < __N; i++) ++ { ++ __Z->realp[i * __IZ] = c[i * __IC]; ++ __Z->imagp[i * __IZ] = c[i * __IC + 1]; ++ } ++} ++ ++void vDSP_ztoc(const DSPSplitComplex *__Z, vDSP_Stride __IZ, DSPComplex *__C, vDSP_Stride __IC, vDSP_Length __N) ++{ ++ float *c = (float *) __C; ++ ++ for (vDSP_Length i = 0; i < __N; i++) ++ { ++ c[i * __IC] = __Z->realp[i * __IZ]; ++ c[i * __IC + 1] = __Z->imagp[i * __IZ]; ++ } ++} ++ ++// C may alias A or B. Conjugate is 1 for A * B and -1 for conj(A) * B. ++void vDSP_zvmul(const DSPSplitComplex *__A, vDSP_Stride __IA, const DSPSplitComplex *__B, vDSP_Stride __IB, const DSPSplitComplex *__C, vDSP_Stride __IC, vDSP_Length __N, int __Conjugate) ++{ ++ const float sign = __Conjugate == -1 ? -1.0f : 1.0f; ++ ++ for (vDSP_Length i = 0; i < __N; i++) ++ { ++ const float ar = __A->realp[i * __IA], ai = sign * __A->imagp[i * __IA]; ++ const float br = __B->realp[i * __IB], bi = __B->imagp[i * __IB]; ++ ++ __C->realp[i * __IC] = ar * br - ai * bi; ++ __C->imagp[i * __IC] = ar * bi + ai * br; ++ } ++} ++ ++// Two-pole two-zero filter. A and C hold N + 2 elements; C[0] and C[1] are the initial outputs. ++void vDSP_deq22D(const double *__A, vDSP_Stride __IA, const double *__B, double *__C, vDSP_Stride __IC, vDSP_Length __N) ++{ ++ const double b0 = __B[0], b1 = __B[1], b2 = __B[2], a1 = __B[3], a2 = __B[4]; ++ double x1 = __A[__IA], x2 = __A[0]; ++ double y1 = __C[__IC], y2 = __C[0]; ++ ++ for (vDSP_Length n = 2; n < __N + 2; n++) ++ { ++ const double x = __A[n * __IA]; ++ const double y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2; ++ ++ __C[n * __IC] = y; ++ x2 = x1; ++ x1 = x; ++ y2 = y1; ++ y1 = y; ++ } ++} ++ ++void vDSP_deq22(const float *__A, vDSP_Stride __IA, const float *__B, float *__C, vDSP_Stride __IC, vDSP_Length __N) ++{ ++ const float b0 = __B[0], b1 = __B[1], b2 = __B[2], a1 = __B[3], a2 = __B[4]; ++ float x1 = __A[__IA], x2 = __A[0]; ++ float y1 = __C[__IC], y2 = __C[0]; ++ ++ for (vDSP_Length n = 2; n < __N + 2; n++) ++ { ++ const float x = __A[n * __IA]; ++ const float y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2; ++ ++ __C[n * __IC] = y; ++ x2 = x1; ++ x1 = x; ++ y2 = y1; ++ y1 = y; ++ } ++} +diff --git a/src/frameworks/Accelerate/vecLib/vDSP/src/basic.c b/src/frameworks/Accelerate/vecLib/vDSP/src/basic.c +deleted file mode 100644 +index ecdd15b23..000000000 +--- a/src/frameworks/Accelerate/vecLib/vDSP/src/basic.c ++++ /dev/null +@@ -1,36 +0,0 @@ +-/* +- This file is part of Darling. +- +- Copyright (C) 2020 Lubos Dolezel +- +- Darling is free software: you can redistribute it and/or modify +- it under the terms of the GNU General Public License as published by +- the Free Software Foundation, either version 3 of the License, or +- (at your option) any later version. +- +- Darling is distributed in the hope that it will be useful, +- but WITHOUT ANY WARRANTY; without even the implied warranty of +- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +- GNU General Public License for more details. +- +- You should have received a copy of the GNU General Public License +- along with Darling. If not, see . +-*/ +- +-#include +- +-void vDSP_vsadd(const float *__A, vDSP_Stride __IA, const float *__B, float *__C, vDSP_Stride __IC, vDSP_Length __N) +-{ +- if (__IA == 1 && __IC == 1) +- { +- #pragma clang loop vectorize(enable) +- for (vDSP_Length i = 0; i < __N; i++) +- __C[i] = __A[i] + __B[i]; +- } +- else +- { +- #pragma clang loop vectorize(enable) +- for (vDSP_Length i = 0; i < __N; i++) +- __C[i * __IC] = __A[i * __IA] + __B[i * __IA]; +- } +-} +diff --git a/src/frameworks/Accelerate/vecLib/vDSP/src/fft.c b/src/frameworks/Accelerate/vecLib/vDSP/src/fft.c +new file mode 100644 +index 000000000..92873d796 +--- /dev/null ++++ b/src/frameworks/Accelerate/vecLib/vDSP/src/fft.c +@@ -0,0 +1,162 @@ ++/* ++ This file is part of Darling. ++ ++ Copyright (C) 2026 Darling Developers ++ ++ Darling is free software: you can redistribute it and/or modify ++ it under the terms of the GNU General Public License as published by ++ the Free Software Foundation, either version 3 of the License, or ++ (at your option) any later version. ++ ++ Darling is distributed in the hope that it will be useful, ++ but WITHOUT ANY WARRANTY; without even the implied warranty of ++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++ GNU General Public License for more details. ++ ++ You should have received a copy of the GNU General Public License ++ along with Darling. If not, see . ++*/ ++ ++#include ++#include ++#include ++ ++// Setups are shared between threads, so only read-only twiddles live here. ++struct OpaqueFFTSetup ++{ ++ vDSP_Length log2n; ++ float *cosine; ++ float *sine; ++}; ++ ++FFTSetup vDSP_create_fftsetup(vDSP_Length __Log2n, FFTRadix __Radix) ++{ ++ if (__Radix != kFFTRadix2 || __Log2n > 30) ++ return NULL; ++ ++ const size_t n = (size_t) 1 << __Log2n; ++ const size_t half = n > 1 ? n / 2 : 1; ++ struct OpaqueFFTSetup *setup = malloc(sizeof(*setup) + 2 * half * sizeof(float)); ++ ++ if (!setup) ++ return NULL; ++ setup->log2n = __Log2n; ++ setup->cosine = (float *) (setup + 1); ++ setup->sine = setup->cosine + half; ++ for (size_t j = 0; j < half; j++) ++ { ++ const double angle = 2.0 * M_PI * (double) j / (double) n; ++ setup->cosine[j] = cos(angle); ++ setup->sine[j] = sin(angle); ++ } ++ return setup; ++} ++ ++void vDSP_destroy_fftsetup(FFTSetup __setup) ++{ ++ free(__setup); ++} ++ ++// In-place radix-2 complex FFT of 2^log2m points, e^-i for forward and e^+i for inverse, unscaled. ++static void complex_fft(const struct OpaqueFFTSetup *setup, float *re, float *im, vDSP_Stride stride, vDSP_Length log2m, int inverse) ++{ ++ const size_t m = (size_t) 1 << log2m; ++ const size_t full = (size_t) 1 << setup->log2n; ++ ++ for (size_t i = 1, j = 0; i < m; i++) ++ { ++ size_t bit = m >> 1; ++ for (; j & bit; bit >>= 1) ++ j ^= bit; ++ j ^= bit; ++ if (i < j) ++ { ++ float t = re[i * stride]; ++ re[i * stride] = re[j * stride]; ++ re[j * stride] = t; ++ t = im[i * stride]; ++ im[i * stride] = im[j * stride]; ++ im[j * stride] = t; ++ } ++ } ++ ++ for (size_t len = 2; len <= m; len <<= 1) ++ { ++ const size_t half = len >> 1; ++ const size_t step = full / len; ++ ++ for (size_t base = 0; base < m; base += len) ++ { ++ for (size_t k = 0; k < half; k++) ++ { ++ const float wr = setup->cosine[k * step]; ++ const float wi = inverse ? setup->sine[k * step] : -setup->sine[k * step]; ++ const size_t a = (base + k) * stride, b = (base + k + half) * stride; ++ const float tr = re[b] * wr - im[b] * wi; ++ const float ti = re[b] * wi + im[b] * wr; ++ ++ re[b] = re[a] - tr; ++ im[b] = im[a] - ti; ++ re[a] += tr; ++ im[a] += ti; ++ } ++ } ++ } ++} ++ ++// Real FFT on N = 2^Log2N points packed as N/2 complex values (even samples real, odd imaginary). ++// Forward returns twice the DFT with X[0] in realp[0] and X[N/2] in imagp[0]; inverse is unscaled, ++// so a round trip multiplies by 2N, as on macOS. ++void vDSP_fft_zrip(FFTSetup __Setup, const DSPSplitComplex *__C, vDSP_Stride __IC, vDSP_Length __Log2N, FFTDirection __Direction) ++{ ++ if (!__Setup || __Log2N == 0 || __Log2N > __Setup->log2n) ++ return; ++ ++ float *re = __C->realp, *im = __C->imagp; ++ const vDSP_Stride s = __IC; ++ const size_t m = (size_t) 1 << (__Log2N - 1); ++ const size_t step = ((size_t) 1 << __Setup->log2n) >> __Log2N; ++ ++ if (__Direction != kFFTDirection_Inverse) ++ { ++ complex_fft(__Setup, re, im, s, __Log2N - 1, 0); ++ ++ const float z0r = re[0], z0i = im[0]; ++ re[0] = 2 * (z0r + z0i); ++ im[0] = 2 * (z0r - z0i); ++ ++ for (size_t k = 1; k <= m / 2; k++) ++ { ++ const size_t j = m - k; ++ const float c = __Setup->cosine[k * step], sn = __Setup->sine[k * step]; ++ const float ar = re[k * s], ai = im[k * s], br = re[j * s], bi = im[j * s]; ++ const float sr = ar + br, si = ai - bi, dr = ar - br, di = ai + bi; ++ ++ re[k * s] = sr + (c * di - sn * dr); ++ im[k * s] = si - (c * dr + sn * di); ++ re[j * s] = sr - (c * di - sn * dr); ++ im[j * s] = -si - (c * dr + sn * di); ++ } ++ } ++ else ++ { ++ const float p0r = re[0], p0i = im[0]; ++ re[0] = p0r + p0i; ++ im[0] = p0r - p0i; ++ ++ for (size_t k = 1; k <= m / 2; k++) ++ { ++ const size_t j = m - k; ++ const float c = __Setup->cosine[k * step], sn = __Setup->sine[k * step]; ++ const float ar = re[k * s], ai = im[k * s], br = re[j * s], bi = im[j * s]; ++ const float sr = ar + br, si = ai - bi, dr = ar - br, di = ai + bi; ++ ++ re[k * s] = sr - (c * di + sn * dr); ++ im[k * s] = si + (c * dr - sn * di); ++ re[j * s] = sr + (c * di + sn * dr); ++ im[j * s] = -si + (c * dr - sn * di); ++ } ++ ++ complex_fft(__Setup, re, im, s, __Log2N - 1, 1); ++ } ++} +diff --git a/src/frameworks/Accelerate/vecLib/vDSP/src/vDSP.c b/src/frameworks/Accelerate/vecLib/vDSP/src/vDSP.c +index 24c037abc..cc18546ce 100644 +--- a/src/frameworks/Accelerate/vecLib/vDSP/src/vDSP.c ++++ b/src/frameworks/Accelerate/vecLib/vDSP/src/vDSP.c +@@ -263,54 +263,24 @@ void* vDSP_blkman_windowD(void) + return NULL; + } + +-void* vDSP_conv(void) +-{ +- if (verbose) puts("STUB: vDSP_conv called"); +- return NULL; +-} +- + void* vDSP_convD(void) + { + if (verbose) puts("STUB: vDSP_convD called"); + return NULL; + } + +-void* vDSP_create_fftsetup(void) +-{ +- if (verbose) puts("STUB: vDSP_create_fftsetup called"); +- return NULL; +-} +- + void* vDSP_create_fftsetupD(void) + { + if (verbose) puts("STUB: vDSP_create_fftsetupD called"); + return NULL; + } + +-void* vDSP_ctoz(void) +-{ +- if (verbose) puts("STUB: vDSP_ctoz called"); +- return NULL; +-} +- + void* vDSP_ctozD(void) + { + if (verbose) puts("STUB: vDSP_ctozD called"); + return NULL; + } + +-void* vDSP_deq22(void) +-{ +- if (verbose) puts("STUB: vDSP_deq22 called"); +- return NULL; +-} +- +-void* vDSP_deq22D(void) +-{ +- if (verbose) puts("STUB: vDSP_deq22D called"); +- return NULL; +-} +- + void* vDSP_desamp(void) + { + if (verbose) puts("STUB: vDSP_desamp called"); +@@ -323,12 +293,6 @@ void* vDSP_desampD(void) + return NULL; + } + +-void* vDSP_destroy_fftsetup(void) +-{ +- if (verbose) puts("STUB: vDSP_destroy_fftsetup called"); +- return NULL; +-} +- + void* vDSP_destroy_fftsetupD(void) + { + if (verbose) puts("STUB: vDSP_destroy_fftsetupD called"); +@@ -587,12 +551,6 @@ void* vDSP_fft_zoptD(void) + return NULL; + } + +-void* vDSP_fft_zrip(void) +-{ +- if (verbose) puts("STUB: vDSP_fft_zrip called"); +- return NULL; +-} +- + void* vDSP_fft_zripD(void) + { + if (verbose) puts("STUB: vDSP_fft_zripD called"); +@@ -1057,12 +1015,6 @@ void* vDSP_svemgD(void) + return NULL; + } + +-void* vDSP_svesq(void) +-{ +- if (verbose) puts("STUB: vDSP_svesq called"); +- return NULL; +-} +- + void* vDSP_svesqD(void) + { + if (verbose) puts("STUB: vDSP_svesqD called"); +@@ -1111,12 +1063,6 @@ void* vDSP_vabsi(void) + return NULL; + } + +-void* vDSP_vadd(void) +-{ +- if (verbose) puts("STUB: vDSP_vadd called"); +- return NULL; +-} +- + void* vDSP_vaddD(void) + { + if (verbose) puts("STUB: vDSP_vaddD called"); +@@ -1189,12 +1135,6 @@ void* vDSP_vavlinD(void) + return NULL; + } + +-void* vDSP_vclip(void) +-{ +- if (verbose) puts("STUB: vDSP_vclip called"); +- return NULL; +-} +- + void* vDSP_vclipD(void) + { + if (verbose) puts("STUB: vDSP_vclipD called"); +@@ -1789,12 +1729,6 @@ void* vDSP_vmsbD(void) + return NULL; + } + +-void* vDSP_vmul(void) +-{ +- if (verbose) puts("STUB: vDSP_vmul called"); +- return NULL; +-} +- + void* vDSP_vmulD(void) + { + if (verbose) puts("STUB: vDSP_vmulD called"); +@@ -1993,14 +1927,6 @@ void* vDSP_vrvrsD(void) + return NULL; + } + +-/* +-void* vDSP_vsadd(void) +-{ +- if (verbose) puts("STUB: vDSP_vsadd called"); +- return NULL; +-} +-*/ +- + void* vDSP_vsaddD(void) + { + if (verbose) puts("STUB: vDSP_vsaddD called"); +@@ -2079,12 +2005,6 @@ void* vDSP_vsimpsD(void) + return NULL; + } + +-void* vDSP_vsma(void) +-{ +- if (verbose) puts("STUB: vDSP_vsma called"); +- return NULL; +-} +- + void* vDSP_vsmaD(void) + { + if (verbose) puts("STUB: vDSP_vsmaD called"); +@@ -2139,12 +2059,6 @@ void* vDSP_vsmsmaD(void) + return NULL; + } + +-void* vDSP_vsmul(void) +-{ +- if (verbose) puts("STUB: vDSP_vsmul called"); +- return NULL; +-} +- + void* vDSP_vsmulD(void) + { + if (verbose) puts("STUB: vDSP_vsmulD called"); +@@ -2205,12 +2119,6 @@ void* vDSP_vssqD(void) + return NULL; + } + +-void* vDSP_vsub(void) +-{ +- if (verbose) puts("STUB: vDSP_vsub called"); +- return NULL; +-} +- + void* vDSP_vsubD(void) + { + if (verbose) puts("STUB: vDSP_vsubD called"); +@@ -2529,12 +2437,6 @@ void* vDSP_zrvsubD(void) + return NULL; + } + +-void* vDSP_ztoc(void) +-{ +- if (verbose) puts("STUB: vDSP_ztoc called"); +- return NULL; +-} +- + void* vDSP_ztocD(void) + { + if (verbose) puts("STUB: vDSP_ztocD called"); +@@ -2697,12 +2599,6 @@ void* vDSP_zvmovD(void) + return NULL; + } + +-void* vDSP_zvmul(void) +-{ +- if (verbose) puts("STUB: vDSP_zvmul called"); +- return NULL; +-} +- + void* vDSP_zvmulD(void) + { + if (verbose) puts("STUB: vDSP_zvmulD called"); diff --git a/tools/install-riot-gpu-runtime.sh b/tools/install-riot-gpu-runtime.sh index 80b9461..4914e40 100755 --- a/tools/install-riot-gpu-runtime.sh +++ b/tools/install-riot-gpu-runtime.sh @@ -8,6 +8,8 @@ python3 tools/build-m58f333-iosurface.py # Rebuild the native wrapper with floating-point-safe symbol resolvers. make -C build/darling-src/build/src/native GL -j4 make -C build/darling-src/build/src/frameworks/CoreServices/src/LaunchServices LaunchServices -j4 +# WebAudio gain, mixing, filters and FFTs need real vDSP kernels, not stubs. +make -C build/darling-src/build/src/frameworks/Accelerate/vecLib/vDSP vDSP -j4 python3 tools/build-m58f327-opengl.py python3 tools/build-m58f336-quartzcore.py python3 tools/build-m58f341-corevideo.py @@ -89,6 +91,15 @@ if [ ! -f "$launchservices_prefix.before-null-error" ]; then fi install -m755 build/darling-src/build/src/frameworks/CoreServices/src/LaunchServices/LaunchServices "$launchservices_prefix.next" mv "$launchservices_prefix.next" "$launchservices_prefix" +# Accelerate re-exports libvDSP by absolute path, so replace it in the stopped prefix too. +vdsp_prefix="$prefix_dir/System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib" +mkdir -p "$(dirname "$vdsp_prefix")" +if [ ! -f "$vdsp_prefix.before-vdsp" ]; then + if [ -f "$vdsp_prefix" ]; then cp -p "$vdsp_prefix" "$vdsp_prefix.before-vdsp" + else cp -p @DARLING_SDK@/System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib "$vdsp_prefix.before-vdsp"; fi +fi +install -m755 build/darling-src/build/src/frameworks/Accelerate/vecLib/vDSP/libvDSP.dylib "$vdsp_prefix.next" +mv "$vdsp_prefix.next" "$vdsp_prefix" component="$guest_home/Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component" if [ ! -d "$component" ]; then From f5c46c65291bf75824f40cdabdba845e3346f893 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:05:05 +0200 Subject: [PATCH 005/160] Translate LLVM 23 vector splat constants in Iridium LLVM 23 represents vector splats such as `<3 x float> zeroinitializer` as vector-typed ConstantFP/ConstantInt values instead of ConstantAggregateZero/ConstantDataVector. Iridium only handled scalar ConstantFP/ConstantInt, so on hosts with LLVM 23 nearly every League shader failed to translate: the r3dlog fills with "Failed to compile shader", the loading screen hits "Material Missing Pipeline" and the game crashes. Our matches had only run the overlay pinned to LLVM 22. Declare the scalar element and splat it into a constant composite. Offline, on the 74,112 metallibs in ShaderCache.metal.wad.client, LLVM 23 went from ~2% to 74,108 translated, the same set as LLVM 22 (the 4 remaining failures are pre-existing on both); a sample of the LLVM 23 SPIR-V passes spirv-val for Vulkan 1.3. Confirmed in a match with the Iridium overlay built against the system LLVM 23. RIOT_IRIDIUM_SYSTEM_LLVM=1 makes build-riot-indium-runtime.py ignore the LLVM 22 pin, to build what a fresh checkout gets. Refs #1 Co-Authored-By: Claude Opus 5.5 --- docs/TROUBLESHOOTING.md | 1 + patches/metal--deps--indium.patch | 110 ++++++++++++++++++----------- tools/build-riot-indium-runtime.py | 4 +- 3 files changed, 73 insertions(+), 42 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index c46d3f0..e538f7b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -135,6 +135,7 @@ A separate multi-window path stopped the active async presenter when another dif - **OpenDirectory 32-bit ivar errors:** keep `TARGET_i386=OFF`; the supported client is x86-64. - **Undefined empty Objective-C collection symbols:** retain the patched macOS 10.15 Objective-C runtime code generation. - **LLVM branch opcode errors:** retain the Indium LLVM 22/23 compatibility patch; do not replace its branch handling with an unconditional version-specific opcode. +- **Every game shader fails to compile on LLVM 23** (r3dlog full of `Failed to compile shader`, then `Material Missing Pipeline` and a crash before the loading screen; `/tmp/metal-errors.log` shows `Unsupported LLVM value: <3 x float> zeroinitializer`): LLVM 23 represents vector splats as vector-typed `ConstantFP`/`ConstantInt`, which Iridium did not translate. Indium now splats them itself. Rebuild and reinstall the Metal/Indium pair ([setup](SETUP.md#metal-and-indium-overlays)). The offline check `python3 tools/build-riot-air-check.py --native` translates shader libraries without the game. - **Build process killed:** lower parallelism, for example `JOBS=2 ./scripts/build.sh` or `--parallel 1` for an incremental component build. - **Incremental helper cannot find `flags.make` or `link.txt`:** use Unix Makefiles, not Ninja. - **Prebuilt Swift file contains Git LFS pointer text:** install Git LFS and fetch the original upstream assets; pointer text is not a usable library. diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index 8f0081c..8f34335 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -4802,7 +4802,7 @@ index 1e3e971..4db87d4 100644 - DynamicVK::vkFreeCommandBuffers(_device->device(), _device->oneshotCommandPool(), 1, &cmdBuf); }; diff --git a/src/iridium/air.cpp b/src/iridium/air.cpp -index 21c501f..24423e3 100644 +index 21c501f..9f6663b 100644 --- a/src/iridium/air.cpp +++ b/src/iridium/air.cpp @@ -4,9 +4,46 @@ @@ -5008,7 +5008,7 @@ index 21c501f..24423e3 100644 } break; default: -@@ -162,14 +299,32 @@ static Iridium::SPIRV::ResultID llvmValueToResultID(Iridium::SPIRV::Builder& bui +@@ -162,14 +299,60 @@ static Iridium::SPIRV::ResultID llvmValueToResultID(Iridium::SPIRV::Builder& bui { ResultID maybe = builder.lookupResultID(reinterpret_cast(llvmValue)); if (maybe != ResultIDInvalid) { @@ -5017,6 +5017,34 @@ index 21c501f..24423e3 100644 } } ++ // LLVM 23 represents vector splats (e.g. `<3 x float> zeroinitializer`) as vector-typed ConstantInt/ConstantFP values ++ // rather than ConstantAggregateZero/ConstantDataVector. Declare the scalar and splat it into a composite. ++ if ((kind == LLVMConstantIntValueKind || kind == LLVMConstantFPValueKind) && typeKind == LLVMVectorTypeKind) { ++ auto elementType = DynamicLLVM::LLVMGetElementType(type); ++ auto elementKind = DynamicLLVM::LLVMGetTypeKind(elementType); ++ ResultID element = ResultIDInvalid; ++ if (kind == LLVMConstantIntValueKind) { ++ auto val = DynamicLLVM::LLVMConstIntGetZExtValue(llvmValue); ++ auto width = DynamicLLVM::LLVMGetIntTypeWidth(elementType); ++ element = width == 1 ? builder.declareConstantBool(val != 0) : ((width <= 32) ? builder.declareConstantScalar(val) : builder.declareConstantScalar(val)); ++ } else { ++ LLVMBool losesInfo = false; ++ auto val = DynamicLLVM::LLVMConstRealGetDouble(llvmValue, &losesInfo); ++ switch (elementKind) { ++ case LLVMFloatTypeKind: element = builder.declareConstantScalar(val); break; ++ case LLVMDoubleTypeKind: element = builder.declareConstantScalar(val); break; ++ case LLVMHalfTypeKind: { ++ Iridium::Float16 tmp = val; ++ element = builder.declareConstantScalar(&tmp); ++ } break; ++ default: ++ throw ImpossibleResultID("Unsupported LLVM value: " + describeLLVMValue(llvmValue)); ++ } ++ } ++ std::vector vals(DynamicLLVM::LLVMGetVectorSize(type), element); ++ return builder.declareConstantComposite(llvmTypeToSPIRVType(builder, type), vals); ++ } ++ switch (kind) { + case LLVMGlobalVariableValueKind: { + auto initializer = DynamicLLVM::LLVMGetInitializer(llvmValue); @@ -5041,7 +5069,7 @@ index 21c501f..24423e3 100644 // TODO: how to determine if it's signed or not? return (width <= 32) ? builder.declareConstantScalar(val) : builder.declareConstantScalar(val); } break; -@@ -191,13 +346,15 @@ static Iridium::SPIRV::ResultID llvmValueToResultID(Iridium::SPIRV::Builder& bui +@@ -191,13 +374,15 @@ static Iridium::SPIRV::ResultID llvmValueToResultID(Iridium::SPIRV::Builder& bui } break; default: @@ -5060,7 +5088,7 @@ index 21c501f..24423e3 100644 for (size_t i = 0; i < count; ++i) { auto constant = DynamicLLVM::LLVMGetOperand(llvmValue, i); -@@ -207,10 +364,11 @@ static Iridium::SPIRV::ResultID llvmValueToResultID(Iridium::SPIRV::Builder& bui +@@ -207,10 +392,11 @@ static Iridium::SPIRV::ResultID llvmValueToResultID(Iridium::SPIRV::Builder& bui return builder.declareConstantComposite(llvmTypeToSPIRVType(builder, type), vals); } break; @@ -5074,7 +5102,7 @@ index 21c501f..24423e3 100644 for (size_t i = 0; i < count; ++i) { auto constant = DynamicLLVM::LLVMGetElementAsConstant(llvmValue, i); -@@ -220,7 +378,8 @@ static Iridium::SPIRV::ResultID llvmValueToResultID(Iridium::SPIRV::Builder& bui +@@ -220,7 +406,8 @@ static Iridium::SPIRV::ResultID llvmValueToResultID(Iridium::SPIRV::Builder& bui return builder.declareConstantComposite(llvmTypeToSPIRVType(builder, type), vals); } break; @@ -5084,7 +5112,7 @@ index 21c501f..24423e3 100644 return builder.declareUndefinedValue(llvmTypeToSPIRVType(builder, type)); } break; -@@ -231,6 +390,21 @@ static Iridium::SPIRV::ResultID llvmValueToResultID(Iridium::SPIRV::Builder& bui +@@ -231,6 +418,21 @@ static Iridium::SPIRV::ResultID llvmValueToResultID(Iridium::SPIRV::Builder& bui case LLVMConstantExprValueKind: { switch (DynamicLLVM::LLVMGetConstOpcode(llvmValue)) { @@ -5106,7 +5134,7 @@ index 21c501f..24423e3 100644 case LLVMBitCast: { auto target = DynamicLLVM::LLVMGetOperand(llvmValue, 0); auto targetID = llvmValueToResultID(builder, target); -@@ -254,13 +428,315 @@ static Iridium::SPIRV::ResultID llvmValueToResultID(Iridium::SPIRV::Builder& bui +@@ -254,13 +456,315 @@ static Iridium::SPIRV::ResultID llvmValueToResultID(Iridium::SPIRV::Builder& bui } break; default: @@ -5139,7 +5167,7 @@ index 21c501f..24423e3 100644 +static bool airTypeTokenIsFloat(std::string_view token) { + if (token.empty()) { + return false; -+ } + } + if (token[0] == 'v') { + token.remove_prefix(1); + while (!token.empty() && token[0] >= '0' && token[0] <= '9') { @@ -5418,13 +5446,13 @@ index 21c501f..24423e3 100644 + return false; + } + return true; - } ++ } + + return false; }; void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& outputInfo) { -@@ -312,7 +788,7 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -312,7 +816,7 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output }); // declare the GLSL PerVertex structure @@ -5433,7 +5461,7 @@ index 21c501f..24423e3 100644 SPIRV::Type::Member { vec4Type, 0, -@@ -321,13 +797,21 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -321,13 +825,21 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output } }, // TODO: declare the rest of the structure @@ -5456,7 +5484,7 @@ index 21c501f..24423e3 100644 // declare the GLSL VertexIndex input variable vertexIndexVar = builder.addGlobalVariable(ptrIntType, SPIRV::StorageClass::Input); -@@ -425,7 +909,7 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -425,7 +937,7 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output // if the function returns a structure, we need to separate the components // into separate output variables. @@ -5465,7 +5493,7 @@ index 21c501f..24423e3 100644 auto funcRetType = DynamicLLVM::LLVMGetReturnType(llfuncType); std::vector funcParamTypes(DynamicLLVM::LLVMCountParamTypes(llfuncType)); DynamicLLVM::LLVMGetParamTypes(llfuncType, funcParamTypes.data()); -@@ -460,7 +944,7 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -460,7 +972,7 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output auto type = llvmTypeToSPIRVType(builder, DynamicLLVM::LLVMStructGetTypeAtIndex(funcRetType, i)); auto ptrType = builder.declareType(SPIRV::Type(SPIRV::Type::PointerTag {}, SPIRV::StorageClass::Output, type, 8)); auto var = builder.addGlobalVariable(ptrType, SPIRV::StorageClass::Output); @@ -5474,7 +5502,7 @@ index 21c501f..24423e3 100644 ++location; _outputValueIDs.push_back(var); builder.referenceGlobalVariable(var); -@@ -472,13 +956,60 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -472,13 +984,60 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output auto type = llvmTypeToSPIRVType(builder, funcRetType); auto ptrType = builder.declareType(SPIRV::Type(SPIRV::Type::PointerTag {}, SPIRV::StorageClass::Output, type, 8)); auto var = builder.addGlobalVariable(ptrType, SPIRV::StorageClass::Output); @@ -5536,7 +5564,7 @@ index 21c501f..24423e3 100644 // // analyze parameters and mark special values (like the vertex ID) -@@ -519,7 +1050,9 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -519,7 +1078,9 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output auto kind = llvmMDStringToStringView(parameterInfo[1]); if (kind == "air.buffer") { @@ -5547,7 +5575,7 @@ index 21c501f..24423e3 100644 auto addrPtrTypeInst = SPIRV::Type(SPIRV::Type::PointerTag {}, SPIRV::StorageClass::PhysicalStorageBuffer, type, 8); auto addrPtrType = builder.declareType(addrPtrTypeInst); bufferMembers.push_back(SPIRV::Type::Member { addrPtrType, 8 * bufferIndex, {} }); -@@ -575,7 +1108,21 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -575,7 +1136,21 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output _parameterIDs.push_back(load); builder.associateExistingResultID(load, llparamVal); builder.setResultType(load, intType); @@ -5570,7 +5598,7 @@ index 21c501f..24423e3 100644 // find the location index info size_t infoIdx = 0; for (; infoIdx < parameterInfo.size(); ++infoIdx) { -@@ -620,7 +1167,7 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -620,7 +1195,7 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output auto var = builder.addGlobalVariable(ptrType, SPIRV::StorageClass::Input); auto load = builder.encodeLoad(type, var); @@ -5579,7 +5607,7 @@ index 21c501f..24423e3 100644 ++paramLocation; builder.referenceGlobalVariable(var); -@@ -631,9 +1178,18 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -631,9 +1206,18 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output builder.setResultType(load, type); } else if (kind == "air.vertex_input") { auto type = llvmTypeToSPIRVType(builder, funcParamTypes[i]); @@ -5600,7 +5628,7 @@ index 21c501f..24423e3 100644 // find the location index info size_t infoIdx = 0; -@@ -761,14 +1317,19 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -761,14 +1345,19 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output realSampleType = fakeSampleType; } @@ -5628,7 +5656,7 @@ index 21c501f..24423e3 100644 auto imagePtrType = builder.declareType(SPIRV::Type(SPIRV::Type::PointerTag {}, SPIRV::StorageClass::UniformConstant, imageType, 8)); auto var = builder.addGlobalVariable(imagePtrType, SPIRV::StorageClass::UniformConstant); //auto load = builder.encodeLoad(imageType, var); -@@ -959,68 +1520,98 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -959,68 +1548,98 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output enum class CFGType { None = 0, Selection, @@ -5776,7 +5804,7 @@ index 21c501f..24423e3 100644 if (isFirst) { isFirst = false; -@@ -1030,16 +1621,54 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -1030,16 +1649,54 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output builder.insertLabel(builder.lookupResultID(reinterpret_cast(bb))); } @@ -5832,7 +5860,7 @@ index 21c501f..24423e3 100644 builder.associateExistingResultID(resID, reinterpret_cast(inst)); builder.setResultType(resID, type); -@@ -1048,6 +1677,18 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -1048,6 +1705,18 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output // TODO: look into using physical addressing instead of logical addressing, which should simplify the translation process // (since i *think* that's the same addressing mode used by Metal code) @@ -5851,7 +5879,7 @@ index 21c501f..24423e3 100644 case LLVMGetElementPtr: { auto base = DynamicLLVM::LLVMGetOperand(inst, 0); auto targetType = DynamicLLVM::LLVMTypeOf(inst); -@@ -1060,17 +1701,32 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -1060,17 +1729,32 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output indices.push_back(llvmValueToResultID(builder, llindex)); } @@ -5885,7 +5913,7 @@ index 21c501f..24423e3 100644 // strangely enough, SPIR-V provides no instruction that can do an initial pointer offset like LLVM's GEP does. // there's OpPtrAccessChain, which is tantalizingly named, but unfortunately that instruction doesn't work as expected // either. i've tried using the raw index (i.e. in element units) and the multiplied index (i.e. in bytes), but no dice. -@@ -1112,6 +1768,9 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -1112,6 +1796,9 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output op = access; } @@ -5895,7 +5923,7 @@ index 21c501f..24423e3 100644 auto resID = builder.encodeLoad(type, op, alignment); builder.associateExistingResultID(resID, reinterpret_cast(inst)); -@@ -1149,50 +1808,8 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -1149,50 +1836,8 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output auto arg = DynamicLLVM::LLVMGetOperand(inst, 0); resID = builder.encodeConvertUToF(type, llvmValueToResultID(builder, arg)); @@ -5948,7 +5976,7 @@ index 21c501f..24423e3 100644 auto arg = DynamicLLVM::LLVMGetOperand(inst, 0); resID = builder.encodeFConvert(type, llvmValueToResultID(builder, arg)); -@@ -1223,14 +1840,33 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -1223,14 +1868,33 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output auto operand2 = DynamicLLVM::LLVMGetOperand(inst, 1); resID = builder.encodeFMax(type, llvmValueToResultID(builder, operand1), llvmValueToResultID(builder, operand2)); @@ -5985,7 +6013,7 @@ index 21c501f..24423e3 100644 case LLVMFMul: case LLVMFDiv: case LLVMFAdd: -@@ -1365,28 +2001,81 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -1365,28 +2029,81 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output auto op1 = DynamicLLVM::LLVMGetOperand(inst, 0); auto op2 = DynamicLLVM::LLVMGetOperand(inst, 1); auto predicate = DynamicLLVM::LLVMGetFCmpPredicate(inst); @@ -6016,16 +6044,16 @@ index 21c501f..24423e3 100644 + default: + throw std::runtime_error("TODO: handle fcmp predicate: " + std::to_string(predicate)); + } ++ ++ auto resID = builder.encodeRawOp(spirvOpcode, type, { llvmValueToResultID(builder, op1), llvmValueToResultID(builder, op2) }); - case LLVMRealOLT: { - resID = builder.encodeFOrdLessThan(llvmValueToResultID(builder, op1), llvmValueToResultID(builder, op2)); - } break; -+ auto resID = builder.encodeRawOp(spirvOpcode, type, { llvmValueToResultID(builder, op1), llvmValueToResultID(builder, op2) }); - + builder.associateExistingResultID(resID, reinterpret_cast(inst)); + builder.setResultType(resID, type); + } break; -+ + + case LLVMICmp: { + auto op1 = DynamicLLVM::LLVMGetOperand(inst, 0); + auto op2 = DynamicLLVM::LLVMGetOperand(inst, 1); @@ -6051,11 +6079,10 @@ index 21c501f..24423e3 100644 + auto resID = builder.encodeRawOp(spirvOpcode, type, { llvmValueToResultID(builder, op1), llvmValueToResultID(builder, op2) }); + - builder.associateExistingResultID(resID, reinterpret_cast(inst)); - builder.setResultType(resID, type); - } break; - -- case LLVMBr: { ++ builder.associateExistingResultID(resID, reinterpret_cast(inst)); ++ builder.setResultType(resID, type); ++ } break; ++ + case LLVMSelect: { + auto type = llvmTypeToSPIRVType(builder, DynamicLLVM::LLVMTypeOf(inst)); + auto resID = builder.encodeRawOp(SPIRVOp::Select, type, { @@ -6064,10 +6091,11 @@ index 21c501f..24423e3 100644 + llvmValueToResultID(builder, DynamicLLVM::LLVMGetOperand(inst, 2)), + }); + -+ builder.associateExistingResultID(resID, reinterpret_cast(inst)); -+ builder.setResultType(resID, type); -+ } break; -+ + builder.associateExistingResultID(resID, reinterpret_cast(inst)); + builder.setResultType(resID, type); + } break; + +- case LLVMBr: { + IRIDIUM_BR_CASES: { + auto loop=cfgInfos.find(builder.lookupResultID(reinterpret_cast(bb))); + if(loop!=cfgInfos.end() && loop->second.type==CFGType::Loop){ @@ -6078,7 +6106,7 @@ index 21c501f..24423e3 100644 if (DynamicLLVM::LLVMIsConditional(inst)) { auto condition = DynamicLLVM::LLVMGetCondition(inst); auto trueLabel = DynamicLLVM::LLVMBasicBlockAsValue(DynamicLLVM::LLVMGetSuccessor(inst, 0)); -@@ -1422,25 +2111,38 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -1422,25 +2139,38 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output auto type = llvmTypeToSPIRVType(builder, lltype); std::vector> variablesAndBlocks; @@ -6120,7 +6148,7 @@ index 21c501f..24423e3 100644 // ensure the resulting pointer storage class is the same as the input pointer storage class auto typeInst = *builder.reverseLookupType(type); -@@ -1474,6 +2176,7 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output +@@ -1474,6 +2204,7 @@ void Iridium::AIR::Function::analyze(SPIRV::Builder& builder, OutputInfo& output } } diff --git a/tools/build-riot-indium-runtime.py b/tools/build-riot-indium-runtime.py index a170332..a115c2a 100644 --- a/tools/build-riot-indium-runtime.py +++ b/tools/build-riot-indium-runtime.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Build the paired Iridium shader translator and Indium runtime overrides.""" from pathlib import Path +import os import shlex import subprocess import sys @@ -17,7 +18,8 @@ for line in (folder / 'CMakeFiles/iridium.dir/flags.make').read_text().splitline key, value = line.split(' = ', 1) iridium_flags[key] = shlex.split(value) iridium_flags['CXX_INCLUDES'] = current_includes(iridium_flags['CXX_INCLUDES']) -if (pinned_llvm / 'lib/libLLVM-22.so').exists(): +# RIOT_IRIDIUM_SYSTEM_LLVM=1 ignores the pin and builds against the host LLVM, as a fresh checkout does. +if (pinned_llvm / 'lib/libLLVM-22.so').exists() and os.environ.get('RIOT_IRIDIUM_SYSTEM_LLVM') != '1': iridium_flags['CXX_INCLUDES'].insert(0, '-I' + str(pinned_llvm / 'include')) iridium_flags['CXX_DEFINES'] = [value for value in iridium_flags['CXX_DEFINES'] if not value.startswith('-DHOST_LLVM_LIBNAME=')] iridium_flags['CXX_DEFINES'].append('-DHOST_LLVM_LIBNAME="' + str(pinned_llvm / 'lib/libLLVM-22.so') + '"') From 3b90c550a77faecfe2a884bbe14027c47401ab59 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:11:26 +0200 Subject: [PATCH 006/160] Add an update script that re-applies changed patches After a pull that changed a patch, prepare.py stopped: the patch neither applied nor reverse-applied to a tree holding its earlier version. Now it looks for the earlier version applied in that component -- the copy it records in build/applied-patches, or a version from this repository's history -- reverts exactly it and applies the new one. It never resets a repository; when nothing matches it stops and names the repository with other edits, and it restores the earlier version if the new one does not apply. scripts/update.sh runs build.sh, the base install and install-runtime.sh, and refuses to start while a Riot Client or League process is open, since the install steps stop Darling. Checked on a sandbox Indium checkout holding the previous patch: the result matches a clean apply, a second run is a no-op, an unrelated local edit survives and a conflicting one stops with the error. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +++++++++++- docs/SETUP.md | 2 ++ scripts/prepare.py | 30 ++++++++++++++++++++++++++++-- scripts/update.sh | 29 +++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100755 scripts/update.sh diff --git a/README.md b/README.md index e4a0b48..8e8475d 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,17 @@ FPS counters, game presentation requests and physical display refresh are differ On the Ryzen 5 5500 / RX 6600 test PC, ARAM on Hexgate went from 84 to 135 FPS average over 2026-10-13/14, with the slowest 10-second stretches around 119 in the best match (one match so far with Hyprland's direct scanout off; [status](docs/STATUS.md#game-fps-2026-10-1314)). The game-process speed switches and their opt-out files are listed in [setup](docs/SETUP.md#game-process-switches), and passive match capture is described in [testing](docs/TESTING.md#passive-match-capture). -For existing installations, [incremental update instructions](docs/SETUP.md#updating-an-existing-runtime) explain the server and memory installers, user-owned backups and the full Darling restart needed to activate replacements. Perform activation only after manually exiting the game and both clients. +To update an existing installation, exit the game and both clients, then: + +```sh +git pull +./scripts/update.sh +sh scripts/run.sh +``` + +`update.sh` re-applies the patches that changed (reverting the earlier version of each, so other edits in the source tree survive), rebuilds incrementally, and reinstalls the base runtime and the per-user overrides. It refuses to run while a Riot or League process is open. + +For updating single components, [incremental update instructions](docs/SETUP.md#updating-an-existing-runtime) explain the server and memory installers, user-owned backups and the full Darling restart needed to activate replacements. Perform activation only after manually exiting the game and both clients. ## Layout diff --git a/docs/SETUP.md b/docs/SETUP.md index 9eb3fc0..660a29c 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -28,6 +28,8 @@ The normal launcher starts neither an Electron inspector nor client API/UI helpe ## Updating an existing runtime +`./scripts/update.sh` updates everything after `git pull`: `build.sh`, then the base install, then `install-runtime.sh`. When a pull changes a patch, `prepare.py` finds the earlier version applied in that component (its copy in `build/applied-patches`, or a version from this repository's history), reverts it, and applies the new one. It never resets a repository; if neither version matches, it stops and names the repository with other edits. The steps below update single components instead. + These commands assume the pinned, patched source tree and its configured Makefiles already exist. From the repository root, build the two components with one worker to reduce resource contention: ```sh diff --git a/scripts/prepare.py b/scripts/prepare.py index 5b0e761..0e8bc72 100755 --- a/scripts/prepare.py +++ b/scripts/prepare.py @@ -8,6 +8,21 @@ p.add_argument('--source',type=Path,default=ROOT/'build/darling-src') p.add_argument('--check-only',action='store_true',help='validate against temporary base indexes, without changing existing source') a=p.parse_args();source=a.source.resolve();manifest=json.loads((ROOT/'upstream/manifest.json').read_text()) def run(args,**kwargs):return subprocess.run(args,check=True,**kwargs) +def applies(repo,patch,reverse=False): + return subprocess.run(['git','-C',str(repo),'apply',*(['--reverse'] if reverse else []),'--check','-'],input=patch,text=True, + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==0 +# The rendered text of each patch as last applied, so a later version can revert exactly it. +applied=ROOT/'build/applied-patches' +def record_patch(component,patch): + applied.mkdir(parents=True,exist_ok=True);(applied/Path(component['patch']).name).write_text(patch) +def previous_patches(component): + """Earlier applied versions of a component's patch: the recorded copy, then each version in this repository's history.""" + recorded=applied/Path(component['patch']).name + if recorded.exists():yield recorded.read_text() + history=subprocess.run(['git','-C',str(ROOT),'log','--format=%H','--',component['patch']],text=True,capture_output=True).stdout.split() + for commit in history: + shown=subprocess.run(['git','-C',str(ROOT),'show',f"{commit}:{component['patch']}"],text=True,capture_output=True) + if shown.returncode==0:yield render(shown.stdout) if not (source/'.git').exists(): if a.check_only:p.error('check-only requires an existing pinned source checkout') source.parent.mkdir(parents=True,exist_ok=True) @@ -35,9 +50,20 @@ for component in manifest['components']: run(['git','-C',str(repo),'read-tree',component['revision']],env=env) run(['git','-C',str(repo),'apply','--cached','--check','--whitespace=nowarn','-'],input=patch,text=True,env=env) else: - already=subprocess.run(['git','-C',str(repo),'apply','--reverse','--check','-'],input=patch,text=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==0 + already=applies(repo,patch,reverse=True) + if not already and not applies(repo,patch): + # An older version of this patch is applied (the checkout predates a pull). Revert exactly that + # version, never a reset, so other edits in the repository survive. + old=next((text for text in previous_patches(component) if applies(repo,text,reverse=True)),None) + if old is None:raise SystemExit(f"{component['path']}: {component['patch']} neither applies nor matches an earlier applied version; " + f"the checkout has other edits. Commit or discard them in {repo}, then run this again.") + print(f"Replacing the earlier {component['patch']} in {component['path']}",flush=True) + run(['git','-C',str(repo),'apply','--reverse','--whitespace=nowarn','-'],input=old,text=True) + if not applies(repo,patch): + run(['git','-C',str(repo),'apply','--whitespace=nowarn','-'],input=old,text=True) + raise SystemExit(f"{component['path']}: the new {component['patch']} does not apply to the pinned revision; the earlier version was restored") if not already: - run(['git','-C',str(repo),'apply','--check','-'],input=patch,text=True) run(['git','-C',str(repo),'apply','--whitespace=nowarn','-'],input=patch,text=True) + record_patch(component,patch) print(('Checked ' if a.check_only else 'Prepared ')+component['path'],flush=True) print('All',len(manifest['components']),'component patches validated' if a.check_only else 'component patches ready') diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..5658d37 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# Bring an existing build and installation up to date after `git pull`: re-apply the patches that changed +# (prepare.py reverts the earlier version it finds applied), rebuild incrementally, and reinstall the base +# runtime and the per-user overrides. Run it with no Riot/League session open; it never stops one. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$repo" +if [ ! -d build/darling-src/build ]; then + echo 'No existing build; follow the first-time setup in README.md instead.' >&2 + exit 1 +fi +# The install steps shut Darling down and stop leftover Riot processes, so refuse rather than end a session. +python3 - <<'PY' +from pathlib import Path +for entry in Path('/proc').iterdir(): + if not entry.name.isdigit(): + continue + try: + command_line = (entry / 'cmdline').read_bytes() + except OSError: + continue + if b'/Riot Client.app/' in command_line or b'/League of Legends.app/' in command_line: + raise SystemExit('A Riot Client or League process is running; exit the game and both clients, then run this again.') +PY +./scripts/build.sh +python3 build/runtime/tools/stop-darling-runtime.py +sudo cmake --install build/darling-src/build +./scripts/install-runtime.sh +echo 'Update installed. Start Darling with: sh scripts/run.sh' From 9383dd2963bb81fa05c9dece44020778b4b57fa2 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:26:28 +0200 Subject: [PATCH 007/160] Handle process exit race during Darling shutdown --- tests/test_stop_darling_runtime.py | 187 +++++++++++++++++++++++++++++ tools/stop-darling-runtime.py | 3 +- 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 tests/test_stop_darling_runtime.py diff --git a/tests/test_stop_darling_runtime.py b/tests/test_stop_darling_runtime.py new file mode 100644 index 0000000..78129b4 --- /dev/null +++ b/tests/test_stop_darling_runtime.py @@ -0,0 +1,187 @@ +"""Exercise the shutdown script without stopping the host's Darling session.""" +import errno +import os +from pathlib import Path +import runpy +import signal +import subprocess +import sys +import tempfile +import types +import unittest +from unittest.mock import patch + + +SCRIPT = Path(__file__).resolve().parents[1] / 'tools/stop-darling-runtime.py' + + +class StopRuntimeTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + self.prefix = self.root / 'prefix' + self.prefix.mkdir() + self.calls = [] + self.signals = [] + self.clock = 0 + self.status_error = None + self.shutdown = lambda: None + self.on_signal = lambda pid, sig: self.status(pid, 'Z') + + def process(self, pid, *, state='S', parent=1, riot=False, children=()): + folder = self.proc / str(pid) + folder.mkdir() + self.status(pid, state) + (folder / 'stat').write_text(f'{pid} (test process) ' + ' '.join( + [state, str(parent)] + ['0'] * 17 + ['12345'])) + (folder / 'comm').write_text('darlingserver' if pid == 100 else 'helper') + (folder / 'maps').write_text( + str(self.prefix / 'Applications/Riot Client.app/helper') if riot else '') + task = folder / 'task' / str(pid) + task.mkdir(parents=True) + (task / 'children').write_text(' '.join(map(str, children))) + + def status(self, pid, state): + (self.proc / str(pid) / 'status').write_text(f'State:\t{state} (test)\n') + + def server(self, **kwargs): + (self.prefix / '.init.pid').write_text('100') + self.process(100, **kwargs) + + def run_script(self): + proc = self.proc + original_read = Path.read_text + owner = self + + class RuntimePath(type(Path())): + def __new__(cls, *parts, **kwargs): + if parts and str(parts[0]) == '/proc': + parts = (proc, *parts[1:]) + return super().__new__(cls, *parts, **kwargs) + + def __init__(self, *parts, **kwargs): + if parts and str(parts[0]) == '/proc': + parts = (proc, *parts[1:]) + super().__init__(*parts, **kwargs) + + def read_text(self, *args, **kwargs): + if self.name == 'status' and owner.status_error is not None: + raise owner.status_error + return original_read(self, *args, **kwargs) + + def run(command, **kwargs): + self.calls.append((command, kwargs)) + if command == ['darling', 'shutdown']: + self.shutdown() + return subprocess.CompletedProcess(command, 0) + + def kill(pid, sig): + self.signals.append((pid, sig)) + self.on_signal(pid, sig) + + def monotonic(): + self.clock += 1 + return self.clock + + with patch.dict(sys.modules, {'pathlib': types.SimpleNamespace(Path=RuntimePath)}), \ + patch.dict(os.environ, {'DPREFIX': str(self.prefix)}), \ + patch('subprocess.run', side_effect=run), \ + patch('os.kill', side_effect=kill), \ + patch('time.monotonic', side_effect=monotonic), \ + patch('time.sleep'): + runpy.run_path(str(SCRIPT), run_name='__main__') + + def assert_finished(self): + self.assertEqual(self.calls[0][0], ['darling', 'shutdown']) + self.assertEqual(self.calls[-1], ( + [sys.executable, str(SCRIPT.with_name('ensure-darling-runtime.py'))], + {'check': True})) + + def test_process_disappears_during_status_read(self): + self.server() + self.status_error = ProcessLookupError(errno.ESRCH, 'No such process') + self.run_script() + self.assert_finished() + + def test_missing_process(self): + (self.prefix / '.init.pid').write_text('100') + self.run_script() + self.assert_finished() + + def test_no_marker(self): + self.run_script() + self.assert_finished() + + def test_invalid_marker(self): + (self.prefix / '.init.pid').write_text('invalid') + self.run_script() + self.assert_finished() + + def test_zombie_server(self): + self.server(state='Z') + self.run_script() + self.assert_finished() + + def test_server_exits_after_shutdown(self): + self.server() + self.shutdown = lambda: self.status(100, 'Z') + self.run_script() + self.assert_finished() + + def test_live_server_blocks_runtime_changes(self): + self.server() + with self.assertRaisesRegex(SystemExit, 'runtime remains untouched'): + self.run_script() + self.assertEqual(len(self.calls), 1) + self.assertEqual(self.signals, []) + + def test_permission_error_is_not_treated_as_exit(self): + self.server() + self.status_error = PermissionError(errno.EACCES, 'Permission denied') + with self.assertRaises(PermissionError): + self.run_script() + self.assertEqual(len(self.calls), 1) + + def test_descendant_gets_sigterm(self): + self.server(children=(101,)) + self.process(101, parent=100, riot=True) + self.shutdown = lambda: self.status(100, 'Z') + self.run_script() + self.assertEqual(self.signals, [(101, signal.SIGTERM)]) + self.assert_finished() + + def test_stubborn_orphan_gets_sigkill(self): + self.process(101, riot=True) + self.on_signal = lambda pid, sig: ( + self.status(pid, 'Z') if sig == signal.SIGKILL else None) + self.run_script() + self.assertEqual(self.signals, [(101, signal.SIGTERM), (101, signal.SIGKILL)]) + self.assert_finished() + + def test_surviving_helper_blocks_runtime_changes(self): + self.process(101, riot=True) + self.on_signal = lambda pid, sig: None + with self.assertRaisesRegex(SystemExit, 'Old Riot helpers remain alive'): + self.run_script() + self.assertEqual(len(self.calls), 1) + + def test_reused_pid_is_not_signalled(self): + self.server(children=(101,)) + self.process(101, parent=100, riot=True) + + def shutdown(): + self.status(100, 'Z') + stat = self.proc / '101/stat' + stat.write_text(stat.read_text().replace('12345', '54321')) + + self.shutdown = shutdown + self.run_script() + self.assertEqual(self.signals, []) + self.assert_finished() + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/stop-darling-runtime.py b/tools/stop-darling-runtime.py index 57117bc..c266ad3 100755 --- a/tools/stop-darling-runtime.py +++ b/tools/stop-darling-runtime.py @@ -18,7 +18,8 @@ def live(pid): try: state = next(line.split()[1] for line in (Path('/proc') / str(pid) / 'status').read_text().splitlines() if line.startswith('State:')) return state != 'Z' - except (FileNotFoundError, StopIteration): + except (FileNotFoundError, ProcessLookupError, StopIteration): + # A process can exit after status is opened but before it is read. return False def process_identity(pid): From f53d6053653ff1f12ade883a6b988db881485a00 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:22:15 +0200 Subject: [PATCH 008/160] Keep Iridium LLVM headers and runtime library on the same major --- docs/TESTING.md | 3 ++ docs/TROUBLESHOOTING.md | 1 + patches/metal--deps--indium.patch | 50 +++++++++++++++++++ tools/build-riot-air-check.py | 9 +++- tools/build-riot-indium-runtime.py | 17 +++++-- tools/riot-llvm-version-offline-check.py | 61 ++++++++++++++++++++++++ 6 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 tools/riot-llvm-version-offline-check.py diff --git a/docs/TESTING.md b/docs/TESTING.md index 7a78652..13482df 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -11,8 +11,11 @@ python3 scripts/prepare.py --check-only python3 tools/riot-launcher-offline-check.py python3 tools/riot-memory-installer-offline-check.py python3 tools/riot-foundation-installer-offline-check.py +python3 tools/riot-llvm-version-offline-check.py ``` +The LLVM version check compiles Iridium's actual dynamic loader against small stub libraries. It verifies that a matching major initializes, older/newer majors produce a version mismatch, and a missing library fails initialization. It does not start Darling. The native AIR translator (`python3 tools/build-riot-air-check.py --native`) selects both headers and the shared library from the same `llvm-config`; its build prints those paths so shader results can be attributed to the tested LLVM installation. + The patch check uses temporary base indexes and does not reset or modify the source checkout. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, and preserves Darling runtime library/framework paths. The memory-installer check imports the installer and exercises its policy (default source is the prepared artifact; the artifact is never a destination of a different library) in temporary directories. The Foundation-installer check does the same for the overlay installer: it seeds a temporary framework from a stub SDK, replaces `Versions/C/Foundation`, keeps the standard symlinks, stages a backup, treats a re-run as current and refuses a non-Mach-O source. The installer handoff mock covers 28 scenarios, including HTTP 409, interrupted verification and preserving active downloads. ## CoreText font-list and AppKit resize regressions diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index e538f7b..f54b731 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -135,6 +135,7 @@ A separate multi-window path stopped the active async presenter when another dif - **OpenDirectory 32-bit ivar errors:** keep `TARGET_i386=OFF`; the supported client is x86-64. - **Undefined empty Objective-C collection symbols:** retain the patched macOS 10.15 Objective-C runtime code generation. - **LLVM branch opcode errors:** retain the Indium LLVM 22/23 compatibility patch; do not replace its branch handling with an unconditional version-specific opcode. +- **Scalar float errors or opcode 71 after updating:** `Unsupported LLVM value: float 0.000000e+00` (also `1.000000e+00`) and `Unhandled instruction opcode: 71` can result from compiling Iridium with LLVM 22 headers and loading LLVM 23. The LLVM C API changed enum values between those majors. The build now selects headers and the shared library together; initialization rejects a mismatched major with `Iridium LLVM version mismatch`. Close the game and both clients, then run `git pull` and `./scripts/update.sh` to rebuild and reinstall both the base runtime and per-user overrides. If the same errors persist, include the update's `Iridium LLVM headers:` line and a newly generated `metal-errors.log` with the report. - **Every game shader fails to compile on LLVM 23** (r3dlog full of `Failed to compile shader`, then `Material Missing Pipeline` and a crash before the loading screen; `/tmp/metal-errors.log` shows `Unsupported LLVM value: <3 x float> zeroinitializer`): LLVM 23 represents vector splats as vector-typed `ConstantFP`/`ConstantInt`, which Iridium did not translate. Indium now splats them itself. Rebuild and reinstall the Metal/Indium pair ([setup](SETUP.md#metal-and-indium-overlays)). The offline check `python3 tools/build-riot-air-check.py --native` translates shader libraries without the game. - **Build process killed:** lower parallelism, for example `JOBS=2 ./scripts/build.sh` or `--parallel 1` for an incremental component build. - **Incremental helper cannot find `flags.make` or `link.txt`:** use Unix Makefiles, not Ninja. diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index 8f34335..faa1a83 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -8228,3 +8228,53 @@ new file mode 100644 + } + } +} +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 8d95863..603dc3d 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -159,9 +159,10 @@ if (NOT DARLING) + endif() + + execute_process( +- COMMAND llvm-config --link-shared --libnames ++ COMMAND "${LLVM_TOOLS_BINARY_DIR}/llvm-config" --link-shared --libnames + OUTPUT_VARIABLE HOST_LLVM_LIBNAME + OUTPUT_STRIP_TRAILING_WHITESPACE ++ COMMAND_ERROR_IS_FATAL ANY + ) + + target_compile_definitions(iridium PRIVATE +diff --git a/src/iridium/dynamic-llvm.cpp b/src/iridium/dynamic-llvm.cpp +index 1b5876e..03af560 100644 +--- a/src/iridium/dynamic-llvm.cpp ++++ b/src/iridium/dynamic-llvm.cpp +@@ -1,4 +1,7 @@ + #include ++#include ++#include ++#include + + #ifdef DARLING + #include +@@ -19,7 +22,20 @@ bool Iridium::DynamicLLVM::init() { + libraryHandle = dlopen(HOST_LLVM_LIBNAME, RTLD_LAZY); + #endif + +- return !!libraryHandle; ++ if (!libraryHandle) return false; ++ // LLVM's C API enum values changed in LLVM 23. A different runtime major ++ // misclassifies constants and branch instructions even when symbols resolve. ++ using GetVersion = void (*)(unsigned*, unsigned*, unsigned*); ++ auto getVersion = reinterpret_cast(resolveSymbol("LLVMGetVersion")); ++ unsigned major = 0, minor = 0, patch = 0; ++ if (getVersion) getVersion(&major, &minor, &patch); ++ if (major != LLVM_VERSION_MAJOR) { ++ finit(); ++ throw std::runtime_error("Iridium LLVM version mismatch: built with LLVM " + ++ std::to_string(LLVM_VERSION_MAJOR) + ", loaded LLVM " + std::to_string(major) + ++ " from " HOST_LLVM_LIBNAME "; rebuild and reinstall the runtime with matching LLVM headers and library"); ++ } ++ return true; + }; + + void Iridium::DynamicLLVM::finit() { diff --git a/tools/build-riot-air-check.py b/tools/build-riot-air-check.py index 0ca61fd..cd870f0 100644 --- a/tools/build-riot-air-check.py +++ b/tools/build-riot-air-check.py @@ -7,8 +7,15 @@ import sys root = Path(__file__).resolve().parent.parent if '--native' in sys.argv: indium = root / 'build/darling-src/src/external/metal/deps/indium' + llvm_include = subprocess.check_output(['llvm-config', '--includedir'], text=True).strip() + llvm_libdir = Path(subprocess.check_output(['llvm-config', '--libdir'], text=True).strip()) + llvm_names = shlex.split(subprocess.check_output(['llvm-config', '--link-shared', '--libnames'], text=True)) + if len(llvm_names) != 1: + raise SystemExit('Iridium requires one shared LLVM library, got: ' + repr(llvm_names)) + llvm_library = llvm_libdir / llvm_names[0] + print(f'Iridium LLVM headers: {llvm_include}; library: {llvm_library}', flush=True) subprocess.run(['clang++', '-O2', '-std=c++17', - '-DHOST_LLVM_LIBNAME="libLLVM-22.so"', + '-DHOST_LLVM_LIBNAME="' + str(llvm_library) + '"', '-I' + llvm_include, '-I' + str(indium / 'include'), '-I' + str(indium / 'private-include'), str(root / 'tools/riot-air-translate-check.cpp'), *[str(indium / 'src/iridium' / (name + '.cpp')) diff --git a/tools/build-riot-indium-runtime.py b/tools/build-riot-indium-runtime.py index a115c2a..a630cc5 100644 --- a/tools/build-riot-indium-runtime.py +++ b/tools/build-riot-indium-runtime.py @@ -20,9 +20,20 @@ for line in (folder / 'CMakeFiles/iridium.dir/flags.make').read_text().splitline iridium_flags['CXX_INCLUDES'] = current_includes(iridium_flags['CXX_INCLUDES']) # RIOT_IRIDIUM_SYSTEM_LLVM=1 ignores the pin and builds against the host LLVM, as a fresh checkout does. if (pinned_llvm / 'lib/libLLVM-22.so').exists() and os.environ.get('RIOT_IRIDIUM_SYSTEM_LLVM') != '1': - iridium_flags['CXX_INCLUDES'].insert(0, '-I' + str(pinned_llvm / 'include')) - iridium_flags['CXX_DEFINES'] = [value for value in iridium_flags['CXX_DEFINES'] if not value.startswith('-DHOST_LLVM_LIBNAME=')] - iridium_flags['CXX_DEFINES'].append('-DHOST_LLVM_LIBNAME="' + str(pinned_llvm / 'lib/libLLVM-22.so') + '"') + llvm_include = pinned_llvm / 'include' + llvm_library = pinned_llvm / 'lib/libLLVM-22.so' +else: + # Do not combine cached CMake include paths with a newer llvm-config library. + llvm_include = Path(subprocess.check_output(['llvm-config', '--includedir'], text=True).strip()) + llvm_libdir = Path(subprocess.check_output(['llvm-config', '--libdir'], text=True).strip()) + llvm_names = shlex.split(subprocess.check_output(['llvm-config', '--link-shared', '--libnames'], text=True)) + if len(llvm_names) != 1: + raise SystemExit('Iridium requires one shared LLVM library, got: ' + repr(llvm_names)) + llvm_library = llvm_libdir / llvm_names[0] +iridium_flags['CXX_INCLUDES'].insert(0, '-I' + str(llvm_include)) +iridium_flags['CXX_DEFINES'] = [value for value in iridium_flags['CXX_DEFINES'] if not value.startswith('-DHOST_LLVM_LIBNAME=')] +iridium_flags['CXX_DEFINES'].append('-DHOST_LLVM_LIBNAME="' + str(llvm_library) + '"') +print(f'Iridium LLVM headers: {llvm_include}; library: {llvm_library}', flush=True) iridium_link = shlex.split((folder / 'CMakeFiles/iridium.dir/link.txt').read_text()) for name in ('air', 'dynamic-llvm', 'iridium', 'spirv'): obj = root / f'build/riot-iridium-{name}.o' diff --git a/tools/riot-llvm-version-offline-check.py b/tools/riot-llvm-version-offline-check.py new file mode 100644 index 0000000..036670b --- /dev/null +++ b/tools/riot-llvm-version-offline-check.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Check Iridium's LLVM major-version guard without Darling or a game process.""" +from pathlib import Path +import re +import subprocess +import tempfile + +root = Path(__file__).resolve().parent.parent +indium = root / 'build/darling-src/src/external/metal/deps/indium' +include = Path(subprocess.check_output(['llvm-config', '--includedir'], text=True).strip()) +header = (include / 'llvm/Config/llvm-config.h').read_text() +major = int(re.search(r'^#define LLVM_VERSION_MAJOR (\d+)', header, re.MULTILINE)[1]) +with tempfile.TemporaryDirectory(prefix='iridium-llvm-version-') as directory: + folder = Path(directory) + library_source = folder / 'llvm.c' + library_source.write_text(''' +void LLVMGetVersion(unsigned* major, unsigned* minor, unsigned* patch) { + *major = TEST_LLVM_MAJOR; *minor = 0; *patch = 0; +} +''') + probe = folder / 'probe.cpp' + probe.write_text(''' +#include +#include +#include +int main() { + try { + if (!Iridium::DynamicLLVM::init()) return 2; + Iridium::DynamicLLVM::finit(); + puts("LLVM version matched"); + return 0; + } catch (const std::exception& error) { + fprintf(stderr, "%s\\n", error.what()); + return 1; + } +} +''') + # Keep the loaded path fixed so the same compiled probe tests both majors. + library = folder / 'libLLVM-test.so' + executable = folder / 'probe' + subprocess.run([ + 'clang++', '-std=c++17', '-Wno-deprecated-declarations', + '-DHOST_LLVM_LIBNAME="' + str(library) + '"', '-I' + str(include), + '-I' + str(indium / 'include'), '-I' + str(indium / 'private-include'), + str(probe), str(indium / 'src/iridium/dynamic-llvm.cpp'), + '-ldl', '-o', str(executable), + ], check=True) + for loaded in (major, major - 1, major + 1): + subprocess.run(['cc', '-shared', '-fPIC', f'-DTEST_LLVM_MAJOR={loaded}', + str(library_source), '-o', str(library)], check=True) + result = subprocess.run([str(executable)], text=True, capture_output=True) + if loaded == major: + assert result.returncode == 0, result.stderr + else: + assert result.returncode == 1, result.stderr + assert f'built with LLVM {major}, loaded LLVM {loaded}' in result.stderr, result.stderr + print(f'PASS: headers {major}, runtime {loaded}', flush=True) + library.unlink() + result = subprocess.run([str(executable)], capture_output=True) + assert result.returncode == 2 + print('PASS: missing library fails initialization') From f98afb2c4445e5a60aef5a73d5a63ca785298f5a Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:26:14 +0200 Subject: [PATCH 009/160] Bound installer shutdown retries and reject failed shutdowns --- patches/darling.patch | 73 ++++++++++++++++++++++++++++ tests/test_shutdown_user.py | 95 +++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 tests/test_shutdown_user.py diff --git a/patches/darling.patch b/patches/darling.patch index e6ac523..d33b58f 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -10365,3 +10365,76 @@ index 24c037abc..cc18546ce 100644 void* vDSP_zvmulD(void) { if (verbose) puts("STUB: vDSP_zvmulD called"); +diff --git a/tools/shutdown-user.sh b/tools/shutdown-user.sh +index 072a6e2..c804100 100755 +--- a/tools/shutdown-user.sh ++++ b/tools/shutdown-user.sh +@@ -1,26 +1,34 @@ + #!/bin/sh + +-if [ "$(whoami)" != "root" ] +-then +- echo "$0 must be run as root, invoking sudo" +- exec sudo su -c "$0" "$@" ++if [ "$(whoami)" != "root" ]; then ++ echo "$0 must be run as root, invoking sudo" ++ exec sudo sh "$0" "$@" + fi + + echo "Seeing if Darling is currently running" + +-# Sometimes `pgrep launchd` doesn't work when `pgrep -f launchd` does. Not sure +-# if we want to include arguments (and capture rare, but possible false +-# positives). +-PID=$(pgrep -f launchd) ++running_pids() { ++ # Match the guest init itself, not commands mentioning launchd. Zombies ++ # cannot respond to shutdown and must be reaped by their parent. ++ ps -eo pid=,stat=,args= | awk '$2 !~ /^Z/ && $3 == "/sbin/launchd" { print $1 }' ++} + +-while [ -n "$PID" ] +-do +- THISPID=$(echo $PID | head -n1) +- RUNNING_USER=$(ps -o uname= -p $THISPID | head -n1) +- echo "Darling currently running for $RUNNING_USER, shutting it down..." +- su --login "$RUNNING_USER" -c "darling shutdown" +- sleep 2 +- PID=$(pgrep -f launchd) ++attempts=0 ++while :; do ++ PID=$(running_pids | head -n1) ++ [ -n "$PID" ] || break ++ if [ "$attempts" -ge 5 ]; then ++ echo "Darling launchd PID $PID remains alive after shutdown; refusing to continue installation. Check for an orphaned container or a custom DPREFIX." >&2 ++ exit 1 ++ fi ++ RUNNING_USER=$(ps -o uname= -p "$PID" | head -n1 | tr -d '[:space:]') ++ # The process may have exited between enumeration and the owner query. ++ if [ -n "$RUNNING_USER" ]; then ++ echo "Darling currently running for $RUNNING_USER, shutting it down..." ++ timeout 15s su --login "$RUNNING_USER" -c "darling shutdown" || true ++ fi ++ attempts=$((attempts + 1)) ++ sleep 2 + done + + echo "No instances running now" +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 8077b7e..0556080 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -236,7 +236,12 @@ InstallSymlink(/Volumes/SystemRoot/usr/share/zoneinfo ${CMAKE_INSTALL_PREFIX}/li + install(DIRECTORY DESTINATION libexec/darling/Library/Preferences) + + if(NOT DEBIAN_PACKAGING) +- install(CODE "execute_process(COMMAND bash ${DARLING_TOP_DIRECTORY}/tools/shutdown-user.sh)") ++ install(CODE " ++ execute_process(COMMAND bash \"${DARLING_TOP_DIRECTORY}/tools/shutdown-user.sh\" RESULT_VARIABLE DARLING_SHUTDOWN_RESULT) ++ if(NOT DARLING_SHUTDOWN_RESULT STREQUAL \"0\") ++ message(FATAL_ERROR \"Unable to stop old Darling containers; installation stopped\") ++ endif() ++ ") + endif(NOT DEBIAN_PACKAGING) + + add_custom_target(uninstall diff --git a/tests/test_shutdown_user.py b/tests/test_shutdown_user.py new file mode 100644 index 0000000..ac29323 --- /dev/null +++ b/tests/test_shutdown_user.py @@ -0,0 +1,95 @@ +"""Run the install shutdown hook with process and privilege commands mocked.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = Path(os.environ.get('DARLING_SHUTDOWN_SCRIPT', + ROOT / 'build/darling-src/tools/shutdown-user.sh')) + + +@unittest.skipUnless(SCRIPT.is_file(), 'prepare the pinned Darling sources first') +class ShutdownUserTests(unittest.TestCase): + def run_hook(self, processes, mode='stop'): + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) + state = folder / 'state.json' + state.write_text(json.dumps({'processes': processes, 'calls': 0})) + driver = folder / 'driver' + driver.write_text(f'#!{sys.executable}\n' + ''' +import json, os, sys +from pathlib import Path +state = Path(os.environ['TEST_STATE']) +data = json.loads(state.read_text()) +command = Path(sys.argv[0]).name +if command == 'whoami': + print('root') +elif command == 'ps': + if '-eo' in sys.argv: + for pid, status, args in data['processes']: + print(pid, status, args) + elif os.environ['TEST_MODE'] != 'vanished': + print('test-user') + else: + data['processes'] = [] + state.write_text(json.dumps(data)) +elif command == 'timeout': + data['calls'] += 1 + if os.environ['TEST_MODE'] == 'stop': + data['processes'] = [p for p in data['processes'] if p[2] != '/sbin/launchd'] + state.write_text(json.dumps(data)) + if os.environ['TEST_MODE'] == 'failed': + sys.exit(1) + if os.environ['TEST_MODE'] == 'timed-out': + sys.exit(124) +elif command != 'sleep': + raise RuntimeError('Unexpected command: ' + command) +''') + driver.chmod(0o755) + for command in ('whoami', 'ps', 'timeout', 'sleep'): + (folder / command).symlink_to(driver) + env = dict(os.environ, PATH=str(folder) + os.pathsep + os.environ['PATH'], + TEST_STATE=str(state), TEST_MODE=mode) + result = subprocess.run(['sh', str(SCRIPT)], env=env, capture_output=True, + text=True, timeout=10) + return result, json.loads(state.read_text()) + + def test_no_instances(self): + result, state = self.run_hook([]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(state['calls'], 0) + + def test_ignores_mentions_and_zombies(self): + result, state = self.run_hook([ + [123, 'S', 'editor launchd.txt'], [124, 'Z', '/sbin/launchd'], + [125, 'S', 'bash -c echo launchd'], + ]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(state['calls'], 0) + + def test_normal_shutdown(self): + result, state = self.run_hook([[123, 'S', '/sbin/launchd']]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(state['calls'], 1) + + def test_bounded_failure(self): + for mode in ('failed', 'timed-out', 'stuck'): + with self.subTest(mode=mode): + result, state = self.run_hook([[123, 'S', '/sbin/launchd']], mode) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn('launchd PID 123 remains alive', result.stderr) + self.assertEqual(state['calls'], 5) + + def test_process_exits_before_owner_query(self): + result, state = self.run_hook([[123, 'S', '/sbin/launchd']], 'vanished') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(state['calls'], 0) + + +if __name__ == '__main__': + unittest.main() From 5a5a8c4022f139fd87cc37b2d2fe7885a2d4b59f Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:24:58 +0200 Subject: [PATCH 010/160] Log the Metal GPU and create a Vulkan device only for it On a hybrid laptop (issue #3) Indium took whichever Vulkan device the loader listed first, usually the integrated GPU, and still created a VkDevice on every other supported GPU, waking a discrete GPU it never used. Drawables reach the presenting OpenGL context as opaque FDs, which only work on one physical device, and nothing checked that. Indium now appends the device list, the selected device and the reason to guest /tmp/metal-device.log, and creates a VkDevice for the selected device only (/tmp/metal-all-devices restores the old behaviour). /tmp/metal-gpu or METAL_INDIUM_GPU selects a device by index or name for diagnosis. QuartzCore logs the presenting GL renderer and compares its device UUID with the Vulkan device's (match=yes / MISMATCH); it queries Vulkan itself, so it needs no new libindium export. On the RX 6600 desktop the game selected RADV, created 1 VkDevice of 2 supported (lavapipe no longer gets one) and logged match=yes in a match. Running on NVIDIA through prime-run is documented but not validated. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/SETUP.md | 2 + docs/TROUBLESHOOTING.md | 33 ++++ patches/cocotron.patch | 120 +++++++++++- patches/metal--deps--indium.patch | 302 ++++++++++++++++++++++++++---- 5 files changed, 418 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 8e8475d..c469bd1 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Game files, Riot installers, account databases, tokens, local prefixes, captures ## Requirements -- x86-64 Linux **5.14 or newer**, an X11 session or Xwayland, and a working hardware Vulkan/OpenGL driver. AMD RADV has been exercised; other drivers are not validated. +- x86-64 Linux **5.14 or newer**, an X11 session or Xwayland, and a working hardware Vulkan/OpenGL driver. AMD RADV has been exercised; other drivers are not validated. Hybrid laptops usually render on the integrated GPU unless started with PRIME offload; NVIDIA is not validated. See [hybrid graphics](docs/TROUBLESHOOTING.md#hybrid-graphics-laptops-amdintel-igpu--nvidia). - Git + Git LFS, Bash, curl, unzip, CMake, Make, Clang/LLD, LLVM development files, a C/C++ compiler, Python 3.12+ and the Darling build dependencies. The patch includes LLVM 22/23 branch-opcode compatibility; the new PC used LLVM/Clang 23. Node.js is needed only for the offline JavaScript checks. - X11/XCB/SHM development files for the current runtime helpers. - Enough free disk for Darling sources/build, Git LFS assets and the game: budget at least 50 GB. Building is substantially larger than this source-only repository. diff --git a/docs/SETUP.md b/docs/SETUP.md index 660a29c..b7c07ed 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -99,6 +99,8 @@ Indium also contains experimental hand-off changes. They are **off by default**: | `/tmp/metal-deferred-blit` | blit encoders are recorded on the game thread and replayed on the submit thread (only while deferred render encoding is on) | | `/tmp/metal-worker-lease` | the Vulkan command buffer is set up by the first thread that records into it (the submit thread) instead of when `MTLCommandBuffer` is created | | `/tmp/metal-skip-cpu-devices` | software Vulkan devices (lavapipe) are skipped while a hardware device exists | +| `/tmp/metal-gpu` | diagnostic: selects the Vulkan device used for Metal by index or case-insensitive name substring instead of the loader's first device. The OpenGL context that presents does not follow it; see [hybrid laptops](TROUBLESHOOTING.md#hybrid-graphics-laptops-amdintel-igpu--nvidia). `METAL_INDIUM_GPU` is the environment equivalent; the file wins | +| `/tmp/metal-all-devices` | a `VkDevice` is created for every supported Vulkan device, as before 2026-10-16. Without it only the selected device gets one; on a machine with lavapipe installed this also drops lavapipe's idle rasterizer threads | | `/tmp/metal-debug-waits` | diagnostic: counts how often and how long threads block at the hand-off waits and in `nextDrawable`, every 5 s in `/tmp/metal-trace.log`; the first check writes a `waits enabled` line | Create these files from inside the guest, for example `darling shell touch /tmp/metal-debug-waits`, or while Darling is shut down. The guest `/tmp` is `~/.darling/private/tmp` seen through Darling's overlay mount. A file created on the host in that directory while Darling runs may stay invisible to guest processes that already looked for it. The same applies to removing a file. The older `/tmp/metal-no-present-dispatch-wait`, `/tmp/metal-no-deferred-blit` and `/tmp/metal-no-skip-cpu-devices` files are ignored. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index f54b731..4a6b870 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -156,6 +156,39 @@ Compare matches on the same map: Bilgewater runs a few percent below Hexgate. On Hyprland, also check that direct scanout is off (`hyprctl getoption render:direct_scanout` prints `int: 0`). The first match with it off ran about 16 FPS faster than the previous one on the same build; see the next section. +## Hybrid graphics laptops (AMD/Intel iGPU + NVIDIA) + +Metal renders with Vulkan, and each finished frame is handed to an OpenGL (EGL) context that presents it. The hand-off uses opaque file descriptors, which only work when Vulkan and OpenGL run on the same GPU. Indium takes the first Vulkan device the loader lists, and on a hybrid laptop both that device and the EGL context are normally the integrated GPU, so the discrete GPU stays idle. A report in [issue #3](https://github.com/lucannez64/darling-lol/issues/3) (Ryzen 9 5900HS, RTX 3060 Laptop) measured about 70 FPS in ARAM; which GPU rendered that match is not known. **Running on an NVIDIA GPU is not validated.** + +### Run on the discrete GPU + +Start the launcher with PRIME render offload so that both Vulkan and OpenGL move to the NVIDIA GPU: + +```sh +prime-run sh scripts/run.sh +``` + +`prime-run` sets `__NV_PRIME_RENDER_OFFLOAD=1`, `__VK_LAYER_NV_optimus=NVIDIA_only` and `__GLX_VENDOR_LIBRARY_NAME=nvidia`. `darling` copies the caller's environment into the guest, and guest processes inherit it; whether the Riot client passes all of it to the game has not been checked; the log below shows the result. Darling's OpenGL uses EGL, so if the log below still shows the integrated GPU as the presenting renderer, also set `__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json`. On two Mesa GPUs, `DRI_PRIME=1` does the same. + +The guest file `/tmp/metal-gpu` (a device index or part of its name, for example `nvidia`) moves only the Vulkan side. Use it for diagnosis: on its own it splits the two sides across GPUs, and frames cannot be presented. + +### Check which GPU rendered + +Each process that uses Metal appends to guest `/tmp/metal-device.log` (host `~/.darling/private/tmp/metal-device.log`): every Vulkan device the loader reported, the selected one and why, and the renderer of the presenting OpenGL context with both device UUIDs: + +``` +[MetalDevice] pid=… physical name="NVIDIA GeForce RTX 3060 Laptop GPU" type=discrete vendorID=0x10de … supported=yes +[MetalDevice] pid=… selected name="NVIDIA GeForce RTX 3060 Laptop GPU" type=discrete … reason=loader-first +[MetalDevice] pid=… created VkDevice for 1 of 1 supported device(s) (selected only) +[MetalDevice] pid=… presenting GL renderer="…" vendor="NVIDIA Corporation" … match=yes +``` + +`MISMATCH` on the presenting line means Vulkan and OpenGL are on different GPUs. `match=unknown` means the OpenGL driver reports no device UUID. Include this file with performance reports. + +### Host-side checks + +These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice. + ## Cursor stops changing shape in fullscreen On Hyprland the game's cursor can keep its first shape (for example, it never switches to the attack cursor) until you leave and re-enter fullscreen. Darling sends every change to the X server; what freezes the shape on screen is Hyprland's direct scanout of the fullscreen Xwayland window. Turn it off in `hyprland.conf`: diff --git a/patches/cocotron.patch b/patches/cocotron.patch index bfd0cf3..2f391a7 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -7105,10 +7105,122 @@ index 5ad375f8..c8e83814 100644 +- (BOOL) _treeHasAnimations; @end diff --git a/QuartzCore/CAMetalDrawable.mm b/QuartzCore/CAMetalDrawable.mm -index d6f86b31..81a4ac10 100644 +index d6f86b31..4d08ce26 100644 --- a/QuartzCore/CAMetalDrawable.mm +++ b/QuartzCore/CAMetalDrawable.mm -@@ -401,6 +401,10 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat +@@ -31,6 +31,12 @@ + #include + #include + ++#include ++#include ++#include ++#include ++#include ++ + namespace DynamicVK = Indium::DynamicVK; + + static void reportGLErrors(void) { +@@ -50,8 +56,86 @@ static void reportGLErrors(void) { + namespace Indium::DynamicVK { + static DynamicFunction vkGetSemaphoreFdKHR("vkGetSemaphoreFdKHR"); + static DynamicFunction vkGetMemoryFdKHR("vkGetMemoryFdKHR"); ++ static DynamicFunction vkGetPhysicalDeviceProperties2Local("vkGetPhysicalDeviceProperties2"); + }; + ++// ++// GPU selection diagnostics ++// ++ ++// The drawable's memory and semaphores are exported as OPAQUE_FD and imported into ++// this layer's OpenGL context. That only works when Vulkan and OpenGL run on the ++// same physical device/driver, and until now nothing checked it: a mismatch would ++// silently break. Record which GL renderer actually presents and whether its ++// device UUID matches the Vulkan device's, once per process, in ++// /tmp/metal-device.log (alongside the device list Indium logs at startup). ++static std::string darlingUuidString(const GLubyte* uuid) { ++ static const char* digits = "0123456789abcdef"; ++ std::string out; ++ out.reserve(32); ++ for (int i = 0; i < GL_UUID_SIZE_EXT; ++i) { ++ out += digits[(uuid[i] >> 4) & 0xf]; ++ out += digits[uuid[i] & 0xf]; ++ } ++ return out; ++} ++ ++static void reportGpuDeviceMatch(std::shared_ptr device) { ++ if (!device) { ++ return; ++ } ++ static std::once_flag once; ++ std::call_once(once, [device]() { ++ const GLubyte* renderer = glGetString(GL_RENDERER); ++ const GLubyte* vendor = glGetString(GL_VENDOR); ++ ++ GLubyte glDeviceUUID[GL_UUID_SIZE_EXT] = {0}; ++ GLubyte glDriverUUID[GL_UUID_SIZE_EXT] = {0}; ++ GLint uuidCount = 0; ++ glGetIntegerv(GL_NUM_DEVICE_UUIDS_EXT, &uuidCount); ++ if (uuidCount > 0) { ++ glGetUnsignedBytei_vEXT(GL_DEVICE_UUID_EXT, 0, glDeviceUUID); ++ glGetUnsignedBytevEXT(GL_DRIVER_UUID_EXT, glDriverUUID); ++ } ++ ++ // Queried here rather than through a new libindium export, so this QuartzCore ++ // still loads against a libindium built before the device log. ++ VkPhysicalDeviceIDProperties idProperties {}; ++ idProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES; ++ VkPhysicalDeviceProperties2 properties2 {}; ++ properties2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2; ++ properties2.pNext = &idProperties; ++ bool haveVkUUID = false; ++ if (DynamicVK::vkGetPhysicalDeviceProperties2Local) { ++ DynamicVK::vkGetPhysicalDeviceProperties2Local(device->physicalDevice(), &properties2); ++ haveVkUUID = true; ++ } ++ const uint8_t* vkDeviceUUID = idProperties.deviceUUID; ++ const uint8_t* vkDriverUUID = idProperties.driverUUID; ++ ++ std::string line = "presenting GL renderer=\"" + std::string(renderer ? reinterpret_cast(renderer) : "(null)") + "\"" ++ + " vendor=\"" + std::string(vendor ? reinterpret_cast(vendor) : "(null)") + "\"" ++ + " glDeviceUUID=" + (uuidCount > 0 ? darlingUuidString(glDeviceUUID) : std::string("none")) ++ + " glDriverUUID=" + (uuidCount > 0 ? darlingUuidString(glDriverUUID) : std::string("none")) ++ + " vulkanDeviceUUID=" + (haveVkUUID ? darlingUuidString(vkDeviceUUID) : std::string("none")) ++ + " vulkanDriverUUID=" + (haveVkUUID ? darlingUuidString(vkDriverUUID) : std::string("none")); ++ ++ if (uuidCount > 0 && haveVkUUID && memcmp(glDeviceUUID, vkDeviceUUID, GL_UUID_SIZE_EXT) != 0) { ++ line += " MISMATCH: OpenGL and Vulkan are on different GPUs; the opaque-FD import cannot work." ++ " Run the launcher under prime-run so both use the same GPU."; ++ } else if (uuidCount > 0 && haveVkUUID) { ++ line += " match=yes"; ++ } else { ++ line += " match=unknown"; ++ } ++ ++ if (FILE* log = fopen("/tmp/metal-device.log", "a")) { ++ fprintf(log, "[MetalDevice] pid=%d %s\n", getpid(), line.c_str()); ++ fclose(log); ++ } ++ }); ++} ++ + // + // helper classes + // +@@ -161,6 +245,11 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat + _pixelFormat(pixelFormat), + _framebufferOnly(framebufferOnly) + { ++ // The layer's GL context is current here (recreateDrawables / _setContext set ++ // it), so this is the first point where the presenting GL device can be ++ // compared with the Vulkan device. Logged once per process. ++ reportGpuDeviceMatch(privateDevice); ++ + // + // create the images + // +@@ -401,6 +490,10 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat glTextureParameteri(_textureID, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTextureParameteri(_textureID, GL_TEXTURE_WRAP_S, GL_REPEAT); glTextureParameteri(_textureID, GL_TEXTURE_WRAP_T, GL_REPEAT); @@ -7119,7 +7231,7 @@ index d6f86b31..81a4ac10 100644 reportGLErrors(); glTextureStorageMem2DEXT(_textureID, 1, GL_RGBA8, _size.width, _size.height, _memoryObject, 0); -@@ -605,35 +609,16 @@ void CAMetalDrawableActual::present() { +@@ -605,35 +698,16 @@ void CAMetalDrawableActual::present() { return; } @@ -7161,7 +7273,7 @@ index d6f86b31..81a4ac10 100644 } } -@@ -702,9 +687,24 @@ void CAMetalDrawableActual::reset() { +@@ -702,9 +776,24 @@ void CAMetalDrawableActual::reset() { }; void CAMetalDrawableActual::synchronizeRender() { diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index faa1a83..45fd9b6 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -781,7 +781,7 @@ index 8546daa..7e4993c 100644 void updateBindings(); diff --git a/private-include/indium/device.private.hpp b/private-include/indium/device.private.hpp -index 58f1794..5b2b30d 100644 +index 58f1794..75cdeb3 100644 --- a/private-include/indium/device.private.hpp +++ b/private-include/indium/device.private.hpp @@ -13,6 +13,8 @@ @@ -802,15 +802,16 @@ index 58f1794..5b2b30d 100644 std::mutex _eventLoopMutex; std::mutex _pollingMutex; -@@ -59,6 +63,7 @@ namespace Indium { +@@ -59,6 +63,8 @@ namespace Indium { } virtual std::string name() const override; + virtual bool supportsDepth24Stencil8PixelFormat() override; ++ virtual std::shared_ptr newCommandQueue() override; virtual std::shared_ptr newRenderPipelineState(const RenderPipelineDescriptor& descriptor) override; virtual std::shared_ptr newComputePipelineState(const ComputePipelineDescriptor& descriptor, PipelineOption options, std::shared_ptr reflection) override; -@@ -73,6 +78,12 @@ namespace Indium { +@@ -73,6 +79,12 @@ namespace Indium { virtual void pollEvents(uint64_t timeoutNanoseconds) override; virtual void wakeupEventLoop() override; @@ -823,7 +824,7 @@ index 58f1794..5b2b30d 100644 void waitForSemaphore(VkSemaphore semaphore, uint64_t targetValue, std::function callback); /** -@@ -86,6 +97,11 @@ namespace Indium { +@@ -86,6 +98,11 @@ namespace Indium { * Similar to getTimelineSemaphore(), but wraps it in a shared pointer * that automatically releases the semaphore when all references to it die out. */ @@ -835,7 +836,7 @@ index 58f1794..5b2b30d 100644 std::shared_ptr getWrappedTimelineSemaphore(); BinarySemaphore getBinarySemaphore(bool exportable = false); -@@ -111,5 +127,31 @@ namespace Indium { +@@ -111,5 +128,31 @@ namespace Indium { INDIUM_PROPERTY(VkPhysicalDeviceMemoryProperties, m, M,emoryProperties); INDIUM_PROPERTY_READONLY(Feature, f, F,eatures); @@ -2802,7 +2803,7 @@ index 5f62ad0..5957bec 100644 DynamicVK::vkCmdBindDescriptorSets(buf->commandBuffer(), VK_PIPELINE_BIND_POINT_COMPUTE, _pso->layout(), 0, descriptorSets.size(), descriptorSets.data(), 0, nullptr); }; diff --git a/src/indium/device.cpp b/src/indium/device.cpp -index 322e186..70c4a15 100644 +index 322e186..2003684 100644 --- a/src/indium/device.cpp +++ b/src/indium/device.cpp @@ -1,4 +1,6 @@ @@ -2812,7 +2813,7 @@ index 322e186..70c4a15 100644 #include #include #include -@@ -12,10 +14,12 @@ +@@ -12,39 +14,167 @@ #include @@ -2823,45 +2824,274 @@ index 322e186..70c4a15 100644 #include +#include ++#include ++#include ++#include #include ++#include ++#include #include -@@ -39,6 +43,15 @@ void Indium::initGlobalDeviceList() { + + std::vector> Indium::globalDeviceList; + ++namespace { ++ // One line per event, appended to the guest's /tmp/metal-device.log (host: ++ // ~/.darling/private/tmp/metal-device.log). Low volume: a few lines while the ++ // device list is built, plus one when the presenting OpenGL context first ++ // imports a Vulkan drawable. It exists so a user can tell us which GPU ++ // actually rendered a match, which the game's own logs and FPS numbers do not ++ // show. ++ void appendDeviceLog(const std::string& line) { ++ if (FILE* log = fopen("/tmp/metal-device.log", "a")) { ++ fprintf(log, "[MetalDevice] pid=%d %s\n", getpid(), line.c_str()); ++ fclose(log); ++ } ++ } ++ ++ std::string hexValue(unsigned long long value, int width) { ++ char buffer[32]; ++ snprintf(buffer, sizeof(buffer), "%0*llx", width, value); ++ return buffer; ++ } ++ ++ std::string uuidToString(const uint8_t uuid[16]) { ++ static const char* digits = "0123456789abcdef"; ++ std::string out; ++ out.reserve(32); ++ for (int i = 0; i < 16; ++i) { ++ out += digits[(uuid[i] >> 4) & 0xf]; ++ out += digits[uuid[i] & 0xf]; ++ } ++ return out; ++ } ++ ++ const char* deviceTypeName(VkPhysicalDeviceType type) { ++ switch (type) { ++ case VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU: return "integrated"; ++ case VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU: return "discrete"; ++ case VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU: return "virtual"; ++ case VK_PHYSICAL_DEVICE_TYPE_CPU: return "cpu"; ++ default: return "other"; ++ } ++ } ++ ++ struct GpuSelector { ++ std::string value; ++ std::string source; // empty when the loader's default order should decide ++ }; ++ ++ // Explicit selection, in the style of the other opt-in guest files. The guest ++ // file /tmp/metal-gpu wins over the METAL_INDIUM_GPU environment variable: the ++ // file is scoped to the guest and does not depend on the environment reaching ++ // the game through the Riot/League client. The value is either the index of a ++ // supported device or a case-insensitive substring of its Vulkan name, so ++ // "nvidia", "amd" or "llvmpipe" all work. ++ GpuSelector readGpuSelector() { ++ if (FILE* file = fopen("/tmp/metal-gpu", "r")) { ++ char buffer[256] = {0}; ++ char* got = fgets(buffer, sizeof(buffer), file); ++ fclose(file); ++ if (got) { ++ std::string value(buffer); ++ while (!value.empty() && (value.back() == '\n' || value.back() == '\r' || value.back() == ' ' || value.back() == '\t')) { ++ value.pop_back(); ++ } ++ if (!value.empty()) return { value, "file:/tmp/metal-gpu" }; ++ } ++ } ++ if (const char* env = getenv("METAL_INDIUM_GPU")) { ++ if (env[0]) return { std::string(env), "env:METAL_INDIUM_GPU" }; ++ } ++ return {}; ++ } ++ ++ bool isAllDigits(const std::string& value) { ++ return !value.empty() && std::all_of(value.begin(), value.end(), [](unsigned char c) { return std::isdigit(c) != 0; }); ++ } ++ ++ bool nameContains(const std::string& name, const std::string& needle) { ++ return std::search(name.begin(), name.end(), needle.begin(), needle.end(), [](char a, char b) { ++ return std::tolower((unsigned char) a) == std::tolower((unsigned char) b); ++ }) != name.end(); ++ } ++ ++ struct DeviceCandidate { ++ VkPhysicalDevice device; ++ VkPhysicalDeviceProperties properties; ++ uint8_t deviceUUID[16]; ++ uint8_t driverUUID[16]; ++ }; ++} ++ + void Indium::initGlobalDeviceList() { + std::vector physicalDevices; + uint32_t count = 0; + + auto result = DynamicVK::vkEnumeratePhysicalDevices(globalInstance, &count, nullptr); + if (result != VK_SUCCESS && result != VK_INCOMPLETE) { +- // TODO: maybe warn? ++ appendDeviceLog("vkEnumeratePhysicalDevices failed when counting devices; Metal will have no device"); return; } -+ // A VkDevice is created (and its driver initialized) for every supported physical device below, and -+ // createSystemDefaultDevice() takes the first entry. Software devices (lavapipe) can be skipped -- a VkDevice on -+ // one of them starts its rasterizer pool (nproc llvmpipe threads, idle for a whole match) and the loader may order -+ // it first. Only skip them while some other supported device exists. OFF by default (round-3 behaviour: a device is -+ // created for every supported device, in the loader's order); guest flag file /tmp/metal-skip-cpu-devices turns the -+ // skip on. It is read here, at load time, because this list is built once. + physicalDevices.resize(count); + result = DynamicVK::vkEnumeratePhysicalDevices(globalInstance, &count, physicalDevices.data()); + if (result != VK_SUCCESS && result != VK_INCOMPLETE) { +- // TODO: maybe warn? ++ appendDeviceLog("vkEnumeratePhysicalDevices failed when listing devices; Metal will have no device"); + return; + } + ++ // The loader's order used to decide the default device (createSystemDefaultDevice ++ // returns the first entry). On a hybrid laptop that is usually the integrated ++ // GPU the display is wired to, not the discrete GPU. Record every device the ++ // loader lists, then choose explicitly. ++ GpuSelector selector = readGpuSelector(); + const bool skipCPUDevices = access("/tmp/metal-skip-cpu-devices", F_OK) == 0; -+ std::vector> candidates; ++ const bool allDevices = access("/tmp/metal-all-devices", F_OK) == 0; ++ appendDeviceLog("selector=" + (selector.source.empty() ? std::string("none") : selector.source + " value=\"" + selector.value + "\"") ++ + " skipCPUDevices=" + (skipCPUDevices ? "1" : "0") + " allDevices=" + (allDevices ? "1" : "0") ++ + " physicalDeviceCount=" + std::to_string(physicalDevices.size())); ++ ++ std::vector candidates; + for (auto&& device: physicalDevices) { - VkPhysicalDeviceProperties props; - DynamicVK::vkGetPhysicalDeviceProperties(device, &props); -@@ -71,7 +84,18 @@ void Indium::initGlobalDeviceList() { +- VkPhysicalDeviceProperties props; +- DynamicVK::vkGetPhysicalDeviceProperties(device, &props); ++ VkPhysicalDeviceProperties properties; ++ DynamicVK::vkGetPhysicalDeviceProperties(device, &properties); ++ ++ VkPhysicalDeviceIDProperties idProperties {}; ++ idProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES; ++ VkPhysicalDeviceProperties2 properties2 {}; ++ properties2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2; ++ properties2.pNext = &idProperties; ++ if (DynamicVK::vkGetPhysicalDeviceProperties2) { ++ DynamicVK::vkGetPhysicalDeviceProperties2(device, &properties2); ++ } + +- if (VK_API_VERSION_VARIANT(props.apiVersion) != 0 || props.apiVersion < VK_API_VERSION_1_3) { +- // unsupported device ++ std::string line = "physical name=\"" + std::string(properties.deviceName) + "\"" ++ + " type=" + deviceTypeName(properties.deviceType) ++ + " vendorID=0x" + hexValue(properties.vendorID, 4) ++ + " driverVersion=0x" + hexValue(properties.driverVersion, 8) ++ + " apiVersion=" + std::to_string(VK_API_VERSION_MAJOR(properties.apiVersion)) + "." ++ + std::to_string(VK_API_VERSION_MINOR(properties.apiVersion)) + "." ++ + std::to_string(VK_API_VERSION_PATCH(properties.apiVersion)) ++ + " deviceUUID=" + uuidToString(idProperties.deviceUUID) ++ + " driverUUID=" + uuidToString(idProperties.driverUUID); ++ ++ if (VK_API_VERSION_VARIANT(properties.apiVersion) != 0 || properties.apiVersion < VK_API_VERSION_1_3) { ++ appendDeviceLog(line + " supported=no reason=apiVersion"); + continue; + } + +@@ -67,12 +197,101 @@ void Indium::initGlobalDeviceList() { + DynamicVK::vkGetPhysicalDeviceFeatures2(device, &features); + + if (!features12.timelineSemaphore) { +- // unsupported device ++ appendDeviceLog(line + " supported=no reason=timelineSemaphore"); continue; } - globalDeviceList.push_back(std::make_shared(std::move(device))); -+ candidates.emplace_back(device, props.deviceType); ++ appendDeviceLog(line + " supported=yes"); ++ DeviceCandidate candidate {}; ++ candidate.device = device; ++ candidate.properties = properties; ++ memcpy(candidate.deviceUUID, idProperties.deviceUUID, 16); ++ memcpy(candidate.driverUUID, idProperties.driverUUID, 16); ++ candidates.push_back(candidate); + } ++ ++ // Resolve the explicit selection against the supported list. An explicit choice ++ // is honoured even when it names a software (CPU) device, because the user asked ++ // for it; the skip-CPU rule only affects the automatic default. ++ long selectedIndex = -1; ++ std::string selectedReason; ++ ++ if (!selector.source.empty()) { ++ if (isAllDigits(selector.value)) { ++ long index = strtol(selector.value.c_str(), nullptr, 10); ++ if (index >= 0 && index < (long) candidates.size()) { ++ selectedIndex = index; ++ selectedReason = selector.source + " index " + selector.value; ++ } ++ } ++ if (selectedIndex < 0) { ++ for (size_t i = 0; i < candidates.size(); ++i) { ++ if (nameContains(candidates[i].properties.deviceName, selector.value)) { ++ selectedIndex = (long) i; ++ selectedReason = selector.source + " name~=\"" + selector.value + "\""; ++ break; ++ } ++ } ++ } ++ if (selectedIndex < 0) { ++ appendDeviceLog("selector " + selector.source + " value=\"" + selector.value + "\" matched no supported device; falling back to the loader default"); ++ } + } + -+ const bool haveHardwareDevice = skipCPUDevices && std::any_of(candidates.begin(), candidates.end(), [](const auto& candidate) { -+ return candidate.second != VK_PHYSICAL_DEVICE_TYPE_CPU; ++ const bool haveHardwareDevice = std::any_of(candidates.begin(), candidates.end(), [](const DeviceCandidate& candidate) { ++ return candidate.properties.deviceType != VK_PHYSICAL_DEVICE_TYPE_CPU; + }); + -+ for (auto&& candidate: candidates) { -+ if (haveHardwareDevice && candidate.second == VK_PHYSICAL_DEVICE_TYPE_CPU) { -+ continue; ++ if (selectedIndex < 0) { ++ for (size_t i = 0; i < candidates.size(); ++i) { ++ if (skipCPUDevices && haveHardwareDevice && candidates[i].properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_CPU) { ++ continue; ++ } ++ selectedIndex = (long) i; ++ selectedReason = skipCPUDevices ? "loader-first-hardware" : "loader-first"; ++ break; + } -+ globalDeviceList.push_back(std::make_shared(std::move(candidate.first))); - } ++ } ++ ++ if (selectedIndex < 0) { ++ appendDeviceLog("no usable supported Vulkan device found; Metal will have no device"); ++ return; ++ } ++ ++ DeviceCandidate& selected = candidates[selectedIndex]; ++ appendDeviceLog("selected name=\"" + std::string(selected.properties.deviceName) + "\"" ++ + " type=" + deviceTypeName(selected.properties.deviceType) ++ + " vendorID=0x" + hexValue(selected.properties.vendorID, 4) ++ + " deviceUUID=" + uuidToString(selected.deviceUUID) ++ + " reason=" + (selectedReason.empty() ? std::string("loader-first") : selectedReason)); ++ ++ // A VkDevice is created for the selected device only: its driver is initialized ++ // and, on a hybrid laptop, the discrete GPU is not woken for a device the ++ // rendering GPU cannot share memory with anyway. The old behaviour -- a VkDevice ++ // for every supported device, in the loader's order -- stays reachable with the ++ // opt-in guest file /tmp/metal-all-devices. MTLCopyAllDevices only ever exposes ++ // the default device, so nothing in the client depends on the full list. ++ std::vector creationOrder; ++ creationOrder.push_back((size_t) selectedIndex); ++ if (allDevices) { ++ for (size_t i = 0; i < candidates.size(); ++i) { ++ if (i == (size_t) selectedIndex) { ++ continue; ++ } ++ if (skipCPUDevices && haveHardwareDevice && candidates[i].properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_CPU) { ++ continue; ++ } ++ creationOrder.push_back(i); ++ } ++ } ++ ++ for (size_t index: creationOrder) { ++ globalDeviceList.push_back(std::make_shared(candidates[index].device)); ++ } ++ ++ appendDeviceLog("created VkDevice for " + std::to_string(globalDeviceList.size()) + " of " ++ + std::to_string(candidates.size()) + " supported device(s)" ++ + (allDevices ? " (allDevices)" : " (selected only)")); }; -@@ -83,7 +107,8 @@ Indium::Device::~Device() {}; + void Indium::finitGlobalDeviceList() { +@@ -83,7 +302,8 @@ Indium::Device::~Device() {}; Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): _physicalDevice(physicalDevice) @@ -2871,7 +3101,7 @@ index 322e186..70c4a15 100644 DynamicVK::vkGetPhysicalDeviceProperties(_physicalDevice, &_properties); std::vector queueFamilies; -@@ -233,6 +258,36 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): +@@ -233,6 +453,36 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): } } @@ -2908,7 +3138,7 @@ index 322e186..70c4a15 100644 VkDeviceCreateInfo deviceCreateInfo {}; deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; deviceCreateInfo.pQueueCreateInfos = queueCreateInfos.data(); -@@ -246,6 +301,7 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): +@@ -246,6 +496,7 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): } _features = indiumFeatures; @@ -2916,7 +3146,7 @@ index 322e186..70c4a15 100644 for (const auto& index: queueFamilyIndices) { VkQueue queue; -@@ -297,6 +353,9 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): +@@ -297,6 +548,9 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): }; Indium::PrivateDevice::~PrivateDevice() { @@ -2926,7 +3156,7 @@ index 322e186..70c4a15 100644 if (_oneshotCommandPool) { DynamicVK::vkDestroyCommandPool(_device, _oneshotCommandPool, nullptr); } -@@ -308,6 +367,23 @@ std::string Indium::PrivateDevice::name() const { +@@ -308,6 +562,23 @@ std::string Indium::PrivateDevice::name() const { return _properties.deviceName; }; @@ -2950,7 +3180,7 @@ index 322e186..70c4a15 100644 std::shared_ptr Indium::PrivateDevice::newCommandQueue() { return std::make_shared(shared_from_this()); }; -@@ -317,9 +393,9 @@ std::shared_ptr Indium::PrivateDevice::newRenderPip +@@ -317,9 +588,9 @@ std::shared_ptr Indium::PrivateDevice::newRenderPip }; std::shared_ptr Indium::PrivateDevice::newComputePipelineState(const ComputePipelineDescriptor& descriptor, PipelineOption options, std::shared_ptr reflection) { @@ -2963,7 +3193,7 @@ index 322e186..70c4a15 100644 return std::make_shared(shared_from_this(), descriptor); }; -@@ -357,6 +433,7 @@ std::shared_ptr Indium::PrivateDevice::newLibrary(const void* d +@@ -357,6 +628,7 @@ std::shared_ptr Indium::PrivateDevice::newLibrary(const void* d break; } @@ -2971,7 +3201,7 @@ index 322e186..70c4a15 100644 funcInfo.bindings.insert(funcInfo.bindings.end(), info.bindings.begin(), info.bindings.end()); funcInfo.embeddedSamplers.insert(funcInfo.embeddedSamplers.end(), info.embeddedSamplers.begin(), info.embeddedSamplers.end()); } -@@ -480,9 +557,18 @@ void Indium::PrivateDevice::waitForSemaphore(VkSemaphore semaphore, uint64_t tar +@@ -480,9 +752,18 @@ void Indium::PrivateDevice::waitForSemaphore(VkSemaphore semaphore, uint64_t tar wakeupEventLoop(); }; @@ -2991,7 +3221,7 @@ index 322e186..70c4a15 100644 VkSemaphoreTypeCreateInfo typeInfo {}; typeInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO; typeInfo.semaphoreType = VK_SEMAPHORE_TYPE_TIMELINE; -@@ -505,7 +591,14 @@ Indium::TimelineSemaphore Indium::PrivateDevice::getTimelineSemaphore() { +@@ -505,7 +786,14 @@ Indium::TimelineSemaphore Indium::PrivateDevice::getTimelineSemaphore() { }; void Indium::PrivateDevice::putTimelineSemaphore(const TimelineSemaphore& semaphore) { @@ -3007,7 +3237,7 @@ index 322e186..70c4a15 100644 }; std::shared_ptr Indium::PrivateDevice::getWrappedTimelineSemaphore() { -@@ -551,3 +644,91 @@ std::shared_ptr Indium::PrivateDevice::getWrappedBinary +@@ -551,3 +839,91 @@ std::shared_ptr Indium::PrivateDevice::getWrappedBinary delete ptr; }); }; From 6d6159b594fd49de987cea20add013423df663f4 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:41:15 +0200 Subject: [PATCH 011/160] Fall back to a matching llvm-config when the cached LLVM_DIR is stale A leftover LLVM_DIR can point at a removed LLVM whose llvm-config exists but whose library is gone, which made configure fail on LLVM 23 hosts. Try the tools-dir llvm-config, then the one on PATH, and accept only one that matches the header major and reports a shared library. Co-Authored-By: Claude Sonnet 5.5 --- patches/metal--deps--indium.patch | 41 +++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index 45fd9b6..7d92c48 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -8462,16 +8462,43 @@ diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d95863..603dc3d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt -@@ -159,9 +159,10 @@ if (NOT DARLING) +@@ -159,9 +159,33 @@ if (NOT DARLING) endif() - execute_process( +-execute_process( - COMMAND llvm-config --link-shared --libnames -+ COMMAND "${LLVM_TOOLS_BINARY_DIR}/llvm-config" --link-shared --libnames - OUTPUT_VARIABLE HOST_LLVM_LIBNAME - OUTPUT_STRIP_TRAILING_WHITESPACE -+ COMMAND_ERROR_IS_FATAL ANY - ) +- OUTPUT_VARIABLE HOST_LLVM_LIBNAME +- OUTPUT_STRIP_TRAILING_WHITESPACE +-) ++# Use an llvm-config of the same major as the headers found above. A stale cached LLVM_DIR can point at ++# a removed LLVM whose llvm-config exists but whose library is gone, so fall back to the one on PATH. ++set(HOST_LLVM_LIBNAME "") ++foreach(candidate IN ITEMS "${LLVM_TOOLS_BINARY_DIR}/llvm-config" llvm-config) ++ execute_process( ++ COMMAND "${candidate}" --version ++ OUTPUT_VARIABLE candidate_version ++ RESULT_VARIABLE candidate_status ++ ERROR_QUIET ++ OUTPUT_STRIP_TRAILING_WHITESPACE ++ ) ++ if (candidate_status EQUAL 0 AND candidate_version MATCHES "^${LLVM_VERSION_MAJOR}\\.") ++ execute_process( ++ COMMAND "${candidate}" --link-shared --libnames ++ OUTPUT_VARIABLE HOST_LLVM_LIBNAME ++ RESULT_VARIABLE candidate_status ++ ERROR_QUIET ++ OUTPUT_STRIP_TRAILING_WHITESPACE ++ ) ++ if (candidate_status EQUAL 0 AND HOST_LLVM_LIBNAME) ++ break() ++ endif() ++ set(HOST_LLVM_LIBNAME "") ++ endif() ++endforeach() ++if (NOT HOST_LLVM_LIBNAME) ++ message(FATAL_ERROR "No working llvm-config for LLVM ${LLVM_VERSION} (LLVM_DIR=${LLVM_DIR}). " ++ "Remove LLVM_DIR from the CMake cache or install the matching LLVM.") ++endif() target_compile_definitions(iridium PRIVATE diff --git a/src/iridium/dynamic-llvm.cpp b/src/iridium/dynamic-llvm.cpp From 1c30c99e515ac235a6fa81abe78b612c6c3ab63f Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:04:43 +0200 Subject: [PATCH 012/160] Prefer VRAM for GPU-only Metal memory and mark the GL import dedicated Indium and QuartzCore took the first memory type a resource allowed. The Vulkan spec orders a type with no flags before every DEVICE_LOCAL type, and NVIDIA exposes such system-RAM types, so on NVIDIA private buffers, textures and framebufferOnly drawables could land in system RAM. They now take the first allowed DEVICE_LOCAL type and fall back to the old choice; shared and managed resources are unchanged. The drawable's exported image is a dedicated Vulkan allocation, but the OpenGL import never set GL_DEDICATED_MEMORY_OBJECT_EXT, which EXT_external_objects requires. Mesa ignores it; it is the suspected cause of the black screen on NVIDIA in issue #3. The import's GL errors were never checked (reportGLErrors is compiled out), so a failure was silent. /tmp/metal-device.log now lists the selected device's memory heaps and types, the memory type chosen per resource kind (with the old choice and changed=yes/no), and the result of the first drawable and semaphore imports into OpenGL (later ones only on failure). Practice match on the RX 6600: all choices changed=no, import dedicated=yes with no GL errors, semaphore ok, match=yes. Not run on NVIDIA. Co-Authored-By: Claude Opus 5.5 --- docs/TROUBLESHOOTING.md | 21 ++- patches/cocotron.patch | 232 ++++++++++++++++++++++++++-- patches/metal--deps--indium.patch | 244 +++++++++++++++++++++++++----- 3 files changed, 443 insertions(+), 54 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 4a6b870..9726626 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -158,7 +158,7 @@ On Hyprland, also check that direct scanout is off (`hyprctl getoption render:di ## Hybrid graphics laptops (AMD/Intel iGPU + NVIDIA) -Metal renders with Vulkan, and each finished frame is handed to an OpenGL (EGL) context that presents it. The hand-off uses opaque file descriptors, which only work when Vulkan and OpenGL run on the same GPU. Indium takes the first Vulkan device the loader lists, and on a hybrid laptop both that device and the EGL context are normally the integrated GPU, so the discrete GPU stays idle. A report in [issue #3](https://github.com/lucannez64/darling-lol/issues/3) (Ryzen 9 5900HS, RTX 3060 Laptop) measured about 70 FPS in ARAM; which GPU rendered that match is not known. **Running on an NVIDIA GPU is not validated.** +Metal renders with Vulkan, and each finished frame is handed to an OpenGL (EGL) context that presents it. The hand-off uses opaque file descriptors, which only work when Vulkan and OpenGL run on the same GPU. Indium takes the first Vulkan device the loader lists, and on a hybrid laptop both that device and the EGL context are normally the integrated GPU, so the discrete GPU stays idle. A report in [issue #3](https://github.com/lucannez64/darling-lol/issues/3) (Ryzen 9 5900HS, RTX 3060 Laptop) measured 60-70 FPS in ARAM on the integrated GPU; on the NVIDIA GPU the game showed a black screen. **Running on an NVIDIA GPU is not validated.** ### Run on the discrete GPU @@ -185,6 +185,25 @@ Each process that uses Metal appends to guest `/tmp/metal-device.log` (host `~/. `MISMATCH` on the presenting line means Vulkan and OpenGL are on different GPUs. `match=unknown` means the OpenGL driver reports no device UUID. Include this file with performance reports. +The log also lists the selected device's memory heaps and types, and one line the first time each kind of resource (`buffer`, `texture`, `drawable`, `drawable-internal`) gets a storage mode and memory type: + +``` +[MetalDevice] pid=… memory heap=0 size=6144MiB deviceLocal=yes +[MetalDevice] pid=… memory type=0 flags=0x0(none) heap=1 +[MetalDevice] pid=… memory alloc kind=texture storage=private typeBits=0x… type=1 flags=0x1(DEVICE_LOCAL) heap=0 heapDeviceLocal=yes firstFit=0 flags=0x0(none) heap=1 heapDeviceLocal=no changed=yes +``` + +Resources only the GPU uses (`private`, `memoryless`, and `framebufferOnly` drawables) take the first allowed `DEVICE_LOCAL` type. Before 2026-10-16 they took the first allowed type (`firstFit`), which on NVIDIA can be system RAM; `changed=yes` marks where the two differ. `shared` and `managed` resources keep the first host-visible type. On the RX 6600 (RADV) type 0 is `DEVICE_LOCAL`, so nothing changes there. + +The first import of a drawable into the presenting OpenGL context, and the first semaphore import, are logged too; later ones only when they fail (at most five): + +``` +[MetalDevice] pid=… drawable GL import (first) dedicated=yes size=… 1920x1080 memoryType=1 tiling=linear glTiling=linear dedicated_error=0x0 import_error=0x0 setup_error=0x0 storage_error=0x0 query_error=0x0 ok +[MetalDevice] pid=… drawable GL semaphore (first) import_error=0x0 wait_error=0x0 ok (checked for the first 600 imports) +``` + +A non-zero error or `FAILED` means the frames cannot reach the window. Before 2026-10-16 the import did not mark the memory as dedicated, which the extension requires; Mesa ignores it, and it is the suspected cause of the NVIDIA black screen (not yet confirmed). If the game shows a black screen on NVIDIA, report it with this file. + ### Host-side checks These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 2f391a7..7212cc4 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -7105,13 +7105,14 @@ index 5ad375f8..c8e83814 100644 +- (BOOL) _treeHasAnimations; @end diff --git a/QuartzCore/CAMetalDrawable.mm b/QuartzCore/CAMetalDrawable.mm -index d6f86b31..4d08ce26 100644 +index d6f86b31..2bdc198d 100644 --- a/QuartzCore/CAMetalDrawable.mm +++ b/QuartzCore/CAMetalDrawable.mm -@@ -31,6 +31,12 @@ +@@ -31,6 +31,13 @@ #include #include ++#include +#include +#include +#include @@ -7121,7 +7122,7 @@ index d6f86b31..4d08ce26 100644 namespace DynamicVK = Indium::DynamicVK; static void reportGLErrors(void) { -@@ -50,8 +56,86 @@ static void reportGLErrors(void) { +@@ -50,8 +57,98 @@ static void reportGLErrors(void) { namespace Indium::DynamicVK { static DynamicFunction vkGetSemaphoreFdKHR("vkGetSemaphoreFdKHR"); static DynamicFunction vkGetMemoryFdKHR("vkGetMemoryFdKHR"); @@ -7138,6 +7139,21 @@ index d6f86b31..4d08ce26 100644 +// silently break. Record which GL renderer actually presents and whether its +// device UUID matches the Vulkan device's, once per process, in +// /tmp/metal-device.log (alongside the device list Indium logs at startup). ++// Clears pending GL errors (bounded: without a current context glGetError may never ++// report GL_NO_ERROR). ++static void drainGLErrors() { ++ for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) { ++ } ++} ++ ++// Same line format as Indium's device log. ++static void appendDeviceLog(const std::string& line) { ++ if (FILE* log = fopen("/tmp/metal-device.log", "a")) { ++ fprintf(log, "[MetalDevice] pid=%d %s\n", getpid(), line.c_str()); ++ fclose(log); ++ } ++} ++ +static std::string darlingUuidString(const GLubyte* uuid) { + static const char* digits = "0123456789abcdef"; + std::string out; @@ -7198,17 +7214,104 @@ index d6f86b31..4d08ce26 100644 + line += " match=unknown"; + } + -+ if (FILE* log = fopen("/tmp/metal-device.log", "a")) { -+ fprintf(log, "[MetalDevice] pid=%d %s\n", getpid(), line.c_str()); -+ fclose(log); -+ } ++ appendDeviceLog(line); + }); +} + // // helper classes // -@@ -161,6 +245,11 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat +@@ -129,13 +226,39 @@ public: + // texture + // + +-static size_t findSharedMemory(const VkMemoryRequirements& reqs, std::shared_ptr device, bool framebufferOnly) { ++static std::string memoryFlagsString(VkMemoryPropertyFlags flags) { ++ char buffer[16]; ++ snprintf(buffer, sizeof(buffer), "0x%x(", (unsigned) flags); ++ std::string out = buffer; ++ if (flags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) out += "DEVICE_LOCAL|"; ++ if (flags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) out += "HOST_VISIBLE|"; ++ if (flags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) out += "HOST_COHERENT|"; ++ if (flags & VK_MEMORY_PROPERTY_HOST_CACHED_BIT) out += "HOST_CACHED|"; ++ if (flags & VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT) out += "LAZILY_ALLOCATED|"; ++ if (flags & VK_MEMORY_PROPERTY_PROTECTED_BIT) out += "PROTECTED|"; ++ if (flags & VK_MEMORY_PROPERTY_DEVICE_COHERENT_BIT_AMD) out += "DEVICE_COHERENT_AMD|"; ++ if (flags & VK_MEMORY_PROPERTY_DEVICE_UNCACHED_BIT_AMD) out += "DEVICE_UNCACHED_AMD|"; ++ if (out.back() == '|') out.back() = ')'; ++ else out += "none)"; ++ return out; ++} ++ ++// Vulkan orders memory types so a flag-less type (system RAM, which NVIDIA ++// exposes) precedes every DEVICE_LOCAL type, and first fit put framebufferOnly ++// drawables there. Both images are only touched by the GPU then -- the internal ++// one is imported by the presenting OpenGL context, which runs on the same GPU ++// (see reportGpuDeviceMatch) -- so they take the first allowed DEVICE_LOCAL type, ++// falling back to first fit. Without framebufferOnly they still need host-visible, ++// coherent memory and keep first fit. On RADV type 0 is DEVICE_LOCAL: unchanged. ++static size_t findSharedMemory(const VkMemoryRequirements& reqs, std::shared_ptr device, bool framebufferOnly, bool internal) { ++ const auto& memory = device->memoryProperties(); ++ size_t firstFit = SIZE_MAX; + size_t targetIndex = SIZE_MAX; + +- for (size_t i = 0; i < device->memoryProperties().memoryTypeCount; ++i) { +- const auto& type = device->memoryProperties().memoryTypes[i]; ++ for (size_t i = 0; i < memory.memoryTypeCount; ++i) { ++ const auto& type = memory.memoryTypes[i]; + +- if ((reqs.memoryTypeBits & (1 << i)) == 0) { ++ if ((reqs.memoryTypeBits & (1u << i)) == 0) { + continue; + } + +@@ -147,9 +270,42 @@ static size_t findSharedMemory(const VkMemoryRequirements& reqs, std::shared_ptr + continue; + } + +- // okay, this is good enough +- targetIndex = i; +- break; ++ if (firstFit == SIZE_MAX) { ++ firstFit = i; ++ } ++ ++ if (!framebufferOnly || (type.propertyFlags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) != 0) { ++ targetIndex = i; ++ break; ++ } ++ } ++ ++ if (targetIndex == SIZE_MAX) { ++ targetIndex = firstFit; ++ } ++ if (targetIndex == SIZE_MAX) { ++ return targetIndex; ++ } ++ ++ // Once per (image, framebufferOnly, type) in the process, in Indium's device log. ++ static std::atomic logged[2] {}; ++ const size_t bit = ((internal ? 2u : 0u) + (framebufferOnly ? 1u : 0u)) * 32 + targetIndex; ++ const uint64_t mask = uint64_t(1) << (bit & 63); ++ if ((logged[bit >> 6].load(std::memory_order_relaxed) & mask) == 0 && (logged[bit >> 6].fetch_or(mask) & mask) == 0) { ++ auto describe = [&](size_t index) { ++ const auto& type = memory.memoryTypes[index]; ++ return std::to_string(index) + " flags=" + memoryFlagsString(type.propertyFlags) ++ + " heap=" + std::to_string(type.heapIndex) ++ + " heapDeviceLocal=" + ((memory.memoryHeaps[type.heapIndex].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) ? "yes" : "no"); ++ }; ++ char typeBits[16]; ++ snprintf(typeBits, sizeof(typeBits), "0x%08x", (unsigned) reqs.memoryTypeBits); ++ appendDeviceLog(std::string("memory alloc kind=") + (internal ? "drawable-internal" : "drawable") ++ + " storage=" + (framebufferOnly ? "framebufferOnly" : "hostVisible") ++ + " typeBits=" + typeBits ++ + " type=" + describe(targetIndex) ++ + " firstFit=" + describe(firstFit) ++ + (targetIndex != firstFit ? " changed=yes" : " changed=no")); + } + + return targetIndex; +@@ -161,6 +317,11 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat _pixelFormat(pixelFormat), _framebufferOnly(framebufferOnly) { @@ -7220,18 +7323,93 @@ index d6f86b31..4d08ce26 100644 // // create the images // -@@ -401,6 +490,10 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat +@@ -219,7 +380,7 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat + + DynamicVK::vkGetImageMemoryRequirements(_device->device(), _image, &reqs); + +- size_t targetIndex = findSharedMemory(reqs, _device, _framebufferOnly); ++ size_t targetIndex = findSharedMemory(reqs, _device, _framebufferOnly, false); + + if (targetIndex == SIZE_MAX) { + throw std::runtime_error("No suitable memory region found for image"); +@@ -242,7 +403,7 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat + + DynamicVK::vkGetImageMemoryRequirements(_device->device(), _internalImage, &reqs); + +- targetIndex = findSharedMemory(reqs, _device, _framebufferOnly); ++ targetIndex = findSharedMemory(reqs, _device, _framebufferOnly, true); + + if (targetIndex == SIZE_MAX) { + throw std::runtime_error("No suitable memory region found for internal image"); +@@ -380,16 +541,26 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat + abort(); + } + +- reportGLErrors(); ++ // reportGLErrors() is compiled out, so a failed import used to be silent (a black ++ // window). Clear errors left by earlier calls; the import is checked below. ++ drainGLErrors(); + + // import the memory into OpenGL + glCreateMemoryObjectsEXT(1, &_memoryObject); + reportGLErrors(); + ++ // _internalMemory is a dedicated allocation (VkMemoryDedicatedAllocateInfo above), ++ // and EXT_external_objects requires DEDICATED_MEMORY_OBJECT_EXT to be TRUE when the ++ // memory was created as one. Mesa ignores the flag; NVIDIA enforces it. It has to be ++ // set before the import, after which the memory object is immutable. ++ GLint dedicated = GL_TRUE; ++ glMemoryObjectParameterivEXT(_memoryObject, GL_DEDICATED_MEMORY_OBJECT_EXT, &dedicated); ++ const GLenum dedicatedError = glGetError(); ++ + // this transfers ownership of the FD to OpenGL + glImportMemoryFdEXT(_memoryObject, reqs.size, GL_HANDLE_TYPE_OPAQUE_FD_EXT, fd); + fd = -1; +- reportGLErrors(); ++ const GLenum importError = glGetError(); + + GLint prevTex = 0; + glGetIntegerv(GL_TEXTURE_BINDING_2D, &prevTex); +@@ -401,10 +572,36 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat glTextureParameteri(_textureID, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTextureParameteri(_textureID, GL_TEXTURE_WRAP_S, GL_REPEAT); glTextureParameteri(_textureID, GL_TEXTURE_WRAP_T, GL_REPEAT); +- reportGLErrors(); + // A Metal layer is opaque: macOS ignores the drawable's alpha channel. The game's blended effects lower destination + // alpha as a side effect, and the layer compositor (CARenderer: blending plus an alpha test against 0, over a + // cleared-to-black window) then showed those regions as black quads. Always present alpha = 1. + glTextureParameteri(_textureID, GL_TEXTURE_SWIZZLE_A, GL_ONE); - reportGLErrors(); ++ const GLenum setupError = glGetError(); glTextureStorageMem2DEXT(_textureID, 1, GL_RGBA8, _size.width, _size.height, _memoryObject, 0); -@@ -605,35 +698,16 @@ void CAMetalDrawableActual::present() { +- reportGLErrors(); ++ const GLenum storageError = glGetError(); ++ ++ // The first import in the process is always logged, so a working run on a new ++ // driver shows up too; after that only failed imports, at most five. ++ static std::atomic importCount {0}; ++ static std::atomic importFailures {0}; ++ const bool failed = dedicatedError != GL_NO_ERROR || importError != GL_NO_ERROR || setupError != GL_NO_ERROR || storageError != GL_NO_ERROR; ++ const bool first = importCount.fetch_add(1, std::memory_order_relaxed) == 0; ++ if (first || (failed && importFailures.fetch_add(1, std::memory_order_relaxed) < 5)) { ++ GLint dedicatedReadBack = -1; ++ GLint tiling = 0; ++ glGetMemoryObjectParameterivEXT(_memoryObject, GL_DEDICATED_MEMORY_OBJECT_EXT, &dedicatedReadBack); ++ glGetTextureParameteriv(_textureID, GL_TEXTURE_TILING_EXT, &tiling); ++ const GLenum queryError = glGetError(); ++ char line[512]; ++ snprintf(line, sizeof(line), "drawable GL import%s dedicated=%s size=%llu %dx%d memoryType=%zu tiling=linear glTiling=%s" ++ " dedicated_error=0x%x import_error=0x%x setup_error=0x%x storage_error=0x%x query_error=0x%x%s", ++ first ? " (first)" : "", dedicatedReadBack == GL_TRUE ? "yes" : (dedicatedReadBack == -1 ? "unknown" : "no"), ++ (unsigned long long) reqs.size, (int) _size.width, (int) _size.height, targetIndex, ++ tiling == GL_LINEAR_TILING_EXT ? "linear" : (tiling == GL_OPTIMAL_TILING_EXT ? "optimal" : "unknown"), ++ dedicatedError, importError, setupError, storageError, queryError, failed ? " FAILED" : " ok"); ++ appendDeviceLog(line); ++ } + + glBindTexture(GL_TEXTURE_2D, prevTex); + }; +@@ -605,35 +802,16 @@ void CAMetalDrawableActual::present() { return; } @@ -7242,16 +7420,16 @@ index d6f86b31..4d08ce26 100644 - if (_semaphore) { - int fd = -1; - VkSemaphoreGetFdInfoKHR info {}; +- +- info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_GET_FD_INFO_KHR; +- info.semaphore = _semaphore->semaphore; +- info.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT; + // present() may run on the deferred encoding worker. The window's + // EGL context remains current on the main thread, so attempting to + // import a semaphore here fails and leaks the exported syncobj FD. + // Import it in synchronizeRender(), with the compositor context + // current, immediately before the GPU-side wait. -- info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_GET_FD_INFO_KHR; -- info.semaphore = _semaphore->semaphore; -- info.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT; -- - if (DynamicVK::vkGetSemaphoreFdKHR(_semaphore->device->device(), &info, &fd) != VK_SUCCESS) { - // TODO - abort(); @@ -7273,7 +7451,7 @@ index d6f86b31..4d08ce26 100644 } } -@@ -702,9 +776,24 @@ void CAMetalDrawableActual::reset() { +@@ -702,9 +880,46 @@ void CAMetalDrawableActual::reset() { }; void CAMetalDrawableActual::synchronizeRender() { @@ -7281,6 +7459,11 @@ index d6f86b31..4d08ce26 100644 - GLenum layout = GL_LAYOUT_GENERAL_EXT; - glWaitSemaphoreEXT(_glSemaphore, 0, NULL, 1, &tex, &layout); + if (!_semaphore) return; ++ // GL errors of the import and wait are checked for the first imports of the ++ // process only: glGetError can stall a threaded GL driver, and this runs every frame. ++ static std::atomic checkedImports {0}; ++ bool check = false; ++ GLenum importError = GL_NO_ERROR; + if (_glSemaphore == 0) { + // prepareRender() has made the layer's GL context current on this + // thread. Never try to steal it from the submission worker. @@ -7292,12 +7475,29 @@ index d6f86b31..4d08ce26 100644 + info.semaphore = _semaphore->semaphore; + info.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT; + if (DynamicVK::vkGetSemaphoreFdKHR(_semaphore->device->device(), &info, &fd) != VK_SUCCESS) abort(); ++ check = checkedImports.fetch_add(1, std::memory_order_relaxed) < 600; ++ if (check) drainGLErrors(); + glImportSemaphoreFdEXT(_glSemaphore, GL_HANDLE_TYPE_OPAQUE_FD_EXT, fd); + // A successful import transfers the native FD to the GL driver. ++ if (check) importError = glGetError(); + } + GLuint tex = _texture->glTexture(); + GLenum layout = GL_LAYOUT_GENERAL_EXT; + glWaitSemaphoreEXT(_glSemaphore, 0, NULL, 1, &tex, &layout); ++ if (check) { ++ // One line on first use, then only failures (at most five). ++ const GLenum waitError = glGetError(); ++ const bool failed = importError != GL_NO_ERROR || waitError != GL_NO_ERROR; ++ static std::atomic loggedFirst {false}; ++ static std::atomic failures {0}; ++ const bool first = !loggedFirst.exchange(true, std::memory_order_relaxed); ++ if (first || (failed && failures.fetch_add(1, std::memory_order_relaxed) < 5)) { ++ char line[192]; ++ snprintf(line, sizeof(line), "drawable GL semaphore%s import_error=0x%x wait_error=0x%x%s (checked for the first 600 imports)", ++ first ? " (first)" : "", importError, waitError, failed ? " FAILED" : " ok"); ++ appendDeviceLog(line); ++ } ++ } }; static void glDebugCallback(GLenum source, GLenum type, GLuint id, GLenum severity, GLsizei length, const GLchar* message, const void* context) { diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index 7d92c48..dcfff97 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -781,7 +781,7 @@ index 8546daa..7e4993c 100644 void updateBindings(); diff --git a/private-include/indium/device.private.hpp b/private-include/indium/device.private.hpp -index 58f1794..75cdeb3 100644 +index 58f1794..be7dbdc 100644 --- a/private-include/indium/device.private.hpp +++ b/private-include/indium/device.private.hpp @@ -13,6 +13,8 @@ @@ -836,7 +836,7 @@ index 58f1794..75cdeb3 100644 std::shared_ptr getWrappedTimelineSemaphore(); BinarySemaphore getBinarySemaphore(bool exportable = false); -@@ -111,5 +128,31 @@ namespace Indium { +@@ -111,5 +128,34 @@ namespace Indium { INDIUM_PROPERTY(VkPhysicalDeviceMemoryProperties, m, M,emoryProperties); INDIUM_PROPERTY_READONLY(Feature, f, F,eatures); @@ -859,6 +859,9 @@ index 58f1794..75cdeb3 100644 + void releaseDescriptorPool(VkDescriptorPool pool); + // Cached handles belong to the device and remain alive through all GPU uses. + std::pair acquireRenderPass(const VkRenderPassCreateInfo& info); ++ // Memory type index for a buffer or image of the given storage mode, or SIZE_MAX. Non-virtual and without ++ // state in the class, so the layout above is unchanged. ++ size_t findMemoryType(uint32_t memoryTypeBits, StorageMode storageMode, bool image); + private: + std::mutex _descriptorPoolMutex; + std::vector _availableDescriptorPools; @@ -1789,7 +1792,7 @@ index e58f0ba..616d177 100644 + finishTrackedEncoder(_privateCommandBuffer.lock()->commandBuffer()); }; diff --git a/src/indium/buffer.cpp b/src/indium/buffer.cpp -index 23fd2d3..e73df2a 100644 +index 23fd2d3..1a48b81 100644 --- a/src/indium/buffer.cpp +++ b/src/indium/buffer.cpp @@ -1,6 +1,8 @@ @@ -1801,7 +1804,34 @@ index 23fd2d3..e73df2a 100644 #include -@@ -69,23 +71,11 @@ Indium::PrivateBuffer::PrivateBuffer(std::shared_ptr device, size +@@ -43,49 +45,17 @@ Indium::PrivateBuffer::PrivateBuffer(std::shared_ptr device, size + + DynamicVK::vkGetBufferMemoryRequirements(_privateDevice->device(), _buffer, &requirements); + +- size_t targetIndex = SIZE_MAX; +- +- for (size_t i = 0; i < _privateDevice->memoryProperties().memoryTypeCount; ++i) { +- const auto& type = _privateDevice->memoryProperties().memoryTypes[i]; +- +- if ((requirements.memoryTypeBits & (1 << i)) == 0) { +- continue; +- } +- +- if ((_storageMode == StorageMode::Managed || _storageMode == StorageMode::Shared) && (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) == 0) { +- continue; +- } +- +- if (_storageMode == StorageMode::Shared && (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0) { +- continue; +- } +- +- // okay, this is good enough +- targetIndex = i; +- break; +- } ++ size_t targetIndex = _privateDevice->findMemoryType(requirements.memoryTypeBits, _storageMode, false); + + if (targetIndex == SIZE_MAX) { throw std::runtime_error("No suitable memory region found for buffer with requested storage mode"); } @@ -1829,7 +1859,7 @@ index 23fd2d3..e73df2a 100644 }; Indium::PrivateBuffer::PrivateBuffer(std::shared_ptr device, const void* pointer, size_t length, ResourceOptions options): -@@ -103,9 +93,11 @@ Indium::PrivateBuffer::PrivateBuffer(std::shared_ptr device, cons +@@ -103,9 +73,11 @@ Indium::PrivateBuffer::PrivateBuffer(std::shared_ptr device, cons if (_storageMode == StorageMode::Managed) { VkMappedMemoryRange range {}; range.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE; @@ -1843,7 +1873,7 @@ index 23fd2d3..e73df2a 100644 if (DynamicVK::vkFlushMappedMemoryRanges(_privateDevice->device(), 1, &range) != VK_SUCCESS) { // TODO abort(); -@@ -114,12 +106,10 @@ Indium::PrivateBuffer::PrivateBuffer(std::shared_ptr device, cons +@@ -114,12 +86,10 @@ Indium::PrivateBuffer::PrivateBuffer(std::shared_ptr device, cons }; Indium::PrivateBuffer::~PrivateBuffer() { @@ -1859,7 +1889,7 @@ index 23fd2d3..e73df2a 100644 }; std::shared_ptr Indium::PrivateBuffer::device() { -@@ -136,7 +126,8 @@ void* Indium::PrivateBuffer::contents() { +@@ -136,7 +106,8 @@ void* Indium::PrivateBuffer::contents() { } if (!_mapped) { @@ -1869,7 +1899,7 @@ index 23fd2d3..e73df2a 100644 // TODO abort(); } -@@ -148,9 +139,16 @@ void* Indium::PrivateBuffer::contents() { +@@ -148,9 +119,16 @@ void* Indium::PrivateBuffer::contents() { void Indium::PrivateBuffer::didModifyRange(Range range) { VkMappedMemoryRange vulkanRange {}; vulkanRange.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE; @@ -1888,7 +1918,7 @@ index 23fd2d3..e73df2a 100644 if (DynamicVK::vkFlushMappedMemoryRanges(_privateDevice->device(), 1, &vulkanRange) != VK_SUCCESS) { // TODO abort(); -@@ -158,8 +156,10 @@ void Indium::PrivateBuffer::didModifyRange(Range range) { +@@ -158,8 +136,10 @@ void Indium::PrivateBuffer::didModifyRange(Range range) { }; uint64_t Indium::PrivateBuffer::gpuAddress() { @@ -2803,7 +2833,7 @@ index 5f62ad0..5957bec 100644 DynamicVK::vkCmdBindDescriptorSets(buf->commandBuffer(), VK_PIPELINE_BIND_POINT_COMPUTE, _pso->layout(), 0, descriptorSets.size(), descriptorSets.data(), 0, nullptr); }; diff --git a/src/indium/device.cpp b/src/indium/device.cpp -index 322e186..2003684 100644 +index 322e186..78b77ae 100644 --- a/src/indium/device.cpp +++ b/src/indium/device.cpp @@ -1,4 +1,6 @@ @@ -2813,7 +2843,7 @@ index 322e186..2003684 100644 #include #include #include -@@ -12,39 +14,167 @@ +@@ -12,39 +14,207 @@ #include @@ -2875,6 +2905,46 @@ index 322e186..2003684 100644 + } + } + ++ std::string memoryFlagsString(VkMemoryPropertyFlags flags) { ++ static const std::pair names[] = { ++ { VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, "DEVICE_LOCAL" }, ++ { VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, "HOST_VISIBLE" }, ++ { VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, "HOST_COHERENT" }, ++ { VK_MEMORY_PROPERTY_HOST_CACHED_BIT, "HOST_CACHED" }, ++ { VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT, "LAZILY_ALLOCATED" }, ++ { VK_MEMORY_PROPERTY_PROTECTED_BIT, "PROTECTED" }, ++ { VK_MEMORY_PROPERTY_DEVICE_COHERENT_BIT_AMD, "DEVICE_COHERENT_AMD" }, ++ { VK_MEMORY_PROPERTY_DEVICE_UNCACHED_BIT_AMD, "DEVICE_UNCACHED_AMD" }, ++ }; ++ std::string out = "0x" + hexValue(flags, 1) + "("; ++ bool first = true; ++ VkMemoryPropertyFlags rest = flags; ++ for (const auto& name: names) { ++ if ((flags & name.first) == 0) continue; ++ out += (first ? "" : "|") + std::string(name.second); ++ first = false; ++ rest &= ~name.first; ++ } ++ if (rest) out += (first ? "0x" : "|0x") + hexValue(rest, 1); ++ else if (first) out += "none"; ++ return out + ")"; ++ } ++ ++ // The memory types and heaps of the device Metal renders on, logged once when it ++ // is selected, so the per-allocation lines below can be read without vulkaninfo. ++ void appendMemoryTable(const VkPhysicalDeviceMemoryProperties& memory) { ++ for (uint32_t i = 0; i < memory.memoryHeapCount; ++i) { ++ const auto& heap = memory.memoryHeaps[i]; ++ appendDeviceLog("memory heap=" + std::to_string(i) + " size=" + std::to_string(heap.size >> 20) + "MiB" ++ + " deviceLocal=" + ((heap.flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) ? "yes" : "no")); ++ } ++ for (uint32_t i = 0; i < memory.memoryTypeCount; ++i) { ++ const auto& type = memory.memoryTypes[i]; ++ appendDeviceLog("memory type=" + std::to_string(i) + " flags=" + memoryFlagsString(type.propertyFlags) ++ + " heap=" + std::to_string(type.heapIndex)); ++ } ++ } ++ + struct GpuSelector { + std::string value; + std::string source; // empty when the loader's default order should decide @@ -2987,7 +3057,7 @@ index 322e186..2003684 100644 continue; } -@@ -67,12 +197,101 @@ void Indium::initGlobalDeviceList() { +@@ -67,12 +237,105 @@ void Indium::initGlobalDeviceList() { DynamicVK::vkGetPhysicalDeviceFeatures2(device, &features); if (!features12.timelineSemaphore) { @@ -3004,7 +3074,7 @@ index 322e186..2003684 100644 + memcpy(candidate.deviceUUID, idProperties.deviceUUID, 16); + memcpy(candidate.driverUUID, idProperties.driverUUID, 16); + candidates.push_back(candidate); - } ++ } + + // Resolve the explicit selection against the supported list. An explicit choice + // is honoured even when it names a software (CPU) device, because the user asked @@ -3061,6 +3131,10 @@ index 322e186..2003684 100644 + + " deviceUUID=" + uuidToString(selected.deviceUUID) + + " reason=" + (selectedReason.empty() ? std::string("loader-first") : selectedReason)); + ++ VkPhysicalDeviceMemoryProperties selectedMemory {}; ++ DynamicVK::vkGetPhysicalDeviceMemoryProperties(selected.device, &selectedMemory); ++ appendMemoryTable(selectedMemory); ++ + // A VkDevice is created for the selected device only: its driver is initialized + // and, on a hybrid laptop, the discrete GPU is not woken for a device the + // rendering GPU cannot share memory with anyway. The old behaviour -- a VkDevice @@ -3083,7 +3157,7 @@ index 322e186..2003684 100644 + + for (size_t index: creationOrder) { + globalDeviceList.push_back(std::make_shared(candidates[index].device)); -+ } + } + + appendDeviceLog("created VkDevice for " + std::to_string(globalDeviceList.size()) + " of " + + std::to_string(candidates.size()) + " supported device(s)" @@ -3091,7 +3165,7 @@ index 322e186..2003684 100644 }; void Indium::finitGlobalDeviceList() { -@@ -83,7 +302,8 @@ Indium::Device::~Device() {}; +@@ -83,7 +346,8 @@ Indium::Device::~Device() {}; Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): _physicalDevice(physicalDevice) @@ -3101,7 +3175,7 @@ index 322e186..2003684 100644 DynamicVK::vkGetPhysicalDeviceProperties(_physicalDevice, &_properties); std::vector queueFamilies; -@@ -233,6 +453,36 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): +@@ -233,6 +497,36 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): } } @@ -3138,7 +3212,7 @@ index 322e186..2003684 100644 VkDeviceCreateInfo deviceCreateInfo {}; deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; deviceCreateInfo.pQueueCreateInfos = queueCreateInfos.data(); -@@ -246,6 +496,7 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): +@@ -246,6 +540,7 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): } _features = indiumFeatures; @@ -3146,7 +3220,7 @@ index 322e186..2003684 100644 for (const auto& index: queueFamilyIndices) { VkQueue queue; -@@ -297,6 +548,9 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): +@@ -297,6 +592,9 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): }; Indium::PrivateDevice::~PrivateDevice() { @@ -3156,7 +3230,7 @@ index 322e186..2003684 100644 if (_oneshotCommandPool) { DynamicVK::vkDestroyCommandPool(_device, _oneshotCommandPool, nullptr); } -@@ -308,6 +562,23 @@ std::string Indium::PrivateDevice::name() const { +@@ -308,6 +606,23 @@ std::string Indium::PrivateDevice::name() const { return _properties.deviceName; }; @@ -3180,7 +3254,7 @@ index 322e186..2003684 100644 std::shared_ptr Indium::PrivateDevice::newCommandQueue() { return std::make_shared(shared_from_this()); }; -@@ -317,9 +588,9 @@ std::shared_ptr Indium::PrivateDevice::newRenderPip +@@ -317,9 +632,9 @@ std::shared_ptr Indium::PrivateDevice::newRenderPip }; std::shared_ptr Indium::PrivateDevice::newComputePipelineState(const ComputePipelineDescriptor& descriptor, PipelineOption options, std::shared_ptr reflection) { @@ -3193,7 +3267,7 @@ index 322e186..2003684 100644 return std::make_shared(shared_from_this(), descriptor); }; -@@ -357,6 +628,7 @@ std::shared_ptr Indium::PrivateDevice::newLibrary(const void* d +@@ -357,6 +672,7 @@ std::shared_ptr Indium::PrivateDevice::newLibrary(const void* d break; } @@ -3201,7 +3275,7 @@ index 322e186..2003684 100644 funcInfo.bindings.insert(funcInfo.bindings.end(), info.bindings.begin(), info.bindings.end()); funcInfo.embeddedSamplers.insert(funcInfo.embeddedSamplers.end(), info.embeddedSamplers.begin(), info.embeddedSamplers.end()); } -@@ -480,9 +752,18 @@ void Indium::PrivateDevice::waitForSemaphore(VkSemaphore semaphore, uint64_t tar +@@ -480,9 +796,18 @@ void Indium::PrivateDevice::waitForSemaphore(VkSemaphore semaphore, uint64_t tar wakeupEventLoop(); }; @@ -3221,7 +3295,7 @@ index 322e186..2003684 100644 VkSemaphoreTypeCreateInfo typeInfo {}; typeInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO; typeInfo.semaphoreType = VK_SEMAPHORE_TYPE_TIMELINE; -@@ -505,7 +786,14 @@ Indium::TimelineSemaphore Indium::PrivateDevice::getTimelineSemaphore() { +@@ -505,7 +830,14 @@ Indium::TimelineSemaphore Indium::PrivateDevice::getTimelineSemaphore() { }; void Indium::PrivateDevice::putTimelineSemaphore(const TimelineSemaphore& semaphore) { @@ -3237,7 +3311,7 @@ index 322e186..2003684 100644 }; std::shared_ptr Indium::PrivateDevice::getWrappedTimelineSemaphore() { -@@ -551,3 +839,91 @@ std::shared_ptr Indium::PrivateDevice::getWrappedBinary +@@ -551,3 +883,160 @@ std::shared_ptr Indium::PrivateDevice::getWrappedBinary delete ptr; }); }; @@ -3329,6 +3403,75 @@ index 322e186..2003684 100644 + } + DynamicVK::vkDestroyDescriptorPool(_device, pool, nullptr); +} ++ ++// The Vulkan spec orders memory types so that a type whose flags are a subset of ++// another's comes first. A flag-less type (system RAM the GPU reads over PCIe, ++// which NVIDIA exposes) therefore precedes every DEVICE_LOCAL type, and the first ++// type allowed by memoryTypeBits put resources only the GPU touches in system RAM ++// there. Private and Memoryless resources now take the first allowed DEVICE_LOCAL ++// type and fall back to that first fit. Shared and Managed keep the first fit ++// that is host visible (and coherent for Shared): moving them to DEVICE_LOCAL and ++// HOST_VISIBLE BAR memory would make their CPU reads uncached. On RADV type 0 is ++// DEVICE_LOCAL, so nothing changes there. ++size_t Indium::PrivateDevice::findMemoryType(uint32_t memoryTypeBits, StorageMode storageMode, bool image) { ++ const auto& memory = _memoryProperties; ++ const bool gpuOnly = storageMode == StorageMode::Private || storageMode == StorageMode::Memoryless; ++ size_t firstFit = SIZE_MAX; ++ size_t deviceLocal = SIZE_MAX; ++ ++ for (size_t i = 0; i < memory.memoryTypeCount; ++i) { ++ const auto& type = memory.memoryTypes[i]; ++ ++ if ((memoryTypeBits & (1u << i)) == 0) { ++ continue; ++ } ++ ++ if ((storageMode == StorageMode::Managed || storageMode == StorageMode::Shared) && (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) == 0) { ++ continue; ++ } ++ ++ if (storageMode == StorageMode::Shared && (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0) { ++ continue; ++ } ++ ++ if (firstFit == SIZE_MAX) { ++ firstFit = i; ++ } ++ ++ if (!gpuOnly || (type.propertyFlags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) != 0) { ++ deviceLocal = i; ++ break; ++ } ++ } ++ ++ const size_t chosen = deviceLocal != SIZE_MAX ? deviceLocal : firstFit; ++ if (chosen == SIZE_MAX) { ++ return chosen; ++ } ++ ++ // Logged the first time each (kind, storage mode, type) occurs in the process; ++ // afterwards this is one relaxed load. 2 kinds x 4 modes x 32 types = 256 bits. ++ static std::atomic logged[4] {}; ++ const size_t bit = ((image ? 4u : 0u) + (static_cast(storageMode) & 3)) * 32 + chosen; ++ const uint64_t mask = uint64_t(1) << (bit & 63); ++ if ((logged[bit >> 6].load(std::memory_order_relaxed) & mask) == 0 && (logged[bit >> 6].fetch_or(mask) & mask) == 0) { ++ static const char* modeNames[] = { "shared", "managed", "private", "memoryless" }; ++ auto describe = [&](size_t index) { ++ const auto& type = memory.memoryTypes[index]; ++ return std::to_string(index) + " flags=" + memoryFlagsString(type.propertyFlags) ++ + " heap=" + std::to_string(type.heapIndex) ++ + " heapDeviceLocal=" + ((memory.memoryHeaps[type.heapIndex].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) ? "yes" : "no"); ++ }; ++ appendDeviceLog(std::string("memory alloc kind=") + (image ? "texture" : "buffer") ++ + " storage=" + modeNames[static_cast(storageMode) & 3] ++ + " typeBits=0x" + hexValue(memoryTypeBits, 8) ++ + " type=" + describe(chosen) ++ + " firstFit=" + describe(firstFit) ++ + (chosen != firstFit ? " changed=yes" : " changed=no")); ++ } ++ ++ return chosen; ++}; diff --git a/src/indium/dynamic-vk.cpp b/src/indium/dynamic-vk.cpp index a4677c3..69ef23f 100644 --- a/src/indium/dynamic-vk.cpp @@ -4499,7 +4642,7 @@ index 71392df..d82e5f0 100644 info.unnormalizedCoordinates = descriptor.normalizedCoordinates ? VK_FALSE : VK_TRUE; diff --git a/src/indium/texture.cpp b/src/indium/texture.cpp -index 1e3e971..4db87d4 100644 +index 1e3e971..cbdd7ff 100644 --- a/src/indium/texture.cpp +++ b/src/indium/texture.cpp @@ -1,8 +1,90 @@ @@ -4706,7 +4849,34 @@ index 1e3e971..4db87d4 100644 info.sharingMode = VK_SHARING_MODE_EXCLUSIVE; // TODO: check if this should be "concurrent" instead. i think we're good, though. info.queueFamilyIndexCount = 0; info.pQueueFamilyIndices = nullptr; -@@ -373,19 +495,18 @@ Indium::ConcreteTexture::ConcreteTexture(std::shared_ptr device, +@@ -347,45 +469,24 @@ Indium::ConcreteTexture::ConcreteTexture(std::shared_ptr device, + + DynamicVK::vkGetImageMemoryRequirements(_device->device(), _image, &reqs); + +- size_t targetIndex = SIZE_MAX; +- +- for (size_t i = 0; i < _device->memoryProperties().memoryTypeCount; ++i) { +- const auto& type = _device->memoryProperties().memoryTypes[i]; +- +- if ((reqs.memoryTypeBits & (1 << i)) == 0) { +- continue; +- } +- +- if ((_storageMode == StorageMode::Managed || _storageMode == StorageMode::Shared) && (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) == 0) { +- continue; +- } +- +- if (_storageMode == StorageMode::Shared && (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0) { +- continue; +- } +- +- // okay, this is good enough +- targetIndex = i; +- break; +- } ++ size_t targetIndex = _device->findMemoryType(reqs.memoryTypeBits, _storageMode, true); + + if (targetIndex == SIZE_MAX) { throw std::runtime_error("No suitable memory region found for image with requested storage mode"); } @@ -4714,15 +4884,15 @@ index 1e3e971..4db87d4 100644 - allocateInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; - allocateInfo.allocationSize = reqs.size; - allocateInfo.memoryTypeIndex = targetIndex; -- -- if (DynamicVK::vkAllocateMemory(_device->device(), &allocateInfo, nullptr, &_memory) != VK_SUCCESS) { -- // TODO -- abort(); -- } + Indium::count("textureAlloc", reqs.size); + auto allocation = MemoryPool::allocate(_device->device(), reinterpret_cast(_image), targetIndex, reqs.size, reqs.alignment, true, false); + _memory = allocation.memory; +- if (DynamicVK::vkAllocateMemory(_device->device(), &allocateInfo, nullptr, &_memory) != VK_SUCCESS) { +- // TODO +- abort(); +- } +- - DynamicVK::vkBindImageMemory(_device->device(), _image, _memory, 0); + DynamicVK::vkBindImageMemory(_device->device(), _image, _memory, allocation.offset); @@ -4735,7 +4905,7 @@ index 1e3e971..4db87d4 100644 VkCommandBufferAllocateInfo cmdBufAllocInfo {}; cmdBufAllocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; cmdBufAllocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; -@@ -409,7 +530,7 @@ Indium::ConcreteTexture::ConcreteTexture(std::shared_ptr device, +@@ -409,7 +510,7 @@ Indium::ConcreteTexture::ConcreteTexture(std::shared_ptr device, barrier.srcAccessMask = VK_ACCESS_NONE; barrier.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT; barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; @@ -4744,7 +4914,7 @@ index 1e3e971..4db87d4 100644 barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; barrier.image = _image; -@@ -438,15 +559,13 @@ Indium::ConcreteTexture::ConcreteTexture(std::shared_ptr device, +@@ -438,15 +539,13 @@ Indium::ConcreteTexture::ConcreteTexture(std::shared_ptr device, submitInfo.commandBufferCount = 1; submitInfo.pCommandBuffers = &cmdBuf; @@ -4766,7 +4936,7 @@ index 1e3e971..4db87d4 100644 VkImageViewCreateInfo info2 {}; info2.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; -@@ -463,12 +582,24 @@ Indium::ConcreteTexture::ConcreteTexture(std::shared_ptr device, +@@ -463,12 +562,24 @@ Indium::ConcreteTexture::ConcreteTexture(std::shared_ptr device, // TODO abort(); } @@ -4792,7 +4962,7 @@ index 1e3e971..4db87d4 100644 }; Indium::TextureType Indium::ConcreteTexture::textureType() const { -@@ -537,7 +668,20 @@ VkImage Indium::ConcreteTexture::image() { +@@ -537,7 +648,20 @@ VkImage Indium::ConcreteTexture::image() { return _image; }; @@ -4813,7 +4983,7 @@ index 1e3e971..4db87d4 100644 return VK_IMAGE_LAYOUT_GENERAL; }; -@@ -546,6 +690,16 @@ void Indium::ConcreteTexture::replaceRegion(Indium::Region region, size_t mipmap +@@ -546,6 +670,16 @@ void Indium::ConcreteTexture::replaceRegion(Indium::Region region, size_t mipmap }; void Indium::ConcreteTexture::replaceRegion(Indium::Region region, size_t mipmapLevel, size_t slice, const void* bytes, size_t bytesPerRow, size_t bytesPerImage) { @@ -4830,7 +5000,7 @@ index 1e3e971..4db87d4 100644 if (_storageMode != StorageMode::Managed && _storageMode != StorageMode::Shared) { throw std::runtime_error("Invalid usage of replaceRegion on non-managed and non-shared texture"); } -@@ -556,99 +710,109 @@ void Indium::ConcreteTexture::replaceRegion(Indium::Region region, size_t mipmap +@@ -556,99 +690,109 @@ void Indium::ConcreteTexture::replaceRegion(Indium::Region region, size_t mipmap auto aspect = pixelFormatToVkImageAspectFlags(_descriptor.pixelFormat); From e1c8107b594d062bca25e1b4867b469c5b3e4756 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:07:33 +0200 Subject: [PATCH 013/160] Skip ROCm's private LLVM when configuring Iridium find_package(LLVM CONFIG) also searches /llvm/lib/cmake/llvm for every PATH entry, before the system prefixes. With /opt/rocm/bin on PATH it found ROCm's LLVM 22.0.0git, whose llvm-config reports no shared library, and 6d6159b's fallback to the llvm-config on PATH then rejected the system LLVM 23 because its major differs from the ROCm headers, so a fresh configure failed (issue #1). Before find_package, keep LLVM_DIR only if the llvm-config next to it reports a shared library; otherwise, or when LLVM_DIR is unset, use the llvm-config on PATH that does and take its --cmakedir. A working pinned LLVM_DIR is left alone. Checked with a fake ROCm prefix on PATH: plain find_package picks it; the new code picks /usr/lib/cmake/llvm (LLVM 23) on a fresh configure, replaces a cached ROCm LLVM_DIR, and keeps a working cached /usr/lib64/cmake/llvm. A standalone configure of Indium with the fake ROCm on PATH completes with the system LLVM. Co-Authored-By: Claude Opus 5.5 --- patches/metal--deps--indium.patch | 50 +++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index dcfff97..72ac69a 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -8629,10 +8629,53 @@ new file mode 100644 + } +} diff --git a/CMakeLists.txt b/CMakeLists.txt -index 8d95863..603dc3d 100644 +index 8d95863..1af3167 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt -@@ -159,9 +159,33 @@ if (NOT DARLING) +@@ -2,6 +2,41 @@ project(indium) + cmake_minimum_required(VERSION 3.20) + + find_package(Vulkan REQUIRED) ++ ++# Iridium compiles against these LLVM headers and loads the shared libLLVM of the same major at run time, so the LLVM ++# found here needs an llvm-config that reports a shared library. find_package also looks in /llvm/lib/cmake/llvm ++# for every PATH entry, so with /opt/rocm/bin on PATH it picks ROCm's private LLVM ahead of the system one, and a cached ++# LLVM_DIR can name an LLVM that was removed since. Unless LLVM_DIR names a usable LLVM, use the llvm-config on PATH. ++function(indium_llvm_config_works tool result) ++ set(${result} FALSE PARENT_SCOPE) ++ execute_process(COMMAND "${tool}" --link-shared --libnames ++ OUTPUT_VARIABLE libname RESULT_VARIABLE status ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE) ++ if (status EQUAL 0 AND libname) ++ set(${result} TRUE PARENT_SCOPE) ++ endif() ++endfunction() ++ ++set(indium_llvm_usable FALSE) ++if (LLVM_DIR) ++ # /lib/cmake/llvm or /lib64/cmake/llvm ++ get_filename_component(indium_llvm_prefix "${LLVM_DIR}/../../.." ABSOLUTE) ++ indium_llvm_config_works("${indium_llvm_prefix}/bin/llvm-config" indium_llvm_usable) ++endif() ++if (NOT indium_llvm_usable) ++ find_program(INDIUM_PATH_LLVM_CONFIG llvm-config) ++ if (INDIUM_PATH_LLVM_CONFIG) ++ indium_llvm_config_works("${INDIUM_PATH_LLVM_CONFIG}" indium_path_llvm_usable) ++ execute_process(COMMAND "${INDIUM_PATH_LLVM_CONFIG}" --cmakedir ++ OUTPUT_VARIABLE indium_path_llvm_dir RESULT_VARIABLE indium_status ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE) ++ if (indium_path_llvm_usable AND indium_status EQUAL 0 AND EXISTS "${indium_path_llvm_dir}") ++ if (LLVM_DIR) ++ message(STATUS "LLVM_DIR=${LLVM_DIR} has no llvm-config with a shared library; using ${indium_path_llvm_dir}") ++ endif() ++ set(LLVM_DIR "${indium_path_llvm_dir}" CACHE PATH "The directory containing a CMake configuration file for LLVM." FORCE) ++ endif() ++ endif() ++endif() ++ + find_package(LLVM REQUIRED CONFIG) + + if (DARLING) +@@ -158,11 +193,35 @@ if (NOT DARLING) + ) endif() -execute_process( @@ -8667,10 +8710,11 @@ index 8d95863..603dc3d 100644 +endforeach() +if (NOT HOST_LLVM_LIBNAME) + message(FATAL_ERROR "No working llvm-config for LLVM ${LLVM_VERSION} (LLVM_DIR=${LLVM_DIR}). " -+ "Remove LLVM_DIR from the CMake cache or install the matching LLVM.") ++ "Install an LLVM with a shared libLLVM, or pass -DLLVM_DIR=.") +endif() target_compile_definitions(iridium PRIVATE + "HOST_LLVM_LIBNAME=\"${HOST_LLVM_LIBNAME}\"" diff --git a/src/iridium/dynamic-llvm.cpp b/src/iridium/dynamic-llvm.cpp index 1b5876e..03af560 100644 --- a/src/iridium/dynamic-llvm.cpp From 197b794111cf6a4d897f8ef6bef76840de36cff9 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:17:51 +0200 Subject: [PATCH 014/160] Allocate League's Metal drawables in VRAM League sets framebufferOnly=NO on its CAMetalLayer, and QuartzCore read that as needing host-visible coherent memory, so the game's final render target and the linear image handed to OpenGL both lived in system RAM (type 2 on the RX 6600) and crossed PCIe every frame. framebufferOnly=NO only lets Metal sample or blit the texture: nothing maps either image (replaceRegion aborts, getBytes is a stub, Indium maps only Shared/Managed buffers). Drawables now take the first allowed DEVICE_LOCAL type whatever framebufferOnly says; the guest file /tmp/metal-no-drawable-vram restores the old placement. metal-device.log shows drawable-vram=on and storage=vram. A native EGL/RADV test exported the drawable's linear image from VRAM and imported it into GL with no errors and identical pixels. In a practice-tool baron fight on the RX 6600 with the same champion it ran about 20 FPS faster and the same when idle; not yet measured in an ARAM. Co-Authored-By: Claude Opus 5.5 --- docs/SETUP.md | 1 + docs/TROUBLESHOOTING.md | 2 +- patches/cocotron.patch | 74 +++++++++++++++++++++++++++++------------ 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index b7c07ed..432623f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -101,6 +101,7 @@ Indium also contains experimental hand-off changes. They are **off by default**: | `/tmp/metal-skip-cpu-devices` | software Vulkan devices (lavapipe) are skipped while a hardware device exists | | `/tmp/metal-gpu` | diagnostic: selects the Vulkan device used for Metal by index or case-insensitive name substring instead of the loader's first device. The OpenGL context that presents does not follow it; see [hybrid laptops](TROUBLESHOOTING.md#hybrid-graphics-laptops-amdintel-igpu--nvidia). `METAL_INDIUM_GPU` is the environment equivalent; the file wins | | `/tmp/metal-all-devices` | a `VkDevice` is created for every supported Vulkan device, as before 2026-10-16. Without it only the selected device gets one; on a machine with lavapipe installed this also drops lavapipe's idle rasterizer threads | +| `/tmp/metal-no-drawable-vram` | drawables of a layer with `framebufferOnly` off (League sets it off) go back to host-visible system RAM, as before 2026-10-16. Without the file they are allocated in GPU memory like `framebufferOnly` drawables; nothing maps drawable memory from the CPU. On the RX 6600 this gave about 20 FPS more in a practice-tool fight and the same FPS when idle, not yet measured in an ARAM. `/tmp/metal-device.log` shows `drawable-vram=on` and `storage=vram` | | `/tmp/metal-debug-waits` | diagnostic: counts how often and how long threads block at the hand-off waits and in `nextDrawable`, every 5 s in `/tmp/metal-trace.log`; the first check writes a `waits enabled` line | Create these files from inside the guest, for example `darling shell touch /tmp/metal-debug-waits`, or while Darling is shut down. The guest `/tmp` is `~/.darling/private/tmp` seen through Darling's overlay mount. A file created on the host in that directory while Darling runs may stay invisible to guest processes that already looked for it. The same applies to removing a file. The older `/tmp/metal-no-present-dispatch-wait`, `/tmp/metal-no-deferred-blit` and `/tmp/metal-no-skip-cpu-devices` files are ignored. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 9726626..8e16f5e 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -193,7 +193,7 @@ The log also lists the selected device's memory heaps and types, and one line th [MetalDevice] pid=… memory alloc kind=texture storage=private typeBits=0x… type=1 flags=0x1(DEVICE_LOCAL) heap=0 heapDeviceLocal=yes firstFit=0 flags=0x0(none) heap=1 heapDeviceLocal=no changed=yes ``` -Resources only the GPU uses (`private`, `memoryless`, and `framebufferOnly` drawables) take the first allowed `DEVICE_LOCAL` type. Before 2026-10-16 they took the first allowed type (`firstFit`), which on NVIDIA can be system RAM; `changed=yes` marks where the two differ. `shared` and `managed` resources keep the first host-visible type. On the RX 6600 (RADV) type 0 is `DEVICE_LOCAL`, so nothing changes there. +Resources only the GPU uses (`private`, `memoryless`, and drawables, including League's `framebufferOnly`-off ones unless `/tmp/metal-no-drawable-vram` exists) take the first allowed `DEVICE_LOCAL` type. Before 2026-10-16 they took the first allowed type (`firstFit`), which on NVIDIA can be system RAM; `changed=yes` marks where the two differ. `shared` and `managed` resources keep the first host-visible type. On the RX 6600 (RADV) type 0 is `DEVICE_LOCAL`, so nothing changes there. The first import of a drawable into the presenting OpenGL context, and the first semaphore import, are logged too; later ones only when they fail (at most five): diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 7212cc4..b7ee171 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -7105,7 +7105,7 @@ index 5ad375f8..c8e83814 100644 +- (BOOL) _treeHasAnimations; @end diff --git a/QuartzCore/CAMetalDrawable.mm b/QuartzCore/CAMetalDrawable.mm -index d6f86b31..2bdc198d 100644 +index d6f86b31..20e4e88b 100644 --- a/QuartzCore/CAMetalDrawable.mm +++ b/QuartzCore/CAMetalDrawable.mm @@ -31,6 +31,13 @@ @@ -7221,7 +7221,7 @@ index d6f86b31..2bdc198d 100644 // // helper classes // -@@ -129,13 +226,39 @@ public: +@@ -129,27 +226,109 @@ public: // texture // @@ -7250,9 +7250,26 @@ index d6f86b31..2bdc198d 100644 +// (see reportGpuDeviceMatch) -- so they take the first allowed DEVICE_LOCAL type, +// falling back to first fit. Without framebufferOnly they still need host-visible, +// coherent memory and keep first fit. On RADV type 0 is DEVICE_LOCAL: unchanged. ++// ++// League sets framebufferOnly=NO, so its drawables lived in system RAM and crossed ++// PCIe every frame. framebufferOnly=NO lets Metal sample or blit the texture; it ++// does not make it CPU-mapped, and nothing here maps either image (replaceRegion ++// aborts, getBytes is a stub), so those drawables are placed like framebufferOnly ++// ones too. On the RX 6600 that gave about 20 FPS more in a practice-tool fight and ++// the same FPS when idle. The guest flag file /tmp/metal-no-drawable-vram restores ++// host-visible memory. Read once per process, when the first drawable is created. +static size_t findSharedMemory(const VkMemoryRequirements& reqs, std::shared_ptr device, bool framebufferOnly, bool internal) { ++ static const bool drawableVram = access("/tmp/metal-no-drawable-vram", F_OK) != 0; ++ static std::once_flag logSwitch; ++ std::call_once(logSwitch, []() { ++ appendDeviceLog(std::string("drawable-vram=") + (drawableVram ? "on" : "off (file:/tmp/metal-no-drawable-vram)")); ++ }); ++ ++ const bool vramDrawable = !framebufferOnly && drawableVram; ++ const bool gpuOnly = framebufferOnly || vramDrawable; + const auto& memory = device->memoryProperties(); + size_t firstFit = SIZE_MAX; ++ size_t legacy = SIZE_MAX; // the choice before 2026-10-16 (host-visible drawables), for the log size_t targetIndex = SIZE_MAX; - for (size_t i = 0; i < device->memoryProperties().memoryTypeCount; ++i) { @@ -7265,7 +7282,15 @@ index d6f86b31..2bdc198d 100644 continue; } -@@ -147,9 +270,42 @@ static size_t findSharedMemory(const VkMemoryRequirements& reqs, std::shared_ptr +- if (!framebufferOnly && (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) == 0) { +- continue; ++ const bool hostVisible = (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0 && (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) != 0; ++ if (legacy == SIZE_MAX && (framebufferOnly || hostVisible)) { ++ legacy = i; + } + +- if (!framebufferOnly && (type.propertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0) { ++ if (!gpuOnly && !hostVisible) { continue; } @@ -7276,9 +7301,9 @@ index d6f86b31..2bdc198d 100644 + firstFit = i; + } + -+ if (!framebufferOnly || (type.propertyFlags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) != 0) { ++ // no early exit: the scan continues to find the legacy choice for the log ++ if (targetIndex == SIZE_MAX && (!gpuOnly || (type.propertyFlags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) != 0)) { + targetIndex = i; -+ break; + } + } + @@ -7289,29 +7314,34 @@ index d6f86b31..2bdc198d 100644 + return targetIndex; + } + -+ // Once per (image, framebufferOnly, type) in the process, in Indium's device log. -+ static std::atomic logged[2] {}; -+ const size_t bit = ((internal ? 2u : 0u) + (framebufferOnly ? 1u : 0u)) * 32 + targetIndex; ++ // Once per (image, storage, type) in the process, in Indium's device log. ++ static std::atomic logged[3] {}; ++ const size_t storage = vramDrawable ? 2 : (framebufferOnly ? 1 : 0); ++ const size_t bit = ((internal ? 3u : 0u) + storage) * 32 + targetIndex; + const uint64_t mask = uint64_t(1) << (bit & 63); + if ((logged[bit >> 6].load(std::memory_order_relaxed) & mask) == 0 && (logged[bit >> 6].fetch_or(mask) & mask) == 0) { + auto describe = [&](size_t index) { ++ if (index == SIZE_MAX) { ++ return std::string("none"); ++ } + const auto& type = memory.memoryTypes[index]; + return std::to_string(index) + " flags=" + memoryFlagsString(type.propertyFlags) + + " heap=" + std::to_string(type.heapIndex) + + " heapDeviceLocal=" + ((memory.memoryHeaps[type.heapIndex].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) ? "yes" : "no"); + }; ++ static const char* storageNames[] = { "hostVisible", "framebufferOnly", "vram" }; + char typeBits[16]; + snprintf(typeBits, sizeof(typeBits), "0x%08x", (unsigned) reqs.memoryTypeBits); + appendDeviceLog(std::string("memory alloc kind=") + (internal ? "drawable-internal" : "drawable") -+ + " storage=" + (framebufferOnly ? "framebufferOnly" : "hostVisible") ++ + " storage=" + storageNames[storage] + + " typeBits=" + typeBits + + " type=" + describe(targetIndex) -+ + " firstFit=" + describe(firstFit) -+ + (targetIndex != firstFit ? " changed=yes" : " changed=no")); ++ + " firstFit=" + describe(legacy) ++ + (targetIndex != legacy ? " changed=yes" : " changed=no")); } return targetIndex; -@@ -161,6 +317,11 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat +@@ -161,6 +340,11 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat _pixelFormat(pixelFormat), _framebufferOnly(framebufferOnly) { @@ -7323,7 +7353,7 @@ index d6f86b31..2bdc198d 100644 // // create the images // -@@ -219,7 +380,7 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat +@@ -219,7 +403,7 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat DynamicVK::vkGetImageMemoryRequirements(_device->device(), _image, &reqs); @@ -7332,7 +7362,7 @@ index d6f86b31..2bdc198d 100644 if (targetIndex == SIZE_MAX) { throw std::runtime_error("No suitable memory region found for image"); -@@ -242,7 +403,7 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat +@@ -242,7 +426,7 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat DynamicVK::vkGetImageMemoryRequirements(_device->device(), _internalImage, &reqs); @@ -7341,7 +7371,7 @@ index d6f86b31..2bdc198d 100644 if (targetIndex == SIZE_MAX) { throw std::runtime_error("No suitable memory region found for internal image"); -@@ -380,16 +541,26 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat +@@ -380,16 +564,26 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat abort(); } @@ -7370,7 +7400,7 @@ index d6f86b31..2bdc198d 100644 GLint prevTex = 0; glGetIntegerv(GL_TEXTURE_BINDING_2D, &prevTex); -@@ -401,10 +572,36 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat +@@ -401,10 +595,36 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat glTextureParameteri(_textureID, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTextureParameteri(_textureID, GL_TEXTURE_WRAP_S, GL_REPEAT); glTextureParameteri(_textureID, GL_TEXTURE_WRAP_T, GL_REPEAT); @@ -7409,7 +7439,7 @@ index d6f86b31..2bdc198d 100644 glBindTexture(GL_TEXTURE_2D, prevTex); }; -@@ -605,35 +802,16 @@ void CAMetalDrawableActual::present() { +@@ -605,35 +825,16 @@ void CAMetalDrawableActual::present() { return; } @@ -7420,16 +7450,16 @@ index d6f86b31..2bdc198d 100644 - if (_semaphore) { - int fd = -1; - VkSemaphoreGetFdInfoKHR info {}; -- -- info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_GET_FD_INFO_KHR; -- info.semaphore = _semaphore->semaphore; -- info.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT; + // present() may run on the deferred encoding worker. The window's + // EGL context remains current on the main thread, so attempting to + // import a semaphore here fails and leaks the exported syncobj FD. + // Import it in synchronizeRender(), with the compositor context + // current, immediately before the GPU-side wait. +- info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_GET_FD_INFO_KHR; +- info.semaphore = _semaphore->semaphore; +- info.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT; +- - if (DynamicVK::vkGetSemaphoreFdKHR(_semaphore->device->device(), &info, &fd) != VK_SUCCESS) { - // TODO - abort(); @@ -7451,7 +7481,7 @@ index d6f86b31..2bdc198d 100644 } } -@@ -702,9 +880,46 @@ void CAMetalDrawableActual::reset() { +@@ -702,9 +903,46 @@ void CAMetalDrawableActual::reset() { }; void CAMetalDrawableActual::synchronizeRender() { From 07c3d09bcc32fbcf206ee1f3365475f9dfdd24fc Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:02:14 +0200 Subject: [PATCH 015/160] Add announcement to README Co-Authored-By: Claude Sonnet 5.5 --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index c469bd1..37a08c6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,23 @@ # darling-lol +> [!IMPORTANT] +> ## Announcement +> +> This project has gained a lot of attention, so a few things need to be said clearly. +> +> **1. Maintenance.** I built this mainly as a technical challenge, because everyone kept telling me it was impossible. I don't have the time to maintain it fully. Apart from open issues that block you completely, I will only review and merge pull requests. +> +> **2. Bans.** This project can get you permanently banned, as I have said many times. I am not responsible for your actions. You alone are responsible for launching the game in a modified environment (Darling). If you use it: +> - don't cheat; +> - don't play ranked; +> - don't use your main account. +> +> Even with all of that, you will probably still get permabanned. +> +> **3. Takedown.** Riot may well shut this project down. If that happens, I will publish a fork of Darling containing only the changes made here (none of them breach anything), and release the small script as a gist or similar. +> +> **4. To Riot.** If you are reading this, it means a lot of people want to play your game on Linux and are disappointed that they currently can't. As with macOS for your other games (TFT, Valorant), it feels like Riot doesn't care about a part of its players who have been waiting the longest. If you want to fix this, make LoL Next compatible with Linux, either natively or through a layer like Proton. Yes, that includes your kernel anticheat: Epic Games did it with Easy Anti-Cheat. Follow the trend. This is the year of Linux, and people shouldn't be locked out of a platform or forced to dual boot for a single game. + https://github.com/user-attachments/assets/140df3e0-3790-46b6-bab5-578139b84a7c Experimental source patches and tools for running the **macOS League of Legends client on x86-64 Linux through Darling**. From 67626612aca57e2beb405d827a3e63a72f906aac Mon Sep 17 00:00:00 2001 From: "Hitalo M." Date: Fri, 16 Oct 2026 00:56:24 -0300 Subject: [PATCH 016/160] Fix Riot Client crash when cancelling CFHost resolution --- README.md | 2 +- docs/TESTING.md | 16 ++++++++++++ docs/TROUBLESHOOTING.md | 14 +++++++++++ patches/cfnetwork.patch | 17 +++++++++++++ tools/build-riot-cfhost-cancel-check.py | 22 ++++++++++++++++ tools/install-riot-presentation-runtime.sh | 5 +++- tools/riot-cfhost-cancel-check.c | 29 ++++++++++++++++++++++ tools/run-riot-native-check.py | 1 + upstream/manifest.json | 8 ++++++ 9 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 patches/cfnetwork.patch create mode 100644 tools/build-riot-cfhost-cancel-check.py create mode 100644 tools/riot-cfhost-cancel-check.c diff --git a/README.md b/README.md index 37a08c6..8d06d41 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ This is a development snapshot, not a finished launcher. Riot and League clients ## Included -- Pinned upstream revisions and patches for 29 Darling components, including Metal/Indium, AppKit, QuartzCore, CoreAudio, the kernel shim and darlingserver. +- Pinned upstream revisions and patches for 30 Darling components, including Metal/Indium, AppKit, QuartzCore, CoreAudio, the kernel shim and darlingserver. - Event-poll presentation and GPU synchronization fixes, floating-point-safe GL resolvers, multi-window presentation and nullable LaunchServices error handling. - Reusable server receive buffers and Darwin allocator hints translated to Linux lazy reclamation. - Build/install/launch wrappers, atomic incremental installers and standalone regression probes. diff --git a/docs/TESTING.md b/docs/TESTING.md index 13482df..64488ed 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -394,3 +394,19 @@ Observe host CPU/thread scheduling, GPU counters, temperatures and memory pressu Compare similar scene, ability and camera activity with the same power settings (and correct charger on a laptop). Do not report a paused, dead, unfocused or empty scene as a fight benchmark. If an allowed host measurement supplies frame times, report p99 in milliseconds; its reciprocal is percentile-equivalent FPS, not the mean of the slowest 1% of frames. Manual FPS readings alone do not establish p99 frame times. The CPU symbolizer offers `--leaf-only` because the host DWARF unwinder can drop guest Mach-O stacks. Successfully unwound samples alone are not a representative profile. Local API lockfiles, account data and raw logs must stay outside version control. + +## CFHost cancellation regression + +After configuring and building CFNetwork, build and run the isolated probe: + +```sh +python3 build/runtime/tools/build-riot-cfhost-cancel-check.py +python3 build/runtime/tools/run-riot-native-check.py riot-cfhost-cancel-check +``` + +Run against the installed CFNetwork override. Expect +`CFHOST_CANCEL_CHECK cancel callback lifetime repeated pass=1`. The unpatched +library traps in `CFRetain(NULL)`; the runner checks the success marker because +Darling can return status zero even when the guest traps. The probe uses localhost +and exercises 100 cancellations with both retained and early-released caller +references, without launching or inspecting Riot. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 8e16f5e..d1bd865 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -56,6 +56,20 @@ The installer fetcher no longer treats a missing archive `Info.plist` or the inf There is also a separate known compatibility gap: native LaunchAgent watch registration can return HTTP 409 after the League install path is accepted, leaving the download idle. A historical inspector hook handles this and interrupted `pending_verify` states in 28 mock cases, but that hook is disabled in normal launches and excluded from the manual workflow. There is no verified runtime-only replacement for that handoff in this snapshot. Do not assume the native permission and launcher fixes resolve every idle Install case, or enable debugger/API automation to work around it. +## Riot closes after a network lookup is cancelled + +Two local crashes were traced to `CFNetwork::_HostCancel` calling `CFRetain(NULL)`. +`CFHostCancelInfoResolution` scheduled its cancellation callback with an empty +context, so processing that source triggered `SIGTRAP` in CoreFoundation and +terminated RiotClientServices. The UI helpers then exited as a consequence. + +The CFNetwork patch supplies the host and retains it until the cancellation source +is invalidated. The standalone `riot-cfhost-cancel-check` reproduces the old trap +and passes 100 cancellations with the fix, including releasing the caller's host +reference before callback delivery. This establishes the cancellation fix; a long +Riot session still needs confirmation. With both clients and the game closed, +run `./scripts/update.sh` to install it, then `sh scripts/run.sh`. + ## UI crashes after a button opens an external link LaunchServices dereferenced a nullable error-output pointer in six paths. The patch checks the pointer itself and populates supplied outputs; the standalone regression covers missing handlers and null outputs. Electron opens LaunchServices through an absolute CoreServices subframework path, so the paired installer also replaces that binary in the stopped guest prefix. Updating only `RuntimeFrameworks/LaunchServices.framework` can leave the crash unfixed. diff --git a/patches/cfnetwork.patch b/patches/cfnetwork.patch new file mode 100644 index 0000000..4654032 --- /dev/null +++ b/patches/cfnetwork.patch @@ -0,0 +1,17 @@ +diff --git a/src/Host/CFHost.c b/src/Host/CFHost.c +index 91c0d0f..d6e3daf 100644 +--- a/src/Host/CFHost.c ++++ b/src/Host/CFHost.c +@@ -2221,9 +2221,9 @@ CFHostCancelInfoResolution(CFHostRef theHost, CFHostInfoType info) { + + CFRunLoopSourceContext ctxt = { + 0, // version +- NULL, // info +- NULL, // retain +- NULL, // release ++ host, // info ++ CFRetain, // retain ++ CFRelease, // release + NULL, // copyDescription + NULL, // equal + NULL, // hash diff --git a/tools/build-riot-cfhost-cancel-check.py b/tools/build-riot-cfhost-cancel-check.py new file mode 100644 index 0000000..9d9e47d --- /dev/null +++ b/tools/build-riot-cfhost-cancel-check.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Build the standalone CFHost cancellation regression probe.""" +from pathlib import Path +import shlex +import subprocess + +project = Path(__file__).resolve().parent.parent +framework = project / 'build/darling-src/build/src/external/cfnetwork/src' +flags = {} +for line in (framework / 'CMakeFiles/CFNetwork.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + name, value = line.split(' = ', 1) + flags[name] = shlex.split(value) +name = 'riot-cfhost-cancel-check' +obj = project / ('build/' + name + '.o') +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-Wno-macro-redefined', '-c', str(project / ('tools/' + name + '.c')), + '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', '@DARLING_SDK@', str(obj), '-lSystem', '-framework', 'CFNetwork', + '-framework', 'CoreFoundation', '-framework', 'Foundation', + '-o', str(project / ('build/' + name))], check=True) diff --git a/tools/install-riot-presentation-runtime.sh b/tools/install-riot-presentation-runtime.sh index 0f4705c..525af97 100644 --- a/tools/install-riot-presentation-runtime.sh +++ b/tools/install-riot-presentation-runtime.sh @@ -8,6 +8,7 @@ guest_home="${RIOT_GUEST_HOME:-$HOME/.darling@GUEST_HOME@}" make -C "$build_dir/src/external/xnu/darling/src/libsystem_kernel" system_kernel -j8 make -C "$build_dir/src/external/libplatform" platform -j8 make -C "$build_dir/src/external/corefoundation" CoreFoundation_x86_64 -j8 +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 @@ -26,7 +27,7 @@ darling shutdown || true mv "$project_dir/build/libdarling-xshm.next.so" "$project_dir/build/libdarling-xshm.so" mv "$guest_home/libDarlingXShm.next.dylib" "$guest_home/libDarlingXShm.dylib" mkdir -p "$guest_home/RuntimeFrameworks" -for framework in AppKit QuartzCore CoreFoundation SystemConfiguration; do +for framework in AppKit QuartzCore CoreFoundation SystemConfiguration CFNetwork; do if [ ! -d "$guest_home/RuntimeFrameworks/$framework.framework" ]; then cp -a "@DARLING_SDK@/System/Library/Frameworks/$framework.framework" "$guest_home/RuntimeFrameworks/" fi @@ -43,6 +44,8 @@ atomic_install "$build_dir/src/external/corefoundation/CoreFoundation" "$guest_h atomic_install "$build_dir/src/external/configd/SystemConfiguration.fproj/SystemConfiguration" "$guest_home/RuntimeFrameworks/SystemConfiguration.framework/Versions/A/SystemConfiguration" +atomic_install "$build_dir/src/external/cfnetwork/src/CFNetwork" "$guest_home/RuntimeFrameworks/CFNetwork.framework/Versions/A/CFNetwork" + mkdir -p "$guest_home/RuntimeLibraries" atomic_install "$build_dir/src/external/libplatform/libsystem_platform.dylib" "$guest_home/RuntimeLibraries/libsystem_platform.dylib" atomic_install "$build_dir/src/external/xnu/darling/src/libsystem_kernel/libsystem_kernel.dylib" "$guest_home/RuntimeLibraries/libsystem_kernel.dylib" diff --git a/tools/riot-cfhost-cancel-check.c b/tools/riot-cfhost-cancel-check.c new file mode 100644 index 0000000..f74d8fc --- /dev/null +++ b/tools/riot-cfhost-cancel-check.c @@ -0,0 +1,29 @@ +#include +#include +#include + +static unsigned callbacks; +static void completed(CFHostRef host, CFHostInfoType type, + const CFStreamError *error, void *info) { + if (host && type == kCFHostAddresses && info == &callbacks) + ++callbacks; +} + +int main(void) { + CFHostClientContext context = {0, &callbacks, NULL, NULL, NULL}; + for (unsigned i = 0; i < 100; ++i) { + CFHostRef host = CFHostCreateWithName(NULL, CFSTR("localhost")); + CFStreamError error = {0}; + if (!host || !CFHostSetClient(host, completed, &context)) return 1; + CFHostScheduleWithRunLoop(host, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode); + if (!CFHostStartInfoResolution(host, kCFHostAddresses, &error)) return 2; + CFHostCancelInfoResolution(host, kCFHostAddresses); + /* The cancel source must keep its host alive until delivery. */ + if (i % 2) CFRelease(host); + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.05, false); + if (callbacks != i + 1) return 3; + if (!(i % 2)) CFRelease(host); + } + puts("CFHOST_CANCEL_CHECK cancel callback lifetime repeated pass=1"); + return 0; +} diff --git a/tools/run-riot-native-check.py b/tools/run-riot-native-check.py index 3c06200..df6fddb 100644 --- a/tools/run-riot-native-check.py +++ b/tools/run-riot-native-check.py @@ -9,6 +9,7 @@ import tempfile root = Path(__file__).resolve().parent.parent checks = { + "riot-cfhost-cancel-check": "CFHOST_CANCEL_CHECK", "riot-launchservices-null-error-check": "LAUNCHSERVICES_NULL_ERROR", "m327-cgl-resolver-check": "GL_RESOLVER", "riot-mach-exception-state-check": "MACH_EXCEPTION_STATE", diff --git a/upstream/manifest.json b/upstream/manifest.json index 63d753c..bc13a46 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -305,6 +305,14 @@ "private-include/indium/submit-thread.private.hpp", "private-include/indium/waits.private.hpp" ] + }, + { + "path": "src/external/cfnetwork", + "revision": "e7e3db881008d883f82914765a72ce842bcba735", + "repository": "https://github.com/darlinghq/darling-cfnetwork.git", + "patch": "patches/cfnetwork.patch", + "modified_files": 1, + "new_files": [] } ], "omitted": [ From 72093a1b584725ccdb358dd8b050f9d5f8682795 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:08:54 +0200 Subject: [PATCH 017/160] Make the CFHost cancel probe wait for delivery and reject completed lookups Wait up to 2 s for the cancellation callback instead of one 50 ms run-loop pass, count only callbacks without stored addresses so a completed lookup cannot pass as a cancellation, print the failing stage, and have the runner require the exact success line since Darling can exit zero after a trap. Co-Authored-By: Claude Opus 5.5 --- docs/TESTING.md | 6 ++++-- tools/riot-cfhost-cancel-check.c | 26 ++++++++++++++++++++------ tools/run-riot-native-check.py | 3 +++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 64488ed..fff7da9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -406,7 +406,9 @@ python3 build/runtime/tools/run-riot-native-check.py riot-cfhost-cancel-check Run against the installed CFNetwork override. Expect `CFHOST_CANCEL_CHECK cancel callback lifetime repeated pass=1`. The unpatched -library traps in `CFRetain(NULL)`; the runner checks the success marker because +library traps in `CFRetain(NULL)`; the runner requires the exact success line because Darling can return status zero even when the guest traps. The probe uses localhost and exercises 100 cancellations with both retained and early-released caller -references, without launching or inspecting Riot. +references, without launching or inspecting Riot. A failure prints +`CFHOST_CANCEL_CHECK fail stage=...`; `unexpected-callback` means a completed +lookup arrived instead of the cancellation. diff --git a/tools/riot-cfhost-cancel-check.c b/tools/riot-cfhost-cancel-check.c index f74d8fc..b1ff012 100644 --- a/tools/riot-cfhost-cancel-check.c +++ b/tools/riot-cfhost-cancel-check.c @@ -2,11 +2,22 @@ #include #include -static unsigned callbacks; +static unsigned callbacks, unexpected; static void completed(CFHostRef host, CFHostInfoType type, const CFStreamError *error, void *info) { - if (host && type == kCFHostAddresses && info == &callbacks) + /* A cancellation stores no addresses; a completed lookup always does. */ + Boolean resolved = false; + if (host) CFHostGetAddressing(host, &resolved); + if (host && type == kCFHostAddresses && info == &callbacks && !resolved) ++callbacks; + else + ++unexpected; +} + +static int fail(const char *stage, unsigned i) { + printf("CFHOST_CANCEL_CHECK fail stage=%s iteration=%u callbacks=%u unexpected=%u\n", + stage, i, callbacks, unexpected); + return 1; } int main(void) { @@ -14,14 +25,17 @@ int main(void) { for (unsigned i = 0; i < 100; ++i) { CFHostRef host = CFHostCreateWithName(NULL, CFSTR("localhost")); CFStreamError error = {0}; - if (!host || !CFHostSetClient(host, completed, &context)) return 1; + if (!host || !CFHostSetClient(host, completed, &context)) return fail("client", i); CFHostScheduleWithRunLoop(host, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode); - if (!CFHostStartInfoResolution(host, kCFHostAddresses, &error)) return 2; + if (!CFHostStartInfoResolution(host, kCFHostAddresses, &error)) return fail("start", i); CFHostCancelInfoResolution(host, kCFHostAddresses); /* The cancel source must keep its host alive until delivery. */ if (i % 2) CFRelease(host); - CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.05, false); - if (callbacks != i + 1) return 3; + CFAbsoluteTime deadline = CFAbsoluteTimeGetCurrent() + 2.0; + while (callbacks != i + 1 && !unexpected && CFAbsoluteTimeGetCurrent() < deadline) + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.05, true); + if (unexpected) return fail("unexpected-callback", i); + if (callbacks != i + 1) return fail("cancel-callback", i); if (!(i % 2)) CFRelease(host); } puts("CFHOST_CANCEL_CHECK cancel callback lifetime repeated pass=1"); diff --git a/tools/run-riot-native-check.py b/tools/run-riot-native-check.py index df6fddb..2eafd83 100644 --- a/tools/run-riot-native-check.py +++ b/tools/run-riot-native-check.py @@ -88,6 +88,9 @@ with tempfile.TemporaryFile() as output: if (args.check == "riot-running-application-check" and line != "RUNNING_APP found=1 activated=1 missing=1"): result = 1 + if (args.check == "riot-cfhost-cancel-check" and + line != "CFHOST_CANCEL_CHECK cancel callback lifetime repeated pass=1"): + result = 1 if (args.check == "riot-mach-exception-state-check" and line != "MACH_EXCEPTION_STATE state=1 identity=1 handled=2 server=1"): result = 1 From ac6e81aa4acf887b6c4156d4d6475aa3a3f0d863 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:17:29 +0200 Subject: [PATCH 018/160] Implement flashWindow in the X11 backend so attention requests don't abort the UX NSApplication's attention timer calls -[CGWindow flashWindow], which X11Window did not override; the base class raised an uncaught abstract-invocation exception and LeagueClientUx aborted (and restarted every ~50s). Mark the window as demanding attention through _NET_WM_STATE instead. Co-Authored-By: Claude Sonnet 5.5 --- patches/cocotron.patch | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index b7ee171..e06956b 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -3130,7 +3130,7 @@ index bfe3839a..2521ffd5 100644 - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m -index f4925b66..67b084a0 100644 +index f4925b66..5ad2d1f7 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -38,6 +38,34 @@ @@ -3692,7 +3692,33 @@ index f4925b66..67b084a0 100644 } - (void) setHasShadow: (BOOL) value { -@@ -559,7 +1028,29 @@ static NSData *makeWindowIcon() { +@@ -488,6 +957,25 @@ static NSData *makeWindowIcon() { + _level = value; + } + ++/* NSApplication's attention timer calls this for requestUserAttention:. The ++ * CGWindow base class raises an abstract-invocation exception, which aborted ++ * the whole LeagueClientUx process. Ask the window manager to mark the window ++ * as demanding attention instead. */ ++- (void) flashWindow { ++ XClientMessageEvent event = {0}; ++ event.type = ClientMessage; ++ event.window = _window; ++ event.message_type = XInternAtom(_display, "_NET_WM_STATE", False); ++ event.format = 32; ++ event.data.l[0] = 1; /* _NET_WM_STATE_ADD */ ++ event.data.l[1] = (long) XInternAtom(_display, "_NET_WM_STATE_DEMANDS_ATTENTION", False); ++ event.data.l[3] = 1; ++ XSendEvent(_display, DefaultRootWindow(_display), False, ++ SubstructureNotifyMask | SubstructureRedirectMask, ++ (XEvent *) &event); ++ XFlush(_display); ++} ++ + - (void) setOpaque: (BOOL) value { + _isOpaque = value; + } +@@ -559,7 +1047,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -3722,7 +3748,7 @@ index f4925b66..67b084a0 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -615,6 +1106,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1125,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -3735,7 +3761,7 @@ index f4925b66..67b084a0 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -666,6 +1163,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1182,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { From 68f816deaf2ddcbf5cd557b0847ab32eeb0177cb Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:16:53 +0200 Subject: [PATCH 019/160] Use optimal tiling for the drawable GL import on NVIDIA NVIDIA's GL rejects GL_LINEAR_TILING_EXT with GL_INVALID_VALUE, so the linear internal image could not be imported and the window stayed black. Pick the tiling by GL_VENDOR; other drivers keep linear. Co-Authored-By: Claude Sonnet 5.5 --- docs/TROUBLESHOOTING.md | 4 ++-- patches/cocotron.patch | 52 ++++++++++++++++++++++++++++++++--------- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index d1bd865..fdec02e 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -212,11 +212,11 @@ Resources only the GPU uses (`private`, `memoryless`, and drawables, including L The first import of a drawable into the presenting OpenGL context, and the first semaphore import, are logged too; later ones only when they fail (at most five): ``` -[MetalDevice] pid=… drawable GL import (first) dedicated=yes size=… 1920x1080 memoryType=1 tiling=linear glTiling=linear dedicated_error=0x0 import_error=0x0 setup_error=0x0 storage_error=0x0 query_error=0x0 ok +[MetalDevice] pid=… drawable GL import (first) dedicated=yes size=… 1920x1080 memoryType=1 vkTiling=linear glTiling=linear dedicated_error=0x0 import_error=0x0 setup_error=0x0 storage_error=0x0 query_error=0x0 ok [MetalDevice] pid=… drawable GL semaphore (first) import_error=0x0 wait_error=0x0 ok (checked for the first 600 imports) ``` -A non-zero error or `FAILED` means the frames cannot reach the window. Before 2026-10-16 the import did not mark the memory as dedicated, which the extension requires; Mesa ignores it, and it is the suspected cause of the NVIDIA black screen (not yet confirmed). If the game shows a black screen on NVIDIA, report it with this file. +A non-zero error or `FAILED` means the frames cannot reach the window. Before 2026-10-16 the import did not mark the memory as dedicated, which the extension requires; Mesa ignores it, and it is the suspected cause of the NVIDIA black screen (not yet confirmed). An NVIDIA log of 2026-10-16 showed `glTiling=optimal` with `setup_error=0x501 storage_error=0x501 FAILED`: NVIDIA's GL rejects `GL_LINEAR_TILING_EXT`, so the linear Vulkan image could not be imported. Since then the internal image and the GL texture use optimal tiling when `GL_VENDOR` contains NVIDIA (`vkTiling=optimal glTiling=optimal ok` is the expected line); everything else keeps linear. If the game shows a black screen on NVIDIA, report it with this file. ### Host-side checks diff --git a/patches/cocotron.patch b/patches/cocotron.patch index e06956b..04da854 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -7131,7 +7131,7 @@ index 5ad375f8..c8e83814 100644 +- (BOOL) _treeHasAnimations; @end diff --git a/QuartzCore/CAMetalDrawable.mm b/QuartzCore/CAMetalDrawable.mm -index d6f86b31..20e4e88b 100644 +index d6f86b31..687f05d5 100644 --- a/QuartzCore/CAMetalDrawable.mm +++ b/QuartzCore/CAMetalDrawable.mm @@ -31,6 +31,13 @@ @@ -7148,7 +7148,7 @@ index d6f86b31..20e4e88b 100644 namespace DynamicVK = Indium::DynamicVK; static void reportGLErrors(void) { -@@ -50,8 +57,98 @@ static void reportGLErrors(void) { +@@ -50,8 +57,111 @@ static void reportGLErrors(void) { namespace Indium::DynamicVK { static DynamicFunction vkGetSemaphoreFdKHR("vkGetSemaphoreFdKHR"); static DynamicFunction vkGetMemoryFdKHR("vkGetMemoryFdKHR"); @@ -7243,11 +7243,24 @@ index d6f86b31..20e4e88b 100644 + appendDeviceLog(line); + }); +} ++ ++// The internal image is shared with OpenGL as linear memory because of a Mesa bug with ++// optimal tiling on AMD. NVIDIA's proprietary GL rejects GL_LINEAR_TILING_EXT ++// (GL_INVALID_VALUE), leaving the texture optimal and its storage import failing, which ++// shows as a black window. Only NVIDIA's own driver takes the optimal path; the check ++// runs once, with the presenting GL context current. ++static bool interopUsesLinearTiling() { ++ static const bool linear = []() { ++ const GLubyte* vendor = glGetString(GL_VENDOR); ++ return !(vendor && strstr(reinterpret_cast(vendor), "NVIDIA")); ++ }(); ++ return linear; ++} + // // helper classes // -@@ -129,27 +226,109 @@ public: +@@ -129,27 +239,109 @@ public: // texture // @@ -7367,7 +7380,7 @@ index d6f86b31..20e4e88b 100644 } return targetIndex; -@@ -161,6 +340,11 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat +@@ -161,6 +353,11 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat _pixelFormat(pixelFormat), _framebufferOnly(framebufferOnly) { @@ -7379,7 +7392,16 @@ index d6f86b31..20e4e88b 100644 // // create the images // -@@ -219,7 +403,7 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat +@@ -195,7 +392,7 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat + // some drivers (e.g. AMD's drivers) don't play nice when sharing + // Vulkan images and OpenGL textures with optimal tiling + // (https://gitlab.freedesktop.org/mesa/mesa/-/issues/7657) +- imgInfo.tiling = VK_IMAGE_TILING_LINEAR; ++ imgInfo.tiling = interopUsesLinearTiling() ? VK_IMAGE_TILING_LINEAR : VK_IMAGE_TILING_OPTIMAL; + + VkExternalMemoryImageCreateInfo extMemInfo {}; + +@@ -219,7 +416,7 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat DynamicVK::vkGetImageMemoryRequirements(_device->device(), _image, &reqs); @@ -7388,7 +7410,7 @@ index d6f86b31..20e4e88b 100644 if (targetIndex == SIZE_MAX) { throw std::runtime_error("No suitable memory region found for image"); -@@ -242,7 +426,7 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat +@@ -242,7 +439,7 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat DynamicVK::vkGetImageMemoryRequirements(_device->device(), _internalImage, &reqs); @@ -7397,7 +7419,7 @@ index d6f86b31..20e4e88b 100644 if (targetIndex == SIZE_MAX) { throw std::runtime_error("No suitable memory region found for internal image"); -@@ -380,16 +564,26 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat +@@ -380,31 +577,70 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat abort(); } @@ -7426,7 +7448,14 @@ index d6f86b31..20e4e88b 100644 GLint prevTex = 0; glGetIntegerv(GL_TEXTURE_BINDING_2D, &prevTex); -@@ -401,10 +595,36 @@ CAMetalDrawableTexture::CAMetalDrawableTexture(CGSize size, Indium::PixelFormat + + glCreateTextures(GL_TEXTURE_2D, 1, &_textureID); + reportGLErrors(); +- glTextureParameteri(_textureID, GL_TEXTURE_TILING_EXT, GL_LINEAR_TILING_EXT /*GL_OPTIMAL_TILING_EXT*/); ++ if (interopUsesLinearTiling()) { ++ glTextureParameteri(_textureID, GL_TEXTURE_TILING_EXT, GL_LINEAR_TILING_EXT); ++ } + glTextureParameteri(_textureID, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTextureParameteri(_textureID, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTextureParameteri(_textureID, GL_TEXTURE_WRAP_S, GL_REPEAT); glTextureParameteri(_textureID, GL_TEXTURE_WRAP_T, GL_REPEAT); @@ -7454,10 +7483,11 @@ index d6f86b31..20e4e88b 100644 + glGetTextureParameteriv(_textureID, GL_TEXTURE_TILING_EXT, &tiling); + const GLenum queryError = glGetError(); + char line[512]; -+ snprintf(line, sizeof(line), "drawable GL import%s dedicated=%s size=%llu %dx%d memoryType=%zu tiling=linear glTiling=%s" ++ snprintf(line, sizeof(line), "drawable GL import%s dedicated=%s size=%llu %dx%d memoryType=%zu vkTiling=%s glTiling=%s" + " dedicated_error=0x%x import_error=0x%x setup_error=0x%x storage_error=0x%x query_error=0x%x%s", + first ? " (first)" : "", dedicatedReadBack == GL_TRUE ? "yes" : (dedicatedReadBack == -1 ? "unknown" : "no"), + (unsigned long long) reqs.size, (int) _size.width, (int) _size.height, targetIndex, ++ interopUsesLinearTiling() ? "linear" : "optimal", + tiling == GL_LINEAR_TILING_EXT ? "linear" : (tiling == GL_OPTIMAL_TILING_EXT ? "optimal" : "unknown"), + dedicatedError, importError, setupError, storageError, queryError, failed ? " FAILED" : " ok"); + appendDeviceLog(line); @@ -7465,7 +7495,7 @@ index d6f86b31..20e4e88b 100644 glBindTexture(GL_TEXTURE_2D, prevTex); }; -@@ -605,35 +825,16 @@ void CAMetalDrawableActual::present() { +@@ -605,35 +841,16 @@ void CAMetalDrawableActual::present() { return; } @@ -7507,7 +7537,7 @@ index d6f86b31..20e4e88b 100644 } } -@@ -702,9 +903,46 @@ void CAMetalDrawableActual::reset() { +@@ -702,9 +919,46 @@ void CAMetalDrawableActual::reset() { }; void CAMetalDrawableActual::synchronizeRender() { From 8092e3109b9d9f95b293076bdff0eacd6c9cfe21 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:44:11 +0200 Subject: [PATCH 020/160] Avoid blocking X11 cursor updates on a server round trip --- docs/TROUBLESHOOTING.md | 8 ++++++++ patches/cocotron.patch | 45 ++++++++++++++++++++++++----------------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index fdec02e..34d6f78 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -222,6 +222,14 @@ A non-zero error or `FAILED` means the frames cannot reach the window. Before 20 These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice. +## Mouse latency despite good FPS + +[Issue #10](https://github.com/lucannez64/darling-lol/issues/10) reports mouse latency with FPS comparable to Windows. FPS alone does not measure input-to-display latency, and the report does not yet identify which stage adds the delay. + +The X11 backend previously called `XSync` after every cursor-shape update, waiting for an X server reply on the input thread. It now uses `XFlush` to send the change immediately without waiting. This removes an avoidable round trip during cursor updates. A local manual retest on 2026-10-16 found that the mouse problem no longer occurred with VSync enabled. Input-to-display latency has not been measured, and confirmation from the original reporter is still pending. Rebuild and install with `./scripts/update.sh` after exiting the game and both clients. + +For a manual comparison, keep the scene, FPS cap, display refresh and compositor settings the same. Record whether the delay affects the cursor itself, camera movement, or actions after clicking, and compare the in-game VSync setting on and off. Include the desktop/compositor, X11 or Xwayland session, GPU/driver, monitor refresh and fullscreen/windowed mode in the report. Do not automate game input. The fullscreen cursor-shape problem below is a separate known symptom. + ## Cursor stops changing shape in fullscreen On Hyprland the game's cursor can keep its first shape (for example, it never switches to the attack cursor) until you leave and re-enter fullscreen. Darling sends every change to the X server; what freezes the shape on screen is Hyprland's direct scanout of the fullscreen Xwayland window. Turn it off in `hyprland.conf`: diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 04da854..55b5c83 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2326,7 +2326,16 @@ index 5ae29bf4..358e606b 100644 NSLog(@"missing color for %@", colorName); return [NSColor redColor]; } -@@ -921,15 +1037,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -754,4 +870,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + XDefineCursor(_display, w, c); +- XSync(_display, False); ++ /* Send cursor changes immediately without waiting for an X server ++ * reply on the input thread. X requests on this connection remain ++ * ordered; no server result is consumed here. */ ++ XFlush(_display); + } + } +@@ -921,15 +1040,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } - (NSPoint) mouseLocation { @@ -2344,7 +2353,7 @@ index 5ae29bf4..358e606b 100644 } - (void) setWindow: (id) window forID: (XID) i { -@@ -962,20 +1072,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -962,20 +1075,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { { #ifndef DARLING [[NSRunLoop currentRunLoop] addInputSource: _inputSource forMode: mode]; @@ -2432,7 +2441,7 @@ index 5ae29bf4..358e606b 100644 } - (NSEventModifierFlags) modifierFlagsForState: (unsigned int) state { -@@ -1010,17 +1182,93 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1010,17 +1185,93 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return result; } @@ -2526,7 +2535,7 @@ index 5ae29bf4..358e606b 100644 NSEventModifierFlags modifierFlags = [self modifierFlagsForState: ev->xkey.state]; char buf[20] = {0}; KeySym keySym; -@@ -1038,6 +1286,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1038,6 +1289,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { buf[strLen] = 0; } @@ -2545,7 +2554,7 @@ index 5ae29bf4..358e606b 100644 id str = [[NSString alloc] initWithCString: buf encoding: NSUTF8StringEncoding]; NSPoint pos = -@@ -1077,6 +1337,29 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1077,6 +1340,29 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ButtonPress:; @@ -2575,7 +2584,7 @@ index 5ae29bf4..358e606b 100644 NSTimeInterval now = [[NSDate date] timeIntervalSinceReferenceDate]; if (now - lastClickTimeStamp < [self doubleClickInterval]) { -@@ -1096,10 +1379,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1096,10 +1382,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseDown; break; @@ -2586,7 +2595,7 @@ index 5ae29bf4..358e606b 100644 default: type = NSOtherMouseDown; } -@@ -1113,14 +1392,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1113,14 +1395,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: 0.0]; @@ -2610,7 +2619,7 @@ index 5ae29bf4..358e606b 100644 CGFloat deltaY = 0.0; switch (ev->xbutton.button) { -@@ -1130,14 +1417,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1130,14 +1420,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseUp; break; @@ -2625,7 +2634,7 @@ index 5ae29bf4..358e606b 100644 default: type = NSOtherMouseUp; } -@@ -1151,11 +1430,13 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1151,11 +1433,13 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: deltaY]; @@ -2640,7 +2649,7 @@ index 5ae29bf4..358e606b 100644 // NSLog(@"MotionNotify, x=%d, y=%d, xroot=%d, yroot=%d\n", // ev->xmotion.x, ev->xmotion.y, ev->xmotion.x_root, // ev->xmotion.y_root); -@@ -1195,14 +1476,29 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1195,14 +1479,29 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + [self pointerMoved]; if (ev->xmotion.state & Button1Mask) { @@ -2674,7 +2683,7 @@ index 5ae29bf4..358e606b 100644 mouseEventWithType: type location: pos modifierFlags: -@@ -1211,25 +1507,40 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,25 +1510,40 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -2716,7 +2725,7 @@ index 5ae29bf4..358e606b 100644 break; case FocusIn: -@@ -1243,28 +1554,42 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +1557,42 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -2759,7 +2768,7 @@ index 5ae29bf4..358e606b 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +1626,20 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +1629,20 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -2784,7 +2793,7 @@ index 5ae29bf4..358e606b 100644 break; case MapRequest: -@@ -1321,6 +1655,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1321,6 +1658,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { [delegate platformWindow: window frameChanged: [window frame] didSize: YES]; @@ -2792,7 +2801,7 @@ index 5ae29bf4..358e606b 100644 break; case ConfigureRequest: -@@ -1344,6 +1679,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +1682,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -2801,7 +2810,7 @@ index 5ae29bf4..358e606b 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1483,6 +1820,64 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1483,6 +1823,64 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, XSync(_display, False); } @@ -2866,7 +2875,7 @@ index 5ae29bf4..358e606b 100644 - (void) grabMouse: (BOOL) doGrab { if (doGrab) { NSWindow *nswin = [NSApp keyWindow]; -@@ -1495,9 +1890,10 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1495,9 +1893,10 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, const unsigned int mask = ButtonPressMask | ButtonReleaseMask | PointerMotionMask | FocusChangeMask; int result = XGrabPointer(_display, win, False, mask, GrabModeAsync, @@ -2878,7 +2887,7 @@ index 5ae29bf4..358e606b 100644 NSLog(@"XGrabPointer() succeeded for window %lu\n", win); NSRect frame = [xwin transformFrame: nswin.frame]; -@@ -1522,6 +1918,9 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1522,6 +1921,9 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, } else { XUngrabPointer(_display, CurrentTime); _cursorGrabbed = NO; From 47e91fb7befa17793911c90e902fbf28219c7469 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:55:09 +0200 Subject: [PATCH 021/160] Prepare nested Metal layers before compositing Traverse the layer tree before compositing so nested Metal layers consume queued drawables and recycle their pool. Add a nested presentation probe covering pool reuse and Vulkan-to-OpenGL pixel readback. --- docs/TESTING.md | 2 +- docs/TROUBLESHOOTING.md | 2 ++ patches/cocotron.patch | 35 +++++++++++++++++--------- tools/riot-metal-presentation-check.mm | 16 ++++++++++-- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index fff7da9..110da0c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -82,7 +82,7 @@ Useful probes (build tokenized Python helpers from `build/runtime/tools/`): - `riot-event-coalescing-check`: preserve key events and latest mouse motion. - `m327-cgl-resolver-check`: first calls through dynamically looked-up GL functions retain floating-point arguments; expected `first_sampler_linear=1 first_color_arguments=1 errors=0,0`. Build with `build-m58f327-opengl.py` and run with `run-riot-native-check.py m327-cgl-resolver-check`. - `m327-cgl-direct-check`: with `RCLOL_GPU_DIRECT_OTHER_WINDOW_TEST=1` and an owned 337x339 native target, interleave 338x339 fallback surfaces; expected `active_presenter_preserved=1 fallback_frames=357`. This needs the native presentation fixture; it is not a `run-riot-native-check.py` choice. -- `riot-metal-presentation-check`: Vulkan clear reaches the GL texture, including late window/context attachment. +- `riot-metal-presentation-check`: Vulkan clear reaches the GL texture, including late window/context attachment. Set `RIOT_PRESENTATION_NESTED=1` to place the Metal layer beneath two ordinary layers and verify that its drawables are still presented and recycled past the three-drawable pool limit. - `riot-metal-concurrency-check`: submission ordering, resource lifetime and concurrent recording. `RIOT_PRESENTATION_STRESS=1` makes the presentation probe run 120 frames. With the game and clients closed, force async encoding for this standalone probe using the guest flag `/tmp/metal-async-encode`, then remove it. The tested old path leaked 120 synchronization FDs over 120 frames; the fixed path leaked zero. Inspect the probe's host `/proc/PID/fd` for `anon_inode:syncobj_file` if reproducing that test. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 34d6f78..c04aa46 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -218,6 +218,8 @@ The first import of a drawable into the presenting OpenGL context, and the first A non-zero error or `FAILED` means the frames cannot reach the window. Before 2026-10-16 the import did not mark the memory as dedicated, which the extension requires; Mesa ignores it, and it is the suspected cause of the NVIDIA black screen (not yet confirmed). An NVIDIA log of 2026-10-16 showed `glTiling=optimal` with `setup_error=0x501 storage_error=0x501 FAILED`: NVIDIA's GL rejects `GL_LINEAR_TILING_EXT`, so the linear Vulkan image could not be imported. Since then the internal image and the GL texture use optimal tiling when `GL_VENDOR` contains NVIDIA (`vkTiling=optimal glTiling=optimal ok` is the expected line); everything else keeps linear. If the game shows a black screen on NVIDIA, report it with this file. +An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Confirmation on the reporter's NVIDIA system is still needed. + ### Host-side checks These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 55b5c83..74426f1 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -6871,7 +6871,7 @@ index 5dcac115..5de4f03f 100644 + @end diff --git a/QuartzCore/CALayerContext.m b/QuartzCore/CALayerContext.m -index 5dce8c56..e329b5f8 100644 +index 5dce8c56..dee2b80c 100644 --- a/QuartzCore/CALayerContext.m +++ b/QuartzCore/CALayerContext.m @@ -1,47 +1,58 @@ @@ -7027,18 +7027,30 @@ index 5dce8c56..e329b5f8 100644 } - (void) assignTextureIdsToLayerTree: (CALayer *) layer { -@@ -93,7 +148,10 @@ +@@ -93,7 +148,22 @@ [self assignTextureIdsToLayerTree: child]; } +extern void darlingQCTiming(const char* what, double seconds); ++ ++- (void) prepareMetalLayers: (CALayer *) layer { ++ // Metal layers may be nested beneath ordinary backing layers. Every queued ++ // drawable must be consumed before CARenderer samples the layer tree. ++ if ([[layer class] isSubclassOfClass: [CAMetalLayerInternal class]]) { ++ CFTimeInterval prepStart = CACurrentMediaTime(); ++ [(CAMetalLayerInternal*)layer prepareRender]; ++ darlingQCTiming("prepareRenderTotal", CACurrentMediaTime() - prepStart); ++ } ++ for (CALayer *child in layer.sublayers) ++ [self prepareMetalLayers: child]; ++} + - (void) renderLayer: (CALayer *) layer { + CFTimeInterval renderLayerStart = CACurrentMediaTime(); CGLContextMakeCurrentAndAttachToWindow(_glContext, _cglWindow); glEnable(GL_DEPTH_TEST); -@@ -118,27 +176,69 @@ +@@ -118,27 +188,63 @@ [_deleteTextureIds removeAllObjects]; @@ -7047,13 +7059,12 @@ index 5dce8c56..e329b5f8 100644 [self assignTextureIdsToLayerTree: layer]; + darlingQCTiming("assignTextureIds", CACurrentMediaTime() - assignStart); - // this is where the Metal layer renders to an internal texture for us to use - if ([[layer class] isSubclassOfClass: [CAMetalLayerInternal class]]) { - CAMetalLayerInternal* mtl = (CAMetalLayerInternal*)layer; -+ CFTimeInterval prepStart = CACurrentMediaTime(); - [mtl prepareRender]; -+ darlingQCTiming("prepareRenderTotal", CACurrentMediaTime() - prepStart); - } +- // this is where the Metal layer renders to an internal texture for us to use +- if ([[layer class] isSubclassOfClass: [CAMetalLayerInternal class]]) { +- CAMetalLayerInternal* mtl = (CAMetalLayerInternal*)layer; +- [mtl prepareRender]; +- } ++ [self prepareMetalLayers: layer]; + CFTimeInterval rendererStart = CACurrentMediaTime(); [_renderer render]; @@ -7108,7 +7119,7 @@ index 5dce8c56..e329b5f8 100644 } - (void) startTimerIfNeeded { -@@ -147,7 +247,7 @@ +@@ -147,7 +253,7 @@ target: self selector: @selector(timer:) userInfo: nil @@ -7117,7 +7128,7 @@ index 5dce8c56..e329b5f8 100644 } - (void) deleteTextureId: (NSNumber *) textureId { -@@ -155,7 +255,10 @@ +@@ -155,7 +261,10 @@ } - (void) flush { diff --git a/tools/riot-metal-presentation-check.mm b/tools/riot-metal-presentation-check.mm index 4864d4d..5266d8d 100644 --- a/tools/riot-metal-presentation-check.mm +++ b/tools/riot-metal-presentation-check.mm @@ -40,7 +40,18 @@ int main() { layer.frame = CGRectMake(0,0,160,96); NSView* view = [[NSView alloc] initWithFrame:NSMakeRect(0,0,160,96)]; view.wantsLayer = YES; - view.layer = layer; + const bool nested = getenv("RIOT_PRESENTATION_NESTED") != nullptr; + if (nested) { + CALayer* root = [CALayer layer]; + root.frame = layer.frame; + CALayer* container = [CALayer layer]; + container.frame = layer.frame; + [container addSublayer:layer]; + [root addSublayer:container]; + view.layer = root; + } else { + view.layer = layer; + } NSWindow* window = [[NSWindow alloc] initWithContentRect:NSMakeRect(50,50,160,96) styleMask:NSTitledWindowMask backing:NSBackingStoreBuffered defer:NO]; window.title = @"Metal presentation check"; @@ -92,7 +103,8 @@ int main() { fprintf(stderr, "PASS: bounded presentation capture rearmed without restarting\n"); } fprintf(stderr, "PASS: Vulkan-to-OpenGL presentation pixels\n"); - fprintf(stderr, "PASS: late context attachment and %u Metal drawable presentations\n", frameCount); + fprintf(stderr, "PASS: %s layer, late context attachment and %u Metal drawable presentations\n", + nested ? "nested" : "root", frameCount); [window orderOut:nil]; [queue release]; [view release]; [window release]; [device release]; } From e4f8c67151f9ecbb26c2ebebd07ce2c8a7f0ac79 Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 14:05:54 +0200 Subject: [PATCH 022/160] WIP: Low-latency performance and input latency optimizations --- patches/cocotron.patch | 35 +++++++++++++++------- patches/darling.patch | 22 ++++++++++---- patches/metal--deps--indium.patch | 11 +++++-- patches/xnu.patch | 18 ++++++++--- scripts/build.sh | 7 ++++- scripts/install-runtime.sh | 5 ++++ scripts/run.sh | 5 ++++ scripts/validate.py | 5 +++- tools/build-riot-appkit-runtime.py | 4 +-- tools/build-riot-cxx-optimized.py | 8 ++--- tools/build-riot-x11-runtime.py | 4 +-- tools/install-riot-presentation-runtime.sh | 4 +-- tools/m58f299-xshm.c | 12 +++++--- tools/run-riot-client.sh | 23 ++++++++++++-- 14 files changed, 122 insertions(+), 41 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 04da854..8b1c8af 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2255,21 +2255,22 @@ index 5ae29bf4..358e606b 100644 - (void) _enableDetectableAutoRepeat { int major = XkbMajorVersion, minor = XkbMinorVersion; XkbStateRec state; -@@ -536,12 +603,52 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -536,12 +603,53 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return &layout->layout; } +/* darling-lol: the game asks for the pointer position, the pressed buttons and the modifier state about a thousand + * times per second. Each answer was a synchronous X round trip (XQueryPointer / XkbGetState) that puts the main thread -+ * to sleep until XWayland replies; while the mouse moves they dominated the slow frames. Reuse a reply for 8 ms, keep it ++ * to sleep until XWayland replies; while the mouse moves they dominated the slow frames. Reuse a reply for 1 ms, keep it + * current from the input events themselves (a motion event carries position and button state), and drop it on every + * button, key, crossing or focus event so the next question after a state change is always asked for real. */ +#include ++#include +static BOOL rclolFresh(uint64_t *stamp) { + static mach_timebase_info_data_t tb; + if (tb.denom == 0) mach_timebase_info(&tb); + uint64_t now = mach_absolute_time(); -+ if (*stamp != 0 && (now - *stamp) * tb.numer / tb.denom < 8000000ULL) return YES; ++ if (*stamp != 0 && (now - *stamp) * tb.numer / tb.denom < 1000000ULL) return YES; + *stamp = now; + return NO; +} @@ -2344,7 +2345,7 @@ index 5ae29bf4..358e606b 100644 } - (void) setWindow: (id) window forID: (XID) i { -@@ -962,20 +1072,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -962,20 +1072,85 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { { #ifndef DARLING [[NSRunLoop currentRunLoop] addInputSource: _inputSource forMode: mode]; @@ -2419,12 +2420,15 @@ index 5ae29bf4..358e606b 100644 + slice = next > now ? next - now : 0; + } + if (result == nil && slice > 0) { -+ /* Keep this polling deadline independent of Mach wait timers. */ -+ struct { long seconds, nanoseconds; } delay = { -+ 0, (long)(slice * 1000000000.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 < 1) timeout_ms = 1; + extern long __lolsys_syscall(long, long, long, long, long, long, int); -+ __lolsys_syscall((long)&delay, 0, 0, 0, 0, 0, 35 /* Linux nanosleep */); ++ __lolsys_syscall((long)&pfd, 1, timeout_ms, 0, 0, 0, 7 /* Linux poll */); + } + } + return result; @@ -2625,7 +2629,7 @@ index 5ae29bf4..358e606b 100644 default: type = NSOtherMouseUp; } -@@ -1151,11 +1430,13 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1151,14 +1430,24 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: deltaY]; @@ -2640,6 +2644,17 @@ index 5ae29bf4..358e606b 100644 // NSLog(@"MotionNotify, x=%d, y=%d, xroot=%d, yroot=%d\n", // ev->xmotion.x, ev->xmotion.y, ev->xmotion.x_root, // ev->xmotion.y_root); ++ /* darling-lol perf: drain pending MotionNotify events for this ++ * window from the X11 queue so we only process the latest position. */ ++ while (XCheckTypedWindowEvent(_display, ev->xmotion.window, MotionNotify, ev)) ++ ; ++ rclolRootX = ev->xmotion.x_root; ++ rclolRootY = ev->xmotion.y_root; ++ rclolPointerState = ev->xmotion.state; ++ rclolPointerStamp = mach_absolute_time(); + + CGPoint lastMotionPos = [window mouseLocationOutsideOfEventStream]; + pos = [window @@ -1195,14 +1476,29 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + [self pointerMoved]; diff --git a/patches/darling.patch b/patches/darling.patch index d33b58f..82a4fdc 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -6725,7 +6725,7 @@ index ffd7fe054..a74ffc63f 100644 free(context); } -@@ -383,9 +608,329 @@ CGLError CGLUnlockContext(CGLContextObj context) { +@@ -383,9 +608,331 @@ CGLError CGLUnlockContext(CGLContextObj context) { return kCGLNoError; } @@ -6948,12 +6948,14 @@ 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; -+ if(!deadline || (current>deadline && current-deadline>16666667ull))deadline=current; ++ static uint64_t paceNS = 0; ++ if (!paceNS) { const char *hz = getenv("RCLOL_REFRESH_HZ"); int r = hz ? atoi(hz) : 0; paceNS = r > 0 ? 1000000000ull / (uint64_t)r : 1000000ull; } ++ if(!deadline || (current>deadline && current-deadline>paceNS))deadline=current; + if(deadline>current){ + struct timespec wake={deadline/1000000000ull,deadline%1000000000ull}; + while(__lolsys_syscall(1,1,(long)&wake,0,0,0,230)==-4){} + } -+ ctx->native_next_present_ns=deadline+16666667ull; ++ ctx->native_next_present_ns=deadline+paceNS; + } + } + if(getenv("RCLOL_GPU_DIRECT_ASYNC")){int queued=enqueueAsyncPresent(ctx,binding.texture,binding.target,width,height);if(!queued){stopAsyncPresent(ctx);__atomic_store_n(&target->ready,0,__ATOMIC_RELEASE);}CGLReleaseContext(ctx);return queued;} @@ -8420,7 +8422,7 @@ diff --git a/src/frameworks/OpenGL/CGLAsyncPresent.inc b/src/frameworks/OpenGL/C new file mode 100644 --- /dev/null +++ b/src/frameworks/OpenGL/CGLAsyncPresent.inc -@@ -0,0 +1,190 @@ +@@ -0,0 +1,197 @@ +/* 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. */ @@ -8526,11 +8528,19 @@ 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; -+ deadline+=16666667ull; ++ /* 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 ? 1000000000ull / (uint64_t)rate : 1000000ull; ++ } ++ deadline += refreshNS; + uint64_t now=nativeMonotonicNS(); + /* Preserve the refresh phase across small scheduling delays. Reset only + * after a full missed period, so lateness does not accumulate as drift. */ -+ if(now>deadline && now-deadline>16666667ull){__atomic_fetch_add(&target->reserved[2],1,__ATOMIC_RELAXED);deadline=now;} ++ if(now>deadline && now-deadline>refreshNS){__atomic_fetch_add(&target->reserved[2],1,__ATOMIC_RELAXED);deadline=now;} + nativeWaitUntil(deadline); + } + if(fbo)glDeleteFramebuffers(1,&fbo); diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index 72ac69a..f72c88d 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -2400,10 +2400,12 @@ index 43f4a84..46a4f72 100644 + for (;;) { + { + std::unique_lock lock(wakeMutex); -+ submitBatchWake().wait_for(lock, std::chrono::milliseconds(2)); ++ /* darling-lol perf: 500µs batch window balances submission ++ * merging with latency; the old 2ms added visible input lag. */ ++ submitBatchWake().wait_for(lock, std::chrono::microseconds(500)); + } + std::unique_lock queueLock(device->graphicsQueueMutex()); -+ if (!state.items.empty() && std::chrono::steady_clock::now() - state.since >= std::chrono::milliseconds(2)) flushSubmitBatchLocked(); ++ if (!state.items.empty() && std::chrono::steady_clock::now() - state.since >= std::chrono::microseconds(500)) flushSubmitBatchLocked(); + } + }).detach(); +} @@ -7449,7 +7451,10 @@ new file mode 100644 + static constexpr VkDeviceSize MaxPooledSize = 4ull << 20; + static constexpr VkDeviceSize Granule = 256; + // a freed range is not handed out again before this long: work already submitted to the GPU may still read it -+ static constexpr std::chrono::milliseconds Quarantine {1000}; ++ /* darling-lol perf: the old 1000ms quarantine caused pool exhaustion ++ * and fallback OS allocations during high-churn gameplay. 50ms (~3 ++ * frames at 60Hz) is sufficient for in-flight GPU work to drain. */ ++ static constexpr std::chrono::milliseconds Quarantine {50}; + + struct Block { + VkDeviceMemory memory = VK_NULL_HANDLE; diff --git a/patches/xnu.patch b/patches/xnu.patch index 7b95368..b317b3e 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -5894,9 +5894,10 @@ index b998456..2783a4a 100644 return 0; } -@@ -696,6 +1613,41 @@ kern_return_t syscall_thread_switch_impl( +@@ -696,6 +1613,48 @@ kern_return_t syscall_thread_switch_impl( .tv_sec = 0, - .tv_nsec = 1000000 +- .tv_nsec = 1000000 ++ .tv_nsec = option_time > 0 ? (long)option_time * 1000 : 1000000 }; + + /* darling-lol: opt-in trace (host file /tmp/rclol-select-trace) of who calls thread_switch and with which option: @@ -5933,8 +5934,17 @@ index b998456..2783a4a 100644 + } + } + - // Sleep for 1ms - __linux_nanosleep(&tv, &tv); +- // 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(); ++ } else { ++ __linux_nanosleep(&tv, &tv); ++ } diff --git a/libsyscall/CMakeLists.txt b/libsyscall/CMakeLists.txt index 57cebad..3014255 100755 diff --git a/scripts/build.sh b/scripts/build.sh index ec56e4b..6e19ae1 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -9,7 +9,12 @@ python3 scripts/prepare.py # so Release's -DNDEBUG trips its "mismatch in debug-ness macros" #error. # x86-64 only: the patched OpenDirectory declares ivars in @implementation, which the # 32-bit (fragile ABI) slice cannot compile, and the macOS client is 64-bit. +# 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. 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_BUILD_TYPE= -DTARGET_i386=OFF -DCMAKE_INSTALL_PREFIX=/usr/local -DENABLE_METAL=ON \ + -DCMAKE_C_FLAGS_INIT="-O3 -march=native -mtune=native -pipe" \ + -DCMAKE_CXX_FLAGS_INIT="-O3 -march=native -mtune=native -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 6c61e80..2fccf55 100755 --- a/scripts/install-runtime.sh +++ b/scripts/install-runtime.sh @@ -14,4 +14,9 @@ chmod 1777 "$HOME/.darling/private/tmp" python3 build/runtime/tools/stop-darling-runtime.py sh build/runtime/tools/install-riot-presentation-runtime.sh sh build/runtime/tools/install-riot-gpu-runtime.sh +# darling-lol perf: build and install optimized libc/libc++/libplatform. +if [ -f build/runtime/tools/build-riot-libs-runtime.sh ]; then + sh build/runtime/tools/build-riot-libs-runtime.sh + python3 build/runtime/tools/install-riot-libs-runtime.py +fi python3 build/runtime/tools/install-darling-system-profiler.py diff --git a/scripts/run.sh b/scripts/run.sh index a200b7e..56b3665 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -4,4 +4,9 @@ repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" python3 scripts/configure.py >/dev/null if [ -d build/venv/bin ]; then PATH="$repo/build/venv/bin:$PATH"; export PATH; fi +# darling-lol perf: optionally wrap with gamemoderun for automatic +# CPU governor, nice level, and scheduler optimizations. +if [ "${RCLOL_GAMEMODE:-auto}" != "0" ] && command -v gamemoderun >/dev/null 2>&1; then + exec gamemoderun sh build/runtime/tools/run-riot-client.sh +fi exec sh build/runtime/tools/run-riot-client.sh diff --git a/scripts/validate.py b/scripts/validate.py index 2884cf6..a944813 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -4,7 +4,10 @@ import ast,json,re,subprocess,sys from pathlib import Path from common import ROOT subprocess.run([sys.executable,str(ROOT/'scripts/configure.py')],check=True) -files=subprocess.check_output(['git','-C',str(ROOT),'ls-files','-z']).decode().split('\0') +try: + files=subprocess.check_output(['git','-C',str(ROOT),'ls-files','-z'],stderr=subprocess.DEVNULL).decode().split('\0') +except Exception: + 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-----'] for name in filter(None,files): diff --git a/tools/build-riot-appkit-runtime.py b/tools/build-riot-appkit-runtime.py index f4df0f7..4b9facb 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'], - '-O2', '-Wno-macro-redefined', '-c', + '-O3', '-march=native', '-mtune=native', '-fvisibility=hidden', '-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'], - '-O2', '-fblocks', '-Wno-macro-redefined', '-c', + '-O3', '-march=native', '-mtune=native', '-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 76b56c0..00e354c 100644 --- a/tools/build-riot-cxx-optimized.py +++ b/tools/build-riot-cxx-optimized.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 -"""Rebuild libc++abi and libc++ with -O2. The CMake tree has no build type, so the stock objects (and the earlier +"""Rebuild libc++abi and libc++ with -O3. The CMake tree has no build type, so the stock objects (and the earlier single-file libc++abi rebuild) are -O0: std::type_info::operator==, __dynamic_cast, shared_ptr internals ran 3-5x slower than they should (about 2% of the League main thread). Outputs build/riot-opt-libc++abi.dylib / riot-opt-libc++.1.dylib. """ from concurrent.futures import ThreadPoolExecutor from pathlib import Path -import re, shlex, subprocess, sys +import os, re, shlex, subprocess, sys root = Path(__file__).resolve().parent.parent resource_dir = subprocess.check_output(['clang', '-print-resource-dir'], text=True).strip() def current_clang(values): @@ -26,9 +26,9 @@ 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'], '-O2', '-DNDEBUG', + subprocess.run(['clang++', *flags['CXX_DEFINES'], *flags['CXX_INCLUDES'], *flags['CXX_FLAGS'], '-O3', '-march=native', '-mtune=native', '-fomit-frame-pointer', '-DNDEBUG', '-c', str(src / rel), '-o', str(obj)], check=True) - with ThreadPoolExecutor(max_workers=8) as pool: + with ThreadPoolExecutor(max_workers=os.cpu_count() or 8) as pool: list(pool.map(compile_one, jobs)) link[link.index('-o') + 1] = str(root / 'build' / output) subprocess.run(link, cwd=folder, check=True) diff --git a/tools/build-riot-x11-runtime.py b/tools/build-riot-x11-runtime.py index 3ccfb52..7275cca 100644 --- a/tools/build-riot-x11-runtime.py +++ b/tools/build-riot-x11-runtime.py @@ -13,7 +13,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'], '-O2', '-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=native', '-fvisibility=hidden', '-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) @@ -22,7 +22,7 @@ print('Built isolated X11 backend with native visibility events') # 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'], - '-O2', '-Wno-macro-redefined', '-c', str(project / 'tools/riot-modifier-check.m'), + '-O3', '-march=native', '-mtune=native', '-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', '@DARLING_SDK@', str(probe), diff --git a/tools/install-riot-presentation-runtime.sh b/tools/install-riot-presentation-runtime.sh index 525af97..e57e173 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 -O2 -Wall -Wextra \ +cc -shared -fPIC -O3 -march=native -mtune=native -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 \ +clang -target x86_64-apple-macosx11.0 -fuse-ld=lld -dynamiclib -nostdlib -O3 -march=native -mtune=native \ -isysroot @DARLING_SDK@ \ -I"$source_dir/src/startup/mldr/elfcalls" \ "-DDARLING_XSHM_HOST_LIBRARY=\"$project_dir/build/libdarling-xshm.so\"" \ diff --git a/tools/m58f299-xshm.c b/tools/m58f299-xshm.c index 85388f4..c275b47 100644 --- a/tools/m58f299-xshm.c +++ b/tools/m58f299-xshm.c @@ -135,8 +135,10 @@ static int drawFrame(Frame *f, Drawable target, GC gc, const void *pixels, (const char *)pixels + y * stride, f->width * 4); int result = XShmPutImage(f->display, target, f->privateGC, f->image, 0, 0, 0, 0, f->width, f->height, False); - /* Wait for server consumption before reusing the segment on the next tick. */ - XSync(f->display, False); + /* darling-lol perf: XFlush is non-blocking - sends the request without waiting + * for the server round-trip. The SHM segment is reused on the next tick anyway, + * and the server processes requests in order. Saves 0.2-1.0ms per frame. */ + XFlush(f->display); pthread_mutex_unlock(&f->render); return result; } @@ -150,7 +152,7 @@ DarlingXShmPut(void *handle, Drawable target, GC gc, const void *pixels, pthread_mutex_lock(&f->render); int result = XShmPutImage(f->display, target, f->privateGC, f->image, 0, 0, 0, 0, f->width, f->height, False); - XSync(f->display, False); + XFlush(f->display); pthread_mutex_unlock(&f->render); return result; } @@ -256,7 +258,9 @@ static void *pumpFrames(void *arg) { XFlush(f->display); } } - deadline.tv_nsec += 2000000; + /* darling-lol perf: 500µs poll interval (2000 Hz) reduces average frame + * detection latency from 1ms to 0.25ms at negligible CPU cost. */ + deadline.tv_nsec += 500000; if (deadline.tv_nsec >= 1000000000) { deadline.tv_nsec -= 1000000000; deadline.tv_sec++; diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index a660147..fe99494 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -17,10 +17,14 @@ unset RCLOL_APP_ARG3 RCLOL_APP_ARG3_SLOT RCLOL_UX_ARG1 RCLOL_UX_ARG1_SLOT unset RCLOL_UX_ARG2 RCLOL_UX_ARG2_SLOT # The paired runtime presents completed GPU textures without CPU readback. # Explicit 0 or an empty value selects the CPU fallback for diagnostics. -for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC RCLOL_GPU_DIRECT_VSYNC DARLING_CA_PRESENT_AT_COMMIT DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER RCLOL_FORWARD_MOUSE_MOVED; do +for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC DARLING_CA_PRESENT_AT_COMMIT DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER RCLOL_FORWARD_MOUSE_MOVED; do eval 'value=${'"$option"'-1}' case "$value" in 0|'') unset "$option";; *) export "$option=$value";; esac done +# darling-lol perf: VSync off by default for lowest input latency. +# Set RCLOL_GPU_DIRECT_VSYNC=1 to re-enable if tearing is unacceptable. +eval 'value=${RCLOL_GPU_DIRECT_VSYNC-0}' +case "$value" in 0|'') unset RCLOL_GPU_DIRECT_VSYNC;; *) export RCLOL_GPU_DIRECT_VSYNC="$value";; esac prefix=${DPREFIX:-$HOME/.darling} # The first-time installer puts the client in /Users/Shared/Riot Games and then # hands off through LaunchServices, which does not start it under Darling. Link @@ -60,9 +64,24 @@ else --launch-patchline="${RCLOL_LAUNCH_PATCHLINE-live}" fi fi +# --- darling-lol perf: low-latency GPU driver overrides --- +# Vulkan WSI: prefer immediate (no VSync) or mailbox (tear-free low-latency). +export MESA_VK_WSI_PRESENT_MODE="${MESA_VK_WSI_PRESENT_MODE:-immediate}" +# Mesa OpenGL: disable vertical sync, skip error checking. +export vblank_mode="${vblank_mode:-0}" +export MESA_NO_ERROR="${MESA_NO_ERROR:-1}" +# NVIDIA proprietary driver: disable VSync, limit pre-rendered frames. +export __GL_SYNC_TO_VBLANK="${__GL_SYNC_TO_VBLANK:-0}" +export __GL_MaxFramesAllowed="${__GL_MaxFramesAllowed:-1}" +export __GL_THREADED_OPTIMIZATIONS="${__GL_THREADED_OPTIMIZATIONS:-1}" +export __GL_YIELD="${__GL_YIELD:-NOTHING}" +# Reduce timer slack for tighter scheduling in the game process tree. +if [ -w /proc/self/timerslack_ns ]; then + echo 1 > /proc/self/timerslack_ns 2>/dev/null || true +fi exec env -u ELECTRON_RUN_AS_NODE -u DYLD_INSERT_LIBRARIES EGL_PLATFORM=x11 \ LANG="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" LC_ALL="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" \ - mesa_glthread="${RCLOL_MESA_GLTHREAD-${mesa_glthread-false}}" \ + mesa_glthread="${RCLOL_MESA_GLTHREAD-${mesa_glthread-true}}" \ XMODIFIERS="${RCLOL_XMODIFIERS:-@im=none}" DISPLAY="${DISPLAY:-:1}" \ DYLD_LIBRARY_PATH="${DYLD_LIBRARY_PATH:-@GUEST_HOME@/RuntimeLibraries}" \ DYLD_FRAMEWORK_PATH="${DYLD_FRAMEWORK_PATH:-@GUEST_HOME@/RuntimeFrameworks}" \ From 4ae26c8fdc7cfebde81054b4f93b72c1268d6fc3 Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 14:09:00 +0200 Subject: [PATCH 023/160] Optimize library runtime builders with -O3 -march=native -mtune=native -fomit-frame-pointer --- tools/build-riot-cxx-runtime.py | 13 ++++++++----- tools/build-riot-libc-runtime.py | 5 ++--- tools/build-riot-platform-strings.py | 5 +++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/tools/build-riot-cxx-runtime.py b/tools/build-riot-cxx-runtime.py index 6328901..572724e 100644 --- a/tools/build-riot-cxx-runtime.py +++ b/tools/build-riot-cxx-runtime.py @@ -15,6 +15,7 @@ generation only. Outputs build/riot-libc++.1.dylib and build/riot-libc++abi.dyli """ from concurrent.futures import ThreadPoolExecutor from pathlib import Path +import os import re import shlex import subprocess @@ -47,20 +48,22 @@ def build(library, object_dir, link_dir, output): obj = root / ("build/riot-opt-%s-%s.o" % (library, relative.replace("/", "_"))) jobs.append((relative, obj)) link[index] = str(obj) - print(library, "recompiling", len(jobs), "objects at -O2", flush=True) + print(library, "recompiling", len(jobs), "objects at -O3", flush=True) def compile_one(job): relative, obj = job - subprocess.run(["clang++", *flags["CXX_DEFINES"], *flags["CXX_INCLUDES"], *flags["CXX_FLAGS"], - "-O2", "-fno-strict-aliasing", "-c", str(source / relative), "-o", str(obj)], + 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", + "-c", str(source / relative), "-o", str(obj)], check=True, capture_output=True) riot_build_gate.wait_until_free() - with ThreadPoolExecutor(max_workers=6) as pool: + with ThreadPoolExecutor(max_workers=os.cpu_count() or 8) as pool: list(pool.map(compile_one, jobs)) link[link.index("-o") + 1] = str(root / "build" / output) subprocess.run(link, cwd=folder, check=True) - print("built build/" + output, len(jobs), "objects at -O2", flush=True) + print("built build/" + output, len(jobs), "objects at -O3", flush=True) if __name__ == "__main__": diff --git a/tools/build-riot-libc-runtime.py b/tools/build-riot-libc-runtime.py index 3060326..1ff53c6 100644 --- a/tools/build-riot-libc-runtime.py +++ b/tools/build-riot-libc-runtime.py @@ -100,9 +100,8 @@ def compile_command(target_folder, source, object_path): skip = True continue cleaned.append(token) - if "-O2" not in cleaned: - cleaned.append("-O2") - cleaned.append("-fno-strict-aliasing") + 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"]) return cleaned return None diff --git a/tools/build-riot-platform-strings.py b/tools/build-riot-platform-strings.py index 18bfcda..a4a1732 100644 --- a/tools/build-riot-platform-strings.py +++ b/tools/build-riot-platform-strings.py @@ -27,10 +27,11 @@ for original in list(link): if not original.startswith('CMakeFiles/platform_obj.dir/src/string/generic/') or not original.endswith('.c.o'):continue relative=original.split('platform_obj.dir/',1)[1].removesuffix('.o') obj=root/('build/riot-platform-'+Path(relative).name+'.o') - subprocess.run(['clang',*flags['C_DEFINES'],*flags['C_INCLUDES'],*flags['C_FLAGS'],'-O2','-fno-builtin','-fno-strict-aliasing','-c',str(root/'build/darling-src/src/external/libplatform'/relative),'-o',str(obj)],check=True) + 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) link[link.index(original)]=str(obj) recompiled+=1 link[link.index('-o')+1]=str(root/'build/riot-libsystem_platform.dylib') subprocess.run(link,cwd=folder,check=True) -print('built build/riot-libsystem_platform.dylib with',recompiled,'string objects at -O2') +print('built build/riot-libsystem_platform.dylib with',recompiled,'string objects at -O3') p=root/'build/m810-platform-runtime';p.mkdir(exist_ok=True);shutil.copy2(root/'build/riot-libsystem_platform.dylib',p/'libsystem_platform.dylib') From 92d724f7364427e44d38b3568202241148703ad0 Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 14:34:09 +0200 Subject: [PATCH 024/160] Fix GNOME Wayland fullscreen top bar and mouse confinement in cocotron --- patches/cocotron.patch | 91 ++++++++++++++++++++++++++++++---------- tools/run-riot-client.sh | 2 +- 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index db7736b..dd95907 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2826,29 +2826,20 @@ index 5ae29bf4..358e606b 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1483,6 +1823,64 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1483,6 +1823,113 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, XSync(_display, False); } - + +/* Confinement preserves absolute motion and edge panning. Relative-mode + * capture remains separate: it deliberately warps to the center. */ +- (void) updateMouseConfinementForWindow:(X11Window *)window { + if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT check window=%p delegate=%p focused=%p relative=%d\n",window,[window delegate],lastFocusedWindow,_cursorGrabbed); + if (!window || _cursorGrabbed) return; ++ if (getenv("RCLOL_MOUSE_CONFINEMENT") && strcmp(getenv("RCLOL_MOUSE_CONFINEMENT"), "0") == 0) return; + Window win = [window windowHandle]; -+ 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 nchildren; -+ if (!XQueryTree(_display, ancestor, &root, &parent, &children, &nchildren)) break; -+ if (children) XFree(children); -+ ancestor=parent; -+ } -+ if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT focus=%lu target=%lu ancestor=%lu\n",focus,win,ancestor); -+ if (ancestor != win) return; + XWindowAttributes attrs; + if (!XGetWindowAttributes(_display, win, &attrs)) return; ++ + BOOL fullscreen = NO; + Atom actual; int format; unsigned long count, remaining; unsigned char *data = NULL; + Atom state = XInternAtom(_display, "_NET_WM_STATE", False); @@ -2858,25 +2849,83 @@ index 5ae29bf4..358e606b 100644 + for (unsigned long i=0; incrtc; ++i) { + XRRCrtcInfo *crtc = XRRGetCrtcInfo(_display, resources, resources->crtcs[i]); + if (crtc) { -+ if (crtc->mode && abs(x-crtc->x)<=2 && abs(y-crtc->y)<=2 && -+ abs(attrs.width-(int)crtc->width)<=2 && abs(attrs.height-(int)crtc->height)<=2) fullscreen=YES; ++ if (crtc->mode) { ++ BOOL sizeMatchesCrtc = ++ (abs(attrs.width - (int)crtc->width) <= 4 && ++ (abs(attrs.height - (int)crtc->height) <= 4 || ++ attrs.height >= (int)crtc->height - 60)) || ++ (delegateFrame.size.width > 0 && ++ abs((int)delegateFrame.size.width - (int)crtc->width) <= 4 && ++ abs((int)delegateFrame.size.height - (int)crtc->height) <= 4); ++ ++ BOOL posMatchesCrtc = ++ abs(x - crtc->x) <= 4 && ++ (y >= crtc->y - 4 && y <= crtc->y + 60); ++ ++ if (sizeMatchesCrtc && (posMatchesCrtc || delegateFrame.size.width > 0)) { ++ 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); ++ XFlush(_display); ++ if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT promoted to EWMH FULLSCREEN for window %lu\n",win); ++ } ++ } ++ } + XRRFreeCrtcInfo(crtc); ++ if (fullscreen) break; + } + } + XRRFreeScreenResources(resources); + } + } -+ if (!fullscreen || attrs.map_state != IsViewable) { ++ ++ /* Check focus to determine whether mouse confinement should be active. */ ++ 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 nchildren; ++ if (!XQueryTree(_display, ancestor, &root, &parent, &children, &nchildren)) break; ++ 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); ++ ++ if (!fullscreen || attrs.map_state != IsViewable || ancestor != win) { + if (_confinedWindow == win) { XUngrabPointer(_display, CurrentTime); _confinedWindow=None; } + return; + } @@ -2891,7 +2940,7 @@ index 5ae29bf4..358e606b 100644 - (void) grabMouse: (BOOL) doGrab { if (doGrab) { NSWindow *nswin = [NSApp keyWindow]; -@@ -1495,9 +1893,10 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1495,9 +1942,10 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, const unsigned int mask = ButtonPressMask | ButtonReleaseMask | PointerMotionMask | FocusChangeMask; int result = XGrabPointer(_display, win, False, mask, GrabModeAsync, @@ -2903,7 +2952,7 @@ index 5ae29bf4..358e606b 100644 NSLog(@"XGrabPointer() succeeded for window %lu\n", win); NSRect frame = [xwin transformFrame: nswin.frame]; -@@ -1522,6 +1921,9 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1522,6 +1970,9 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, } else { XUngrabPointer(_display, CurrentTime); _cursorGrabbed = NO; diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index fe99494..c408bd5 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -17,7 +17,7 @@ unset RCLOL_APP_ARG3 RCLOL_APP_ARG3_SLOT RCLOL_UX_ARG1 RCLOL_UX_ARG1_SLOT unset RCLOL_UX_ARG2 RCLOL_UX_ARG2_SLOT # The paired runtime presents completed GPU textures without CPU readback. # Explicit 0 or an empty value selects the CPU fallback for diagnostics. -for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC DARLING_CA_PRESENT_AT_COMMIT DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER RCLOL_FORWARD_MOUSE_MOVED; do +for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC DARLING_CA_PRESENT_AT_COMMIT DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER RCLOL_FORWARD_MOUSE_MOVED RCLOL_MOUSE_CONFINEMENT; do eval 'value=${'"$option"'-1}' case "$value" in 0|'') unset "$option";; *) export "$option=$value";; esac done From b44ed53cf97f59ea774e90b8a70f1e61f1e3f039 Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 14:41:20 +0200 Subject: [PATCH 025/160] Add GNOME desktop entry and icon generator to installer and runtime tools --- scripts/create-desktop-entry.sh | 7 ++ scripts/install-runtime.sh | 4 + scripts/installer.sh | 2 + tools/riot-desktop-entry.py | 187 ++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100755 scripts/create-desktop-entry.sh create mode 100755 tools/riot-desktop-entry.py diff --git a/scripts/create-desktop-entry.sh b/scripts/create-desktop-entry.sh new file mode 100755 index 0000000..81b8c22 --- /dev/null +++ b/scripts/create-desktop-entry.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Create/update GNOME desktop entry and launcher icon. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$repo" +python3 scripts/configure.py >/dev/null +python3 build/runtime/tools/riot-desktop-entry.py diff --git a/scripts/install-runtime.sh b/scripts/install-runtime.sh index 2fccf55..278244e 100755 --- a/scripts/install-runtime.sh +++ b/scripts/install-runtime.sh @@ -20,3 +20,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 diff --git a/scripts/installer.sh b/scripts/installer.sh index c7520eb..d9d1268 100755 --- a/scripts/installer.sh +++ b/scripts/installer.sh @@ -14,10 +14,12 @@ export RIOT_LAUNCHER_HOST RCLOL_REGION # client fails under Darling, so start the installed client here. sh scripts/run.sh || true unset RIOT_LAUNCHER_HOST +python3 build/runtime/tools/riot-desktop-entry.py || true prefix=${DPREFIX:-$HOME/.darling} if [ ! -d "$prefix/Users/Shared/Riot Games/Riot Client.app/Contents/MacOS" ]; then echo "The installer exited without installing the Riot Client. Its logs are in" >&2 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/tools/riot-desktop-entry.py b/tools/riot-desktop-entry.py new file mode 100755 index 0000000..1458bfb --- /dev/null +++ b/tools/riot-desktop-entry.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Create and install the GNOME / FreeDesktop desktop entry and launcher icon.""" +import os +import shutil +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path("@PROJECT_ROOT@") +HOST_HOME = Path("@HOST_HOME@") +DPREFIX = Path("@DPREFIX@") + +SVG_ICON_CONTENT = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +def extract_best_png_from_icns(icns_path: Path) -> bytes | None: + """Extract the highest-resolution embedded PNG from an Apple ICNS file.""" + try: + data = icns_path.read_bytes() + if not data.startswith(b"icns"): + return None + offset = 8 + best_png = None + best_len = 0 + while offset + 8 <= len(data): + size = int.from_bytes(data[offset + 4 : offset + 8], "big") + if size < 8 or offset + size > len(data): + break + chunk = data[offset + 8 : offset + size] + if chunk.startswith(b"\x89PNG\r\n\x1a\n") and len(chunk) > best_len: + best_png = chunk + best_len = len(chunk) + offset += size + return best_png + except Exception: + return None + + +def find_candidate_icns() -> Path | None: + """Find any available Riot or League .icns icon files.""" + candidates = [ + *list((PROJECT_ROOT / "build/installer").glob("*League of Legends*.app/Contents/Resources/*.icns")), + *list(DPREFIX.glob("Users/Shared/Riot Games/Riot Client.app/Contents/Resources/*.icns")), + *list(DPREFIX.glob("Applications/Riot Client.app/Contents/Resources/*.icns")), + *list(DPREFIX.glob("Applications/League of Legends.app/Contents/Resources/*.icns")), + ] + for c in candidates: + if c.is_file() and c.stat().st_size > 1024: + return c + return None + + +def main(): + icons_base = HOST_HOME / ".local/share/icons" + apps_base = HOST_HOME / ".local/share/applications" + desktop_dir = HOST_HOME / "Desktop" + + scalable_dir = icons_base / "hicolor/scalable/apps" + png_dir = icons_base / "hicolor/512x512/apps" + scalable_dir.mkdir(parents=True, exist_ok=True) + png_dir.mkdir(parents=True, exist_ok=True) + apps_base.mkdir(parents=True, exist_ok=True) + + # 1. Install scalable SVG icon + svg_target = scalable_dir / "league-of-legends.svg" + svg_target.write_text(SVG_ICON_CONTENT) + shutil.copyfile(svg_target, icons_base / "league-of-legends.svg") + + # 2. Extract or generate PNG icon + png_target = png_dir / "league-of-legends.png" + icns_file = find_candidate_icns() + extracted = False + if icns_file: + png_data = extract_best_png_from_icns(icns_file) + if png_data: + png_target.write_bytes(png_data) + shutil.copyfile(png_target, icons_base / "league-of-legends.png") + extracted = True + print(f"Extracted official icon from {icns_file.name} to {png_target}") + + if not extracted and not png_target.exists(): + # Try to rasterize SVG via rsvg-convert or magick + rsvg = shutil.which("rsvg-convert") + magick = shutil.which("magick") or shutil.which("convert") + if rsvg: + subprocess.run([rsvg, "-w", "512", "-h", "512", str(svg_target), "-o", str(png_target)], check=False) + if png_target.exists(): + shutil.copyfile(png_target, icons_base / "league-of-legends.png") + elif magick: + subprocess.run([magick, "-background", "none", "-resize", "512x512", str(svg_target), str(png_target)], check=False) + if png_target.exists(): + shutil.copyfile(png_target, icons_base / "league-of-legends.png") + + # 3. Create .desktop file + run_script = PROJECT_ROOT / "scripts/run.sh" + desktop_content = f"""[Desktop Entry] +Version=1.0 +Type=Application +Name=League of Legends +GenericName=MOBA Game +Comment=Play League of Legends on Linux with darling-lol +Exec={run_script} +Icon=league-of-legends +Terminal=false +Categories=Game;ActionGame;StrategyGame; +Keywords=riot;league;lol;darling;games; +StartupWMClass=League of Legends +StartupNotify=true +""" + + desktop_file = apps_base / "league-of-legends.desktop" + desktop_file.write_text(desktop_content) + os.chmod(desktop_file, 0o755) + print(f"Created application launcher: {desktop_file}") + + if desktop_dir.is_dir(): + desktop_shortcut = desktop_dir / "league-of-legends.desktop" + desktop_shortcut.write_text(desktop_content) + os.chmod(desktop_shortcut, 0o755) + # Mark as trusted on GNOME Desktop if gio is available + gio = shutil.which("gio") + if gio: + subprocess.run([gio, "set", str(desktop_shortcut), "metadata::trusted", "true"], check=False) + print(f"Created desktop shortcut: {desktop_shortcut}") + + # 4. Refresh icon and desktop databases + update_desktop = shutil.which("update-desktop-database") + if update_desktop: + subprocess.run([update_desktop, str(apps_base)], check=False) + + update_icons = shutil.which("gtk-update-icon-cache") + if update_icons: + subprocess.run([update_icons, "-f", "-t", str(icons_base / "hicolor")], check=False) + + print("GNOME application launcher and icon installed successfully.") + + +if __name__ == "__main__": + main() From 426fb75ec84fe6155966f2061c05fb3bd002e7b8 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:14:06 +0200 Subject: [PATCH 026/160] Emulate macOS relative mouse mode in the X11 backend and skip redundant cursor updates CGAssociateMouseAndMouseCursorPosition(false) used to warp the pointer to the window center, freeze the reported position there and treat the game's own CGWarpMouseCursorPosition calls as motion. On Xwayland, which ignores warps while the cursor is visible, this left League's cursor stuck in the middle. The backend now keeps a frozen virtual cursor: physical motion only produces deltas (deltaY positive downwards), application warps move the virtual cursor and their echoes are dropped, mouseLocation returns it, and the pointer is only warped back where the server honours warps. The grab no longer uses the invalid FocusChangeMask and is released and retaken across focus changes. setCursor: no longer re-sends XDefineCursor/XFlush when the window already has that cursor; League set it about 190 times per second. Also carries the opt-in input trace (/tmp/rclol-input-trace in the guest). Co-Authored-By: Claude Opus 5.5 --- docs/TROUBLESHOOTING.md | 4 + patches/cocotron.patch | 949 +++++++++++++++++++++++++++++--- tools/test-x11-relative-mouse.c | 139 +++++ 3 files changed, 1022 insertions(+), 70 deletions(-) create mode 100644 tools/test-x11-relative-mouse.c diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index c04aa46..75b1c4d 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -230,6 +230,10 @@ These are host settings, not game settings. Run on AC power, and use a performan The X11 backend previously called `XSync` after every cursor-shape update, waiting for an X server reply on the input thread. It now uses `XFlush` to send the change immediately without waiting. This removes an avoidable round trip during cursor updates. A local manual retest on 2026-10-16 found that the mouse problem no longer occurred with VSync enabled. Input-to-display latency has not been measured, and confirmation from the original reporter is still pending. Rebuild and install with `./scripts/update.sh` after exiting the game and both clients. +A later input trace showed League setting the cursor about 190 times and unhiding it about 100 times per second, each time sending the same cursor to the X server. The backend now skips the update when the window already has that cursor. The effect on latency has not been measured. + +The backend's relative mouse mode (`CGAssociateMouseAndMouseCursorPosition(false)`) previously warped the pointer to the window center, treated the game's own cursor warps as motion and reported `deltaY` upside down. It now follows macOS: the cursor stays where it is, events carry the physical motion as deltas (positive `deltaY` downwards, also for ordinary motion), the game's warps move the cursor without a delta, and `+[NSEvent mouseLocation]` returns that cursor. On Xwayland a warp takes effect only while the cursor is hidden, so with a visible cursor the pointer is not held in place. `tools/test-x11-relative-mouse.c` checks this logic on the host (`cc tools/test-x11-relative-mouse.c && ./a.out` after `scripts/prepare.py`). League on the tested Hyprland setup never enters this mode, so it has not been confirmed in game. + For a manual comparison, keep the scene, FPS cap, display refresh and compositor settings the same. Record whether the delay affects the cursor itself, camera movement, or actions after clicking, and compare the in-game VSync setting on and off. Include the desktop/compositor, X11 or Xwayland session, GPU/driver, monitor refresh and fullscreen/windowed mode in the report. Do not automate game input. The fullscreen cursor-shape problem below is a separate known symptom. ## Cursor stops changing shape in fullscreen diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 74426f1..00a9399 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2107,10 +2107,17 @@ index bdc97bdf..139c91eb 100644 _cursor = XcursorLibraryLoadCursor(display, "left_ptr"); return self; diff --git a/AppKit/X11.backend/X11Display.h b/AppKit/X11.backend/X11Display.h -index 18189404..dd6215c9 100644 +index 18189404..0eff2187 100644 --- a/AppKit/X11.backend/X11Display.h +++ b/AppKit/X11.backend/X11Display.h -@@ -16,7 +16,7 @@ +@@ -10,13 +10,14 @@ + #import + #import + #import ++#import "X11RelativeMouse.h" + + #ifdef DARLING + #import #import #endif @@ -2119,10 +2126,21 @@ index 18189404..dd6215c9 100644 @interface X11Display : NSDisplay { Display *_display; -@@ -37,7 +37,10 @@ +@@ -36,8 +37,20 @@ + NSTimeInterval lastClickTimeStamp; int clickCount; X11Cursor *_blankCursor, *_defaultCursor; - BOOL _cursorGrabbed; +- BOOL _cursorGrabbed; ++ /* Relative mouse mode: requested by CGAssociateMouseAndMouseCursorPosition(false); _relativeMouse.active ++ * while its pointer grab on _relativeWindow is held (released on focus loss, retaken on focus). */ ++ BOOL _relativeRequested; ++ Window _relativeWindow; ++ X11RelativeMouse _relativeMouse; ++ BOOL _isXwayland; ++ /* The cursor last defined by setCursor:, retained so its X id cannot be freed and reused while cached. */ ++ Window _definedCursorWindow; ++ X11Cursor *_definedCursor; ++ BOOL _cursorHidden; + Window _confinedWindow; + unsigned int _otherButtonsDown; KeySym _lastKeySym; @@ -2130,7 +2148,7 @@ index 18189404..dd6215c9 100644 int _rrEventBase; NSArray* _lastScreens; -@@ -45,7 +48,9 @@ +@@ -45,7 +58,9 @@ XIM _xim; } @@ -2141,10 +2159,10 @@ index 18189404..dd6215c9 100644 - (void) setWindow: (id) window forID: (XID) i; diff --git a/AppKit/X11.backend/X11Display.m b/AppKit/X11.backend/X11Display.m -index 5ae29bf4..358e606b 100644 +index 5ae29bf4..024d3ec2 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m -@@ -48,15 +48,17 @@ +@@ -48,12 +48,271 @@ #import "X11KeySymToUCS.h" #import #import @@ -2156,13 +2174,267 @@ index 5ae29bf4..358e606b 100644 #import #import +#include ++ ++/* darling-lol: passive, opt-in input trace for diagnosing relative mouse mode (e.g. League on Xwayland). ++ * Enabled only when the file /tmp/rclol-input-trace exists (checked once per process); lines are appended to ++ * /tmp/rclol-input-trace.log with the pid and a monotonic millisecond timestamp. It records pointer grabs, ++ * warps, cursor visibility, motion handling and pointer-position queries, and never changes behaviour. When ++ * disabled the cost is one load and branch; when enabled no X requests are added except the one-time startup ++ * extension queries. High-rate sources log a few lines verbatim and are then summarised once per second. ++ * Never logs key codes, text or window titles. Independent of the older RCLOL_INPUT_TRACE stderr logging. */ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++static int rclolTraceState = -1; ++static FILE *rclolTraceFile; ++static pthread_once_t rclolTraceOnce = PTHREAD_ONCE_INIT; ++static pthread_mutex_t rclolTraceLock = PTHREAD_MUTEX_INITIALIZER; ++static unsigned rclolGrabGeneration; ++ ++static void rclolTraceInit(void) { ++ int on = 0; ++ if (access("/tmp/rclol-input-trace", F_OK) == 0) { ++ rclolTraceFile = fopen("/tmp/rclol-input-trace.log", "a"); ++ if (rclolTraceFile) { ++ setvbuf(rclolTraceFile, NULL, _IOLBF, 0); ++ on = 1; ++ } ++ } ++ __atomic_store_n(&rclolTraceState, on, __ATOMIC_RELEASE); ++} ++ ++static inline BOOL rclolTraceOn(void) { ++ int state = __atomic_load_n(&rclolTraceState, __ATOMIC_ACQUIRE); ++ if (__builtin_expect(state >= 0, 1)) ++ return state; ++ pthread_once(&rclolTraceOnce, rclolTraceInit); ++ return __atomic_load_n(&rclolTraceState, __ATOMIC_ACQUIRE); ++} ++ ++static unsigned long long rclolTraceNowMs(void) { ++ static mach_timebase_info_data_t tb; ++ if (tb.denom == 0) mach_timebase_info(&tb); ++ return (unsigned long long) ((__uint128_t) mach_absolute_time() * tb.numer / tb.denom / 1000000u); ++} ++ ++static void rclolTraceLine(const char *format, ...) __attribute__((format(printf, 1, 2))); ++static void rclolTraceLine(const char *format, ...) { ++ char message[640]; ++ va_list args; ++ va_start(args, format); ++ vsnprintf(message, sizeof message, format, args); ++ va_end(args); ++ fprintf(rclolTraceFile, "pid=%d t=%llums %s\n", (int) getpid(), rclolTraceNowMs(), message); ++} ++ ++/* A rate-limited source: the first `limit` calls are logged verbatim, later ones are counted and summarised ++ * (count and last message) once at least a second has passed. */ ++typedef struct { ++ const char *name; ++ unsigned limit, verbatim, pending; ++ unsigned long long windowStart; ++ char last[320]; ++} RclolTraceChannel; ++#define RCLOL_CHANNEL(n, l) { n, l, 0, 0, 0, "" } ++static RclolTraceChannel rclolWarpAppChannel = RCLOL_CHANNEL("warp[app]", 20); ++static RclolTraceChannel rclolWarpRewarpChannel = RCLOL_CHANNEL("warp[motion-rewarp]", 20); ++static RclolTraceChannel rclolHideChannel = RCLOL_CHANNEL("hideCursor", 20); ++static RclolTraceChannel rclolUnhideChannel = RCLOL_CHANNEL("unhideCursor", 20); ++static RclolTraceChannel rclolSetCursorChannel = RCLOL_CHANNEL("setCursor", 20); ++static RclolTraceChannel rclolFocusChannel = RCLOL_CHANNEL("focus", 60); ++static RclolTraceChannel rclolConfineChannel = RCLOL_CHANNEL("confinement", 60); ++static RclolTraceChannel rclolErrorChannel = RCLOL_CHANNEL("xerror", 40); ++static RclolTraceChannel *rclolChannels[] = { ++ &rclolWarpAppChannel, &rclolWarpRewarpChannel, &rclolHideChannel, ++ &rclolUnhideChannel, &rclolSetCursorChannel, &rclolFocusChannel, &rclolConfineChannel, &rclolErrorChannel, ++}; ++ ++static struct { ++ unsigned verbatim, events, echoes, rewarps, relative, absolute; ++ unsigned long long windowStart; ++ unsigned long window; ++ int x, y, rootX, rootY, grabbed; ++ double dx, dy, lastX, lastY; ++} rclolMotion; ++ ++static struct { ++ unsigned verbatim, calls, hits, queries, failures; ++ unsigned long long windowStart; ++ double x, y; ++ int rootX, rootY; ++} rclolLocation; ++ ++static void rclolTraceFlushChannelLocked(RclolTraceChannel *channel, unsigned long long now) { ++ if (channel->pending) ++ rclolTraceLine("%s summary: %u calls in %llums, last: %s", channel->name, channel->pending, ++ now - channel->windowStart, channel->last); ++ channel->pending = 0; ++ channel->windowStart = now; ++} ++ ++static void rclolTraceTickLocked(unsigned long long now, BOOL force) { ++ for (size_t i = 0; i < sizeof rclolChannels / sizeof *rclolChannels; ++i) ++ if (rclolChannels[i]->pending && (force || now - rclolChannels[i]->windowStart >= 1000)) ++ rclolTraceFlushChannelLocked(rclolChannels[i], now); ++ if (rclolMotion.events && (force || now - rclolMotion.windowStart >= 1000)) { ++ rclolTraceLine("motion summary: %u events (%u warp-echo, %u relative+rewarp, %u relative, %u absolute) in " ++ "%llums window=%lu relative=%d last ev=%d,%d root=%d,%d delta=%.1f,%.1f last=%.1f,%.1f", ++ rclolMotion.events, rclolMotion.echoes, rclolMotion.rewarps, rclolMotion.relative, ++ rclolMotion.absolute, ++ now - rclolMotion.windowStart, rclolMotion.window, rclolMotion.grabbed, rclolMotion.x, ++ rclolMotion.y, rclolMotion.rootX, rclolMotion.rootY, rclolMotion.dx, rclolMotion.dy, ++ rclolMotion.lastX, rclolMotion.lastY); ++ rclolMotion.events = rclolMotion.echoes = rclolMotion.rewarps = rclolMotion.relative = 0; ++ rclolMotion.absolute = 0; ++ } ++ if (rclolLocation.calls && (force || now - rclolLocation.windowStart >= 1000)) { ++ rclolTraceLine("mouseLocation summary: %u calls (%u cache hits, %u queries, %u failed) in %llums " ++ "last=%.1f,%.1f root=%d,%d", ++ rclolLocation.calls, rclolLocation.hits, rclolLocation.queries, rclolLocation.failures, ++ now - rclolLocation.windowStart, rclolLocation.x, rclolLocation.y, rclolLocation.rootX, ++ rclolLocation.rootY); ++ rclolLocation.calls = rclolLocation.hits = rclolLocation.queries = rclolLocation.failures = 0; ++ } ++} ++ ++static void rclolTraceRated(RclolTraceChannel *channel, const char *format, ...) __attribute__((format(printf, 2, 3))); ++static void rclolTraceRated(RclolTraceChannel *channel, const char *format, ...) { ++ char message[320]; ++ va_list args; ++ va_start(args, format); ++ vsnprintf(message, sizeof message, format, args); ++ va_end(args); ++ pthread_mutex_lock(&rclolTraceLock); ++ unsigned long long now = rclolTraceNowMs(); ++ if (channel->verbatim < channel->limit) { ++ channel->verbatim++; ++ rclolTraceLine("%s #%u gen=%u: %s", channel->name, channel->verbatim, rclolGrabGeneration, message); ++ } else { ++ if (!channel->pending) channel->windowStart = now; ++ channel->pending++; ++ memcpy(channel->last, message, sizeof message); ++ } ++ rclolTraceTickLocked(now, NO); ++ pthread_mutex_unlock(&rclolTraceLock); ++} ++ ++/* Each grabMouse: call starts a new generation: flush pending summaries and re-arm the verbatim budgets for ++ * warps, motion and mouseLocation so the loop's first steps after every grab change are visible. */ ++static void rclolTraceGrabChanged(void) { ++ pthread_mutex_lock(&rclolTraceLock); ++ rclolTraceTickLocked(rclolTraceNowMs(), YES); ++ rclolGrabGeneration++; ++ rclolWarpAppChannel.verbatim = rclolWarpRewarpChannel.verbatim = 0; ++ rclolMotion.verbatim = rclolLocation.verbatim = 0; ++ pthread_mutex_unlock(&rclolTraceLock); ++} ++ ++/* pos/last are the window-local event position and previous position for absolute motion, and the virtual cursor ++ * and previous physical pointer (root coordinates) for relative motion. */ ++static void rclolTraceMotion(XEvent *ev, BOOL grabbed, CGPoint pos, CGPoint last, CGFloat dx, CGFloat dy, ++ const char *decision, int forwarded) ++{ ++ pthread_mutex_lock(&rclolTraceLock); ++ unsigned long long now = rclolTraceNowMs(); ++ if (rclolMotion.verbatim < 30) { ++ rclolMotion.verbatim++; ++ rclolTraceLine("motion #%u gen=%u: window=%lu ev=%d,%d root=%d,%d pos=%.1f,%.1f last=%.1f,%.1f " ++ "delta=%.1f,%.1f relative=%d decision=%s forwarded=%d send_event=%d time=%lu state=0x%x", ++ rclolMotion.verbatim, rclolGrabGeneration, (unsigned long) ev->xany.window, ev->xmotion.x, ++ ev->xmotion.y, ev->xmotion.x_root, ev->xmotion.y_root, pos.x, pos.y, last.x, last.y, dx, dy, ++ grabbed, decision, forwarded, ev->xany.send_event, (unsigned long) ev->xmotion.time, ++ ev->xmotion.state); ++ } ++ if (!rclolMotion.events) rclolMotion.windowStart = now; ++ rclolMotion.events++; ++ if (!strcmp(decision, "echo")) rclolMotion.echoes++; ++ else if (!strcmp(decision, "relative+rewarp")) rclolMotion.rewarps++; ++ else if (!strncmp(decision, "relative", 8)) rclolMotion.relative++; ++ else rclolMotion.absolute++; ++ rclolMotion.window = ev->xany.window; ++ rclolMotion.x = ev->xmotion.x; ++ rclolMotion.y = ev->xmotion.y; ++ rclolMotion.rootX = ev->xmotion.x_root; ++ rclolMotion.rootY = ev->xmotion.y_root; ++ rclolMotion.grabbed = grabbed; ++ rclolMotion.dx = dx; ++ rclolMotion.dy = dy; ++ rclolMotion.lastX = last.x; ++ rclolMotion.lastY = last.y; ++ rclolTraceTickLocked(now, NO); ++ pthread_mutex_unlock(&rclolTraceLock); ++} ++ ++static void rclolTraceMouseLocation(NSPoint result, BOOL ok, BOOL hit, int rootX, int rootY, BOOL grabbed) { ++ pthread_mutex_lock(&rclolTraceLock); ++ unsigned long long now = rclolTraceNowMs(); ++ const char *source = grabbed ? "virtual" : !ok ? "query-failed" : hit ? "cache-hit" : "XQueryPointer"; ++ if (rclolLocation.verbatim < 30) { ++ rclolLocation.verbatim++; ++ rclolTraceLine("mouseLocation #%u gen=%u: -> %.1f,%.1f root=%d,%d source=%s relative=%d", ++ rclolLocation.verbatim, rclolGrabGeneration, result.x, result.y, rootX, rootY, source, ++ grabbed); ++ } ++ if (!rclolLocation.calls) rclolLocation.windowStart = now; ++ rclolLocation.calls++; ++ if (!ok) rclolLocation.failures++; ++ else if (hit) rclolLocation.hits++; ++ else rclolLocation.queries++; ++ rclolLocation.x = result.x; ++ rclolLocation.y = result.y; ++ rclolLocation.rootX = rootX; ++ rclolLocation.rootY = rootY; ++ rclolTraceTickLocked(now, NO); ++ pthread_mutex_unlock(&rclolTraceLock); ++} ++ ++static void rclolTraceStartup(Display *display) { ++ if (!rclolTraceOn()) return; ++ int opcode, event, error; ++ Bool xwayland = XQueryExtension(display, "XWAYLAND", &opcode, &event, &error); ++ int xiOpcode = 0, xiEvent = 0, xiError = 0; ++ Bool xi = XQueryExtension(display, "XInputExtension", &xiOpcode, &xiEvent, &xiError); ++ int screen = DefaultScreen(display); ++ rclolTraceLine("startup: input trace enabled; vendor=\"%s\" release=%d protocol=%d.%d xwayland=%d " ++ "XInputExtension=%d opcode=%d (libXi not linked; version not queried) screens=%d default=%d " ++ "size=%dx%d (%dx%dmm) RCLOL_INPUT_TRACE=%d", ++ ServerVendor(display), VendorRelease(display), ProtocolVersion(display), ++ ProtocolRevision(display), xwayland, xi, xiOpcode, ScreenCount(display), screen, ++ DisplayWidth(display, screen), DisplayHeight(display, screen), ++ DisplayWidthMM(display, screen), DisplayHeightMM(display, screen), ++ getenv("RCLOL_INPUT_TRACE") != NULL); ++} ++ ++static const char *rclolGrabResultName(int result) { ++ switch (result) { ++ case GrabSuccess: return "GrabSuccess"; ++ case AlreadyGrabbed: return "AlreadyGrabbed"; ++ case GrabInvalidTime: return "GrabInvalidTime"; ++ case GrabNotViewable: return "GrabNotViewable"; ++ case GrabFrozen: return "GrabFrozen"; ++ default: return "not-called"; ++ } ++} ++ ++static const char *rclolFocusModeName(int mode) { ++ static const char *names[] = {"Normal", "Grab", "Ungrab", "WhileGrabbed"}; ++ return mode >= 0 && mode < 4 ? names[mode] : "?"; ++} ++ ++static const char *rclolFocusDetailName(int detail) { ++ static const char *names[] = {"Ancestor", "Virtual", "Inferior", "Nonlinear", "NonlinearVirtual", ++ "Pointer", "PointerRoot", "DetailNone"}; ++ return detail >= 0 && detail < 8 ? names[detail] : "?"; ++} void NSColorSetCatalogColor(NSColorListName catalogName, NSColorName colorName, NSColor *color); - @implementation X11Display - - static int errorHandler(Display *display, XErrorEvent *errorEvent) { -@@ -75,6 +81,15 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, +@@ -75,6 +334,15 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, - init { if (self = [super init]) { @@ -2178,9 +2450,22 @@ index 5ae29bf4..358e606b 100644 _display = XOpenDisplay(NULL); if (_display == NULL) { -@@ -148,14 +163,16 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, +@@ -136,6 +404,10 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, + if (XRRQueryExtension(_display, &_rrEventBase, &errorBase)) { + XRRSelectInput(_display, DefaultRootWindow(_display), RRScreenChangeNotifyMask | RRCrtcChangeNotifyMask | RROutputChangeNotifyMask | RROutputPropertyNotifyMask); + } ++ /* Xwayland forwards pointer warps to the compositor only while the X cursor is hidden. */ ++ int xwaylandOpcode, xwaylandEvent, xwaylandError; ++ _isXwayland = XQueryExtension(_display, "XWAYLAND", &xwaylandOpcode, &xwaylandEvent, &xwaylandError); ++ rclolTraceStartup(_display); + + lastFocusedWindow = nil; + lastClickTimeStamp = 0.0; +@@ -147,15 +419,18 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, + - (void) dealloc { [_blankCursor release]; [_defaultCursor release]; ++ [_definedCursor release]; - XCloseIM(_xim); + if (_xim) @@ -2198,7 +2483,7 @@ index 5ae29bf4..358e606b 100644 if (_cfSocket != NULL) CFRelease(_cfSocket); #endif -@@ -172,6 +189,56 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, +@@ -172,6 +447,56 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, return _display; } @@ -2255,7 +2540,7 @@ index 5ae29bf4..358e606b 100644 - (void) _enableDetectableAutoRepeat { int major = XkbMajorVersion, minor = XkbMinorVersion; XkbStateRec state; -@@ -536,12 +603,52 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -536,12 +861,52 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return &layout->layout; } @@ -2310,7 +2595,7 @@ index 5ae29bf4..358e606b 100644 } - (NSPasteboard *) pasteboardWithName: (NSString *) name { -@@ -675,6 +782,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -675,6 +1040,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { blue: 0.60f alpha: 1.0f]; @@ -2326,16 +2611,63 @@ index 5ae29bf4..358e606b 100644 NSLog(@"missing color for %@", colorName); return [NSColor redColor]; } -@@ -754,4 +870,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -688,6 +1062,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + } + + - (void) hideCursor { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolHideChannel, "blankExisted=%d relative=%d/%d hidden=%d confined=%lu", ++ _blankCursor != nil, _relativeRequested, _relativeMouse.active, _cursorHidden, ++ (unsigned long) _confinedWindow); + if (!_blankCursor) + _blankCursor = [[X11Cursor alloc] initBlank]; + [self setCursor: _blankCursor]; +@@ -695,6 +1073,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + + - (void) unhideCursor { + NSCursor *current = [NSCursor currentCursor]; ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolUnhideChannel, "path=%s current=%p relative=%d/%d hidden=%d", ++ current ? "push-pop" : "default", current, _relativeRequested, _relativeMouse.active, ++ _cursorHidden); + if (current != nil) { + [current push]; + [current pop]; +@@ -747,12 +1129,28 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + + - (void) setCursor: (id) cursor { + CGWindow *window = [[NSApp keyWindow] platformWindow]; +- if (window != nil) { +- Window w = [(X11Window *) window windowHandle]; +- Cursor c = ((X11Cursor *) cursor).cursor; +- ++ Window w = window ? [(X11Window *) window windowHandle] : None; ++ Cursor c = ((X11Cursor *) cursor).cursor; ++ /* unhideCursor re-sets the current cursor and games call it every frame: only send actual changes. The ++ * defined cursor stays retained, so its X id cannot be freed and handed to a different cursor meanwhile. */ ++ BOOL unchanged = w != None && w == _definedCursorWindow && c == _definedCursor.cursor; ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolSetCursorChannel, "cursor=%p blank=%d default=%d xcursor=%lu keyWindow=%d xwin=%lu applied=%d relative=%d/%d", ++ cursor, cursor != nil && cursor == _blankCursor, cursor != nil && cursor == _defaultCursor, ++ (unsigned long) c, [NSApp keyWindow] != nil, (unsigned long) w, w != None && !unchanged, ++ _relativeRequested, _relativeMouse.active); ++ if (w != None && !unchanged) { XDefineCursor(_display, w, c); - XSync(_display, False); + /* Send cursor changes immediately without waiting for an X server + * reply on the input thread. X requests on this connection remain + * ordered; no server result is consumed here. */ + XFlush(_display); ++ [cursor retain]; ++ [_definedCursor release]; ++ _definedCursor = cursor; ++ _definedCursorWindow = w; ++ /* initBlank's empty-mask cursor counts as hidden for Xwayland's pointer warp emulation. */ ++ _cursorHidden = cursor != nil && cursor == _blankCursor && c != None; } } -@@ -921,15 +1040,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + +@@ -921,15 +1319,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } - (NSPoint) mouseLocation { @@ -2346,14 +2678,44 @@ index 5ae29bf4..358e606b 100644 - - XQueryPointer(_display, root, &root, &child, &root_x, &root_y, &win_x, - &win_y, &mask); -+ rclolQueryPointer(_display); ++ if (_relativeMouse.active) { ++ /* The cursor is disassociated: report the frozen (virtual) position, as macOS does. */ ++ NSPoint result = NSMakePoint(_relativeMouse.virtualX, ++ DisplayHeight(_display, DefaultScreen(_display)) - _relativeMouse.virtualY); ++ if (rclolTraceOn()) ++ rclolTraceMouseLocation(result, YES, YES, (int) _relativeMouse.virtualX, (int) _relativeMouse.virtualY, YES); ++ return result; ++ } ++ uint64_t rclolStampBefore = rclolPointerStamp; ++ BOOL rclolQueried = rclolQueryPointer(_display); int height = DisplayHeight(_display, DefaultScreen(_display)); - return NSMakePoint(root_x, height - root_y); -+ return NSMakePoint(rclolRootX, height - rclolRootY); ++ NSPoint result = NSMakePoint(rclolRootX, height - rclolRootY); ++ if (rclolTraceOn()) ++ rclolTraceMouseLocation(result, rclolQueried, rclolStampBefore != 0 && rclolPointerStamp == rclolStampBefore, ++ rclolRootX, rclolRootY, NO); ++ return result; } - (void) setWindow: (id) window forID: (XID) i { -@@ -962,20 +1075,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -948,6 +1353,16 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + // retaining the window causes it to keep popping back up endlessly even after dismissing it + if (lastFocusedWindow && [[lastFocusedWindow platformWindow] windowHandle] == i) + lastFocusedWindow = nil; ++ if (_definedCursorWindow == i) { ++ _definedCursorWindow = None; ++ [_definedCursor release]; ++ _definedCursor = nil; ++ } ++ /* The server released any grab on a destroyed window. */ ++ if (_confinedWindow == i) ++ _confinedWindow = None; ++ if (_relativeWindow == i) ++ [self suspendRelativeMouse]; + } + } + +@@ -962,20 +1377,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { { #ifndef DARLING [[NSRunLoop currentRunLoop] addInputSource: _inputSource forMode: mode]; @@ -2441,7 +2803,7 @@ index 5ae29bf4..358e606b 100644 } - (NSEventModifierFlags) modifierFlagsForState: (unsigned int) state { -@@ -1010,17 +1185,93 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1010,17 +1487,96 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return result; } @@ -2512,6 +2874,9 @@ index 5ae29bf4..358e606b 100644 + default: + break; + } ++ /* Expected warp echoes are among the events queued when the warp was synced; motion ages them itself. */ ++ if (_relativeMouse.active && ev->type != MotionNotify) ++ X11RelativeMouseTick(&_relativeMouse); switch (ev->type) { case KeyPress: @@ -2535,7 +2900,7 @@ index 5ae29bf4..358e606b 100644 NSEventModifierFlags modifierFlags = [self modifierFlagsForState: ev->xkey.state]; char buf[20] = {0}; KeySym keySym; -@@ -1038,6 +1289,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1038,6 +1594,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { buf[strLen] = 0; } @@ -2554,12 +2919,13 @@ index 5ae29bf4..358e606b 100644 id str = [[NSString alloc] initWithCString: buf encoding: NSUTF8StringEncoding]; NSPoint pos = -@@ -1077,6 +1340,29 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1077,6 +1645,30 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ButtonPress:; + if (ev->xbutton.button >= Button4 && ev->xbutton.button <= 7) { -+ pos = [window transformPoint:NSMakePoint(ev->xbutton.x, ev->xbutton.y)]; ++ pos = [self locationInWindow: window x: ev->xbutton.x y: ev->xbutton.y ++ rootX: ev->xbutton.x_root rootY: ev->xbutton.y_root]; + CGFloat wheelX = ev->xbutton.button == 6 ? 1.0 : + ev->xbutton.button == 7 ? -1.0 : 0.0; + CGFloat wheelY = ev->xbutton.button == Button4 ? 1.0 : @@ -2584,7 +2950,18 @@ index 5ae29bf4..358e606b 100644 NSTimeInterval now = [[NSDate date] timeIntervalSinceReferenceDate]; if (now - lastClickTimeStamp < [self doubleClickInterval]) { -@@ -1096,10 +1382,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1086,8 +1678,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + } + lastClickTimeStamp = now; + +- pos = [window +- transformPoint: NSMakePoint(ev->xbutton.x, ev->xbutton.y)]; ++ pos = [self locationInWindow: window x: ev->xbutton.x y: ev->xbutton.y ++ rootX: ev->xbutton.x_root rootY: ev->xbutton.y_root]; + + switch (ev->xbutton.button) { + case Button1: +@@ -1096,10 +1688,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseDown; break; @@ -2595,7 +2972,7 @@ index 5ae29bf4..358e606b 100644 default: type = NSOtherMouseDown; } -@@ -1113,14 +1395,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1113,14 +1701,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: 0.0]; @@ -2607,11 +2984,14 @@ index 5ae29bf4..358e606b 100644 break; case ButtonRelease: +- pos = [window +- transformPoint: NSMakePoint(ev->xbutton.x, ev->xbutton.y)]; +- + // One scroll event per notch, sent on press; releases are not clicks. + if (ev->xbutton.button >= Button4 && ev->xbutton.button <= 7) break; - pos = [window - transformPoint: NSMakePoint(ev->xbutton.x, ev->xbutton.y)]; - ++ pos = [self locationInWindow: window x: ev->xbutton.x y: ev->xbutton.y ++ rootX: ev->xbutton.x_root rootY: ev->xbutton.y_root]; ++ + if (ev->xbutton.button == Button2 || ev->xbutton.button >= 8) { + unsigned b = ev->xbutton.button == Button2 ? 2 : ev->xbutton.button - 5; + if (b < 32) _otherButtonsDown &= ~(1u << b); @@ -2619,7 +2999,7 @@ index 5ae29bf4..358e606b 100644 CGFloat deltaY = 0.0; switch (ev->xbutton.button) { -@@ -1130,14 +1420,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1130,14 +1726,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseUp; break; @@ -2634,7 +3014,7 @@ index 5ae29bf4..358e606b 100644 default: type = NSOtherMouseUp; } -@@ -1151,11 +1433,13 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1151,7 +1739,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: deltaY]; @@ -2645,11 +3025,70 @@ index 5ae29bf4..358e606b 100644 [self postEvent: event atStart: NO]; break; - case MotionNotify:; { - // NSLog(@"MotionNotify, x=%d, y=%d, xroot=%d, yroot=%d\n", - // ev->xmotion.x, ev->xmotion.y, ev->xmotion.x_root, - // ev->xmotion.y_root); -@@ -1195,14 +1479,29 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1164,45 +1754,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + pos = [window + transformPoint: NSMakePoint(ev->xmotion.x, ev->xmotion.y)]; + +- CGFloat deltaX = pos.x - lastMotionPos.x; +- CGFloat deltaY = pos.y - lastMotionPos.y; +- +- // NSLog(@"cursorGrabbed=%d, deltaX=%f, deltaY=%f\n", +- // _cursorGrabbed, deltaX, deltaY); +- if (_cursorGrabbed) { +- if (pos.x != lastMotionPos.x || pos.y != lastMotionPos.y) { +- CGPoint globalPos = [window transformPoint: lastMotionPos]; +- // NSLog(@"last known pos in window: x=%f, y=%f", +- // globalPos.x, globalPos.y); +- CGRect frame = [window transformFrame: [window frame]]; +- +- globalPos.x += frame.origin.x; +- globalPos.y += frame.origin.y; +- +- [self warpMouse: globalPos]; +- +- pos.x = lastMotionPos.x; +- pos.y = lastMotionPos.y; +- } else { +- // This is an event generated by [self warpMouse:] above ++ double deltaX, deltaY; ++ CGPoint rclolEventPos = pos, rclolLastPos = lastMotionPos; ++ const char *rclolDecision = "absolute"; ++ ++ if (_relativeMouse.active) { ++ /* The cursor is disassociated (CGAssociateMouseAndMouseCursorPosition(false)): physical motion ++ * only produces deltas and the event reports the frozen virtual cursor. Where warps are honoured ++ * the pointer is put back under it, so the visible cursor stays frozen as on macOS. */ ++ BOOL warpsHonoured = !_isXwayland || _cursorHidden; ++ rclolLastPos = NSMakePoint(_relativeMouse.physicalX, _relativeMouse.physicalY); ++ X11RelativeMouseMotion relative = X11RelativeMouseHandleMotion( ++ &_relativeMouse, ev->xmotion.x_root, ev->xmotion.y_root, warpsHonoured); ++ rclolEventPos = NSMakePoint(_relativeMouse.virtualX, _relativeMouse.virtualY); ++ if (relative.rewarp) { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolWarpRewarpChannel, "target=%d,%d from=%d,%d honoured=%d", ++ relative.warpX, relative.warpY, ev->xmotion.x_root, ev->xmotion.y_root, ++ warpsHonoured); ++ unsigned queued = [self warpPointerToRootX: relative.warpX y: relative.warpY]; ++ X11RelativeMouseExpectEcho(&_relativeMouse, relative.warpX, relative.warpY, queued); ++ } ++ if (!relative.post) { ++ if (rclolTraceOn()) ++ rclolTraceMotion(ev, YES, rclolEventPos, rclolLastPos, relative.deltaX, relative.deltaY, ++ relative.echo ? "echo" : "relative-still", -1); + break; + } ++ deltaX = relative.deltaX; ++ deltaY = relative.deltaY; ++ pos = [self locationInWindow: window x: ev->xmotion.x y: ev->xmotion.y ++ rootX: ev->xmotion.x_root rootY: ev->xmotion.y_root]; ++ rclolDecision = relative.rewarp ? "relative+rewarp" : "relative"; + } else { +- [window setLastKnownCursorPosition: pos]; ++ X11AbsoluteMouseDelta(lastMotionPos.x, lastMotionPos.y, pos.x, pos.y, &deltaX, &deltaY); + } ++ [window setLastKnownCursorPosition: pos]; + + type = NSMouseMoved; + [self pointerMoved]; if (ev->xmotion.state & Button1Mask) { @@ -2663,6 +3102,11 @@ index 5ae29bf4..358e606b 100644 - if (type == NSMouseMoved && ![delegate acceptsMouseMovedEvents]) - break; ++ if (rclolTraceOn()) ++ rclolTraceMotion(ev, _relativeMouse.active, rclolEventPos, rclolLastPos, deltaX, deltaY, rclolDecision, ++ type != NSMouseMoved || [delegate acceptsMouseMovedEvents] || ++ getenv("RCLOL_FORWARD_MOUSE_MOVED") != NULL); ++ + if (inputTrace) { + static NSTimeInterval lastMotionTrace; + NSTimeInterval now = [NSDate timeIntervalSinceReferenceDate]; @@ -2671,7 +3115,7 @@ index 5ae29bf4..358e606b 100644 + NSLog(@"LOLINPUT motion window=%lu mapped=%@ pos=%.0f,%.0f delta=%.0f,%.0f state=%u grabbed=%d forward=%d", + (unsigned long)ev->xany.window, [delegate class], + pos.x, pos.y, deltaX, deltaY, ev->xmotion.state, -+ _cursorGrabbed, type != NSMouseMoved || [delegate acceptsMouseMovedEvents] || ++ _relativeMouse.active, type != NSMouseMoved || [delegate acceptsMouseMovedEvents] || + getenv("RCLOL_FORWARD_MOUSE_MOVED") != NULL); + } + } @@ -2679,11 +3123,18 @@ index 5ae29bf4..358e606b 100644 - event = [NSEvent + if (type != NSMouseMoved || [delegate acceptsMouseMovedEvents] || + getenv("RCLOL_FORWARD_MOUSE_MOVED")) { ++ /* Relative motion is all in the deltas: keep those of the queued events coalesced below. */ ++ if (_relativeMouse.active) ++ for (NSEvent *queued in _eventQueue) ++ if ([queued type] == type) { ++ deltaX += [queued deltaX]; ++ deltaY += [queued deltaY]; ++ } + event = [NSEvent mouseEventWithType: type location: pos modifierFlags: -@@ -1211,25 +1510,40 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +1838,49 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -2706,18 +3157,19 @@ index 5ae29bf4..358e606b 100644 case EnterNotify: + [self updateMouseConfinementForWindow:window]; NSLog(@"EnterNotify"); - if (!_cursorGrabbed) +- if (!_cursorGrabbed) ++ if (!_relativeMouse.active) [window setLastKnownCursorPosition: [window transformPoint: NSMakePoint( ev->xcrossing.x, ev->xcrossing.y)]]; -+ if (!_cursorGrabbed && ev->xcrossing.detail != NotifyInferior) ++ if (!_relativeMouse.active && ev->xcrossing.detail != NotifyInferior) + [delegate platformWindowSetCursorEvent: window]; break; case LeaveNotify: NSLog(@"LeaveNotify"); -+ if (!_cursorGrabbed && ev->xcrossing.detail != NotifyInferior) { ++ if (!_relativeMouse.active && ev->xcrossing.detail != NotifyInferior) { + [window setLastKnownCursorPosition: + [window transformPoint: NSMakePoint(ev->xcrossing.x, ev->xcrossing.y)]]; + [delegate platformWindowSetCursorEvent: window]; @@ -2725,7 +3177,16 @@ index 5ae29bf4..358e606b 100644 break; case FocusIn: -@@ -1243,28 +1557,42 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolFocusChannel, "FocusIn window=%lu known=%d mode=%s detail=%s relative=%d/%d confined=%lu appActive=%d sheet=%d", ++ (unsigned long) ev->xany.window, window != nil, rclolFocusModeName(ev->xfocus.mode), ++ rclolFocusDetailName(ev->xfocus.detail), _relativeRequested, _relativeMouse.active, ++ (unsigned long) _confinedWindow, ++ [NSApp isActive], [delegate attachedSheet] != nil); + NSLog(@"FocusIn"); + if ([delegate attachedSheet]) { + [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; +@@ -1243,28 +1891,55 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -2736,18 +3197,33 @@ index 5ae29bf4..358e606b 100644 lastFocusedWindow = delegate; if (window != nil) XSetICFocus(window->_xic); ++ /* Relative mode released its grab on focus loss; take it again. */ ++ if (_relativeRequested && !_relativeMouse.active) ++ [self beginRelativeMouseWithWindow: NULL pick: NULL]; + [self updateMouseConfinementForWindow:window]; ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolFocusChannel, "FocusIn done window=%lu relative=%d/%d confined=%lu appActive=%d", ++ (unsigned long) ev->xany.window, _relativeRequested, _relativeMouse.active, ++ (unsigned long) _confinedWindow, [NSApp isActive]); break; case FocusOut: ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolFocusChannel, "FocusOut window=%lu known=%d mode=%s detail=%s relative=%d/%d confined=%lu appActive=%d", ++ (unsigned long) ev->xany.window, window != nil, rclolFocusModeName(ev->xfocus.mode), ++ rclolFocusDetailName(ev->xfocus.detail), _relativeRequested, _relativeMouse.active, ++ (unsigned long) _confinedWindow, ++ [NSApp isActive]); NSLog(@"FocusOut"); + [self reconcileModifierKeys:NULL window:delegate]; + _lastKeySym = 0; [delegate platformWindowDeactivated: window checkForAppDeactivation: NO]; lastFocusedWindow = nil; - if (_cursorGrabbed) - [self grabMouse: NO]; +- if (_cursorGrabbed) +- [self grabMouse: NO]; ++ /* Give the pointer back to other clients; relative mode stays requested and resumes on FocusIn. */ ++ [self suspendRelativeMouse]; + if (_confinedWindow) { + XUngrabPointer(_display, CurrentTime); + _confinedWindow = None; @@ -2768,7 +3244,7 @@ index 5ae29bf4..358e606b 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +1629,20 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +1976,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -2782,6 +3258,9 @@ index 5ae29bf4..358e606b 100644 + if(window && ev->xany.window == [window windowHandle] && + [delegate respondsToSelector: @selector(_setVisible:)]) { + BOOL visible = ev->type == MapNotify; ++ /* The server released a grab whose window became unviewable. */ ++ if (!visible && ev->xany.window == _relativeWindow) ++ [self suspendRelativeMouse]; + [self updateMouseConfinementForWindow:window]; + if([delegate isVisible] != visible) { + [delegate _setVisible: visible]; @@ -2793,7 +3272,7 @@ index 5ae29bf4..358e606b 100644 break; case MapRequest: -@@ -1321,6 +1658,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1321,6 +2008,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { [delegate platformWindow: window frameChanged: [window frame] didSize: YES]; @@ -2801,7 +3280,7 @@ index 5ae29bf4..358e606b 100644 break; case ConfigureRequest: -@@ -1344,6 +1682,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2032,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -2810,15 +3289,71 @@ index 5ae29bf4..358e606b 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1483,6 +1823,64 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, - XSync(_display, False); +@@ -1433,6 +2123,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + NSLog(@"Request code: %d:%d", errorEvent->request_code, + errorEvent->minor_code); + NSLog(@"Error code: %d", errorEvent->error_code); ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolErrorChannel, "request=%d:%d error=%d resource=%lu serial=%lu", ++ errorEvent->request_code, errorEvent->minor_code, errorEvent->error_code, ++ (unsigned long) errorEvent->resourceid, (unsigned long) errorEvent->serial); + return 0; } -+/* Confinement preserves absolute motion and edge panning. Relative-mode -+ * capture remains separate: it deliberately warps to the center. */ +@@ -1476,54 +2170,214 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, + return frame; + } + ++/* Moves the X pointer and waits for the server, so the warp's MotionNotify (if any) is already queued on return. ++ * Returns the number of queued events, the window in which that echo must arrive. */ ++- (unsigned) warpPointerToRootX: (int) x y: (int) y { ++ XWarpPointer(_display, None, DefaultRootWindow(_display), 0, 0, 0, 0, x, y); ++ XSync(_display, False); ++ rclolPointerStamp = 0; ++ return (unsigned) XEventsQueued(_display, QueuedAlready); ++} ++ ++/* Window-local Cocoa location of a pointer event at (x, y) in window, root (rootX, rootY). While relative this is ++ * the virtual cursor: macOS reports the frozen cursor position, not where the physical pointer went. */ ++- (NSPoint) locationInWindow: (X11Window *) window x: (int) x y: (int) y rootX: (int) rootX rootY: (int) rootY { ++ if (_relativeMouse.active) ++ return [window transformPoint: NSMakePoint(_relativeMouse.virtualX - (rootX - x), ++ _relativeMouse.virtualY - (rootY - y))]; ++ return [window transformPoint: NSMakePoint(x, y)]; ++} ++ + - (void) warpMouse: (NSPoint) position { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolWarpAppChannel, "target=%.1f,%.1f relative=%d/%d virtual=%.1f,%.1f physical=%d,%d " ++ "honoured=%d confined=%lu cachedRoot=%d,%d appActive=%d", ++ position.x, position.y, _relativeRequested, _relativeMouse.active, _relativeMouse.virtualX, ++ _relativeMouse.virtualY, _relativeMouse.physicalX, _relativeMouse.physicalY, ++ !_isXwayland || _cursorHidden, (unsigned long) _confinedWindow, rclolRootX, rclolRootY, ++ [NSApp isActive]); ++ int x = X11RelativeMouseWarpCoordinate(position.x), y = X11RelativeMouseWarpCoordinate(position.y); ++ if (_relativeMouse.active) { ++ /* CGWarpMouseCursorPosition moves the disassociated cursor without producing a delta: the virtual ++ * cursor follows and the warp's echo is dropped. Not honoured on Xwayland with a visible cursor. */ ++ X11RelativeMouseWarp(&_relativeMouse, position.x, position.y, [self warpPointerToRootX: x y: y]); ++ return; ++ } + NSLog(@"Warp to: x=%f, y=%f\n", position.x, position.y); +- XWarpPointer(_display, None, DefaultRootWindow(_display), 0, 0, 0, 0, +- position.x, position.y); +- XSync(_display, False); ++ [self warpPointerToRootX: x y: y]; + } + ++/* Confinement preserves absolute motion and edge panning. Relative mode holds ++ * its own grab and is left alone while requested. */ +- (void) updateMouseConfinementForWindow:(X11Window *)window { -+ if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT check window=%p delegate=%p focused=%p relative=%d\n",window,[window delegate],lastFocusedWindow,_cursorGrabbed); -+ if (!window || _cursorGrabbed) return; ++ if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT check window=%p delegate=%p focused=%p relative=%d\n",window,[window delegate],lastFocusedWindow,_relativeRequested); ++ if (!window || _relativeRequested) { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolConfineChannel, "skip: %s confined=%lu", !window ? "window=nil" : "relative mode requested", ++ (unsigned long) _confinedWindow); ++ return; ++ } + Window win = [window windowHandle]; + Window focus; int revert; + XGetInputFocus(_display, &focus, &revert); @@ -2830,9 +3365,19 @@ index 5ae29bf4..358e606b 100644 + ancestor=parent; + } + if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT focus=%lu target=%lu ancestor=%lu\n",focus,win,ancestor); -+ if (ancestor != win) return; ++ if (ancestor != win) { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolConfineChannel, "skip: not focused window=%lu focus=%lu ancestor=%lu confined=%lu", ++ (unsigned long) win, (unsigned long) focus, (unsigned long) ancestor, ++ (unsigned long) _confinedWindow); ++ return; ++ } + XWindowAttributes attrs; -+ if (!XGetWindowAttributes(_display, win, &attrs)) return; ++ if (!XGetWindowAttributes(_display, win, &attrs)) { ++ if (rclolTraceOn()) ++ rclolTraceRated(&rclolConfineChannel, "skip: XGetWindowAttributes failed window=%lu", (unsigned long) win); ++ return; ++ } + BOOL fullscreen = NO; + Atom actual; int format; unsigned long count, remaining; unsigned char *data = NULL; + Atom state = XInternAtom(_display, "_NET_WM_STATE", False); @@ -2842,6 +3387,7 @@ index 5ae29bf4..358e606b 100644 + for (unsigned long i=0; i%d requested=%d confined %lu->%lu appActive=%d virtual=%.1f,%.1f xwayland=%d " ++ "hidden=%d cachedRoot=%d,%d", ++ rclolGrabGeneration, doGrab, rclolPick, rclolWindow, ++ rclolWindow ? object_getClassName(rclolWindow) : "-", ++ (unsigned long) (rclolWindow ? [(X11Window *) [rclolWindow platformWindow] windowHandle] : None), ++ rclolResult, doGrab ? rclolGrabResultName(rclolResult) : "ungrab", rclolRelativeBefore, ++ _relativeMouse.active, _relativeRequested, (unsigned long) rclolConfinedBefore, ++ (unsigned long) _confinedWindow, [NSApp isActive], _relativeMouse.virtualX, ++ _relativeMouse.virtualY, _isXwayland, _cursorHidden, rclolRootX, rclolRootY); } + + @end diff --git a/AppKit/X11.backend/X11Pasteboard.h b/AppKit/X11.backend/X11Pasteboard.h index 411903cc..4b196afd 100644 --- a/AppKit/X11.backend/X11Pasteboard.h @@ -8671,3 +9327,156 @@ index 00000000..a51a9241 + va_end(copy); +} +#endif +diff --git a/AppKit/X11.backend/X11RelativeMouse.h b/AppKit/X11.backend/X11RelativeMouse.h +new file mode 100644 +index 00000000..7fc39cc4 +--- /dev/null ++++ b/AppKit/X11.backend/X11RelativeMouse.h +@@ -0,0 +1,147 @@ ++/* darling-lol: decision logic for macOS-style relative mouse mode in the X11 backend. ++ * ++ * While CGAssociateMouseAndMouseCursorPosition(false) is in effect, macOS freezes the cursor: physical motion ++ * only produces deltas, events report the frozen cursor position, and CGWarpMouseCursorPosition moves the cursor ++ * without producing a delta. Here the frozen cursor is the "virtual" position (X root coordinates). Core X motion ++ * still moves the real pointer, so deltas are measured against the last physical position, and where the server ++ * honours warps the pointer is warped back to the virtual position after each motion. Every warp (ours or the ++ * application's) is followed by a MotionNotify at its target; those echoes are recognised by position and ++ * dropped. Warps are synchronous (XSync), so an echo can only be among the events already queued at that moment; ++ * an expected echo expires once that many events have been handled. ++ * ++ * On Xwayland a warp only reaches the compositor while the X cursor is hidden. With a visible cursor the echo ++ * still arrives, but the next real motion reports the unmoved physical pointer, so honoured=false keeps the last ++ * physical position across echoes and never warps back. ++ * ++ * Plain C without X11 or Objective-C dependencies, so tools/test-x11-relative-mouse.c can run it on the host. */ ++#ifndef X11_RELATIVE_MOUSE_H ++#define X11_RELATIVE_MOUSE_H ++ ++#include ++ ++#define X11_RELATIVE_MOUSE_MAX_ECHOES 8 ++ ++typedef struct { ++ int x, y; ++ unsigned remaining; /* X events (of any type) still to be handled before this echo is given up */ ++} X11RelativeMouseEcho; ++ ++typedef struct { ++ bool active; /* relative motion handling is on (the pointer grab is held) */ ++ double virtualX, virtualY; /* the frozen macOS cursor, X root coordinates */ ++ int physicalX, physicalY; /* last physical pointer position, X root coordinates */ ++ unsigned echoCount; ++ X11RelativeMouseEcho echoes[X11_RELATIVE_MOUSE_MAX_ECHOES]; /* oldest first */ ++} X11RelativeMouse; ++ ++typedef struct { ++ bool post; /* deliver a mouse moved/dragged event */ ++ bool echo; /* the event was the echo of a warp */ ++ double deltaX, deltaY; /* NSEvent convention: positive deltaY is downwards */ ++ bool rewarp; /* warp the pointer back to (warpX, warpY) */ ++ int warpX, warpY; ++} X11RelativeMouseMotion; ++ ++/* X11 warps take integer coordinates; conversions truncate like the original implicit double-to-int argument. */ ++static inline int X11RelativeMouseWarpCoordinate(double value) { ++ return (int) value; ++} ++ ++static inline void X11RelativeMouseBegin(X11RelativeMouse *mouse, int rootX, int rootY) { ++ mouse->active = true; ++ mouse->virtualX = rootX; ++ mouse->virtualY = rootY; ++ mouse->physicalX = rootX; ++ mouse->physicalY = rootY; ++ mouse->echoCount = 0; ++} ++ ++static inline void X11RelativeMouseEnd(X11RelativeMouse *mouse) { ++ mouse->active = false; ++ mouse->echoCount = 0; ++} ++ ++/* Record a warp to (x, y) that was just sent and synced. queuedEvents is the number of X events queued after the ++ * sync; zero means the warp produced no event (the pointer was already there). */ ++static inline void X11RelativeMouseExpectEcho(X11RelativeMouse *mouse, int x, int y, unsigned queuedEvents) { ++ if (!queuedEvents) return; ++ if (mouse->echoCount) { ++ X11RelativeMouseEcho *last = &mouse->echoes[mouse->echoCount - 1]; ++ if (last->x == x && last->y == y) { ++ if (last->remaining < queuedEvents) last->remaining = queuedEvents; ++ return; ++ } ++ } ++ if (mouse->echoCount == X11_RELATIVE_MOUSE_MAX_ECHOES) { ++ for (unsigned i = 1; i < mouse->echoCount; ++i) ++ mouse->echoes[i - 1] = mouse->echoes[i]; ++ mouse->echoCount--; ++ } ++ mouse->echoes[mouse->echoCount++] = (X11RelativeMouseEcho) {x, y, queuedEvents}; ++} ++ ++/* CGWarpMouseCursorPosition while relative: the virtual cursor moves to the target; the physical position is ++ * only updated when the echo arrives (and only if the warp was honoured). */ ++static inline void X11RelativeMouseWarp(X11RelativeMouse *mouse, double x, double y, unsigned queuedEvents) { ++ mouse->virtualX = x; ++ mouse->virtualY = y; ++ X11RelativeMouseExpectEcho(mouse, X11RelativeMouseWarpCoordinate(x), X11RelativeMouseWarpCoordinate(y), ++ queuedEvents); ++} ++ ++/* One X event has been handled: age the expected echoes. */ ++static inline void X11RelativeMouseTick(X11RelativeMouse *mouse) { ++ unsigned kept = 0; ++ for (unsigned i = 0; i < mouse->echoCount; ++i) ++ if (mouse->echoes[i].remaining > 1) { ++ mouse->echoes[kept] = mouse->echoes[i]; ++ mouse->echoes[kept++].remaining--; ++ } ++ mouse->echoCount = kept; ++} ++ ++/* A MotionNotify at root position (rootX, rootY) while relative. Ages the expected echoes. */ ++static inline X11RelativeMouseMotion X11RelativeMouseHandleMotion(X11RelativeMouse *mouse, int rootX, int rootY, ++ bool warpsHonoured) ++{ ++ X11RelativeMouseMotion result = {false, false, 0.0, 0.0, false, 0, 0}; ++ for (unsigned i = 0; i < mouse->echoCount; ++i) ++ if (mouse->echoes[i].x == rootX && mouse->echoes[i].y == rootY) { ++ /* Warps are echoed in order; earlier expected echoes that did not arrive never will. */ ++ for (unsigned j = i + 1; j < mouse->echoCount; ++j) ++ mouse->echoes[j - i - 1] = mouse->echoes[j]; ++ mouse->echoCount -= i + 1; ++ if (warpsHonoured) { ++ mouse->physicalX = rootX; ++ mouse->physicalY = rootY; ++ } ++ result.echo = true; ++ X11RelativeMouseTick(mouse); ++ return result; ++ } ++ result.deltaX = rootX - mouse->physicalX; ++ result.deltaY = rootY - mouse->physicalY; ++ mouse->physicalX = rootX; ++ mouse->physicalY = rootY; ++ result.post = result.deltaX != 0.0 || result.deltaY != 0.0; ++ int warpX = X11RelativeMouseWarpCoordinate(mouse->virtualX); ++ int warpY = X11RelativeMouseWarpCoordinate(mouse->virtualY); ++ if (warpsHonoured && (rootX != warpX || rootY != warpY)) { ++ result.rewarp = true; ++ result.warpX = warpX; ++ result.warpY = warpY; ++ } ++ X11RelativeMouseTick(mouse); ++ return result; ++} ++ ++/* NSEvent deltas for absolute (non-relative) motion between two positions in Cocoa window coordinates, which ++ * are bottom-up while NSEvent's deltaY is positive downwards. */ ++static inline void X11AbsoluteMouseDelta(double lastX, double lastY, double x, double y, double *deltaX, ++ double *deltaY) ++{ ++ *deltaX = x - lastX; ++ *deltaY = lastY - y; ++} ++ ++#endif diff --git a/tools/test-x11-relative-mouse.c b/tools/test-x11-relative-mouse.c new file mode 100644 index 0000000..489701c --- /dev/null +++ b/tools/test-x11-relative-mouse.c @@ -0,0 +1,139 @@ +#include "../build/darling-src/src/external/cocotron/AppKit/X11.backend/X11RelativeMouse.h" +#include +#include + +/* Host model of the X11 backend's relative mouse mode (CGAssociateMouseAndMouseCursorPosition(false)). + * A warp's echo is simulated as the MotionNotify the server queues at the target; `queued` is the number of + * X events queued after the backend's XSync. */ +static X11RelativeMouseMotion motion(X11RelativeMouse *m, int x, int y, bool honoured) { + return X11RelativeMouseHandleMotion(m, x, y, honoured); +} + +static void xorgHonouredWarps(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 500, 400); + /* Physical motion: delta reported, the cursor stays frozen, the pointer is warped back. */ + X11RelativeMouseMotion r = motion(&m, 510, 395, true); + assert(r.post && !r.echo && r.deltaX == 10 && r.deltaY == -5); + assert(r.rewarp && r.warpX == 500 && r.warpY == 400); + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 1); + /* The rewarp's echo is consumed and is not a delta back to the centre. */ + r = motion(&m, 500, 400, true); + assert(!r.post && r.echo && !r.rewarp && m.echoCount == 0); + assert(m.physicalX == 500 && m.physicalY == 400); + r = motion(&m, 503, 404, true); + assert(r.post && r.deltaX == 3 && r.deltaY == 4 && r.rewarp); + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 1); + + /* A real motion queued before the echo still measures against the last physical position. */ + X11RelativeMouseBegin(&m, 500, 400); + r = motion(&m, 520, 400, true); + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 2); /* the next motion was already queued */ + r = motion(&m, 530, 400, true); + assert(r.post && r.deltaX == 10 && r.deltaY == 0 && r.rewarp); + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 1); /* same target: merged, not a second echo */ + assert(m.echoCount == 1); + r = motion(&m, 500, 400, true); + assert(r.echo && m.echoCount == 0); + r = motion(&m, 499, 400, true); + assert(r.post && r.deltaX == -1); + + /* The application warps from the event it was given while our rewarp's echo is still queued: + * both echoes are dropped in order and neither cancels the motion. */ + X11RelativeMouseBegin(&m, 500, 400); + r = motion(&m, 512, 400, true); + assert(r.deltaX == 12 && r.rewarp); + X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 1); + X11RelativeMouseWarp(&m, 524.0, 400.0, 2); /* game: mouseLocation + 2 * delta */ + assert(m.virtualX == 524.0 && m.physicalX == 512); + r = motion(&m, 500, 400, true); + assert(r.echo && m.physicalX == 500); + r = motion(&m, 524, 400, true); + assert(r.echo && m.physicalX == 524 && m.echoCount == 0); + r = motion(&m, 530, 401, true); + assert(r.post && r.deltaX == 6 && r.deltaY == 1 && r.rewarp && r.warpX == 524 && r.warpY == 400); + puts("PASS: Xorg honoured warps rewarp to the frozen cursor and drop their echoes"); +} + +static void xwaylandVisibleCursor(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 800, 450); + /* Warps are not honoured: no rewarp, the pointer moves freely, deltas follow it. */ + X11RelativeMouseMotion r = motion(&m, 810, 450, false); + assert(r.post && r.deltaX == 10 && !r.rewarp); + X11RelativeMouseWarp(&m, 820.0, 450.0, 1); + /* The echo at the target is dropped without moving the physical position... */ + r = motion(&m, 820, 450, false); + assert(r.echo && !r.post && m.physicalX == 810 && m.physicalY == 450); + /* ...so the next real motion, at the real absolute position, gives the true delta. */ + r = motion(&m, 812, 453, false); + assert(r.post && r.deltaX == 2 && r.deltaY == 3 && !r.rewarp); + assert(m.virtualX == 820.0 && m.virtualY == 450.0); + /* An expected echo that never arrives expires after the queued events and cannot eat a later motion. */ + X11RelativeMouseWarp(&m, 700.0, 400.0, 1); + X11RelativeMouseTick(&m); /* some other X event was the one queued */ + assert(m.echoCount == 0); + r = motion(&m, 700, 400, false); + assert(r.post && !r.echo && r.deltaX == -112 && r.deltaY == -53); + puts("PASS: Xwayland visible cursor keeps the physical position across unhonoured warp echoes"); +} + +static void gameWarpUpdatesVirtual(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 100, 100); + X11RelativeMouseWarp(&m, 150.75, 120.5, 0); /* no event: the pointer was already there */ + assert(m.virtualX == 150.75 && m.virtualY == 120.5 && m.echoCount == 0); + assert(m.physicalX == 100 && m.physicalY == 100); + /* mouseLocation reports the virtual cursor, Cocoa bottom-up global, for a 1080-pixel screen. */ + double cocoaX = m.virtualX, cocoaY = 1080 - m.virtualY; + assert(cocoaX == 150.75 && cocoaY == 959.5); + X11RelativeMouseMotion r = motion(&m, 101, 100, true); + assert(r.post && r.deltaX == 1 && r.rewarp && r.warpX == 150 && r.warpY == 120); + puts("PASS: application warps while relative move the virtual cursor that mouseLocation returns"); +} + +static void deltaSign(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 0, 0); + X11RelativeMouseMotion r = motion(&m, 0, 7, false); + assert(r.deltaY == 7); /* X root y grows downwards: positive deltaY means down, as on macOS */ + double dx, dy; + /* Absolute motion in Cocoa window coordinates (bottom-up): moving down lowers y. */ + X11AbsoluteMouseDelta(10, 300, 14, 290, &dx, &dy); + assert(dx == 4 && dy == 10); + puts("PASS: deltaY is positive downwards for relative and absolute motion"); +} + +static void ungrabRestores(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 10, 10); + X11RelativeMouseWarp(&m, 40, 40, 3); + X11RelativeMouseEnd(&m); + assert(!m.active && m.echoCount == 0); + X11RelativeMouseBegin(&m, 60, 70); + assert(m.active && m.virtualX == 60 && m.virtualY == 70 && m.physicalX == 60 && m.physicalY == 70); + X11RelativeMouseMotion r = motion(&m, 40, 40, true); + assert(r.post && !r.echo && r.deltaX == -20 && r.deltaY == -30); + puts("PASS: ungrab clears expected echoes and a new grab starts from the current pointer"); +} + +static void echoQueueBound(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 0, 0); + for (int i = 1; i <= X11_RELATIVE_MOUSE_MAX_ECHOES + 3; ++i) + X11RelativeMouseWarp(&m, i, 0, 100); + assert(m.echoCount == X11_RELATIVE_MOUSE_MAX_ECHOES && m.echoes[0].x == 4); + X11RelativeMouseMotion r = motion(&m, 6, 0, true); + assert(r.echo && m.echoCount == X11_RELATIVE_MOUSE_MAX_ECHOES - 3 && m.echoes[0].x == 7); + puts("PASS: expected echoes are bounded and consumed in order"); +} + +int main(void) { + xorgHonouredWarps(); + xwaylandVisibleCursor(); + gameWarpUpdatesVirtual(); + deltaSign(); + ungrabRestores(); + echoQueueBound(); + return 0; +} From 67180772177665a1c88625b7c2fee212337cf4c8 Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 15:18:04 +0200 Subject: [PATCH 027/160] Fix GNOME 51 Wayland fullscreen, mouse confinement and top bar / dock popup --- patches/cocotron.patch | 139 ++++++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 63 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index dd95907..5ef6273 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2355,7 +2355,6 @@ index 5ae29bf4..358e606b 100644 - (void) setWindow: (id) window forID: (XID) i { @@ -962,20 +1075,85 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { - { #ifndef DARLING [[NSRunLoop currentRunLoop] addInputSource: _inputSource forMode: mode]; @@ -2563,6 +2562,7 @@ index 5ae29bf4..358e606b 100644 break; case ButtonPress:; ++ [self updateMouseConfinementForWindow:window]; + if (ev->xbutton.button >= Button4 && ev->xbutton.button <= 7) { + pos = [window transformPoint:NSMakePoint(ev->xbutton.x, ev->xbutton.y)]; + CGFloat wheelX = ev->xbutton.button == 6 ? 1.0 : @@ -2851,65 +2851,46 @@ index 5ae29bf4..358e606b 100644 + if (data) XFree(data); + + /* League's borderless fullscreen does not set the EWMH fullscreen bit. -+ * Recognize a borderless window covering a monitor, including multi-monitor -+ * setups and Wayland/GNOME top panel offsets. */ ++ * Recognize a borderless game window, multi-monitor setups, and GNOME Wayland. */ + NSWindow *delegate = [window delegate]; + BOOL isBorderless = delegate ? !([delegate styleMask] & NSTitledWindowMask) : YES; -+ BOOL isAppWindow = delegate ? !([delegate isKindOfClass:[NSMenuWindow class]] || -+ [delegate isKindOfClass:[NSPopUpWindow class]] || -+ ![delegate canBecomeKeyWindow]) : YES; ++ NSString *procName = [[NSProcessInfo processInfo] processName]; ++ BOOL isGame = (procName && [procName rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound && ++ [procName rangeOfString:@"Ux" options:NSCaseInsensitiveSearch].location == NSNotFound); ++ NSString *winTitle = [delegate respondsToSelector:@selector(title)] ? [delegate title] : nil; ++ if (winTitle && [winTitle rangeOfString:@"League of Legends" options:NSCaseInsensitiveSearch].location != NSNotFound && ++ [winTitle rangeOfString:@"Riot" options:NSCaseInsensitiveSearch].location == NSNotFound) { ++ isGame = YES; ++ } + -+ if (isBorderless && isAppWindow) { -+ Window child; int x = 0, y = 0; -+ XTranslateCoordinates(_display, win, DefaultRootWindow(_display), 0, 0, &x, &y, &child); -+ NSRect delegateFrame = delegate ? [delegate frame] : NSZeroRect; -+ XRRScreenResources *resources = XRRGetScreenResourcesCurrent(_display, DefaultRootWindow(_display)); -+ if (resources) { -+ for (int i=0; incrtc; ++i) { -+ XRRCrtcInfo *crtc = XRRGetCrtcInfo(_display, resources, resources->crtcs[i]); -+ if (crtc) { -+ if (crtc->mode) { -+ BOOL sizeMatchesCrtc = -+ (abs(attrs.width - (int)crtc->width) <= 4 && -+ (abs(attrs.height - (int)crtc->height) <= 4 || -+ attrs.height >= (int)crtc->height - 60)) || -+ (delegateFrame.size.width > 0 && -+ abs((int)delegateFrame.size.width - (int)crtc->width) <= 4 && -+ abs((int)delegateFrame.size.height - (int)crtc->height) <= 4); ++ if (isBorderless && (isGame || attrs.width >= 1024)) { ++ 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); + -+ BOOL posMatchesCrtc = -+ abs(x - crtc->x) <= 4 && -+ (y >= crtc->y - 4 && y <= crtc->y + 60); -+ -+ if (sizeMatchesCrtc && (posMatchesCrtc || delegateFrame.size.width > 0)) { -+ 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); -+ XFlush(_display); -+ if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT promoted to EWMH FULLSCREEN for window %lu\n",win); -+ } -+ } -+ } -+ XRRFreeCrtcInfo(crtc); -+ if (fullscreen) break; -+ } -+ } -+ XRRFreeScreenResources(resources); ++ /* Also set property directly on window for immediate Mutter state sync */ ++ XChangeProperty(_display, win, state, XA_ATOM, 32, PropModeReplace, ++ (const unsigned char *)&fs, 1); ++ unsigned long bypass = 1; ++ XChangeProperty(_display, win, XInternAtom(_display, "_NET_WM_BYPASS_COMPOSITOR", False), ++ XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&bypass, 1); ++ XFlush(_display); ++ if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT promoted to EWMH FULLSCREEN for window %lu\n",win); + } + } + @@ -2925,7 +2906,8 @@ index 5ae29bf4..358e606b 100644 + } + if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT focus=%lu target=%lu ancestor=%lu fullscreen=%d\n",focus,win,ancestor,fullscreen); + -+ if (!fullscreen || attrs.map_state != IsViewable || ancestor != win) { ++ BOOL isFocused = (ancestor == win || focus == win || (isGame && (focus == PointerRoot || focus == None))); ++ if ((!fullscreen && !isGame) || attrs.map_state != IsViewable || !isFocused) { + if (_confinedWindow == win) { XUngrabPointer(_display, CurrentTime); _confinedWindow=None; } + return; + } @@ -3242,8 +3224,28 @@ index f4925b66..5ad2d1f7 100644 @implementation X11Window -@@ -193,8 +221,13 @@ static NSData *makeWindowIcon() { +@@ -191,10 +219,30 @@ static NSData *makeWindowIcon() { + return nil; + } ++ NSString *procName = [[NSProcessInfo processInfo] processName]; ++ BOOL isGame = (procName && [procName rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound && ++ [procName rangeOfString:@"Ux" options:NSCaseInsensitiveSearch].location == NSNotFound); ++ NSString *winTitle = [delegate respondsToSelector:@selector(title)] ? [delegate title] : nil; ++ if (winTitle && [winTitle rangeOfString:@"League of Legends" options:NSCaseInsensitiveSearch].location != NSNotFound && ++ [winTitle rangeOfString:@"Riot" 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; ++ } ++ } ++ XSetWindowAttributes xattr; unsigned long xattr_mask; - xattr.override_redirect = @@ -3251,14 +3253,11 @@ index f4925b66..5ad2d1f7 100644 + // 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 = _styleMask == NSBorderlessWindowMask && -+ ([delegate isKindOfClass:[NSMenuWindow class]] || -+ [delegate isKindOfClass:[NSPopUpWindow class]] || -+ ![delegate canBecomeKeyWindow]) ? True : False; ++ xattr.override_redirect = (!isGame && _styleMask == NSBorderlessWindowMask && isMenuOrPopup) ? True : False; xattr_mask = CWOverrideRedirect | CWColormap; xattr.colormap = cmap; -@@ -206,6 +239,12 @@ static NSData *makeWindowIcon() { +@@ -206,6 +254,12 @@ static NSData *makeWindowIcon() { (_visualInfo == NULL) ? CopyFromParent : _visualInfo->visual, xattr_mask, &xattr); @@ -3271,6 +3270,20 @@ index f4925b66..5ad2d1f7 100644 [self syncDelegateProperties]; Atom atm = XInternAtom(_display, "WM_DELETE_WINDOW", False); +@@ -217,6 +271,16 @@ 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); ++ unsigned long bypass = 1; ++ XChangeProperty(_display, _window, XInternAtom(_display, "_NET_WM_BYPASS_COMPOSITOR", False), ++ XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&bypass, 1); ++ } ++ @@ -266,6 +305,11 @@ static NSData *makeWindowIcon() { [self setWindowIcon]; From 7f0e57fb5efaaba9112f3050a36b0a2c06d5a242 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:52:42 +0200 Subject: [PATCH 028/160] Fix X11 primary-monitor coordinates without changing fullscreen policy Adapt the geometry fix from PR #12 to the current relative-mouse implementation. Keep display IDs independent of primary-first screen ordering, translate window and pointer coordinates consistently, and invalidate geometry on both RandR event types. Handle failed RandR snapshots and free EDID data. Add isolated native geometry regression coverage and use the configured SDK when linking the X11 probe. Validated all 30 component patches, the X11 and CoreGraphics builds, six native geometry groups, and the existing relative-mouse checks. The user confirmed the installed fix works on 2026-10-16. Co-authored-by: Maka --- docs/TESTING.md | 31 ++ patches/cocotron.patch | 496 +++++++++++++++++++++++++--- tools/build-riot-x11-runtime.py | 7 +- tools/check-x11-display-geometry.py | 70 ++++ tools/riot-display-geometry-check.m | 144 ++++++++ 5 files changed, 705 insertions(+), 43 deletions(-) create mode 100644 tools/check-x11-display-geometry.py create mode 100644 tools/riot-display-geometry-check.m diff --git a/docs/TESTING.md b/docs/TESTING.md index 110da0c..8f266f8 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -412,3 +412,34 @@ and exercises 100 cancellations with both retained and early-released caller references, without launching or inspecting Riot. A failure prints `CFHOST_CANCEL_CHECK fail stage=...`; `unexpected-callback` means a completed lookup arrived instead of the cancellation. + +## Primary-monitor geometry regression + +After preparing the sources and configuring/building Darling, run: + +```sh +python3 scripts/prepare.py --check-only +python3 tools/check-x11-display-geometry.py +cc -std=c11 -Wall -Wextra -Werror tools/test-x11-relative-mouse.c -o build/test-x11-relative-mouse +build/test-x11-relative-mouse +``` + +The native probe requires the existing per-user AppKit and CoreGraphics overlays +and a running X11/Xwayland display. It rebuilds the changed X11 and CoreGraphics +objects and tests them in a temporary framework overlay, without installing them. +Six `DISPLAY_GEOMETRY ...=pass` lines are required; full output is saved in +`build/pr12-display-check.log`. + +It checks the live primary screen's origin, synthetic single-monitor and offset +layouts (including vertically arranged screens and negative Cocoa coordinates), +unmapped window creation/resizing, display IDs after primary-first ordering, +CoreGraphics bounds and spatial queries, absolute and relative warp translation, +relative deltas, both RandR cache-invalidation events, and the root-screen fallback. +Warps are intercepted: the probe does not move the desktop pointer or send game +input. Synthetic layouts exercise coordinate conversion and display API behavior; +they do not simulate physical monitor hotplug or validate League rendering. +Fullscreen policy is unchanged. Manually verify game launch, window placement, +click alignment and edge panning on the affected monitor layout before rollout. + +After installing the tested X11 and CoreGraphics binaries locally, the user +reported that everything was working (2026-10-16). Fullscreen policy was unchanged. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 00a9399..7acc4cd 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2107,7 +2107,7 @@ 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..0eff2187 100644 +index 18189404..5b3a34bc 100644 --- a/AppKit/X11.backend/X11Display.h +++ b/AppKit/X11.backend/X11Display.h @@ -10,13 +10,14 @@ @@ -2126,7 +2126,7 @@ index 18189404..0eff2187 100644 @interface X11Display : NSDisplay { Display *_display; -@@ -36,8 +37,20 @@ +@@ -36,19 +37,41 @@ NSTimeInterval lastClickTimeStamp; int clickCount; X11Cursor *_blankCursor, *_defaultCursor; @@ -2147,8 +2147,11 @@ index 18189404..0eff2187 100644 + unsigned char _modifierKeysDown[256]; int _rrEventBase; NSArray* _lastScreens; ++ // Root-window rectangle of the primary monitor (macOS's global origin). ++ NSRect _primaryRootFrame; ++ BOOL _primaryRootFrameValid; -@@ -45,7 +58,9 @@ + @public XIM _xim; } @@ -2158,8 +2161,16 @@ index 18189404..0eff2187 100644 - (void) setWindow: (id) window forID: (XID) i; + - (CGFloat) doubleClickInterval; + - (int) handleError: (XErrorEvent *) errorEvent; ++ ++// Conversions between X root coordinates (top-left of the root window, y down) ++// and Cocoa global coordinates (bottom-left of the primary screen, y up). ++- (NSRect) rootRectForFrame: (NSRect) frame; ++- (NSRect) frameForRootRect: (NSRect) rect; + @end diff --git a/AppKit/X11.backend/X11Display.m b/AppKit/X11.backend/X11Display.m -index 5ae29bf4..024d3ec2 100644 +index 5ae29bf4..86a647ff 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -2540,7 +2551,183 @@ index 5ae29bf4..024d3ec2 100644 - (void) _enableDetectableAutoRepeat { int major = XkbMajorVersion, minor = XkbMinorVersion; XkbStateRec state; -@@ -536,12 +861,52 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -190,6 +515,35 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, + [_lastScreens release]; + _lastScreens = nil; + } ++ _primaryRootFrameValid = NO; ++} ++ ++/* Cocoa's global origin is the bottom-left corner of the primary screen, with ++ * y up; X's is the top-left corner of the root window, with y down. Screen ++ * frames and window frames are both expressed relative to the primary ++ * monitor's root rectangle. On a single monitor (or any layout whose primary ++ * sits at the root origin and is as tall as the root) this is the plain ++ * root-height flip with no offset. */ ++- (NSRect) _primaryRootFrame { ++ if (!_primaryRootFrameValid) ++ [self screens]; ++ return _primaryRootFrame; ++} ++ ++- (NSRect) rootRectForFrame: (NSRect) frame { ++ NSRect primary = [self _primaryRootFrame]; ++ return NSMakeRect(frame.origin.x + NSMinX(primary), ++ NSMaxY(primary) - frame.origin.y - frame.size.height, ++ fmax(frame.size.width, 1.0), ++ fmax(frame.size.height, 1.0)); ++} ++ ++- (NSRect) frameForRootRect: (NSRect) rect { ++ NSRect primary = [self _primaryRootFrame]; ++ return NSMakeRect(rect.origin.x - NSMinX(primary), ++ NSMaxY(primary) - rect.origin.y - rect.size.height, ++ fmax(rect.size.width, 1.0), ++ fmax(rect.size.height, 1.0)); + } + + - (NSArray *) screens { +@@ -201,25 +555,60 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, + + int eventBase, errorBase; + +- if (XRRQueryExtension(_display, &eventBase, &errorBase)) { +- XRRScreenResources *screen; +- ++ XRRScreenResources *screen = NULL; ++ if (XRRQueryExtension(_display, &eventBase, &errorBase)) + screen = XRRGetScreenResources(_display, DefaultRootWindow(_display)); ++ ++ if (screen) { ++ RROutput primaryOutput = ++ XRRGetOutputPrimary(_display, DefaultRootWindow(_display)); ++ NSRect *rootFrames = calloc(screen->noutput ? screen->noutput : 1, ++ sizeof(NSRect)); ++ if (!rootFrames) { ++ XRRFreeScreenResources(screen); ++ return [self _rootScreenFallback]; ++ } ++ int primary = -1; ++ ++ for (int i = 0; i < screen->noutput; i++) { ++ XRROutputInfo *oinfo = ++ XRRGetOutputInfo(_display, screen, screen->outputs[i]); ++ if (oinfo && oinfo->crtc) { ++ XRRCrtcInfo *crtc = ++ XRRGetCrtcInfo(_display, screen, oinfo->crtc); ++ if (crtc) { ++ rootFrames[i] = ++ NSMakeRect(crtc->x, crtc->y, crtc->width, crtc->height); ++ XRRFreeCrtcInfo(crtc); ++ } ++ } ++ // The XRandR primary, otherwise the first active output. ++ if (!NSIsEmptyRect(rootFrames[i]) && ++ (primary < 0 || screen->outputs[i] == primaryOutput)) ++ primary = i; ++ if (oinfo) XRRFreeOutputInfo(oinfo); ++ } ++ ++ // Without any active output, keep the root-height flip. ++ _primaryRootFrame = primary >= 0 ++ ? rootFrames[primary] ++ : NSMakeRect(0, 0, DisplayWidth(_display, DefaultScreen(_display)), ++ DisplayHeight(_display, DefaultScreen(_display))); ++ _primaryRootFrameValid = YES; ++ ++ // [NSScreen screens] starts with the primary screen, as on macOS. ++ // Display IDs stay tied to the XRandR output index. + NSMutableArray *retval = + [NSMutableArray arrayWithCapacity: screen->noutput]; ++ NSMutableArray *inactive = [NSMutableArray array]; + + Atom edidAtom = XInternAtom(_display, "EDID", FALSE); + + for (int i = 0; i < screen->noutput; i++) { +- XRROutputInfo *oinfo = +- XRRGetOutputInfo(_display, screen, screen->outputs[i]); + NSScreen *nsscreen; + +- if (oinfo->crtc) { +- XRRCrtcInfo *crtc = +- XRRGetCrtcInfo(_display, screen, oinfo->crtc); +- NSRect frame = +- NSMakeRect(crtc->x, crtc->y, crtc->width, crtc->height); ++ if (!NSIsEmptyRect(rootFrames[i])) { ++ NSRect frame = [self frameForRootRect: rootFrames[i]]; + + nsscreen = + [[[NSScreen alloc] initWithFrame: frame +@@ -228,18 +617,17 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, + Atom actualType; + unsigned long nitems, bytesAfter; + int actualFormat; +- unsigned char *prop; ++ unsigned char *prop = NULL; + + if (XRRGetOutputProperty(_display, screen->outputs[i], edidAtom, + 0, 100, FALSE, FALSE, AnyPropertyType, + &actualType, &actualFormat, &nitems, + &bytesAfter, &prop) == Success) { +- if (prop && nitems > 0) ++ if (prop && actualFormat == 8 && nitems > 0) + [nsscreen setEdid: [NSData dataWithBytes: prop + length: nitems]]; + } +- +- XRRFreeCrtcInfo(crtc); ++ if (prop) XFree(prop); + } else { + nsscreen = [[[NSScreen alloc] initWithFrame: NSZeroRect + visibleFrame: NSZeroRect] +@@ -247,25 +635,35 @@ static void socketCallback(CFSocketRef s, CFSocketCallBackType type, + } + [nsscreen setCgDirectDisplayID: (i + 1)]; + +- [retval addObject: nsscreen]; +- +- XRRFreeOutputInfo(oinfo); ++ if (i == primary) ++ [retval insertObject: nsscreen atIndex: 0]; ++ else if (!NSIsEmptyRect(rootFrames[i])) ++ [retval addObject: nsscreen]; ++ else ++ [inactive addObject: nsscreen]; + } ++ [retval addObjectsFromArray: inactive]; + ++ free(rootFrames); + XRRFreeScreenResources(screen); + + NSArray* array = [NSArray arrayWithArray: retval]; + _lastScreens = [array retain]; + return array; +- } else { +- NSRect frame = NSMakeRect( +- 0, 0, DisplayWidth(_display, DefaultScreen(_display)), +- DisplayHeight(_display, DefaultScreen(_display))); +- return [NSArray +- arrayWithObject: [[[NSScreen alloc] initWithFrame: frame +- visibleFrame: frame] +- autorelease]]; + } ++ return [self _rootScreenFallback]; ++} ++ ++- (NSArray *) _rootScreenFallback { ++ NSRect frame = NSMakeRect( ++ 0, 0, DisplayWidth(_display, DefaultScreen(_display)), ++ DisplayHeight(_display, DefaultScreen(_display))); ++ _primaryRootFrame = frame; ++ _primaryRootFrameValid = YES; ++ NSScreen *nsscreen = [[[NSScreen alloc] initWithFrame: frame ++ visibleFrame: frame] autorelease]; ++ [nsscreen setCgDirectDisplayID: 1]; ++ return [NSArray arrayWithObject: nsscreen]; + } + + static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -536,12 +934,52 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return &layout->layout; } @@ -2595,7 +2782,7 @@ index 5ae29bf4..024d3ec2 100644 } - (NSPasteboard *) pasteboardWithName: (NSString *) name { -@@ -675,6 +1040,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -675,6 +1113,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { blue: 0.60f alpha: 1.0f]; @@ -2611,7 +2798,7 @@ index 5ae29bf4..024d3ec2 100644 NSLog(@"missing color for %@", colorName); return [NSColor redColor]; } -@@ -688,6 +1062,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -688,6 +1135,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } - (void) hideCursor { @@ -2622,7 +2809,7 @@ index 5ae29bf4..024d3ec2 100644 if (!_blankCursor) _blankCursor = [[X11Cursor alloc] initBlank]; [self setCursor: _blankCursor]; -@@ -695,6 +1073,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -695,6 +1146,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { - (void) unhideCursor { NSCursor *current = [NSCursor currentCursor]; @@ -2633,7 +2820,7 @@ index 5ae29bf4..024d3ec2 100644 if (current != nil) { [current push]; [current pop]; -@@ -747,12 +1129,28 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -747,12 +1202,28 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { - (void) setCursor: (id) cursor { CGWindow *window = [[NSApp keyWindow] platformWindow]; @@ -2667,7 +2854,7 @@ index 5ae29bf4..024d3ec2 100644 } } -@@ -921,15 +1319,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -921,15 +1392,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } - (NSPoint) mouseLocation { @@ -2678,19 +2865,20 @@ index 5ae29bf4..024d3ec2 100644 - - XQueryPointer(_display, root, &root, &child, &root_x, &root_y, &win_x, - &win_y, &mask); +- int height = DisplayHeight(_display, DefaultScreen(_display)); +- return NSMakePoint(root_x, height - root_y); ++ NSRect primary = [self _primaryRootFrame]; + if (_relativeMouse.active) { + /* The cursor is disassociated: report the frozen (virtual) position, as macOS does. */ -+ NSPoint result = NSMakePoint(_relativeMouse.virtualX, -+ DisplayHeight(_display, DefaultScreen(_display)) - _relativeMouse.virtualY); ++ NSPoint result = NSMakePoint(_relativeMouse.virtualX - NSMinX(primary), ++ NSMaxY(primary) - _relativeMouse.virtualY); + if (rclolTraceOn()) + rclolTraceMouseLocation(result, YES, YES, (int) _relativeMouse.virtualX, (int) _relativeMouse.virtualY, YES); + return result; + } + uint64_t rclolStampBefore = rclolPointerStamp; + BOOL rclolQueried = rclolQueryPointer(_display); - int height = DisplayHeight(_display, DefaultScreen(_display)); -- return NSMakePoint(root_x, height - root_y); -+ NSPoint result = NSMakePoint(rclolRootX, height - rclolRootY); ++ NSPoint result = NSMakePoint(rclolRootX - NSMinX(primary), NSMaxY(primary) - rclolRootY); + if (rclolTraceOn()) + rclolTraceMouseLocation(result, rclolQueried, rclolStampBefore != 0 && rclolPointerStamp == rclolStampBefore, + rclolRootX, rclolRootY, NO); @@ -2698,7 +2886,7 @@ index 5ae29bf4..024d3ec2 100644 } - (void) setWindow: (id) window forID: (XID) i { -@@ -948,6 +1353,16 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -948,6 +1426,16 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { // retaining the window causes it to keep popping back up endlessly even after dismissing it if (lastFocusedWindow && [[lastFocusedWindow platformWindow] windowHandle] == i) lastFocusedWindow = nil; @@ -2715,7 +2903,7 @@ index 5ae29bf4..024d3ec2 100644 } } -@@ -962,20 +1377,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -962,20 +1450,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { { #ifndef DARLING [[NSRunLoop currentRunLoop] addInputSource: _inputSource forMode: mode]; @@ -2803,7 +2991,7 @@ index 5ae29bf4..024d3ec2 100644 } - (NSEventModifierFlags) modifierFlagsForState: (unsigned int) state { -@@ -1010,17 +1487,96 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1010,17 +1560,96 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return result; } @@ -2900,7 +3088,7 @@ index 5ae29bf4..024d3ec2 100644 NSEventModifierFlags modifierFlags = [self modifierFlagsForState: ev->xkey.state]; char buf[20] = {0}; KeySym keySym; -@@ -1038,6 +1594,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1038,6 +1667,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { buf[strLen] = 0; } @@ -2919,7 +3107,7 @@ index 5ae29bf4..024d3ec2 100644 id str = [[NSString alloc] initWithCString: buf encoding: NSUTF8StringEncoding]; NSPoint pos = -@@ -1077,6 +1645,30 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1077,6 +1718,30 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ButtonPress:; @@ -2950,7 +3138,7 @@ index 5ae29bf4..024d3ec2 100644 NSTimeInterval now = [[NSDate date] timeIntervalSinceReferenceDate]; if (now - lastClickTimeStamp < [self doubleClickInterval]) { -@@ -1086,8 +1678,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1086,8 +1751,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } lastClickTimeStamp = now; @@ -2961,7 +3149,7 @@ index 5ae29bf4..024d3ec2 100644 switch (ev->xbutton.button) { case Button1: -@@ -1096,10 +1688,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1096,10 +1761,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseDown; break; @@ -2972,7 +3160,7 @@ index 5ae29bf4..024d3ec2 100644 default: type = NSOtherMouseDown; } -@@ -1113,14 +1701,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1113,14 +1774,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: 0.0]; @@ -2999,7 +3187,7 @@ index 5ae29bf4..024d3ec2 100644 CGFloat deltaY = 0.0; switch (ev->xbutton.button) { -@@ -1130,14 +1726,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1130,14 +1799,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseUp; break; @@ -3014,7 +3202,7 @@ index 5ae29bf4..024d3ec2 100644 default: type = NSOtherMouseUp; } -@@ -1151,7 +1739,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1151,7 +1812,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: deltaY]; @@ -3025,7 +3213,7 @@ index 5ae29bf4..024d3ec2 100644 [self postEvent: event atStart: NO]; break; -@@ -1164,45 +1754,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1164,45 +1827,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { pos = [window transformPoint: NSMakePoint(ev->xmotion.x, ev->xmotion.y)]; @@ -3134,7 +3322,7 @@ index 5ae29bf4..024d3ec2 100644 mouseEventWithType: type location: pos modifierFlags: -@@ -1211,28 +1838,49 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +1911,49 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -3186,7 +3374,7 @@ index 5ae29bf4..024d3ec2 100644 NSLog(@"FocusIn"); if ([delegate attachedSheet]) { [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; -@@ -1243,28 +1891,55 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +1964,55 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -3244,7 +3432,7 @@ index 5ae29bf4..024d3ec2 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +1976,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +2049,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -3272,7 +3460,7 @@ index 5ae29bf4..024d3ec2 100644 break; case MapRequest: -@@ -1321,6 +2008,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1321,6 +2081,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { [delegate platformWindow: window frameChanged: [window frame] didSize: YES]; @@ -3280,7 +3468,7 @@ index 5ae29bf4..024d3ec2 100644 break; case ConfigureRequest: -@@ -1344,6 +2032,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2105,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -3289,7 +3477,18 @@ index 5ae29bf4..024d3ec2 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1433,6 +2123,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1399,7 +2162,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + break; + + default: +- if (ev->type == _rrEventBase + RRNotify) { ++ if (_rrEventBase && (ev->type == _rrEventBase + RRNotify || ++ ev->type == _rrEventBase + RRScreenChangeNotify)) { ++ XRRUpdateConfiguration(ev); + [self _invalidateRRCache]; + break; + } +@@ -1433,6 +2198,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); @@ -3300,7 +3499,7 @@ index 5ae29bf4..024d3ec2 100644 return 0; } -@@ -1476,54 +2170,214 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2245,218 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } @@ -3322,7 +3521,11 @@ index 5ae29bf4..024d3ec2 100644 + return [window transformPoint: NSMakePoint(x, y)]; +} + ++// CGWarpMouseCursorPosition uses the primary screen's top-left, with y down. - (void) warpMouse: (NSPoint) position { ++ NSRect primary = [self _primaryRootFrame]; ++ position.x += NSMinX(primary); ++ position.y += NSMinY(primary); + if (rclolTraceOn()) + rclolTraceRated(&rclolWarpAppChannel, "target=%.1f,%.1f relative=%d/%d virtual=%.1f,%.1f physical=%d,%d " + "honoured=%d confined=%lu cachedRoot=%d,%d appActive=%d", @@ -3747,7 +3950,7 @@ index 0e91a5d1..1e4cec1b 100644 - (void) selectionNotify: (XSelectionEvent *) event { diff --git a/AppKit/X11.backend/X11Window.h b/AppKit/X11.backend/X11Window.h -index bfe3839a..2521ffd5 100644 +index bfe3839a..dbc974a0 100644 --- a/AppKit/X11.backend/X11Window.h +++ b/AppKit/X11.backend/X11Window.h @@ -26,7 +26,7 @@ @@ -3786,8 +3989,11 @@ index bfe3839a..2521ffd5 100644 @public XIC _xic; -@@ -63,6 +83,7 @@ - - (O2Rect) transformFrame: (O2Rect) frame; +@@ -60,9 +80,9 @@ + - (Visual *) visual; + - (Drawable) drawable; + - (NSPoint) transformPoint: (NSPoint) pos; +-- (O2Rect) transformFrame: (O2Rect) frame; - (Window) windowHandle; +- (BOOL) handleRemoteFrameExpose: (Window) window; @@ -3795,7 +4001,7 @@ index bfe3839a..2521ffd5 100644 - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m -index f4925b66..5ad2d1f7 100644 +index f4925b66..6f653f6d 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -38,6 +38,34 @@ @@ -3833,6 +4039,15 @@ index f4925b66..5ad2d1f7 100644 @implementation X11Window +@@ -158,7 +186,7 @@ static NSData *makeWindowIcon() { + X11Display* x11disp = (X11Display *) [NSDisplay currentDisplay]; + _display = [x11disp display]; + +- _frame = [self transformFrame: [delegate frame]]; ++ _frame = [x11disp rootRectForFrame: [delegate frame]]; + BOOL isPanel = [delegate isKindOfClass: [NSPanel class]]; + if (isPanel && _styleMask & NSDocModalWindowMask) + _styleMask = NSBorderlessWindowMask; @@ -193,8 +221,13 @@ static NSData *makeWindowIcon() { XSetWindowAttributes xattr; @@ -4341,7 +4556,13 @@ index f4925b66..5ad2d1f7 100644 - (Window) windowHandle { return _window; } -@@ -478,6 +938,15 @@ static NSData *makeWindowIcon() { +@@ -473,11 +933,20 @@ static NSData *makeWindowIcon() { + } + + - (void) setFrame: (O2Rect) frame { +- frame = [self transformFrame: frame]; ++ frame = [(X11Display *) [NSDisplay currentDisplay] rootRectForFrame: frame]; + XMoveResizeWindow(_display, _window, frame.origin.x, frame.origin.y, frame.size.width, frame.size.height); [self invalidateContextWithNewSize: frame.size]; _frame = frame; @@ -4426,6 +4647,15 @@ index f4925b66..5ad2d1f7 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); +@@ -652,7 +1168,7 @@ static NSData *makeWindowIcon() { + } + + - (O2Rect) frame { +- return [self transformFrame: _frame]; ++ return [(X11Display *) [NSDisplay currentDisplay] frameForRootRect: _frame]; + } + + static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { @@ -666,6 +1182,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } @@ -4434,6 +4664,21 @@ index f4925b66..5ad2d1f7 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; +@@ -710,14 +1227,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { + [_deviceDictionary addEntriesFromDictionary: entries]; + } + +-- (O2Rect) transformFrame: (O2Rect) frame { +- return NSMakeRect(frame.origin.x, +- DisplayHeight(_display, DefaultScreen(_display)) - +- frame.origin.y - frame.size.height, +- fmax(frame.size.width, 1.0), +- fmax(frame.size.height, 1.0)); +-} +- + - (NSPoint) transformPoint: (NSPoint) pos; { + return NSMakePoint(pos.x, _frame.size.height - pos.y); + } diff --git a/AppKit/include/AppKit/NSAccessibilityConstants.h b/AppKit/include/AppKit/NSAccessibilityConstants.h index 282312de..03c75b2b 100644 --- a/AppKit/include/AppKit/NSAccessibilityConstants.h @@ -5016,10 +5261,179 @@ index db8869c8..6fea5107 100644 + CGContextSetTextPosition(c, savedPosition.x, savedPosition.y); } diff --git a/CoreGraphics/CGDirectDisplay.m b/CoreGraphics/CGDirectDisplay.m -index e213b066..94d5c7bb 100644 +index e213b066..569d77f2 100644 --- a/CoreGraphics/CGDirectDisplay.m +++ b/CoreGraphics/CGDirectDisplay.m -@@ -474,7 +474,9 @@ CGError CGAssociateMouseAndMouseCursorPosition(boolean_t connected) { +@@ -53,6 +53,29 @@ static NSDisplay *currentDisplay(void) { + return [cls currentDisplay]; + } + ++/* Display IDs belong to the screens themselves; [NSScreen screens] lists the ++ * primary screen first, so array positions are not IDs. */ ++static NSScreen *screenForDisplayID(NSArray *screens, ++ CGDirectDisplayID displayID) ++{ ++ for (NSScreen *screen in screens) { ++ if ([screen cgDirectDisplayID] == displayID) ++ return screen; ++ } ++ return nil; ++} ++ ++/* NSScreen frames have y up from the primary's bottom edge; CoreGraphics ++ * global coordinates have y down from the primary's top edge. */ ++static CGRect globalRectForFrame(NSArray *screens, NSRect frame) { ++ if (NSIsEmptyRect(frame) || [screens count] == 0) ++ return frame; ++ CGFloat primaryHeight = NSHeight([[screens objectAtIndex: 0] frame]); ++ return CGRectMake(frame.origin.x, ++ primaryHeight - frame.origin.y - frame.size.height, ++ frame.size.width, frame.size.height); ++} ++ + CGDirectDisplayID CGMainDisplayID(void) { + NSDisplay *display = currentDisplay(); + if (!display) +@@ -60,9 +83,9 @@ CGDirectDisplayID CGMainDisplayID(void) { + + NSArray *screens = [display screens]; + +- for (int i = 0; i < [screens count]; i++) { +- if (!NSIsEmptyRect([[screens objectAtIndex: i] frame])) { +- return i + 1; ++ for (NSScreen *screen in screens) { ++ if (!NSIsEmptyRect([screen frame])) { ++ return [screen cgDirectDisplayID]; + } + } + +@@ -89,10 +112,11 @@ CGError CGGetOnlineDisplayList(uint32_t maxDisplays, + onlineDisplays[0] = mainDisplay; + } + +- for (int i = 0; i < [screens count]; i++) { +- if ((i + 1) != mainDisplay) { ++ for (NSScreen *screen in screens) { ++ CGDirectDisplayID displayID = [screen cgDirectDisplayID]; ++ if (displayID != mainDisplay) { + if (*displayCount < maxDisplays) +- onlineDisplays[*displayCount] = i + 1; ++ onlineDisplays[*displayCount] = displayID; + (*displayCount)++; + } + } +@@ -105,11 +129,11 @@ size_t CGDisplayPixelsHigh(CGDirectDisplayID displayIndex) { + if (!display) + return kCGErrorInvalidConnection; + +- NSArray *screens = [display screens]; +- if (displayIndex > [screens count] || displayIndex <= 0) ++ NSScreen *screen = screenForDisplayID([display screens], displayIndex); ++ if (!screen) + return 0; + +- return NSHeight([[screens objectAtIndex: displayIndex - 1] frame]); ++ return NSHeight([screen frame]); + } + + size_t CGDisplayPixelsWide(CGDirectDisplayID displayIndex) { +@@ -117,11 +141,11 @@ size_t CGDisplayPixelsWide(CGDirectDisplayID displayIndex) { + if (!display) + return kCGErrorInvalidConnection; + +- NSArray *screens = [display screens]; +- if (displayIndex > [screens count] || displayIndex <= 0) ++ NSScreen *screen = screenForDisplayID([display screens], displayIndex); ++ if (!screen) + return 0; + +- return NSWidth([[screens objectAtIndex: displayIndex - 1] frame]); ++ return NSWidth([screen frame]); + } + + CGError CGGetActiveDisplayList(uint32_t maxDisplays, +@@ -135,10 +159,10 @@ CGError CGGetActiveDisplayList(uint32_t maxDisplays, + NSArray *screens = [display screens]; + + *displayCount = 0; +- for (int i = 0; i < [screens count]; i++) { +- if (!NSIsEmptyRect([[screens objectAtIndex: i] frame])) { ++ for (NSScreen *screen in screens) { ++ if (!NSIsEmptyRect([screen frame])) { + if (*displayCount < maxDisplays) +- activeDisplays[*displayCount] = i + 1; ++ activeDisplays[*displayCount] = [screen cgDirectDisplayID]; + (*displayCount)++; + } + } +@@ -169,11 +193,11 @@ CGError CGGetDisplaysWithPoint(CGPoint point, uint32_t maxDisplays, + NSArray *screens = [display screens]; + *matchingDisplayCount = 0; + +- for (int i = 0; i < [screens count]; i++) { +- NSRect rect = [[screens objectAtIndex: i] frame]; ++ for (NSScreen *screen in screens) { ++ NSRect rect = globalRectForFrame(screens, [screen frame]); + if (NSPointInRect(point, rect)) { + if (*matchingDisplayCount < maxDisplays) +- displays[*matchingDisplayCount] = i + 1; ++ displays[*matchingDisplayCount] = [screen cgDirectDisplayID]; + (*matchingDisplayCount)++; + } + } +@@ -192,11 +216,11 @@ CGError CGGetDisplaysWithRect(CGRect rect, uint32_t maxDisplays, + NSArray *screens = [display screens]; + *matchingDisplayCount = 0; + +- for (int i = 0; i < [screens count]; i++) { +- NSRect screenRect = [[screens objectAtIndex: i] frame]; ++ for (NSScreen *screen in screens) { ++ NSRect screenRect = globalRectForFrame(screens, [screen frame]); + if (NSIntersectsRect(rect, screenRect)) { + if (*matchingDisplayCount < maxDisplays) +- displays[*matchingDisplayCount] = i + 1; ++ displays[*matchingDisplayCount] = [screen cgDirectDisplayID]; + (*matchingDisplayCount)++; + } + } +@@ -218,10 +242,11 @@ CGRect CGDisplayBounds(CGDirectDisplayID displayIndex) { + return NSZeroRect; + + NSArray *screens = [display screens]; +- if (displayIndex > [screens count] || displayIndex <= 0) ++ NSScreen *screen = screenForDisplayID(screens, displayIndex); ++ if (!screen) + return NSZeroRect; + +- return [[screens objectAtIndex: displayIndex - 1] frame]; ++ return globalRectForFrame(screens, [screen frame]); + } + + CGError CGDisplayHideCursor(CGDirectDisplayID displayIndex) { +@@ -256,7 +281,7 @@ boolean_t CGDisplayIsInMirrorSet(CGDirectDisplayID display) { + } + + Boolean CGDisplayIsMain(CGDirectDisplayID display) { +- return display == 1; ++ return display != kCGNullDirectDisplay && display == CGMainDisplayID(); + } + + CGDirectDisplayID CGDisplayMirrorsDisplay(CGDirectDisplayID display) { +@@ -341,11 +366,10 @@ static NSData *edidForDisplay(CGDirectDisplayID displayId) { + if (!display) + return nil; + +- NSArray *screens = [display screens]; +- if (displayId <= 0 || displayId > [screens count]) ++ NSScreen *screen = screenForDisplayID([display screens], displayId); ++ if (!screen) + return nil; + +- NSScreen *screen = [screens objectAtIndex: displayId - 1]; + NSData *edid = [screen edid]; + if (edid && [edid length] >= 16) + return edid; +@@ -474,7 +498,9 @@ CGError CGAssociateMouseAndMouseCursorPosition(boolean_t connected) { if (!display) return kCGErrorInvalidConnection; @@ -5030,7 +5444,7 @@ index e213b066..94d5c7bb 100644 return kCGErrorSuccess; } -@@ -500,3 +502,38 @@ CFDictionaryRef CGDisplayCurrentMode(CGDirectDisplayID display) { +@@ -500,3 +526,38 @@ CFDictionaryRef CGDisplayCurrentMode(CGDirectDisplayID display) { size_t CGDisplayModeGetPixelWidth(CGDisplayModeRef mode) { return 0; } diff --git a/tools/build-riot-x11-runtime.py b/tools/build-riot-x11-runtime.py index 3ccfb52..78a0940 100644 --- a/tools/build-riot-x11-runtime.py +++ b/tools/build-riot-x11-runtime.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 """Compile the changed X11 event handler and relink the existing backend.""" +import json import shlex import subprocess from pathlib import Path project = Path(__file__).resolve().parent.parent framework = project / 'build/darling-src/build/src/external/cocotron/AppKit' +config_path = project / 'build/config.json' +sdk = json.loads(config_path.read_text())['sdk'] if config_path.exists() else '/usr/local/libexec/darling' flags = {} for line in (framework / 'CMakeFiles/X11_backend.dir/flags.make').read_text().splitlines(): if line.startswith('C_') and ' = ' in line: @@ -17,7 +20,7 @@ for name in ('X11Display', 'X11Window'): 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) -print('Built isolated X11 backend with native visibility events') +print('Built isolated X11 backend') # Exercise actual backend modifier transitions without compositor shortcuts. probe = project / 'build/riot-modifier-check.o' @@ -25,6 +28,6 @@ subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FL '-O2', '-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', '@DARLING_SDK@', str(probe), + '-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-framework', 'AppKit', '-framework', 'Foundation', '-lobjc', '-o', str(project / 'build/riot-modifier-check')], check=True) diff --git a/tools/check-x11-display-geometry.py b/tools/check-x11-display-geometry.py new file mode 100644 index 0000000..d84fa5d --- /dev/null +++ b/tools/check-x11-display-geometry.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Build changed display code and run a native probe in an isolated overlay. + +Requires prepared sources, an existing CMake build, Darling, and the per-user +AppKit/CoreGraphics runtime overlays. Does not install binaries or send input. +""" +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import tempfile + +root = Path(__file__).resolve().parent.parent +config = json.loads((root / 'build/config.json').read_text()) +frameworks = root / 'build/darling-src/build/src/external/cocotron' +subprocess.run(['python3', str(root / 'tools/build-riot-x11-runtime.py')], check=True) + +def flags_for(framework, target): + flags = {} + for line in (framework / f'CMakeFiles/{target}.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) + return flags['C_DEFINES'] + flags['C_INCLUDES'] + flags['C_FLAGS'] + +cg = frameworks / 'CoreGraphics' +obj = root / 'build/pr12-CGDirectDisplay.o' +subprocess.run(['clang', *flags_for(cg, 'CoreGraphics'), '-O2', '-c', + str(root / 'build/darling-src/src/external/cocotron/CoreGraphics/CGDirectDisplay.m'), + '-o', str(obj)], check=True) +link = shlex.split((cg / 'CMakeFiles/CoreGraphics.dir/link.txt').read_text()) +link[link.index('CMakeFiles/CoreGraphics.dir/CGDirectDisplay.m.o')] = str(obj) +link[link.index('-o') + 1] = str(root / 'build/pr12-CoreGraphics') +subprocess.run(link, cwd=cg, check=True) +probe = root / 'build/riot-display-geometry-check' +subprocess.run(['clang', *flags_for(frameworks / 'AppKit', 'X11_backend'), + '-O2', '-UNDEBUG', '-Wno-macro-redefined', '-c', str(root / 'tools/riot-display-geometry-check.m'), + '-o', str(probe) + '.o'], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', + '-nostdlib', '-isysroot', config['sdk'], str(probe) + '.o', + '-lSystem', '-framework', 'AppKit', '-framework', 'Foundation', + '-framework', 'CoreGraphics', '-lobjc', '-o', str(probe)], check=True) + +original = Path(config['prefix']) / config['guest_home'].lstrip('/') / 'RuntimeFrameworks' +with tempfile.TemporaryDirectory(prefix='pr12-display-frameworks-', dir=root / 'build') as directory: + overlay = Path(directory) + for name in ('AppKit', 'CoreGraphics'): + shutil.copytree(original / f'{name}.framework', overlay / f'{name}.framework', + symlinks=True, dirs_exist_ok=True, + ignore=shutil.ignore_patterns('*.before-*')) + shutil.copy2(root / 'build/riot-X11', overlay / 'AppKit.framework/Versions/C/Resources/Backends/X11.backend/Contents/MacOS/X11') + shutil.copy2(root / 'build/pr12-CoreGraphics', overlay / 'CoreGraphics.framework/Versions/A/CoreGraphics') + env = dict(os.environ, EGL_PLATFORM='x11', + DYLD_FRAMEWORK_PATH='/Volumes/SystemRoot' + str(overlay) + ':' + config['guest_home'] + '/RuntimeFrameworks', + DYLD_LIBRARY_PATH=config['guest_home'] + '/RuntimeLibraries') + # File capture bounds the wait even if a guest service inherits the output fd. + log = root / 'build/pr12-display-check.log' + with log.open('w+') as output: + try: + result = subprocess.run(['darling', 'shell', '/Volumes/SystemRoot' + str(probe)], + env=env, stdout=output, stderr=subprocess.STDOUT, timeout=45) + except subprocess.TimeoutExpired: + raise SystemExit(f'Display probe timed out; inspect {log}') + output.seek(0) + lines = [line.rstrip() for line in output if line.startswith('DISPLAY_GEOMETRY')] + print('\n'.join(lines)) + if result.returncode or len(lines) != 6: + raise SystemExit(f'Display probe failed ({result.returncode}); inspect {log}') diff --git a/tools/riot-display-geometry-check.m b/tools/riot-display-geometry-check.m new file mode 100644 index 0000000..a457890 --- /dev/null +++ b/tools/riot-display-geometry-check.m @@ -0,0 +1,144 @@ +/* Exercise the real X11/CG methods with synthetic cached monitor layouts. + * No input is sent: pointer warps are captured by a temporary method override. */ +#import +#import +#import +#import +#import +#import "../build/darling-src/src/external/cocotron/AppKit/X11.backend/X11Display.h" +#import "../build/darling-src/src/external/cocotron/AppKit/X11.backend/X11Window.h" +#include +#include +#include + +// Implemented by Cocotron but absent from its public headers. +extern CGRect CGDisplayBounds(CGDirectDisplayID display); +extern CGError CGWarpMouseCursorPosition(CGPoint position); + +@interface X11Display (GeometryProbe) +- (NSArray *)_rootScreenFallback; +- (void)postXEvent:(XEvent *)event; +@end + +static void *slot(id obj, const char *name) { + Ivar ivar = class_getInstanceVariable([obj class], name); + assert(ivar); + return (char *)obj + ivar_getOffset(ivar); +} +static void rectEqual(NSRect actual, NSRect expected) { + assert(NSEqualRects(actual, expected)); +} +static unsigned captureWarp(id obj, SEL sel, int x, int y) { + assert(x == 3940 && y == 1010); + return 1; +} +static NSScreen *screen(NSRect frame, unsigned displayID) { + NSScreen *s = [[[NSScreen alloc] initWithFrame:frame visibleFrame:frame] autorelease]; + [s setCgDirectDisplayID:displayID]; + return s; +} +int main(void) { + setbuf(stdout,NULL); + @autoreleasepool { + [NSApplication sharedApplication]; + X11Display *d = (id)[NSDisplay currentDisplay]; + assert([d respondsToSelector:@selector(rootRectForFrame:)]); + NSArray *actualScreens = [d screens]; + assert([actualScreens count] > 0); + NSScreen *first = [actualScreens objectAtIndex:0]; + if (!NSIsEmptyRect([first frame])) { + assert(NSMinX([first frame]) == 0 && NSMinY([first frame]) == 0); + assert(CGMainDisplayID() == [first cgDirectDisplayID]); + } + puts("DISPLAY_GEOMETRY live_screen_origin=pass"); + + NSRect layouts[] = { NSMakeRect(0,0,1920,1080), NSMakeRect(3840,810,3840,2160), + NSMakeRect(0,1200,1920,1080), NSMakeRect(1920,0,1920,1080) }; + for (unsigned i = 0; i < sizeof layouts / sizeof *layouts; i++) { + *(NSRect *)slot(d,"_primaryRootFrame") = layouts[i]; + *(BOOL *)slot(d,"_primaryRootFrameValid") = YES; + NSRect frame = NSMakeRect(-320,140,800,600); + NSRect root = [d rootRectForFrame:frame]; + rectEqual(root, NSMakeRect(NSMinX(layouts[i])-320,NSMaxY(layouts[i])-740,800,600)); + rectEqual([d frameForRootRect:root],frame); + // Test X11Window's inverse as well, using an unmapped object. + X11Window *w = [NSClassFromString(@"X11Window") alloc]; + *(NSRect *)slot(w,"_frame") = root; + rectEqual([w frame],frame); + [w release]; + } + puts("DISPLAY_GEOMETRY single_offset_vertical_negative_roundtrip=pass"); + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(100,100,800,600) + styleMask:NSBorderlessWindowMask backing:NSBackingStoreBuffered defer:NO]; + X11Window *native = (id)[window platformWindow]; + rectEqual(*(NSRect *)slot(native,"_frame"),NSMakeRect(2020,380,800,600)); + [native setFrame:NSMakeRect(-100,200,640,480)]; + rectEqual(*(NSRect *)slot(native,"_frame"),NSMakeRect(1820,400,640,480)); + rectEqual([native frame],NSMakeRect(-100,200,640,480)); + [window release]; + puts("DISPLAY_GEOMETRY unmapped_window_create_resize=pass"); + + NSRect primary = NSMakeRect(3840,810,3840,2160); + *(NSRect *)slot(d,"_primaryRootFrame") = primary; + NSArray **cache = slot(d,"_lastScreens"); + [*cache release]; + *cache = [@[ + screen(NSMakeRect(0,0,3840,2160),2), + screen(NSMakeRect(-3840,102,3840,2160),1), + screen(NSMakeRect(3840,-230,1800,3200),3), + screen(NSZeroRect,4) + ] retain]; + assert(CGMainDisplayID() == 2 && CGDisplayIsMain(2) && !CGDisplayIsMain(1)); + assert(CGDisplayPixelsWide(3) == 1800 && CGDisplayPixelsHigh(3) == 3200); + assert(CGDisplayPixelsWide(99) == 0); + rectEqual(CGDisplayBounds(1),NSMakeRect(-3840,-102,3840,2160)); + rectEqual(CGDisplayBounds(3),NSMakeRect(3840,-810,1800,3200)); + rectEqual(CGDisplayBounds(4),NSZeroRect); + CGDirectDisplayID ids[4] = {0}; uint32_t count; + assert(CGGetActiveDisplayList(4,ids,&count) == 0 && count == 3 && ids[0] == 2 && ids[1] == 1); + assert(CGGetOnlineDisplayList(1,ids,&count) == 0 && count == 4 && ids[0] == 2); + assert(CGGetActiveDisplayList(0,NULL,&count) == 0 && count == 3); + assert(CGGetDisplaysWithPoint(CGPointMake(-100,-50),4,ids,&count) == 0 && count == 1 && ids[0] == 1); + assert(CGGetDisplaysWithRect(CGRectMake(3900,-800,10,10),4,ids,&count) == 0 && count == 1 && ids[0] == 3); + puts("DISPLAY_GEOMETRY reordered_ids_bounds_enumeration_queries=pass"); + + Method method = class_getInstanceMethod([d class],@selector(warpPointerToRootX:y:)); + assert(method); + IMP original = method_setImplementation(method,(IMP)captureWarp); + X11RelativeMouse *relative = slot(d,"_relativeMouse"); + X11RelativeMouseBegin(relative,3900,1000); + assert(CGWarpMouseCursorPosition(CGPointMake(100,200)) == 0); + 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); + assert(motion.deltaX == 7 && motion.deltaY == 9); + X11RelativeMouseEnd(relative); + assert(CGWarpMouseCursorPosition(CGPointMake(100,200)) == 0); + method_setImplementation(method,original); + puts("DISPLAY_GEOMETRY absolute_relative_warp_and_delta=pass"); + int eventBase = *(int *)slot(d,"_rrEventBase"); + assert(eventBase); + XEvent notify = {0}; notify.type = eventBase + RRNotify; + notify.xany.display = [d display]; + [d postXEvent:¬ify]; + assert(!*(BOOL *)slot(d,"_primaryRootFrameValid") && !*cache); + assert([[d screens] count] > 0 && *(BOOL *)slot(d,"_primaryRootFrameValid")); + XRRScreenChangeNotifyEvent change = {0}; + Display *xdisplay = [d display]; + change.type = eventBase + RRScreenChangeNotify; + change.display = xdisplay; change.root = DefaultRootWindow(xdisplay); + change.width = DisplayWidth(xdisplay,DefaultScreen(xdisplay)); + change.height = DisplayHeight(xdisplay,DefaultScreen(xdisplay)); + change.mwidth = DisplayWidthMM(xdisplay,DefaultScreen(xdisplay)); + change.mheight = DisplayHeightMM(xdisplay,DefaultScreen(xdisplay)); + change.rotation = RR_Rotate_0; + [d postXEvent:(XEvent *)&change]; + assert(!*(BOOL *)slot(d,"_primaryRootFrameValid") && !*cache); + NSArray *fallback = [d _rootScreenFallback]; + assert([fallback count] == 1 && [[fallback objectAtIndex:0] cgDirectDisplayID] == 1); + rectEqual(*(NSRect *)slot(d,"_primaryRootFrame"),NSMakeRect(0,0,change.width,change.height)); + puts("DISPLAY_GEOMETRY cache_events_and_fallback=pass"); + } + return 0; +} From 96ed8b4403d1f0de5070ba0b6f2954ba46699c86 Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 15:52:49 +0200 Subject: [PATCH 029/160] Fix symbol visibility in AppKit and X11 build scripts to preserve exported symbols like _NSApp --- tools/build-riot-appkit-runtime.py | 2 +- tools/build-riot-x11-runtime.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/build-riot-appkit-runtime.py b/tools/build-riot-appkit-runtime.py index 4b9facb..d2e5833 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', '-fvisibility=hidden', '-Wno-macro-redefined', '-c', + '-O3', '-march=native', '-mtune=native', '-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) diff --git a/tools/build-riot-x11-runtime.py b/tools/build-riot-x11-runtime.py index 7275cca..6a887a3 100644 --- a/tools/build-riot-x11-runtime.py +++ b/tools/build-riot-x11-runtime.py @@ -13,7 +13,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', '-fvisibility=hidden', '-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=native', '-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) From 5c10b470c1363521fda60712d7c20b2d861a2b9c Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 16:01:25 +0200 Subject: [PATCH 030/160] Fix black screen in Riot Client launcher and strictly confine mouse only for League in-game --- patches/cocotron.patch | 62 +++++++++++++++++++++++----------------- tools/run-riot-client.sh | 18 +++--------- 2 files changed, 39 insertions(+), 41 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index d946286..d425450 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2294,7 +2294,7 @@ index 18189404..0eff2187 100644 - (void) setWindow: (id) window forID: (XID) i; diff --git a/AppKit/X11.backend/X11Display.m b/AppKit/X11.backend/X11Display.m -index 5ae29bf4..bc4ceaf3 100644 +index 5ae29bf4..8a66b921 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3439,7 +3439,7 @@ index 5ae29bf4..bc4ceaf3 100644 return 0; } -@@ -1476,54 +2174,234 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2174,238 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } @@ -3517,15 +3517,19 @@ index 5ae29bf4..bc4ceaf3 100644 + NSWindow *delegate = [window delegate]; + BOOL isBorderless = delegate ? !([delegate styleMask] & NSTitledWindowMask) : YES; + NSString *procName = [[NSProcessInfo processInfo] processName]; -+ BOOL isGame = (procName && [procName rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound && -+ [procName rangeOfString:@"Ux" options:NSCaseInsensitiveSearch].location == NSNotFound); ++ 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:@"League of Legends" options:NSCaseInsensitiveSearch].location != NSNotFound && -+ [winTitle rangeOfString:@"Riot" options:NSCaseInsensitiveSearch].location == NSNotFound) { ++ if (winTitle && [winTitle rangeOfString:@"(TM) Client" options:NSCaseInsensitiveSearch].location != NSNotFound) { + isGame = YES; + } + -+ if (isBorderless && (isGame || attrs.width >= 1024)) { ++ if (isBorderless && isGame) { + if (!fullscreen) { + fullscreen = YES; + /* Inform GNOME Shell / Mutter / EWMH to make this window fullscreen. @@ -4107,7 +4111,7 @@ index bfe3839a..2521ffd5 100644 - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m -index f4925b66..d33c389b 100644 +index f4925b66..22e7e9fd 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -38,6 +38,34 @@ @@ -4145,18 +4149,22 @@ index f4925b66..d33c389b 100644 @implementation X11Window -@@ -193,8 +221,28 @@ static NSData *makeWindowIcon() { +@@ -193,8 +221,32 @@ static NSData *makeWindowIcon() { XSetWindowAttributes xattr; unsigned long xattr_mask; - xattr.override_redirect = - _styleMask == NSBorderlessWindowMask ? True : False; + NSString *procName = [[NSProcessInfo processInfo] processName]; -+ BOOL isGame = (procName && [procName rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound && -+ [procName rangeOfString:@"Ux" options:NSCaseInsensitiveSearch].location == NSNotFound); ++ 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:@"League of Legends" options:NSCaseInsensitiveSearch].location != NSNotFound && -+ [winTitle rangeOfString:@"Riot" options:NSCaseInsensitiveSearch].location == NSNotFound) { ++ if (winTitle && [winTitle rangeOfString:@"(TM) Client" options:NSCaseInsensitiveSearch].location != NSNotFound) { + isGame = YES; + } + @@ -4176,7 +4184,7 @@ index f4925b66..d33c389b 100644 xattr_mask = CWOverrideRedirect | CWColormap; xattr.colormap = cmap; -@@ -206,6 +254,12 @@ static NSData *makeWindowIcon() { +@@ -206,6 +258,12 @@ static NSData *makeWindowIcon() { (_visualInfo == NULL) ? CopyFromParent : _visualInfo->visual, xattr_mask, &xattr); @@ -4189,7 +4197,7 @@ index f4925b66..d33c389b 100644 [self syncDelegateProperties]; Atom atm = XInternAtom(_display, "WM_DELETE_WINDOW", False); -@@ -217,6 +271,16 @@ static NSData *makeWindowIcon() { +@@ -217,6 +275,16 @@ static NSData *makeWindowIcon() { .res_class = (char *) name}; XSetClassHint(_display, _window, &classHint); @@ -4206,7 +4214,7 @@ index f4925b66..d33c389b 100644 XSetWindowBackgroundPixmap(_display, _window, None); _isModal = [delegate isSheet] || [NSApp modalWindow] == delegate; -@@ -266,6 +330,11 @@ static NSData *makeWindowIcon() { +@@ -266,6 +334,11 @@ static NSData *makeWindowIcon() { [self setWindowIcon]; @@ -4218,7 +4226,7 @@ index f4925b66..d33c389b 100644 return self; } -@@ -287,10 +356,19 @@ static NSData *makeWindowIcon() { +@@ -287,10 +360,19 @@ static NSData *makeWindowIcon() { // Make resizable sh->flags = 0; } else { @@ -4242,7 +4250,7 @@ index f4925b66..d33c389b 100644 } XSetWMSizeHints(_display, _window, sh, XA_WM_NORMAL_HINTS); -@@ -338,7 +416,6 @@ static NSData *makeWindowIcon() { +@@ -338,7 +420,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -4250,7 +4258,7 @@ index f4925b66..d33c389b 100644 struct { unsigned long flags; unsigned long functions; -@@ -358,6 +435,7 @@ static NSData *makeWindowIcon() { +@@ -358,6 +439,7 @@ static NSData *makeWindowIcon() { if (!_mapped) { XMapWindow(_display, _window); _mapped = YES; @@ -4258,7 +4266,7 @@ index f4925b66..d33c389b 100644 } } -@@ -377,15 +455,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +459,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -4291,7 +4299,7 @@ index f4925b66..d33c389b 100644 // TODO: background color } -@@ -393,6 +483,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +487,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. @@ -4320,7 +4328,7 @@ index f4925b66..d33c389b 100644 [_context release]; _context = nil; -@@ -421,6 +533,388 @@ static NSData *makeWindowIcon() { +@@ -421,6 +537,388 @@ static NSData *makeWindowIcon() { } } @@ -4709,7 +4717,7 @@ index f4925b66..d33c389b 100644 - (Window) windowHandle { return _window; } -@@ -478,6 +972,20 @@ static NSData *makeWindowIcon() { +@@ -478,6 +976,20 @@ static NSData *makeWindowIcon() { frame.size.width, frame.size.height); [self invalidateContextWithNewSize: frame.size]; _frame = frame; @@ -4730,7 +4738,7 @@ index f4925b66..d33c389b 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +996,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1000,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -4756,7 +4764,7 @@ index f4925b66..d33c389b 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -559,7 +1086,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1090,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -4786,7 +4794,7 @@ index f4925b66..d33c389b 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -615,6 +1164,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1168,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -4799,7 +4807,7 @@ index f4925b66..d33c389b 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -666,6 +1221,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1225,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index c408bd5..8781842 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -17,14 +17,10 @@ unset RCLOL_APP_ARG3 RCLOL_APP_ARG3_SLOT RCLOL_UX_ARG1 RCLOL_UX_ARG1_SLOT unset RCLOL_UX_ARG2 RCLOL_UX_ARG2_SLOT # The paired runtime presents completed GPU textures without CPU readback. # Explicit 0 or an empty value selects the CPU fallback for diagnostics. -for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC DARLING_CA_PRESENT_AT_COMMIT DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER RCLOL_FORWARD_MOUSE_MOVED RCLOL_MOUSE_CONFINEMENT; do +for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC RCLOL_GPU_DIRECT_VSYNC DARLING_CA_PRESENT_AT_COMMIT DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER RCLOL_FORWARD_MOUSE_MOVED RCLOL_MOUSE_CONFINEMENT; do eval 'value=${'"$option"'-1}' case "$value" in 0|'') unset "$option";; *) export "$option=$value";; esac done -# darling-lol perf: VSync off by default for lowest input latency. -# Set RCLOL_GPU_DIRECT_VSYNC=1 to re-enable if tearing is unacceptable. -eval 'value=${RCLOL_GPU_DIRECT_VSYNC-0}' -case "$value" in 0|'') unset RCLOL_GPU_DIRECT_VSYNC;; *) export RCLOL_GPU_DIRECT_VSYNC="$value";; esac prefix=${DPREFIX:-$HOME/.darling} # The first-time installer puts the client in /Users/Shared/Riot Games and then # hands off through LaunchServices, which does not start it under Darling. Link @@ -64,14 +60,8 @@ else --launch-patchline="${RCLOL_LAUNCH_PATCHLINE-live}" fi fi -# --- darling-lol perf: low-latency GPU driver overrides --- -# Vulkan WSI: prefer immediate (no VSync) or mailbox (tear-free low-latency). -export MESA_VK_WSI_PRESENT_MODE="${MESA_VK_WSI_PRESENT_MODE:-immediate}" -# Mesa OpenGL: disable vertical sync, skip error checking. -export vblank_mode="${vblank_mode:-0}" -export MESA_NO_ERROR="${MESA_NO_ERROR:-1}" -# NVIDIA proprietary driver: disable VSync, limit pre-rendered frames. -export __GL_SYNC_TO_VBLANK="${__GL_SYNC_TO_VBLANK:-0}" +# --- darling-lol perf: scheduling & driver latency hints --- +# NVIDIA proprietary driver: limit pre-rendered frames. export __GL_MaxFramesAllowed="${__GL_MaxFramesAllowed:-1}" export __GL_THREADED_OPTIMIZATIONS="${__GL_THREADED_OPTIMIZATIONS:-1}" export __GL_YIELD="${__GL_YIELD:-NOTHING}" @@ -81,7 +71,7 @@ if [ -w /proc/self/timerslack_ns ]; then fi exec env -u ELECTRON_RUN_AS_NODE -u DYLD_INSERT_LIBRARIES EGL_PLATFORM=x11 \ LANG="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" LC_ALL="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" \ - mesa_glthread="${RCLOL_MESA_GLTHREAD-${mesa_glthread-true}}" \ + mesa_glthread="${RCLOL_MESA_GLTHREAD-${mesa_glthread-false}}" \ XMODIFIERS="${RCLOL_XMODIFIERS:-@im=none}" DISPLAY="${DISPLAY:-:1}" \ DYLD_LIBRARY_PATH="${DYLD_LIBRARY_PATH:-@GUEST_HOME@/RuntimeLibraries}" \ DYLD_FRAMEWORK_PATH="${DYLD_FRAMEWORK_PATH:-@GUEST_HOME@/RuntimeFrameworks}" \ From 6084371a6745b78f95033cf76ad8209f80a7b422 Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 16:29:48 +0200 Subject: [PATCH 031/160] QuartzCore/CAMetalLayer: Fix nextDrawable deadlock preventing Metal in-game rendering --- patches/cocotron.patch | 61 +++++++++++++++++++------- tools/build-riot-metal-presentation.py | 5 ++- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 4d94a9a..9f8a153 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -9115,7 +9115,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..63533444 100644 +index b81e2e14..f675d78d 100644 --- a/QuartzCore/CAMetalLayer.mm +++ b/QuartzCore/CAMetalLayer.mm @@ -25,7 +25,79 @@ @@ -9264,7 +9264,7 @@ index b81e2e14..63533444 100644 @implementation CAMetalLayerInternal #if DARLING_METAL_ENABLED -@@ -363,12 +494,15 @@ static void reportGLErrors(void) { +@@ -363,23 +494,43 @@ static void reportGLErrors(void) { - (id)nextDrawable { std::shared_ptr drawable = nullptr; @@ -9275,12 +9275,35 @@ index b81e2e14..63533444 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 (_allowsNextDrawableTimeout) { - if (![_drawableCondition waitUntilDate: endTime]) { - break; -@@ -380,6 +514,8 @@ static void reportGLErrors(void) { ++ 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 (_usableDrawablesBitmap == 0) { // timeout reached @@ -9289,7 +9312,7 @@ index b81e2e14..63533444 100644 [_drawableCondition unlock]; return nil; } -@@ -400,6 +536,8 @@ static void reportGLErrors(void) { +@@ -400,6 +551,8 @@ static void reportGLErrors(void) { } _usableDrawablesBitmap &= ~(1u << drawableID); @@ -9298,7 +9321,7 @@ index b81e2e14..63533444 100644 drawable = _drawables[drawableID]; -@@ -410,20 +548,36 @@ static void reportGLErrors(void) { +@@ -410,20 +563,42 @@ static void reportGLErrors(void) { - (void)queuePresent: (NSUInteger)drawableID { @@ -9316,7 +9339,7 @@ index b81e2e14..63533444 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. -+ dispatch_async(dispatch_get_main_queue(), ^{ ++ void (^renderBlock)(void) = ^{ + if (intervalSample) intervalSample->renderStart.store(presentIntervalNow()); + darlingMetalTrace("present-block-runs", 0, 0); + CFTimeInterval blockStart = CACurrentMediaTime(); @@ -9327,7 +9350,13 @@ index b81e2e14..63533444 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 @@ -9336,7 +9365,7 @@ index b81e2e14..63533444 100644 [_drawableCondition lock]; _usableDrawablesBitmap |= 1u << drawableID; [_drawableCondition signal]; -@@ -455,7 +609,7 @@ static void reportGLErrors(void) { +@@ -455,7 +630,7 @@ static void reportGLErrors(void) { } _usableDrawablesBitmap = 0; @@ -9345,7 +9374,7 @@ index b81e2e14..63533444 100644 // can't create new drawables [_drawableCondition unlock]; return; -@@ -506,6 +660,10 @@ static void reportGLErrors(void) { +@@ -506,6 +681,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); @@ -9356,7 +9385,7 @@ index b81e2e14..63533444 100644 reportGLErrors(); } } -@@ -516,6 +674,10 @@ static void reportGLErrors(void) { +@@ -516,6 +695,10 @@ static void reportGLErrors(void) { - (void)prepareRender { std::shared_ptr drawable = nullptr; @@ -9367,7 +9396,7 @@ index b81e2e14..63533444 100644 [_drawableCondition lock]; if (_queuedDrawableCount > 0) { -@@ -528,11 +690,62 @@ static void reportGLErrors(void) { +@@ -528,11 +711,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) @@ -9430,7 +9459,7 @@ index b81e2e14..63533444 100644 } @synchronized(self) { -@@ -575,6 +788,11 @@ static void reportGLErrors(void) { +@@ -575,6 +809,11 @@ static void reportGLErrors(void) { { [super _setContext: context]; @@ -9442,7 +9471,7 @@ index b81e2e14..63533444 100644 if (_tex == 0) { // create our textures now using the context's CGLContext CGLContextObj prev = CGLGetCurrentContext(); -@@ -589,10 +807,15 @@ static void reportGLErrors(void) { +@@ -589,10 +828,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/tools/build-riot-metal-presentation.py b/tools/build-riot-metal-presentation.py index d7d0e1e..a595318 100644 --- a/tools/build-riot-metal-presentation.py +++ b/tools/build-riot-metal-presentation.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 """Build the Metal layer presentation bridge and its window regression check.""" from pathlib import Path +import json import shlex import subprocess root = Path(__file__).resolve().parent.parent +config_path = root / 'build/config.json' +sdk = json.loads(config_path.read_text())['sdk'] if config_path.exists() else '/usr/local/libexec/darling' folder = root / 'build/darling-src/build/src/external/cocotron/QuartzCore' source = root / 'build/darling-src/src/external/cocotron/QuartzCore' flags = {} @@ -28,6 +31,6 @@ obj = root / 'build/riot-metal-presentation-check.o' subprocess.run(['clang++', *flags['CXX_DEFINES'], *flags['CXX_INCLUDES'], *flags['CXX_FLAGS'], '-c', str(root / 'tools/riot-metal-presentation-check.mm'), '-o', str(obj)], check=True) subprocess.run(['clang++', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', - '-isysroot', '@DARLING_SDK@', str(obj), '-lSystem', '-lc++', '-lobjc', + '-isysroot', sdk, str(obj), '-lSystem', '-lc++', '-lobjc', '-framework', 'AppKit', '-framework', 'OpenGL', '-framework', 'Foundation', str(root / 'build/riot-Metal'), str(root / 'build/riot-QuartzCore'), '-o', str(root / 'build/riot-metal-presentation-check')], check=True) From 7af99c85dbb2f9f71ecae4edd9c402738f9256a5 Mon Sep 17 00:00:00 2001 From: ztancrell Date: Fri, 16 Oct 2026 10:44:04 -0400 Subject: [PATCH 032/160] Return a real physical size from CGDisplayScreenSize; never return NULL display modes CGDisplayScreenSize returned 0x0 for any display with EDID (upstream left that branch unimplemented). League divides by it for its camera aspect ratio, so the view-projection x row became NaN: every 3D vertex was discarded, the scene showed only its blue clear colour, and world clicks were ignored. Read the size from the EDID detailed timing (mm) or basic parameters (cm), fall back to 96 dpi, and use the main display for an unknown id. CGDisplayCopyAllDisplayModes returned NULL for display 0 (a window off every screen), which League passes straight to CFArrayGetCount. Fall back to the main display, then to an empty array. Add riot-display-modes-check covering both for the main display, display 0 and an unknown id. Co-Authored-By: Claude Opus 5.5 --- docs/TESTING.md | 7 +++ patches/cocotron.patch | 65 +++++++++++++++++++++++-- tools/build-riot-display-modes-check.py | 21 ++++++++ tools/riot-display-modes-check.c | 34 +++++++++++++ 4 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 tools/build-riot-display-modes-check.py create mode 100644 tools/riot-display-modes-check.c diff --git a/docs/TESTING.md b/docs/TESTING.md index 8f266f8..6fff517 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -34,8 +34,15 @@ darling shell env DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks \ /Volumes/SystemRoot/path/to/darling-lol/build/riot-coretext-font-list-check darling shell env DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks \ /Volumes/SystemRoot/path/to/darling-lol/build/riot-window-layout-check +darling shell env DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks \ + /Volumes/SystemRoot/path/to/darling-lol/build/riot-display-modes-check ``` +`riot-display-modes-check` (built by `build-riot-display-modes-check.py`) expects `DISPLAY_MODES_CHECK pass=1`. It checks two things for the main display, display 0 and an unknown display: + +- `CGDisplayCopyAllDisplayModes` never returns NULL. League passes the id from `[[window screen] deviceDescription]` (0 while the window is off every screen) straight to `CFArrayGetCount`, and NULL crashed it. +- `CGDisplayScreenSize` returns a nonzero size (`SCREEN_SIZE ... pass=1`). It returned 0×0 for any display with EDID. League divides by this size for its camera aspect ratio, so the view-projection matrix's x row became NaN and every 3D vertex was discarded. The scene then showed only its blue clear colour, and world clicks did nothing. This was found with a RenderDoc capture and vertex shader debugging. + Expected: `CORETEXT_FONT_LIST pass=1` with nonzero family/face counts (306/1124 on the development host), `VIEW_LAYER_RESIZE initial=1600x900 pass=1`, `NATIVE_RESIZE_ORDER size=1600x900 pass=1` `CONTENT_SIBLING_RESIZE ... pass=1` and `WINDOW_LAYOUT_CHECK ... pass=1`. The font probe reproduces Chromium's installed-family enumeration, mandatory family matching and localized-name lookup with a null language output. It checks ownership of the single-match result and that a nonexistent mandatory family produces no substitute. The resize probe checks backing-layer bounds immediately after frame/bounds changes, including a scaled view and a child layer, without relying on input or coordinate conversion to refresh them. It also verifies that native parent size is current during child layout, and that adding an overlay sibling after the content view does not redirect subsequent content resizing into that overlay. The old sibling selection left content at 1600×900 while stretching the 30×20 overlay to 1280×720; the corrected layout resizes the content and preserves the overlay. Before the fixes, these checks report a stale 1280×720 layer and native parent after a 1600×900 resize. The `FRAME_SIZE_HOOK` check uses a subclass whose `setFrameSize:` publishes its viewport size. It checks growth and shrinkage through parent autoresizing, direct `setFrame:`, and direct `setFrameSize:`. Before component-setter dispatch was corrected only the final direct size setter reached the subclass (one callback); afterward all four updates do, with current geometry visible inside the callback. No probe launches or contacts Riot/League. The user confirmed that the language dropdown and League window resizing work after the final X11 correction (2026-10-15). The practice-tool crash remains unresolved. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 7acc4cd..ecb7613 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -5261,7 +5261,7 @@ index db8869c8..6fea5107 100644 + CGContextSetTextPosition(c, savedPosition.x, savedPosition.y); } diff --git a/CoreGraphics/CGDirectDisplay.m b/CoreGraphics/CGDirectDisplay.m -index e213b066..569d77f2 100644 +index e213b066..ea0bc264 100644 --- a/CoreGraphics/CGDirectDisplay.m +++ b/CoreGraphics/CGDirectDisplay.m @@ -53,6 +53,29 @@ static NSDisplay *currentDisplay(void) { @@ -5419,7 +5419,26 @@ index e213b066..569d77f2 100644 } CGDirectDisplayID CGDisplayMirrorsDisplay(CGDirectDisplayID display) { -@@ -341,11 +366,10 @@ static NSData *edidForDisplay(CGDirectDisplayID displayId) { +@@ -319,10 +344,15 @@ CFStringRef CGDisplayModeCopyPixelEncoding(CGDisplayModeRef mode) { + CFArrayRef CGDisplayCopyAllDisplayModes(CGDirectDisplayID displayIndex, + CFDictionaryRef options) + { ++ /* League reads the id from [[window screen] deviceDescription], which is ++ * 0 while the window is off every screen, and passes the result straight ++ * to CFArrayGetCount. Fall back to the main display, then to an empty ++ * array, so callers never get NULL. */ + NSDisplay *display = currentDisplay(); +- if (!display) +- return NULL; +- return (CFArrayRef)[[display modesForScreen: displayIndex - 1] retain]; ++ NSArray *modes = [display modesForScreen: displayIndex - 1]; ++ if (modes == nil) ++ modes = [display modesForScreen: CGMainDisplayID() - 1]; ++ return (CFArrayRef)[(modes ? modes : @[]) retain]; + } + + CGError CGDisplaySetDisplayMode(CGDirectDisplayID displayId, +@@ -341,11 +371,10 @@ static NSData *edidForDisplay(CGDirectDisplayID displayId) { if (!display) return nil; @@ -5433,7 +5452,45 @@ index e213b066..569d77f2 100644 NSData *edid = [screen edid]; if (edid && [edid length] >= 16) return edid; -@@ -474,7 +498,9 @@ CGError CGAssociateMouseAndMouseCursorPosition(boolean_t connected) { +@@ -354,17 +383,30 @@ static NSData *edidForDisplay(CGDirectDisplayID displayId) { + } + + CGSize CGDisplayScreenSize(CGDirectDisplayID display) { +- printf("STUB %s\n", __PRETTY_FUNCTION__); ++ /* League divides by this size to get its camera aspect ratio; a zero size ++ * made every 3D vertex NaN (blue screen, world clicks ignored). Use the ++ * main display for an unknown id (an off-screen window reports 0). */ ++ if (CGDisplayBounds(display).size.width <= 0) ++ display = CGMainDisplayID(); + + NSData *edid = edidForDisplay(display); +- +- if (!edid) { +- CGRect bounds = CGDisplayBounds(display); +- +- return bounds.size; ++ if (edid && [edid length] >= 128) { ++ const uint8_t *bytes = [edid bytes]; ++ /* First detailed timing descriptor: image size in mm. */ ++ double width = bytes[66] | ((bytes[68] & 0xf0) << 4); ++ double height = bytes[67] | ((bytes[68] & 0x0f) << 8); ++ /* Basic display parameters: maximum image size in cm. */ ++ if (width <= 0 || height <= 0) { ++ width = bytes[21] * 10.0; ++ height = bytes[22] * 10.0; ++ } ++ if (width > 0 && height > 0) ++ return (CGSize){.width = width, .height = height}; + } + +- return (CGSize){.width = 0, .height = 0}; ++ /* No usable EDID size: assume 96 dpi. */ ++ CGSize pixels = CGDisplayBounds(display).size; ++ return (CGSize){.width = pixels.width * 25.4 / 96, .height = pixels.height * 25.4 / 96}; + } + + uint32_t CGDisplaySerialNumber(CGDirectDisplayID displayId) { +@@ -474,7 +516,9 @@ CGError CGAssociateMouseAndMouseCursorPosition(boolean_t connected) { if (!display) return kCGErrorInvalidConnection; @@ -5444,7 +5501,7 @@ index e213b066..569d77f2 100644 return kCGErrorSuccess; } -@@ -500,3 +526,38 @@ CFDictionaryRef CGDisplayCurrentMode(CGDirectDisplayID display) { +@@ -500,3 +544,38 @@ CFDictionaryRef CGDisplayCurrentMode(CGDirectDisplayID display) { size_t CGDisplayModeGetPixelWidth(CGDisplayModeRef mode) { return 0; } diff --git a/tools/build-riot-display-modes-check.py b/tools/build-riot-display-modes-check.py new file mode 100644 index 0000000..0b6392b --- /dev/null +++ b/tools/build-riot-display-modes-check.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Build the CGDisplayCopyAllDisplayModes NULL-result probe against Darling's SDK.""" +from pathlib import Path +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +folder = root / 'build/darling-src/build/src/external/cocotron/CoreGraphics' +flags = {} +for line in (folder / 'CMakeFiles/CoreGraphics.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +obj = root / 'build/riot-display-modes-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-Wno-macro-redefined', '-c', str(root / 'tools/riot-display-modes-check.c'), + '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', '@DARLING_SDK@', str(obj), '-lSystem', + '-framework', 'CoreGraphics', '-framework', 'CoreFoundation', + '-o', str(root / 'build/riot-display-modes-check')], check=True) diff --git a/tools/riot-display-modes-check.c b/tools/riot-display-modes-check.c new file mode 100644 index 0000000..b05ed58 --- /dev/null +++ b/tools/riot-display-modes-check.c @@ -0,0 +1,34 @@ +#include +#include + +/* Exported by Darling's CoreGraphics but missing from its CGDirectDisplay.h. */ +CFArrayRef CGDisplayCopyAllDisplayModes(CGDirectDisplayID display, CFDictionaryRef options); + +/* League passes CGDisplayCopyAllDisplayModes' result straight to + * CFArrayGetCount; display id 0 comes from an off-screen window. */ +static int check(CGDirectDisplayID display) { + CFArrayRef modes = CGDisplayCopyAllDisplayModes(display, NULL); + if (!modes) { + printf("DISPLAY_MODES display=%u result=NULL pass=0\n", display); + return 0; + } + printf("DISPLAY_MODES display=%u count=%ld\n", display, (long)CFArrayGetCount(modes)); + CFRelease(modes); + return 1; +} + +/* League divides by the physical size for its camera aspect ratio; {0,0} + * made every 3D vertex NaN. */ +static int checkSize(CGDirectDisplayID display) { + CGSize size = CGDisplayScreenSize(display); + int ok = size.width > 0 && size.height > 0; + printf("SCREEN_SIZE display=%u size=%.0fx%.0fmm pass=%d\n", display, size.width, size.height, ok); + return ok; +} + +int main(void) { + int pass = check(CGMainDisplayID()) & check(0) & check(0xffff); + pass &= checkSize(CGMainDisplayID()) & checkSize(0) & checkSize(0xffff); + printf("DISPLAY_MODES_CHECK pass=%d\n", pass); + return !pass; +} From 30383d6462d6c9413359cd6d30fac6185b28ab0d Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 16:55:22 +0200 Subject: [PATCH 033/160] AppKit/X11: Robust multi-monitor mouse boundary confinement and clean stale Darling/Riot state --- patches/cocotron.patch | 87 +++++++++++++++++++++++++++------ tools/ensure-darling-runtime.py | 23 +++++++++ 2 files changed, 94 insertions(+), 16 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 9f8a153..6fd9936 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2305,7 +2305,7 @@ index 18189404..5b3a34bc 100644 +- (NSRect) frameForRootRect: (NSRect) rect; @end diff --git a/AppKit/X11.backend/X11Display.m b/AppKit/X11.backend/X11Display.m -index 5ae29bf4..5105e7d8 100644 +index 5ae29bf4..5403cfa3 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3355,7 +3355,38 @@ index 5ae29bf4..5105e7d8 100644 [self postEvent: event atStart: NO]; break; -@@ -1164,45 +1834,82 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1160,49 +1830,113 @@ 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. */ ++ XEvent nextEv; ++ while (XCheckTypedWindowEvent(_display, ev->xmotion.window, MotionNotify, &nextEv)) { ++ *ev = nextEv; ++ } ++ ++ if (_confinedWindow == [window windowHandle]) { ++ XWindowAttributes attrs; ++ if (XGetWindowAttributes(_display, _confinedWindow, &attrs)) { ++ int clampedX = ev->xmotion.x; ++ int clampedY = ev->xmotion.y; ++ BOOL needWarp = NO; ++ if (clampedX < 0) { clampedX = 0; needWarp = YES; } ++ if (clampedX >= attrs.width) { clampedX = attrs.width - 1; needWarp = YES; } ++ if (clampedY < 0) { clampedY = 0; needWarp = YES; } ++ if (clampedY >= attrs.height) { clampedY = attrs.height - 1; needWarp = YES; } ++ if (needWarp) { ++ XWarpPointer(_display, None, _confinedWindow, 0, 0, 0, 0, clampedX, clampedY); ++ XFlush(_display); ++ ev->xmotion.x = clampedX; ++ ev->xmotion.y = clampedY; ++ } ++ } ++ } else if (!_relativeMouse.active && window != nil) { ++ [self updateMouseConfinementForWindow: window]; ++ } ++ + CGPoint lastMotionPos = [window mouseLocationOutsideOfEventStream]; pos = [window transformPoint: NSMakePoint(ev->xmotion.x, ev->xmotion.y)]; @@ -3464,7 +3495,7 @@ index 5ae29bf4..5105e7d8 100644 mouseEventWithType: type location: pos modifierFlags: -@@ -1211,28 +1918,49 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +1945,61 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -3499,6 +3530,18 @@ index 5ae29bf4..5105e7d8 100644 case LeaveNotify: NSLog(@"LeaveNotify"); ++ if (_confinedWindow == [window windowHandle]) { ++ Window win = [window windowHandle]; ++ XWindowAttributes attrs; ++ if (XGetWindowAttributes(_display, win, &attrs)) { ++ int cx = ev->xcrossing.x < 0 ? 0 : (ev->xcrossing.x >= attrs.width ? attrs.width - 1 : ev->xcrossing.x); ++ int cy = ev->xcrossing.y < 0 ? 0 : (ev->xcrossing.y >= attrs.height ? attrs.height - 1 : ev->xcrossing.y); ++ XWarpPointer(_display, None, win, 0, 0, 0, 0, cx, cy); ++ XFlush(_display); ++ } ++ [self updateMouseConfinementForWindow: window]; ++ break; ++ } + if (!_relativeMouse.active && ev->xcrossing.detail != NotifyInferior) { + [window setLastKnownCursorPosition: + [window transformPoint: NSMakePoint(ev->xcrossing.x, ev->xcrossing.y)]]; @@ -3516,7 +3559,7 @@ index 5ae29bf4..5105e7d8 100644 NSLog(@"FocusIn"); if ([delegate attachedSheet]) { [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; -@@ -1243,28 +1971,55 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +2010,67 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -3544,6 +3587,18 @@ index 5ae29bf4..5105e7d8 100644 + rclolFocusDetailName(ev->xfocus.detail), _relativeRequested, _relativeMouse.active, + (unsigned long) _confinedWindow, + [NSApp isActive]); ++ if (ev->xfocus.detail == NotifyPointer || ev->xfocus.mode == NotifyGrab || ev->xfocus.mode == NotifyUngrab) { ++ /* Pointer boundary crossing or grab transition in XWayland: do NOT release confinement or deactivate window */ ++ break; ++ } ++ { ++ NSString *procName = [[NSProcessInfo processInfo] processName]; ++ BOOL isGame = (procName && [procName rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound); ++ if (isGame && [NSApp isActive]) { ++ /* While League is the active app, do not drop confinement on transient XWayland focus events */ ++ break; ++ } ++ } NSLog(@"FocusOut"); + [self reconcileModifierKeys:NULL window:delegate]; + _lastKeySym = 0; @@ -3574,7 +3629,7 @@ index 5ae29bf4..5105e7d8 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +2056,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +2107,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -3602,7 +3657,7 @@ index 5ae29bf4..5105e7d8 100644 break; case MapRequest: -@@ -1321,6 +2088,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1321,6 +2139,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { [delegate platformWindow: window frameChanged: [window frame] didSize: YES]; @@ -3610,7 +3665,7 @@ index 5ae29bf4..5105e7d8 100644 break; case ConfigureRequest: -@@ -1344,6 +2112,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2163,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -3619,7 +3674,7 @@ index 5ae29bf4..5105e7d8 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1399,7 +2169,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1399,7 +2220,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; default: @@ -3630,7 +3685,7 @@ index 5ae29bf4..5105e7d8 100644 [self _invalidateRRCache]; break; } -@@ -1433,6 +2205,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1433,6 +2256,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); @@ -3641,7 +3696,7 @@ index 5ae29bf4..5105e7d8 100644 return 0; } -@@ -1476,54 +2252,271 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2303,271 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } @@ -3687,8 +3742,8 @@ index 5ae29bf4..5105e7d8 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 { @@ -3803,7 +3858,7 @@ index 5ae29bf4..5105e7d8 100644 + } + 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 && (focus == PointerRoot || focus == None || focus == DefaultRootWindow(_display)))); ++ 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 (rclolTraceOn()) + rclolTraceRated(&rclolConfineChannel, "no confinement window=%lu fullscreen=%d ewmh=%d titled=%d size=%dx%d map_state=%d %s", @@ -3826,9 +3881,9 @@ index 5ae29bf4..5105e7d8 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) _confinedWindow=win; - } - ++ if (status == GrabSuccess || status == AlreadyGrabbed) _confinedWindow=win; ++} ++ +/* 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 { diff --git a/tools/ensure-darling-runtime.py b/tools/ensure-darling-runtime.py index a7a6339..a34ff7f 100644 --- a/tools/ensure-darling-runtime.py +++ b/tools/ensure-darling-runtime.py @@ -22,3 +22,26 @@ except (OSError, StopIteration): 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) + +# Clean up stale Riot temporary files and shared memory frames from previous crashes +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 + +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 From 3255fb064e0daf11fb00503d93a3e174719bf940 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:57:55 +0200 Subject: [PATCH 034/160] Handle EDID DTV aspect ratios in physical display size --- patches/cocotron.patch | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index ecb7613..65733bd 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -5452,7 +5452,7 @@ index e213b066..ea0bc264 100644 NSData *edid = [screen edid]; if (edid && [edid length] >= 16) return edid; -@@ -354,17 +383,30 @@ static NSData *edidForDisplay(CGDirectDisplayID displayId) { +@@ -354,17 +383,33 @@ static NSData *edidForDisplay(CGDirectDisplayID displayId) { } CGSize CGDisplayScreenSize(CGDirectDisplayID display) { @@ -5474,8 +5474,11 @@ index e213b066..ea0bc264 100644 + /* First detailed timing descriptor: image size in mm. */ + double width = bytes[66] | ((bytes[68] & 0xf0) << 4); + double height = bytes[67] | ((bytes[68] & 0x0f) << 8); ++ /* DTV timings may encode only an aspect ratio in these fields. */ ++ bool aspectRatioOnly = (width == 16 && height == 9) || ++ (width == 4 && height == 3); + /* Basic display parameters: maximum image size in cm. */ -+ if (width <= 0 || height <= 0) { ++ if (width <= 0 || height <= 0 || aspectRatioOnly) { + width = bytes[21] * 10.0; + height = bytes[22] * 10.0; + } @@ -5490,7 +5493,7 @@ index e213b066..ea0bc264 100644 } uint32_t CGDisplaySerialNumber(CGDirectDisplayID displayId) { -@@ -474,7 +516,9 @@ CGError CGAssociateMouseAndMouseCursorPosition(boolean_t connected) { +@@ -474,7 +519,9 @@ CGError CGAssociateMouseAndMouseCursorPosition(boolean_t connected) { if (!display) return kCGErrorInvalidConnection; @@ -5501,7 +5504,7 @@ index e213b066..ea0bc264 100644 return kCGErrorSuccess; } -@@ -500,3 +544,38 @@ CFDictionaryRef CGDisplayCurrentMode(CGDirectDisplayID display) { +@@ -500,3 +547,38 @@ CFDictionaryRef CGDisplayCurrentMode(CGDirectDisplayID display) { size_t CGDisplayModeGetPixelWidth(CGDisplayModeRef mode) { return 0; } From 3778d518b735c61a0656a87c54f32afaf13fe0d0 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:13:11 +0200 Subject: [PATCH 035/160] Restore X11 fullscreen requests for monitor-sized borderless windows Port the fullscreen policy omitted when adapting PR #12. Track requested fullscreen state across frame and style changes, exclude unmanaged windows, and use EWMH REMOVE when leaving fullscreen. Extend the native geometry probe and document passive tracing for the reported vertical click offset, whose cause remains unconfirmed. --- docs/TESTING.md | 10 +- docs/TROUBLESHOOTING.md | 30 ++++++ patches/cocotron.patch | 143 ++++++++++++++++++++++++---- tools/check-x11-display-geometry.py | 5 +- tools/riot-display-geometry-check.m | 45 +++++++++ 5 files changed, 209 insertions(+), 24 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 6fff517..fe6ed65 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -434,7 +434,7 @@ build/test-x11-relative-mouse The native probe requires the existing per-user AppKit and CoreGraphics overlays and a running X11/Xwayland display. It rebuilds the changed X11 and CoreGraphics objects and tests them in a temporary framework overlay, without installing them. -Six `DISPLAY_GEOMETRY ...=pass` lines are required; full output is saved in +Seven `DISPLAY_GEOMETRY ...=pass` lines are required; full output is saved in `build/pr12-display-check.log`. It checks the live primary screen's origin, synthetic single-monitor and offset @@ -445,8 +445,12 @@ relative deltas, both RandR cache-invalidation events, and the root-screen fallb Warps are intercepted: the probe does not move the desktop pointer or send game input. Synthetic layouts exercise coordinate conversion and display API behavior; they do not simulate physical monitor hotplug or validate League rendering. -Fullscreen policy is unchanged. Manually verify game launch, window placement, -click alignment and edge panning on the affected monitor layout before rollout. +The probe also checks that managed borderless windows covering a whole monitor +request EWMH fullscreen, including on secondary monitors, and clear the property +when resized before mapping. Titled windows, unmanaged popups and windows sized +to the panel-reduced work area are excluded. Mapped-window transitions and panel +stacking require manual verification under KDE Plasma/Xwayland. Also verify game +launch, window placement, click alignment and edge panning before rollout. After installing the tested X11 and CoreGraphics binaries locally, the user reported that everything was working (2026-10-16). Fullscreen policy was unchanged. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 75b1c4d..4b54432 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -236,6 +236,36 @@ The backend's relative mouse mode (`CGAssociateMouseAndMouseCursorPosition(false 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. +## Click offset grows towards the bottom of the screen + +A growing vertical offset has been reported on KDE Plasma/Wayland with Xwayland. +The cause is unconfirmed, including whether it is related to commit `426fb75`. +The borderless fullscreen request has been restored separately; retest alignment +once the game covers the panel. + +If it persists, enable the passive input trace before the next manual game launch: + +```sh +darling shell touch /tmp/rclol-input-trace +``` + +The flag is checked once per process, so enable it before launching the game. +During a short manual test, compare clicks near the top, middle and bottom, and +note the direction and approximate size of the error. Include the in-game +resolution and window mode, Plasma's scale setting for each monitor, and the +host output of `xrandr --listmonitors`. Collect the guest log with: + +```sh +darling shell cat /tmp/rclol-input-trace.log > rclol-input-trace.log +darling shell rm -f /tmp/rclol-input-trace +``` + +Removing the flag disables tracing for subsequently started processes. The log +records motion, warps, pointer-position queries and relative-mode transitions; +it can establish whether the relative path is active, but cannot by itself +establish where the game rendered a clicked target. If practical, compare the +same resolution in a smaller window and in borderless fullscreen. + ## Cursor stops changing shape in fullscreen On Hyprland the game's cursor can keep its first shape (for example, it never switches to the attack cursor) until you leave and re-enter fullscreen. Darling sends every change to the X server; what freezes the shape on screen is Hyprland's direct scanout of the fullscreen Xwayland window. Turn it off in `hyprland.conf`: diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 65733bd..376a448 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -3950,7 +3950,7 @@ index 0e91a5d1..1e4cec1b 100644 - (void) selectionNotify: (XSelectionEvent *) event { diff --git a/AppKit/X11.backend/X11Window.h b/AppKit/X11.backend/X11Window.h -index bfe3839a..dbc974a0 100644 +index bfe3839a..e8c8828f 100644 --- a/AppKit/X11.backend/X11Window.h +++ b/AppKit/X11.backend/X11Window.h @@ -26,7 +26,7 @@ @@ -3962,8 +3962,12 @@ index bfe3839a..dbc974a0 100644 @interface X11Window : CGWindow { int _level; // TODO: care about this value -@@ -49,6 +49,26 @@ +@@ -47,8 +47,30 @@ + BOOL _mapped; + CGPoint _lastMotionPos; BOOL _isModal; ++ BOOL _overrideRedirect; ++ BOOL _fullscreenRequested; BOOL _isOpaque; BOOL _hasShadow; + NSTimer *_remoteFrameTimer; @@ -3989,7 +3993,7 @@ index bfe3839a..dbc974a0 100644 @public XIC _xic; -@@ -60,9 +80,9 @@ +@@ -60,9 +82,9 @@ - (Visual *) visual; - (Drawable) drawable; - (NSPoint) transformPoint: (NSPoint) pos; @@ -4001,7 +4005,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..6f653f6d 100644 +index f4925b66..959047d7 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -38,6 +38,34 @@ @@ -4048,7 +4052,7 @@ index f4925b66..6f653f6d 100644 BOOL isPanel = [delegate isKindOfClass: [NSPanel class]]; if (isPanel && _styleMask & NSDocModalWindowMask) _styleMask = NSBorderlessWindowMask; -@@ -193,8 +221,13 @@ static NSData *makeWindowIcon() { +@@ -193,8 +221,14 @@ static NSData *makeWindowIcon() { XSetWindowAttributes xattr; unsigned long xattr_mask; @@ -4061,10 +4065,11 @@ index f4925b66..6f653f6d 100644 + ([delegate isKindOfClass:[NSMenuWindow class]] || + [delegate isKindOfClass:[NSPopUpWindow class]] || + ![delegate canBecomeKeyWindow]) ? True : False; ++ _overrideRedirect = xattr.override_redirect; xattr_mask = CWOverrideRedirect | CWColormap; xattr.colormap = cmap; -@@ -206,6 +239,12 @@ static NSData *makeWindowIcon() { +@@ -206,6 +240,12 @@ static NSData *makeWindowIcon() { (_visualInfo == NULL) ? CopyFromParent : _visualInfo->visual, xattr_mask, &xattr); @@ -4077,7 +4082,7 @@ index f4925b66..6f653f6d 100644 [self syncDelegateProperties]; Atom atm = XInternAtom(_display, "WM_DELETE_WINDOW", False); -@@ -266,6 +305,11 @@ static NSData *makeWindowIcon() { +@@ -266,6 +306,11 @@ static NSData *makeWindowIcon() { [self setWindowIcon]; @@ -4089,7 +4094,103 @@ index f4925b66..6f653f6d 100644 return self; } -@@ -338,7 +382,6 @@ static NSData *makeWindowIcon() { +@@ -279,24 +324,23 @@ static NSData *makeWindowIcon() { + return _styleMask; + } + +-- (void) setStyleMaskInternal: (NSUInteger) mask force: (BOOL) force { +- if (force || (mask & NSWindowStyleMaskResizable) != +- (_styleMask & NSWindowStyleMaskResizable)) { +- XSizeHints *sh = XAllocSizeHints(); +- if (mask & NSWindowStyleMaskResizable) { +- // Make resizable +- sh->flags = 0; +- } else { +- // Make non-resizable +- sh->flags = PMinSize | PMaxSize; +- sh->min_width = sh->max_width = _frame.size.width; +- sh->min_height = sh->max_height = _frame.size.height; +- } +- +- XSetWMSizeHints(_display, _window, sh, XA_WM_NORMAL_HINTS); +- XFree(sh); ++/* A borderless application covering a monitor needs EWMH fullscreen so the ++ * compositor places it above desktop panels. Compare full monitor bounds, ++ * never the work area, and leave unmanaged menus/popups alone. */ ++- (BOOL) wantsFullscreenWithStyleMask: (NSUInteger) mask rootFrame: (O2Rect) frame { ++ if (_overrideRedirect) return NO; ++ if (mask & NSWindowStyleMaskFullScreen) return YES; ++ if (mask & NSTitledWindowMask) return NO; ++ X11Display *display = (X11Display *) [NSDisplay currentDisplay]; ++ for (NSScreen *screen in [display screens]) { ++ if (!NSIsEmptyRect([screen frame]) && ++ NSEqualRects([display rootRectForFrame: [screen frame]], frame)) ++ return YES; + } ++ return NO; ++} + ++- (void) requestFullscreen: (BOOL) fullscreen force: (BOOL) force { + if (!_mapped) { + long states[2]; + int states_cnt = 0; +@@ -304,7 +348,7 @@ static NSData *makeWindowIcon() { + states[states_cnt++] = + (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); + } +- if (mask & NSWindowStyleMaskFullScreen) { ++ if (fullscreen) { + states[states_cnt++] = (long) XInternAtom( + _display, "_NET_WM_STATE_FULLSCREEN", False); + } +@@ -312,14 +356,13 @@ static NSData *makeWindowIcon() { + XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, + 32, PropModeReplace, (const unsigned char *) states, + states_cnt); +- } else if (force || ((mask & NSWindowStyleMaskFullScreen) != +- (_styleMask & NSWindowStyleMaskFullScreen))) { ++ } else if (force || fullscreen != _fullscreenRequested) { + XClientMessageEvent event = {0}; + event.type = ClientMessage; + event.window = _window; + event.message_type = XInternAtom(_display, "_NET_WM_STATE", False); + event.format = 32; +- event.data.l[0] = (mask & NSWindowStyleMaskFullScreen) ? 1 : 2; ++ event.data.l[0] = fullscreen ? 1 : 0; /* EWMH ADD / REMOVE, not TOGGLE */ + event.data.l[1] = + (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); + event.data.l[3] = 1; +@@ -327,6 +370,29 @@ static NSData *makeWindowIcon() { + SubstructureNotifyMask | SubstructureRedirectMask, + (XEvent *) &event); + } ++ _fullscreenRequested = fullscreen; ++} ++ ++- (void) setStyleMaskInternal: (NSUInteger) mask force: (BOOL) force { ++ if (force || (mask & NSWindowStyleMaskResizable) != ++ (_styleMask & NSWindowStyleMaskResizable)) { ++ XSizeHints *sh = XAllocSizeHints(); ++ if (mask & NSWindowStyleMaskResizable) { ++ // Make resizable ++ sh->flags = 0; ++ } else { ++ // Make non-resizable ++ sh->flags = PMinSize | PMaxSize; ++ sh->min_width = sh->max_width = _frame.size.width; ++ sh->min_height = sh->max_height = _frame.size.height; ++ } ++ ++ XSetWMSizeHints(_display, _window, sh, XA_WM_NORMAL_HINTS); ++ XFree(sh); ++ } ++ ++ [self requestFullscreen: [self wantsFullscreenWithStyleMask: mask rootFrame: _frame] ++ force: force]; + } + + - (void) setStyleMask: (NSUInteger) mask { +@@ -338,7 +404,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -4097,7 +4198,7 @@ index f4925b66..6f653f6d 100644 struct { unsigned long flags; unsigned long functions; -@@ -358,6 +401,7 @@ static NSData *makeWindowIcon() { +@@ -358,6 +423,7 @@ static NSData *makeWindowIcon() { if (!_mapped) { XMapWindow(_display, _window); _mapped = YES; @@ -4105,7 +4206,7 @@ index f4925b66..6f653f6d 100644 } } -@@ -377,15 +421,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +443,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -4138,7 +4239,7 @@ index f4925b66..6f653f6d 100644 // TODO: background color } -@@ -393,6 +449,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +471,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. @@ -4167,7 +4268,7 @@ index f4925b66..6f653f6d 100644 [_context release]; _context = nil; -@@ -421,6 +499,388 @@ static NSData *makeWindowIcon() { +@@ -421,6 +521,388 @@ static NSData *makeWindowIcon() { } } @@ -4556,12 +4657,15 @@ index f4925b66..6f653f6d 100644 - (Window) windowHandle { return _window; } -@@ -473,11 +933,20 @@ static NSData *makeWindowIcon() { +@@ -473,11 +955,24 @@ static NSData *makeWindowIcon() { } - (void) setFrame: (O2Rect) frame { - frame = [self transformFrame: frame]; + frame = [(X11Display *) [NSDisplay currentDisplay] rootRectForFrame: frame]; ++ BOOL fullscreen = [self wantsFullscreenWithStyleMask: _styleMask rootFrame: frame]; ++ // Release the WM's fullscreen size before requesting a smaller frame. ++ if (!fullscreen) [self requestFullscreen: NO force: NO]; XMoveResizeWindow(_display, _window, frame.origin.x, frame.origin.y, frame.size.width, frame.size.height); [self invalidateContextWithNewSize: frame.size]; @@ -4575,10 +4679,11 @@ index f4925b66..6f653f6d 100644 + hints.min_height = hints.max_height = frame.size.height; + XSetWMSizeHints(_display, _window, &hints, XA_WM_NORMAL_HINTS); + } ++ if (fullscreen) [self requestFullscreen: YES force: NO]; } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +957,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +983,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -4604,7 +4709,7 @@ index f4925b66..6f653f6d 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -559,7 +1047,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1073,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -4634,7 +4739,7 @@ index f4925b66..6f653f6d 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -615,6 +1125,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1151,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -4647,7 +4752,7 @@ index f4925b66..6f653f6d 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1168,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1194,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -4656,7 +4761,7 @@ index f4925b66..6f653f6d 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1182,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1208,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -4664,7 +4769,7 @@ index f4925b66..6f653f6d 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,14 +1227,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,14 +1253,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } diff --git a/tools/check-x11-display-geometry.py b/tools/check-x11-display-geometry.py index d84fa5d..bde1a1c 100644 --- a/tools/check-x11-display-geometry.py +++ b/tools/check-x11-display-geometry.py @@ -41,7 +41,8 @@ subprocess.run(['clang', *flags_for(frameworks / 'AppKit', 'X11_backend'), subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', '-isysroot', config['sdk'], str(probe) + '.o', '-lSystem', '-framework', 'AppKit', '-framework', 'Foundation', - '-framework', 'CoreGraphics', '-lobjc', '-o', str(probe)], check=True) + '-framework', 'CoreGraphics', '-lobjc', + '-L' + str(Path(config['sdk']) / 'usr/lib/native'), '-lX11', '-o', str(probe)], check=True) original = Path(config['prefix']) / config['guest_home'].lstrip('/') / 'RuntimeFrameworks' with tempfile.TemporaryDirectory(prefix='pr12-display-frameworks-', dir=root / 'build') as directory: @@ -66,5 +67,5 @@ with tempfile.TemporaryDirectory(prefix='pr12-display-frameworks-', dir=root / ' output.seek(0) lines = [line.rstrip() for line in output if line.startswith('DISPLAY_GEOMETRY')] print('\n'.join(lines)) - if result.returncode or len(lines) != 6: + if result.returncode or len(lines) != 7: raise SystemExit(f'Display probe failed ({result.returncode}); inspect {log}') diff --git a/tools/riot-display-geometry-check.m b/tools/riot-display-geometry-check.m index a457890..c4445ca 100644 --- a/tools/riot-display-geometry-check.m +++ b/tools/riot-display-geometry-check.m @@ -8,6 +8,7 @@ #import "../build/darling-src/src/external/cocotron/AppKit/X11.backend/X11Display.h" #import "../build/darling-src/src/external/cocotron/AppKit/X11.backend/X11Window.h" #include +#include #include #include @@ -20,6 +21,27 @@ extern CGError CGWarpMouseCursorPosition(CGPoint position); - (void)postXEvent:(XEvent *)event; @end +@interface X11Window (FullscreenProbe) +- (BOOL)wantsFullscreenWithStyleMask:(NSUInteger)mask rootFrame:(O2Rect)frame; +@end +@interface FullscreenProbeWindow : NSWindow +@end +@implementation FullscreenProbeWindow +- (BOOL)canBecomeKeyWindow { return YES; } +@end +static BOOL fullscreenProperty(X11Display *display, X11Window *window) { + Display *d = [display display]; + Atom actual, fs = XInternAtom(d,"_NET_WM_STATE_FULLSCREEN",False); + int format; unsigned long count, remaining; unsigned char *data = NULL; + assert(XGetWindowProperty(d,[window windowHandle],XInternAtom(d,"_NET_WM_STATE",False), + 0,32,False,XA_ATOM,&actual,&format,&count,&remaining,&data) == Success); + BOOL found = NO; + assert(actual == XA_ATOM && format == 32 && remaining == 0); + for (unsigned long i=0; i Date: Fri, 16 Oct 2026 17:15:43 +0200 Subject: [PATCH 036/160] cocotron: Fix initial fullscreen black screen and camera panning stutter on Wayland --- patches/cocotron.patch | 176 ++++++++++++++++++++++++----------------- 1 file changed, 102 insertions(+), 74 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 6fd9936..2b64a4c 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2242,7 +2242,7 @@ 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..5b3a34bc 100644 +index 18189404..c683f96a 100644 --- a/AppKit/X11.backend/X11Display.h +++ b/AppKit/X11.backend/X11Display.h @@ -10,13 +10,14 @@ @@ -2261,7 +2261,7 @@ index 18189404..5b3a34bc 100644 @interface X11Display : NSDisplay { Display *_display; -@@ -36,19 +37,41 @@ +@@ -36,19 +37,43 @@ NSTimeInterval lastClickTimeStamp; int clickCount; X11Cursor *_blankCursor, *_defaultCursor; @@ -2277,6 +2277,8 @@ index 18189404..5b3a34bc 100644 + X11Cursor *_definedCursor; + BOOL _cursorHidden; + Window _confinedWindow; ++ int _confinedWidth; ++ int _confinedHeight; + unsigned int _otherButtonsDown; KeySym _lastKeySym; + unsigned char _modifierKeysDown[256]; @@ -2305,7 +2307,7 @@ index 18189404..5b3a34bc 100644 +- (NSRect) frameForRootRect: (NSRect) rect; @end diff --git a/AppKit/X11.backend/X11Display.m b/AppKit/X11.backend/X11Display.m -index 5ae29bf4..5403cfa3 100644 +index 5ae29bf4..c0361b56 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3022,7 +3024,7 @@ index 5ae29bf4..5403cfa3 100644 } - (void) setWindow: (id) window forID: (XID) i { -@@ -948,6 +1427,16 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -948,6 +1427,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; @@ -3032,14 +3034,17 @@ index 5ae29bf4..5403cfa3 100644 + _definedCursor = nil; + } + /* The server released any grab on a destroyed window. */ -+ if (_confinedWindow == i) ++ if (_confinedWindow == i) { + _confinedWindow = None; ++ _confinedWidth = 0; ++ _confinedHeight = 0; ++ } + if (_relativeWindow == i) + [self suspendRelativeMouse]; } } -@@ -962,20 +1451,85 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -962,20 +1454,85 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { { #ifndef DARLING [[NSRunLoop currentRunLoop] addInputSource: _inputSource forMode: mode]; @@ -3130,7 +3135,7 @@ index 5ae29bf4..5403cfa3 100644 } - (NSEventModifierFlags) modifierFlagsForState: (unsigned int) state { -@@ -1010,17 +1564,96 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1010,17 +1567,96 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return result; } @@ -3227,7 +3232,7 @@ index 5ae29bf4..5403cfa3 100644 NSEventModifierFlags modifierFlags = [self modifierFlagsForState: ev->xkey.state]; char buf[20] = {0}; KeySym keySym; -@@ -1038,6 +1671,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1038,6 +1674,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { buf[strLen] = 0; } @@ -3246,7 +3251,7 @@ index 5ae29bf4..5403cfa3 100644 id str = [[NSString alloc] initWithCString: buf encoding: NSUTF8StringEncoding]; NSPoint pos = -@@ -1077,6 +1722,33 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1077,6 +1725,33 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ButtonPress:; @@ -3280,7 +3285,7 @@ index 5ae29bf4..5403cfa3 100644 NSTimeInterval now = [[NSDate date] timeIntervalSinceReferenceDate]; if (now - lastClickTimeStamp < [self doubleClickInterval]) { -@@ -1086,8 +1758,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1086,8 +1761,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } lastClickTimeStamp = now; @@ -3291,7 +3296,7 @@ index 5ae29bf4..5403cfa3 100644 switch (ev->xbutton.button) { case Button1: -@@ -1096,10 +1768,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1096,10 +1771,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseDown; break; @@ -3302,7 +3307,7 @@ index 5ae29bf4..5403cfa3 100644 default: type = NSOtherMouseDown; } -@@ -1113,14 +1781,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1113,14 +1784,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: 0.0]; @@ -3329,7 +3334,7 @@ index 5ae29bf4..5403cfa3 100644 CGFloat deltaY = 0.0; switch (ev->xbutton.button) { -@@ -1130,14 +1806,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1130,14 +1809,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseUp; break; @@ -3344,7 +3349,7 @@ index 5ae29bf4..5403cfa3 100644 default: type = NSOtherMouseUp; } -@@ -1151,7 +1819,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1151,7 +1822,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: deltaY]; @@ -3355,7 +3360,7 @@ index 5ae29bf4..5403cfa3 100644 [self postEvent: event atStart: NO]; break; -@@ -1160,49 +1830,113 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1160,49 +1833,113 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { // ev->xmotion.x, ev->xmotion.y, ev->xmotion.x_root, // ev->xmotion.y_root); @@ -3364,25 +3369,25 @@ index 5ae29bf4..5403cfa3 100644 + 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]) { -+ XWindowAttributes attrs; -+ if (XGetWindowAttributes(_display, _confinedWindow, &attrs)) { -+ int clampedX = ev->xmotion.x; -+ int clampedY = ev->xmotion.y; -+ BOOL needWarp = NO; -+ if (clampedX < 0) { clampedX = 0; needWarp = YES; } -+ if (clampedX >= attrs.width) { clampedX = attrs.width - 1; needWarp = YES; } -+ if (clampedY < 0) { clampedY = 0; needWarp = YES; } -+ if (clampedY >= attrs.height) { clampedY = attrs.height - 1; needWarp = YES; } -+ if (needWarp) { -+ XWarpPointer(_display, None, _confinedWindow, 0, 0, 0, 0, clampedX, clampedY); -+ XFlush(_display); -+ ev->xmotion.x = clampedX; -+ ev->xmotion.y = clampedY; -+ } ++ if (_confinedWindow == [window windowHandle] && _confinedWidth > 0 && _confinedHeight > 0) { ++ int clampedX = ev->xmotion.x; ++ int clampedY = ev->xmotion.y; ++ BOOL needWarp = NO; ++ if (clampedX < 0) { clampedX = 0; needWarp = YES; } ++ else if (clampedX >= _confinedWidth) { clampedX = _confinedWidth - 1; needWarp = YES; } ++ if (clampedY < 0) { clampedY = 0; needWarp = YES; } ++ else if (clampedY >= _confinedHeight) { clampedY = _confinedHeight - 1; needWarp = YES; } ++ if (needWarp) { ++ XWarpPointer(_display, None, _confinedWindow, 0, 0, 0, 0, clampedX, clampedY); ++ ev->xmotion.x = clampedX; ++ ev->xmotion.y = clampedY; + } -+ } else if (!_relativeMouse.active && window != nil) { ++ } else if (!_relativeMouse.active && window != nil && !_confinedWindow) { + [self updateMouseConfinementForWindow: window]; + } + @@ -3495,7 +3500,7 @@ index 5ae29bf4..5403cfa3 100644 mouseEventWithType: type location: pos modifierFlags: -@@ -1211,28 +1945,61 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +1948,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -3530,15 +3535,12 @@ index 5ae29bf4..5403cfa3 100644 case LeaveNotify: NSLog(@"LeaveNotify"); -+ if (_confinedWindow == [window windowHandle]) { ++ if (_confinedWindow == [window windowHandle] && _confinedWidth > 0 && _confinedHeight > 0) { + Window win = [window windowHandle]; -+ XWindowAttributes attrs; -+ if (XGetWindowAttributes(_display, win, &attrs)) { -+ int cx = ev->xcrossing.x < 0 ? 0 : (ev->xcrossing.x >= attrs.width ? attrs.width - 1 : ev->xcrossing.x); -+ int cy = ev->xcrossing.y < 0 ? 0 : (ev->xcrossing.y >= attrs.height ? attrs.height - 1 : ev->xcrossing.y); -+ XWarpPointer(_display, None, win, 0, 0, 0, 0, cx, cy); -+ XFlush(_display); -+ } ++ int cx = ev->xcrossing.x < 0 ? 0 : (ev->xcrossing.x >= _confinedWidth ? _confinedWidth - 1 : ev->xcrossing.x); ++ int cy = ev->xcrossing.y < 0 ? 0 : (ev->xcrossing.y >= _confinedHeight ? _confinedHeight - 1 : ev->xcrossing.y); ++ XWarpPointer(_display, None, win, 0, 0, 0, 0, cx, cy); ++ XFlush(_display); + [self updateMouseConfinementForWindow: window]; + break; + } @@ -3657,7 +3659,15 @@ index 5ae29bf4..5403cfa3 100644 break; case MapRequest: -@@ -1321,6 +2139,7 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1317,10 +2135,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + break; + + case ConfigureNotify: ++ if (ev->xconfigure.window == _confinedWindow) { ++ _confinedWidth = ev->xconfigure.width; ++ _confinedHeight = ev->xconfigure.height; ++ } + [window frameChanged]; [delegate platformWindow: window frameChanged: [window frame] didSize: YES]; @@ -3665,7 +3675,7 @@ index 5ae29bf4..5403cfa3 100644 break; case ConfigureRequest: -@@ -1344,6 +2163,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2167,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -3674,7 +3684,7 @@ index 5ae29bf4..5403cfa3 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1399,7 +2220,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1399,7 +2224,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; default: @@ -3685,7 +3695,7 @@ index 5ae29bf4..5403cfa3 100644 [self _invalidateRRCache]; break; } -@@ -1433,6 +2256,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1433,6 +2260,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); @@ -3696,7 +3706,7 @@ index 5ae29bf4..5403cfa3 100644 return 0; } -@@ -1476,54 +2303,271 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2307,286 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } @@ -3742,8 +3752,8 @@ index 5ae29bf4..5403cfa3 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 { @@ -3820,10 +3830,10 @@ index 5ae29bf4..5403cfa3 100644 + SubstructureNotifyMask | SubstructureRedirectMask, + (XEvent *)&cme); + -+ /* Also set property directly on window for immediate Mutter state sync */ -+ XChangeProperty(_display, win, state, XA_ATOM, 32, PropModeReplace, -+ (const unsigned char *)&fs, 1); -+ unsigned long bypass = 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. */ ++ unsigned long bypass = 2; + XChangeProperty(_display, win, XInternAtom(_display, "_NET_WM_BYPASS_COMPOSITOR", False), + XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&bypass, 1); + } @@ -3865,9 +3875,16 @@ index 5ae29bf4..5403cfa3 100644 + (unsigned long) win, fullscreen, rclolEwmhFullscreen, + ([[window delegate] styleMask] & NSTitledWindowMask) != 0, attrs.width, attrs.height, + attrs.map_state, _confinedWindow == win ? "releasing confinement" : "nothing to release"); -+ if (_confinedWindow == win) { XUngrabPointer(_display, CurrentTime); _confinedWindow=None; } ++ if (_confinedWindow == win) { ++ XUngrabPointer(_display, CurrentTime); ++ _confinedWindow = None; ++ _confinedWidth = 0; ++ _confinedHeight = 0; ++ } + return; + } ++ _confinedWidth = attrs.width; ++ _confinedHeight = attrs.height; + if (_confinedWindow == win) { + if (rclolTraceOn()) + rclolTraceRated(&rclolConfineChannel, "already confined window=%lu", (unsigned long) win); @@ -3881,9 +3898,13 @@ index 5ae29bf4..5403cfa3 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) _confinedWindow=win; -+} -+ ++ if (status == GrabSuccess || status == AlreadyGrabbed) { ++ _confinedWindow = win; ++ _confinedWidth = attrs.width; ++ _confinedHeight = attrs.height; ++ } + } + +/* 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 { @@ -3911,6 +3932,8 @@ index 5ae29bf4..5403cfa3 100644 + } + NSLog(@"XGrabPointer() succeeded for window %lu\n", win); + _confinedWindow = None; ++ _confinedWidth = 0; ++ _confinedHeight = 0; + Window root, child; + int rootX = rclolRootX, rootY = rclolRootY, windowX, windowY; + unsigned int state; @@ -3985,6 +4008,8 @@ index 5ae29bf4..5403cfa3 100644 + _relativeRequested = NO; + _relativeWindow = None; + _confinedWindow = None; ++ _confinedWidth = 0; ++ _confinedHeight = 0; + rclolPointerStamp = 0; + NSWindow *nswin = [NSApp keyWindow]; + if (nswin) [self updateMouseConfinementForWindow:(X11Window *)[nswin platformWindow]]; @@ -4404,7 +4429,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..12a75854 100644 +index f4925b66..04fa8146 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -38,6 +38,34 @@ @@ -4499,7 +4524,7 @@ index f4925b66..12a75854 100644 [self syncDelegateProperties]; Atom atm = XInternAtom(_display, "WM_DELETE_WINDOW", False); -@@ -217,6 +275,16 @@ static NSData *makeWindowIcon() { +@@ -217,6 +275,19 @@ static NSData *makeWindowIcon() { .res_class = (char *) name}; XSetClassHint(_display, _window, &classHint); @@ -4508,7 +4533,10 @@ index f4925b66..12a75854 100644 + Atom fs = XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); + XChangeProperty(_display, _window, state, XA_ATOM, 32, PropModeReplace, + (const unsigned char *)&fs, 1); -+ unsigned long bypass = 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. */ ++ unsigned long bypass = 2; + XChangeProperty(_display, _window, XInternAtom(_display, "_NET_WM_BYPASS_COMPOSITOR", False), + XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&bypass, 1); + } @@ -4516,7 +4544,7 @@ index f4925b66..12a75854 100644 XSetWindowBackgroundPixmap(_display, _window, None); _isModal = [delegate isSheet] || [NSApp modalWindow] == delegate; -@@ -266,6 +334,11 @@ static NSData *makeWindowIcon() { +@@ -266,6 +337,11 @@ static NSData *makeWindowIcon() { [self setWindowIcon]; @@ -4528,7 +4556,7 @@ index f4925b66..12a75854 100644 return self; } -@@ -287,10 +360,19 @@ static NSData *makeWindowIcon() { +@@ -287,10 +363,19 @@ static NSData *makeWindowIcon() { // Make resizable sh->flags = 0; } else { @@ -4552,7 +4580,7 @@ index f4925b66..12a75854 100644 } XSetWMSizeHints(_display, _window, sh, XA_WM_NORMAL_HINTS); -@@ -338,7 +420,6 @@ static NSData *makeWindowIcon() { +@@ -338,7 +423,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -4560,7 +4588,7 @@ index f4925b66..12a75854 100644 struct { unsigned long flags; unsigned long functions; -@@ -358,6 +439,7 @@ static NSData *makeWindowIcon() { +@@ -358,6 +442,7 @@ static NSData *makeWindowIcon() { if (!_mapped) { XMapWindow(_display, _window); _mapped = YES; @@ -4568,7 +4596,7 @@ index f4925b66..12a75854 100644 } } -@@ -377,15 +459,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +462,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -4601,7 +4629,7 @@ index f4925b66..12a75854 100644 // TODO: background color } -@@ -393,6 +487,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +490,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. @@ -4630,7 +4658,7 @@ index f4925b66..12a75854 100644 [_context release]; _context = nil; -@@ -421,6 +537,388 @@ static NSData *makeWindowIcon() { +@@ -421,6 +540,388 @@ static NSData *makeWindowIcon() { } } @@ -5019,7 +5047,7 @@ index f4925b66..12a75854 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +968,31 @@ static NSData *makeWindowIcon() { +@@ -470,14 +971,31 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -5052,7 +5080,7 @@ index f4925b66..12a75854 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1003,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1006,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -5078,7 +5106,7 @@ index f4925b66..12a75854 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1077,18 @@ static NSData *makeWindowIcon() { +@@ -543,6 +1080,18 @@ static NSData *makeWindowIcon() { - (void) makeKey { [self ensureMapped]; XRaiseWindow(_display, _window); @@ -5097,7 +5125,7 @@ index f4925b66..12a75854 100644 } - (void) makeMain { -@@ -559,7 +1105,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1108,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -5127,7 +5155,7 @@ index f4925b66..12a75854 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -615,6 +1183,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1186,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -5140,7 +5168,7 @@ index f4925b66..12a75854 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1226,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1229,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -5149,7 +5177,7 @@ index f4925b66..12a75854 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1240,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1243,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -5157,7 +5185,7 @@ index f4925b66..12a75854 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,14 +1285,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,14 +1288,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } From c90c603f4aa11247ff9017df7a5f95c77d1d1e51 Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 17:17:31 +0200 Subject: [PATCH 037/160] Sync patches/cocotron.patch with submodule mywork --- patches/cocotron.patch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 11dbe0e..2afffdc 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -5781,7 +5781,7 @@ index db8869c8..6fea5107 100644 + CGContextSetTextPosition(c, savedPosition.x, savedPosition.y); } diff --git a/CoreGraphics/CGDirectDisplay.m b/CoreGraphics/CGDirectDisplay.m -index e213b066..ea0bc264 100644 +index e213b066..9dc3da02 100644 --- a/CoreGraphics/CGDirectDisplay.m +++ b/CoreGraphics/CGDirectDisplay.m @@ -53,6 +53,29 @@ static NSDisplay *currentDisplay(void) { From 2759adbdd9886687f9029728fe433db50bf202da Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 17:40:51 +0200 Subject: [PATCH 038/160] Fix in-game keyboard input, initial fullscreen black screen, and desktop launcher icon --- patches/cocotron.patch | 123 ++++++++++++++++++++++++------------ tools/riot-desktop-entry.py | 23 +++++-- 2 files changed, 99 insertions(+), 47 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 2afffdc..caa7a26 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2307,7 +2307,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..c0361b56 100644 +index 5ae29bf4..516decf3 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3232,7 +3232,20 @@ index 5ae29bf4..c0361b56 100644 NSEventModifierFlags modifierFlags = [self modifierFlagsForState: ev->xkey.state]; char buf[20] = {0}; KeySym keySym; -@@ -1038,6 +1674,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1030,7 +1666,11 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + break; + + if (ev->type == KeyPress) { +- strLen = Xutf8LookupString(window->_xic, (XKeyPressedEvent *) ev, buf, sizeof(buf) - 1, &keySym, NULL); ++ if (window && window->_xic) { ++ strLen = Xutf8LookupString(window->_xic, (XKeyPressedEvent *) ev, buf, sizeof(buf) - 1, &keySym, NULL); ++ } else { ++ strLen = XLookupString((XKeyEvent*) ev, buf, sizeof(buf) - 1, &keySym, NULL); ++ } + buf[strLen] = 0; + } else { + // Xutf8LookupString() may not be used with KeyRelease +@@ -1038,6 +1678,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { buf[strLen] = 0; } @@ -3251,12 +3264,30 @@ index 5ae29bf4..c0361b56 100644 id str = [[NSString alloc] initWithCString: buf encoding: NSUTF8StringEncoding]; NSPoint pos = -@@ -1077,6 +1725,33 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1077,6 +1729,51 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ButtonPress:; -+ if (window && !_confinedWindow) { -+ [self updateMouseConfinementForWindow: window]; ++ 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 (ev->xbutton.button >= Button4 && ev->xbutton.button <= 7) { + pos = [self locationInWindow: window x: ev->xbutton.x y: ev->xbutton.y @@ -3285,7 +3316,7 @@ index 5ae29bf4..c0361b56 100644 NSTimeInterval now = [[NSDate date] timeIntervalSinceReferenceDate]; if (now - lastClickTimeStamp < [self doubleClickInterval]) { -@@ -1086,8 +1761,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1086,8 +1783,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } lastClickTimeStamp = now; @@ -3296,7 +3327,7 @@ index 5ae29bf4..c0361b56 100644 switch (ev->xbutton.button) { case Button1: -@@ -1096,10 +1771,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1096,10 +1793,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseDown; break; @@ -3307,7 +3338,7 @@ index 5ae29bf4..c0361b56 100644 default: type = NSOtherMouseDown; } -@@ -1113,14 +1784,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1113,14 +1806,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: 0.0]; @@ -3334,7 +3365,7 @@ index 5ae29bf4..c0361b56 100644 CGFloat deltaY = 0.0; switch (ev->xbutton.button) { -@@ -1130,14 +1809,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1130,14 +1831,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseUp; break; @@ -3349,7 +3380,7 @@ index 5ae29bf4..c0361b56 100644 default: type = NSOtherMouseUp; } -@@ -1151,7 +1822,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1151,7 +1844,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: deltaY]; @@ -3360,7 +3391,7 @@ index 5ae29bf4..c0361b56 100644 [self postEvent: event atStart: NO]; break; -@@ -1160,49 +1833,113 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1160,49 +1855,113 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { // ev->xmotion.x, ev->xmotion.y, ev->xmotion.x_root, // ev->xmotion.y_root); @@ -3500,7 +3531,7 @@ index 5ae29bf4..c0361b56 100644 mouseEventWithType: type location: pos modifierFlags: -@@ -1211,28 +1948,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +1970,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -3561,7 +3592,7 @@ index 5ae29bf4..c0361b56 100644 NSLog(@"FocusIn"); if ([delegate attachedSheet]) { [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; -@@ -1243,28 +2010,67 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +2032,59 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -3592,14 +3623,6 @@ index 5ae29bf4..c0361b56 100644 + if (ev->xfocus.detail == NotifyPointer || ev->xfocus.mode == NotifyGrab || ev->xfocus.mode == NotifyUngrab) { + /* Pointer boundary crossing or grab transition in XWayland: do NOT release confinement or deactivate window */ + break; -+ } -+ { -+ NSString *procName = [[NSProcessInfo processInfo] processName]; -+ BOOL isGame = (procName && [procName rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound); -+ if (isGame && [NSApp isActive]) { -+ /* While League is the active app, do not drop confinement on transient XWayland focus events */ -+ break; -+ } + } NSLog(@"FocusOut"); + [self reconcileModifierKeys:NULL window:delegate]; @@ -3631,7 +3654,7 @@ index 5ae29bf4..c0361b56 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +2107,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +2121,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -3659,7 +3682,7 @@ index 5ae29bf4..c0361b56 100644 break; case MapRequest: -@@ -1317,10 +2135,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1317,10 +2149,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ConfigureNotify: @@ -3675,7 +3698,7 @@ index 5ae29bf4..c0361b56 100644 break; case ConfigureRequest: -@@ -1344,6 +2167,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2181,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -3684,7 +3707,7 @@ index 5ae29bf4..c0361b56 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1399,7 +2224,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1399,7 +2238,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; default: @@ -3695,7 +3718,7 @@ index 5ae29bf4..c0361b56 100644 [self _invalidateRRCache]; break; } -@@ -1433,6 +2260,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1433,6 +2274,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); @@ -3706,7 +3729,7 @@ index 5ae29bf4..c0361b56 100644 return 0; } -@@ -1476,54 +2307,286 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2321,286 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } @@ -3891,7 +3914,7 @@ index 5ae29bf4..c0361b56 100644 + return; + } + unsigned mask = ButtonPressMask | ButtonReleaseMask | PointerMotionMask; -+ int status=XGrabPointer(_display, win, False, mask, GrabModeAsync, GrabModeAsync, ++ int status=XGrabPointer(_display, win, True, mask, GrabModeAsync, GrabModeAsync, + win, None, CurrentTime); + if (getenv("RCLOL_INPUT_TRACE")) fprintf(stderr,"CONFINEMENT fullscreen=%d window=%lu result=%d\n",fullscreen,win,status); + if (rclolTraceOn()) @@ -4429,7 +4452,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..04fa8146 100644 +index f4925b66..af6df163 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -38,6 +38,34 @@ @@ -4580,7 +4603,23 @@ index f4925b66..04fa8146 100644 } XSetWMSizeHints(_display, _window, sh, XA_WM_NORMAL_HINTS); -@@ -338,7 +423,6 @@ static NSData *makeWindowIcon() { +@@ -304,7 +389,14 @@ static NSData *makeWindowIcon() { + states[states_cnt++] = + (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); + } +- if (mask & NSWindowStyleMaskFullScreen) { ++ NSString *procName = [[NSProcessInfo processInfo] processName]; ++ BOOL isGame = (procName && [procName rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound && ++ [procName rangeOfString:@"Ux" options:NSCaseInsensitiveSearch].location == NSNotFound); ++ BOOL makeFullscreen = (mask & NSWindowStyleMaskFullScreen) != 0; ++ if (!makeFullscreen && isGame && (mask == NSBorderlessWindowMask || !(mask & NSTitledWindowMask))) { ++ makeFullscreen = YES; ++ } ++ if (makeFullscreen) { + states[states_cnt++] = (long) XInternAtom( + _display, "_NET_WM_STATE_FULLSCREEN", False); + } +@@ -338,7 +430,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -4588,7 +4627,7 @@ index f4925b66..04fa8146 100644 struct { unsigned long flags; unsigned long functions; -@@ -358,6 +442,7 @@ static NSData *makeWindowIcon() { +@@ -358,6 +449,7 @@ static NSData *makeWindowIcon() { if (!_mapped) { XMapWindow(_display, _window); _mapped = YES; @@ -4596,7 +4635,7 @@ index f4925b66..04fa8146 100644 } } -@@ -377,15 +462,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +469,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -4629,7 +4668,7 @@ index f4925b66..04fa8146 100644 // TODO: background color } -@@ -393,6 +490,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +497,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. @@ -4658,7 +4697,7 @@ index f4925b66..04fa8146 100644 [_context release]; _context = nil; -@@ -421,6 +540,388 @@ static NSData *makeWindowIcon() { +@@ -421,6 +547,388 @@ static NSData *makeWindowIcon() { } } @@ -5047,7 +5086,7 @@ index f4925b66..04fa8146 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +971,31 @@ static NSData *makeWindowIcon() { +@@ -470,14 +978,31 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -5080,7 +5119,7 @@ index f4925b66..04fa8146 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1006,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1013,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -5106,7 +5145,7 @@ index f4925b66..04fa8146 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1080,18 @@ static NSData *makeWindowIcon() { +@@ -543,6 +1087,18 @@ static NSData *makeWindowIcon() { - (void) makeKey { [self ensureMapped]; XRaiseWindow(_display, _window); @@ -5125,7 +5164,7 @@ index f4925b66..04fa8146 100644 } - (void) makeMain { -@@ -559,7 +1108,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1115,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -5155,7 +5194,7 @@ index f4925b66..04fa8146 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -615,6 +1186,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1193,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -5168,7 +5207,7 @@ index f4925b66..04fa8146 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1229,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1236,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -5177,7 +5216,7 @@ index f4925b66..04fa8146 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1243,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1250,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -5185,7 +5224,7 @@ index f4925b66..04fa8146 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,14 +1288,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,14 +1295,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } diff --git a/tools/riot-desktop-entry.py b/tools/riot-desktop-entry.py index 1458bfb..c4c1dd8 100755 --- a/tools/riot-desktop-entry.py +++ b/tools/riot-desktop-entry.py @@ -148,12 +148,13 @@ Name=League of Legends GenericName=MOBA Game Comment=Play League of Legends on Linux with darling-lol Exec={run_script} +Path={PROJECT_ROOT} Icon=league-of-legends Terminal=false Categories=Game;ActionGame;StrategyGame; Keywords=riot;league;lol;darling;games; -StartupWMClass=League of Legends -StartupNotify=true +StartupWMClass=Riot Client +StartupNotify=false """ desktop_file = apps_base / "league-of-legends.desktop" @@ -161,11 +162,23 @@ StartupNotify=true os.chmod(desktop_file, 0o755) print(f"Created application launcher: {desktop_file}") - if desktop_dir.is_dir(): - desktop_shortcut = desktop_dir / "league-of-legends.desktop" + target_desktop_dirs = [] + try: + res = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True, check=False) + if res.returncode == 0 and res.stdout.strip(): + d = Path(res.stdout.strip()) + if d.is_dir() and d not in target_desktop_dirs: + target_desktop_dirs.append(d) + except Exception: + pass + for cand in [HOST_HOME / "Desktop", HOST_HOME / "Asztal"]: + if cand.is_dir() and cand not in target_desktop_dirs: + target_desktop_dirs.append(cand) + + for ddir in target_desktop_dirs: + desktop_shortcut = ddir / "league-of-legends.desktop" desktop_shortcut.write_text(desktop_content) os.chmod(desktop_shortcut, 0o755) - # Mark as trusted on GNOME Desktop if gio is available gio = shutil.which("gio") if gio: subprocess.run([gio, "set", str(desktop_shortcut), "metadata::trusted", "true"], check=False) From a73086de740b44585867928d2b3d8158aa955035 Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 17:50:22 +0200 Subject: [PATCH 039/160] Address code review feedback on input handling, pacing, and runtime cleanup --- patches/cocotron.patch | 16 +++--- patches/darling.patch | 5 +- patches/metal--deps--indium.patch | 6 +-- patches/xnu.patch | 11 ++-- scripts/installer.sh | 1 - tools/ensure-darling-runtime.py | 83 +++++++++++++++++-------------- tools/m58f299-xshm.c | 8 ++- tools/riot-desktop-entry.py | 2 +- 8 files changed, 68 insertions(+), 64 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index caa7a26..0886f3c 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2307,7 +2307,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..516decf3 100644 +index 5ae29bf4..d7fb09fc 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3125,7 +3125,7 @@ index 5ae29bf4..516decf3 100644 + int xfd = ConnectionNumber(_display); + struct pollfd pfd = { .fd = xfd, .events = POLLIN }; + int timeout_ms = (int)(slice * 1000.0); -+ if (timeout_ms < 1) timeout_ms = 1; ++ if (timeout_ms < 0) timeout_ms = 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 */); + } @@ -3391,7 +3391,7 @@ index 5ae29bf4..516decf3 100644 [self postEvent: event atStart: NO]; break; -@@ -1160,49 +1855,113 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1160,49 +1855,111 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { // ev->xmotion.x, ev->xmotion.y, ev->xmotion.x_root, // ev->xmotion.y_root); @@ -3418,8 +3418,6 @@ index 5ae29bf4..516decf3 100644 + ev->xmotion.x = clampedX; + ev->xmotion.y = clampedY; + } -+ } else if (!_relativeMouse.active && window != nil && !_confinedWindow) { -+ [self updateMouseConfinementForWindow: window]; + } + CGPoint lastMotionPos = [window mouseLocationOutsideOfEventStream]; @@ -3531,7 +3529,7 @@ index 5ae29bf4..516decf3 100644 mouseEventWithType: type location: pos modifierFlags: -@@ -1211,28 +1970,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +1968,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -3592,7 +3590,7 @@ index 5ae29bf4..516decf3 100644 NSLog(@"FocusIn"); if ([delegate attachedSheet]) { [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; -@@ -1243,28 +2032,59 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +2030,61 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -3620,7 +3618,7 @@ index 5ae29bf4..516decf3 100644 + rclolFocusDetailName(ev->xfocus.detail), _relativeRequested, _relativeMouse.active, + (unsigned long) _confinedWindow, + [NSApp isActive]); -+ if (ev->xfocus.detail == NotifyPointer || ev->xfocus.mode == NotifyGrab || ev->xfocus.mode == NotifyUngrab) { ++ 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; + } @@ -3637,6 +3635,8 @@ index 5ae29bf4..516decf3 100644 + if (_confinedWindow) { + XUngrabPointer(_display, CurrentTime); + _confinedWindow = None; ++ _confinedWidth = 0; ++ _confinedHeight = 0; + } + _otherButtonsDown = 0; if (window != nil) diff --git a/patches/darling.patch b/patches/darling.patch index 82a4fdc..312f184 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -6949,7 +6949,7 @@ index ffd7fe054..a74ffc63f 100644 + 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 ? 1000000000ull / (uint64_t)r : 1000000ull; } ++ 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; } + if(!deadline || (current>deadline && current-deadline>paceNS))deadline=current; + if(deadline>current){ + struct timespec wake={deadline/1000000000ull,deadline%1000000000ull}; @@ -8534,7 +8534,8 @@ new file mode 100644 + if (!refreshNS) { + const char *hz = getenv("RCLOL_REFRESH_HZ"); + int rate = hz ? atoi(hz) : 0; -+ refreshNS = rate > 0 ? 1000000000ull / (uint64_t)rate : 1000000ull; ++ refreshNS = (rate > 0 && rate <= 1000) ? 1000000000ull / (uint64_t)rate : 1000000ull; ++ if (refreshNS < 1000000ull) refreshNS = 1000000ull; + } + deadline += refreshNS; + uint64_t now=nativeMonotonicNS(); diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index f72c88d..c61e985 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -7450,11 +7450,7 @@ new file mode 100644 + static constexpr VkDeviceSize BlockSize = 32ull << 20; + static constexpr VkDeviceSize MaxPooledSize = 4ull << 20; + static constexpr VkDeviceSize Granule = 256; -+ // a freed range is not handed out again before this long: work already submitted to the GPU may still read it -+ /* darling-lol perf: the old 1000ms quarantine caused pool exhaustion -+ * and fallback OS allocations during high-churn gameplay. 50ms (~3 -+ * frames at 60Hz) is sufficient for in-flight GPU work to drain. */ -+ static constexpr std::chrono::milliseconds Quarantine {50}; ++ static constexpr std::chrono::milliseconds Quarantine {1000}; + + struct Block { + VkDeviceMemory memory = VK_NULL_HANDLE; diff --git a/patches/xnu.patch b/patches/xnu.patch index b317b3e..0bd1ad0 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -5893,11 +5893,14 @@ index b998456..2783a4a 100644 + __linux_sched_yield_site("SWTCH"); return 0; } - -@@ -696,6 +1613,48 @@ kern_return_t syscall_thread_switch_impl( - .tv_sec = 0, +@@ -693,11 +1610,53 @@ kern_return_t syscall_thread_switch_impl( + mach_msg_timeout_t option_time) + { + struct timespec tv = { +- .tv_sec = 0, - .tv_nsec = 1000000 -+ .tv_nsec = option_time > 0 ? (long)option_time * 1000 : 1000000 ++ .tv_sec = option_time > 0 ? (time_t)(option_time / 1000000) : 0, ++ .tv_nsec = option_time > 0 ? (long)((option_time % 1000000) * 1000) : 1000000 }; + + /* darling-lol: opt-in trace (host file /tmp/rclol-select-trace) of who calls thread_switch and with which option: diff --git a/scripts/installer.sh b/scripts/installer.sh index d9d1268..0d19df5 100755 --- a/scripts/installer.sh +++ b/scripts/installer.sh @@ -14,7 +14,6 @@ export RIOT_LAUNCHER_HOST RCLOL_REGION # client fails under Darling, so start the installed client here. sh scripts/run.sh || true unset RIOT_LAUNCHER_HOST -python3 build/runtime/tools/riot-desktop-entry.py || true prefix=${DPREFIX:-$HOME/.darling} if [ ! -d "$prefix/Users/Shared/Riot Games/Riot Client.app/Contents/MacOS" ]; then echo "The installer exited without installing the Riot Client. Its logs are in" >&2 diff --git a/tools/ensure-darling-runtime.py b/tools/ensure-darling-runtime.py index a34ff7f..d475114 100644 --- a/tools/ensure-darling-runtime.py +++ b/tools/ensure-darling-runtime.py @@ -1,47 +1,54 @@ #!/usr/bin/env python3 -"""Clear only a dead Darling server's stale PID marker before startup.""" +"""Clear only a dead Darling server's stale PID marker and buffers before startup.""" import os from pathlib import Path import time + prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))) marker = prefix / '.init.pid' -try: - pid = int(marker.read_text().strip()) -except (OSError, ValueError): - raise SystemExit(0) -if pid <= 0: - raise SystemExit(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) -# Clean up stale Riot temporary files and shared memory frames from previous crashes -try: - tmp_dir = prefix / 'private/tmp' - if tmp_dir.is_dir(): - for f in tmp_dir.glob('.com.riotgames.*'): +server_running = False +if marker.exists(): + try: + pid = int(marker.read_text().strip()) + if pid > 0: + status = Path('/proc') / str(pid) / 'status' try: - f.unlink(missing_ok=True) - except OSError: - pass -except OSError: - pass + 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 -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 + 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 + +# Only clean up stale Riot temporary files and shared memory frames if the server is NOT running +if not server_running: + 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 + + 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 diff --git a/tools/m58f299-xshm.c b/tools/m58f299-xshm.c index c275b47..2e1324f 100644 --- a/tools/m58f299-xshm.c +++ b/tools/m58f299-xshm.c @@ -135,10 +135,8 @@ static int drawFrame(Frame *f, Drawable target, GC gc, const void *pixels, (const char *)pixels + y * stride, f->width * 4); int result = XShmPutImage(f->display, target, f->privateGC, f->image, 0, 0, 0, 0, f->width, f->height, False); - /* darling-lol perf: XFlush is non-blocking - sends the request without waiting - * for the server round-trip. The SHM segment is reused on the next tick anyway, - * and the server processes requests in order. Saves 0.2-1.0ms per frame. */ - XFlush(f->display); + /* Wait for server consumption before reusing the segment on the next tick. */ + XSync(f->display, False); pthread_mutex_unlock(&f->render); return result; } @@ -152,7 +150,7 @@ DarlingXShmPut(void *handle, Drawable target, GC gc, const void *pixels, pthread_mutex_lock(&f->render); int result = XShmPutImage(f->display, target, f->privateGC, f->image, 0, 0, 0, 0, f->width, f->height, False); - XFlush(f->display); + XSync(f->display, False); pthread_mutex_unlock(&f->render); return result; } diff --git a/tools/riot-desktop-entry.py b/tools/riot-desktop-entry.py index c4c1dd8..6782c31 100755 --- a/tools/riot-desktop-entry.py +++ b/tools/riot-desktop-entry.py @@ -147,7 +147,7 @@ Type=Application Name=League of Legends GenericName=MOBA Game Comment=Play League of Legends on Linux with darling-lol -Exec={run_script} +Exec="{run_script}" Path={PROJECT_ROOT} Icon=league-of-legends Terminal=false From 9576070e1793ecf4a1ff7cdd73f09c5e5853c187 Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 17:52:11 +0200 Subject: [PATCH 040/160] build: apply optimization flags to existing build trees and escape Exec path --- scripts/build.sh | 4 ++-- tools/riot-desktop-entry.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/build.sh b/scripts/build.sh index 6e19ae1..4a5a35a 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -14,7 +14,7 @@ python3 scripts/prepare.py # is required by objc4's debug-ness macro check. 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_INIT="-O3 -march=native -mtune=native -pipe" \ - -DCMAKE_CXX_FLAGS_INIT="-O3 -march=native -mtune=native -pipe" + -DCMAKE_C_FLAGS="-O3 -march=native -mtune=native -pipe" \ + -DCMAKE_CXX_FLAGS="-O3 -march=native -mtune=native -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/tools/riot-desktop-entry.py b/tools/riot-desktop-entry.py index 6782c31..51d6a74 100755 --- a/tools/riot-desktop-entry.py +++ b/tools/riot-desktop-entry.py @@ -141,13 +141,14 @@ def main(): # 3. Create .desktop file run_script = PROJECT_ROOT / "scripts/run.sh" + exec_path = str(run_script).replace("%", "%%") desktop_content = f"""[Desktop Entry] Version=1.0 Type=Application Name=League of Legends GenericName=MOBA Game Comment=Play League of Legends on Linux with darling-lol -Exec="{run_script}" +Exec="{exec_path}" Path={PROJECT_ROOT} Icon=league-of-legends Terminal=false From aca9b1561ab230b724042d377627b08a0e1f6cd4 Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 19:47:12 +0200 Subject: [PATCH 041/160] cocotron: avoid dropping relative motion events and warp echoes during coalescing --- patches/cocotron.patch | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 0886f3c..dad1f5c 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2307,7 +2307,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..d7fb09fc 100644 +index 5ae29bf4..b98eff64 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3391,14 +3391,17 @@ index 5ae29bf4..d7fb09fc 100644 [self postEvent: event atStart: NO]; break; -@@ -1160,49 +1855,111 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1160,49 +1855,114 @@ 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. */ -+ XEvent nextEv; -+ while (XCheckTypedWindowEvent(_display, ev->xmotion.window, MotionNotify, &nextEv)) { -+ *ev = nextEv; ++ /* 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; @@ -3529,7 +3532,7 @@ index 5ae29bf4..d7fb09fc 100644 mouseEventWithType: type location: pos modifierFlags: -@@ -1211,28 +1968,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +1971,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -3590,7 +3593,7 @@ index 5ae29bf4..d7fb09fc 100644 NSLog(@"FocusIn"); if ([delegate attachedSheet]) { [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; -@@ -1243,28 +2030,61 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +2033,61 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -3654,7 +3657,7 @@ index 5ae29bf4..d7fb09fc 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +2121,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +2124,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -3682,7 +3685,7 @@ index 5ae29bf4..d7fb09fc 100644 break; case MapRequest: -@@ -1317,10 +2149,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1317,10 +2152,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ConfigureNotify: @@ -3698,7 +3701,7 @@ index 5ae29bf4..d7fb09fc 100644 break; case ConfigureRequest: -@@ -1344,6 +2181,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2184,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -3707,7 +3710,7 @@ index 5ae29bf4..d7fb09fc 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1399,7 +2238,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1399,7 +2241,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; default: @@ -3718,7 +3721,7 @@ index 5ae29bf4..d7fb09fc 100644 [self _invalidateRRCache]; break; } -@@ -1433,6 +2274,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1433,6 +2277,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); @@ -3729,7 +3732,7 @@ index 5ae29bf4..d7fb09fc 100644 return 0; } -@@ -1476,54 +2321,286 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2324,286 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } From 5f494fd450db5ce8890512b39cd5716f0372a52c Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 20:21:58 +0200 Subject: [PATCH 042/160] fix(launcher,display): fix desktop launcher icon, Alt-Tab grab, and 144Hz camera panning --- patches/cocotron.patch | 49 +++++++++++++++++++------------ patches/darling.patch | 12 ++++++-- patches/xnu.patch | 2 +- tools/ensure-darling-runtime.py | 6 ++++ tools/install-riot-gpu-runtime.sh | 4 ++- tools/riot-desktop-entry.py | 3 +- tools/run-riot-client.sh | 6 ++++ 7 files changed, 58 insertions(+), 24 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index dad1f5c..7199576 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2307,7 +2307,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..b98eff64 100644 +index 5ae29bf4..dc3fa95e 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3076,10 +3076,10 @@ index 5ae29bf4..b98eff64 100644 + result = nil; + NSTimeInterval remaining; + while (result == nil && (remaining = [untilDate timeIntervalSinceNow]) > 0) { -+ /* Display-link and input sources have no CF timer deadline. A 20ms -+ * fallback can sleep past every 60Hz source wakeup; bound its latency -+ * below a refresh interval while the socket wakeup path is polled. */ -+ NSTimeInterval slice = remaining < 0.004 ? remaining : 0.004; ++ /* Display-link and input sources have no CF timer deadline. Bound the ++ * idle-poll latency below half a 144Hz frame (~3.5ms) so input events ++ * are dispatched promptly at high refresh rates. */ ++ NSTimeInterval slice = remaining < 0.002 ? remaining : 0.002; + /* A quiet X connection must not prevent main-queue callbacks or + * CF timers from running while AppKit waits for its next event. */ + CFStringRef cfMode = (mode == nil || [mode isEqual:NSDefaultRunLoopMode]) @@ -3593,7 +3593,7 @@ index 5ae29bf4..b98eff64 100644 NSLog(@"FocusIn"); if ([delegate attachedSheet]) { [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; -@@ -1243,28 +2033,61 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +2033,74 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -3621,8 +3621,21 @@ index 5ae29bf4..b98eff64 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 */ ++ if (ev->xfocus.detail == NotifyPointer || ev->xfocus.detail == NotifyInferior) { ++ /* Pointer boundary crossing within the same top-level: ignore completely. */ ++ break; ++ } ++ if (ev->xfocus.mode == NotifyGrab || ev->xfocus.mode == NotifyUngrab) { ++ /* A window manager grab (e.g. Alt-Tab switcher): release the pointer ++ * so the cursor can move to other windows, but don't deactivate the ++ * app — FocusIn will re-establish confinement if focus returns. */ ++ [self suspendRelativeMouse]; ++ if (_confinedWindow) { ++ XUngrabPointer(_display, CurrentTime); ++ _confinedWindow = None; ++ _confinedWidth = 0; ++ _confinedHeight = 0; ++ } + break; + } NSLog(@"FocusOut"); @@ -3657,7 +3670,7 @@ index 5ae29bf4..b98eff64 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +2124,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +2137,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -3685,7 +3698,7 @@ index 5ae29bf4..b98eff64 100644 break; case MapRequest: -@@ -1317,10 +2152,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1317,10 +2165,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ConfigureNotify: @@ -3701,7 +3714,7 @@ index 5ae29bf4..b98eff64 100644 break; case ConfigureRequest: -@@ -1344,6 +2184,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2197,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -3710,7 +3723,7 @@ index 5ae29bf4..b98eff64 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1399,7 +2241,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1399,7 +2254,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; default: @@ -3721,7 +3734,7 @@ index 5ae29bf4..b98eff64 100644 [self _invalidateRRCache]; break; } -@@ -1433,6 +2277,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1433,6 +2290,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); @@ -3732,7 +3745,7 @@ index 5ae29bf4..b98eff64 100644 return 0; } -@@ -1476,54 +2324,286 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2337,286 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } @@ -3778,8 +3791,8 @@ index 5ae29bf4..b98eff64 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 { @@ -3929,8 +3942,8 @@ index 5ae29bf4..b98eff64 100644 + _confinedWidth = attrs.width; + _confinedHeight = attrs.height; + } - } - ++} ++ +/* 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 { diff --git a/patches/darling.patch b/patches/darling.patch index 312f184..4994591 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,878 @@ +@@ -1,38 +1,884 @@ #include +#include +#include @@ -4377,8 +4377,14 @@ index 3e9cba226..99e0a5494 100644 +static void *displayClockThread(void *) { + struct { long sec, nsec; } deadline; + if (__lolsys_syscall(1,(long)&deadline,0,0,0,0,228)<0) return NULL; ++ static uint64_t cvIntervalNS = 0; ++ 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); ++ } + for (;;) { -+ deadline.nsec += NSEC_PER_SEC / 60; ++ deadline.nsec += cvIntervalNS; + if(deadline.nsec>=NSEC_PER_SEC){deadline.nsec-=NSEC_PER_SEC;++deadline.sec;} + long result; + do { result=__lolsys_syscall(1,1,(long)&deadline,0,0,0,230); } while(result==-4); @@ -4386,7 +4392,7 @@ index 3e9cba226..99e0a5494 100644 + dispatchTimerFire(NULL); + struct { long sec, nsec; } now; + if(__lolsys_syscall(1,(long)&now,0,0,0,0,228)==0 && -+ (now.sec>deadline.sec || (now.sec==deadline.sec && now.nsec-deadline.nsec>NSEC_PER_SEC/60))) { ++ (now.sec>deadline.sec || (now.sec==deadline.sec && (uint64_t)(now.nsec-deadline.nsec)>cvIntervalNS))) { + deadline.sec=now.sec;deadline.nsec=now.nsec; + } + } diff --git a/patches/xnu.patch b/patches/xnu.patch index 0bd1ad0..6f1b399 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -5944,7 +5944,7 @@ index b998456..2783a4a 100644 + * 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(); ++ __linux_sched_yield_site("THRSWT"); + } else { + __linux_nanosleep(&tv, &tv); + } diff --git a/tools/ensure-darling-runtime.py b/tools/ensure-darling-runtime.py index d475114..981c0a9 100644 --- a/tools/ensure-darling-runtime.py +++ b/tools/ensure-darling-runtime.py @@ -2,6 +2,7 @@ """Clear only a dead Darling server's stale PID marker and buffers before startup.""" import os from pathlib import Path +import subprocess import time prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))) @@ -31,6 +32,11 @@ if marker.exists(): # 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 + try: tmp_dir = prefix / 'private/tmp' if tmp_dir.is_dir(): diff --git a/tools/install-riot-gpu-runtime.sh b/tools/install-riot-gpu-runtime.sh index 4914e40..68f887f 100755 --- a/tools/install-riot-gpu-runtime.sh +++ b/tools/install-riot-gpu-runtime.sh @@ -32,7 +32,9 @@ build/riot-host-message-check cc -shared -fPIC -O2 -Wall -Wextra -Wl,-soname,"$project_dir/build/libdarling-xshm.so" tools/m58f299-xshm.c -o build/libdarling-xshm.so.next -pthread $(pkg-config --cflags --libs x11 x11-xcb xcb-shm xext) python3 tools/stop-darling-runtime.py # Host fileports own SCM_RIGHTS descriptors and must die with their last sender. -sudo make -C "$project_dir/build/darling-src/build/src/external/darlingserver" install -j12 +if sudo -n true 2>/dev/null; then + sudo make -C "$project_dir/build/darling-src/build/src/external/darlingserver" install -j12 +fi # Electron's window-visibility correction lives in the AppKit overlay # (NSWindow -occlusionState, cocotron.patch); no Riot binary is modified here. for name in IOSurface OpenGL QuartzCore CoreVideo CoreAudio AudioToolbox CoreGraphics CoreText CoreFoundation Metal; do diff --git a/tools/riot-desktop-entry.py b/tools/riot-desktop-entry.py index 51d6a74..477b575 100755 --- a/tools/riot-desktop-entry.py +++ b/tools/riot-desktop-entry.py @@ -142,13 +142,14 @@ def main(): # 3. Create .desktop file run_script = PROJECT_ROOT / "scripts/run.sh" exec_path = str(run_script).replace("%", "%%") + exec_cmd = f'"{exec_path}"' if " " in exec_path else exec_path desktop_content = f"""[Desktop Entry] Version=1.0 Type=Application Name=League of Legends GenericName=MOBA Game Comment=Play League of Legends on Linux with darling-lol -Exec="{exec_path}" +Exec={exec_cmd} Path={PROJECT_ROOT} Icon=league-of-legends Terminal=false diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 8781842..bf0474a 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -61,6 +61,12 @@ 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}" +# Vulkan WSI & Mesa low-latency overrides +export MESA_VK_WSI_PRESENT_MODE="${MESA_VK_WSI_PRESENT_MODE:-immediate}" +export vblank_mode="${vblank_mode:-0}" +export MESA_NO_ERROR="${MESA_NO_ERROR:-1}" # NVIDIA proprietary driver: limit pre-rendered frames. export __GL_MaxFramesAllowed="${__GL_MaxFramesAllowed:-1}" export __GL_THREADED_OPTIMIZATIONS="${__GL_THREADED_OPTIMIZATIONS:-1}" From e9e9da1187a97ae9fb68f675a3fcaf71319602bc Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 20:38:32 +0200 Subject: [PATCH 043/160] fix(input,video): restore click accuracy, prevent Alt-Tab focus steal, and eliminate 144Hz display link stutter --- patches/cocotron.patch | 74 ++++++++++++++++------------------------ patches/darling.patch | 9 +++-- tools/run-riot-client.sh | 4 +-- 3 files changed, 37 insertions(+), 50 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 7199576..fdcdba4 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2307,7 +2307,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..dc3fa95e 100644 +index 5ae29bf4..580386b4 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3593,7 +3593,7 @@ index 5ae29bf4..dc3fa95e 100644 NSLog(@"FocusIn"); if ([delegate attachedSheet]) { [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; -@@ -1243,28 +2033,74 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +2033,61 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -3621,21 +3621,8 @@ index 5ae29bf4..dc3fa95e 100644 + rclolFocusDetailName(ev->xfocus.detail), _relativeRequested, _relativeMouse.active, + (unsigned long) _confinedWindow, + [NSApp isActive]); -+ if (ev->xfocus.detail == NotifyPointer || ev->xfocus.detail == NotifyInferior) { -+ /* Pointer boundary crossing within the same top-level: ignore completely. */ -+ break; -+ } -+ if (ev->xfocus.mode == NotifyGrab || ev->xfocus.mode == NotifyUngrab) { -+ /* A window manager grab (e.g. Alt-Tab switcher): release the pointer -+ * so the cursor can move to other windows, but don't deactivate the -+ * app — FocusIn will re-establish confinement if focus returns. */ -+ [self suspendRelativeMouse]; -+ if (_confinedWindow) { -+ XUngrabPointer(_display, CurrentTime); -+ _confinedWindow = None; -+ _confinedWidth = 0; -+ _confinedHeight = 0; -+ } ++ 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"); @@ -3670,7 +3657,7 @@ index 5ae29bf4..dc3fa95e 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +2137,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +2124,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -3698,7 +3685,7 @@ index 5ae29bf4..dc3fa95e 100644 break; case MapRequest: -@@ -1317,10 +2165,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1317,10 +2152,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ConfigureNotify: @@ -3714,7 +3701,7 @@ index 5ae29bf4..dc3fa95e 100644 break; case ConfigureRequest: -@@ -1344,6 +2197,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2184,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -3723,7 +3710,7 @@ index 5ae29bf4..dc3fa95e 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1399,7 +2254,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1399,7 +2241,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; default: @@ -3734,7 +3721,7 @@ index 5ae29bf4..dc3fa95e 100644 [self _invalidateRRCache]; break; } -@@ -1433,6 +2290,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1433,6 +2277,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); @@ -3745,7 +3732,7 @@ index 5ae29bf4..dc3fa95e 100644 return 0; } -@@ -1476,54 +2337,286 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2324,285 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } @@ -3791,8 +3778,8 @@ index 5ae29bf4..dc3fa95e 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 { @@ -3875,24 +3862,23 @@ index 5ae29bf4..dc3fa95e 100644 + 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); + } -+ -+ /* 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. */ @@ -3942,8 +3928,8 @@ index 5ae29bf4..dc3fa95e 100644 + _confinedWidth = attrs.width; + _confinedHeight = attrs.height; + } -+} -+ + } + +/* 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 { diff --git a/patches/darling.patch b/patches/darling.patch index 4994591..bd28532 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -4391,9 +4391,12 @@ index 3e9cba226..99e0a5494 100644 + if(result<0) return NULL; + dispatchTimerFire(NULL); + struct { long sec, nsec; } now; -+ if(__lolsys_syscall(1,(long)&now,0,0,0,0,228)==0 && -+ (now.sec>deadline.sec || (now.sec==deadline.sec && (uint64_t)(now.nsec-deadline.nsec)>cvIntervalNS))) { -+ deadline.sec=now.sec;deadline.nsec=now.nsec; ++ if(__lolsys_syscall(1,(long)&now,0,0,0,0,228)==0) { ++ long long diffNS = ((long long)now.sec - (long long)deadline.sec) * 1000000000LL + (now.nsec - deadline.nsec); ++ if (diffNS > (long long)cvIntervalNS) { ++ deadline.sec = now.sec; ++ deadline.nsec = now.nsec; ++ } + } + } +} diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index bf0474a..fd29d04 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -63,9 +63,7 @@ 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}" -# Vulkan WSI & Mesa low-latency overrides -export MESA_VK_WSI_PRESENT_MODE="${MESA_VK_WSI_PRESENT_MODE:-immediate}" -export vblank_mode="${vblank_mode:-0}" +# Vulkan WSI & Mesa optimizations export MESA_NO_ERROR="${MESA_NO_ERROR:-1}" # NVIDIA proprietary driver: limit pre-rendered frames. export __GL_MaxFramesAllowed="${__GL_MaxFramesAllowed:-1}" From 204dd6c5ad9aca220abb8a5383f11bb294c9db4c Mon Sep 17 00:00:00 2001 From: Andras Laszlo Date: Fri, 16 Oct 2026 20:58:43 +0200 Subject: [PATCH 044/160] fix(input): eliminate motion warp feedback loop, fix fullscreen promotion, and restore camera edge panning --- patches/cocotron.patch | 119 +++++++++++++++++++++++++---------------- 1 file changed, 74 insertions(+), 45 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index fdcdba4..28e958b 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2307,7 +2307,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..580386b4 100644 +index 5ae29bf4..25056329 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3391,7 +3391,7 @@ index 5ae29bf4..580386b4 100644 [self postEvent: event atStart: NO]; break; -@@ -1160,49 +1855,114 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1160,49 +1855,106 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { // ev->xmotion.x, ev->xmotion.y, ev->xmotion.x_root, // ev->xmotion.y_root); @@ -3409,18 +3409,10 @@ index 5ae29bf4..580386b4 100644 + rclolPointerStamp = mach_absolute_time(); + + if (_confinedWindow == [window windowHandle] && _confinedWidth > 0 && _confinedHeight > 0) { -+ int clampedX = ev->xmotion.x; -+ int clampedY = ev->xmotion.y; -+ BOOL needWarp = NO; -+ if (clampedX < 0) { clampedX = 0; needWarp = YES; } -+ else if (clampedX >= _confinedWidth) { clampedX = _confinedWidth - 1; needWarp = YES; } -+ if (clampedY < 0) { clampedY = 0; needWarp = YES; } -+ else if (clampedY >= _confinedHeight) { clampedY = _confinedHeight - 1; needWarp = YES; } -+ if (needWarp) { -+ XWarpPointer(_display, None, _confinedWindow, 0, 0, 0, 0, clampedX, clampedY); -+ ev->xmotion.x = clampedX; -+ ev->xmotion.y = clampedY; -+ } ++ if (ev->xmotion.x < 0) ev->xmotion.x = 0; ++ else if (ev->xmotion.x > _confinedWidth) ev->xmotion.x = _confinedWidth; ++ if (ev->xmotion.y < 0) ev->xmotion.y = 0; ++ else if (ev->xmotion.y > _confinedHeight) ev->xmotion.y = _confinedHeight; + } + CGPoint lastMotionPos = [window mouseLocationOutsideOfEventStream]; @@ -3532,7 +3524,7 @@ index 5ae29bf4..580386b4 100644 mouseEventWithType: type location: pos modifierFlags: -@@ -1211,28 +1971,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +1963,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -3593,7 +3585,7 @@ index 5ae29bf4..580386b4 100644 NSLog(@"FocusIn"); if ([delegate attachedSheet]) { [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; -@@ -1243,28 +2033,61 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +2025,61 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -3657,7 +3649,7 @@ index 5ae29bf4..580386b4 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +2124,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +2116,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -3685,7 +3677,7 @@ index 5ae29bf4..580386b4 100644 break; case MapRequest: -@@ -1317,10 +2152,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1317,10 +2144,15 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ConfigureNotify: @@ -3701,7 +3693,7 @@ index 5ae29bf4..580386b4 100644 break; case ConfigureRequest: -@@ -1344,6 +2184,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2176,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -3710,7 +3702,7 @@ index 5ae29bf4..580386b4 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1399,7 +2241,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1399,7 +2233,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; default: @@ -3721,7 +3713,7 @@ index 5ae29bf4..580386b4 100644 [self _invalidateRRCache]; break; } -@@ -1433,6 +2277,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1433,6 +2269,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); @@ -3732,7 +3724,7 @@ index 5ae29bf4..580386b4 100644 return 0; } -@@ -1476,54 +2324,285 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2316,285 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } @@ -4454,7 +4446,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..af6df163 100644 +index f4925b66..c288eb38 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -38,6 +38,34 @@ @@ -4581,7 +4573,7 @@ index f4925b66..af6df163 100644 return self; } -@@ -287,10 +363,19 @@ static NSData *makeWindowIcon() { +@@ -287,16 +363,33 @@ static NSData *makeWindowIcon() { // Make resizable sh->flags = 0; } else { @@ -4605,23 +4597,59 @@ index f4925b66..af6df163 100644 } XSetWMSizeHints(_display, _window, sh, XA_WM_NORMAL_HINTS); -@@ -304,7 +389,14 @@ static NSData *makeWindowIcon() { + 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 wasFullscreen = (_styleMask & NSWindowStyleMaskFullScreen) != 0 || ++ (isGame && (_styleMask == NSBorderlessWindowMask || !(_styleMask & NSTitledWindowMask))); ++ BOOL makeFullscreen = (mask & NSWindowStyleMaskFullScreen) != 0 || ++ (isGame && (mask == NSBorderlessWindowMask || !(mask & NSTitledWindowMask))); ++ + if (!_mapped) { + long states[2]; + int states_cnt = 0; +@@ -304,7 +397,7 @@ static NSData *makeWindowIcon() { states[states_cnt++] = (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); } - if (mask & NSWindowStyleMaskFullScreen) { -+ NSString *procName = [[NSProcessInfo processInfo] processName]; -+ BOOL isGame = (procName && [procName rangeOfString:@"League" options:NSCaseInsensitiveSearch].location != NSNotFound && -+ [procName rangeOfString:@"Ux" options:NSCaseInsensitiveSearch].location == NSNotFound); -+ BOOL makeFullscreen = (mask & NSWindowStyleMaskFullScreen) != 0; -+ if (!makeFullscreen && isGame && (mask == NSBorderlessWindowMask || !(mask & NSTitledWindowMask))) { -+ makeFullscreen = YES; -+ } + if (makeFullscreen) { states[states_cnt++] = (long) XInternAtom( _display, "_NET_WM_STATE_FULLSCREEN", False); } -@@ -338,7 +430,6 @@ static NSData *makeWindowIcon() { +@@ -312,20 +405,25 @@ static NSData *makeWindowIcon() { + XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, + 32, PropModeReplace, (const unsigned char *) states, + states_cnt); +- } else if (force || ((mask & NSWindowStyleMaskFullScreen) != +- (_styleMask & NSWindowStyleMaskFullScreen))) { ++ } else if (force || (makeFullscreen != wasFullscreen)) { + XClientMessageEvent event = {0}; + event.type = ClientMessage; + event.window = _window; + event.message_type = XInternAtom(_display, "_NET_WM_STATE", False); + event.format = 32; +- event.data.l[0] = (mask & NSWindowStyleMaskFullScreen) ? 1 : 2; ++ event.data.l[0] = makeFullscreen ? 1 : 2; + event.data.l[1] = + (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); + event.data.l[3] = 1; + XSendEvent(_display, DefaultRootWindow(_display), False, + SubstructureNotifyMask | SubstructureRedirectMask, + (XEvent *) &event); ++ if (makeFullscreen) { ++ unsigned long bypass = 2; ++ XChangeProperty(_display, _window, XInternAtom(_display, "_NET_WM_BYPASS_COMPOSITOR", False), ++ XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&bypass, 1); ++ } ++ XFlush(_display); + } + } + +@@ -338,7 +436,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -4629,7 +4657,7 @@ index f4925b66..af6df163 100644 struct { unsigned long flags; unsigned long functions; -@@ -358,6 +449,7 @@ static NSData *makeWindowIcon() { +@@ -358,6 +455,7 @@ static NSData *makeWindowIcon() { if (!_mapped) { XMapWindow(_display, _window); _mapped = YES; @@ -4637,7 +4665,7 @@ index f4925b66..af6df163 100644 } } -@@ -377,15 +469,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +475,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -4670,7 +4698,7 @@ index f4925b66..af6df163 100644 // TODO: background color } -@@ -393,6 +497,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +503,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. @@ -4699,7 +4727,7 @@ index f4925b66..af6df163 100644 [_context release]; _context = nil; -@@ -421,6 +547,388 @@ static NSData *makeWindowIcon() { +@@ -421,6 +553,388 @@ static NSData *makeWindowIcon() { } } @@ -5088,7 +5116,7 @@ index f4925b66..af6df163 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +978,31 @@ static NSData *makeWindowIcon() { +@@ -470,14 +984,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -5118,10 +5146,11 @@ index f4925b66..af6df163 100644 + XSetWMSizeHints(_display, _window, &hints, XA_WM_NORMAL_HINTS); + } + } ++ [(X11Display *)[NSDisplay currentDisplay] updateMouseConfinementForWindow:self]; } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1013,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1020,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -5147,7 +5176,7 @@ index f4925b66..af6df163 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1087,18 @@ static NSData *makeWindowIcon() { +@@ -543,6 +1094,18 @@ static NSData *makeWindowIcon() { - (void) makeKey { [self ensureMapped]; XRaiseWindow(_display, _window); @@ -5166,7 +5195,7 @@ index f4925b66..af6df163 100644 } - (void) makeMain { -@@ -559,7 +1115,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1122,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -5196,7 +5225,7 @@ index f4925b66..af6df163 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -615,6 +1193,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1200,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -5209,7 +5238,7 @@ index f4925b66..af6df163 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1236,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1243,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -5218,7 +5247,7 @@ index f4925b66..af6df163 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1250,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1257,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -5226,7 +5255,7 @@ index f4925b66..af6df163 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,14 +1295,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,14 +1302,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } From adf74bf72604b9be499e7809815b49cad7129867 Mon Sep 17 00:00:00 2001 From: DevWebeloper Date: Sat, 17 Oct 2026 00:49:53 +0300 Subject: [PATCH 045/160] Keep the relative-mode cursor on the visible pointer when warps are not honoured League enters relative mouse mode on GNOME (Xwayland) and inside gamescope, but keeps a visible cursor and warps it every frame at its own mouse speed. Xwayland cannot move a visible pointer, so those warps never took effect: the virtual cursor, where clicks land, drifted hundreds of pixels from the cursor on screen and never reached the screen edges, which also stopped edge panning. Where warps are not honoured, the virtual cursor now follows the physical pointer and the game's warps leave it in place. With a hidden cursor, or on a server that honours warps, nothing changes. The host model test gains a case replaying the drift from an in-game trace. Tested manually in the practice tool under gamescope 3.16.31 on GNOME 51 with AMD RADV: clicks land on the cursor and edge panning works. Co-Authored-By: Claude Opus 5.5 --- docs/TROUBLESHOOTING.md | 4 ++- patches/cocotron.patch | 37 ++++++++++++++++++--------- tools/test-x11-relative-mouse.c | 44 +++++++++++++++++++++++++++------ 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 75b1c4d..9d28a99 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -232,7 +232,9 @@ The X11 backend previously called `XSync` after every cursor-shape update, waiti A later input trace showed League setting the cursor about 190 times and unhiding it about 100 times per second, each time sending the same cursor to the X server. The backend now skips the update when the window already has that cursor. The effect on latency has not been measured. -The backend's relative mouse mode (`CGAssociateMouseAndMouseCursorPosition(false)`) previously warped the pointer to the window center, treated the game's own cursor warps as motion and reported `deltaY` upside down. It now follows macOS: the cursor stays where it is, events carry the physical motion as deltas (positive `deltaY` downwards, also for ordinary motion), the game's warps move the cursor without a delta, and `+[NSEvent mouseLocation]` returns that cursor. On Xwayland a warp takes effect only while the cursor is hidden, so with a visible cursor the pointer is not held in place. `tools/test-x11-relative-mouse.c` checks this logic on the host (`cc tools/test-x11-relative-mouse.c && ./a.out` after `scripts/prepare.py`). League on the tested Hyprland setup never enters this mode, so it has not been confirmed in game. +The backend's relative mouse mode (`CGAssociateMouseAndMouseCursorPosition(false)`) previously warped the pointer to the window center, treated the game's own cursor warps as motion and reported `deltaY` upside down. It now follows macOS: the cursor stays where it is, events carry the physical motion as deltas (positive `deltaY` downwards, also for ordinary motion), the game's warps move the cursor without a delta, and `+[NSEvent mouseLocation]` returns that cursor. On Xwayland a warp takes effect only while the cursor is hidden, so with a visible cursor the pointer is not held in place. `tools/test-x11-relative-mouse.c` checks this logic on the host (`cc tools/test-x11-relative-mouse.c && ./a.out` after `scripts/prepare.py`). League on the tested Hyprland setup never enters this mode. + +On GNOME (Xwayland) and inside gamescope, League does enter it, but keeps a visible cursor and warps it each frame at its own mouse speed (about half the physical motion in a local trace). Those warps cannot move a visible Xwayland pointer, so the virtual cursor, where clicks land, drifted hundreds of pixels from the cursor on screen and never reached the screen edges, which stopped edge panning. Where warps are not honoured, the virtual cursor now follows the physical pointer and the game's warps leave it in place; with a hidden cursor nothing changes. A manual practice-tool test on 2026-10-17 (GNOME 51, gamescope 3.16.31, AMD RADV) confirmed clicks on the cursor and edge panning in gamescope. The plain GNOME session, an NVIDIA driver and a real match were not retested. For a manual comparison, keep the scene, FPS cap, display refresh and compositor settings the same. Record whether the delay affects the cursor itself, camera movement, or actions after clicking, and compare the in-game VSync setting on and off. Include the desktop/compositor, X11 or Xwayland session, GPU/driver, monitor refresh and fullscreen/windowed mode in the report. Do not automate game input. The fullscreen cursor-shape problem below is a separate known symptom. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 65733bd..19864f8 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2170,7 +2170,7 @@ index 18189404..5b3a34bc 100644 +- (NSRect) frameForRootRect: (NSRect) rect; @end diff --git a/AppKit/X11.backend/X11Display.m b/AppKit/X11.backend/X11Display.m -index 5ae29bf4..86a647ff 100644 +index 5ae29bf4..af9c4e44 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3499,7 +3499,7 @@ index 5ae29bf4..86a647ff 100644 return 0; } -@@ -1476,54 +2245,218 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2245,220 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } @@ -3536,8 +3536,10 @@ index 5ae29bf4..86a647ff 100644 + int x = X11RelativeMouseWarpCoordinate(position.x), y = X11RelativeMouseWarpCoordinate(position.y); + if (_relativeMouse.active) { + /* CGWarpMouseCursorPosition moves the disassociated cursor without producing a delta: the virtual -+ * cursor follows and the warp's echo is dropped. Not honoured on Xwayland with a visible cursor. */ -+ X11RelativeMouseWarp(&_relativeMouse, position.x, position.y, [self warpPointerToRootX: x y: y]); ++ * cursor follows and the warp's echo is dropped. Not honoured on Xwayland with a visible cursor, where ++ * the virtual cursor keeps tracking the pointer the user sees. */ ++ X11RelativeMouseWarp(&_relativeMouse, position.x, position.y, [self warpPointerToRootX: x y: y], ++ !_isXwayland || _cursorHidden); + return; + } NSLog(@"Warp to: x=%f, y=%f\n", position.x, position.y); @@ -9803,10 +9805,10 @@ index 00000000..a51a9241 +#endif diff --git a/AppKit/X11.backend/X11RelativeMouse.h b/AppKit/X11.backend/X11RelativeMouse.h new file mode 100644 -index 00000000..7fc39cc4 +index 00000000..d34c1bcc --- /dev/null +++ b/AppKit/X11.backend/X11RelativeMouse.h -@@ -0,0 +1,147 @@ +@@ -0,0 +1,158 @@ +/* darling-lol: decision logic for macOS-style relative mouse mode in the X11 backend. + * + * While CGAssociateMouseAndMouseCursorPosition(false) is in effect, macOS freezes the cursor: physical motion @@ -9820,7 +9822,10 @@ index 00000000..7fc39cc4 + * + * On Xwayland a warp only reaches the compositor while the X cursor is hidden. With a visible cursor the echo + * still arrives, but the next real motion reports the unmoved physical pointer, so honoured=false keeps the last -+ * physical position across echoes and never warps back. ++ * physical position across echoes and never warps back. The cursor the user sees is then the physical pointer, ++ * so the virtual cursor follows it and application warps do not move it: League keeps a visible cursor while ++ * relative on GNOME and gamescope and warps it at its own mouse speed, which otherwise left the virtual cursor ++ * (where clicks land) hundreds of pixels from the visible one and kept it off the screen edges. + * + * Plain C without X11 or Objective-C dependencies, so tools/test-x11-relative-mouse.c can run it on the host. */ +#ifndef X11_RELATIVE_MOUSE_H @@ -9889,11 +9894,15 @@ index 00000000..7fc39cc4 + mouse->echoes[mouse->echoCount++] = (X11RelativeMouseEcho) {x, y, queuedEvents}; +} + -+/* CGWarpMouseCursorPosition while relative: the virtual cursor moves to the target; the physical position is -+ * only updated when the echo arrives (and only if the warp was honoured). */ -+static inline void X11RelativeMouseWarp(X11RelativeMouse *mouse, double x, double y, unsigned queuedEvents) { -+ mouse->virtualX = x; -+ mouse->virtualY = y; ++/* CGWarpMouseCursorPosition while relative: where warps are honoured the virtual cursor moves to the target and ++ * the physical position is updated when the echo arrives. Otherwise the pointer stays put, and so does the ++ * virtual cursor that tracks it; the echo is still expected so it is not taken for motion. */ ++static inline void X11RelativeMouseWarp(X11RelativeMouse *mouse, double x, double y, unsigned queuedEvents, ++ bool warpsHonoured) { ++ if (warpsHonoured) { ++ mouse->virtualX = x; ++ mouse->virtualY = y; ++ } + X11RelativeMouseExpectEcho(mouse, X11RelativeMouseWarpCoordinate(x), X11RelativeMouseWarpCoordinate(y), + queuedEvents); +} @@ -9932,6 +9941,10 @@ index 00000000..7fc39cc4 + result.deltaY = rootY - mouse->physicalY; + mouse->physicalX = rootX; + mouse->physicalY = rootY; ++ if (!warpsHonoured) { ++ mouse->virtualX = rootX; ++ mouse->virtualY = rootY; ++ } + result.post = result.deltaX != 0.0 || result.deltaY != 0.0; + int warpX = X11RelativeMouseWarpCoordinate(mouse->virtualX); + int warpY = X11RelativeMouseWarpCoordinate(mouse->virtualY); diff --git a/tools/test-x11-relative-mouse.c b/tools/test-x11-relative-mouse.c index 489701c..6625b9c 100644 --- a/tools/test-x11-relative-mouse.c +++ b/tools/test-x11-relative-mouse.c @@ -44,7 +44,7 @@ static void xorgHonouredWarps(void) { r = motion(&m, 512, 400, true); assert(r.deltaX == 12 && r.rewarp); X11RelativeMouseExpectEcho(&m, r.warpX, r.warpY, 1); - X11RelativeMouseWarp(&m, 524.0, 400.0, 2); /* game: mouseLocation + 2 * delta */ + X11RelativeMouseWarp(&m, 524.0, 400.0, 2, true); /* game: mouseLocation + 2 * delta */ assert(m.virtualX == 524.0 && m.physicalX == 512); r = motion(&m, 500, 400, true); assert(r.echo && m.physicalX == 500); @@ -58,19 +58,22 @@ static void xorgHonouredWarps(void) { static void xwaylandVisibleCursor(void) { X11RelativeMouse m = {0}; X11RelativeMouseBegin(&m, 800, 450); - /* Warps are not honoured: no rewarp, the pointer moves freely, deltas follow it. */ + /* Warps are not honoured: no rewarp, the pointer moves freely, deltas follow it and so does the virtual + * cursor, since the visible cursor is the pointer. */ X11RelativeMouseMotion r = motion(&m, 810, 450, false); assert(r.post && r.deltaX == 10 && !r.rewarp); - X11RelativeMouseWarp(&m, 820.0, 450.0, 1); + assert(m.virtualX == 810.0 && m.virtualY == 450.0); + X11RelativeMouseWarp(&m, 820.0, 450.0, 1, false); + assert(m.virtualX == 810.0 && m.virtualY == 450.0); /* the pointer did not move, so neither does the cursor */ /* The echo at the target is dropped without moving the physical position... */ r = motion(&m, 820, 450, false); assert(r.echo && !r.post && m.physicalX == 810 && m.physicalY == 450); /* ...so the next real motion, at the real absolute position, gives the true delta. */ r = motion(&m, 812, 453, false); assert(r.post && r.deltaX == 2 && r.deltaY == 3 && !r.rewarp); - assert(m.virtualX == 820.0 && m.virtualY == 450.0); + assert(m.virtualX == 812.0 && m.virtualY == 453.0); /* An expected echo that never arrives expires after the queued events and cannot eat a later motion. */ - X11RelativeMouseWarp(&m, 700.0, 400.0, 1); + X11RelativeMouseWarp(&m, 700.0, 400.0, 1, false); X11RelativeMouseTick(&m); /* some other X event was the one queued */ assert(m.echoCount == 0); r = motion(&m, 700, 400, false); @@ -78,10 +81,34 @@ static void xwaylandVisibleCursor(void) { puts("PASS: Xwayland visible cursor keeps the physical position across unhonoured warp echoes"); } +/* League on GNOME and gamescope: relative with a visible cursor, warping its cursor at about half the physical + * motion each frame (from an in-game trace). Clicks land at the virtual cursor, so it must stay on the visible + * pointer and reach the screen edges, where League pans the camera. */ +static void leagueVisibleCursorWarps(void) { + X11RelativeMouse m = {0}; + X11RelativeMouseBegin(&m, 2888, 30); + int x = 2888, y = 30; + for (int step = 0; step < 40; ++step) { + x -= 20; + y += 16; + X11RelativeMouseMotion r = motion(&m, x, y, false); + assert(r.post && r.deltaX == -20 && r.deltaY == 16 && !r.rewarp); + double gameX = m.virtualX + r.deltaX / 2, gameY = m.virtualY + r.deltaY / 2; + X11RelativeMouseWarp(&m, gameX, gameY, 1, false); + r = motion(&m, (int) gameX, (int) gameY, false); /* the unhonoured warp's echo */ + assert(r.echo && !r.post); + assert(m.virtualX == x && m.virtualY == y); + } + /* The pointer stops at the right edge of a 3440-pixel screen; the cursor is there too. */ + X11RelativeMouseMotion r = motion(&m, 3439, 700, false); + assert(r.post && m.virtualX == 3439 && m.virtualY == 700); + puts("PASS: League's visible-cursor warps leave the virtual cursor on the pointer, up to the screen edge"); +} + static void gameWarpUpdatesVirtual(void) { X11RelativeMouse m = {0}; X11RelativeMouseBegin(&m, 100, 100); - X11RelativeMouseWarp(&m, 150.75, 120.5, 0); /* no event: the pointer was already there */ + X11RelativeMouseWarp(&m, 150.75, 120.5, 0, true); /* no event: the pointer was already there */ assert(m.virtualX == 150.75 && m.virtualY == 120.5 && m.echoCount == 0); assert(m.physicalX == 100 && m.physicalY == 100); /* mouseLocation reports the virtual cursor, Cocoa bottom-up global, for a 1080-pixel screen. */ @@ -107,7 +134,7 @@ static void deltaSign(void) { static void ungrabRestores(void) { X11RelativeMouse m = {0}; X11RelativeMouseBegin(&m, 10, 10); - X11RelativeMouseWarp(&m, 40, 40, 3); + X11RelativeMouseWarp(&m, 40, 40, 3, true); X11RelativeMouseEnd(&m); assert(!m.active && m.echoCount == 0); X11RelativeMouseBegin(&m, 60, 70); @@ -121,7 +148,7 @@ static void echoQueueBound(void) { X11RelativeMouse m = {0}; X11RelativeMouseBegin(&m, 0, 0); for (int i = 1; i <= X11_RELATIVE_MOUSE_MAX_ECHOES + 3; ++i) - X11RelativeMouseWarp(&m, i, 0, 100); + X11RelativeMouseWarp(&m, i, 0, 100, true); assert(m.echoCount == X11_RELATIVE_MOUSE_MAX_ECHOES && m.echoes[0].x == 4); X11RelativeMouseMotion r = motion(&m, 6, 0, true); assert(r.echo && m.echoCount == X11_RELATIVE_MOUSE_MAX_ECHOES - 3 && m.echoes[0].x == 7); @@ -131,6 +158,7 @@ static void echoQueueBound(void) { int main(void) { xorgHonouredWarps(); xwaylandVisibleCursor(); + leagueVisibleCursorWarps(); gameWarpUpdatesVirtual(); deltaSign(); ungrabRestores(); From 3db7115c9332df92d28b357898c17b5f6a3f4bb0 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:25:34 +0200 Subject: [PATCH 046/160] Fix runtime safety, Xorg mouse synchronization and fullscreen startup --- .github/workflows/source-check.yml | 2 + docs/SETUP.md | 4 +- docs/TROUBLESHOOTING.md | 24 +- patches/cocotron.patch | 702 +++++++++++--------- patches/darling.patch | 47 +- patches/metal--deps--indium.patch | 19 +- patches/xnu.patch | 45 +- scripts/build.sh | 7 +- scripts/install-runtime.sh | 10 +- scripts/installer.sh | 1 - scripts/validate.py | 6 +- tools/build-riot-appkit-runtime.py | 4 +- tools/build-riot-cxx-optimized.py | 2 +- tools/build-riot-cxx-runtime.py | 4 +- tools/build-riot-kqueue-runtime.py | 16 +- tools/build-riot-libc-runtime.py | 8 +- tools/build-riot-libs-runtime.sh | 2 +- tools/build-riot-platform-strings.py | 4 +- tools/build-riot-x11-runtime.py | 22 +- tools/check-darwin-tlv-calls.py | 62 ++ tools/ensure-darling-runtime.py | 136 ++-- tools/install-riot-gpu-runtime.sh | 2 + tools/install-riot-presentation-runtime.sh | 4 +- tools/riot-desktop-entry.py | 2 +- tools/riot-display-geometry-check.m | 2 +- tools/riot-fullscreen-geometry-check.m | 46 ++ tools/riot-input-offline-check.py | 57 ++ tools/riot-remote-frame-ownership-check.m | 50 ++ tools/riot-runtime-cleanup-offline-check.py | 61 ++ tools/run-riot-client.sh | 12 +- tools/test-x11-relative-mouse-xorg.c | 96 +++ tools/test-x11-relative-mouse.c | 114 +++- tools/trace-riot-windows.py | 98 +++ 33 files changed, 1200 insertions(+), 471 deletions(-) create mode 100644 tools/check-darwin-tlv-calls.py create mode 100644 tools/riot-fullscreen-geometry-check.m create mode 100644 tools/riot-input-offline-check.py create mode 100644 tools/riot-remote-frame-ownership-check.m create mode 100644 tools/riot-runtime-cleanup-offline-check.py create mode 100644 tools/test-x11-relative-mouse-xorg.c create mode 100644 tools/trace-riot-windows.py 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() From 2aac45d68dde0f719d700fece095a2d6a758e366 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:06:50 +0200 Subject: [PATCH 047/160] Keep Metal presentation active in mixed IOSurface layer trees --- docs/TESTING.md | 1 + patches/cocotron.patch | 31 ++++++---- tools/build-riot-metal-presentation.py | 16 ++--- tools/riot-ca-render-policy-check.mm | 82 ++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 17 deletions(-) create mode 100644 tools/riot-ca-render-policy-check.mm diff --git a/docs/TESTING.md b/docs/TESTING.md index fe6ed65..a1e6e80 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -90,6 +90,7 @@ Useful probes (build tokenized Python helpers from `build/runtime/tools/`): - `m327-cgl-resolver-check`: first calls through dynamically looked-up GL functions retain floating-point arguments; expected `first_sampler_linear=1 first_color_arguments=1 errors=0,0`. Build with `build-m58f327-opengl.py` and run with `run-riot-native-check.py m327-cgl-resolver-check`. - `m327-cgl-direct-check`: with `RCLOL_GPU_DIRECT_OTHER_WINDOW_TEST=1` and an owned 337x339 native target, interleave 338x339 fallback surfaces; expected `active_presenter_preserved=1 fallback_frames=357`. This needs the native presentation fixture; it is not a `run-riot-native-check.py` choice. - `riot-metal-presentation-check`: Vulkan clear reaches the GL texture, including late window/context attachment. Set `RIOT_PRESENTATION_NESTED=1` to place the Metal layer beneath two ordinary layers and verify that its drawables are still presented and recycled past the three-drawable pool limit. +- `riot-ca-render-policy-check`: headless check of the actual compositor render policy. A nested Metal layer must still be prepared when a full-window IOSurface is present; surface-only trees should retain the local-render shortcut. Built by `build-riot-metal-presentation.py`; expected `PASS: mixed-tree Metal preparation survives IOSurface shortcut`. Creates no window or GPU work and does not publish its test surface. - `riot-metal-concurrency-check`: submission ordering, resource lifetime and concurrent recording. `RIOT_PRESENTATION_STRESS=1` makes the presentation probe run 120 frames. With the game and clients closed, force async encoding for this standalone probe using the guest flag `/tmp/metal-async-encode`, then remove it. The tested old path leaked 120 synchronization FDs over 120 frames; the fixed path leaked zero. Inspect the probe's host `/proc/PID/fd` for `anon_inode:syncobj_file` if reproducing that test. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 899b568..84e76c6 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -8703,10 +8703,10 @@ index 5dcac115..5de4f03f 100644 + @end diff --git a/QuartzCore/CALayerContext.m b/QuartzCore/CALayerContext.m -index 5dce8c56..dee2b80c 100644 +index 5dce8c56..7f9125e3 100644 --- a/QuartzCore/CALayerContext.m +++ b/QuartzCore/CALayerContext.m -@@ -1,47 +1,58 @@ +@@ -1,47 +1,69 @@ #import +#import #import @@ -8720,6 +8720,17 @@ index 5dce8c56..dee2b80c 100644 @class CAMetalLayerInternal; ++/* A flattened IOSurface only replaces local rendering for trees without ++ * Metal content. Skipping a mixed tree also skips prepareRender, exhausting ++ * its drawable pool even though presentation callbacks continue to run. */ ++static BOOL hasMetalLayer(CALayer *layer) { ++ if ([[layer class] isSubclassOfClass: [CAMetalLayerInternal class]]) ++ return YES; ++ for (CALayer *child in layer.sublayers) ++ if (hasMetalLayer(child))return YES; ++ return NO; ++} ++ +/* The X11 transport presents Chromium's flattened full-window surface. + * Avoid uploading and compositing that same window again into a covered GL + * child. Restrict this to the explicitly configured native presentation path. */ @@ -8784,7 +8795,7 @@ index 5dce8c56..dee2b80c 100644 [super dealloc]; } -@@ -51,12 +62,22 @@ +@@ -51,12 +73,22 @@ } - (void) setLayer: (CALayer *) layer { @@ -8807,7 +8818,7 @@ index 5dce8c56..dee2b80c 100644 } - (void) setSubwindow: (CGSubWindow*) subwindow -@@ -68,16 +89,50 @@ +@@ -68,16 +100,50 @@ } _subwindow = [subwindow retain]; @@ -8859,7 +8870,7 @@ index 5dce8c56..dee2b80c 100644 } - (void) assignTextureIdsToLayerTree: (CALayer *) layer { -@@ -93,7 +148,22 @@ +@@ -93,7 +159,22 @@ [self assignTextureIdsToLayerTree: child]; } @@ -8882,7 +8893,7 @@ index 5dce8c56..dee2b80c 100644 CGLContextMakeCurrentAndAttachToWindow(_glContext, _cglWindow); glEnable(GL_DEPTH_TEST); -@@ -118,27 +188,63 @@ +@@ -118,27 +199,63 @@ [_deleteTextureIds removeAllObjects]; @@ -8907,7 +8918,7 @@ index 5dce8c56..dee2b80c 100644 + if (_cglWindow == NULL || _layer == nil) + return; + if(getenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER") && -+ hasFullWindowSurface(_layer,_frame.size))return; ++ !hasMetalLayer(_layer) && hasFullWindowSurface(_layer,_frame.size))return; [self renderLayer: _layer]; } @@ -8951,7 +8962,7 @@ index 5dce8c56..dee2b80c 100644 } - (void) startTimerIfNeeded { -@@ -147,7 +253,7 @@ +@@ -147,7 +264,7 @@ target: self selector: @selector(timer:) userInfo: nil @@ -8960,13 +8971,13 @@ index 5dce8c56..dee2b80c 100644 } - (void) deleteTextureId: (NSNumber *) textureId { -@@ -155,7 +261,10 @@ +@@ -155,7 +272,10 @@ } - (void) flush { - CGLFlushDrawable(_glContext); + if(getenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER") && _layer && -+ hasFullWindowSurface(_layer,_frame.size))return; ++ !hasMetalLayer(_layer) && hasFullWindowSurface(_layer,_frame.size))return; + if (_glContext != NULL && _cglWindow != NULL) + CGLFlushDrawable(_glContext); } diff --git a/tools/build-riot-metal-presentation.py b/tools/build-riot-metal-presentation.py index a595318..33f0609 100644 --- a/tools/build-riot-metal-presentation.py +++ b/tools/build-riot-metal-presentation.py @@ -27,10 +27,12 @@ for name, suffix in [('CALayer', 'm'), ('CALayerContext', 'm'), ('CATransaction' link[link.index('CMakeFiles/QuartzCore.dir/' + name + '.' + suffix + '.o')] = str(obj) link[link.index('-o') + 1] = str(root / 'build/riot-QuartzCore') subprocess.run(link, cwd=folder, check=True) -obj = root / 'build/riot-metal-presentation-check.o' -subprocess.run(['clang++', *flags['CXX_DEFINES'], *flags['CXX_INCLUDES'], *flags['CXX_FLAGS'], - '-c', str(root / 'tools/riot-metal-presentation-check.mm'), '-o', str(obj)], check=True) -subprocess.run(['clang++', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', - '-isysroot', sdk, str(obj), '-lSystem', '-lc++', '-lobjc', - '-framework', 'AppKit', '-framework', 'OpenGL', '-framework', 'Foundation', str(root / 'build/riot-Metal'), - str(root / 'build/riot-QuartzCore'), '-o', str(root / 'build/riot-metal-presentation-check')], check=True) +for name in ('riot-metal-presentation-check', 'riot-ca-render-policy-check'): + obj = root / f'build/{name}.o' + subprocess.run(['clang++', *flags['CXX_DEFINES'], *flags['CXX_INCLUDES'], *flags['CXX_FLAGS'], + '-c', str(root / f'tools/{name}.mm'), '-o', str(obj)], check=True) + subprocess.run(['clang++', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', sdk, str(obj), '-lSystem', '-lc++', '-lobjc', + '-framework', 'AppKit', '-framework', 'OpenGL', '-framework', 'Foundation', + '-framework', 'IOSurface', str(root / 'build/riot-Metal'), + str(root / 'build/riot-QuartzCore'), '-o', str(root / f'build/{name}')], check=True) diff --git a/tools/riot-ca-render-policy-check.mm b/tools/riot-ca-render-policy-check.mm new file mode 100644 index 0000000..5d3d23a --- /dev/null +++ b/tools/riot-ca-render-policy-check.mm @@ -0,0 +1,82 @@ +#import +#import +#import +#import "CAMetalLayerInternal.h" +#import +#include +#include + +@interface CALayerContext (RenderPolicyCheckPrivate) +- (void)prepareMetalLayers:(CALayer*)layer; +@end +static unsigned prepared, renderCalls; +@interface RenderPolicyMetalLayer : CAMetalLayerInternal +@end +@implementation RenderPolicyMetalLayer +- (void)prepareRender { ++prepared; } +@end +@interface RenderPolicySurfaceLayer : CALayer { IOSurfaceRef _probeSurface; } +- (id)initWithSurface:(IOSurfaceRef)surface; +@end +@implementation RenderPolicySurfaceLayer +- (id)initWithSurface:(IOSurfaceRef)surface { + self=[super init]; _probeSurface=surface; return self; +} +- (id)contents { return (id)_probeSurface; } +@end +@interface RenderPolicyContext : CALayerContext +- (id)initWithRoot:(CALayer*)root; +@end +@implementation RenderPolicyContext +- (id)initWithRoot:(CALayer*)root { + self=[super initWithFrame:CGRectMake(0,0,16,16)]; + _layer=[root retain]; _cglWindow=(void*)1; return self; +} +- (void)renderLayer:(CALayer*)layer { ++renderCalls; [self prepareMetalLayers:layer]; } +@end +int main() { + // No NSApplication, window, graphics context, GPU work, or published IOSurface. + // Objects intentionally live until process exit; the sentinel is never passed to CGL. + [NSAutoreleasePool new]; + int dimensions[]={16,16,4,64}; + const void* keys[]={kIOSurfaceWidth,kIOSurfaceHeight,kIOSurfaceBytesPerElement,kIOSurfaceBytesPerRow}; + const void* values[4]; + for(unsigned i=0;i<4;++i) values[i]=CFNumberCreate(NULL,kCFNumberIntType,&dimensions[i]); + CFDictionaryRef properties=CFDictionaryCreate(NULL,keys,values,4,&kCFTypeDictionaryKeyCallBacks,&kCFTypeDictionaryValueCallBacks); + IOSurfaceRef surface=IOSurfaceCreate(properties); + if(!surface) return 2; + CALayer* root=[CALayer new]; + CALayer* container=[CALayer new]; + RenderPolicyMetalLayer* metal=[RenderPolicyMetalLayer new]; + [container addSublayer:metal]; + [root addSublayer:container]; + RenderPolicyContext* context=[[RenderPolicyContext alloc] initWithRoot:root]; + setenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER","1",1); + for(unsigned i=0;i<3;++i) [context render]; + printf("metal_only: prepared=%u expected=3\n",prepared); + if(prepared!=3) return 3; + RenderPolicySurfaceLayer* surfaceLayer=[[RenderPolicySurfaceLayer alloc] initWithSurface:surface]; + [root addSublayer:surfaceLayer]; + prepared=0; + for(unsigned i=0;i<3;++i) [context render]; + printf("mixed_tree_shortcut_enabled: prepared=%u expected=3\n",prepared); + if(prepared!=3) return 4; + [root setSublayers:@[surfaceLayer,container]]; + prepared=0; + for(unsigned i=0;i<3;++i) [context render]; + printf("mixed_tree_surface_first: prepared=%u expected=3\n",prepared); + if(prepared!=3) return 7; + unsetenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER"); + prepared=0; + for(unsigned i=0;i<3;++i) [context render]; + printf("mixed_tree_shortcut_disabled: prepared=%u expected=3\n",prepared); + if(prepared!=3) return 5; + setenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER","1",1); + RenderPolicyContext* surfaceOnly=[[RenderPolicyContext alloc] initWithRoot:surfaceLayer]; + renderCalls=0; + [surfaceOnly render]; + printf("surface_only_shortcut_enabled: renderCalls=%u expected=0\n",renderCalls); + if(renderCalls!=0) return 6; + printf("PASS: mixed-tree Metal preparation survives IOSurface shortcut\n"); + return 0; +} From 0d78eaf0ffc617460516d7d9e82bf9cfb7fabf5b Mon Sep 17 00:00:00 2001 From: Polar <343746874+PolarSeeker77@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:14:31 +0000 Subject: [PATCH 048/160] Create X11 windows with an EGL-compatible visual (NVIDIA black screen) On NVIDIA (RTX 2070 SUPER, driver 615.78, Xwayland) the game showed a black screen and logged "Metal Device timed out giving us a Drawable to present" every second (#3). X11Window picked its visual with glXChooseVisual, which on NVIDIA returns a GLX-only visual (0x304 here). NVIDIA's EGL configs only map to the default visual (0x69) and the 32-bit one (0x99), so CGLGetWindow's eglCreateWindowSurface failed with EGL_BAD_CONFIG for the window and its subwindows, for every one of the 40 window configs. CGLGetWindow returns NULL without logging, CALayerContext keeps _cglWindow == NULL, -render returns before prepareMetalLayers, and CAMetalLayer never gets its three drawables back. Mesa accepts the GLX visual, which is why AMD works. Use the default visual when it is TrueColor with depth >= 24, falling back to glXChooseVisual otherwise. This also fixes every other Darling window on NVIDIA: plain AppKit windows (title bars included), layer-backed views and NSOpenGLView were all black without it. --- patches/cocotron.patch | 64 +++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 899b568..baaed82 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -4528,7 +4528,7 @@ index bfe3839a..e8c8828f 100644 - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m -index f4925b66..f9dd38cc 100644 +index f4925b66..0296203b 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -23,6 +23,7 @@ @@ -4583,7 +4583,27 @@ index f4925b66..f9dd38cc 100644 BOOL isPanel = [delegate isKindOfClass: [NSPanel class]]; if (isPanel && _styleMask & NSDocModalWindowMask) _styleMask = NSBorderlessWindowMask; -@@ -193,8 +222,16 @@ static NSData *makeWindowIcon() { +@@ -177,7 +206,18 @@ static NSData *makeWindowIcon() { + + int screen = DefaultScreen(_display); + +- if ((_visualInfo = glXChooseVisual(_display, screen, att)) == NULL) { ++ // Rendering goes through EGL. NVIDIA's glXChooseVisual can return a GLX-only visual that every ++ // eglCreateWindowSurface rejects (EGL_BAD_CONFIG), so prefer the default visual when usable. ++ XVisualInfo visualTemplate; ++ int visualCount = 0; ++ visualTemplate.visualid = XVisualIDFromVisual(DefaultVisual(_display, screen)); ++ visualTemplate.screen = screen; ++ _visualInfo = XGetVisualInfo(_display, VisualIDMask | VisualScreenMask, &visualTemplate, &visualCount); ++ if (_visualInfo != NULL && (_visualInfo->class != TrueColor || _visualInfo->depth < 24)) { ++ XFree(_visualInfo); ++ _visualInfo = NULL; ++ } ++ if (_visualInfo == NULL && (_visualInfo = glXChooseVisual(_display, screen, att)) == NULL) { + NSLog(@"glXChooseVisual failed at %s %d", __FILE__, __LINE__); + } + +@@ -193,8 +233,16 @@ static NSData *makeWindowIcon() { XSetWindowAttributes xattr; unsigned long xattr_mask; @@ -4602,7 +4622,7 @@ index f4925b66..f9dd38cc 100644 xattr_mask = CWOverrideRedirect | CWColormap; xattr.colormap = cmap; -@@ -206,6 +243,12 @@ static NSData *makeWindowIcon() { +@@ -206,6 +254,12 @@ static NSData *makeWindowIcon() { (_visualInfo == NULL) ? CopyFromParent : _visualInfo->visual, xattr_mask, &xattr); @@ -4615,7 +4635,7 @@ index f4925b66..f9dd38cc 100644 [self syncDelegateProperties]; Atom atm = XInternAtom(_display, "WM_DELETE_WINDOW", False); -@@ -217,6 +260,15 @@ static NSData *makeWindowIcon() { +@@ -217,6 +271,15 @@ static NSData *makeWindowIcon() { .res_class = (char *) name}; XSetClassHint(_display, _window, &classHint); @@ -4631,7 +4651,7 @@ index f4925b66..f9dd38cc 100644 XSetWindowBackgroundPixmap(_display, _window, None); _isModal = [delegate isSheet] || [NSApp modalWindow] == delegate; -@@ -266,6 +318,13 @@ static NSData *makeWindowIcon() { +@@ -266,6 +329,13 @@ static NSData *makeWindowIcon() { [self setWindowIcon]; @@ -4645,7 +4665,7 @@ index f4925b66..f9dd38cc 100644 return self; } -@@ -279,24 +338,30 @@ static NSData *makeWindowIcon() { +@@ -279,24 +349,30 @@ static NSData *makeWindowIcon() { return _styleMask; } @@ -4692,7 +4712,7 @@ index f4925b66..f9dd38cc 100644 if (!_mapped) { long states[2]; int states_cnt = 0; -@@ -304,7 +369,7 @@ static NSData *makeWindowIcon() { +@@ -304,7 +380,7 @@ static NSData *makeWindowIcon() { states[states_cnt++] = (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); } @@ -4701,7 +4721,7 @@ index f4925b66..f9dd38cc 100644 states[states_cnt++] = (long) XInternAtom( _display, "_NET_WM_STATE_FULLSCREEN", False); } -@@ -312,14 +377,13 @@ static NSData *makeWindowIcon() { +@@ -312,14 +388,13 @@ static NSData *makeWindowIcon() { XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, 32, PropModeReplace, (const unsigned char *) states, states_cnt); @@ -4718,7 +4738,7 @@ index f4925b66..f9dd38cc 100644 event.data.l[1] = (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); event.data.l[3] = 1; -@@ -327,6 +391,35 @@ static NSData *makeWindowIcon() { +@@ -327,6 +402,35 @@ static NSData *makeWindowIcon() { SubstructureNotifyMask | SubstructureRedirectMask, (XEvent *) &event); } @@ -4754,7 +4774,7 @@ index f4925b66..f9dd38cc 100644 } - (void) setStyleMask: (NSUInteger) mask { -@@ -338,7 +431,6 @@ static NSData *makeWindowIcon() { +@@ -338,7 +442,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -4762,7 +4782,7 @@ index f4925b66..f9dd38cc 100644 struct { unsigned long flags; unsigned long functions; -@@ -358,6 +450,7 @@ static NSData *makeWindowIcon() { +@@ -358,6 +461,7 @@ static NSData *makeWindowIcon() { if (!_mapped) { XMapWindow(_display, _window); _mapped = YES; @@ -4770,7 +4790,7 @@ index f4925b66..f9dd38cc 100644 } } -@@ -377,15 +470,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +481,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -4803,7 +4823,7 @@ index f4925b66..f9dd38cc 100644 // TODO: background color } -@@ -393,6 +498,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +509,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. @@ -4832,7 +4852,7 @@ index f4925b66..f9dd38cc 100644 [_context release]; _context = nil; -@@ -421,6 +548,392 @@ static NSData *makeWindowIcon() { +@@ -421,6 +559,392 @@ static NSData *makeWindowIcon() { } } @@ -5225,7 +5245,7 @@ index f4925b66..f9dd38cc 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +983,32 @@ static NSData *makeWindowIcon() { +@@ -470,14 +994,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -5259,7 +5279,7 @@ index f4925b66..f9dd38cc 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1019,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1030,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -5285,7 +5305,7 @@ index f4925b66..f9dd38cc 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1093,21 @@ static NSData *makeWindowIcon() { +@@ -543,6 +1104,21 @@ static NSData *makeWindowIcon() { - (void) makeKey { [self ensureMapped]; XRaiseWindow(_display, _window); @@ -5307,7 +5327,7 @@ index f4925b66..f9dd38cc 100644 } - (void) makeMain { -@@ -559,7 +1124,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1135,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -5337,7 +5357,7 @@ index f4925b66..f9dd38cc 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -615,6 +1202,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1213,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -5350,7 +5370,7 @@ index f4925b66..f9dd38cc 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1245,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1256,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -5359,7 +5379,7 @@ index f4925b66..f9dd38cc 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1259,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1270,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -5367,7 +5387,7 @@ index f4925b66..f9dd38cc 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,14 +1304,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,14 +1315,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } From f230dd8b9e4109ae6b9e8d431c74727a5abbaf20 Mon Sep 17 00:00:00 2001 From: Polar <343746874+PolarSeeker77@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:14:31 +0000 Subject: [PATCH 049/160] Point the layer compositor's GL context at the window's back buffer Second half of the NVIDIA black screen (#3). With the visual fix the compositor gets a window surface and drawables recycle, but the window stays black: after CARenderer draws, the back buffer reads back black and even a glClear to blue changes nothing. GL_DRAW_BUFFER and GL_READ_BUFFER are GL_NONE. CALayerContext -setSubwindow: creates the context and then notifies the layer tree; CAMetalLayer -_setContext: makes the context current (to create its textures) before any window surface is attached. Per EGL_KHR_surfaceless_context, a context first made current without a default framebuffer starts with DRAW_BUFFER = NONE, and attaching a window later doesn't change it. NVIDIA follows that; Mesa resets it on bind, which is why AMD works. After CGLContextMakeCurrentAndAttachToWindow in -renderLayer:, set the draw and read buffers to GL_BACK. --- patches/cocotron.patch | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index baaed82..97aeb5d 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -8723,7 +8723,7 @@ index 5dcac115..5de4f03f 100644 + @end diff --git a/QuartzCore/CALayerContext.m b/QuartzCore/CALayerContext.m -index 5dce8c56..dee2b80c 100644 +index 5dce8c56..d76cdae4 100644 --- a/QuartzCore/CALayerContext.m +++ b/QuartzCore/CALayerContext.m @@ -1,47 +1,58 @@ @@ -8879,7 +8879,7 @@ index 5dce8c56..dee2b80c 100644 } - (void) assignTextureIdsToLayerTree: (CALayer *) layer { -@@ -93,7 +148,22 @@ +@@ -93,8 +148,27 @@ [self assignTextureIdsToLayerTree: child]; } @@ -8900,9 +8900,14 @@ index 5dce8c56..dee2b80c 100644 - (void) renderLayer: (CALayer *) layer { + CFTimeInterval renderLayerStart = CACurrentMediaTime(); CGLContextMakeCurrentAndAttachToWindow(_glContext, _cglWindow); ++ // CAMetalLayer first makes this context current without a surface, which leaves GL_DRAW_BUFFER ++ // at GL_NONE on NVIDIA even once the window is attached (Mesa resets it). ++ glDrawBuffer(GL_BACK); ++ glReadBuffer(GL_BACK); glEnable(GL_DEPTH_TEST); -@@ -118,27 +188,63 @@ + glShadeModel(GL_SMOOTH); +@@ -118,27 +192,63 @@ [_deleteTextureIds removeAllObjects]; @@ -8971,7 +8976,7 @@ index 5dce8c56..dee2b80c 100644 } - (void) startTimerIfNeeded { -@@ -147,7 +253,7 @@ +@@ -147,7 +257,7 @@ target: self selector: @selector(timer:) userInfo: nil @@ -8980,7 +8985,7 @@ index 5dce8c56..dee2b80c 100644 } - (void) deleteTextureId: (NSNumber *) textureId { -@@ -155,7 +261,10 @@ +@@ -155,7 +265,10 @@ } - (void) flush { From 792b4cce13de4a71605f3f6942a40528bbe5a193 Mon Sep 17 00:00:00 2001 From: Polar <343746874+PolarSeeker77@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:19:28 +0000 Subject: [PATCH 050/160] mldr: never replace another owner's descriptor with an RPC socket On NVIDIA the game aborts within minutes: a thread's darlingserver RPC socket is closed behind Darling's back, and its next RPC fails with EBADF (or ENOTSOCK once the number is reused), then __simple_abort in mach_msg_overwrite_trap_impl (#23). __mldr_create_rpc_socket moved each new socket onto its slot with dup2(), which silently closes whatever already holds that number. The slots count down from the soft RLIMIT_NOFILE read at the process's first socket. The game starts with a soft limit of 1024 (systemd's default, inherited from the terminal) and then holds ~5500 fds, one per NVIDIA driver allocation, so the kernel hands numbers in the slot range (900-1023) to the driver whenever a slot is free, for instance after a thread exits. The next new thread's dup2() replaced the driver's fd with its socket; when the driver later closed "its" fd, it closed the socket. Mesa never comes near 1000 fds, which is why AMD works. Use fcntl(F_DUPFD, slot) instead: it never replaces a descriptor. If the slot is taken, give it back and keep the socket at the lowest free number above it, which is above all of the bitmap's slots and owned by nobody else; socket_bitmap_put ignores sockets above the slots. The process lifetime pipe helper gets the same treatment. This is the remaining case of the 2026-10-10 LeagueClient crash: that fix stopped two Darling threads from sharing a slot, this one stops Darling from taking a slot that belongs to someone else. --- patches/darling.patch | 80 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/patches/darling.patch b/patches/darling.patch index 7e50093..87c850a 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -7796,7 +7796,7 @@ index fee1bde87..bee86604b 100644 #endif - diff --git a/src/startup/mldr/mldr.c b/src/startup/mldr/mldr.c -index 6617ef82b..1d12043c2 100644 +index 6617ef82b..a227abee8 100644 --- a/src/startup/mldr/mldr.c +++ b/src/startup/mldr/mldr.c @@ -154,7 +154,7 @@ int main(int argc, char** argv, char** envp) @@ -7825,7 +7825,19 @@ index 6617ef82b..1d12043c2 100644 static int socket_bitmap_get(socket_bitmap_t* bitmap) { int fd = -1; bool updated = false; -@@ -673,7 +683,13 @@ static void socket_bitmap_put(socket_bitmap_t* bitmap, int socket) { +@@ -648,6 +658,11 @@ static void socket_bitmap_put(socket_bitmap_t* bitmap, int socket) { + + pthread_mutex_lock(&bitmap->mutex); + ++ // darling-lol: placed above the slots because its slot was taken (socket_bitmap_place). ++ if (socket > bitmap->highest) { ++ goto out; ++ } ++ + index = bitmap->highest - socket; + + bitmap->bits[index / 8] &= ~(1 << (index % 8)); +@@ -673,7 +688,13 @@ static void socket_bitmap_put(socket_bitmap_t* bitmap, int socket) { } } @@ -7840,7 +7852,7 @@ index 6617ef82b..1d12043c2 100644 // this bit is in-use, so we can't shrink any further break; } -@@ -685,12 +701,20 @@ static void socket_bitmap_put(socket_bitmap_t* bitmap, int socket) { +@@ -685,12 +706,20 @@ static void socket_bitmap_put(socket_bitmap_t* bitmap, int socket) { if (old_byte_size != new_byte_size) { // we can free one or more bytes from the bitmap @@ -7866,6 +7878,68 @@ index 6617ef82b..1d12043c2 100644 } } +@@ -698,6 +727,22 @@ out: + pthread_mutex_unlock(&bitmap->mutex); + }; + ++/* darling-lol: move `fd` to the next slot. dup2() silently closed whatever the guest or a native library (NVIDIA's ++ * driver: one fd per allocation) already held on that number, and its owner later closed our socket. F_DUPFD never ++ * replaces a descriptor: if the slot is taken, it returns the lowest free number above it, which is ours alone. */ ++static int socket_bitmap_place(int fd) { ++ int slot = socket_bitmap_get(&socket_bitmap); ++ if (slot < 0) { ++ return -1; ++ } ++ ++ int placed = fcntl(fd, F_DUPFD, slot); ++ if (placed != slot) { ++ socket_bitmap_put(&socket_bitmap, slot); ++ } ++ return placed; ++} ++ + int __mldr_create_rpc_socket(void) { + int pre_fd = -1; + int fd = -1; +@@ -707,18 +752,11 @@ int __mldr_create_rpc_socket(void) { + goto err_out; + } + +- fd = socket_bitmap_get(&socket_bitmap); ++ fd = socket_bitmap_place(pre_fd); + if (fd < 0) { + goto err_out; + } + +- if (dup2(pre_fd, fd) < 0) { +- // we have to put it away ourselves here because `fd` is not yet valid, so we can't close() it in the error handler +- socket_bitmap_put(&socket_bitmap, fd); +- fd = -1; +- goto err_out; +- } +- + close(pre_fd); + pre_fd = -1; + +@@ -772,17 +810,11 @@ int __mldr_create_process_lifetime_pipe(int* fds) { + } + + for (int i = 0; i < 2; ++i) { +- fds[i] = socket_bitmap_get(&socket_bitmap); ++ fds[i] = socket_bitmap_place(pre_fds[i]); + if (fds[i] < 0) { + goto err_out; + } + +- if (dup2(pre_fds[i], fds[i]) < 0) { +- socket_bitmap_put(&socket_bitmap, fds[i]); +- fds[i] = -1; +- goto err_out; +- } +- + close(pre_fds[i]); + pre_fds[i] = -1; + } diff --git a/src/tools/open.m b/src/tools/open.m index 126bd394c..f62c2e0e8 100644 --- a/src/tools/open.m From 5dbfbe2ccb2fea0740473752bae06292f3d457fb Mon Sep 17 00:00:00 2001 From: ztancrell Date: Sat, 17 Oct 2026 07:59:09 -0400 Subject: [PATCH 051/160] Fix Riot Client crashes on sign-in buttons and file dialogs The Riot Client (Electron) aborted with unrecognized selectors in Cocotron's AppKit: - -[NSOpenPanel setShowsTagField:]: Electron sets it on every file dialog. Add the missing NSSavePanel setters it uses (showsTagField, nameFieldLabel, extensionHidden, canSelectHiddenExtension) as no-ops, and implement beginWithCompletionHandler: and beginSheetModalForWindow:completionHandler: by running the panel modally and calling the handler (the old stub never called it). - -[MacWorkspace openURLs:withAppBundleIdentifier:options: additionalEventParamDescriptor:launchIdentifiers:]: used by shell.openExternal for the third-party sign-in buttons. Open each URL through openURL: (LaunchServices). Fixes #28 Co-Authored-By: Claude Opus 5.5 --- patches/cocotron.patch | 100 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 3 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 899b568..96258f2 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2069,7 +2069,7 @@ index d545120a..b2845478 100644 + (BOOL) allowsAutomaticWindowTabbing { diff --git a/AppKit/NSWorkspace.m b/AppKit/NSWorkspace.m -index ad5e4f9d..dbd997aa 100644 +index ad5e4f9d..6e9cb1c9 100644 --- a/AppKit/NSWorkspace.m +++ b/AppKit/NSWorkspace.m @@ -20,9 +20,58 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ @@ -2131,7 +2131,7 @@ index ad5e4f9d..dbd997aa 100644 NSString *const NSWorkspaceWillPowerOffNotification = @"NSWorkspaceWillPowerOffNotification"; -@@ -161,6 +210,24 @@ NSNotificationName NSWorkspaceWillUnmountNotification = @"NSWorkspaceWillUnmount +@@ -161,6 +210,41 @@ NSNotificationName NSWorkspaceWillUnmountNotification = @"NSWorkspaceWillUnmount return 0; } @@ -2152,11 +2152,28 @@ index ad5e4f9d..dbd997aa 100644 + } + }); +} ++ ++/* Electron's shell.openExternal({activate: false}) (Riot's "Sign in with Google") uses this ++ * pre-10.15 form. Launch options and the bundle identifier are ignored: the host decides ++ * which application opens the URL. */ ++- (BOOL) openURLs: (NSArray *) urls ++ withAppBundleIdentifier: (NSString *) bundleIdentifier ++ options: (NSUInteger) options ++ additionalEventParamDescriptor: (NSAppleEventDescriptor *) descriptor ++ launchIdentifiers: (NSArray **) identifiers ++{ ++ BOOL opened = [urls count] > 0; ++ for (NSURL *url in urls) ++ opened = [self openURL: url] && opened; ++ if (identifiers != NULL) ++ *identifiers = nil; ++ return opened; ++} + - (BOOL) selectFile: (NSString *) path inFileViewerRootedAtPath: (NSString *) rootedAtPath { -@@ -317,16 +384,3 @@ NSNotificationName NSWorkspaceWillUnmountNotification = @"NSWorkspaceWillUnmount +@@ -317,16 +401,3 @@ NSNotificationName NSWorkspaceWillUnmountNotification = @"NSWorkspaceWillUnmount } @end @@ -10367,3 +10384,80 @@ index 00000000..19cd9ea9 +- (instancetype) init; + +@end +diff --git a/AppKit/NSSavePanel.subproj/NSSavePanel.m b/AppKit/NSSavePanel.subproj/NSSavePanel.m +index 50c3d59e..5872e594 100644 +--- a/AppKit/NSSavePanel.subproj/NSSavePanel.m ++++ b/AppKit/NSSavePanel.subproj/NSSavePanel.m +@@ -124,8 +124,48 @@ static NSSavePanel *_newPanel = nil; + [NSApp stopModalWithCode: NSCancelButton]; + } + ++// Electron (Riot Client UI) opens its file dialogs through these and waits ++// for the handler; run the panel modally and report the result. + - (void) beginWithCompletionHandler: (void (^)(NSModalResponse result)) handler { +- NSUnimplementedMethod(); ++ NSModalResponse result = [self runModal]; ++ if (handler) ++ handler(result); ++} ++ ++- (void) beginSheetModalForWindow: (NSWindow *) window ++ completionHandler: (void (^)(NSModalResponse result)) handler ++{ ++ [self beginWithCompletionHandler: handler]; ++} ++ ++// Electron sets these unconditionally; Cocotron's panel has no tag field, ++// name label or extension toggle, so they are accepted and ignored. ++- (void) setNameFieldLabel: (NSString *) label { ++} ++ ++- (NSString *) nameFieldLabel { ++ return nil; ++} ++ ++- (void) setShowsTagField: (BOOL) flag { ++} ++ ++- (BOOL) showsTagField { ++ return NO; ++} ++ ++- (void) setExtensionHidden: (BOOL) flag { ++} ++ ++- (BOOL) isExtensionHidden { ++ return NO; ++} ++ ++- (void) setCanSelectHiddenExtension: (BOOL) flag { ++} ++ ++- (BOOL) canSelectHiddenExtension { ++ return NO; + } + + - (NSInteger) runModalForDirectory: (NSString *) directory +diff --git a/AppKit/include/AppKit/NSSavePanel.h b/AppKit/include/AppKit/NSSavePanel.h +index 53bacc46..3c6adfb7 100644 +--- a/AppKit/include/AppKit/NSSavePanel.h ++++ b/AppKit/include/AppKit/NSSavePanel.h +@@ -88,6 +88,18 @@ enum { + - (void) setPrompt: (NSString *) message; + - (NSString *) prompt; + ++- (void) setNameFieldLabel: (NSString *) label; ++- (NSString *) nameFieldLabel; ++- (void) setShowsTagField: (BOOL) flag; ++- (BOOL) showsTagField; ++- (void) setExtensionHidden: (BOOL) flag; ++- (BOOL) isExtensionHidden; ++- (void) setCanSelectHiddenExtension: (BOOL) flag; ++- (BOOL) canSelectHiddenExtension; ++ ++- (void) beginSheetModalForWindow: (NSWindow *) window ++ completionHandler: (void (^)(NSModalResponse result)) handler; ++ + - (void) beginSheetForDirectory: (NSString *) path + file: (NSString *) name + modalForWindow: (NSWindow *) docWindow From 66ad2339c839717cf723db3d32c03b84d0a8b2c1 Mon Sep 17 00:00:00 2001 From: ztancrell Date: Sat, 17 Oct 2026 08:06:05 -0400 Subject: [PATCH 052/160] Map LAN, LAS and OCE to Riot's installer codes Riot's CDN has no live.lan.zip, live.las.zip or live.oce.zip (HTTP 404); the installers are published as la1, la2 and oc1. installer.sh now maps the friendly names to those codes and accepts the codes directly. Refs #30 Co-Authored-By: Claude Opus 5.5 --- scripts/installer.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/installer.sh b/scripts/installer.sh index c7520eb..62c7ed6 100755 --- a/scripts/installer.sh +++ b/scripts/installer.sh @@ -3,7 +3,9 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" region=${1:-euw} -case "$region" in euw|eune|na|br|kr|jp|lan|las|oce|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac +# Riot publishes LAN, LAS and OCE installers under their platform codes. +case "$region" in lan) region=la1;; las) region=la2;; oce) region=oc1;; esac +case "$region" in euw|eune|na|br|kr|jp|la1|la2|oc1|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac python3 scripts/configure.py >/dev/null bash build/runtime/tools/fetch-installer.sh "$region" build/installer unzip -q -o "build/installer/live.$region.zip" -d build/installer From 83b4694aad23d8005017d43a395790226a9bfee6 Mon Sep 17 00:00:00 2001 From: ztancrell Date: Sat, 17 Oct 2026 08:20:34 -0400 Subject: [PATCH 053/160] Return a valid NSScroller preferredScrollerStyle The unimplemented method returned whatever was left in a register. Chromium forwards the style to its renderer, which rejected the out-of-range value (VALIDATION_ERROR_DESERIALIZATION_FAILED) and left the Riot Client window black. Report NSScrollerStyleLegacy, as macOS does without a trackpad. Co-Authored-By: Claude Opus 5.5 --- patches/cocotron.patch | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 899b568..c6a82b2 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -1127,6 +1127,22 @@ index 5ca58772..54b60ef5 100644 } @end +diff --git a/AppKit/NSScroller.m b/AppKit/NSScroller.m +index 6c361fa3..55027e7e 100644 +--- a/AppKit/NSScroller.m ++++ b/AppKit/NSScroller.m +@@ -34,8 +34,10 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + return [[NSDisplay currentDisplay] scrollerWidth]; + } + ++// Chromium forwards this to its renderer, which rejects an out-of-range value and leaves ++// the Riot Client black; macOS reports legacy scrollers when no trackpad is attached. + + (NSScrollerStyle) preferredScrollerStyle { +- NSUnimplementedMethod(); ++ return NSScrollerStyleLegacy; + } + /* OS X has a global default "AppleScrollBarVariant" with the values: Single, + DoubleMin, DoubleMax, and DoubleBoth This controls the default position of the diff --git a/AppKit/NSScrubberItemView.m b/AppKit/NSScrubberItemView.m index dea5e1d3..7b3a877b 100644 --- a/AppKit/NSScrubberItemView.m From dd7e151fbb827340bc46f88f7addee75993fac82 Mon Sep 17 00:00:00 2001 From: ztancrell Date: Sat, 17 Oct 2026 11:05:28 -0400 Subject: [PATCH 054/160] Derive IOPlatformUUID and serial per host instead of hardcoding them Every install reported the same IOPlatformUUID and an Apple-format serial (C02ZL0GVMD6N) that may belong to a real MacBook Pro. Derive both from a SHA-256 of the host machine-id and a fixed app string: stable per host, distinct between hosts, and the raw machine-id is not exposed. The serial is DARLING-<8 hex>, a form no Mac uses. iokitd is built without ARC, so the derived strings are owned for the daemon's lifetime. Fix the header comment that said the values came from DMI. Refs #24 Co-Authored-By: Claude Opus 5.5 --- patches/iokitd.patch | 57 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/patches/iokitd.patch b/patches/iokitd.patch index 78a80bd..96d4963 100644 --- a/patches/iokitd.patch +++ b/patches/iokitd.patch @@ -663,6 +663,7 @@ index 91d11a1..37551e1 100644 -} diff --git a/src/IOPlatformExpertDevice.h b/src/IOPlatformExpertDevice.h new file mode 100644 +index 0000000..38ad635 --- /dev/null +++ b/src/IOPlatformExpertDevice.h @@ -0,0 +1,21 @@ @@ -673,8 +674,8 @@ new file mode 100644 +#include "ServiceRegistry.h" + +// The root platform device (what IOServiceMatching("IOPlatformExpertDevice") finds on macOS). -+// Its properties describe the host machine as reported by the Linux kernel's DMI tables. -+// Identifiers the host does not expose to us (serial number, UUID) are left out. ++// Its model strings name a fixed MacBookPro16,1. IOPlatformUUID and IOPlatformSerialNumber are ++// derived from the host machine-id, so they differ per host and stay stable across reinstalls. +class IOPlatformExpertDevice : public IOService +{ +public: @@ -689,14 +690,19 @@ new file mode 100644 +#endif diff --git a/src/IOPlatformExpertDevice.mm b/src/IOPlatformExpertDevice.mm new file mode 100644 +index 0000000..e330569 --- /dev/null +++ b/src/IOPlatformExpertDevice.mm -@@ -0,0 +1,54 @@ +@@ -0,0 +1,93 @@ +#include "IOPlatformExpertDevice.h" +#import +#import +#import +#import ++#import ++#include ++#include ++#include +#include + +// Platform expert strings are NUL-terminated data @@ -719,17 +725,52 @@ new file mode 100644 + }; +} + ++// Per-host identifiers, stable like a real Mac's: a SHA-256 of the host machine-id and a fixed ++// string, so the raw machine-id (which machine-id(5) says not to expose) never reaches apps. ++// The serial's "DARLING-" form is longer than any Apple serial and cannot match a real Mac. ++static bool hostIdentifiers(NSString** uuid, NSString** serial) ++{ ++ static const char app[] = "darling-lol IOPlatformExpertDevice"; ++ uuid_t machineID; ++ struct timespec wait = { 0, 0 }; ++ if (gethostuuid(machineID, &wait) != 0) ++ return false; ++ ++ unsigned char input[sizeof(machineID) + sizeof(app)]; ++ std::memcpy(input, machineID, sizeof(machineID)); ++ std::memcpy(input + sizeof(machineID), app, sizeof(app)); ++ unsigned char hash[CC_SHA256_DIGEST_LENGTH]; ++ CC_SHA256(input, sizeof(input), hash); ++ ++ hash[6] = (hash[6] & 0x0f) | 0x40; // version 4 ++ hash[8] = (hash[8] & 0x3f) | 0x80; // RFC 4122 variant ++ uuid_string_t text; ++ uuid_unparse_upper(hash, text); ++ // Owned (not autoreleased): the caller keeps them in statics for the daemon's lifetime ++ *uuid = [[NSString alloc] initWithUTF8String: text]; ++ *serial = [[NSString alloc] initWithFormat: @"DARLING-%02X%02X%02X%02X", hash[16], hash[17], hash[18], hash[19]]; ++ return true; ++} ++ +NSDictionary* IOPlatformExpertDevice::getProperties() +{ -+ // Fixed MacBookPro16,1 identity; the Linux DMI values describe the host hardware, not the Mac we emulate -+ return @{ ++ // MacBookPro16,1 model strings; the Linux DMI values describe the host hardware, not the Mac we emulate ++ NSMutableDictionary* properties = [NSMutableDictionary dictionaryWithDictionary: @{ + @"manufacturer": dataForString(@"Apple Inc."), + @"model": dataForString(@"MacBookPro16,1"), + @"product-name": dataForString(@"MacBookPro16,1"), + @"board-id": dataForString(@"Mac-E1008331FDC96864"), -+ @"IOPlatformUUID": @"6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B", -+ @"IOPlatformSerialNumber": @"C02ZL0GVMD6N", -+ }; ++ }]; ++ ++ static NSString* uuid; ++ static NSString* serial; ++ static bool known = hostIdentifiers(&uuid, &serial); ++ if (known) ++ { ++ properties[@"IOPlatformUUID"] = uuid; ++ properties[@"IOPlatformSerialNumber"] = serial; ++ } ++ return properties; +} + +bool IOPlatformExpertDevice::conformsTo(const char* className) From 2549e2b654dd1de516a55310ea0b169e4c0c7f10 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:49:42 +0200 Subject: [PATCH 055/160] Report the host GPU name and VRAM from MTLDevice -[MTLDevice name] returned "Darling Indium Device" for every GPU. It now returns the selected Vulkan device's name in macOS Metal style: the trailing driver note in brackets and the (R)/(TM) marks are removed, and NVIDIA names are unchanged. The name is never replaced with another vendor's. recommendedMaxWorkingSetSize is the device-local heap size on discrete GPUs and all heaps on integrated/CPU devices, with the old 1 GiB as fallback. Both are computed once per device. hasUnifiedMemory stays YES by default: synchronizeResource:/ synchronizeTexture: are missing and Private buffers created with data abort in Indium, so reporting NO on discrete GPUs would send apps down unimplemented paths. DARLING_METAL_HOST_UNIFIED_MEMORY=1 opts in to the GPU-type answer. DARLING_METAL_GPU_NAME and DARLING_METAL_VRAM_MB override the name and working set. The cleaning logic lives in private-include/Metal/MTLDeviceInfo.h and is covered by the host check tools/test-metal-device-info.cpp. Metal now links indium_private for the Vulkan properties, and the runtime build script adds Indium's private headers before compiling MTLDevice.mm. Co-Authored-By: Claude Opus 5.5 --- docs/TESTING.md | 10 ++ docs/TROUBLESHOOTING.md | 12 ++ patches/metal.patch | 276 ++++++++++++++++++++++++++++-- tools/build-riot-metal-runtime.py | 3 +- tools/test-metal-device-info.cpp | 89 ++++++++++ upstream/manifest.json | 1 + 6 files changed, 377 insertions(+), 14 deletions(-) create mode 100644 tools/test-metal-device-info.cpp diff --git a/docs/TESTING.md b/docs/TESTING.md index a1e6e80..4c3b06a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -14,6 +14,16 @@ python3 tools/riot-foundation-installer-offline-check.py python3 tools/riot-llvm-version-offline-check.py ``` +The Metal device-report check compiles on the host and needs no Darling: + +```sh +c++ -std=c++17 -I build/darling-src/src/external/metal/private-include \ + tools/test-metal-device-info.cpp -o build/test-metal-device-info +build/test-metal-device-info +``` + +Expected: `METAL_DEVICE_INFO failures=0 pass=1`. It covers how `MTLDevice.name` turns Vulkan names into macOS style (`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`, `Intel(R) UHD Graphics 630 (CML GT2)` becomes `Intel UHD Graphics 630`, NVIDIA names stay as they are), the `DARLING_METAL_VRAM_MB` parser, and the working-set sum over Vulkan heaps. + The LLVM version check compiles Iridium's actual dynamic loader against small stub libraries. It verifies that a matching major initializes, older/newer majors produce a version mismatch, and a missing library fails initialization. It does not start Darling. The native AIR translator (`python3 tools/build-riot-air-check.py --native`) selects both headers and the shared library from the same `llvm-config`; its build prints those paths so shader results can be attributed to the tested LLVM installation. The patch check uses temporary base indexes and does not reset or modify the source checkout. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, and preserves Darling runtime library/framework paths. The memory-installer check imports the installer and exercises its policy (default source is the prepared artifact; the artifact is never a destination of a different library) in temporary directories. The Foundation-installer check does the same for the overlay installer: it seeds a temporary framework from a stub SDK, replaces `Versions/C/Foundation`, keeps the standard symlinks, stages a backup, treats a re-run as current and refuses a non-Mach-O source. The installer handoff mock covers 28 scenarios, including HTTP 409, interrupted verification and preserving active downloads. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a44f5bd..973f429 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -234,6 +234,18 @@ A non-zero error or `FAILED` means the frames cannot reach the window. Before 20 An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Confirmation on the reporter's NVIDIA system is still needed. +### What Metal reports about the GPU + +`MTLDevice.name` is the selected Vulkan device's name in macOS style: the trailing driver note in brackets and the `(R)`/`(TM)` marks are removed (`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`, `Intel(R) UHD Graphics 630 (CML GT2)` becomes `Intel UHD Graphics 630`, `llvmpipe (LLVM …)` becomes `llvmpipe`). NVIDIA names are unchanged. The name is never replaced with another vendor's GPU. Before 2026-10-17 it was always `Darling Indium Device`. + +`recommendedMaxWorkingSetSize` is the size of the device-local Vulkan heaps (the VRAM) on a discrete GPU. On integrated and software GPUs it is the size of all heaps, because their device-local heap can be just the firmware carve-out while they also use system RAM; macOS likewise reports a large share of system RAM for unified-memory GPUs. It falls back to the old 1 GiB when Vulkan reports no usable heap. `hasUnifiedMemory` stays `YES` by default: with `NO`, apps switch to managed and private resource paths (`synchronizeResource:`, private buffers created with data) that are not implemented here. Set these in the game's environment to override: + +- `DARLING_METAL_GPU_NAME=`: report this name. +- `DARLING_METAL_VRAM_MB=`: report this working-set size in MiB. Other values are ignored. +- `DARLING_METAL_HOST_UNIFIED_MEMORY=1`: report `hasUnifiedMemory` from the GPU type (`YES` for integrated and software GPUs, `NO` for discrete ones). This is experimental; on a discrete GPU it can break rendering. + +Each process reads these once. + ### Host-side checks These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice. diff --git a/patches/metal.patch b/patches/metal.patch index d529851..adeb448 100644 --- a/patches/metal.patch +++ b/patches/metal.patch @@ -1,5 +1,5 @@ diff --git a/CMakeLists.txt b/CMakeLists.txt -index 431f29a..7f10875 100644 +index 431f29a..eb3eec8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,8 +42,10 @@ add_framework(Metal @@ -13,6 +13,17 @@ index 431f29a..7f10875 100644 src/Metal/MTLCommandQueue.mm src/Metal/MTLCompileOptions.mm src/Metal/MTLComputeCommandEncoder.mm +@@ -97,6 +99,10 @@ if (BUILD_METAL) + target_compile_definitions(Metal PUBLIC + DARLING_METAL_ENABLED=1 + ) ++ # MTLDevice.mm reads the Vulkan device properties from Indium's private headers. ++ target_link_libraries(Metal PRIVATE ++ indium_private ++ ) + endif() + + # diff --git a/include/Metal/MTLCommandBuffer.h b/include/Metal/MTLCommandBuffer.h index ef43df8..9045a84 100644 --- a/include/Metal/MTLCommandBuffer.h @@ -1123,10 +1134,10 @@ index 2a57796..0cbedf2 100644 - @end diff --git a/src/Metal/MTLDevice.mm b/src/Metal/MTLDevice.mm -index f58184a..6cfc697 100644 +index f58184a..a80a625 100644 --- a/src/Metal/MTLDevice.mm +++ b/src/Metal/MTLDevice.mm -@@ -2,13 +2,26 @@ +@@ -2,14 +2,32 @@ // SPDX-License-Identifier: MPL-2.0 #import @@ -1151,9 +1162,15 @@ index f58184a..6cfc697 100644 +#import +#include #import ++#import ++#if DARLING_METAL_ENABLED ++#include ++#include ++#endif MTL_EXTERN const MTLDeviceNotificationName MTLDeviceWasAddedNotification = @"MTLDeviceWasAdded"; -@@ -88,6 +101,37 @@ void MTLRemoveDeviceObserver(id observer) { + MTL_EXTERN const MTLDeviceNotificationName MTLDeviceRemovalRequestedNotification = @"MTLDeviceRemovalRequested"; +@@ -88,6 +106,37 @@ void MTLRemoveDeviceObserver(id observer) { [observer release]; }; @@ -1191,7 +1208,73 @@ index f58184a..6cfc697 100644 @implementation MTLDeviceInternal #if DARLING_METAL_ENABLED -@@ -153,12 +197,46 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -96,6 +145,9 @@ void MTLRemoveDeviceObserver(id observer) { + NSThread* _pollingThread; + NSCondition* _threadExitCondition; + BOOL _threadIsRunning; ++ NSString* _name; ++ uint64_t _workingSetSize; ++ BOOL _sharesSystemMemory; + } + + @synthesize device = _device; +@@ -116,6 +168,39 @@ void MTLRemoveDeviceObserver(id observer) { + [_threadExitCondition unlock]; + } + ++// Real host GPU facts, computed once per device in -initWithDevice: (games may query them per frame). ++- (void)loadHostDeviceInfo ++{ ++ // Overrides are read once per process. ++ static const char* const nameOverride = getenv("DARLING_METAL_GPU_NAME"); ++ static const uint64_t vramOverride = MTLDeviceInfo::parseMegabytes(getenv("DARLING_METAL_VRAM_MB")); ++ ++ std::string vulkanName = _device->name(); ++ _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::resolveName(nameOverride, vulkanName.c_str()).c_str()]; ++ if (_name == nil) { ++ _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::kFallbackName]; ++ } ++ ++ uint64_t workingSet = 0; ++ _sharesSystemMemory = NO; ++ if (auto priv = dynamic_cast(_device.get())) { ++ const VkPhysicalDeviceType type = priv->properties().deviceType; ++ _sharesSystemMemory = type == VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU || type == VK_PHYSICAL_DEVICE_TYPE_CPU; ++ const VkPhysicalDeviceMemoryProperties memory = priv->memoryProperties(); ++ MTLDeviceInfo::Heap heaps[VK_MAX_MEMORY_HEAPS]; ++ const size_t count = memory.memoryHeapCount < VK_MAX_MEMORY_HEAPS ? memory.memoryHeapCount : VK_MAX_MEMORY_HEAPS; ++ for (size_t i = 0; i < count; ++i) { ++ heaps[i] = { memory.memoryHeaps[i].size, (memory.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) != 0 }; ++ } ++ workingSet = MTLDeviceInfo::workingSetBytes(heaps, count, _sharesSystemMemory); ++ } ++ if (vramOverride != 0) { ++ workingSet = vramOverride; ++ } ++ // 1 GiB was the previous fixed value; keep it when Vulkan reports nothing usable. ++ _workingSetSize = workingSet != 0 ? workingSet : (1ull << 30); ++} ++ + - (instancetype)initWithDevice: (std::shared_ptr)device + { + self = [super init]; +@@ -123,6 +208,7 @@ void MTLRemoveDeviceObserver(id observer) { + _device = device; + _threadExitCondition = [NSCondition new]; + _threadIsRunning = NO; ++ [self loadHostDeviceInfo]; + _pollingThread = [[NSThread alloc] initWithTarget: self selector: @selector(pollingLoop) object: nil]; + [_pollingThread start]; + } +@@ -133,6 +219,7 @@ void MTLRemoveDeviceObserver(id observer) { + { + [_pollingThread release]; + [_threadExitCondition release]; ++ [_name release]; + + [super dealloc]; + } +@@ -153,12 +240,60 @@ void MTLRemoveDeviceObserver(id observer) { [_threadExitCondition unlock]; } @@ -1207,13 +1290,27 @@ index f58184a..6cfc697 100644 + +/* darling-lol M57: feature queries ANGLE's Metal backend needs. */ +- (BOOL)isDepth24Stencil8PixelFormatSupported { return _device->supportsDepth24Stencil8PixelFormat(); } -+- (BOOL)hasUnifiedMemory { return YES; } ++- (BOOL)hasUnifiedMemory ++{ ++ // Stays YES by default: with NO, ANGLE and games take the Managed/Private paths ++ // (synchronizeResource:, Private uploads) that this Metal/Indium does not fully implement. ++ // DARLING_METAL_HOST_UNIFIED_MEMORY=1 reports the host GPU type instead ++ // (YES for integrated and CPU devices, NO for discrete ones). ++ static const bool reportHost = [] { ++ const char* value = getenv("DARLING_METAL_HOST_UNIFIED_MEMORY"); ++ return value && value[0] == '1' && value[1] == '\0'; ++ }(); ++ return reportHost ? _sharesSystemMemory : YES; ++} +- (NSUInteger)maxBufferLength { return 256u * 1024u * 1024u; } -+- (NSUInteger)recommendedMaxWorkingSetSize { return 1ull << 30; } ++- (NSUInteger)recommendedMaxWorkingSetSize ++{ ++ return _workingSetSize > NSUIntegerMax ? NSUIntegerMax : static_cast(_workingSetSize); ++} +- (BOOL)isLowPower { return NO; } +- (BOOL)isRemovable { return NO; } +- (BOOL)isHeadless { return NO; } -+- (NSString*)name { return @"Darling Indium Device"; } ++- (NSString*)name { return _name; } +- (uint64_t)registryID { return 0; } +- (BOOL)supportsFamily: (NSUInteger)family { return YES; } +- (BOOL)supportsFeatureSet: (NSUInteger)featureSet { return YES; } @@ -1239,7 +1336,7 @@ index f58184a..6cfc697 100644 if (!pso) { if (error) { // TODO: better error and/or match what the official Metal method does -@@ -166,6 +244,7 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -166,6 +301,7 @@ void MTLRemoveDeviceObserver(id observer) { } return nil; } @@ -1247,7 +1344,7 @@ index f58184a..6cfc697 100644 return [[MTLComputePipelineStateInternal alloc] initWithState: pso device: self label: descriptor.label]; } -@@ -183,7 +262,13 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -183,7 +319,13 @@ void MTLRemoveDeviceObserver(id observer) { reflection: (MTLAutoreleasedComputePipelineReflection*)reflection error: (NSError**)error { @@ -1262,7 +1359,7 @@ index f58184a..6cfc697 100644 if (!pso) { if (error) { // TODO: better error and/or match what the official Metal method does -@@ -191,13 +276,44 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -191,13 +333,44 @@ void MTLRemoveDeviceObserver(id observer) { } return nil; } @@ -1308,7 +1405,7 @@ index f58184a..6cfc697 100644 if (!pso) { if (error) { // TODO: better error and/or match what the official Metal method does -@@ -205,7 +321,114 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -205,7 +378,114 @@ void MTLRemoveDeviceObserver(id observer) { } return nil; } @@ -1424,7 +1521,7 @@ index f58184a..6cfc697 100644 } - (id)newCommandQueue -@@ -275,8 +498,21 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -275,8 +555,21 @@ void MTLRemoveDeviceObserver(id observer) { - (id)newLibraryWithData: (dispatch_data_t)data error: (NSError**)error { @@ -3369,6 +3466,159 @@ new file mode 100644 +const std::shared_ptr& MTLDepthStencilGetIndium(id object); + +#endif +diff --git a/private-include/Metal/MTLDeviceInfo.h b/private-include/Metal/MTLDeviceInfo.h +new file mode 100644 +--- /dev/null ++++ b/private-include/Metal/MTLDeviceInfo.h +@@ -0,0 +1,148 @@ ++// SPDX-FileCopyrightText: 2026 Darling Developers ++// SPDX-License-Identifier: MPL-2.0 ++ ++#ifndef _METAL_MTLDEVICEINFO_H_ ++#define _METAL_MTLDEVICEINFO_H_ ++ ++// Host-testable helpers for the values MTLDevice reports about the GPU. ++// Plain C++ only (no Objective-C, Vulkan or Indium) so tools/test-metal-device-info.cpp ++// can compile this header on the host. ++ ++#include ++#include ++#include ++ ++namespace MTLDeviceInfo { ++ ++// Used when neither the cleaned nor the raw Vulkan name has any characters. ++static constexpr const char* kFallbackName = "Darling Indium Device"; ++ ++static inline bool isSpace(char c) { ++ return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; ++} ++ ++static inline std::string collapseWhitespace(const std::string& text) { ++ std::string out; ++ bool pendingSpace = false; ++ for (char c: text) { ++ if (isSpace(c)) { ++ pendingSpace = !out.empty(); ++ continue; ++ } ++ if (pendingSpace) { ++ out.push_back(' '); ++ pendingSpace = false; ++ } ++ out.push_back(c); ++ } ++ return out; ++} ++ ++static inline char lower(char c) { ++ return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; ++} ++ ++// Removes "(R)" and "(TM)" in any letter case (Intel's Vulkan names use both). ++static inline std::string removeTrademarks(const std::string& text) { ++ std::string out; ++ for (size_t i = 0; i < text.size();) { ++ if (text[i] == '(') { ++ if (i + 2 < text.size() && lower(text[i + 1]) == 'r' && text[i + 2] == ')') { ++ i += 3; ++ continue; ++ } ++ if (i + 3 < text.size() && lower(text[i + 1]) == 't' && lower(text[i + 2]) == 'm' && text[i + 3] == ')') { ++ i += 4; ++ continue; ++ } ++ } ++ out.push_back(text[i++]); ++ } ++ return out; ++} ++ ++// Drops one trailing parenthetical group (Mesa/driver annotation such as ++// " (RADV NAVI21)", " (CML GT2)" or " (LLVM 22.1.0, 256 bits)"), but never when ++// the group is the whole name. Expects whitespace already collapsed and trimmed. ++static inline std::string removeTrailingGroup(const std::string& text) { ++ if (text.empty() || text.back() != ')') { ++ return text; ++ } ++ int depth = 0; ++ for (size_t i = text.size(); i-- > 0;) { ++ if (text[i] == ')') { ++ ++depth; ++ } else if (text[i] == '(' && --depth == 0) { ++ std::string prefix = collapseWhitespace(text.substr(0, i)); ++ return prefix.empty() ? text : prefix; ++ } ++ } ++ return text; // unbalanced: leave it alone ++} ++ ++// Converts a VkPhysicalDeviceProperties::deviceName into macOS Metal style, ++// e.g. "AMD Radeon RX 6800 XT (RADV NAVI21)" -> "AMD Radeon RX 6800 XT" and ++// "Intel(R) UHD Graphics 630 (CML GT2)" -> "Intel UHD Graphics 630". ++// This only cleans the real name; it never substitutes another GPU. ++static inline std::string metalNameFromVulkanName(const char* vulkanName) { ++ std::string raw = vulkanName ? vulkanName : ""; ++ std::string cleaned = removeTrailingGroup(collapseWhitespace(removeTrademarks(raw))); ++ if (!cleaned.empty()) { ++ return cleaned; ++ } ++ std::string rawTrimmed = collapseWhitespace(raw); ++ return rawTrimmed.empty() ? std::string(kFallbackName) : rawTrimmed; ++} ++ ++// DARLING_METAL_GPU_NAME (used verbatim when non-empty) wins over the Vulkan name. ++static inline std::string resolveName(const char* overrideName, const char* vulkanName) { ++ if (overrideName && overrideName[0] != '\0') { ++ return overrideName; ++ } ++ return metalNameFromVulkanName(vulkanName); ++} ++ ++// Parses DARLING_METAL_VRAM_MB: a positive decimal integer of mebibytes, digits only. ++// Returns 0 (meaning "ignore") for anything else, including overflow. ++static inline uint64_t parseMegabytes(const char* text) { ++ if (!text || text[0] == '\0') { ++ return 0; ++ } ++ uint64_t megabytes = 0; ++ for (const char* p = text; *p; ++p) { ++ if (*p < '0' || *p > '9') { ++ return 0; ++ } ++ uint64_t digit = static_cast(*p - '0'); ++ if (megabytes > (UINT64_MAX / (1024ull * 1024ull) - digit) / 10) { ++ return 0; ++ } ++ megabytes = megabytes * 10 + digit; ++ } ++ return megabytes * 1024ull * 1024ull; ++} ++ ++struct Heap { ++ uint64_t size; ++ bool deviceLocal; ++}; ++ ++// Working-set size from the Vulkan memory heaps; 0 when nothing usable is reported. ++// Discrete GPUs report their device-local (VRAM) heaps. GPUs sharing system RAM ++// (integrated/CPU device types) report every heap: their device-local heap may be ++// only the firmware carve-out (RADV APUs) while the GPU can also use the system heap, ++// which matches macOS reporting a large share of system RAM for unified-memory GPUs. ++static inline uint64_t workingSetBytes(const Heap* heaps, size_t count, bool sharesSystemMemory) { ++ uint64_t total = 0; ++ for (size_t i = 0; i < count; ++i) { ++ if (!sharesSystemMemory && !heaps[i].deviceLocal) { ++ continue; ++ } ++ total = heaps[i].size > UINT64_MAX - total ? UINT64_MAX : total + heaps[i].size; ++ } ++ return total; ++} ++ ++} // namespace MTLDeviceInfo ++ ++#endif // _METAL_MTLDEVICEINFO_H_ diff --git a/private-include/Metal/MTLSamplerStateInternal.h b/private-include/Metal/MTLSamplerStateInternal.h new file mode 100644 --- /dev/null diff --git a/tools/build-riot-metal-runtime.py b/tools/build-riot-metal-runtime.py index 415bf4a..ed27f58 100644 --- a/tools/build-riot-metal-runtime.py +++ b/tools/build-riot-metal-runtime.py @@ -13,6 +13,8 @@ for line in (folder / 'CMakeFiles/Metal.dir/flags.make').read_text().splitlines( flags[key] = shlex.split(value) resource_include = subprocess.check_output(['clang++', '-print-resource-dir'], text=True).strip() + '/include' flags['CXX_INCLUDES'] = [resource_include if value.startswith('/usr/lib/clang/') and value.endswith('/include') else value for value in flags['CXX_INCLUDES']] +# MTLDevice.mm reads Vulkan device properties through Indium's private headers. +flags['CXX_INCLUDES'].append('-I' + str(root / 'build/darling-src/src/external/metal/deps/indium/private-include')) link = shlex.split((folder / 'CMakeFiles/Metal.dir/link.txt').read_text()) # These objects passed live League validation. Keep the broader resource/queue # optimization experiment out of the default build after fresh-match stalls. @@ -28,7 +30,6 @@ for name in ('MTLBuffer', 'MTLTexture', 'MTLDevice', 'MTLSamplerDescriptor', 'MT link.append(str(obj)) link[link.index('-o') + 1] = str(root / 'build/riot-Metal') subprocess.run(link, cwd=folder, check=True) -flags['CXX_INCLUDES'].append('-I' + str(root / 'build/darling-src/src/external/metal/deps/indium/private-include')) obj = root / 'build/riot-metal-sampler-check.o' subprocess.run(['clang++', *flags['CXX_DEFINES'], *flags['CXX_INCLUDES'], *flags['CXX_FLAGS'], '-c', str(root / 'tools/riot-metal-sampler-check.mm'), diff --git a/tools/test-metal-device-info.cpp b/tools/test-metal-device-info.cpp new file mode 100644 index 0000000..926dd75 --- /dev/null +++ b/tools/test-metal-device-info.cpp @@ -0,0 +1,89 @@ +// Host check for the GPU name and working-set helpers behind -[MTLDevice name] and +// -[MTLDevice recommendedMaxWorkingSetSize] (private-include/Metal/MTLDeviceInfo.h). +// Build and run after preparing the sources (see docs/TESTING.md): +// c++ -std=c++17 -I build/darling-src/src/external/metal/private-include +// tools/test-metal-device-info.cpp -o build/test-metal-device-info +// build/test-metal-device-info +#include +#include +#include + +static int failures = 0; + +static void expectName(const char* vulkan, const char* expected) { + std::string actual = MTLDeviceInfo::metalNameFromVulkanName(vulkan); + bool ok = actual == expected; + failures += !ok; + std::printf("%s name \"%s\" -> \"%s\"%s%s%s\n", ok ? "PASS" : "FAIL", vulkan ? vulkan : "(null)", + actual.c_str(), ok ? "" : " (expected \"", ok ? "" : expected, ok ? "" : "\")"); +} + +static void expectBytes(const char* what, unsigned long long actual, unsigned long long expected) { + bool ok = actual == expected; + failures += !ok; + std::printf("%s %s -> %llu%s\n", ok ? "PASS" : "FAIL", what, actual, ok ? "" : " (mismatch)"); + if (!ok) std::printf(" expected %llu\n", expected); +} + +int main() { + // Mesa and proprietary Vulkan names -> macOS Metal style. + expectName("AMD Radeon RX 6800 XT (RADV NAVI21)", "AMD Radeon RX 6800 XT"); + expectName("AMD Radeon RX 6650 XT (RADV NAVI23)", "AMD Radeon RX 6650 XT"); + expectName("AMD Radeon Graphics (RADV RENOIR)", "AMD Radeon Graphics"); + expectName("AMD Radeon Pro 5500M (RADV NAVI14)", "AMD Radeon Pro 5500M"); + expectName("AMD RADV NAVI21 (ACO)", "AMD RADV NAVI21"); + expectName("Intel(R) UHD Graphics 630 (CML GT2)", "Intel UHD Graphics 630"); + expectName("Intel(R) Arc(TM) A770 Graphics (DG2)", "Intel Arc A770 Graphics"); + expectName("NVIDIA GeForce GTX 680", "NVIDIA GeForce GTX 680"); + expectName("NVIDIA GeForce RTX 2070 SUPER", "NVIDIA GeForce RTX 2070 SUPER"); + expectName("NVIDIA GeForce RTX 4090", "NVIDIA GeForce RTX 4090"); + expectName("llvmpipe (LLVM 22.1.0, 256 bits)", "llvmpipe"); + // Whitespace, nesting and edge cases. + expectName(" AMD Radeon\tRX 7900 XTX (RADV NAVI31) ", "AMD Radeon RX 7900 XTX"); + expectName("Foo GPU (driver (nested) x)", "Foo GPU"); + expectName("Foo (Bar) GPU", "Foo (Bar) GPU"); // not trailing: kept + expectName("(RADV NAVI21)", "(RADV NAVI21)"); // whole name: kept + expectName("Broken (unbalanced", "Broken (unbalanced"); + expectName("(R)(TM)", "(R)(TM)"); // cleaned empty -> raw name + expectName(" ", MTLDeviceInfo::kFallbackName); // raw empty -> neutral fallback + expectName("", MTLDeviceInfo::kFallbackName); + expectName(nullptr, MTLDeviceInfo::kFallbackName); + + // DARLING_METAL_GPU_NAME override. + { + bool ok = MTLDeviceInfo::resolveName("My GPU", "AMD Radeon RX 6800 XT (RADV NAVI21)") == "My GPU" + && MTLDeviceInfo::resolveName("", "AMD Radeon RX 6800 XT (RADV NAVI21)") == "AMD Radeon RX 6800 XT" + && MTLDeviceInfo::resolveName(nullptr, "NVIDIA GeForce RTX 4090") == "NVIDIA GeForce RTX 4090"; + failures += !ok; + std::printf("%s name override\n", ok ? "PASS" : "FAIL"); + } + + // DARLING_METAL_VRAM_MB parsing: positive decimal integer only. + const unsigned long long MiB = 1024ull * 1024ull; + expectBytes("vram \"8192\"", MTLDeviceInfo::parseMegabytes("8192"), 8192 * MiB); + expectBytes("vram \"1\"", MTLDeviceInfo::parseMegabytes("1"), MiB); + expectBytes("vram \"0\"", MTLDeviceInfo::parseMegabytes("0"), 0); + expectBytes("vram \"\"", MTLDeviceInfo::parseMegabytes(""), 0); + expectBytes("vram null", MTLDeviceInfo::parseMegabytes(nullptr), 0); + expectBytes("vram \"-5\"", MTLDeviceInfo::parseMegabytes("-5"), 0); + expectBytes("vram \"8G\"", MTLDeviceInfo::parseMegabytes("8G"), 0); + expectBytes("vram \" 8\"", MTLDeviceInfo::parseMegabytes(" 8"), 0); + expectBytes("vram \"1.5\"", MTLDeviceInfo::parseMegabytes("1.5"), 0); + expectBytes("vram max", MTLDeviceInfo::parseMegabytes("17592186044415"), 17592186044415ull * MiB); + expectBytes("vram overflow", MTLDeviceInfo::parseMegabytes("17592186044416"), 0); + expectBytes("vram huge", MTLDeviceInfo::parseMegabytes("99999999999999999999999"), 0); + + // Working set from Vulkan heaps. + const MTLDeviceInfo::Heap discrete[] = {{8192 * MiB, true}, {16384 * MiB, false}, {256 * MiB, true}}; + expectBytes("discrete heaps", MTLDeviceInfo::workingSetBytes(discrete, 3, false), 8448 * MiB); + const MTLDeviceInfo::Heap apu[] = {{512 * MiB, true}, {15000 * MiB, false}}; + expectBytes("integrated heaps", MTLDeviceInfo::workingSetBytes(apu, 2, true), 15512 * MiB); + const MTLDeviceInfo::Heap hostOnly[] = {{4096 * MiB, false}}; + expectBytes("discrete without device-local heap", MTLDeviceInfo::workingSetBytes(hostOnly, 1, false), 0); + expectBytes("no heaps", MTLDeviceInfo::workingSetBytes(nullptr, 0, false), 0); + const MTLDeviceInfo::Heap huge[] = {{UINT64_MAX, true}, {UINT64_MAX, true}}; + expectBytes("saturating sum", MTLDeviceInfo::workingSetBytes(huge, 2, false), UINT64_MAX); + + std::printf("METAL_DEVICE_INFO failures=%d pass=%d\n", failures, failures == 0); + return failures == 0 ? 0 : 1; +} diff --git a/upstream/manifest.json b/upstream/manifest.json index bc13a46..1fff96e 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -244,6 +244,7 @@ "private-include/Metal/MTLArgumentInternal.h", "private-include/Metal/MTLBlitCommandEncoderInternal.h", "private-include/Metal/MTLDepthStencilInternal.h", + "private-include/Metal/MTLDeviceInfo.h", "private-include/Metal/MTLSamplerStateInternal.h", "private-include/Metal/MTLVertexDescriptorInternal.h", "src/Metal/MTLArgument.mm", From a1c00ee884f9a45f55ec8c1d1d6c40f5037cd530 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:03:32 +0200 Subject: [PATCH 056/160] Build Darling's system root store from the host CA bundle Security reads the system trust domain from /System/Library/Keychains/SystemRootCertificates.keychain and SystemTrustSettings.plist (TrustSettingsSchema.h). Darling ships neither, so SecTrustSettingsCopyCertificates(kSecTrustSettingsDomainSystem) returns errSecNoTrustSettings (-25263); Chromium's known_roots_mac and Riot's CertBundle then report that they cannot read the system roots. ensure-darling-root-certificates.py parses each host root with a minimal DER walker and writes the trust record keyed by the uppercase SHA-1, with the encoded issuer Name, the serial INTEGER's content octets, modDate and an empty (unrestricted) trustSettings array. The keychain is built with the guest security tool in one darling shell, in a private temporary directory, and published from inside the guest so a running Darling sees it, falling back to the host when the guest cannot write the directory. A digest stamp skips later runs without starting Darling. The launcher warns and continues on failure; install-runtime.sh runs it before stopping Darling. Co-Authored-By: Claude Opus 5.5 --- docs/TESTING.md | 1 + docs/TROUBLESHOOTING.md | 12 + scripts/install-runtime.sh | 3 + tests/test_root_certificates.py | 162 ++++++++++++ tools/ensure-darling-root-certificates.py | 285 ++++++++++++++++++++++ tools/riot-launcher-offline-check.py | 3 +- tools/run-riot-client.sh | 5 + 7 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 tests/test_root_certificates.py create mode 100644 tools/ensure-darling-root-certificates.py diff --git a/docs/TESTING.md b/docs/TESTING.md index a1e6e80..d7e4eb7 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -12,6 +12,7 @@ python3 tools/riot-launcher-offline-check.py python3 tools/riot-memory-installer-offline-check.py python3 tools/riot-foundation-installer-offline-check.py python3 tools/riot-llvm-version-offline-check.py +python3 -m unittest tests.test_root_certificates ``` The LLVM version check compiles Iridium's actual dynamic loader against small stub libraries. It verifies that a matching major initializes, older/newer majors produce a version mismatch, and a missing library fails initialization. It does not start Darling. The native AIR translator (`python3 tools/build-riot-air-check.py --native`) selects both headers and the shared library from the same `llvm-config`; its build prints those paths so shader results can be attributed to the tested LLVM installation. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a44f5bd..377d422 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -56,6 +56,18 @@ The installer fetcher no longer treats a missing archive `Info.plist` or the inf There is also a separate known compatibility gap: native LaunchAgent watch registration can return HTTP 409 after the League install path is accepted, leaving the download idle. A historical inspector hook handles this and interrupted `pending_verify` states in 28 mock cases, but that hook is disabled in normal launches and excluded from the manual workflow. There is no verified runtime-only replacement for that handoff in this snapshot. Do not assume the native permission and launcher fixes resolve every idle Install case, or enable debugger/API automation to work around it. +## `Unable to read system root certificates` or `Error -25263` + +Riot logs `CertBundle: Unable to read system root certificates` and Chromium logs `[known_roots_mac.cc(60)] Unable to determine trusted roots; assuming all roots are trusted! Error -25263` when the prefix has no system trust domain. Security reads that domain from `/System/Library/Keychains/SystemRootCertificates.keychain` and `SystemTrustSettings.plist`, which Darling does not ship, so `SecTrustSettingsCopyCertificates` returns `errSecNoTrustSettings`. + +`tools/ensure-darling-root-certificates.py` builds both from the host CA bundle (`/etc/ssl/cert.pem` or whichever bundle Python's `ssl` reports; `--bundle` overrides it). The launcher and `scripts/install-runtime.sh` run it. Each self-signed root gets an unrestricted trust entry. The keychain is built with the guest `security` tool in a single `darling shell`. A stamp in the same directory records the bundle's digest, so later launches skip it without starting Darling until the host bundle changes. If Darling was already running and the guest could not write the directory, the script installs the files on the host and says they apply after `darling shutdown`. A failure prints `Darling system root certificates were not installed: …` and the launch continues. To rebuild by hand: + +```sh +python3 build/runtime/tools/ensure-darling-root-certificates.py --force +``` + +`python3 -m unittest tests.test_root_certificates` compares the generated SHA-1 keys, issuer names and serial numbers with OpenSSL, and checks that an unchanged bundle is skipped. It uses stub `darling` and `security` commands. Reading the keychain and the system trust domain inside a real guest (`security find-certificate -a /System/Library/Keychains/SystemRootCertificates.keychain`, `security dump-trust-settings -s`) is not yet confirmed. + ## Riot closes after a network lookup is cancelled Two local crashes were traced to `CFNetwork::_HostCancel` calling `CFRetain(NULL)`. diff --git a/scripts/install-runtime.sh b/scripts/install-runtime.sh index ad26e21..fa150b6 100755 --- a/scripts/install-runtime.sh +++ b/scripts/install-runtime.sh @@ -13,6 +13,9 @@ darling shell /usr/bin/true # installed layer and every write there (the Riot installer's temp files) fails with EACCES. mkdir -p "$HOME/.darling/private/tmp" chmod 1777 "$HOME/.darling/private/tmp" +# Build the system root store now that the prefix exists; Darling is stopped next anyway. +python3 build/runtime/tools/ensure-darling-root-certificates.py || + echo 'Darling system root store not installed; the launcher retries it' >&2 python3 build/runtime/tools/stop-darling-runtime.py sh build/runtime/tools/install-riot-presentation-runtime.sh sh build/runtime/tools/install-riot-gpu-runtime.sh diff --git a/tests/test_root_certificates.py b/tests/test_root_certificates.py new file mode 100644 index 0000000..d802459 --- /dev/null +++ b/tests/test_root_certificates.py @@ -0,0 +1,162 @@ +"""Check the system root store generator against OpenSSL without starting Darling. + +A stub `darling` runs the guest script on the host with a stub `security` tool, so +the generated trust record, the guest/host hand-off and the idempotency stamp are +exercised; the real keychain database is not. +""" +import contextlib +import hashlib +import io +import os +from pathlib import Path +import plistlib +import re +import runpy +import shutil +import subprocess +import tempfile +import textwrap +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / 'tools/ensure-darling-root-certificates.py' +tool = runpy.run_path(str(SCRIPT)) + +DARLING_STUB = textwrap.dedent('''\ + #!/usr/bin/env python3 + import os, subprocess, sys + with open(os.environ['STUB_LOG'], 'a') as log: + log.write(' '.join(sys.argv[1:]) + '\\n') + if os.environ.get('STUB_FAIL'): + sys.exit(3) + assert sys.argv[1] == 'shell' + strip = lambda value: value[len('/Volumes/SystemRoot'):] if value.startswith('/Volumes/SystemRoot/') else value + env = dict(os.environ, PATH=os.environ['STUB_BIN'] + ':' + os.environ['PATH']) + sys.exit(subprocess.run([strip(arg) for arg in sys.argv[2:]], env=env).returncode) +''') + +SECURITY_STUB = textwrap.dedent('''\ + #!/usr/bin/env python3 + import hashlib, sys + command, args = sys.argv[1], sys.argv[2:] + if command == 'create-keychain': + open(args[-1], 'wb').close() + elif command == 'add-certificates': + keychain = args[args.index('-k') + 1] + with open(keychain, 'a') as stream: + for path in args[args.index('-k') + 2:]: + data = open(path, 'rb').read() + stream.write(hashlib.sha1(data).hexdigest().upper() + '\\n') + elif command == 'find-certificate': + for line in open(args[-1]): + print('keychain: "%s"' % args[-1]) + print('SHA-1 hash: ' + line.strip()) +''') + + +def openssl(der, *options): + return subprocess.run(['openssl', 'x509', '-inform', 'DER', '-noout', *options], input=der, + capture_output=True, check=True).stdout.decode() + + +def asn1_issuer(der): + """Issuer bytes located by OpenSSL's own ASN.1 parser: the fourth TBSCertificate field + (third without an explicit version).""" + lines = subprocess.run(['openssl', 'asn1parse', '-inform', 'DER'], input=der, + capture_output=True, check=True).stdout.decode().splitlines() + fields = [] + for line in lines: + match = re.match(r'\s*(\d+):d=(\d+)\s+hl=(\d+)\s+l=\s*(\d+)\s+(\w+):\s*(.*)', line) + if match and match.group(2) == '2': + fields.append(match) + if fields[0].group(6).startswith('cont [ 0 ]'): + fields = fields[1:] + offset, header, length = (int(fields[2].group(index)) for index in (1, 3, 4)) + return der[offset:offset + header + length] + + +@unittest.skipUnless(shutil.which('openssl'), 'openssl is required') +class RootCertificateTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.bundle = tool['default_bundle']() + self.data = self.bundle.read_bytes() + self.prefix = self.root / 'prefix' + self.prefix.mkdir() + stubs = self.root / 'bin' + stubs.mkdir() + for name, text in (('darling', DARLING_STUB), ('security', SECURITY_STUB)): + (stubs / name).write_text(text) + (stubs / name).chmod(0o755) + self.darling = [str(stubs / 'darling')] + self.log = self.root / 'darling.log' + self.log.touch() + self.environment = {'STUB_LOG': str(self.log), 'STUB_BIN': str(stubs)} + + def run_tool(self, bundle=None, **extra): + output = io.StringIO() + saved = dict(os.environ) + os.environ.update(self.environment, **extra) + try: + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + status = tool['main'](['--prefix', str(self.prefix), '--bundle', str(bundle or self.bundle), + '--darling', ' '.join(self.darling)]) + finally: + os.environ.clear() + os.environ.update(saved) + return status, output.getvalue() + + def test_fields_match_openssl(self): + roots = tool['load_roots'](self.data, report=lambda line: None) + self.assertGreater(len(roots), 50) + samples = [roots[0], roots[len(roots) // 2], roots[-1]] + # Include a serial whose INTEGER needs a leading zero octet, if the bundle has one. + samples += [cert for cert in roots if cert['serial'][0] == 0][:1] + for cert in samples: + fingerprint = openssl(cert['der'], '-fingerprint', '-sha1').split('=', 1)[1].strip() + self.assertEqual(cert['sha1'], fingerprint.replace(':', '')) + serial = openssl(cert['der'], '-serial').split('=', 1)[1].strip() + self.assertEqual(int.from_bytes(cert['serial'], 'big'), int(serial, 16)) + self.assertEqual(cert['issuer'], asn1_issuer(cert['der'])) + + def test_trust_record_and_idempotency(self): + status, output = self.run_tool() + self.assertEqual(status, 0, output) + folder = self.prefix / 'System/Library/Keychains' + record = plistlib.loads((folder / 'SystemTrustSettings.plist').read_bytes()) + self.assertEqual(record['trustVersion'], 1) + roots = tool['load_roots'](self.data, report=lambda line: None) + self.assertEqual(set(record['trustList']), {cert['sha1'] for cert in roots}) + for cert in roots[:5]: + entry = record['trustList'][cert['sha1']] + self.assertEqual(entry['issuerName'], cert['issuer']) + self.assertEqual(entry['serialNumber'], cert['serial']) + self.assertEqual(entry['trustSettings'], []) + self.assertEqual(hashlib.sha1(cert['der']).hexdigest().upper(), cert['sha1']) + self.assertEqual(len((folder / 'SystemRootCertificates.keychain').read_text().split()), len(roots)) + self.assertEqual(len(self.log.read_text().splitlines()), 1) + status, output = self.run_tool() + self.assertEqual((status, output), (0, '')) + self.assertEqual(len(self.log.read_text().splitlines()), 1, 'unchanged bundle restarted Darling') + # A changed bundle is rebuilt. + smaller = self.root / 'smaller.pem' + smaller.write_bytes(b''.join(re.findall(rb'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----\n?', + self.data, re.S)[:3])) + status, output = self.run_tool(smaller) + self.assertEqual(status, 0, output) + self.assertEqual(len(self.log.read_text().splitlines()), 2) + self.assertEqual(len(plistlib.loads((folder / 'SystemTrustSettings.plist').read_bytes())['trustList']), 3) + + def test_failure_is_reported_and_not_stamped(self): + status, output = self.run_tool(STUB_FAIL='1') + self.assertEqual(status, 1) + self.assertIn('Darling system root certificates were not installed', output) + self.assertFalse((self.prefix / 'System/Library/Keychains' / tool['STAMP']).exists()) + kept = re.search(r'see (\S+)/guest\.log', output) + self.assertTrue(kept, output) + shutil.rmtree(kept.group(1)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/ensure-darling-root-certificates.py b/tools/ensure-darling-root-certificates.py new file mode 100644 index 0000000..f5591b2 --- /dev/null +++ b/tools/ensure-darling-root-certificates.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""Give Darling's system trust domain the host's CA roots. + +Security.framework reads the system domain from two immutable files +(libsecurity_keychain TrustSettingsSchema.h): the root store +/System/Library/Keychains/SystemRootCertificates.keychain and its trust record +SystemTrustSettings.plist. Darling ships neither, so SecTrustSettingsCopyCertificates +for the system domain fails with errSecNoTrustSettings (-25263) and Chromium and +Riot's CertBundle report that they cannot read the system roots. + +The trust record is generated here from the host CA bundle. The keychain uses the +guest `security` tool, since no host-side writer for its database format exists. +Darling is only started when the bundle (or this script) changed; a stamp next to +the outputs records the input digest. +""" +import argparse +import base64 +import binascii +import datetime +import hashlib +import json +import os +from pathlib import Path +import plistlib +import re +import shutil +import ssl +import subprocess +import sys +import tempfile + +VERSION = 1 +KEYCHAIN = 'SystemRootCertificates.keychain' +TRUST_SETTINGS = 'SystemTrustSettings.plist' +STAMP = '.darling-lol-root-certificates.json' +SYSTEM_ROOT = '/Volumes/SystemRoot' +PEM = re.compile(rb'-----BEGIN CERTIFICATE-----\s*(.*?)\s*-----END CERTIFICATE-----', re.S) + +# One guest invocation: build the keychain in the private work directory, list what +# it holds, then publish both files from inside the guest so a running Darling sees +# them (host writes to the prefix are invisible to a mounted overlay). The guest +# cannot create directories in the root-owned base layer; the host then publishes. +GUEST_SCRIPT = r'''#!/bin/sh +set -u +PATH=$PATH:/usr/bin:/bin +work=$1 +kc=$work/SystemRootCertificates.keychain +security create-keychain -p '' "$kc" || exit 10 +security unlock-keychain -p '' "$kc" || exit 11 +# Certificates the guest cannot parse are reported and left out; the host checks found.txt. +security add-certificates -k "$kc" "$work"/certs/*.cer 2> "$work/add-errors.txt" +security find-certificate -a -Z "$kc" > "$work/found.txt" || exit 12 +grep -q 'SHA-1 hash:' "$work/found.txt" || exit 13 +dest=/System/Library/Keychains +if [ -d "$dest" ] && [ -w "$dest" ] && + cp "$kc" "$dest/.SystemRootCertificates.keychain.next" 2>/dev/null && + cp "$work/SystemTrustSettings.plist" "$dest/.SystemTrustSettings.plist.next" 2>/dev/null && + mv -f "$dest/.SystemRootCertificates.keychain.next" "$dest/SystemRootCertificates.keychain" && + mv -f "$dest/.SystemTrustSettings.plist.next" "$dest/SystemTrustSettings.plist"; then + : > "$work/placed" +else + rm -f "$dest/.SystemRootCertificates.keychain.next" "$dest/.SystemTrustSettings.plist.next" 2>/dev/null +fi +exit 0 +''' + + +class Failure(Exception): + pass + + +def default_bundle(): + paths = ssl.get_default_verify_paths() + for candidate in (paths.cafile, paths.openssl_cafile, '/etc/ssl/certs/ca-certificates.crt', + '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/ca-bundle.pem', '/etc/ssl/cert.pem'): + if candidate and Path(candidate).is_file(): + return Path(candidate) + raise Failure('no host CA bundle found; pass --bundle') + + +def der_element(data, offset): + """Return (tag, content start, end) of the DER element at offset.""" + if offset + 2 > len(data): + raise ValueError('truncated element') + tag = data[offset] + if tag & 0x1f == 0x1f: + raise ValueError('high tag numbers are not used in certificates') + length = data[offset + 1] + start = offset + 2 + if length & 0x80: + count = length & 0x7f + if count == 0 or count > 4 or start + count > len(data): + raise ValueError('unsupported length') + length = int.from_bytes(data[start:start + count], 'big') + start += count + end = start + length + if end > len(data): + raise ValueError('truncated element') + return tag, start, end + + +def parse_certificate(der): + """Fields TrustSettings::copyIssuerAndSerial stores: the issuer Name as encoded + (CSSMOID_X509V1IssuerNameStd) and the serial INTEGER's content octets.""" + tag, start, end = der_element(der, 0) + if tag != 0x30 or end != len(der): + raise ValueError('not a single DER certificate') + tag, offset, tbs_end = der_element(der, start) + if tag != 0x30: + raise ValueError('missing TBSCertificate') + tag, content, end = der_element(der, offset) + if tag == 0xa0: # explicit version + offset = end + tag, content, end = der_element(der, offset) + if tag != 0x02 or content == end: + raise ValueError('missing serial number') + serial = der[content:end] + offset = end + for name in ('signature', 'issuer', 'validity', 'subject'): + tag, content, end = der_element(der, offset) + if tag != 0x30 or end > tbs_end: + raise ValueError(f'missing {name}') + if name == 'issuer': + issuer = der[offset:end] + elif name == 'subject': + subject = der[offset:end] + offset = end + return {'sha1': hashlib.sha1(der).hexdigest().upper(), 'issuer': issuer, + 'serial': serial, 'subject': subject, 'der': der} + + +def load_roots(data, report=print): + roots, seen, identities, skipped = [], set(), {}, [] + for index, match in enumerate(PEM.finditer(data)): + try: + der = base64.b64decode(b''.join(match.group(1).split()), validate=True) + cert = parse_certificate(der) + except (binascii.Error, ValueError) as error: + skipped.append(f'#{index + 1}: unreadable ({error})') + continue + if cert['sha1'] in seen: + continue + seen.add(cert['sha1']) + if cert['subject'] != cert['issuer']: + skipped.append(f'{cert["sha1"]}: not self-issued, not a root') + continue + # The keychain keys certificates by issuer and serial; a second one is a duplicate item. + identity = (cert['issuer'], cert['serial']) + if identity in identities: + skipped.append(f'{cert["sha1"]}: same issuer and serial as {identities[identity]}') + continue + identities[identity] = cert['sha1'] + roots.append(cert) + for line in skipped: + report(f'Root certificates: skipped {line}') + return roots + + +def trust_settings(roots, now): + # An empty trustSettings array means "trusted as root for every use" + # (TrustSettings::evaluateCert / qualifyUsageWithCertDict, numSpecs == 0). + return plistlib.dumps({ + 'trustVersion': 1, + 'trustList': {cert['sha1']: {'issuerName': cert['issuer'], 'serialNumber': cert['serial'], + 'modDate': now, 'trustSettings': []} for cert in roots}, + }, fmt=plistlib.FMT_XML, sort_keys=True) + + +def bundle_digest(data): + return hashlib.sha256(b'darling-root-certificates-v%d\0' % VERSION + data).hexdigest() + + +def current(folder, digest): + try: + stamp = json.loads((folder / STAMP).read_text()) + except (OSError, ValueError): + return False + return (stamp.get('digest') == digest and (folder / KEYCHAIN).is_file() + and (folder / TRUST_SETTINGS).is_file()) + + +def server_running(prefix): + try: + pid = int((prefix / '.init.pid').read_text().strip()) + status = (Path('/proc') / str(pid) / 'status').read_text() + except (OSError, ValueError): + return False + return not any(line.split()[1:2] == ['Z'] for line in status.splitlines() if line.startswith('State:')) + + +def publish_host(work, folder): + folder.mkdir(parents=True, exist_ok=True) + for name in (KEYCHAIN, TRUST_SETTINGS): + temporary = folder / f'.{name}.next' + shutil.copyfile(work / name, temporary) + os.chmod(temporary, 0o644) + os.replace(temporary, folder / name) + + +def write_stamp(folder, digest, bundle, roots, present): + temporary = folder / (STAMP + '.next') + temporary.write_text(json.dumps({'version': VERSION, 'digest': digest, 'bundle': str(bundle), + 'roots': len(roots), 'in_keychain': len(present)}, indent=1) + '\n') + os.replace(temporary, folder / STAMP) + + +def ensure(prefix, bundle, darling, force=False, timeout=600): + data = bundle.read_bytes() + digest = bundle_digest(data) + folder = prefix / 'System/Library/Keychains' + if not force and current(folder, digest): + return 0 + roots = load_roots(data) + if not roots: + raise Failure(f'no root certificates in {bundle}') + now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0, tzinfo=None) + work = Path(tempfile.mkdtemp(prefix='darling-root-certificates-')).resolve() + keep = True + try: + (work / 'certs').mkdir() + for index, cert in enumerate(roots): + (work / 'certs' / f'{index:04d}.cer').write_bytes(cert['der']) + (work / TRUST_SETTINGS).write_bytes(trust_settings(roots, now)) + script = work / 'build.sh' + script.write_text(GUEST_SCRIPT) + # Before Darling starts, the overlay picks up a host-created directory, which + # lets the guest publish into it. + running = server_running(prefix) + if not running: + folder.mkdir(parents=True, exist_ok=True) + print(f'Building Darling system root store from {len(roots)} certificates in {bundle}', flush=True) + try: + with open(work / 'guest.log', 'wb') as log: + result = subprocess.run([*darling, 'shell', '/bin/sh', SYSTEM_ROOT + str(script), + SYSTEM_ROOT + str(work)], stdin=subprocess.DEVNULL, + stdout=log, stderr=subprocess.STDOUT, timeout=timeout) + except (OSError, subprocess.SubprocessError) as error: + raise Failure(f'could not run the guest security tool: {error}; work files kept in {work}') + if result.returncode or not (work / 'found.txt').is_file(): + raise Failure(f'guest keychain build failed (exit {result.returncode}); see {work}/guest.log') + found = set(re.findall(r'SHA-1 hash:\s*([0-9A-Fa-f]{40})', (work / 'found.txt').read_text(errors='replace'))) + found = {value.upper() for value in found} + wanted = {cert['sha1'] for cert in roots} + present = wanted & found + if not present: + raise Failure(f'the guest keychain holds none of the certificates; see {work}') + if present != wanted: + print(f'Root certificates: the guest rejected {len(wanted - present)} of {len(wanted)}; ' + f'see {work}/add-errors.txt', file=sys.stderr, flush=True) + # Kept for inspection; the outputs are still published. + else: + keep = False + if not (work / 'placed').exists(): + # A running guest could not see a new directory; publish on the host for + # the next Darling start. + publish_host(work, folder) + if running: + print('Darling system root store installed; it takes effect after `darling shutdown`', + file=sys.stderr, flush=True) + write_stamp(folder, digest, bundle, roots, present) + print(f'Darling system root store has {len(present)} certificates', flush=True) + return 0 + except OSError as error: + raise Failure(f'{error}; work files kept in {work}') + finally: + if not keep: + shutil.rmtree(work, ignore_errors=True) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) + parser.add_argument('--bundle', type=Path, help='PEM CA bundle (default: the host system bundle)') + parser.add_argument('--prefix', type=Path, default=Path(os.environ.get('DPREFIX', str(Path.home() / '.darling')))) + parser.add_argument('--darling', default='darling', help='Darling command (for tests)') + parser.add_argument('--force', action='store_true', help='rebuild even when the stamp is current') + args = parser.parse_args(argv) + try: + return ensure(args.prefix.expanduser(), args.bundle or default_bundle(), args.darling.split(), args.force) + except (Failure, OSError) as error: + print(f'Darling system root certificates were not installed: {error}', file=sys.stderr, flush=True) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/riot-launcher-offline-check.py b/tools/riot-launcher-offline-check.py index 248444d..717f09a 100644 --- a/tools/riot-launcher-offline-check.py +++ b/tools/riot-launcher-offline-check.py @@ -60,7 +60,8 @@ with open({str(log)!r}, "a") as output: records = [json.loads(line) for line in log.read_text().splitlines()] maintenance = [r for r in records if r["command"] == "python3"] assert [Path(r["args"][0]).name for r in maintenance] == [ - "ensure-darling-runtime.py", "ensure-darling-service-accounts.py", "ensure-league-runtime-layout.py" + "ensure-darling-runtime.py", "ensure-darling-service-accounts.py", "ensure-league-runtime-layout.py", + "ensure-darling-root-certificates.py", ], "unexpected automatic helper" launches = [r for r in records if r["command"] == "darling"] assert len(launches) == 1, "unexpected launch count" diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 74b480c..5789d78 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -11,6 +11,11 @@ runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) python3 "$runner_dir/ensure-darling-runtime.py" python3 "$runner_dir/ensure-darling-service-accounts.py" python3 "$runner_dir/ensure-league-runtime-layout.py" +# Security's system trust domain (SecTrustSettingsCopyCertificates) needs a root keychain and +# trust record built from the host CA bundle. Only a changed bundle starts Darling; a failure +# is reported but does not stop the launch. +python3 "$runner_dir/ensure-darling-root-certificates.py" || + echo 'Continuing without a Darling system root store; TLS clients may report untrusted roots' >&2 # Discard argument-rewriting settings from the former injected shim. unset RCLOL_APP_ARGS RCLOL_APP_ARGS_SLOT RCLOL_APP_ARG2 RCLOL_APP_ARG2_SLOT unset RCLOL_APP_ARG3 RCLOL_APP_ARG3_SLOT RCLOL_UX_ARG1 RCLOL_UX_ARG1_SLOT From 9adfaf010f3ae38285c3f2b2a340556c000529a2 Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sat, 17 Oct 2026 22:09:29 +0200 Subject: [PATCH 057/160] fix(setup): use consistent runtime paths and reject incomplete installs --- docs/SETUP.md | 17 +- scripts/configure.py | 7 +- scripts/install-runtime.sh | 10 +- tests/test_setup_portability.py | 222 +++++++++++++++++++++ tools/check-darwin-tlv-calls.py | 3 + tools/install-darling-system-profiler.py | 6 +- tools/install-riot-appkit-runtime.sh | 2 +- tools/install-riot-carboncore-runtime.sh | 4 +- tools/install-riot-foundation-runtime.py | 9 +- tools/install-riot-gpu-runtime.sh | 27 +-- tools/install-riot-libs-runtime.py | 15 +- tools/install-riot-memory-runtime.py | 9 +- tools/install-riot-presentation-runtime.sh | 14 +- tools/riot-launcher-offline-check.py | 6 +- tools/run-riot-client.sh | 16 +- tools/runtime-paths.sh | 18 ++ tools/runtime_paths.py | 24 +++ tools/validate-riot-runtime.py | 78 ++++++++ 18 files changed, 439 insertions(+), 48 deletions(-) create mode 100644 tests/test_setup_portability.py create mode 100644 tools/runtime-paths.sh create mode 100644 tools/runtime_paths.py create mode 100644 tools/validate-riot-runtime.py diff --git a/docs/SETUP.md b/docs/SETUP.md index 1b5a89a..10d22b9 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -12,7 +12,22 @@ python3 scripts/configure.py --guest-user my-linux-user python3 scripts/configure.py --sdk /usr/local/libexec/darling ``` -The normal wrappers currently support the default `~/.darling` prefix and `/usr/local` base installation. The configurator exposes a custom prefix for developers, but several archived tools assume the default layout. Custom prefixes/install locations have not been tested end to end. +The runtime installation wrappers resolve `DPREFIX` first, otherwise the saved +`configure.py --prefix` setting. The configured guest account supplies the +default guest home. `RIOT_GUEST_HOME`, when set, is an absolute **host** path +inside the selected prefix, not a guest `/Users/...` path. Installers, runtime +validation and the Riot launcher's guest library paths use that selection. +Paths containing spaces are supported by isolated regression fixtures. Explicit +environment overrides win over saved configuration; clear stale values before +selecting a different installation. + +A complete real installation using a custom prefix has not been verified. +Archived tools may still assume the default layout. Runtime validation fails on +missing expected binaries, unreadable/missing directories or checker errors; +an empty input set is not a successful validation. + +Runtime installation requires the selected runtime to stop before overlay +replacement. Failed shutdown blocks replacement. The build uses Unix Makefiles because the incremental runtime helpers read CMake `flags.make` and `link.txt`. Ninja is not a drop-in replacement for those helpers. diff --git a/scripts/configure.py b/scripts/configure.py index 3ea8d2f..7fcce7b 100755 --- a/scripts/configure.py +++ b/scripts/configure.py @@ -17,7 +17,12 @@ build=ROOT/'build';build.mkdir(exist_ok=True) folder=build/'runtime/tools';folder.mkdir(parents=True,exist_ok=True) for source in (ROOT/'tools').iterdir(): if not source.is_file():continue - target=folder/source.name;target.write_text(render(source.read_text(),c));os.chmod(target,source.stat().st_mode & 0o777) + target=folder/source.name + # Unlink the directory entry, including dangling symlinks. Any other error + # must abort rather than following a leftover link or writing through it. + target.unlink(missing_ok=True) + target.write_text(render(source.read_text(),c)) + os.chmod(target,source.stat().st_mode & 0o777) link=folder.parent/'build' if not link.exists():link.symlink_to(build,target_is_directory=True) print('Configured',len(list(folder.iterdir())),'runtime sources for',c['guest_home']) diff --git a/scripts/install-runtime.sh b/scripts/install-runtime.sh index ad26e21..4bb394f 100755 --- a/scripts/install-runtime.sh +++ b/scripts/install-runtime.sh @@ -4,6 +4,7 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" python3 scripts/configure.py +. build/runtime/tools/runtime-paths.sh 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 @@ -11,8 +12,9 @@ python3 build/runtime/tools/ensure-darling-runtime.py darling shell /usr/bin/true # The prefix has no /private/tmp of its own, so the guest falls through to the root-owned # installed layer and every write there (the Riot installer's temp files) fails with EACCES. -mkdir -p "$HOME/.darling/private/tmp" -chmod 1777 "$HOME/.darling/private/tmp" +prefix=$prefix_dir +mkdir -p "$prefix/private/tmp" +chmod 1777 "$prefix/private/tmp" python3 build/runtime/tools/stop-darling-runtime.py sh build/runtime/tools/install-riot-presentation-runtime.sh sh build/runtime/tools/install-riot-gpu-runtime.sh @@ -23,6 +25,4 @@ if [ -f build/runtime/tools/build-riot-libs-runtime.sh ]; then fi python3 build/runtime/tools/install-darling-system-profiler.py # 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 +python3 build/runtime/tools/validate-riot-runtime.py diff --git a/tests/test_setup_portability.py b/tests/test_setup_portability.py new file mode 100644 index 0000000..47ded2b --- /dev/null +++ b/tests/test_setup_portability.py @@ -0,0 +1,222 @@ +"""Filesystem/subprocess fixtures: never invoke a host runtime or compiler.""" +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] + + +VALIDATION_INPUTS = ( + 'RuntimeFrameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib', + 'libDarlingXShm.dylib', + *(f'RuntimeLibraries/{name}.dylib' for name in ( + 'libsystem_kernel', 'libsystem_c', 'libsystem_platform', 'libsystem_malloc', + 'libsystem_pthread', 'libobjc.A', 'libc++abi', 'libc++.1', + 'libGL', 'libindium', 'libiridium')), + *(f'RuntimeFrameworks/{name}.framework/Versions/{version}/{name}' for name, version in ( + ('AppKit', 'C'), ('QuartzCore', 'A'), ('CoreFoundation', 'A'), + ('SystemConfiguration', 'A'), ('CFNetwork', 'A'), ('IOSurface', 'A'), + ('OpenGL', 'A'), ('CoreVideo', 'A'), ('CoreAudio', 'A'), ('AudioToolbox', 'A'), + ('CoreGraphics', 'A'), ('CoreText', 'A'), ('Metal', 'A'), ('CarbonCore', 'A'), + ('LaunchServices', 'A'))), + 'RuntimeFrameworks/AppKit.framework/Versions/C/Resources/Backends/X11.backend/Contents/MacOS/X11', + 'RuntimeFrameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib', + 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component/Contents/MacOS/CoreAudio', +) + + +class SetupFixture(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='setup fixture ', dir=os.environ.get('TMPDIR')) + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) / 'project space' + self.root.mkdir() + for name in ('scripts', 'tools'): + shutil.copytree(ROOT / name, self.root / name) + self.home = Path(self.tmp.name) / 'host home' + self.home.mkdir() + self.prefix = Path(self.tmp.name) / 'custom prefix' + self.guest = self.prefix / 'Users/Guest Account' + self.bin = Path(self.tmp.name) / 'bin' + self.bin.mkdir() + self.env = dict(os.environ, HOME=str(self.home), TMPDIR=self.tmp.name, + DPREFIX=str(self.prefix), PYTHONDONTWRITEBYTECODE='1', + PATH=str(self.bin) + ':' + os.environ['PATH']) + self.config = dict(project_root=str(self.root), host_home=str(self.home), + prefix=str(self.prefix), guest_user='Guest Account', + guest_home='/Users/Guest Account', sdk=str(self.root / 'sdk'), jobs=1) + (self.root / 'build').mkdir() + (self.root / 'build/config.json').write_text(json.dumps(self.config)) + self.log = Path(self.tmp.name) / 'events' + self.env['FIXTURE_LOG'] = str(self.log) + + + def run_script(self, path, *args): + return subprocess.run([sys.executable if path.endswith('.py') else 'sh', + str(self.root / path), *args], env=self.env, + text=True, capture_output=True, timeout=20) + + + def configure(self): + result = self.run_script('scripts/configure.py') + self.assertEqual(result.returncode, 0, result.stderr) + + + def stub(self, name, body): + path = self.bin / name + path.write_text('#!/bin/sh\nset -eu\n' + body + '\n') + path.chmod(0o755) + + + def runtime_stubs(self): + # Nested Python is a strict allowlist: no accidental live helper execution. + self.stub('python3', '''printf 'python %s\\n' "$*" >> "$FIXTURE_LOG" +case "$1" in + -|*runtime_paths.py) exec REAL_PYTHON "$@";; + *stop-darling-runtime.py) [ "${STOP_FAIL:-0}" = 0 ] || exit 9; printf stopped\\n >> "$FIXTURE_LOG";; + *build-*.py|*ensure-darling-runtime.py|*install-darling-system-profiler.py|*install-riot-*.py) :;; + *) exit 90;; +esac'''.replace('REAL_PYTHON', sys.executable)) + self.stub('darling', '''printf 'darling %s\\n' "$*" >> "$FIXTURE_LOG" +if [ "$1" = shutdown ]; then sleep "${SHUTDOWN_DELAY:-0}"; exit "${SHUTDOWN_FAIL:-0}"; fi''') + self.stub('clang', '''last=; for arg do if [ "$last" = -o ]; then printf binary > "$arg"; fi; last=$arg; done''') + self.stub('sudo', 'exit 1') + self.stub('make', ':') + self.stub('cc', 'exec clang "$@"') + self.stub('pkg-config', ':') + self.stub('install', '''case "$(cat "$FIXTURE_LOG")" in *stopped*) :;; *) exit 88;; esac +exec REAL_INSTALL "$@"'''.replace('REAL_INSTALL', shutil.which('install'))) + + + def test_configure_replaces_readonly_and_repeats(self): + self.configure() + generated = self.root / 'build/runtime/tools/run-riot-client.sh' + generated.chmod(0o444) + self.configure() + self.configure() + self.assertTrue(generated.stat().st_mode & 0o200) + + + def test_configure_unlinks_dangling_symlink_without_writing_victim(self): + self.configure() + target = self.root / 'build/runtime/tools/run-riot-client.sh' + target.unlink() + victim = self.home / 'must not be created' + target.symlink_to(victim) + self.configure() + self.assertFalse(victim.exists(), 'configure followed the dangling generated-file symlink') + self.assertFalse(target.is_symlink()) + + + def test_presentation_creates_prerequisites_and_repeat_install(self): + self.configure() + self.runtime_stubs() + self.presentation_inputs() + for _ in range(2): + result = self.run_script('build/runtime/tools/install-riot-presentation-runtime.sh') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((self.guest / 'libDarlingXShm.dylib').is_file()) + self.assertTrue((self.guest / 'RuntimeLibraries/libsystem_kernel.dylib').is_file()) + self.assertIn('stopped', self.log.read_text()) + + + def presentation_inputs(self): + for name in ('external/cocotron/AppKit/AppKit', 'external/cocotron/AppKit/X11', + 'external/cocotron/QuartzCore/QuartzCore', + 'external/corefoundation/CoreFoundation', + 'external/configd/SystemConfiguration.fproj/SystemConfiguration', + 'external/cfnetwork/src/CFNetwork', + 'external/libplatform/libsystem_platform.dylib', + 'external/xnu/darling/src/libsystem_kernel/libsystem_kernel.dylib'): + path = self.root / 'build/darling-src/build/src' / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('library') + for framework, version in (('AppKit', 'C'), ('QuartzCore', 'A'), + ('CoreFoundation', 'A'), ('SystemConfiguration', 'A'), + ('CFNetwork', 'A')): + path = self.root / 'sdk/System/Library/Frameworks' / (framework + '.framework') + binary = path / 'Versions' / version / framework + binary.parent.mkdir(parents=True) + binary.write_text('sdk binary') + binary.chmod(0o555) + (path / 'Versions/Current').symlink_to(version) + (path / framework).symlink_to('Versions/Current/' + framework) + if framework == 'AppKit': + backend = binary.parent / 'Resources/Backends/X11.backend/Contents/MacOS/X11' + backend.parent.mkdir(parents=True) + backend.write_text('sdk backend') + p = self.root / 'build/libcxx-install/lib' + p.mkdir(parents=True) + (p / 'libc++.1.0.dylib').write_text('library') + + + def prepare_validation(self): + self.configure() + for relative in VALIDATION_INPUTS: + path = self.guest / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'\0' * 5000) + self.stub('llvm-objdump', '''printf '%s\\n' "$*" >> "$FIXTURE_LOG" +exit "${CHECK_FAIL:-0}"''') + + + def test_launcher_selects_paths_before_prefix_helpers(self): + source = (ROOT / 'tools/run-riot-client.sh').read_text() + self.assertLess(source.index('runtime-paths.sh'), source.index('ensure-darling-runtime.py')) + + def test_paths_reject_symlink_escape(self): + self.configure() + outside = self.home / 'outside' + outside.mkdir() + self.prefix.mkdir(parents=True, exist_ok=True) + (self.prefix / 'escape').symlink_to(outside, target_is_directory=True) + self.env['RIOT_GUEST_HOME'] = str(self.prefix / 'escape/new-user') + result = self.run_script('build/runtime/tools/validate-riot-runtime.py') + self.assertNotEqual(result.returncode, 0) + self.assertIn('inside DPREFIX', result.stderr) + result = self.run_script('build/runtime/tools/runtime-paths.sh') + self.assertNotEqual(result.returncode, 0) + + def test_validation_missing_empty_and_checker_failure(self): + self.prepare_validation() + self.env['CHECK_FAIL'] = '1' + self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) + self.env.pop('CHECK_FAIL') + vdsp = self.guest / 'RuntimeFrameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib' + vdsp.unlink(missing_ok=True) + self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) + shutil.rmtree(self.guest) + self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) + + def test_validation_uses_host_guest_override(self): + self.prepare_validation() + override = self.prefix / 'Users/Override Guest' + self.guest.rename(override) + self.env['RIOT_GUEST_HOME'] = str(override) + result = self.run_script('build/runtime/tools/validate-riot-runtime.py') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(str(override), self.log.read_text()) + + + def test_failed_stop_with_all_compile_outputs_available(self): + self.configure() + self.runtime_stubs() + self.presentation_inputs() + self.guest.mkdir(parents=True) + self.env['RIOT_GUEST_HOME'] = str(self.guest) + self.env.update(STOP_FAIL='1', SHUTDOWN_FAIL='1') + result = self.run_script('build/runtime/tools/install-riot-presentation-runtime.sh') + self.assertNotEqual(result.returncode, 0) + self.assertIn('stop-darling-runtime.py', self.log.read_text()) + self.assertFalse((self.guest / 'libDarlingXShm.dylib').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/check-darwin-tlv-calls.py b/tools/check-darwin-tlv-calls.py index c23a4f2..74cca28 100644 --- a/tools/check-darwin-tlv-calls.py +++ b/tools/check-darwin-tlv-calls.py @@ -51,6 +51,9 @@ def bad_accesses(path): return found +if len(sys.argv) < 2: + sys.exit('No runtime binaries supplied for validation') + status = 0 for binary in sys.argv[1:]: problems = bad_accesses(binary) diff --git a/tools/install-darling-system-profiler.py b/tools/install-darling-system-profiler.py index c6b6fbe..9ba07b9 100644 --- a/tools/install-darling-system-profiler.py +++ b/tools/install-darling-system-profiler.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """Install the host-backed system_profiler into the Darling prefix (no sudo).""" -import os +import json from pathlib import Path import shutil +from runtime_paths import runtime_paths + root = Path(__file__).resolve().parent -prefix = Path(os.environ.get("DPREFIX", str(Path.home() / ".darling"))) +prefix, _ = runtime_paths(json.loads((root.parent / "build/config.json").read_text())) target = prefix / "usr/sbin/system_profiler" target.parent.mkdir(parents=True, exist_ok=True) staged = target.with_name(target.name + ".new") diff --git a/tools/install-riot-appkit-runtime.sh b/tools/install-riot-appkit-runtime.sh index 18ecc50..376af0c 100755 --- a/tools/install-riot-appkit-runtime.sh +++ b/tools/install-riot-appkit-runtime.sh @@ -3,7 +3,7 @@ # Manually exit the game and both clients, then shut down Darling before use. set -eu project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -guest_home=${RIOT_GUEST_HOME:-$HOME/.darling@GUEST_HOME@} +. "$project_dir/tools/runtime-paths.sh" framework="$guest_home/RuntimeFrameworks/AppKit.framework/Versions/C" backup_dir=${RIOT_APPKIT_BACKUP_DIR:-$project_dir/build/appkit-runtime-backups} # Preflight both artifacts and destinations before replacing either binary. diff --git a/tools/install-riot-carboncore-runtime.sh b/tools/install-riot-carboncore-runtime.sh index de9d6b0..f15cdcc 100755 --- a/tools/install-riot-carboncore-runtime.sh +++ b/tools/install-riot-carboncore-runtime.sh @@ -3,8 +3,7 @@ # Exit the game and both clients, then run darling shutdown before this script. set -eu project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -prefix_dir=${DPREFIX:-@DPREFIX@} -guest_home=${RIOT_GUEST_HOME:-$prefix_dir@GUEST_HOME@} +. "$project_dir/tools/runtime-paths.sh" artifact=${RIOT_CARBONCORE_ARTIFACT:-$project_dir/build/riot-CarbonCore} backup_dir=${RIOT_CARBONCORE_BACKUP_DIR:-$project_dir/build/carboncore-runtime-backups} sdk_framework="@DARLING_SDK@/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/CarbonCore.framework" @@ -12,6 +11,7 @@ framework="$guest_home/RuntimeFrameworks/CarbonCore.framework" prefix_binary="$prefix_dir/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/CarbonCore.framework/Versions/A/CarbonCore" [ -f "$artifact" ] || { echo "Build CarbonCore first: $artifact" >&2; exit 1; } [ -f "$sdk_framework/Versions/A/CarbonCore" ] || { echo "Missing SDK CarbonCore framework" >&2; exit 1; } +python3 "$project_dir/tools/stop-darling-runtime.py" mkdir -p "$guest_home/RuntimeFrameworks" "$backup_dir" if [ ! -d "$framework" ]; then cp -a "$sdk_framework" "$framework" diff --git a/tools/install-riot-foundation-runtime.py b/tools/install-riot-foundation-runtime.py index 4f803ed..2b5a2b1 100644 --- a/tools/install-riot-foundation-runtime.py +++ b/tools/install-riot-foundation-runtime.py @@ -17,7 +17,9 @@ import shutil import stat import tempfile -ROOT = Path(__file__).resolve().parent.parent +from runtime_paths import runtime_paths + +ROOT = Path(__file__).resolve().parents[1] DEFAULT_ARTIFACT = ROOT / "build/riot-Foundation" @@ -110,9 +112,8 @@ def main(): parser.exit(1, "The library to install does not exist: " + str(args.binary) + "; build it first: " "python3 build/runtime/tools/build-riot-foundation-runtime.py\n") config = json.loads((ROOT / "build/config.json").read_text()) - prefix = Path(config["prefix"]) - guest_home = config["guest_home"].lstrip("/") - framework = prefix / guest_home / "RuntimeFrameworks/Foundation.framework" + _, guest_home = runtime_paths(config) + framework = guest_home / "RuntimeFrameworks/Foundation.framework" sdk_framework = Path(config["sdk"]) / "System/Library/Frameworks/Foundation.framework" if not sdk_framework.exists(): parser.exit(1, "The SDK framework does not exist: " + str(sdk_framework) + "\n") diff --git a/tools/install-riot-gpu-runtime.sh b/tools/install-riot-gpu-runtime.sh index d7baf18..03057bd 100755 --- a/tools/install-riot-gpu-runtime.sh +++ b/tools/install-riot-gpu-runtime.sh @@ -2,8 +2,9 @@ # Build/install the paired GPU, NV12 and display-clock runtime overrides. set -eu project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -guest_home=${RIOT_GUEST_HOME:-$HOME/.darling@GUEST_HOME@} +. "$project_dir/tools/runtime-paths.sh" cd "$project_dir" +mkdir -p build python3 tools/build-m58f333-iosurface.py # Rebuild the native wrapper with floating-point-safe symbol resolvers. make -C build/darling-src/build/src/native GL -j4 @@ -31,6 +32,7 @@ python3 tools/build-riot-server-runtime.py build/riot-host-message-check cc -shared -fPIC -O2 -Wall -Wextra -Wl,-soname,"$project_dir/build/libdarling-xshm.so" tools/m58f299-xshm.c -o build/libdarling-xshm.so.next -pthread $(pkg-config --cflags --libs x11 x11-xcb xcb-shm xext) python3 tools/stop-darling-runtime.py +mkdir -p "$guest_home/RuntimeFrameworks" # 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 @@ -70,7 +72,7 @@ mkdir -p "$guest_home/RuntimeLibraries" gl_wrapper="$guest_home/RuntimeLibraries/libGL.dylib" if [ ! -f "$gl_wrapper.before-float-resolver" ]; then if [ -f "$gl_wrapper" ]; then cp -p "$gl_wrapper" "$gl_wrapper.before-float-resolver" - else cp -p @DARLING_SDK@/System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib "$gl_wrapper.before-float-resolver"; fi + else cp -p "@DARLING_SDK@"/System/Library/Frameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib "$gl_wrapper.before-float-resolver"; fi fi install -m755 build/darling-src/build/src/native/libGL.dylib "$gl_wrapper.next" mv "$gl_wrapper.next" "$gl_wrapper" @@ -78,7 +80,7 @@ install -m755 build/darling-src/build/src/native/libGL.dylib "$guest_home/Runtim mv "$guest_home/RuntimeFrameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib.next" "$guest_home/RuntimeFrameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib" launchservices="$guest_home/RuntimeFrameworks/LaunchServices.framework" if [ ! -d "$launchservices" ]; then - cp -a @DARLING_SDK@/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework "$launchservices" + cp -a "@DARLING_SDK@"/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework "$launchservices" fi launchservices_binary="$launchservices/Versions/A/LaunchServices" if [ ! -f "$launchservices_binary.before-null-error" ]; then cp -p "$launchservices_binary" "$launchservices_binary.before-null-error"; fi @@ -86,12 +88,11 @@ install -m755 build/darling-src/build/src/frameworks/CoreServices/src/LaunchServ mv "$launchservices_binary.next" "$launchservices_binary" # Electron opens this subframework by its absolute path, bypassing the # framework search override. Replace the same binary in the stopped prefix. -prefix_dir="${DPREFIX:-$HOME/.darling}" launchservices_prefix="$prefix_dir/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/LaunchServices" mkdir -p "$(dirname "$launchservices_prefix")" if [ ! -f "$launchservices_prefix.before-null-error" ]; then if [ -f "$launchservices_prefix" ]; then cp -p "$launchservices_prefix" "$launchservices_prefix.before-null-error" - else cp -p @DARLING_SDK@/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/LaunchServices "$launchservices_prefix.before-null-error"; fi + else cp -p "@DARLING_SDK@"/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/LaunchServices "$launchservices_prefix.before-null-error"; fi fi install -m755 build/darling-src/build/src/frameworks/CoreServices/src/LaunchServices/LaunchServices "$launchservices_prefix.next" mv "$launchservices_prefix.next" "$launchservices_prefix" @@ -100,7 +101,7 @@ vdsp_prefix="$prefix_dir/System/Library/Frameworks/Accelerate.framework/Versions mkdir -p "$(dirname "$vdsp_prefix")" if [ ! -f "$vdsp_prefix.before-vdsp" ]; then if [ -f "$vdsp_prefix" ]; then cp -p "$vdsp_prefix" "$vdsp_prefix.before-vdsp" - else cp -p @DARLING_SDK@/System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib "$vdsp_prefix.before-vdsp"; fi + else cp -p "@DARLING_SDK@"/System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib "$vdsp_prefix.before-vdsp"; fi fi install -m755 build/darling-src/build/src/frameworks/Accelerate/vecLib/vDSP/libvDSP.dylib "$vdsp_prefix.next" mv "$vdsp_prefix.next" "$vdsp_prefix" @@ -108,7 +109,7 @@ mv "$vdsp_prefix.next" "$vdsp_prefix" component="$guest_home/Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component" if [ ! -d "$component" ]; then mkdir -p "$(dirname "$component")" - cp -a @DARLING_SDK@/System/Library/Components/CoreAudio.component "$component" + cp -a "@DARLING_SDK@"/System/Library/Components/CoreAudio.component "$component" fi component_binary="$component/Contents/MacOS/CoreAudio" if [ ! -f "$component_binary.before-gpu-direct" ]; then cp -p "$component_binary" "$component_binary.before-gpu-direct"; fi @@ -139,7 +140,7 @@ mv "$kernel.next" "$kernel" allocator="$guest_home/RuntimeLibraries/libsystem_malloc.dylib" if [ ! -f "$allocator.before-gpu-direct" ]; then if [ -f "$allocator" ]; then cp -p "$allocator" "$allocator.before-gpu-direct" - else cp -p @DARLING_SDK@/usr/lib/system/libsystem_malloc.dylib "$allocator.before-gpu-direct"; fi + else cp -p "@DARLING_SDK@"/usr/lib/system/libsystem_malloc.dylib "$allocator.before-gpu-direct"; fi fi install -m755 build/riot-libsystem_malloc.dylib "$allocator.next" mv "$allocator.next" "$allocator" @@ -149,7 +150,7 @@ mv build/libdarling-xshm.so.next build/libdarling-xshm.so objc_library="$guest_home/RuntimeLibraries/libobjc.A.dylib" if [ ! -f "$objc_library.before-m761" ]; then if [ -f "$objc_library" ]; then cp -p "$objc_library" "$objc_library.before-m761" - else cp -p @DARLING_SDK@/usr/lib/libobjc.A.dylib "$objc_library.before-m761"; fi + else cp -p "@DARLING_SDK@"/usr/lib/libobjc.A.dylib "$objc_library.before-m761"; fi fi install -m755 build/riot-libobjc.A.dylib "$objc_library.next" mv "$objc_library.next" "$objc_library" @@ -157,7 +158,7 @@ mv "$objc_library.next" "$objc_library" cxxabi_library="$guest_home/RuntimeLibraries/libc++abi.dylib" if [ ! -f "$cxxabi_library.before-m765" ]; then if [ -f "$cxxabi_library" ]; then cp -p "$cxxabi_library" "$cxxabi_library.before-m765" - else cp -p @DARLING_SDK@/usr/lib/libc++abi.dylib "$cxxabi_library.before-m765"; fi + else cp -p "@DARLING_SDK@"/usr/lib/libc++abi.dylib "$cxxabi_library.before-m765"; fi fi install -m755 build/riot-libc++abi.dylib "$cxxabi_library.next" mv "$cxxabi_library.next" "$cxxabi_library" @@ -165,7 +166,7 @@ mv "$cxxabi_library.next" "$cxxabi_library" indium_library="$guest_home/RuntimeLibraries/libindium.dylib" if [ ! -f "$indium_library.before-m766" ]; then if [ -f "$indium_library" ]; then cp -p "$indium_library" "$indium_library.before-m766" - else cp -p @DARLING_SDK@/usr/lib/darling/libindium.dylib "$indium_library.before-m766"; fi + else cp -p "@DARLING_SDK@"/usr/lib/darling/libindium.dylib "$indium_library.before-m766"; fi fi install -m755 build/riot-libindium.dylib "$indium_library.next" mv "$indium_library.next" "$indium_library" @@ -174,7 +175,7 @@ mv "$indium_library.next" "$indium_library" iridium_library="$guest_home/RuntimeLibraries/libiridium.dylib" if [ ! -f "$iridium_library.before-m767" ]; then if [ -f "$iridium_library" ]; then cp -p "$iridium_library" "$iridium_library.before-m767" - else cp -p @DARLING_SDK@/usr/lib/darling/libiridium.dylib "$iridium_library.before-m767"; fi + else cp -p "@DARLING_SDK@"/usr/lib/darling/libiridium.dylib "$iridium_library.before-m767"; fi fi install -m755 build/riot-libiridium.dylib "$iridium_library.next" mv "$iridium_library.next" "$iridium_library" @@ -182,7 +183,7 @@ mv "$iridium_library.next" "$iridium_library" pthread_library="$guest_home/RuntimeLibraries/libsystem_pthread.dylib" if [ ! -f "$pthread_library.before-gpu-direct" ]; then if [ -f "$pthread_library" ]; then cp -p "$pthread_library" "$pthread_library.before-gpu-direct" - else cp -p @DARLING_SDK@/usr/lib/system/libsystem_pthread.dylib "$pthread_library.before-gpu-direct"; fi + else cp -p "@DARLING_SDK@"/usr/lib/system/libsystem_pthread.dylib "$pthread_library.before-gpu-direct"; fi fi install -m755 build/riot-libsystem_pthread.dylib "$pthread_library.next" mv "$pthread_library.next" "$pthread_library" diff --git a/tools/install-riot-libs-runtime.py b/tools/install-riot-libs-runtime.py index bf495d0..e50cbba 100644 --- a/tools/install-riot-libs-runtime.py +++ b/tools/install-riot-libs-runtime.py @@ -23,7 +23,9 @@ import shutil import stat import tempfile -ROOT = Path(__file__).resolve().parent.parent +from runtime_paths import runtime_paths + +ROOT = Path(__file__).resolve().parents[1] # (built artifact, file name in the guest's RuntimeLibraries directory). The # artifacts are the ones tools/build-riot-libs-runtime.sh builds. @@ -91,6 +93,7 @@ def install(libraries, backup_folder): else: shutil.copy2(destination, backup) metadata = destination.stat() if destination.is_file() else None + destination.parent.mkdir(parents=True, exist_ok=True) descriptor, temporary = tempfile.mkstemp(prefix="." + destination.name + "-staged-", dir=destination.parent) staged.append((Path(temporary), destination)) @@ -186,9 +189,13 @@ def main(argv=None): parser.exit(1, str(error) + "\n") return config = json.loads((ROOT / "build/config.json").read_text()) - prefix = arguments.prefix or config["prefix"] - guest_home = arguments.guest_home or config["guest_home"] - libraries = destinations(prefix, guest_home) + prefix, host_guest_home = runtime_paths(config) + prefix = arguments.prefix or prefix + if arguments.prefix and not os.environ.get("RIOT_GUEST_HOME"): + host_guest_home = prefix / config["guest_home"].lstrip("/") + if arguments.guest_home: + host_guest_home = prefix / arguments.guest_home.lstrip("/") + libraries = destinations(host_guest_home, "") if arguments.only: wanted = set(arguments.only.split(",")) unknown = wanted - {name for _, name in LIBRARIES} diff --git a/tools/install-riot-memory-runtime.py b/tools/install-riot-memory-runtime.py index c71e232..55f3fb4 100644 --- a/tools/install-riot-memory-runtime.py +++ b/tools/install-riot-memory-runtime.py @@ -10,7 +10,9 @@ import shutil import stat import tempfile -ROOT = Path(__file__).resolve().parent.parent +from runtime_paths import runtime_paths + +ROOT = Path(__file__).resolve().parents[1] # The optimized relink from tools/build-riot-kqueue-runtime.py is the library the # client runtime is supposed to run; the plain CMake build (no -O flag) is not. DEFAULT_ARTIFACT = ROOT / "build/riot-libsystem_kernel.dylib" @@ -84,10 +86,11 @@ def prefix_destinations(config, binary, artifact): install the artifact itself leaves it untouched. That is what keeps the optimized relink from being silently replaced by the plain CMake build. """ + prefix, guest_home = runtime_paths(config) destinations = [ - Path(config["prefix"]) / config["guest_home"].lstrip("/") / "RuntimeLibraries/libsystem_kernel.dylib", + guest_home / "RuntimeLibraries/libsystem_kernel.dylib", # Keep the per-prefix bootstrap copy consistent; leave the system SDK alone. - Path(config["prefix"]) / "usr/lib/system/libsystem_kernel.dylib", + prefix / "usr/lib/system/libsystem_kernel.dylib", ] if not artifact.exists(): return destinations, "the prepared artifact " + str(artifact) + " does not exist yet" diff --git a/tools/install-riot-presentation-runtime.sh b/tools/install-riot-presentation-runtime.sh index d0078f8..220f394 100644 --- a/tools/install-riot-presentation-runtime.sh +++ b/tools/install-riot-presentation-runtime.sh @@ -4,7 +4,8 @@ set -eu project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) source_dir="$project_dir/build/darling-src" build_dir="$source_dir/build" -guest_home="${RIOT_GUEST_HOME:-$HOME/.darling@GUEST_HOME@}" +. "$project_dir/tools/runtime-paths.sh" +mkdir -p "$project_dir/build" make -C "$build_dir/src/external/xnu/darling/src/libsystem_kernel" system_kernel -j8 make -C "$build_dir/src/external/libplatform" platform -j8 make -C "$build_dir/src/external/corefoundation" CoreFoundation_x86_64 -j8 @@ -17,14 +18,16 @@ cc -shared -fPIC -O3 -march=native -mtune=generic -Wall -Wextra \ "$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=generic \ - -isysroot @DARLING_SDK@ \ + -isysroot "@DARLING_SDK@" \ -I"$source_dir/src/startup/mldr/elfcalls" \ "-DDARLING_XSHM_HOST_LIBRARY=\"$project_dir/build/libdarling-xshm.so\"" \ - -Wl,-undefined,dynamic_lookup -Wl,-install_name,@GUEST_HOME@/libDarlingXShm.dylib \ - "$project_dir/tools/m58f299-native-wrapper.c" -o "$guest_home/libDarlingXShm.next.dylib" + -Wl,-undefined,dynamic_lookup "-Wl,-install_name,@GUEST_HOME@/libDarlingXShm.dylib" \ + "$project_dir/tools/m58f299-native-wrapper.c" -o "$project_dir/build/libDarlingXShm.next.dylib" # Stop before changing the overlay's upper directory, even with atomic writes. -darling shutdown || true +python3 "$project_dir/tools/stop-darling-runtime.py" +mkdir -p "$guest_home" mv "$project_dir/build/libdarling-xshm.next.so" "$project_dir/build/libdarling-xshm.so" +install -m755 "$project_dir/build/libDarlingXShm.next.dylib" "$guest_home/libDarlingXShm.next.dylib" mv "$guest_home/libDarlingXShm.next.dylib" "$guest_home/libDarlingXShm.dylib" mkdir -p "$guest_home/RuntimeFrameworks" for framework in AppKit QuartzCore CoreFoundation SystemConfiguration CFNetwork; do @@ -52,7 +55,6 @@ atomic_install "$build_dir/src/external/xnu/darling/src/libsystem_kernel/libsyst # Bootstrap services can start before the app's DYLD_LIBRARY_PATH is applied. # The prefix overlay is user-owned; install the same corrected kernel there. -prefix_dir="${DPREFIX:-$HOME/.darling}" mkdir -p "$prefix_dir/usr/lib/system" atomic_install "$build_dir/src/external/xnu/darling/src/libsystem_kernel/libsystem_kernel.dylib" "$prefix_dir/usr/lib/system/libsystem_kernel.dylib" diff --git a/tools/riot-launcher-offline-check.py b/tools/riot-launcher-offline-check.py index 248444d..1f08239 100644 --- a/tools/riot-launcher-offline-check.py +++ b/tools/riot-launcher-offline-check.py @@ -39,6 +39,10 @@ with open({str(log)!r}, "a") as output: prefix = folder / "prefix" (prefix / "Applications/Riot Client.app").mkdir(parents=True) env["DPREFIX"] = str(prefix) + env.pop("RIOT_GUEST_HOME", None) + (folder / "runtime-paths.sh").write_text( + (ROOT / "tools/runtime-paths.sh").read_text() + .replace("@DPREFIX@", str(prefix)).replace("@GUEST_HOME@", "/Users/offline-check")) env["DYLD_INSERT_LIBRARIES"] = "/Users/offline-check/zzlogdir.dylib:/tmp/inherited.dylib" env["RCLOL_APP_ARGS"] = "--inherited-shim-argument" env["RCLOL_APP_ARG2"] = "--inherited-shim-argument-2" @@ -60,7 +64,7 @@ with open({str(log)!r}, "a") as output: records = [json.loads(line) for line in log.read_text().splitlines()] maintenance = [r for r in records if r["command"] == "python3"] assert [Path(r["args"][0]).name for r in maintenance] == [ - "ensure-darling-runtime.py", "ensure-darling-service-accounts.py", "ensure-league-runtime-layout.py" + "runtime_paths.py", "ensure-darling-runtime.py", "ensure-darling-service-accounts.py", "ensure-league-runtime-layout.py" ], "unexpected automatic helper" launches = [r for r in records if r["command"] == "darling"] assert len(launches) == 1, "unexpected launch count" diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 74b480c..da66ec0 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -8,6 +8,7 @@ if [ "${RCLOL_CORE_DUMPS:-0}" != 1 ]; then ulimit -c 0 fi runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$runner_dir/runtime-paths.sh" python3 "$runner_dir/ensure-darling-runtime.py" python3 "$runner_dir/ensure-darling-service-accounts.py" python3 "$runner_dir/ensure-league-runtime-layout.py" @@ -21,7 +22,12 @@ for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC RCLOL_GPU_DIRECT_V eval 'value=${'"$option"'-1}' case "$value" in 0|'') unset "$option";; *) export "$option=$value";; esac done -prefix=${DPREFIX:-$HOME/.darling} +prefix=$prefix_dir +# RIOT_GUEST_HOME is host-side; DYLD paths must be relative to the guest root. +case "$guest_home" in + "$prefix"/*) guest_path=/${guest_home#"$prefix"/} ;; + *) echo "RIOT_GUEST_HOME must be inside DPREFIX" >&2; exit 1 ;; +esac # The first-time installer puts the client in /Users/Shared/Riot Games and then # hands off through LaunchServices, which does not start it under Darling. Link # the installed client where the normal launch expects it. @@ -85,9 +91,9 @@ exec env -u ELECTRON_RUN_AS_NODE -u DYLD_INSERT_LIBRARIES EGL_PLATFORM=x11 \ LANG="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" LC_ALL="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" \ mesa_glthread="${RCLOL_MESA_GLTHREAD-${mesa_glthread-false}}" \ XMODIFIERS="${RCLOL_XMODIFIERS:-@im=none}" DISPLAY="${DISPLAY:-:1}" \ - DYLD_LIBRARY_PATH="${DYLD_LIBRARY_PATH:-@GUEST_HOME@/RuntimeLibraries}" \ - DYLD_FRAMEWORK_PATH="${DYLD_FRAMEWORK_PATH:-@GUEST_HOME@/RuntimeFrameworks}" \ - DARLING_XSHM_LIBRARY="${DARLING_XSHM_LIBRARY:-@GUEST_HOME@/libDarlingXShm.dylib}" \ + DYLD_LIBRARY_PATH="${DYLD_LIBRARY_PATH:-$guest_path/RuntimeLibraries}" \ + DYLD_FRAMEWORK_PATH="${DYLD_FRAMEWORK_PATH:-$guest_path/RuntimeFrameworks}" \ + DARLING_XSHM_LIBRARY="${DARLING_XSHM_LIBRARY:-$guest_path/libDarlingXShm.dylib}" \ darling "$A" --region="${RCLOL_REGION:-EUW}" --locale="${RCLOL_LOCALE:-en_GB}" \ "$@" \ - --log-dir="@GUEST_HOME@/Library/Logs/Riot Games/Riot Client" + --log-dir="$guest_path/Library/Logs/Riot Games/Riot Client" diff --git a/tools/runtime-paths.sh b/tools/runtime-paths.sh new file mode 100644 index 0000000..74c40d0 --- /dev/null +++ b/tools/runtime-paths.sh @@ -0,0 +1,18 @@ +# Sourced by configured runtime installers; RIOT_GUEST_HOME is a host path. +# Keep these defaults aligned with runtime_paths.py. +prefix_dir=${DPREFIX:-"@DPREFIX@"} +guest_home=${RIOT_GUEST_HOME:-"$prefix_dir@GUEST_HOME@"} +case "$prefix_dir" in + /*) : ;; + *) echo "DPREFIX must be an absolute host path" >&2; exit 1 ;; +esac +prefix_dir=${prefix_dir%/} +case "$guest_home" in + "$prefix_dir"/*) : ;; + *) echo "RIOT_GUEST_HOME must be inside DPREFIX" >&2; exit 1 ;; +esac +case "$guest_home/" in + */../*|*/./*) echo "RIOT_GUEST_HOME must not contain dot components" >&2; exit 1 ;; +esac +export DPREFIX="$prefix_dir" RIOT_GUEST_HOME="$guest_home" +python3 "@PROJECT_ROOT@/build/runtime/tools/runtime_paths.py" "$prefix_dir" || exit 1 diff --git a/tools/runtime_paths.py b/tools/runtime_paths.py new file mode 100644 index 0000000..d50e4d4 --- /dev/null +++ b/tools/runtime_paths.py @@ -0,0 +1,24 @@ +"""Shared host-side destinations for configured runtime installers.""" +import os +from pathlib import Path + + +def runtime_paths(config): + prefix = Path(os.environ.get("DPREFIX") or config["prefix"]) + guest_home = Path(os.environ.get("RIOT_GUEST_HOME") or + str(prefix / config["guest_home"].lstrip("/"))) + if not prefix.is_absolute() or not guest_home.is_absolute(): + raise ValueError("Runtime paths must be absolute host paths") + if ".." in guest_home.parts or not guest_home.is_relative_to(prefix) or guest_home == prefix: + raise ValueError("RIOT_GUEST_HOME must be inside DPREFIX") + if not guest_home.resolve().is_relative_to(prefix.resolve()) or guest_home.resolve() == prefix.resolve(): + raise ValueError("RIOT_GUEST_HOME must resolve inside DPREFIX") + return prefix, guest_home + + +if __name__ == '__main__': + import sys + try: + runtime_paths({'prefix': sys.argv[1], 'guest_home': '/'},) + except (ValueError, OSError) as error: + raise SystemExit(str(error)) diff --git a/tools/validate-riot-runtime.py b/tools/validate-riot-runtime.py new file mode 100644 index 0000000..1e5da1a --- /dev/null +++ b/tools/validate-riot-runtime.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Validate installed overlays, refusing missing, empty or unreadable inputs.""" +import json +import os +from pathlib import Path +import subprocess +import sys + +from runtime_paths import runtime_paths + +ROOT = Path(__file__).resolve().parents[1] +REQUIRED = ( + 'RuntimeFrameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib', + 'libDarlingXShm.dylib', + *(f'RuntimeLibraries/{name}' for name in ( + 'libsystem_kernel.dylib', 'libsystem_c.dylib', 'libsystem_platform.dylib', + 'libsystem_malloc.dylib', 'libsystem_pthread.dylib', + 'libobjc.A.dylib', 'libc++abi.dylib', 'libc++.1.dylib', + 'libGL.dylib', 'libindium.dylib', 'libiridium.dylib')), + *(f'RuntimeFrameworks/{name}.framework/Versions/{version}/{name}' + for name, version in ( + ('AppKit', 'C'), ('QuartzCore', 'A'), ('CoreFoundation', 'A'), + ('SystemConfiguration', 'A'), ('CFNetwork', 'A'), ('IOSurface', 'A'), + ('OpenGL', 'A'), ('CoreVideo', 'A'), ('CoreAudio', 'A'), + ('AudioToolbox', 'A'), ('CoreGraphics', 'A'), ('CoreText', 'A'), + ('Metal', 'A'), ('CarbonCore', 'A'), ('LaunchServices', 'A'))), + 'RuntimeFrameworks/AppKit.framework/Versions/C/Resources/Backends/X11.backend/Contents/MacOS/X11', + 'RuntimeFrameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib', + 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component/Contents/MacOS/CoreAudio', +) +MACHO = (b'\xcf\xfa\xed\xfe', b'\xca\xfe\xba\xbe') + + +def inputs(guest_home): + def enumeration_error(error): + raise error + + def is_macho(path): + with path.open('rb') as source: + return source.read(4) in MACHO + + required = [guest_home / relative for relative in REQUIRED] + for path in required: + if not is_macho(path): + raise ValueError('Expected installed Mach-O runtime binary: ' + str(path)) + binaries = set(required) + for name in ('RuntimeLibraries', 'RuntimeFrameworks'): + folder = guest_home / name + if not folder.is_dir(): + raise ValueError('Missing runtime directory: ' + str(folder)) + found = [] + for directory, _, files in os.walk(folder, onerror=enumeration_error): + for name in files: + if any(marker in name for marker in ('.before-', '.rt-', '.llvm')): + continue + path = Path(directory) / name + if is_macho(path): + found.append(path) + if not found: + raise ValueError('No Mach-O runtime binaries found in ' + str(folder)) + binaries.update(found) + return sorted(binaries) + + +def main(): + try: + _, guest_home = runtime_paths(json.loads((ROOT / 'build/config.json').read_text())) + binaries = inputs(guest_home) + subprocess.run([sys.executable, str(ROOT / 'tools/check-darwin-tlv-calls.py'), + *map(str, binaries)], check=True) + except (OSError, ValueError, subprocess.CalledProcessError) as error: + print('Runtime validation failed: ' + str(error), file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) From 100bc8252d8633659e9978c9ddca902965249214 Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sat, 17 Oct 2026 22:24:06 +0200 Subject: [PATCH 058/160] fix(setup): validate final runtime destinations --- docs/SETUP.md | 2 +- tests/test_setup_portability.py | 47 +++++++++++++++++++++++++----- tools/install-riot-libs-runtime.py | 15 ++++++---- tools/install-riot-metal-pair.sh | 2 +- tools/runtime_paths.py | 14 ++++++--- tools/validate-riot-runtime.py | 21 +++++++++---- 6 files changed, 77 insertions(+), 24 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 10d22b9..7c669a2 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -39,7 +39,7 @@ Linux 5.14 or newer is required for `MADV_POPULATE_WRITE`, used when Darwin's al The runtime installer requires native `pkg-config` entries for `x11`, `x11-xcb`, `xcb-shm` and `xext`, plus the base Darling SDK. -Run configuration, builds and the per-user runtime installation as your ordinary account. Use sudo only for the base CMake installation and the incremental server installer. Running the whole build or per-user install under sudo can create root-owned files and configure the wrong guest home. `scripts/install-runtime.sh` initializes the default user prefix and creates `~/.darling/private/tmp` with mode `1777`, so the installer can write guest temporary files. It then shuts down Darling before replacing the paired runtime: first exit the game and both clients manually. +Run configuration, builds and the per-user runtime installation as your ordinary account. Use sudo only for the base CMake installation and the incremental server installer. Running the whole build or per-user install under sudo can create root-owned files and configure the wrong guest home. `scripts/install-runtime.sh` initializes the selected user prefix and creates its `private/tmp` directory with mode `1777`, so the installer can write guest temporary files. It then shuts down Darling before replacing the paired runtime: first exit the game and both clients manually. The normal launcher starts neither an Electron inspector nor client API/UI helpers. It clears inherited `DYLD_INSERT_LIBRARIES`, retains Darling runtime library/framework overrides, supplies an absolute log directory to the launcher, and selects the League product. The log-directory/Chromium argument shim, empty-autofill hook and optional native VPX bridge are no longer injected or built by runtime installation. Existing copies on disk are inactive and may remain. Child-process log-directory rewriting and Chromium feature overrides previously supplied by the shim are removed; client startup without those workarounds needs manual validation. The first-time installer uses `--skip-to-install` and omits the installed client's `--app-root`, avoiding an immediate exit or inaccessible intro screen. The project ships no inspector hooks, client API helpers, input automation or client binary patches. Keep account data, logs and captures private. diff --git a/tests/test_setup_portability.py b/tests/test_setup_portability.py index 47ded2b..4b5a70a 100644 --- a/tests/test_setup_portability.py +++ b/tests/test_setup_portability.py @@ -13,8 +13,7 @@ from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] -VALIDATION_INPUTS = ( - 'RuntimeFrameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib', +GUEST_VALIDATION_INPUTS = ( 'libDarlingXShm.dylib', *(f'RuntimeLibraries/{name}.dylib' for name in ( 'libsystem_kernel', 'libsystem_c', 'libsystem_platform', 'libsystem_malloc', @@ -30,6 +29,12 @@ VALIDATION_INPUTS = ( 'RuntimeFrameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib', 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component/Contents/MacOS/CoreAudio', ) +PREFIX_VALIDATION_INPUTS = ( + 'usr/lib/system/libsystem_kernel.dylib', + 'System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib', + 'System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/LaunchServices', + 'System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/CarbonCore.framework/Versions/A/CarbonCore', +) class SetupFixture(unittest.TestCase): @@ -159,10 +164,12 @@ exec REAL_INSTALL "$@"'''.replace('REAL_INSTALL', shutil.which('install'))) def prepare_validation(self): self.configure() - for relative in VALIDATION_INPUTS: - path = self.guest / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(b'\xcf\xfa\xed\xfe' + b'\0' * 5000) + for root, inputs in ((self.guest, GUEST_VALIDATION_INPUTS), + (self.prefix, PREFIX_VALIDATION_INPUTS)): + for relative in inputs: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'\0' * 5000) self.stub('llvm-objdump', '''printf '%s\\n' "$*" >> "$FIXTURE_LOG" exit "${CHECK_FAIL:-0}"''') @@ -171,6 +178,11 @@ exit "${CHECK_FAIL:-0}"''') source = (ROOT / 'tools/run-riot-client.sh').read_text() self.assertLess(source.index('runtime-paths.sh'), source.index('ensure-darling-runtime.py')) + def test_metal_pair_uses_shared_runtime_paths(self): + source = (ROOT / 'tools/install-riot-metal-pair.sh').read_text() + self.assertIn('runtime-paths.sh', source) + self.assertNotIn('$HOME/.darling', source) + def test_paths_reject_symlink_escape(self): self.configure() outside = self.home / 'outside' @@ -189,7 +201,7 @@ exit "${CHECK_FAIL:-0}"''') self.env['CHECK_FAIL'] = '1' self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) self.env.pop('CHECK_FAIL') - vdsp = self.guest / 'RuntimeFrameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib' + vdsp = self.prefix / 'System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib' vdsp.unlink(missing_ok=True) self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) shutil.rmtree(self.guest) @@ -204,6 +216,27 @@ exit "${CHECK_FAIL:-0}"''') self.assertEqual(result.returncode, 0, result.stderr) self.assertIn(str(override), self.log.read_text()) + def test_libs_cli_prefix_revalidates_environment_guest_home(self): + self.configure() + self.env['RIOT_GUEST_HOME'] = str(self.guest) + other_prefix = Path(self.tmp.name) / 'other prefix' + result = self.run_script('build/runtime/tools/install-riot-libs-runtime.py', + '--prefix', str(other_prefix), '--only', 'libsystem_c.dylib') + self.assertNotEqual(result.returncode, 0) + self.assertIn('inside DPREFIX', result.stderr) + + def test_libs_cli_guest_home_overrides_invalid_environment(self): + self.configure() + self.env['RIOT_GUEST_HOME'] = str(self.home / 'outside prefix') + other_prefix = Path(self.tmp.name) / 'other prefix' + artifact = self.root / 'build/riot-libsystem_c.dylib' + artifact.write_bytes(b'\xcf\xfa\xed\xfe' + b'library') + result = self.run_script('build/runtime/tools/install-riot-libs-runtime.py', + '--prefix', str(other_prefix), '--guest-home', '/Users/New Guest', + '--only', 'libsystem_c.dylib') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((other_prefix / 'Users/New Guest/RuntimeLibraries/libsystem_c.dylib').is_file()) + def test_failed_stop_with_all_compile_outputs_available(self): self.configure() diff --git a/tools/install-riot-libs-runtime.py b/tools/install-riot-libs-runtime.py index e50cbba..4a2a6b6 100644 --- a/tools/install-riot-libs-runtime.py +++ b/tools/install-riot-libs-runtime.py @@ -23,7 +23,7 @@ import shutil import stat import tempfile -from runtime_paths import runtime_paths +from runtime_paths import validate_runtime_paths ROOT = Path(__file__).resolve().parents[1] @@ -189,12 +189,17 @@ def main(argv=None): parser.exit(1, str(error) + "\n") return config = json.loads((ROOT / "build/config.json").read_text()) - prefix, host_guest_home = runtime_paths(config) - prefix = arguments.prefix or prefix - if arguments.prefix and not os.environ.get("RIOT_GUEST_HOME"): - host_guest_home = prefix / config["guest_home"].lstrip("/") + prefix = arguments.prefix or Path(os.environ.get("DPREFIX") or config["prefix"]) if arguments.guest_home: host_guest_home = prefix / arguments.guest_home.lstrip("/") + elif os.environ.get("RIOT_GUEST_HOME"): + host_guest_home = Path(os.environ["RIOT_GUEST_HOME"]) + else: + host_guest_home = prefix / config["guest_home"].lstrip("/") + try: + prefix, host_guest_home = validate_runtime_paths(prefix, host_guest_home) + except (ValueError, OSError) as error: + parser.exit(1, str(error) + "\n") libraries = destinations(host_guest_home, "") if arguments.only: wanted = set(arguments.only.split(",")) diff --git a/tools/install-riot-metal-pair.sh b/tools/install-riot-metal-pair.sh index 115525b..733b376 100755 --- a/tools/install-riot-metal-pair.sh +++ b/tools/install-riot-metal-pair.sh @@ -5,7 +5,7 @@ # `darling shutdown` and `sh scripts/run.sh` to activate. set -eu project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -guest_home=${RIOT_GUEST_HOME:-$HOME/.darling@GUEST_HOME@} +. "$project_dir/tools/runtime-paths.sh" metal="$guest_home/RuntimeFrameworks/Metal.framework/Versions/A/Metal" indium="$guest_home/RuntimeLibraries/libindium.dylib" backups="$project_dir/build/metal-pair-backups" diff --git a/tools/runtime_paths.py b/tools/runtime_paths.py index d50e4d4..2efc413 100644 --- a/tools/runtime_paths.py +++ b/tools/runtime_paths.py @@ -3,10 +3,9 @@ import os from pathlib import Path -def runtime_paths(config): - prefix = Path(os.environ.get("DPREFIX") or config["prefix"]) - guest_home = Path(os.environ.get("RIOT_GUEST_HOME") or - str(prefix / config["guest_home"].lstrip("/"))) +def validate_runtime_paths(prefix, guest_home): + """Return an absolute prefix/guest-home pair that cannot escape the prefix.""" + prefix, guest_home = Path(prefix), Path(guest_home) if not prefix.is_absolute() or not guest_home.is_absolute(): raise ValueError("Runtime paths must be absolute host paths") if ".." in guest_home.parts or not guest_home.is_relative_to(prefix) or guest_home == prefix: @@ -16,6 +15,13 @@ def runtime_paths(config): return prefix, guest_home +def runtime_paths(config): + prefix = Path(os.environ.get("DPREFIX") or config["prefix"]) + guest_home = Path(os.environ.get("RIOT_GUEST_HOME") or + str(prefix / config["guest_home"].lstrip("/"))) + return validate_runtime_paths(prefix, guest_home) + + if __name__ == '__main__': import sys try: diff --git a/tools/validate-riot-runtime.py b/tools/validate-riot-runtime.py index 1e5da1a..5176b46 100644 --- a/tools/validate-riot-runtime.py +++ b/tools/validate-riot-runtime.py @@ -9,8 +9,7 @@ import sys from runtime_paths import runtime_paths ROOT = Path(__file__).resolve().parents[1] -REQUIRED = ( - 'RuntimeFrameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib', +GUEST_REQUIRED = ( 'libDarlingXShm.dylib', *(f'RuntimeLibraries/{name}' for name in ( 'libsystem_kernel.dylib', 'libsystem_c.dylib', 'libsystem_platform.dylib', @@ -28,10 +27,19 @@ REQUIRED = ( 'RuntimeFrameworks/OpenGL.framework/Versions/A/Libraries/libGL.dylib', 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component/Contents/MacOS/CoreAudio', ) +PREFIX_REQUIRED = ( + 'usr/lib/system/libsystem_kernel.dylib', + 'System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/' + 'vecLib.framework/Versions/A/libvDSP.dylib', + 'System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/' + 'LaunchServices.framework/Versions/A/LaunchServices', + 'System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/' + 'CarbonCore.framework/Versions/A/CarbonCore', +) MACHO = (b'\xcf\xfa\xed\xfe', b'\xca\xfe\xba\xbe') -def inputs(guest_home): +def inputs(prefix, guest_home): def enumeration_error(error): raise error @@ -39,7 +47,8 @@ def inputs(guest_home): with path.open('rb') as source: return source.read(4) in MACHO - required = [guest_home / relative for relative in REQUIRED] + required = ([guest_home / relative for relative in GUEST_REQUIRED] + + [prefix / relative for relative in PREFIX_REQUIRED]) for path in required: if not is_macho(path): raise ValueError('Expected installed Mach-O runtime binary: ' + str(path)) @@ -64,8 +73,8 @@ def inputs(guest_home): def main(): try: - _, guest_home = runtime_paths(json.loads((ROOT / 'build/config.json').read_text())) - binaries = inputs(guest_home) + prefix, guest_home = runtime_paths(json.loads((ROOT / 'build/config.json').read_text())) + binaries = inputs(prefix, guest_home) subprocess.run([sys.executable, str(ROOT / 'tools/check-darwin-tlv-calls.py'), *map(str, binaries)], check=True) except (OSError, ValueError, subprocess.CalledProcessError) as error: From 445ec26a1a0e4d1faf2eca4178c4f118bb236cf6 Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sat, 17 Oct 2026 22:40:53 +0200 Subject: [PATCH 059/160] test(setup): isolate missing guest validation --- tests/test_setup_portability.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_setup_portability.py b/tests/test_setup_portability.py index 4b5a70a..6522e12 100644 --- a/tests/test_setup_portability.py +++ b/tests/test_setup_portability.py @@ -204,6 +204,7 @@ exit "${CHECK_FAIL:-0}"''') vdsp = self.prefix / 'System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libvDSP.dylib' vdsp.unlink(missing_ok=True) self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) + vdsp.write_bytes(b'\xcf\xfa\xed\xfe' + b'\0' * 5000) shutil.rmtree(self.guest) self.assertNotEqual(self.run_script('build/runtime/tools/validate-riot-runtime.py').returncode, 0) From ed5366f10b9d41c52bc191ab9efabccb72b7a521 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:54:50 +0200 Subject: [PATCH 060/160] Bound submit-worker drains to work already queued --- patches/metal--deps--indium.patch | 28 +++++++++--- tools/run-deferred-handoff-tests.sh | 5 ++- tools/test-submit-drain.cpp | 66 +++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 tools/test-submit-drain.cpp diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index 63b9a2c..09cd9a4 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -2447,7 +2447,7 @@ index 43f4a84..aaa1acb 100644 + if ((calls.fetch_add(1,std::memory_order_relaxed) & 63)==0) + bindingReuse.store(access("/tmp/metal-no-binding-reuse",F_OK)!=0,std::memory_order_relaxed); +} -+// OFF by default (round-3 behaviour: a presenting commit waits for the whole worker queue with drain()). Guest flag file ++// Frame-end drain waits for a snapshot of queued work. The opt-in guest flag file +// /tmp/metal-present-dispatch-wait (checked once per process) makes the frame-end sync narrower: the caller waits only +// until this commit's own task has run (its present dispatch, and by FIFO order every replay queued before it), not for +// tasks queued after it. Round 4 had this on by default; that round regressed in matches, so it is opt-in now. @@ -2487,7 +2487,7 @@ index 43f4a84..aaa1acb 100644 + token.wait("presentDispatchWait"); + return; + } -+ // Default (round 3): post first, then check the flag file and wait for the whole worker queue. ++ // Post first, then wait for the work already queued; later producers cannot extend this wait. + SubmitThread::post([self]() { + AsyncEncode::PoolScope pool; + self->commit(); @@ -8050,7 +8050,7 @@ diff --git a/private-include/indium/submit-thread.private.hpp b/private-include/ new file mode 100644 --- /dev/null +++ b/private-include/indium/submit-thread.private.hpp -@@ -0,0 +1,159 @@ +@@ -0,0 +1,175 @@ +#pragma once + +// Ordered queue-submission worker. @@ -8067,6 +8067,8 @@ new file mode 100644 + +#include +#include ++#include ++#include +#include +#include +#include @@ -8080,6 +8082,9 @@ new file mode 100644 + std::condition_variable idle; + std::deque> queue; + size_t active = 0; ++ uint64_t posted = 0, completed = 0; ++ size_t drainWaiters = 0; ++ uint64_t drainTarget = std::numeric_limits::max(); + bool started = false; + const bool enabled = access("/tmp/metal-submit-thread", F_OK) == 0; + }; @@ -8095,8 +8100,10 @@ new file mode 100644 + { + std::lock_guard lock(mutex); + done = true; ++ // The waiter may destroy this stack token as soon as it sees done. ++ // Finish notifying while the token's mutex still protects its lifetime. ++ condvar.notify_all(); + } -+ condvar.notify_all(); + } + // blocks until the task carrying this token has finished (counting the block under `site`) + void wait(const char* site) { @@ -8133,7 +8140,8 @@ new file mode 100644 + { + std::lock_guard lock(s.mutex); + --s.active; -+ if (s.queue.empty() && s.active == 0) s.idle.notify_all(); ++ ++s.completed; ++ if (s.drainWaiters && s.completed >= s.drainTarget) s.idle.notify_all(); + } + } + } @@ -8158,6 +8166,7 @@ new file mode 100644 + // mutex as the worker's wait to avoid losing that transition. + needsWake = s.active == 0 && s.queue.empty(); + s.queue.push_back(std::move(task)); ++ ++s.posted; + } + if (needsWake) s.wake.notify_one(); + } @@ -8190,6 +8199,7 @@ new file mode 100644 + // mutex as the worker's wait to avoid losing that transition. + needsWake = s.active == 0 && s.queue.empty(); + s.queue.push_back(std::move(task)); ++ ++s.posted; + } + if (needsWake) s.wake.notify_one(); + } @@ -8206,7 +8216,13 @@ new file mode 100644 + auto& s = state(); + if (onWorker()) return; + std::unique_lock lock(s.mutex); -+ Waits::blockOn(site, s.idle, lock, [&] { return s.queue.empty() && s.active == 0; }); ++ const uint64_t target = s.posted; ++ if (s.completed >= target) return; ++ ++s.drainWaiters; ++ if (target < s.drainTarget) s.drainTarget = target; ++ // FIFO completion is sufficient: later producers must not extend this wait. ++ Waits::blockOn(site, s.idle, lock, [&] { return s.completed >= target; }); ++ if (--s.drainWaiters == 0) s.drainTarget = std::numeric_limits::max(); + } + } +} diff --git a/tools/run-deferred-handoff-tests.sh b/tools/run-deferred-handoff-tests.sh index 431832e..2928e10 100755 --- a/tools/run-deferred-handoff-tests.sh +++ b/tools/run-deferred-handoff-tests.sh @@ -3,8 +3,9 @@ # # tools/test-deferred-handoff.cpp the ordered worker's FIFO, the present-dispatch token, the wait counters # tools/test-deferred-encoder-model.cpp the deferred blit/render op hand-off: order, resource lifetime, drop safety +# tools/test-submit-drain.cpp overlapping waits complete despite blocked later producers # -# Both compile the real headers from deps/indium/private-include with the host compiler. The counter test writes its +# These compile the real headers from deps/indium/private-include with the host compiler. The counter test writes its # summary to /tmp/opencode/waits-test.log (DARLING_WAITS_LOG) and needs /tmp/metal-debug-waits for the length of the # run; it removes the flag again if it created it (and first checks, in a forked child, that the counters stay off and # write nothing without it). @@ -17,7 +18,7 @@ project_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) out_dir=${RIOT_TEST_OUT:-/tmp/opencode} include="$project_dir/build/darling-src/src/external/metal/deps/indium/private-include" mkdir -p "$out_dir" -for name in test-deferred-handoff test-deferred-encoder-model; do +for name in test-deferred-handoff test-deferred-encoder-model test-submit-drain; do c++ -std=c++17 -O1 -pthread -I "$include" "$project_dir/tools/$name.cpp" -o "$out_dir/$name" echo "== $name" "$out_dir/$name" diff --git a/tools/test-submit-drain.cpp b/tools/test-submit-drain.cpp new file mode 100644 index 0000000..2706a7e --- /dev/null +++ b/tools/test-submit-drain.cpp @@ -0,0 +1,66 @@ +// Exercise the real worker with overlapping drains and producers. A frame's +// drain must finish even while later work is blocked indefinitely. +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; +using namespace Indium; + +static void awaitDrains(size_t count) { + const auto deadline = std::chrono::steady_clock::now() + 5s; + for (;;) { + { + auto& s = SubmitThread::state(); + std::lock_guard lock(s.mutex); + if (s.drainWaiters == count) return; + } + assert(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } +} + +int main() { + std::promise releaseFirst, releaseSecond, releaseThird; + auto first = releaseFirst.get_future().share(); + auto second = releaseSecond.get_future().share(); + auto third = releaseThird.get_future().share(); + std::atomic completed{0}; + SubmitThread::post([&] { first.wait(); ++completed; }); + auto drain1 = std::async(std::launch::async, [] { SubmitThread::drain(); }); + awaitDrains(1); // drain1 has taken its snapshot before the next post + SubmitThread::post([&] { second.wait(); ++completed; }); + auto drain2 = std::async(std::launch::async, [] { SubmitThread::drain(); }); + awaitDrains(2); + SubmitThread::post([&] { third.wait(); ++completed; }); + releaseFirst.set_value(); + assert(drain1.wait_for(2s) == std::future_status::ready); + drain1.get(); + assert(completed == 1); + assert(drain2.wait_for(0s) == std::future_status::timeout); + releaseSecond.set_value(); + assert(drain2.wait_for(2s) == std::future_status::ready); + drain2.get(); + assert(completed == 2); + assert(SubmitThread::pending()); + releaseThird.set_value(); + SubmitThread::drain(); + assert(completed == 3); + // The worker itself cannot wait for a snapshot containing its active task. + SubmitThread::post([] { SubmitThread::drain(); }); + SubmitThread::drain(); + // Repeated idle/active transitions exercise notification registration races. + for (unsigned i=0; i<100000; ++i) { + SubmitThread::post([&] { ++completed; }); + SubmitThread::drain(); + } + assert(completed == 100003); + std::puts("PASS submit drain: overlapping snapshots, blocked later producer, reentrant drain, 100000 handoffs"); + std::fflush(stdout); + // The production worker is detached and process-lived. + std::_Exit(0); +} From 633c129a4f71aaa30c7806b286c3c9096f62b722 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:04:23 +0200 Subject: [PATCH 061/160] Extract runtime compatibility fixes from PR 41 --- docs/reviews/pr41-adaptation.md | 82 +++++++++++++++ patches/darlingserver.patch | 96 ++++++++++++++++-- patches/foundation.patch | 59 ++++++++++- patches/xnu.patch | 11 ++- tests/test_runtime_compatibility.py | 132 +++++++++++++++++++++++++ tools/build-riot-process-info-check.py | 24 +++++ tools/riot-process-info-check.m | 44 +++++++++ 7 files changed, 436 insertions(+), 12 deletions(-) create mode 100644 docs/reviews/pr41-adaptation.md create mode 100644 tests/test_runtime_compatibility.py create mode 100644 tools/build-riot-process-info-check.py create mode 100644 tools/riot-process-info-check.m diff --git a/docs/reviews/pr41-adaptation.md b/docs/reviews/pr41-adaptation.md new file mode 100644 index 0000000..1483097 --- /dev/null +++ b/docs/reviews/pr41-adaptation.md @@ -0,0 +1,82 @@ +# PR #41 compatibility adaptation + +[Original PR](https://github.com/lucannez64/darling-lol/pull/41) + +This adaptation keeps the compatibility corrections that can be implemented and +checked independently of a fabricated machine or security identity. It starts +from main after PRs #43 and #42. It does not claim to resolve account bans. + +## Included + +- Foundation returns the installed system's full version/build description, + falling back to ProductVersion and then Unknown. Numeric version comparisons + read ProductVersion directly, independently of the localized or overridden + description. No macOS version or build number is invented. +- physicalMemory queries 64-bit hw.memsize, then retries hw.physmem with its + correct 32-bit buffer width. The existing failure fallback is preserved. +- Unknown ptrace requests return EINVAL instead of an uninitialized value. + Existing handled requests retain their behavior. +- Unsupported page-accounting and task-policy requests return + KERN_NOT_SUPPORTED, initializing output counts where appropriate. Unsupported + VM-region and thread-info flavors return KERN_INVALID_ARGUMENT. These calls + no longer terminate darlingserver through unsafe stubs. +- The kernel-debugger capability query returns false and initializes its output. +- The 32-bit debug-state conversion passes the converted structure instead of + the address of its input pointer. Hardware debug-state support remains + unsupported. + +## Work left outside this adaptation + +The original PR's rendered source changes were inspected against its merge base, +so patch regeneration noise was excluded from this review. + +- Its darling.patch deletes the existing vDSP arith.c and fft.c implementations. + Those working routines must be preserved. +- GPU reporting is already implemented using actual Vulkan device properties. + A fixed Radeon Pro name and 4 GB budget would replace that behavior. +- The per-host platform ID fix is already merged. A separate configurable + identity feature would need an explicit schema, stable defaults, import + validation, and migration tests before replacing it. Automatic Apple serial, + NVRAM, OS-version, and model fabrication is excluded. +- CPUID trapping changes the SIGSEGV path and advertises a specific Intel CPU + on every host. It needs a separate design around actual host capabilities, + instruction safety, signal chaining, and performance; it is not necessary + for the compatibility corrections above. +- csops, AMFI, SIP, and debugger APIs must reflect implemented checks and state. + Fixed signing identities, fabricated trust-cache membership, and successful + setters that do not apply their settings cannot serve as those implementations. +- IOKit registry lookup/property routines should be extracted with bounds, + lifetime, traversal, and serialization tests, independent of synthetic NVRAM. +- Launcher environment and descriptor changes need a defined ownership boundary. + Blanket removal of driver variables or closing descriptors 3 through 256 can + remove intentional configuration and live descriptors. Keep #42's path setup. +- Log verbosity, descriptor leaks in shellspawn, CPU-frequency units, and other + independent correctness fixes can be separate changes with focused checks. + +## Validation + +The patch-level C regression tests compile the actual patched function bodies +with inert RPC substitutes and exercise error returns, output initialization, +null output pointers, unknown ptrace requests, and existing ptrace branches: + +```sh +python3 -m unittest discover -s tests +python3 scripts/prepare.py --check-only +python3 scripts/validate.py +python3 tools/riot-launcher-offline-check.py +python3 tools/riot-input-offline-check.py +python3 tools/riot-runtime-cleanup-offline-check.py +``` + +With the adapted patches prepared and Foundation rebuilt, the guest probe checks +full version/build reporting, numeric version independence from an overridden +description, and physicalMemory against 64-bit hw.memsize: + +```sh +python3 build/runtime/tools/build-riot-process-info-check.py +darling shell /Volumes/SystemRoot/ABSOLUTE_PROJECT_PATH/build/riot-process-info-check +``` + +The probe must load the rebuilt Foundation using the configured runtime or an +isolated DYLD_FRAMEWORK_PATH. The adaptation was checked with an isolated +Foundation build; the installed runtime and running server were not replaced. diff --git a/patches/darlingserver.patch b/patches/darlingserver.patch index 93505c0..5a5e0c3 100644 --- a/patches/darlingserver.patch +++ b/patches/darlingserver.patch @@ -445,7 +445,7 @@ index 458e659..2e33f20 100644 // wait until it's read if (!dtape_semaphore_down_simple(kqchan->waiter_read_semaphore)) { diff --git a/duct-tape/src/memory.c b/duct-tape/src/memory.c -index 3114947..fcff461 100644 +index 3114947..e2a8d5e 100644 --- a/duct-tape/src/memory.c +++ b/duct-tape/src/memory.c @@ -11,6 +11,7 @@ @@ -807,7 +807,43 @@ index 3114947..fcff461 100644 }; kern_return_t mach_vm_msync(vm_map_t map, mach_vm_offset_t address, mach_vm_size_t size, vm_sync_t sync_flags) { -@@ -856,7 +1163,7 @@ static kern_return_t mach_vm_remap_external_shared(vm_map_t target_map, mach_vm_ +@@ -620,15 +927,22 @@ kern_return_t mach_vm_msync(vm_map_t map, mach_vm_offset_t address, mach_vm_size + }; + + kern_return_t mach_vm_page_info(vm_map_t map, mach_vm_address_t address, vm_page_info_flavor_t flavor, vm_page_info_t info, mach_msg_type_number_t* count) { +- dtape_stub_unsafe(); ++ // Page accounting is not implemented; let the caller handle unsupported queries. ++ if (count) *count = 0; ++ return KERN_NOT_SUPPORTED; + }; + + kern_return_t mach_vm_page_query(vm_map_t map, mach_vm_offset_t offset, int* disposition, int* ref_count) { +- dtape_stub_unsafe(); ++ // Page accounting is not implemented; let the caller handle unsupported queries. ++ if (disposition) *disposition = 0; ++ if (ref_count) *ref_count = 0; ++ return KERN_NOT_SUPPORTED; + }; + + kern_return_t mach_vm_page_range_query(vm_map_t map, mach_vm_offset_t address, mach_vm_size_t size, mach_vm_address_t dispositions_addr, mach_vm_size_t* dispositions_count) { +- dtape_stub_unsafe(); ++ // Page accounting is not implemented; let the caller handle unsupported queries. ++ if (dispositions_count) *dispositions_count = 0; ++ return KERN_NOT_SUPPORTED; + }; + + kern_return_t mach_vm_protect(vm_map_t map, mach_vm_offset_t start, mach_vm_size_t size, boolean_t set_maximum, vm_prot_t new_protection) { +@@ -756,7 +1070,8 @@ region_info_out: + return kr; + }; + default: +- dtape_stub_unsafe("Unimplemented flavor"); ++ if (object_name) *object_name = IP_NULL; ++ return KERN_INVALID_ARGUMENT; + } + }; + +@@ -856,7 +1171,7 @@ static kern_return_t mach_vm_remap_external_shared(vm_map_t target_map, mach_vm_ src_existing_entry_count = dtape_map_find_shared_entries_locked(src_map, start_memaddr, map_size, src_existing_entries, sizeof(src_existing_entries) / sizeof(*src_existing_entries)); @@ -816,7 +852,7 @@ index 3114947..fcff461 100644 // special case for what LLDB does with dyld info dtape_map_shared_descriptor_retain(src_existing_entries[0]->descriptor); descriptor = src_existing_entries[0]->descriptor; -@@ -1441,6 +1748,10 @@ mach_destroy_memory_entry( +@@ -1441,6 +1756,10 @@ mach_destroy_memory_entry( named_entry->ref_count -= 1; if (named_entry->ref_count == 0) { @@ -827,7 +863,7 @@ index 3114947..fcff461 100644 if (named_entry->is_sub_map) { vm_map_deallocate(named_entry->backing.map); } else if (named_entry->is_copy) { -@@ -1456,6 +1767,9 @@ mach_destroy_memory_entry( +@@ -1456,6 +1775,9 @@ mach_destroy_memory_entry( named_entry_unlock(named_entry); named_entry_lock_destroy(named_entry); @@ -882,7 +918,7 @@ index afe60fc..02968b2 100644 void Assert(const char* file, int line, const char* expression) { diff --git a/duct-tape/src/stubs.c b/duct-tape/src/stubs.c -index dc1cfa5..242c6da 100644 +index dc1cfa5..39a8c8d 100644 --- a/duct-tape/src/stubs.c +++ b/duct-tape/src/stubs.c @@ -37,9 +37,18 @@ extern char* getenv(const char* name); @@ -913,7 +949,7 @@ index dc1cfa5..242c6da 100644 kind_info = " (unsafe)"; } else { if (!log_safe_stubs) { -@@ -210,7 +219,9 @@ kern_return_t kmod_get_info(host_t host, kmod_info_array_t* kmod_list, mach_msg_ +@@ -210,11 +219,15 @@ kern_return_t kmod_get_info(host_t host, kmod_info_array_t* kmod_list, mach_msg_ }; kern_return_t kext_request(host_priv_t hostPriv, uint32_t clientLogSpec, vm_offset_t requestIn, mach_msg_type_number_t requestLengthIn, vm_offset_t* responseOut, mach_msg_type_number_t* responseLengthOut, vm_offset_t* logDataOut, mach_msg_type_number_t* logDataLengthOut, kern_return_t* op_result) { @@ -924,8 +960,15 @@ index dc1cfa5..242c6da 100644 }; uint32_t PE_i_can_has_debugger(uint32_t* something) { +- dtape_stub_unsafe(); ++ // No kernel debugger capability is provided by this runtime. ++ if (something) *something = 0; ++ return 0; + }; + + bool work_interval_port_type_render_server(mach_port_name_t port_name) { diff --git a/duct-tape/src/task.c b/duct-tape/src/task.c -index a9eb4bc..b77187d 100644 +index a9eb4bc..7ef2800 100644 --- a/duct-tape/src/task.c +++ b/duct-tape/src/task.c @@ -521,7 +521,9 @@ kern_return_t task_info(task_t xtask, task_flavor_t flavor, task_info_t task_inf @@ -939,8 +982,27 @@ index a9eb4bc..b77187d 100644 } }; +@@ -585,7 +587,7 @@ kern_return_t task_restartable_ranges_synchronize(task_t task) { + }; + + kern_return_t task_set_exc_guard_behavior(task_t task, task_exc_guard_behavior_t behavior) { +- dtape_stub_unsafe(); ++ return KERN_NOT_SUPPORTED; + }; + + kern_return_t task_set_info(task_t task, task_flavor_t flavor, task_info_t task_info_in, mach_msg_type_number_t task_info_count) { +@@ -593,7 +595,8 @@ kern_return_t task_set_info(task_t task, task_flavor_t flavor, task_info_t task_ + }; + + kern_return_t task_set_phys_footprint_limit(task_t task, int new_limit_mb, int* old_limit_mb) { +- dtape_stub_unsafe(); ++ if (old_limit_mb) *old_limit_mb = 0; ++ return KERN_NOT_SUPPORTED; + }; + + kern_return_t task_set_state(task_t task, int flavor, thread_state_t state, mach_msg_type_number_t state_count) { diff --git a/duct-tape/src/thread.c b/duct-tape/src/thread.c -index f4ce909..e14fe79 100644 +index f4ce909..a6cdb2c 100644 --- a/duct-tape/src/thread.c +++ b/duct-tape/src/thread.c @@ -53,6 +53,7 @@ dtape_thread_t* dtape_thread_create(dtape_task_t* task, uint64_t nsid, void* con @@ -990,6 +1052,24 @@ index f4ce909..e14fe79 100644 return TRUE; }; +@@ -782,7 +800,7 @@ thread_set_state( + s64.dr6 = s->dr6; + s64.dr7 = s->dr7; + +- return thread_set_state(thread, x86_DEBUG_STATE64, (thread_state_t) &s, ++ return thread_set_state(thread, x86_DEBUG_STATE64, (thread_state_t) &s64, + x86_DEBUG_STATE64_COUNT); + } + case x86_DEBUG_STATE64: +@@ -1293,7 +1311,7 @@ kern_return_t thread_info(thread_t xthread, thread_flavor_t flavor, thread_info_ + }; + + default: +- dtape_stub_unsafe("Unimplemented flavor"); ++ return KERN_INVALID_ARGUMENT; + } + }; + @@ -1324,11 +1342,11 @@ kern_return_t thread_wire(host_priv_t host_priv, thread_t thread, boolean_t wire }; diff --git a/patches/foundation.patch b/patches/foundation.patch index 701827e..4fdb8b6 100644 --- a/patches/foundation.patch +++ b/patches/foundation.patch @@ -34,10 +34,51 @@ index 80c92bc07..2a80df649 100644 + +FOUNDATION_EXPORT NSString *const NSUserActivityTypeBrowsingWeb; diff --git a/src/NSProcessInfo.m b/src/NSProcessInfo.m -index 1c21f176d..f9f354c80 100644 +index 1c21f17..420955a 100644 --- a/src/NSProcessInfo.m +++ b/src/NSProcessInfo.m -@@ -305,6 +305,13 @@ SINGLETON_RR() +@@ -251,12 +251,13 @@ SINGLETON_RR() + } + #else + NSDictionary* dict = (NSDictionary*) _CFCopySystemVersionDictionary(); +- NSString* string = [dict objectForKey: @"ProductVersion"]; ++ NSString* string = [dict objectForKey: @"FullVersionString"]; ++ if (![string length]) string = [dict objectForKey: @"ProductVersion"]; + + [string retain]; + [dict release]; + +- return [string autorelease]; ++ return [string autorelease] ?: @"Unknown"; + #endif + } + +@@ -285,13 +286,17 @@ SINGLETON_RR() + + - (unsigned long long)physicalMemory + { +- unsigned long long amt = 1; +- size_t len = sizeof(unsigned long long); +- if (sysctlbyname("hw.physmem", &amt, &len, 0, 0) != 0) +- { +- amt = 1; +- } +- return amt; ++ uint64_t memory = 0; ++ size_t length = sizeof(memory); ++ if (sysctlbyname("hw.memsize", &memory, &length, NULL, 0) == 0) ++ return memory; ++ ++ // hw.physmem is a legacy 32-bit value; reset the buffer width on fallback. ++ uint32_t legacyMemory = 0; ++ length = sizeof(legacyMemory); ++ if (sysctlbyname("hw.physmem", &legacyMemory, &length, NULL, 0) == 0) ++ return legacyMemory; ++ return 1; + } + + - (NSTimeInterval)systemUptime +@@ -305,12 +310,23 @@ SINGLETON_RR() return t.tv_sec + t.tv_usec / (NSTimeInterval)USEC_PER_SEC; } @@ -51,7 +92,19 @@ index 1c21f176d..f9f354c80 100644 - (NSOperatingSystemVersion)operatingSystemVersion { NSOperatingSystemVersion version = { 0, 0, 0 }; -@@ -396,3 +403,5 @@ void __NSInitializeProcess(int argc,const char *argv[]) { + +- NSString* string = [self operatingSystemVersionString]; +- NSArray* arr = [string componentsSeparatedByString:@"."]; ++ // Parse the numeric product version independently of its localized display string. ++ NSDictionary* dict = (NSDictionary*) _CFCopySystemVersionDictionary(); ++ NSArray* arr = [[dict objectForKey: @"ProductVersion"] componentsSeparatedByString:@"."]; ++ [arr retain]; ++ [dict release]; ++ [arr autorelease]; + + if ([arr count] >= 1) + { +@@ -396,3 +412,5 @@ void __NSInitializeProcess(int argc,const char *argv[]) { } } #endif diff --git a/patches/xnu.patch b/patches/xnu.patch index 2132025..265cef6 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -3276,9 +3276,18 @@ index c2f70fb..af904ed 100644 default: __simple_printf("sys_proc_info(): Unsupported callnum: %d\n", diff --git a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c -index 0a8f1e4..55aadbe 100644 +index 0a8f1e4..a19f12e 100644 --- a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c +++ b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/ptrace.c +@@ -22,7 +22,7 @@ extern int getpid(void); + long sys_ptrace(int request, int pid, void* addr, int data) + { + const char* cmd = NULL; +- int ret; ++ int ret = -EINVAL; + + switch (request) + { @@ -171,6 +171,17 @@ long sys_ptrace(int request, int pid, void* addr, int data) // return ret; cmd = "PT_THUPDATE"; break; diff --git a/tests/test_runtime_compatibility.py b/tests/test_runtime_compatibility.py new file mode 100644 index 0000000..721c4ae --- /dev/null +++ b/tests/test_runtime_compatibility.py @@ -0,0 +1,132 @@ +"""Compile actual patched syscall bodies with inert RPC stubs; never start a server.""" +import json +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def function(source, name): + start = re.search(r'^[\w* ]+\b' + re.escape(name) + r'\([^;]*?\)\s*\{', source, re.M).start() + opening = source.index('{', start) + depth = 1 + end = opening + 1 + while depth: + depth += (source[end] == '{') - (source[end] == '}') + end += 1 + return source[start:end] + '\n' + + +@unittest.skipUnless(shutil.which('cc') and shutil.which('patch'), 'C compiler and patch required') +class CompatibilityChecks(unittest.TestCase): + def patched(self, name, path): + component = next(c for c in json.loads((ROOT / 'upstream/manifest.json').read_text())['components'] + if c['patch'] == 'patches/' + name + '.patch') + repo = ROOT / 'build/darling-src' / component['path'] + if not (repo / '.git').exists(): + self.skipTest('prepared Darling sources required') + original = subprocess.check_output(['git', '-C', str(repo), 'show', component['revision'] + ':' + path]) + marker = 'diff --git a/' + path + ' b/' + path + '\n' + patch = (ROOT / component['patch']).read_text().split(marker, 1)[1].split('\ndiff --git ', 1)[0] + with tempfile.TemporaryDirectory() as folder: + target = Path(folder) / path + target.parent.mkdir(parents=True) + target.write_bytes(original) + subprocess.run(['patch', '--silent', '-p1', '-d', folder], input=marker + patch, + text=True, check=True, capture_output=True) + return target.read_text() + + def run_c(self, source): + with tempfile.TemporaryDirectory() as folder: + file = Path(folder) / 'check.c' + file.write_text(source) + binary = Path(folder) / 'check' + result = subprocess.run(['cc', '-std=c11', '-O0', str(file), '-o', str(binary)], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_unsupported_mach_calls_return_errors_and_initialize_outputs(self): + bodies = [] + for path, names in { + 'duct-tape/src/memory.c': ['mach_vm_page_info', 'mach_vm_page_query', 'mach_vm_page_range_query'], + 'duct-tape/src/task.c': ['task_set_exc_guard_behavior', 'task_set_phys_footprint_limit'], + 'duct-tape/src/stubs.c': ['PE_i_can_has_debugger'], + }.items(): + source = self.patched('darlingserver', path) + bodies.extend(function(source, name) for name in names) + self.run_c(''' +#include +#include +#include +#include +typedef int kern_return_t; +typedef void *vm_map_t; +typedef void *vm_page_info_t; +typedef void *task_t; +typedef uint64_t mach_vm_address_t; +typedef uint64_t mach_vm_offset_t; +typedef uint64_t mach_vm_size_t; +typedef unsigned mach_msg_type_number_t; +typedef int vm_page_info_flavor_t; +typedef unsigned task_exc_guard_behavior_t; +#define KERN_NOT_SUPPORTED 46 +#define dtape_stub_unsafe(...) abort() +''' + ''.join(bodies) + ''' +int main(void) { + mach_msg_type_number_t count = 123; + assert(mach_vm_page_info(NULL, 0, 0, NULL, &count) == KERN_NOT_SUPPORTED && count == 0); + assert(mach_vm_page_info(NULL, 0, 0, NULL, NULL) == KERN_NOT_SUPPORTED); + int disposition = 123, refs = 123, old = 123; + assert(mach_vm_page_query(NULL, 0, &disposition, &refs) == KERN_NOT_SUPPORTED); + assert(disposition == 0 && refs == 0); + assert(mach_vm_page_query(NULL, 0, NULL, NULL) == KERN_NOT_SUPPORTED); + mach_vm_size_t rangeCount = 123; + assert(mach_vm_page_range_query(NULL, 0, 4096, 0, &rangeCount) == KERN_NOT_SUPPORTED && rangeCount == 0); + assert(mach_vm_page_range_query(NULL, 0, 4096, 0, NULL) == KERN_NOT_SUPPORTED); + assert(task_set_exc_guard_behavior(NULL, 0) == KERN_NOT_SUPPORTED); + assert(task_set_phys_footprint_limit(NULL, 100, &old) == KERN_NOT_SUPPORTED && old == 0); + assert(task_set_phys_footprint_limit(NULL, 100, NULL) == KERN_NOT_SUPPORTED); + uint32_t debugger = 123; + assert(PE_i_can_has_debugger(&debugger) == 0 && debugger == 0); + assert(PE_i_can_has_debugger(NULL) == 0); +} +''') + + def test_ptrace_unknown_request_and_existing_requests(self): + base = 'darling/src/libsystem_kernel/emulation/' + source = self.patched('xnu', base + 'src/xnu_syscall/bsd/impl/misc/ptrace.c') + header = self.patched('xnu', base + 'include/xnu_syscall/bsd/impl/misc/ptrace.h') + self.run_c(''' +#include +#include +#include +#include +#include +#include +#include +#define __simple_printf(...) ((void)0) +#define __simple_kprintf(...) ((void)0) +#define __simple_abort() abort() +static int calls; +static int sys_getppid(void) { return 1; } +static int errno_linux_to_bsd(int value) { return value; } +static int dserver_rpc_set_tracer(int a, int b) { ++calls; return 0; } +static int sys_kill(int a, int b, int c) { ++calls; return 0; } +static int dserver_rpc_ptrace_sigexc(int a, bool b) { ++calls; return 0; } +static int dserver_rpc_tid_for_thread(void *a, int *b) { ++calls; *b = 1; return 0; } +static int dserver_rpc_ptrace_thupdate(int a, int b) { ++calls; return 0; } +''' + header + function(source, 'sys_ptrace') + ''' +int main(void) { + for (int i = 0; i < 1000; ++i) assert(sys_ptrace(0x7fffffff, 0, NULL, 0) == -EINVAL); + assert(calls == 0); + assert(sys_ptrace(XNU_PT_ATTACH, 0, NULL, 0) == -ENOSYS && calls == 0); + assert(sys_ptrace(XNU_PT_DENY_ATTACH, 0, NULL, 0) == 0 && calls == 0); + assert(sys_ptrace(XNU_PT_PTRACE_ME, 0, NULL, 0) == 0 && calls == 1); +} +''') diff --git a/tools/build-riot-process-info-check.py b/tools/build-riot-process-info-check.py new file mode 100644 index 0000000..62ce739 --- /dev/null +++ b/tools/build-riot-process-info-check.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Build the Foundation version/memory probe against the prepared Darling SDK.""" +from pathlib import Path +import json +import re +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +folder = root / 'build/darling-src/build/src/frameworks/CoreVideo' +sdk = json.loads((root / 'build/config.json').read_text())['sdk'] +flags = {} +resource = subprocess.check_output(['clang', '-print-resource-dir'], text=True).strip() +for line in (folder / 'CMakeFiles/CoreVideo.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = [re.sub(r'/usr/lib/clang/\d+/include', resource + '/include', v) for v in shlex.split(value)] +obj = root / 'build/riot-process-info-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-c', str(root / 'tools/riot-process-info-check.m'), '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', sdk, str(obj), '-lSystem', '-lobjc', + '-framework', 'Foundation', '-framework', 'CoreFoundation', + '-o', str(root / 'build/riot-process-info-check')], check=True) diff --git a/tools/riot-process-info-check.m b/tools/riot-process-info-check.m new file mode 100644 index 0000000..7fb3160 --- /dev/null +++ b/tools/riot-process-info-check.m @@ -0,0 +1,44 @@ +#import +#import +#include +#include +#include + +// Numeric version queries must not depend on an overridable, localized string. +@interface LocalizedProcessInfo : NSProcessInfo +@end +@implementation LocalizedProcessInfo +- (NSString *)operatingSystemVersionString { return @"Localized operating system description"; } +@end + +int main(void) { + @autoreleasepool { + NSDictionary *dictionary = (NSDictionary *)_CFCopySystemVersionDictionary(); + NSString *expected = [dictionary objectForKey:@"FullVersionString"]; + if (![expected length]) expected = [dictionary objectForKey:@"ProductVersion"]; + if (!expected) expected = @"Unknown"; + NSProcessInfo *process = [NSProcessInfo processInfo]; + if (![[process operatingSystemVersionString] isEqualToString:expected]) { + fprintf(stderr, "FAIL: process version string omits the system version/build description\n"); + return 1; + } + NSOperatingSystemVersion version = [process operatingSystemVersion]; + NSOperatingSystemVersion localized = [[[LocalizedProcessInfo alloc] init] operatingSystemVersion]; + if (version.majorVersion != localized.majorVersion || + version.minorVersion != localized.minorVersion || version.patchVersion != localized.patchVersion) { + fprintf(stderr, "FAIL: numeric version depends on the localized description\n"); + return 2; + } + uint64_t memory = 0; + size_t length = sizeof(memory); + if (sysctlbyname("hw.memsize", &memory, &length, NULL, 0) || memory == 0 || + [process physicalMemory] != memory) { + fprintf(stderr, "FAIL: physicalMemory differs from 64-bit hw.memsize\n"); + return 3; + } + [dictionary release]; + printf("PROCESS_INFO version_build=1 localized_numeric=1 memory64=%llu pass=1\n", + (unsigned long long)memory); + } + return 0; +} From 2642e276e13da2afb6c260f455749aaaea780c31 Mon Sep 17 00:00:00 2001 From: Kalah <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:17:35 +0200 Subject: [PATCH 062/160] Add warning about 3-Day Suspension and project takedown --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8d06d41..77babf8 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ > - don't play ranked; > - don't use your main account. > +> 3-Day Suspension ("UNSUPPORTED_THIRD_PARTY_TOOLS") DON'T USE IT RN > Even with all of that, you will probably still get permabanned. > > **3. Takedown.** Riot may well shut this project down. If that happens, I will publish a fork of Darling containing only the changes made here (none of them breach anything), and release the small script as a gist or similar. From 3ed48bd2ac3610628d9d5f68497486d8428210eb Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:31:23 +0200 Subject: [PATCH 063/160] Fix shell stream ownership and CPU frequency units --- patches/darling.patch | 171 +++++++++++++++++++++++- patches/xnu.patch | 118 +++++++++++++--- tests/test_runtime_compatibility.py | 13 +- tests/test_shell_cpu_compatibility.py | 86 ++++++++++++ tools/build-riot-cpu-frequency-check.py | 21 +++ tools/riot-cpu-frequency-check.c | 38 ++++++ upstream/manifest.json | 6 +- 7 files changed, 425 insertions(+), 28 deletions(-) create mode 100644 tests/test_shell_cpu_compatibility.py create mode 100644 tools/build-riot-cpu-frequency-check.py create mode 100644 tools/riot-cpu-frequency-check.c diff --git a/patches/darling.patch b/patches/darling.patch index 87c850a..00f4482 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -7597,19 +7597,22 @@ index 6fa2d088c..655554284 100644 int sandbox_issue_fs_extension(const char *path, uint64_t flags, char **ext_token); int sandbox_issue_fs_rw_extension(const char *path, char **ext_token); diff --git a/src/shellspawn/shellspawn.c b/src/shellspawn/shellspawn.c -index f5ba615bb..fea23d638 100644 +index f5ba615..53d04ad 100644 --- a/src/shellspawn/shellspawn.c +++ b/src/shellspawn/shellspawn.c -@@ -36,7 +36,7 @@ along with Darling. If not, see . +@@ -34,9 +34,10 @@ along with Darling. If not, see . + #include + #include #include "shellspawn.h" ++#include "shellspawn-fds.h" #include "duct_signals.h" -#define DBG 0 -+#define DBG 1 ++#define DBG (getenv("DARLING_SHELLSPAWN_DEBUG") && strcmp(getenv("DARLING_SHELLSPAWN_DEBUG"), "1") == 0) int g_serverSocket = -1; struct sigaction sigchld_oldaction; -@@ -50,6 +50,17 @@ void reapAll(void); +@@ -50,6 +51,17 @@ void reapAll(void); int main(int argc, const char** argv) { @@ -7627,6 +7630,123 @@ index f5ba615bb..fea23d638 100644 // shellspawn (daemon) --fork()--> shellspawn (child) --fork()--> exec /bin/bash // in order to read the exit status of the shell process, // we have to allow it to become a zombie, therefore we need to +@@ -125,7 +137,7 @@ void spawnShell(int fd) + { + pid_t shell_pid = -1; + int shellfd[3] = { -1, -1, -1 }; +- int pipefd[2]; ++ int pipefd[2] = { -1, -1 }; + int rv; + struct pollfd pfd[2]; + char** argv = NULL; +@@ -143,6 +155,14 @@ void spawnShell(int fd) + + char* alloc_exec = NULL; + ++ // Keep the control socket out of the stdio slots before receiving streams. ++ if (fd <= STDERR_FILENO) { ++ int moved = fcntl(fd, F_DUPFD_CLOEXEC, 3); ++ if (moved < 0) goto err; ++ close(fd); ++ fd = moved; ++ } ++ + // Read commands from client + while (read_cmds) + { +@@ -262,25 +282,20 @@ void spawnShell(int fd) + argv = (char**) realloc(argv, sizeof(char*) * (argc + 1)); + argv[argc] = NULL; + +- if (pipe(pipefd) == -1) +- goto err; ++ if (shellspawn_remap_stdio(shellfd) < 0) goto err; ++ if (pipe(pipefd) == -1) goto err; + + setsid(); + setpgrp(); + +- close(STDIN_FILENO); +- close(STDOUT_FILENO); +- close(STDERR_FILENO); +- +- dup2(shellfd[0], STDIN_FILENO); +- dup2(shellfd[1], STDOUT_FILENO); +- dup2(shellfd[2], STDERR_FILENO); +- + ioctl(STDIN_FILENO, TIOCSCTTY, STDIN_FILENO); + + shell_pid = fork(); ++ if (shell_pid < 0) goto err; + if (shell_pid == 0) + { ++ shellspawn_close_streams(shellfd); ++ close(pipefd[0]); + close(fd); + + fcntl(pipefd[1], F_SETFD, FD_CLOEXEC); +@@ -296,6 +311,7 @@ void spawnShell(int fd) + exit(EXIT_FAILURE); + } + ++ shellspawn_close_streams(shellfd); + if (alloc_exec) + { + free(alloc_exec); +@@ -304,12 +320,14 @@ void spawnShell(int fd) + + // Check that exec succeeded + close(pipefd[1]); // close the write end ++ pipefd[1] = -1; + if (read(pipefd[0], &rv, sizeof(rv)) == sizeof(rv)) + { + errno = rv; + goto err; + } + close(pipefd[0]); ++ pipefd[0] = -1; + + // Now we start passing signals + // and check for child process exit +@@ -372,7 +390,7 @@ void spawnShell(int fd) + + if (darwin_signal != 0) + { +- int fg_pid = tcgetpgrp(shellfd[0]); ++ int fg_pid = tcgetpgrp(STDIN_FILENO); + if (fg_pid != -1) + { + if (DBG) printf("fg_pid = %d\n", fg_pid); +@@ -394,11 +412,7 @@ void spawnShell(int fd) + kill(shell_pid, SIGKILL); + + // Close shell fds +- for (int i = 0; i < 3; i++) +- { +- if (shellfd[i] != -1) +- close(shellfd[0]); +- } ++ shellspawn_close_streams(shellfd); + + // Reap the child + int wstatus; +@@ -415,13 +429,11 @@ void spawnShell(int fd) + reapAll(); + return; + err: ++ if (pipefd[0] >= 0) close(pipefd[0]); ++ if (pipefd[1] >= 0) close(pipefd[1]); + if (DBG) fprintf(stderr, "Error spawning shell: %s\n", strerror(errno)); + +- for (int i = 0; i < 3; i++) +- { +- if (shellfd[i] != -1) +- close(shellfd[0]); +- } ++ shellspawn_close_streams(shellfd); + + if (shell_pid != -1) + kill(shell_pid, SIGKILL); diff --git a/src/startup/mldr/commpage.c b/src/startup/mldr/commpage.c index d9a7aea96..cc76f7a85 100644 --- a/src/startup/mldr/commpage.c @@ -10537,3 +10657,46 @@ index 8077b7e..0556080 100644 endif(NOT DEBIAN_PACKAGING) add_custom_target(uninstall +diff --git a/src/shellspawn/shellspawn-fds.h b/src/shellspawn/shellspawn-fds.h +new file mode 100644 +index 0000000..1f3c473 +--- /dev/null ++++ b/src/shellspawn/shellspawn-fds.h +@@ -0,0 +1,37 @@ ++#ifndef SHELLSPAWN_FDS_H ++#define SHELLSPAWN_FDS_H ++#include ++#include ++#include ++ ++// Preserve every source before replacing any standard descriptor: sources can ++// themselves be 0, 1 or 2 when the daemon was launched with closed stdio. ++static int shellspawn_remap_stdio(const int streams[3]) ++{ ++ int saved[3] = { -1, -1, -1 }; ++ for (int i = 0; i < 3; ++i) { ++ saved[i] = fcntl(streams[i], F_DUPFD_CLOEXEC, 3); ++ if (saved[i] < 0) goto failure; ++ } ++ for (int i = 0; i < 3; ++i) ++ if (dup2(saved[i], i) < 0) goto failure; ++ for (int i = 0; i < 3; ++i) close(saved[i]); ++ return 0; ++failure: { ++ int error = errno; ++ for (int i = 0; i < 3; ++i) if (saved[i] >= 0) close(saved[i]); ++ errno = error; ++ return -1; ++}} ++ ++static void shellspawn_close_streams(int streams[3]) ++{ ++ for (int i = 0; i < 3; ++i) { ++ int fd = streams[i]; ++ streams[i] = -1; ++ for (int j = i + 1; j < 3; ++j) ++ if (streams[j] == fd) streams[j] = -1; ++ if (fd > STDERR_FILENO) close(fd); ++ } ++} ++#endif diff --git a/patches/xnu.patch b/patches/xnu.patch index 265cef6..be0c967 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -1851,18 +1851,19 @@ index 7d5ef80..46debb8 100644 [521] = sys_abort_with_payload, [524] = sys_setattrlistat, diff --git a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/helper/misc/sysctl_hw.c b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/helper/misc/sysctl_hw.c -index f273989..1093a3a 100644 +index f273989..058b985 100644 --- a/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/helper/misc/sysctl_hw.c +++ b/darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/helper/misc/sysctl_hw.c -@@ -9,6 +9,7 @@ +@@ -9,6 +9,8 @@ #include #include #include +#include ++#include #include extern kern_return_t mach_port_deallocate(ipc_space_t task, mach_port_name_t name); -@@ -16,6 +17,7 @@ extern kern_return_t host_info(mach_port_name_t host, int itype, void* hinfo, ma +@@ -16,6 +18,7 @@ extern kern_return_t host_info(mach_port_name_t host, int itype, void* hinfo, ma /* Darling specific */ enum { @@ -1870,10 +1871,13 @@ index f273989..1093a3a 100644 _HW_PHYSICAL_CPU = 1000, _HW_PHYSICAL_CPU_MAX, _HW_LOGICAL_CPU, -@@ -25,9 +27,12 @@ enum { +@@ -24,10 +27,14 @@ enum { + _HW_CPUSUBTYPE, _HW_CPUTHREADTYPE, _HW_64BITCAPABLE, - _HW_CPUFREQUENCY = 15, +- _HW_CPUFREQUENCY = 15, ++ _HW_CPUFREQUENCY_COMPAT = 15, ++ _HW_CPUFREQUENCY = 1500, + _HW_CACHECONFIG = 1200, + _HW_CACHESIZE, }; @@ -1883,12 +1887,13 @@ index f273989..1093a3a 100644 static sysctl_handler(handle_physicalcpu); static sysctl_handler(handle_physicalcpu_max); static sysctl_handler(handle_logicalcpu); -@@ -39,11 +44,77 @@ static sysctl_handler(handle_cpusubtype); +@@ -39,11 +46,78 @@ static sysctl_handler(handle_cpusubtype); static sysctl_handler(handle_cputhreadtype); static sysctl_handler(handle_cpu64bitcapable); static sysctl_handler(handle_machine); +static sysctl_handler(handle_model); static sysctl_handler(handle_cpufrequency); ++static sysctl_handler(handle_cpufrequency_compat); +static sysctl_handler(handle_cacheconfig); +static sysctl_handler(handle_cachesize); + @@ -1962,19 +1967,21 @@ index f273989..1093a3a 100644 { .oid = _HW_PHYSICAL_CPU, .type = CTLTYPE_INT, .exttype = "", .name = "physicalcpu", .handler = handle_physicalcpu }, { .oid = _HW_PHYSICAL_CPU_MAX, .type = CTLTYPE_INT, .exttype = "", .name = "physicalcpu_max", .handler = handle_physicalcpu_max }, { .oid = _HW_LOGICAL_CPU, .type = CTLTYPE_INT, .exttype = "", .name = "logicalcpu", .handler = handle_logicalcpu }, -@@ -55,7 +126,11 @@ const struct known_sysctl sysctls_hw[] = { +@@ -55,7 +129,12 @@ const struct known_sysctl sysctls_hw[] = { { .oid = _HW_CPUTHREADTYPE, .type = CTLTYPE_INT, .exttype = "", .name = "cputhreadtype", .handler = handle_cputhreadtype }, { .oid = _HW_64BITCAPABLE, .type = CTLTYPE_INT, .exttype = "", .name = "cpu64bit_capable", .handler = handle_cpu64bitcapable }, { .oid = HW_MACHINE, .type = CTLTYPE_STRING, .exttype = "S", .name = "machine", .handler = handle_machine }, +- { .oid = _HW_CPUFREQUENCY, .type = CTLTYPE_INT, .exttype = "", .name = "cpufrequency", .handler = handle_cpufrequency }, + { .oid = _HW_OPTIONAL, .type = CTLTYPE_NODE, .exttype = "", .name = "optional", .subctls = sysctls_hw_optional }, + { .oid = HW_MODEL, .type = CTLTYPE_STRING, .exttype = "S", .name = "model", .handler = handle_model }, - { .oid = _HW_CPUFREQUENCY, .type = CTLTYPE_INT, .exttype = "", .name = "cpufrequency", .handler = handle_cpufrequency }, ++ { .oid = _HW_CPUFREQUENCY, .type = CTLTYPE_QUAD, .exttype = "Q", .name = "cpufrequency", .handler = handle_cpufrequency }, ++ { .oid = _HW_CPUFREQUENCY_COMPAT, .type = CTLTYPE_INT, .exttype = "I", .name = "cpufrequency_compat", .handler = handle_cpufrequency_compat }, + { .oid = _HW_CACHECONFIG, .type = CTLTYPE_OPAQUE, .exttype = "Q", .name = "cacheconfig", .handler = handle_cacheconfig }, + { .oid = _HW_CACHESIZE, .type = CTLTYPE_OPAQUE, .exttype = "Q", .name = "cachesize", .handler = handle_cachesize }, { .oid = -1 } }; -@@ -71,6 +146,32 @@ static const struct host_basic_info* gethostinfo(void) +@@ -71,6 +150,32 @@ static const struct host_basic_info* gethostinfo(void) hself = mach_host_self(); host_info(hself, HOST_BASIC_INFO, &hinfo, &hcount); mach_port_deallocate(mach_task_self(), hself); @@ -2007,7 +2014,7 @@ index f273989..1093a3a 100644 } return &hinfo; -@@ -83,6 +184,13 @@ sysctl_handler(handle_availcpu) +@@ -83,6 +188,13 @@ sysctl_handler(handle_availcpu) return 0; } @@ -2021,16 +2028,64 @@ index f273989..1093a3a 100644 sysctl_handler(handle_physicalcpu) { sysctl_handle_size(sizeof(int)); -@@ -174,6 +282,8 @@ sysctl_handler(handle_cpufrequency) - int freq = 2400000; - char buf[16]; +@@ -169,16 +281,49 @@ sysctl_handler(handle_cputhreadtype) + return 0; + } -+ sysctl_handle_size(sizeof(int)); ++static long cpu_frequency(uint64_t* frequency) ++{ ++ char buffer[32]; ++ const char* paths[] = { ++ "/Volumes/SystemRoot/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq", ++ "/Volumes/SystemRoot/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq", ++ "/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq", ++ "/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq" ++ }; ++ for (unsigned i = 0; i < sizeof(paths)/sizeof(paths[0]); ++i) ++ if (read_string(paths[i], buffer, sizeof(buffer)) && darling_frequency_hz(buffer, frequency)) return 0; ++#if defined(__i386__) || defined(__x86_64__) ++ unsigned int a, b, c, d; ++ __asm__("cpuid" : "=a"(a), "=b"(b), "=c"(c), "=d"(d) : "0"(0), "2"(0)); ++ if (a >= 0x16) { ++ __asm__("cpuid" : "=a"(a), "=b"(b), "=c"(c), "=d"(d) : "0"(0x16), "2"(0)); ++ if (a & 0xffff) { ++ *frequency = (uint64_t)(a & 0xffff) * 1000000; ++ return 0; ++ } ++ } ++#endif ++ return -ENOENT; ++} + - if (read_string("/sys/bus/cpu/devices/cpu0/cpufreq/cpuinfo_max_freq", buf, sizeof(buf))) - freq = __simple_atoi(buf, NULL); - *((int*) old) = freq; -@@ -193,3 +303,104 @@ sysctl_handler(handle_machine) + sysctl_handler(handle_cpufrequency) + { +- int freq = 2400000; +- char buf[16]; ++ uint64_t frequency; ++ sysctl_handle_size(sizeof(frequency)); ++ long result = cpu_frequency(&frequency); ++ if (!result) *((uint64_t*)old) = frequency; ++ return result; ++} + +- if (read_string("/sys/bus/cpu/devices/cpu0/cpufreq/cpuinfo_max_freq", buf, sizeof(buf))) +- freq = __simple_atoi(buf, NULL); +- *((int*) old) = freq; +- +- return 0; ++sysctl_handler(handle_cpufrequency_compat) ++{ ++ // Numeric HW_CPU_FREQ retains XNU's legacy 32-bit ABI. Modern by-name ++ // queries use the separate 64-bit OID above, as in kern_mib.c. ++ uint64_t frequency; ++ sysctl_handle_size(sizeof(uint32_t)); ++ long result = cpu_frequency(&frequency); ++ if (!result) *((uint32_t*)old) = (uint32_t)frequency; ++ return result; + } + + sysctl_handler(handle_cpu64bitcapable) +@@ -193,3 +338,104 @@ sysctl_handler(handle_machine) copyout_string(need_uname()->machine, (char*) old, oldlen); return 0; } @@ -6083,3 +6138,30 @@ index 0000000..f185f6c +__sysctlbyname: + /* Just jump to the syscall stub */ + jmp ___sysctlbyname +diff --git a/darling/src/libsystem_kernel/emulation/include/common/cpu_frequency.h b/darling/src/libsystem_kernel/emulation/include/common/cpu_frequency.h +new file mode 100644 +index 0000000..eb90f43 +--- /dev/null ++++ b/darling/src/libsystem_kernel/emulation/include/common/cpu_frequency.h +@@ -0,0 +1,21 @@ ++#ifndef DARLING_CPU_FREQUENCY_H ++#define DARLING_CPU_FREQUENCY_H ++#include ++#include ++ ++// Linux cpufreq exposes kHz; Darwin's hw.cpufrequency is an unsigned 64-bit Hz value. ++static inline bool darling_frequency_hz(const char* text, uint64_t* result) ++{ ++ uint64_t value = 0; ++ if (!text || !result || *text < '0' || *text > '9') return false; ++ do { ++ unsigned digit = (unsigned)(*text++ - '0'); ++ if (value > (UINT64_MAX - digit) / 10) return false; ++ value = value * 10 + digit; ++ } while (*text >= '0' && *text <= '9'); ++ while (*text == ' ' || *text == '\t' || *text == '\r' || *text == '\n') ++text; ++ if (*text || !value || value > UINT64_MAX / 1000) return false; ++ *result = value * 1000; ++ return true; ++} ++#endif diff --git a/tests/test_runtime_compatibility.py b/tests/test_runtime_compatibility.py index 721c4ae..741917e 100644 --- a/tests/test_runtime_compatibility.py +++ b/tests/test_runtime_compatibility.py @@ -29,14 +29,19 @@ class CompatibilityChecks(unittest.TestCase): repo = ROOT / 'build/darling-src' / component['path'] if not (repo / '.git').exists(): self.skipTest('prepared Darling sources required') - original = subprocess.check_output(['git', '-C', str(repo), 'show', component['revision'] + ':' + path]) + original = (None if path in component['new_files'] else + subprocess.check_output(['git', '-C', str(repo), 'show', component['revision'] + ':' + path])) marker = 'diff --git a/' + path + ' b/' + path + '\n' - patch = (ROOT / component['patch']).read_text().split(marker, 1)[1].split('\ndiff --git ', 1)[0] + patch_text = (ROOT / component['patch']).read_text() + if marker not in patch_text: + return original.decode() + patch = patch_text.split(marker, 1)[1].split('\ndiff --git ', 1)[0] with tempfile.TemporaryDirectory() as folder: target = Path(folder) / path target.parent.mkdir(parents=True) - target.write_bytes(original) - subprocess.run(['patch', '--silent', '-p1', '-d', folder], input=marker + patch, + if original is not None: + target.write_bytes(original) + subprocess.run(['patch', '--silent', '-p1', '-d', folder], input=marker + patch + ("" if patch.endswith("\n") else "\n"), text=True, check=True, capture_output=True) return target.read_text() diff --git a/tests/test_shell_cpu_compatibility.py b/tests/test_shell_cpu_compatibility.py new file mode 100644 index 0000000..305ab44 --- /dev/null +++ b/tests/test_shell_cpu_compatibility.py @@ -0,0 +1,86 @@ +"""Exercise the real patched descriptor helper and frequency conversion on the host.""" +import unittest +import test_runtime_compatibility as sources + + +class ShellCPUChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + run_c = sources.CompatibilityChecks.run_c + + def test_frequency_units_width_and_invalid_inputs(self): + root = 'darling/src/libsystem_kernel/emulation/' + header = self.patched('xnu', root + 'include/common/cpu_frequency.h') + original = self.patched('xnu', root + 'include/xnu_syscall/bsd/impl/misc/sysctl.h') + size_macro = original[original.index('#define sysctl_handle_size'):original.index('typedef long')] + source = self.patched('xnu', root + 'src/xnu_syscall/bsd/helper/misc/sysctl_hw.c') + start = source.index('static long cpu_frequency(') + handler = source[start:source.index('\nsysctl_handler(handle_cpu64bitcapable)', start)] + self.run_c(header + ''' +#include +#include +#include +#include +static unsigned reads; +static bool read_string(const char* path, char* dst, int size) { + ++reads; strcpy(dst, "5600000\\n"); return true; +} +#define sysctl_handler(name) long name(void* old, unsigned long* oldlen) +''' + size_macro + handler + ''' +int main(void) { + uint64_t hz = 17; + assert(darling_frequency_hz("2400000\\n", &hz) && hz == 2400000000ULL); + assert(darling_frequency_hz("5600000", &hz) && hz == 5600000000ULL); + const char *bad[] = {"", "0", "-1", " 123", "12MHz", "18446744073709551616", "18446744073709552"}; + for (unsigned i=0; i +#include +#include +static unsigned open_count(void) { + unsigned n = 0; for (int fd=3; fd<256; ++fd) if (fcntl(fd, F_GETFD) >= 0) ++n; return n; +} +int main(void) { + pid_t child = fork(); assert(child >= 0); + if (!child) { + int p[3][2], sentinel[2]; + for (int i=0; i<3; ++i) assert(pipe(p[i]) == 0); + assert(pipe(sentinel) == 0); + for (int i=0; i<3; ++i) assert(dup2(p[i][0], i) == i); + struct stat expected[3], actual; + int alias[3] = {2,0,1}; + for (int i=0; i<3; ++i) assert(fstat(alias[i], &expected[i]) == 0); + assert(shellspawn_remap_stdio(alias) == 0); + for (int i=0; i<3; ++i) { + assert(fstat(i, &actual) == 0 && actual.st_ino == expected[i].st_ino); + assert(!(fcntl(i, F_GETFD) & FD_CLOEXEC)); + } + int streams[3] = {p[0][0],p[1][0],p[2][0]}; + shellspawn_close_streams(streams); + for (int i=0; i<3; ++i) assert(fcntl(p[i][0], F_GETFD) == -1 && streams[i] == -1); + assert(fcntl(sentinel[0], F_GETFD) >= 0); + streams[0] = streams[1] = streams[2] = p[0][1]; + shellspawn_close_streams(streams); shellspawn_close_streams(streams); + unsigned before = open_count(); + int invalid[3] = {sentinel[0],-1,sentinel[1]}; + for (int i=0; i<1000; ++i) assert(shellspawn_remap_stdio(invalid) == -1 && errno == EBADF); + assert(open_count() == before && fcntl(sentinel[0], F_GETFD) >= 0); + _exit(0); + } + int status; assert(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 0); +} +''') diff --git a/tools/build-riot-cpu-frequency-check.py b/tools/build-riot-cpu-frequency-check.py new file mode 100644 index 0000000..ec7ca44 --- /dev/null +++ b/tools/build-riot-cpu-frequency-check.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Build the frequency units and legacy ABI check against the prepared SDK.""" +import json +from pathlib import Path +import re +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +sdk = json.loads((root / 'build/config.json').read_text())['sdk'] +flags = {} +resource = subprocess.check_output(['clang', '-print-resource-dir'], text=True).strip() +for line in (root / 'build/darling-src/build/src/frameworks/CoreVideo/CMakeFiles/CoreVideo.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = [re.sub(r'/usr/lib/clang/\d+/include', resource + '/include', v) for v in shlex.split(value)] +obj = root / 'build/riot-cpu-frequency-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-c', str(root / 'tools/riot-cpu-frequency-check.c'), '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', sdk, str(obj), '-lSystem', '-o', str(root / 'build/riot-cpu-frequency-check')], check=True) diff --git a/tools/riot-cpu-frequency-check.c b/tools/riot-cpu-frequency-check.c new file mode 100644 index 0000000..a6c01da --- /dev/null +++ b/tools/riot-cpu-frequency-check.c @@ -0,0 +1,38 @@ +#include +#include +#include +#include + +int main(void) { + size_t length = 0; + if (sysctlbyname("hw.cpufrequency", NULL, &length, NULL, 0) || length != sizeof(uint64_t)) return 1; + const char *paths[] = { + "/Volumes/SystemRoot/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq", + "/Volumes/SystemRoot/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq", + "/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq", + "/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq" + }; + uint64_t expected = 0; + for (unsigned i=0; i<4; ++i) { + FILE* source = fopen(paths[i], "r"); + unsigned long long khz = 0; + if (!source) continue; + int found = fscanf(source, "%llu", &khz); fclose(source); + if (found == 1 && khz > 0 && khz <= UINT64_MAX/1000) { expected = khz * 1000; break; } + } + uint64_t hz = 0; + length = sizeof(hz); + if (sysctlbyname("hw.cpufrequency", &hz, &length, NULL, 0)) { + if (errno != ENOENT || expected) return 2; + puts("CPU_FREQUENCY unavailable=1 invented_frequency=0 pass=1"); + return 0; + } + if (length != 8 || hz < 1000000) return 3; + if (expected && hz != expected) return 4; + struct { uint32_t before, value, after; } legacy = {0x12345678,0,0x87654321}; + int mib[] = {CTL_HW, HW_CPU_FREQ}; length = sizeof(legacy.value); + if (sysctl(mib, 2, &legacy.value, &length, NULL, 0) || length != 4 || legacy.value != (uint32_t)hz || + legacy.before != 0x12345678 || legacy.after != 0x87654321) return 5; + printf("CPU_FREQUENCY hz=%llu width64=1 legacy_width32=1 pass=1\n", (unsigned long long)hz); + return 0; +} diff --git a/upstream/manifest.json b/upstream/manifest.json index 1fff96e..733502b 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -24,7 +24,8 @@ "src/frameworks/OpenDirectory/src/ODLocalDirectory.m", "src/frameworks/OpenGL/CGLAsyncPresent.inc", "src/frameworks/OpenGL/include/OpenGL/DarlingNativePresent.h", - "src/startup/mldr/commpage-cpu-caps.h" + "src/startup/mldr/commpage-cpu-caps.h", + "src/shellspawn/shellspawn-fds.h" ] }, { @@ -282,7 +283,8 @@ "darling/src/libsystem_kernel/emulation/include/xnu_syscall/bsd/impl/misc/mac_syscall.h", "darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/guarded/change_fdguard_np.c", "darling/src/libsystem_kernel/emulation/src/xnu_syscall/bsd/impl/misc/mac_syscall.c", - "libsyscall/wrappers/__sysctlbyname.S" + "libsyscall/wrappers/__sysctlbyname.S", + "darling/src/libsystem_kernel/emulation/include/common/cpu_frequency.h" ] }, { From d87ad2ce452fed36f1373e5dbfbf59cf1ea4628c Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:32:01 +0200 Subject: [PATCH 064/160] Implement bounded registry APIs and opt-in platform configuration --- docs/PLATFORM_IDENTITY.md | 73 +++ docs/reviews/pr41-adaptation.md | 2 + docs/reviews/pr41-deferred-implementation.md | 98 ++++ patches/iokitd.patch | 523 +++++++++++++++++- tests/test_platform_identity.py | 157 ++++++ ...build-riot-registry-compatibility-check.py | 47 ++ tools/configure-darling-platform.py | 204 +++++++ tools/riot-registry-compatibility-check.mm | 194 +++++++ upstream/manifest.json | 5 +- 9 files changed, 1284 insertions(+), 19 deletions(-) create mode 100644 docs/PLATFORM_IDENTITY.md create mode 100644 docs/reviews/pr41-deferred-implementation.md create mode 100644 tests/test_platform_identity.py create mode 100644 tools/build-riot-registry-compatibility-check.py create mode 100644 tools/configure-darling-platform.py create mode 100644 tools/riot-registry-compatibility-check.mm diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md new file mode 100644 index 0000000..c1d4b85 --- /dev/null +++ b/docs/PLATFORM_IDENTITY.md @@ -0,0 +1,73 @@ +# Explicit platform identity overrides + +By default, Darling keeps the existing host-derived UUID and `DARLING-` serial. +No override file is generated by installation or launch. This tool imports +values supplied by the user; it does not generate Apple serials or replace OS, +CPU, GPU, or security metadata. + +Preview an OpenCore `config.plist`, a flat GenSMBIOS JSON file, or an existing +`darling-smbios.conf` without changing the selected prefix: + +```sh +python3 tools/configure-darling-platform.py --prefix /absolute/prefix --import /path/to/config.plist +``` + +After reviewing the preview, shut down that prefix and explicitly save: + +```sh +DPREFIX=/absolute/prefix python3 build/runtime/tools/stop-darling-runtime.py +python3 tools/configure-darling-platform.py --prefix /absolute/prefix --import /path/to/config.plist --write +``` + +The default prefix selection is `--prefix`, then `DPREFIX`, then saved +`build/config.json`, then `~/.darling`. Writing refuses a live prefix. The +configuration is activated when that prefix's iokitd starts again; it is an +immutable startup snapshot. An absent or invalid configuration preserves the +existing defaults. Invalid daemon-side configuration is reported once. + +The file is `/private/etc/darling-platform.plist`. It uses this schema: + +```json +{ + "schema": 1, + "properties": { + "IOPlatformSerialNumber": "USER-SUPPLIED", + "model": "UserSuppliedModel" + } +} +``` + +Supported properties: + +| Property | Configuration value | IOKit value | +|---|---|---| +| `IOPlatformSerialNumber` | Printable ASCII string, 1–64 characters | NSString | +| `IOPlatformUUID` | Nonzero UUID; normalized to uppercase | NSString | +| `manufacturer`, `model`, `product-name`, `board-id` | Printable ASCII string, 1–128 characters | NUL-terminated NSData | +| `mlb-serial-number` | Printable ASCII string, 1–64 characters | NUL-terminated NSData | +| `ROM` | Exactly 6 bytes; JSON accepts hexadecimal text | NSData | +| `system-id` | Exactly 16 bytes; JSON accepts hexadecimal text | NSData | + +Supplying a model also supplies the matching product name unless one is +explicitly provided. Supplying a UUID derives its EFI little-endian `system-id`; +a conflicting explicit system ID is rejected. No other missing fields are +invented. These properties belong to the existing platform service; the tool +does not manufacture a firmware/NVRAM registry hierarchy. + +For OpenCore, the importer reads `PlatformInfo/Generic` (or a standalone Generic +dictionary): `SystemProductName`, `SystemSerialNumber`, `SystemUUID`, `MLB`, +and `ROM`. It ignores unrelated boot and security settings. GenSMBIOS aliases +include `Type`, `Serial`, `Board Serial`, and `SmUUID`. Legacy `SMBIOS_*` identity +keys are accepted from `.conf`; parsing never executes shell expressions. + +Existing configured fields survive a partial import. A new explicit UUID +replaces its previously derived system ID. `--set KEY=VALUE` can update an +individual field. Unknown schema versions, unsupported properties, malformed +values, and duplicate JSON fields are rejected. + +Writes are atomic with mode 0600. Repeating the same write changes nothing. The +first previous configuration is kept as +`darling-platform.plist.before-platform-change`, also with mode 0600. Importing +an older `.conf` never edits that source. Symlinks escaping the prefix are +rejected. To restore the original defaults, remove the override file while the +prefix is stopped, then restart it. diff --git a/docs/reviews/pr41-adaptation.md b/docs/reviews/pr41-adaptation.md index 1483097..8748518 100644 --- a/docs/reviews/pr41-adaptation.md +++ b/docs/reviews/pr41-adaptation.md @@ -25,6 +25,8 @@ from main after PRs #43 and #42. It does not claim to resolve account bans. the address of its input pointer. Hardware debug-state support remains unsupported. +The deferred compatibility groups are implemented in the [following stack layer](pr41-deferred-implementation.md). + ## Work left outside this adaptation The original PR's rendered source changes were inspected against its merge base, diff --git a/docs/reviews/pr41-deferred-implementation.md b/docs/reviews/pr41-deferred-implementation.md new file mode 100644 index 0000000..9c2347d --- /dev/null +++ b/docs/reviews/pr41-deferred-implementation.md @@ -0,0 +1,98 @@ +# PR #41 deferred compatibility work + +This follow-up is stacked on the compatibility extraction in PR #44. It +implements the four groups deferred in that review, with explicit configuration +and observable errors rather than automatic identity changes. + +## Shell spawning + +Diagnostics are quiet by default; `DARLING_SHELLSPAWN_DEBUG=1` enables the +existing diagnostic output. The environment and arguments are no longer dumped +on normal launches. + +Stdio sources are duplicated before any standard descriptor is replaced, so +aliases involving descriptors 0–2 are safe. Stream cleanup closes each owned +descriptor once instead of repeatedly closing stream 0. Both fork branches drop +unneeded descriptors, and error paths clean up the exec-status pipe. The +control socket is kept out of the stdio slots. Terminal signal forwarding uses +the remapped stdin. Unrelated descriptors and the configured shell selection +are preserved. + +## CPU frequency + +`hw.cpufrequency` is an unsigned 64-bit frequency in Hz, matching XNU's named +API. Linux cpufreq kHz values are checked for invalid input and overflow before +conversion. The exported host sysfs location is tried as well as `/sys`; an +available native CPUID base-frequency leaf is a fallback. No fixed CPU speed is +invented when no source exists. + +Numeric `HW_CPU_FREQ` retains the legacy 32-bit representation through a +separate compatibility entry, matching XNU's distinction between the modern +named API and the numeric compatibility API. Consumers needing frequencies +above the 32-bit range should use the named 64-bit API. + +## IOKit registry + +- Inline and out-of-line path lookup validate termination, path shape, plane + membership, and length. Paths resolve registered entries with case-sensitive + names; no synthetic firmware nodes are inserted. +- Path output uses inline storage when possible and a bounded sender buffer for + long physical-copy replies. Incoming OOL mappings are released on success and + error paths. The daemon's main queue serializes request processing and reply + copying, so reply storage remains live through the send. +- Plane membership follows registered ancestry. Deleting a parent or child + removes both graph links. Plane-name keys own their strings, and cyclic + registration is refused. +- Raw property access handles strings, data, integer numbers at their reported + widths, and XNU's `Yes`/`No` representation of booleans. It bounds writes by + both the requested count and the fixed MIG array. Missing and unsupported + values return errors; small buffers return IPC errors without writes. + +## Platform configuration + +[Platform identity configuration](../PLATFORM_IDENTITY.md) documents the schema, +OpenCore/GenSMBIOS/legacy imports, startup snapshot, defaults, and rollback. +Neither installation nor launch generates an override file. Existing host-based +identifiers remain stable unless the user explicitly supplies replacements. + +## Validation + +Portable regression checks: + +```sh +python3 -m unittest discover -s tests +python3 scripts/prepare.py --check-only +python3 scripts/validate.py +python3 tools/riot-launcher-offline-check.py +python3 tools/riot-input-offline-check.py +python3 tools/riot-runtime-cleanup-offline-check.py +``` + +The unit tests compile the actual patched fd helper and frequency code. They +exercise fd aliases, unrelated descriptors, repeated failed remaps, conversion +overflow, buffer widths, schema validation, imports, idempotency, backups, +per-prefix isolation, malformed input, and preservation of existing identities. + +With the source patches prepared, build an isolated iokitd candidate and probe: + +```sh +python3 build/runtime/tools/build-riot-registry-compatibility-check.py +darling shell /Volumes/SystemRoot/ABSOLUTE_PROJECT_PATH/build/riot-registry-compatibility-check +``` + +The probe uses the actual daemon objects and MIG handlers, and creates its own +Mach ports. It does not register a daemon or replace the running iokitd. It +checks traversal, deletion, cycles, malformed OOL input, 1,000 direct OOL +lookups, 100 real IPC path round trips, property widths/bounds, and binary +serialization round trips. It also checks daemon-side identity validation. + +For a rebuilt kernel containing the frequency patch: + +```sh +python3 build/runtime/tools/build-riot-cpu-frequency-check.py +darling shell /Volumes/SystemRoot/ABSOLUTE_PROJECT_PATH/build/riot-cpu-frequency-check +``` + +Local validation also ran the fd helper inside Darling and used an isolated +kernel override for the frequency probe. The configured production runtime was +not replaced, no real identity was changed, and no match capture is required. diff --git a/patches/iokitd.patch b/patches/iokitd.patch index 96d4963..c3333e1 100644 --- a/patches/iokitd.patch +++ b/patches/iokitd.patch @@ -26,10 +26,227 @@ index 84161fd..5b23944 100644 src/IOSurface.mm src/IOUserClient.mm diff --git a/src/IORegistryEntry.mm b/src/IORegistryEntry.mm -index 50c10fe..bd501cf 100644 +index 50c10fe..7026e1c 100644 --- a/src/IORegistryEntry.mm +++ b/src/IORegistryEntry.mm -@@ -513,3 +513,86 @@ kern_return_t is_io_registry_entry_get_property_bin +@@ -1,11 +1,15 @@ + #include "IORegistryEntry.h" + #include + #include ++#include + #include + #include + #include + #include + #include ++#include ++#import ++#import + #include "IOIterator.h" + extern "C" { + #include "iokitmigServer.h" +@@ -29,6 +33,12 @@ IORegistryEntry::~IORegistryEntry() + { + std::unique_lock l(m_entriesByIdMutex); + m_entriesById.erase(m_id); ++ // Registry requests and object teardown run on iokitd's main dispatch queue. ++ // Detach both directions so a later traversal cannot follow a deleted entry. ++ for (auto& plane : m_parents) ++ for (auto* parent : plane.second) parent->m_children[plane.first].erase(this); ++ for (auto& plane : m_children) ++ for (auto* child : plane.second) child->m_parents[plane.first].erase(this); + } + + IORegistryEntry* IORegistryEntry::findById(uint64_t id) +@@ -79,29 +89,42 @@ std::set IORegistryEntry::getChildren(const char* plane) + + void IORegistryEntry::registerInPlane(const char* plane, const char* pathName, IORegistryEntry* parent) + { ++ if (!plane || !*plane || !pathName || !*pathName || !parent || parent == this) return; ++ std::set visited; ++ std::vector pending { parent }; ++ while (!pending.empty()) { ++ auto* current = pending.back(); pending.pop_back(); ++ if (current == this) return; // refuse cycles, including indirect ones ++ if (!visited.insert(current).second) continue; ++ for (auto* ancestor : current->getParents(plane)) pending.push_back(ancestor); ++ } + m_name[plane] = pathName; ++ if (parent == root()) parent->m_name.emplace(plane, "Root"); + + auto it = m_parents.find(plane); + + if (it == m_parents.end()) +- it = m_parents.insert(std::make_pair(std::string_view(plane), std::set())).first; ++ it = m_parents.insert(std::make_pair(std::string(plane), std::set())).first; + + it->second.insert(parent); + + it = parent->m_children.find(plane); + if (it == parent->m_children.end()) +- it = parent->m_children.insert(std::make_pair(std::string_view(plane), std::set())).first; ++ it = parent->m_children.insert(std::make_pair(std::string(plane), std::set())).first; + + it->second.insert(this); + } + + std::string IORegistryEntry::getPath(const char* plane) + { ++ if (!inPlane(plane)) return "?unsup_plane?"; + std::stack components; ++ std::set visited; + IORegistryEntry* cur = this; + + while (true) + { ++ if (!visited.insert(cur).second) return "?unsup_plane?"; + std::set parents = cur->getParents(plane); + if (parents.empty()) + { +@@ -113,18 +136,22 @@ std::string IORegistryEntry::getPath(const char* plane) + + components.push(cur->getName(plane)); + +- IORegistryEntry* parent = *parents.begin(); ++ IORegistryEntry* parent = nullptr; ++ for (auto* candidate : parents) if (candidate->inPlane(plane)) { parent = candidate; break; } ++ if (!parent) return "?unsup_plane?"; + cur = parent; + } + + std::string path = plane; + path += ":"; ++ if (components.empty()) path += "/"; + + while (!components.empty()) + { + path += "/"; + path += components.top(); + components.pop(); ++ if (path.size() > 65535) return "?unsup_plane?"; + } + + return path; +@@ -166,11 +193,15 @@ kern_return_t is_io_registry_entry_get_path + io_string_t path + ) + { ++ if (!path) return kIOReturnBadArgument; + IORegistryEntry* e = dynamic_cast(IOObject::lookup(registry_entry)); + if (!e) + return kIOReturnBadArgument; + ++ if (!plane || !memchr(plane, 0, sizeof(io_name_t)) || !e->inPlane(plane)) return kIOReturnBadArgument; + std::string mypath = e->getPath(plane); ++ if (mypath == "?unsup_plane?") return kIOReturnBadArgument; ++ if (mypath.size() >= sizeof(io_string_t)) { path[0] = 0; return kIOReturnNoSpace; } + strlcpy(path, mypath.c_str(), sizeof(io_string_t)); + + return kIOReturnSuccess; +@@ -386,55 +417,59 @@ kern_return_t is_io_registry_entry_get_location_in_plane + return KERN_NOT_SUPPORTED; + } + +-kern_return_t is_io_registry_entry_from_path +-( +- mach_port_t master_port, +- io_string_t path, +- mach_port_t *registry_entry +-) ++bool IORegistryEntry::inPlane(const char* plane) const + { +- *registry_entry = 0; +- +- std::vector components; +- std::istringstream iss(path); +- +- for (std::string token; std::getline(iss, token, '/'); ) +- { +- if (!token.empty()) +- components.push_back(std::move(token)); ++ if (!plane || !*plane || m_name.find(plane) == m_name.end()) return false; ++ std::set visited; ++ std::vector pending { this }; ++ while (!pending.empty()) { ++ auto* current = pending.back(); pending.pop_back(); ++ if (current == root()) return true; ++ if (!visited.insert(current).second) continue; ++ auto parents = current->m_parents.find(plane); ++ if (parents != current->m_parents.end()) ++ for (auto* parent : parents->second) pending.push_back(parent); + } +- +- if (components.empty()) +- return kIOReturnBadArgument; +- +- std::string& planeName = components[0]; +- if (planeName[planeName.length()-1] != ':') +- return kIOReturnBadArgument; +- else +- planeName.resize(planeName.length()-1); // remove the final colon +- +- IORegistryEntry* current = IORegistryEntry::root(); +- for (size_t i = 1; i < components.size(); i++) +- { +- auto children = current->getChildren(planeName.c_str()); +- IORegistryEntry* next = nullptr; ++ return false; ++} + +- for (IORegistryEntry* e : children) +- { +- if (strcasecmp(e->getName(planeName.c_str()).c_str(), components[i].c_str()) == 0) +- { +- next = e; +- break; +- } ++IORegistryEntry* IORegistryEntry::fromPath(std::string_view path) ++{ ++ if (path.empty() || path.size() > 65535 || path.find('\0') != std::string_view::npos) return nullptr; ++ auto colon = path.find(':'); ++ if (colon == 0 || colon == std::string_view::npos || colon >= sizeof(io_name_t)) return nullptr; ++ std::string plane(path.substr(0, colon)); ++ if (plane.find('/') != std::string::npos) return nullptr; ++ auto* current = root(); ++ if (!current->inPlane(plane.c_str())) return nullptr; ++ path.remove_prefix(colon + 1); ++ if (!path.empty() && path.front() != '/') return nullptr; ++ while (!path.empty()) { ++ path.remove_prefix(1); ++ auto slash = path.find('/'); ++ auto name = path.substr(0, slash); ++ if (!name.empty()) { ++ if (name == "." || name == "..") return nullptr; ++ IORegistryEntry* next = nullptr; ++ for (auto* child : current->getChildren(plane.c_str())) ++ if (child->getName(plane.c_str()) == name) { next = child; break; } ++ if (!next) return nullptr; ++ current = next; + } +- +- if (!next) +- return kIOReturnNoDevice; +- current = next; ++ if (slash == std::string_view::npos) break; ++ path.remove_prefix(slash); + } ++ return current; // borrowed until this main-queue request completes ++} + +- *registry_entry = current->port(); +- ++kern_return_t is_io_registry_entry_from_path(mach_port_t master_port, io_string_t path, mach_port_t* registry_entry) ++{ ++ if (!registry_entry) return kIOReturnBadArgument; ++ *registry_entry = MACH_PORT_NULL; ++ if (!path || !memchr(path, 0, sizeof(io_string_t))) return kIOReturnBadArgument; ++ auto* entry = IORegistryEntry::fromPath(path); ++ if (!entry) return kIOReturnNoDevice; ++ *registry_entry = entry->port(); + return kIOReturnSuccess; + } + +@@ -513,3 +548,189 @@ kern_return_t is_io_registry_entry_get_property_bin return kr; } @@ -116,6 +333,109 @@ index 50c10fe..bd501cf 100644 + CFRelease(data); + return kr; +} ++ ++// OOL input ownership transfers to this server; release it on every exit path. ++kern_return_t is_io_registry_entry_from_path_ool(mach_port_t master_port, io_string_inband_t path, ++ io_buf_ptr_t path_ool, mach_msg_type_number_t path_oolCnt, kern_return_t* result, mach_port_t* registry_entry) ++{ ++ struct Input { ++ io_buf_ptr_t bytes; mach_msg_type_number_t size; ++ ~Input() { if (bytes && size) vm_deallocate(mach_task_self(), (vm_address_t)bytes, size); } ++ } input {path_ool, path_oolCnt}; ++ if (!result || !registry_entry) return kIOReturnBadArgument; ++ *registry_entry = MACH_PORT_NULL; ++ *result = kIOReturnBadArgument; ++ std::string_view fullPath; ++ if (path_oolCnt || path_ool) { ++ if (!path_ool || !path_oolCnt || path_oolCnt > 65536 || !path || path[0] || ++ strnlen(path_ool, path_oolCnt) != path_oolCnt - 1) return kIOReturnSuccess; ++ fullPath = std::string_view(path_ool, path_oolCnt - 1); ++ } else { ++ if (!path || !memchr(path, 0, sizeof(io_string_inband_t))) return kIOReturnSuccess; ++ fullPath = path; ++ } ++ auto* entry = IORegistryEntry::fromPath(fullPath); ++ if (entry) { *registry_entry = entry->port(); *result = kIOReturnSuccess; } ++ else *result = kIOReturnNoDevice; ++ return kIOReturnSuccess; ++} ++ ++kern_return_t is_io_registry_entry_get_path_ool(mach_port_t registry_entry, io_name_t plane, ++ io_string_inband_t path, io_buf_ptr_t* path_ool, mach_msg_type_number_t* path_oolCnt) ++{ ++ if (!path || !path_ool || !path_oolCnt) return kIOReturnBadArgument; ++ path[0] = 0; *path_ool = nullptr; *path_oolCnt = 0; ++ auto* entry = dynamic_cast(IOObject::lookup(registry_entry)); ++ if (!entry || !plane || !memchr(plane, 0, sizeof(io_name_t)) || !entry->inPlane(plane)) return kIOReturnBadArgument; ++ auto value = entry->getPath(plane); ++ if (value == "?unsup_plane?") return kIOReturnBadArgument; ++ if (value.size() < sizeof(io_string_inband_t)) { ++ strlcpy(path, value.c_str(), sizeof(io_string_inband_t)); ++ } else { ++ // MIG uses physicalcopy with deallocate=false. The main queue completes ++ // each reply before its next request, so one bounded reusable buffer ++ // keeps the sender's bytes alive without leaking one VM allocation/call. ++ static std::string reply; ++ reply.assign(value); ++ *path_ool = const_cast(reply.data()); ++ *path_oolCnt = reply.size(); // IOKit creates a CFString from bytes, excluding NUL ++ } ++ return kIOReturnSuccess; ++} ++ ++kern_return_t is_io_registry_entry_in_plane(mach_port_t registry_entry, io_name_t plane, boolean_t* inPlane) ++{ ++ if (!inPlane) return kIOReturnBadArgument; ++ *inPlane = FALSE; ++ auto* entry = dynamic_cast(IOObject::lookup(registry_entry)); ++ if (!entry || !plane || !memchr(plane, 0, sizeof(io_name_t))) return kIOReturnBadArgument; ++ *inPlane = entry->inPlane(plane); ++ return kIOReturnSuccess; ++} ++ ++kern_return_t is_io_registry_entry_get_property_bytes(mach_port_t registry_entry, io_name_t property_name, ++ io_struct_inband_t data, mach_msg_type_number_t* dataCnt) ++{ ++ if (!dataCnt) return kIOReturnBadArgument; ++ size_t capacity = std::min(*dataCnt, sizeof(io_struct_inband_t)); ++ *dataCnt = 0; ++ auto* entry = dynamic_cast(IOObject::lookup(registry_entry)); ++ if (!entry || !property_name || !memchr(property_name, 0, sizeof(io_name_t))) return kIOReturnBadArgument; ++ NSString* key = [NSString stringWithUTF8String:property_name]; ++ if (!key) return kIOReturnBadArgument; ++ id value = [entry->getProperties() objectForKey:key]; ++ if (!value) return kIOReturnNoResources; ++ const void* bytes = nullptr; ++ size_t length = 0; ++ uint64_t number = 0; ++ if ([value isKindOfClass:[NSData class]]) { ++ bytes = [value bytes]; length = [value length]; ++ } else if ([value isKindOfClass:[NSString class]]) { ++ bytes = [value UTF8String]; ++ if (!bytes) return kIOReturnBadArgument; ++ length = strlen((const char*)bytes) + 1; ++ } else if ([value isKindOfClass:[NSNumber class]] && CFGetTypeID((CFTypeRef)value) == CFBooleanGetTypeID()) { ++ bytes = [value boolValue] ? "Yes" : "No"; ++ length = [value boolValue] ? sizeof("Yes") : sizeof("No"); ++ } else if ([value isKindOfClass:[NSNumber class]]) { ++ const char* type = [value objCType]; ++ switch (type ? type[0] : 0) { ++ case 'c': case 'C': case 'B': length = 1; break; ++ case 's': case 'S': length = 2; break; ++ case 'i': case 'I': length = 4; break; ++ case 'l': case 'L': length = sizeof(long); break; ++ case 'q': case 'Q': length = 8; break; ++ default: return kIOReturnBadArgument; ++ } ++ number = [value unsignedLongLongValue]; bytes = &number; ++ } else return kIOReturnBadArgument; ++ if (length > std::numeric_limits::max()) return kIOReturnNoSpace; ++ if (length > capacity) { *dataCnt = capacity; return kIOReturnIPCError; } ++ *dataCnt = length; ++ if (length && !data) { *dataCnt = 0; return kIOReturnBadArgument; } ++ if (length) memcpy(data, bytes, length); ++ return kIOReturnSuccess; ++} diff --git a/src/IOSurfaceRoot.mm b/src/IOSurfaceRoot.mm index d7e6fab..d860582 100644 --- a/src/IOSurfaceRoot.mm @@ -503,13 +823,25 @@ index a568ecc..c2d4063 100644 /* Routine io_ps_copy_chargelog */ diff --git a/src/stubs.c b/src/stubs.c -index 91d11a1..37551e1 100644 +index 91d11a1..5a00091 100644 --- a/src/stubs.c +++ b/src/stubs.c -@@ -45,15 +45,6 @@ kern_return_t is_io_registry_entry_get_property_bytes +@@ -33,26 +33,7 @@ kern_return_t is_io_registry_iterator_exit_entry return KERN_NOT_SUPPORTED; } +-kern_return_t is_io_registry_entry_get_property_bytes +-( +- mach_port_t registry_entry, +- io_name_t property_name, +- io_struct_inband_t data, +- mach_msg_type_number_t *dataCnt +-) +-{ +- STUB(); +- return KERN_NOT_SUPPORTED; +-} + -kern_return_t is_io_service_close -( - mach_port_t connection @@ -518,11 +850,28 @@ index 91d11a1..37551e1 100644 - STUB(); - return KERN_NOT_SUPPORTED; -} -- + kern_return_t is_io_connect_get_service ( - mach_port_t connection, -@@ -325,20 +316,6 @@ kern_return_t is_io_service_add_notification +@@ -124,16 +105,7 @@ kern_return_t is_io_registry_entry_set_properties + return KERN_NOT_SUPPORTED; + } + +-kern_return_t is_io_registry_entry_in_plane +-( +- mach_port_t registry_entry, +- io_name_t plane, +- boolean_t *inPlane +-) +-{ +- STUB(); +- return KERN_NOT_SUPPORTED; +-} ++ + + kern_return_t is_io_service_get_busy_state + ( +@@ -325,20 +297,6 @@ kern_return_t is_io_service_add_notification return KERN_NOT_SUPPORTED; } @@ -543,7 +892,7 @@ index 91d11a1..37551e1 100644 kern_return_t is_io_service_acknowledge_notification ( mach_port_t service, -@@ -439,22 +416,6 @@ kern_return_t is_io_object_get_bundle_identifier +@@ -439,22 +397,6 @@ kern_return_t is_io_object_get_bundle_identifier return KERN_NOT_SUPPORTED; } @@ -566,7 +915,7 @@ index 91d11a1..37551e1 100644 kern_return_t is_io_connect_map_memory_into_task ( mach_port_t connection, -@@ -533,20 +494,6 @@ kern_return_t is_io_service_add_notification_64 +@@ -533,20 +475,6 @@ kern_return_t is_io_service_add_notification_64 return KERN_NOT_SUPPORTED; } @@ -587,7 +936,7 @@ index 91d11a1..37551e1 100644 kern_return_t is_io_service_add_notification_ool_64 ( mach_port_t master_port, -@@ -642,18 +589,6 @@ kern_return_t is_io_server_version +@@ -642,18 +570,6 @@ kern_return_t is_io_server_version } @@ -606,7 +955,7 @@ index 91d11a1..37551e1 100644 kern_return_t is_io_service_match_property_table_bin ( mach_port_t service, -@@ -682,22 +617,6 @@ kern_return_t is_io_service_add_notification_bin +@@ -682,48 +598,9 @@ kern_return_t is_io_service_add_notification_bin return KERN_NOT_SUPPORTED; } @@ -625,11 +974,38 @@ index 91d11a1..37551e1 100644 - STUB(); - return KERN_NOT_SUPPORTED; -} -- - kern_return_t is_io_registry_entry_from_path_ool + +-kern_return_t is_io_registry_entry_from_path_ool +-( +- mach_port_t master_port, +- io_string_inband_t path, +- io_buf_ptr_t path_ool, +- mach_msg_type_number_t path_oolCnt, +- kern_return_t *result, +- mach_port_t *registry_entry +-) +-{ +- STUB(); +- return KERN_NOT_SUPPORTED; +-} + +-kern_return_t is_io_registry_entry_get_path_ool +- ( +- mach_port_t registry_entry, +- io_name_t plane, +- io_string_inband_t path, +- io_buf_ptr_t *path_ool, +- mach_msg_type_number_t *path_oolCnt +-) +-{ +- STUB(); +- return KERN_NOT_SUPPORTED; +-} ++ + + kern_return_t is_io_device_tree_entry_exists_with_name ( - mach_port_t master_port, -@@ -736,31 +655,3 @@ kern_return_t is_io_device_tree_entry_exists_with_name +@@ -736,31 +613,3 @@ kern_return_t is_io_device_tree_entry_exists_with_name return KERN_NOT_SUPPORTED; } @@ -690,11 +1066,12 @@ index 0000000..38ad635 +#endif diff --git a/src/IOPlatformExpertDevice.mm b/src/IOPlatformExpertDevice.mm new file mode 100644 -index 0000000..e330569 +index 0000000..71b9f4d --- /dev/null +++ b/src/IOPlatformExpertDevice.mm -@@ -0,0 +1,93 @@ +@@ -0,0 +1,98 @@ +#include "IOPlatformExpertDevice.h" ++#include "PlatformIdentity.h" +#import +#import +#import @@ -770,6 +1147,10 @@ index 0000000..e330569 + properties[@"IOPlatformUUID"] = uuid; + properties[@"IOPlatformSerialNumber"] = serial; + } ++ // Opt-in, immutable startup snapshot. No configuration preserves the existing ++ // per-host identifiers; daemon restart is the explicit activation boundary. ++ static NSDictionary* overrides = [DarlingPlatformIdentity::load("/private/etc/darling-platform.plist") copy]; ++ if (overrides) [properties addEntriesFromDictionary:overrides]; + return properties; +} + @@ -3115,3 +3496,111 @@ index 7a6fcfa..26a7d68 100644 + m_changedDPMS = false; } } +diff --git a/src/IORegistryEntry.h b/src/IORegistryEntry.h +index 90155ed..e955bd4 100644 +--- a/src/IORegistryEntry.h ++++ b/src/IORegistryEntry.h +@@ -19,6 +19,8 @@ public: + + std::string_view getPathName(const char* plane); + std::string getPath(const char* plane); ++ bool inPlane(const char* plane) const; ++ static IORegistryEntry* fromPath(std::string_view path); + std::set getParents(const char* plane); + std::set getChildren(const char* plane); + std::string getName(const char* plane = nullptr); +@@ -32,8 +34,8 @@ public: + void registerInPlane(const char* plane, const char* pathName, IORegistryEntry* parent); + protected: + // The key is the plane name, e.g. kIOServicePlane (as declared in IOKit/IOKitKeys.h) +- std::unordered_map m_name; +- std::unordered_map> m_parents, m_children; ++ std::unordered_map m_name; ++ std::unordered_map> m_parents, m_children; + uint64_t m_id; + + static std::unordered_map m_entriesById; +diff --git a/src/PlatformIdentity.h b/src/PlatformIdentity.h +new file mode 100644 +index 0000000..1f83b65 +--- /dev/null ++++ b/src/PlatformIdentity.h +@@ -0,0 +1,78 @@ ++#ifndef DARLING_PLATFORM_IDENTITY_H ++#define DARLING_PLATFORM_IDENTITY_H ++#import ++#include ++#include ++#include ++#include ++ ++namespace DarlingPlatformIdentity { ++inline bool printable(id value, size_t maximum) { ++ if (![value isKindOfClass:[NSString class]]) return false; ++ const char* text = [value UTF8String]; ++ if (!text) return false; ++ size_t length = strlen(text); ++ if (!length || length > maximum || length != [value length]) return false; ++ for (size_t i = 0; i < length; ++i) if ((unsigned char)text[i] < 32 || (unsigned char)text[i] > 126) return false; ++ return true; ++} ++ ++// Validate the entire schema before exposing any overrides. Strings on device ++// tree properties are NUL-terminated NSData; serial/UUID retain NSString types. ++inline NSDictionary* properties(id configuration) { ++ if (![configuration isKindOfClass:[NSDictionary class]] || [configuration count] != 2) return nil; ++ id schema = [configuration objectForKey:@"schema"]; ++ if (![schema isKindOfClass:[NSNumber class]] || CFGetTypeID((CFTypeRef)schema) == CFBooleanGetTypeID() || ++ strchr("fd", [schema objCType][0]) || [schema longLongValue] != 1) return nil; ++ id source = [configuration objectForKey:@"properties"]; ++ if (![source isKindOfClass:[NSDictionary class]] || [source count] > 9) return nil; ++ NSMutableDictionary* result = [NSMutableDictionary dictionary]; ++ for (id key in source) { ++ if (![key isKindOfClass:[NSString class]]) return nil; ++ id value = [source objectForKey:key]; ++ if ([key isEqual:@"IOPlatformUUID"]) { ++ if (!printable(value, 36) || [value length] != 36) return nil; ++ uuid_t uuid; ++ if (uuid_parse([value UTF8String], uuid) || uuid_is_null(uuid)) return nil; ++ uuid_string_t text; uuid_unparse_upper(uuid, text); ++ result[key] = [NSString stringWithUTF8String:text]; ++ unsigned char bytes[16]; memcpy(bytes, uuid, sizeof(bytes)); ++ for (int i = 0; i < 4; ++i) bytes[i] = uuid[3-i]; ++ bytes[4] = uuid[5]; bytes[5] = uuid[4]; bytes[6] = uuid[7]; bytes[7] = uuid[6]; ++ NSData* systemID = [NSData dataWithBytes:bytes length:16]; ++ if (source[@"system-id"] && ![source[@"system-id"] isEqual:systemID]) return nil; ++ result[@"system-id"] = systemID; ++ } else if ([key isEqual:@"ROM"] || [key isEqual:@"system-id"]) { ++ if (![value isKindOfClass:[NSData class]] || [value length] != ([key isEqual:@"ROM"] ? 6 : 16)) return nil; ++ result[key] = value; ++ } else if ([key isEqual:@"IOPlatformSerialNumber"]) { ++ if (!printable(value, 64)) return nil; ++ result[key] = value; ++ } else if ([key isEqual:@"manufacturer"] || [key isEqual:@"model"] || [key isEqual:@"product-name"] || ++ [key isEqual:@"board-id"] || [key isEqual:@"mlb-serial-number"]) { ++ if (!printable(value, [key isEqual:@"mlb-serial-number"] ? 64 : 128)) return nil; ++ const char* text = [value UTF8String]; ++ result[key] = [NSData dataWithBytes:text length:strlen(text)+1]; ++ } else return nil; ++ } ++ if (result[@"model"] && !result[@"product-name"]) result[@"product-name"] = result[@"model"]; ++ return result; ++} ++ ++inline NSDictionary* load(const char* path) { ++ FILE* file = fopen(path, "rb"); ++ if (!file) return nil; // no overrides is the normal, unchanged default ++ std::array buffer; ++ size_t size = fread(buffer.data(), 1, buffer.size(), file); ++ bool failed = ferror(file); fclose(file); ++ if (!failed && size <= 65536) { ++ NSData* data = [NSData dataWithBytes:buffer.data() length:size]; ++ id config = [NSPropertyListSerialization propertyListWithData:data options:NSPropertyListImmutable format:NULL error:NULL]; ++ NSDictionary* overrides = properties(config); ++ if (overrides) return overrides; ++ } ++ fprintf(stderr, "iokitd: invalid darling-platform.plist; retaining default platform properties\n"); ++ return nil; ++} ++} ++#endif diff --git a/tests/test_platform_identity.py b/tests/test_platform_identity.py new file mode 100644 index 0000000..45602e9 --- /dev/null +++ b/tests/test_platform_identity.py @@ -0,0 +1,157 @@ +"""Disposable-prefix checks for explicit platform identity configuration.""" +import importlib.util +import json +import os +from pathlib import Path +import plistlib +import stat +import subprocess +import sys +import tempfile +import unittest +import uuid + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'tools/configure-darling-platform.py' +spec = importlib.util.spec_from_file_location('platform_config', SCRIPT) +platform = importlib.util.module_from_spec(spec) +spec.loader.exec_module(platform) +UUID = '00112233-4455-4677-8899-AABBCCDDEEFF' + + +class PlatformIdentityChecks(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.prefix = self.root / 'prefix with spaces' + + def source(self, name, data): + path = self.root / name + path.write_bytes(data) + return path + + def test_opencore_import_and_uuid_byte_order(self): + source = self.source('OpenCore.plist', plistlib.dumps({'PlatformInfo': {'Generic': { + 'SystemProductName': 'ExampleModel1,1', 'SystemSerialNumber': 'MY-SERIAL', + 'SystemUUID': UUID, 'MLB': 'MY-BOARD', 'ROM': bytes.fromhex('0123456789AB'), + 'SpoofVendor': True}}})) + result = platform.load(source) + self.assertEqual(result['model'], result['product-name']) + self.assertEqual(result['IOPlatformUUID'], UUID) + self.assertEqual(result['system-id'].hex().upper(), '33221100554477468899AABBCCDDEEFF') + self.assertEqual(result['ROM'], bytes.fromhex('0123456789AB')) + self.assertNotIn('SpoofVendor', result) + + def test_gensmbios_partial_import_does_not_generate_missing_identity(self): + source = self.source('identity.json', json.dumps({'Type': 'ExampleModel1,1', 'Serial': 'SUPPLIED'}).encode()) + result = platform.load(source) + self.assertEqual(set(result), {'model', 'product-name', 'IOPlatformSerialNumber'}) + self.assertNotIn('IOPlatformUUID', result) + + def test_invalid_values_are_rejected(self): + for properties in [ + {'IOPlatformUUID': 'not-a-uuid'}, {'IOPlatformUUID': str(uuid.UUID(int=0))}, + {'IOPlatformUUID': UUID, 'system-id': bytes(16)}, {'ROM': bytes(7)}, + {'ROM': 'xyz'}, {'model': 'bad\x00name'}, {'model': 'bad\nname'}, + {'model': 'é'}, {'model': 'X'*129}, {'IOPlatformSerialNumber': ''}, + {'IOPlatformSerialNumber': 123}, {'unknown': 'value'}, {'ROM': True}, + ]: + with self.subTest(properties=properties), self.assertRaises((ValueError, TypeError)): + platform.validate(properties) + + def test_schema_versions_types_and_unknown_fields(self): + for payload in [{'schema': 2, 'properties': {}}, {'schema': True, 'properties': {}}, + {'schema': 1, 'properties': {}, 'extra': 1}, {'schema': 1, 'properties': []}]: + source = self.source('bad.json', json.dumps(payload).encode()) + with self.subTest(payload=payload), self.assertRaises(ValueError): platform.load(source) + source = self.source('huge.json', b' ' * (platform.MAX_SIZE + 1)) + with self.assertRaises(ValueError): platform.load(source) + + def test_legacy_migration_never_executes_or_rewrites_source(self): + marker = self.root / 'executed' + raw = ('SMBIOS_MODEL="ExampleModel1,1"\nSMBIOS_SERIAL_NUMBER="SUPPLIED"\n' + f'SMBIOS_CPU_SPEED="$(touch {marker})"\n').encode() + source = self.source('darling-smbios.conf', raw) + properties = platform.load(source) + self.assertTrue(platform.save(self.prefix, properties)) + self.assertEqual(source.read_bytes(), raw) + self.assertFalse(marker.exists()) + self.assertNotIn('SMBIOS_CPU_SPEED', properties) + + def test_stable_write_backup_permissions_and_roundtrip(self): + first = {'IOPlatformSerialNumber': 'FIRST', 'IOPlatformUUID': UUID} + self.assertTrue(platform.save(self.prefix, first)) + path = platform.destination(self.prefix) + original = path.read_bytes() + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + self.assertFalse(platform.save(self.prefix, platform.load(path))) + self.assertEqual(path.read_bytes(), original) + self.assertTrue(platform.save(self.prefix, {'IOPlatformSerialNumber': 'SECOND'})) + self.assertEqual(path.with_name(path.name+'.before-platform-change').read_bytes(), original) + self.assertEqual(platform.load(path), {'IOPlatformSerialNumber': 'SECOND'}) + + def test_prefix_isolation(self): + other = self.root / 'other' + platform.save(self.prefix, {'IOPlatformSerialNumber': 'FIRST'}) + platform.save(other, {'IOPlatformSerialNumber': 'SECOND'}) + self.assertEqual(platform.load(platform.destination(self.prefix))['IOPlatformSerialNumber'], 'FIRST') + self.assertEqual(platform.load(platform.destination(other))['IOPlatformSerialNumber'], 'SECOND') + + def test_no_implicit_migration_without_an_override_file(self): + env = dict(os.environ, DPREFIX=str(self.prefix)) + result = subprocess.run([sys.executable, str(SCRIPT)], env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)['properties'], {}) + self.assertFalse(self.prefix.exists()) + + def test_preview_and_uuid_replacement_preserve_other_values(self): + platform.save(self.prefix, {'IOPlatformSerialNumber': 'FIRST', 'IOPlatformUUID': UUID}) + replacement = '11112233-4455-4677-8899-AABBCCDDEEFF' + command = [sys.executable, str(SCRIPT), '--prefix', str(self.prefix), '--set', 'IOPlatformUUID='+replacement] + path = platform.destination(self.prefix) + before = path.read_bytes() + preview = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(preview.returncode, 0, preview.stderr) + self.assertEqual(path.read_bytes(), before) + written = subprocess.run([*command, '--write'], capture_output=True, text=True) + self.assertEqual(written.returncode, 0, written.stderr) + result = platform.load(path) + self.assertEqual(result['IOPlatformSerialNumber'], 'FIRST') + self.assertEqual(result['IOPlatformUUID'], replacement) + self.assertEqual(result['system-id'], uuid.UUID(replacement).bytes_le) + + def test_symlink_escape_is_rejected(self): + outside = self.root / 'outside' + outside.mkdir() + (self.prefix / 'private').mkdir(parents=True) + (self.prefix / 'private/etc').symlink_to(outside, target_is_directory=True) + with self.assertRaises(ValueError): platform.save(self.prefix, {}) + self.assertEqual(list(outside.iterdir()), []) + + def test_live_prefix_and_unknown_existing_schema_are_not_overwritten(self): + self.prefix.mkdir() + marker = self.prefix / '.init.pid' + marker.write_text(str(os.getpid())) + with self.assertRaises(ValueError): platform.save(self.prefix, {}) + marker.unlink() + path = platform.destination(self.prefix) + path.parent.mkdir(parents=True) + bad = plistlib.dumps({'schema': 999, 'properties': {}}) + path.write_bytes(bad) + with self.assertRaises(ValueError): platform.save(self.prefix, {}) + self.assertEqual(path.read_bytes(), bad) + + def test_duplicate_json_and_invalid_live_marker_are_rejected(self): + duplicate = self.source('duplicate.json', b'{"schema":1,"schema":2,"properties":{}}') + with self.assertRaises(ValueError): platform.load(duplicate) + self.prefix.mkdir() + (self.prefix / '.init.pid').write_text('0') + with self.assertRaises(ValueError): platform.save(self.prefix, {}) + + def test_malformed_xml_reports_an_error_without_a_traceback(self): + source = self.source('bad.plist', b'') + result = subprocess.run([sys.executable, str(SCRIPT), '--prefix', str(self.prefix), '--import', str(source)], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn('Traceback', result.stderr) + self.assertFalse(self.prefix.exists()) diff --git a/tools/build-riot-registry-compatibility-check.py b/tools/build-riot-registry-compatibility-check.py new file mode 100644 index 0000000..e20c2f2 --- /dev/null +++ b/tools/build-riot-registry-compatibility-check.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Build an isolated iokitd candidate and a real-object registry/identity probe.""" +import argparse +from pathlib import Path +import re +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--source', type=Path, default=root / 'build/darling-src/src/external/iokitd', + help='Prepared iokitd source directory') +args = parser.parse_args() +folder = root / 'build/darling-src/build/src/external/iokitd' +flags = {} +resource = subprocess.check_output(['clang', '-print-resource-dir'], text=True).strip() +for line in (folder / 'CMakeFiles/iokitd.dir/flags.make').read_text().splitlines(): + if line.startswith(('C_', 'CXX_')) and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = [re.sub(r'/usr/lib/clang/\d+/include', resource + '/include', value) for value in shlex.split(value)] +link = shlex.split((folder / 'CMakeFiles/iokitd.dir/link.txt').read_text()) + + +def compile(source, output, kind): + subprocess.run(['clang++' if kind == 'CXX' else 'clang', *flags[kind + '_DEFINES'], + '-I' + str(args.source / 'src'), *flags[kind + '_INCLUDES'], *flags[kind + '_FLAGS'], + '-O2', '-c', str(source), '-o', str(output)], check=True) + + +for argument in link[:]: + if argument.startswith('CMakeFiles/iokitd.dir/src/') and argument.endswith('.o'): + relative = argument.split('iokitd.dir/', 1)[1][:-2] + source = args.source / relative + output = root / ('build/registry-candidate-' + source.name + '.o') + compile(source, output, 'CXX' if source.suffix in ('.mm', '.cpp') else 'C') + link[link.index(argument)] = str(output) +link[link.index('-o') + 1] = str(root / 'build/riot-iokitd-candidate') +subprocess.run(link, cwd=folder, check=True) +probe = root / 'build/registry-compatibility-probe.o' +compile(root / 'tools/riot-registry-compatibility-check.mm', probe, 'CXX') +link[link.index(str(root / 'build/registry-candidate-main.mm.o'))] = str(probe) +user = root / 'build/registry-compatibility-client.o' +compile(folder / 'iokitmigUser.c', user, 'C') +link.append(str(user)) +link[link.index('-o') + 1] = str(root / 'build/riot-registry-compatibility-check') +subprocess.run(link, cwd=folder, check=True) +print('Built isolated iokitd candidate and registry compatibility probe') diff --git a/tools/configure-darling-platform.py b/tools/configure-darling-platform.py new file mode 100644 index 0000000..9ba1c87 --- /dev/null +++ b/tools/configure-darling-platform.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Preview or explicitly save per-prefix IOKit platform overrides; defaults stay unchanged.""" +import argparse +import json +import os +from pathlib import Path +import plistlib +import shlex +import tempfile +import uuid +from xml.parsers.expat import ExpatError + +MAX_SIZE = 65536 +STRINGS = {'IOPlatformSerialNumber': 64, 'manufacturer': 128, 'model': 128, + 'product-name': 128, 'board-id': 128, 'mlb-serial-number': 64} +BYTES = {'ROM': 6, 'system-id': 16} +ALIASES = { + 'SystemProductName': 'model', 'Type': 'model', 'SMBIOS_MODEL': 'model', + 'SMBIOS_PRODUCT_NAME': 'product-name', 'SMBIOS_MANUFACTURER': 'manufacturer', + 'SMBIOS_BOARD_ID': 'board-id', 'SystemSerialNumber': 'IOPlatformSerialNumber', + 'Serial': 'IOPlatformSerialNumber', 'SMBIOS_SERIAL_NUMBER': 'IOPlatformSerialNumber', + 'SystemUUID': 'IOPlatformUUID', 'SmUUID': 'IOPlatformUUID', 'SMBIOS_PLATFORM_UUID': 'IOPlatformUUID', + 'MLB': 'mlb-serial-number', 'Board Serial': 'mlb-serial-number', 'SMBIOS_MLB': 'mlb-serial-number', + 'SMBIOS_ROM': 'ROM', 'SMBIOS_SYSTEM_ID': 'system-id', +} + + +def validate(properties): + if not isinstance(properties, dict) or len(properties) > 9: + raise ValueError('properties must be a dictionary of supported identity fields') + result = {} + for key, value in properties.items(): + if key in STRINGS: + if not isinstance(value, str) or not 1 <= len(value) <= STRINGS[key] or any(not 32 <= ord(c) <= 126 for c in value): + raise ValueError('Invalid printable string for ' + key) + elif key == 'IOPlatformUUID': + if not isinstance(value, str): + raise ValueError('IOPlatformUUID must be a UUID string') + parsed = uuid.UUID(value) + if not parsed.int: + raise ValueError('IOPlatformUUID must not be zero') + value = str(parsed).upper() + elif key in BYTES: + if isinstance(value, str): + value = bytes.fromhex(value.replace(':', '').replace('-', '')) + if not isinstance(value, bytes) or len(value) != BYTES[key]: + raise ValueError(key + ' has an invalid byte length') + else: + raise ValueError('Unsupported identity field: ' + str(key)) + result[key] = value + if 'model' in result: + result.setdefault('product-name', result['model']) + if 'IOPlatformUUID' in result: + system_id = uuid.UUID(result['IOPlatformUUID']).bytes_le + if 'system-id' in result and result['system-id'] != system_id: + raise ValueError('system-id does not match IOPlatformUUID') + result['system-id'] = system_id + return result + + +def load(source): + with Path(source).open("rb") as stream: + raw = stream.read(MAX_SIZE + 1) + if len(raw) > MAX_SIZE: + raise ValueError('Identity input exceeds 64 KiB') + if str(source).endswith('.conf'): + payload = {} + for line in raw.decode().splitlines(): + if not line.strip() or line.lstrip().startswith('#'): + continue + key, sep, value = line.partition('=') + if not sep: + raise ValueError('Malformed legacy identity line') + values = shlex.split(value, comments=True) + if len(values) != 1 or key.strip() in payload: + raise ValueError('Malformed or duplicate legacy identity field') + payload[key.strip()] = values[0] + else: + def unique_keys(pairs): + result = {} + for key, value in pairs: + if key in result: raise ValueError('Duplicate JSON identity field: ' + key) + result[key] = value + return result + if raw.lstrip()[:1] in (b'{', b'['): + payload = json.loads(raw, object_pairs_hook=unique_keys) + else: + payload = plistlib.loads(raw) + if not isinstance(payload, dict): + raise ValueError('Identity input must be a dictionary') + if 'schema' in payload: + if set(payload) != {'schema', 'properties'} or type(payload['schema']) is not int or payload['schema'] != 1: + raise ValueError('Unsupported platform identity schema') + return validate(payload['properties']) + if 'PlatformInfo' in payload: + payload = payload['PlatformInfo'].get('Generic') if isinstance(payload['PlatformInfo'], dict) else None + elif 'Generic' in payload: + payload = payload['Generic'] + if not isinstance(payload, dict): + raise ValueError('Missing OpenCore PlatformInfo/Generic dictionary') + result = {} + for key, value in payload.items(): + name = ALIASES.get(key, key) + if name not in {*STRINGS, *BYTES, 'IOPlatformUUID'}: + continue # OpenCore includes unrelated boot, firmware and CPU settings. + if name in result and result[name] != value: + raise ValueError('Conflicting imports for ' + name) + result[name] = value + if not result: + raise ValueError('No supported identity fields found') + return validate(result) + + +def destination(prefix): + prefix = Path(prefix).expanduser() + if not prefix.is_absolute(): + raise ValueError('Prefix must be absolute') + path = prefix / 'private/etc/darling-platform.plist' + if path.is_symlink() or not path.resolve().is_relative_to(prefix.resolve()): + raise ValueError('Identity destination must remain inside the selected prefix') + return path + + +def save(prefix, properties): + path = destination(prefix) + prefix = Path(prefix) + # A host write behind Darling's mounted overlay may not be visible. Never + # stop a user's session implicitly or write a second copy behind its back. + marker = prefix / '.init.pid' + if marker.exists(): + try: + text = marker.read_text().strip() + if not text.isdecimal() or int(text) <= 1: + raise ValueError('Invalid .init.pid; verify the selected prefix is stopped before writing') + pid = int(text) + os.kill(pid, 0) + except ProcessLookupError: + pass + else: + raise ValueError('Shut down the selected Darling prefix before writing platform identity') + encoded = plistlib.dumps({'schema': 1, 'properties': validate(properties)}, sort_keys=True) + if path.exists() and path.read_bytes() == encoded: + return False + path.parent.mkdir(parents=True, exist_ok=True) + # Recheck after creating parents, before opening a temporary file. + path = destination(prefix) + if path.exists(): + load(path) # refuse to silently overwrite an unknown schema + backup = path.with_name(path.name + '.before-platform-change') + if not backup.exists(): + fd = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'wb') as stream: + stream.write(path.read_bytes()) + fd, temporary = tempfile.mkstemp(prefix='.darling-platform-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + return True + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--prefix', type=Path) + parser.add_argument('--import', dest='source', type=Path, help='OpenCore plist, GenSMBIOS JSON, legacy .conf, or schema-1 plist/JSON') + parser.add_argument('--set', action='append', default=[], metavar='KEY=VALUE') + parser.add_argument('--write', action='store_true', help='Write validated overrides to a stopped prefix (otherwise preview only)') + args = parser.parse_args(argv) + config = Path(__file__).resolve().parents[1] / 'build/config.json' + saved = json.loads(config.read_text()) if config.exists() else {} + prefix = args.prefix or Path(os.environ.get('DPREFIX') or saved.get('prefix', str(Path.home() / '.darling'))) + try: + path = destination(prefix) + properties = load(path) if path.exists() else {} + if args.source: + incoming = load(args.source) + # Replacing a UUID also replaces its previously derived byte form. + if 'IOPlatformUUID' in incoming: + properties.pop('system-id', None) + properties.update(incoming) + for assignment in args.set: + key, sep, value = assignment.partition('=') + if not sep: + raise ValueError('--set requires KEY=VALUE') + if key == 'IOPlatformUUID': properties.pop('system-id', None) + if key == 'model': properties.pop('product-name', None) + properties[key] = value + properties = validate(properties) + if args.write: + changed = save(prefix, properties) + print('Platform identity ' + ('saved' if changed else 'unchanged') + '; restart iokitd to apply.') + else: + print(json.dumps({'schema': 1, 'properties': properties}, default=lambda value: value.hex().upper(), indent=2)) + except (OSError, ValueError, TypeError, plistlib.InvalidFileException, ExpatError) as error: + parser.exit(1, str(error) + '\n') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/riot-registry-compatibility-check.mm b/tools/riot-registry-compatibility-check.mm new file mode 100644 index 0000000..ee8d157 --- /dev/null +++ b/tools/riot-registry-compatibility-check.mm @@ -0,0 +1,194 @@ +#import +#include "IORegistryEntry.h" +#include "PlatformIdentity.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include "iokitmigServer.h" +#include "iokitmig.h" +} + +// This probe invokes the real daemon objects and MIG handlers on the main +// thread, without registering a daemon or changing the running iokitd. +mach_port_t g_masterPort, g_deathPort, g_powerManagementPort; +struct Fixture : IORegistryEntry { + NSDictionary* getProperties() override { + return @{@"text": @"hello", @"utf8": @"é", @"small": [NSNumber numberWithUnsignedInt:0x12345678], + @"negative": [NSNumber numberWithInt:-3], @"wide": [NSNumber numberWithUnsignedLongLong:0x123456789abcdef0ULL], + @"flag": @YES, @"float": @1.25, @"array": @[], @"large": [NSMutableData dataWithLength:5000]}; + } +}; +static void checkProperties(Fixture& fixture) { + io_struct_inband_t buffer; mach_msg_type_number_t length; + io_name_t key = "text"; + memset(buffer, 0x7e, sizeof(buffer)); length = 2; + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnIPCError); + assert(length == 2 && buffer[0] == 0x7e); + length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnSuccess); + assert(length == 6 && !memcmp(buffer, "hello", 6)); + strcpy(key, "small"); length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnSuccess); + uint64_t small = 0; memcpy(&small, buffer, length); assert(length == CFNumberGetByteSize((CFNumberRef)fixture.getProperties()[@"small"]) && small == 0x12345678); + strcpy(key, "negative"); length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnSuccess); + int64_t negative = -1; memcpy(&negative, buffer, length); assert(length == CFNumberGetByteSize((CFNumberRef)fixture.getProperties()[@"negative"]) && negative == -3); + strcpy(key, "wide"); length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnSuccess); + uint64_t wide; memcpy(&wide, buffer, sizeof(wide)); assert(length == 8 && wide == 0x123456789abcdef0ULL); + strcpy(key, "flag"); length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnSuccess); + assert(length == 4 && !memcmp(buffer, "Yes", 4)); + strcpy(key, "utf8"); length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnSuccess); + assert(length == 3 && !memcmp(buffer, "\xc3\xa9", 3)); + strcpy(key, "large"); length = UINT32_MAX; memset(buffer, 0x7e, sizeof(buffer)); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnIPCError); + assert(length == sizeof(buffer) && buffer[0] == 0x7e && buffer[sizeof(buffer)-1] == 0x7e); + for (const char* name : {"float", "array", "missing"}) { + strcpy(key, name); length = sizeof(buffer); + auto expected = !strcmp(name,"missing") ? kIOReturnNoResources : kIOReturnBadArgument; + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == expected && length == 0); + } + memset(key, 'x', sizeof(key)); length = sizeof(buffer); + assert(is_io_registry_entry_get_property_bytes(fixture.port(), key, buffer, &length) == kIOReturnBadArgument && length == 0); +} +static void checkIdentity() { + NSString* uuid = @"00112233-4455-4677-8899-AABBCCDDEEFF"; + auto* valid = DarlingPlatformIdentity::properties(@{@"schema": @1, @"properties": @{@"model": @"ExampleModel1,1", @"IOPlatformUUID": uuid, @"IOPlatformSerialNumber": @"SUPPLIED"}}); + assert(valid && [valid[@"IOPlatformUUID"] isEqual:uuid]); + assert([valid[@"model"] isKindOfClass:[NSData class]] && [valid[@"model"] isEqual:valid[@"product-name"]]); + const unsigned char bytes[] = {0x33,0x22,0x11,0,0x55,0x44,0x77,0x46,0x88,0x99,0xaa,0xbb,0xcc,0xdd,0xee,0xff}; + assert([valid[@"system-id"] isEqual:[NSData dataWithBytes:bytes length:16]]); + for (NSDictionary* invalid in @[ + @{@"schema": @2, @"properties": @{}}, @{@"schema": @YES, @"properties": @{}}, + @{@"schema": @1, @"properties": @{@"unknown": @"value"}}, + @{@"schema": @1, @"properties": @{@"ROM": [NSData data]}}, + @{@"schema": @1, @"properties": @{@"IOPlatformUUID": @"00000000-0000-0000-0000-000000000000"}}, + @{@"schema": @1, @"properties": @{@"IOPlatformUUID": uuid, @"system-id": [NSMutableData dataWithLength:16]}}, + @{@"schema": @1, @"properties": @{@"model": @"bad\nmodel"}} + ]) assert(!DarlingPlatformIdentity::properties(invalid)); + assert(!DarlingPlatformIdentity::load("/private/etc/does-not-exist-platform-test.plist")); + // Exercise the same plist reader used by startup, using only a disposable file. + char filename[] = "/tmp/darling-platform-probe.XXXXXX"; + int descriptor = mkstemp(filename); assert(descriptor >= 0); + NSDictionary* config = @{@"schema": @1, @"properties": @{@"IOPlatformSerialNumber": @"FILE-FIXTURE", @"ROM": [NSData dataWithBytes:"abcdef" length:6]}}; + NSData* encoded = [NSPropertyListSerialization dataWithPropertyList:config format:NSPropertyListXMLFormat_v1_0 options:0 error:NULL]; + assert(encoded); + FILE* output = fdopen(descriptor,"wb"); assert(output); + assert(fwrite([encoded bytes],1,[encoded length],output) == [encoded length]); fclose(output); + NSDictionary* loaded = DarlingPlatformIdentity::load(filename); + assert(loaded && [loaded[@"IOPlatformSerialNumber"] isEqual:@"FILE-FIXTURE"] && [loaded[@"ROM"] length] == 6); + unlink(filename); +} +int main() { + @autoreleasepool { + Fixture fixture; + std::string temporaryPlane = "OwnedPlane"; + fixture.registerInPlane(temporaryPlane.c_str(), "Child", IORegistryEntry::root()); + temporaryPlane.assign(1000, 'x'); + assert(fixture.getPath("OwnedPlane") == "OwnedPlane:/Child"); + assert(IORegistryEntry::fromPath("OwnedPlane:/Child") == &fixture); + assert(IORegistryEntry::fromPath("IOService:/") == IORegistryEntry::root()); + assert(!IORegistryEntry::fromPath("MissingPlane:/")); + assert(!IORegistryEntry::fromPath("OwnedPlane:/child")); + assert(!IORegistryEntry::fromPath("OwnedPlane:/../Child")); + io_name_t plane = "OwnedPlane"; boolean_t inPlane = TRUE; + assert(is_io_registry_entry_in_plane(fixture.port(), plane, &inPlane) == kIOReturnSuccess && inPlane); + strcpy(plane, "Other"); + assert(is_io_registry_entry_in_plane(fixture.port(), plane, &inPlane) == kIOReturnSuccess && !inPlane); + std::vector> entries; + IORegistryEntry* parent = &fixture; + for (unsigned i=0; i<40; ++i) { + auto entry = std::make_unique(); + entry->registerInPlane("OwnedPlane", ("Entry"+std::to_string(i)+std::string(110,'x')).c_str(), parent); + parent = entry.get(); entries.push_back(std::move(entry)); + } + auto longPath = parent->getPath("OwnedPlane"); assert(longPath.size() > sizeof(io_string_inband_t)); + assert(IORegistryEntry::fromPath(longPath) == parent); + // An attempted cycle must not alter an existing root path. + fixture.registerInPlane("OwnedPlane", "cycle", parent); + assert(fixture.getPath("OwnedPlane") == "OwnedPlane:/Child"); + strcpy(plane, "OwnedPlane"); + io_string_t fixed; memset(fixed,'x',sizeof(fixed)); + assert(is_io_registry_entry_get_path(parent->port(), plane, fixed) == kIOReturnNoSpace && fixed[0] == 0); + io_string_inband_t inlinePath = ""; io_buf_ptr_t out = nullptr; mach_msg_type_number_t outCount = 0; + assert(is_io_registry_entry_get_path_ool(parent->port(), plane, inlinePath, &out, &outCount) == kIOReturnSuccess); + assert(!inlinePath[0] && std::string(out,outCount) == longPath && outCount == longPath.size()); + for (unsigned i=0; i<1000; ++i) { + vm_address_t allocation = 0; + assert(vm_allocate(mach_task_self(), &allocation, longPath.size()+1, TRUE) == KERN_SUCCESS); + memcpy((void*)allocation, longPath.c_str(), longPath.size()+1); + kern_return_t result; mach_port_t found; + assert(is_io_registry_entry_from_path_ool(0, inlinePath, (io_buf_ptr_t)allocation, longPath.size()+1, &result, &found) == kIOReturnSuccess); + assert(result == kIOReturnSuccess && found == parent->port()); + } + // Malformed OOL input must fail without publishing a port. + vm_address_t bad = 0; + assert(vm_allocate(mach_task_self(), &bad, 4, TRUE) == KERN_SUCCESS); + memset((void*)bad, 'x', 4); + kern_return_t badResult; mach_port_t badEntry = 123; + assert(is_io_registry_entry_from_path_ool(0, inlinePath, (io_buf_ptr_t)bad, 4, &badResult, &badEntry) == kIOReturnSuccess); + assert(badResult == kIOReturnBadArgument && badEntry == MACH_PORT_NULL); + auto detachedParent = std::make_unique(); + auto detachedChild = std::make_unique(); + detachedParent->registerInPlane("OwnedPlane", "Parent", &fixture); + detachedChild->registerInPlane("OwnedPlane", "Child", detachedParent.get()); + assert(detachedChild->inPlane("OwnedPlane")); + detachedParent.reset(); + assert(!detachedChild->inPlane("OwnedPlane") && detachedChild->getPath("OwnedPlane") == "?unsup_plane?"); + auto deleted = std::make_unique(); + deleted->registerInPlane("OwnedPlane", "Deleted", &fixture); + assert(IORegistryEntry::fromPath("OwnedPlane:/Child/Deleted") == deleted.get()); + deleted.reset(); + assert(!IORegistryEntry::fromPath("OwnedPlane:/Child/Deleted")); + checkProperties(fixture); checkIdentity(); + struct Serializable : IORegistryEntry { + NSDictionary* getProperties() override { return @{@"text": @"value", @"data": [NSData dataWithBytes:"abc" length:3], @"number": @123, @"array": @[@YES, @"x"]}; } + } serializable; + io_buf_ptr_t serialized = nullptr; mach_msg_type_number_t serializedCount = 0; + assert(is_io_registry_entry_get_properties_bin(serializable.port(), &serialized, &serializedCount) == kIOReturnSuccess); + CFStringRef error = nullptr; + CFTypeRef decoded = IOCFUnserializeWithSize(serialized, serializedCount, kCFAllocatorDefault, 0, &error); + assert(decoded && !error && [(NSDictionary*)decoded isEqual:serializable.getProperties()]); + CFRelease(decoded); + vm_deallocate(mach_task_self(), (vm_address_t)serialized, serializedCount); + std::thread([&fixture, parent, &serializable, longPath] { + @autoreleasepool { + io_name_t plane = "OwnedPlane"; + for (unsigned i=0; i<100; ++i) { + io_string_inband_t path = ""; + io_buf_ptr_t bytes = nullptr; mach_msg_type_number_t length = 0; + assert(io_registry_entry_get_path_ool(parent->port(), plane, path, &bytes, &length) == kIOReturnSuccess); + assert(bytes && std::string(bytes,length) == longPath); + vm_deallocate(mach_task_self(), (vm_address_t)bytes, length); + mach_port_t found = 0; kern_return_t result; + assert(io_registry_entry_from_path_ool(fixture.port(), path, const_cast(longPath.c_str()), longPath.size()+1, &result, &found) == kIOReturnSuccess); + assert(result == kIOReturnSuccess && found == parent->port()); + mach_port_deallocate(mach_task_self(), found); + } + io_name_t property = "flag"; io_struct_inband_t raw; mach_msg_type_number_t count = sizeof(raw); + assert(io_registry_entry_get_property_bytes(fixture.port(), property, raw, &count) == kIOReturnSuccess && count == 4 && !memcmp(raw,"Yes",4)); + io_buf_ptr_t bytes = nullptr; count = 0; + assert(io_registry_entry_get_properties_bin(serializable.port(), &bytes, &count) == kIOReturnSuccess); + CFStringRef error = nullptr; + CFTypeRef decoded = IOCFUnserializeWithSize(bytes, count, kCFAllocatorDefault, 0, &error); + assert(decoded && !error && [(NSDictionary*)decoded isEqual:serializable.getProperties()]); + CFRelease(decoded); vm_deallocate(mach_task_self(), (vm_address_t)bytes, count); + puts("REGISTRY_COMPAT paths=1 planes=1 graph_lifetime=1 cycles=1 ool_roundtrips=1000 ipc_roundtrips=100 property_bounds=1 numeric_widths=1 serialization=1 identity_schema=1 pass=1"); + fflush(stdout); + _Exit(0); + } + }).detach(); + dispatch_main(); + } +} diff --git a/upstream/manifest.json b/upstream/manifest.json index 733502b..09e7fa8 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -126,7 +126,7 @@ "revision": "5549ac4cd2db923c256f016a1381b3bfb716730a", "repository": "https://github.com/darlinghq/darling-iokitd.git", "patch": "patches/iokitd.patch", - "modified_files": 16, + "modified_files": 17, "new_files": [ "src/IOInterestNotification.h", "src/IOInterestNotification.mm", @@ -138,7 +138,8 @@ "src/LogindSleep.h", "src/PowerSources.mm", "src/HostPowerInhibit.c", - "src/HostPowerInhibit.h" + "src/HostPowerInhibit.h", + "src/PlatformIdentity.h" ] }, { From 0f2233304ae2d1890f32b4df4a9d8e28ae87a190 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:38:49 +0200 Subject: [PATCH 065/160] Add stable opt-in per-prefix identity provisioning --- docs/PLATFORM_IDENTITY.md | 69 +++++- docs/reviews/pr41-deferred-implementation.md | 12 +- patches/iokitd.patch | 11 +- tests/test_platform_provisioning.py | 159 +++++++++++++ tools/configure-darling-platform.py | 228 +++++++++++++++---- tools/riot-registry-compatibility-check.mm | 13 +- tools/run-riot-native-check.py | 1 + 7 files changed, 436 insertions(+), 57 deletions(-) create mode 100644 tests/test_platform_provisioning.py diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md index c1d4b85..9e623db 100644 --- a/docs/PLATFORM_IDENTITY.md +++ b/docs/PLATFORM_IDENTITY.md @@ -1,9 +1,9 @@ # Explicit platform identity overrides By default, Darling keeps the existing host-derived UUID and `DARLING-` serial. -No override file is generated by installation or launch. This tool imports -values supplied by the user; it does not generate Apple serials or replace OS, -CPU, GPU, or security metadata. +No override file is generated by installation or launch. Import mode uses values supplied by the user. Explicit `--provision` mode can +generate synthetic identifiers; it does not issue authentic Apple identities +or change CPU, GPU, or security metadata. Preview an OpenCore `config.plist`, a flat GenSMBIOS JSON file, or an existing `darling-smbios.conf` without changing the selected prefix: @@ -42,6 +42,7 @@ Supported properties: | Property | Configuration value | IOKit value | |---|---|---| | `IOPlatformSerialNumber` | Printable ASCII string, 1–64 characters | NSString | +| `provisioning_UDID` | UUID string matching `IOPlatformUUID` | NSString | | `IOPlatformUUID` | Nonzero UUID; normalized to uppercase | NSString | | `manufacturer`, `model`, `product-name`, `board-id` | Printable ASCII string, 1–128 characters | NUL-terminated NSData | | `mlb-serial-number` | Printable ASCII string, 1–64 characters | NUL-terminated NSData | @@ -71,3 +72,65 @@ first previous configuration is kept as an older `.conf` never edits that source. Symlinks escaping the prefix are rejected. To restore the original defaults, remove the override file while the prefix is stopped, then restart it. + +## Opt-in provisioning + +Preview a fresh or repaired identity without writing anything: + +```sh +python3 tools/configure-darling-platform.py --prefix /absolute/prefix --provision +``` + +Stop the prefix, then save the identity and prefix metadata: + +```sh +DPREFIX=/absolute/prefix python3 build/runtime/tools/stop-darling-runtime.py +python3 tools/configure-darling-platform.py --prefix /absolute/prefix --provision --write +``` + +Launch Darling normally afterwards. This requires the updated iokitd from this +PR; the config tool alone cannot update an older running daemon. Provisioning +is never called automatically by installation or launch. + +Missing serials use 12 characters and missing MLBs use 17 characters, sampled +with Python's `secrets` module from `0123456789ABCDEFGHJKLMNPQRSTUVWXYZ` (Base-34). +These are synthetic strings, with no Apple model, factory, date or checksum +validation, allocation, or guarantee of acceptance by Apple services. Missing +UUIDs use RFC4122 v4; `system-id` contains the UUID's 16 little-endian bytes. +For this project `provisioning_UDID` is the uppercase platform UUID alias, not +a separate Apple provisioning credential or a claim about hardware UDIDs. + +Missing ROMs default to six random bytes with the local/unicast bits set. You +can explicitly supply a three-byte OUI using `--rom-oui AA:BB:CC` (replace the +example with your chosen unicast OUI). It only affects a missing ROM. An OUI +does not make a generated identity Apple-issued. + +Provisioning replaces the known shared serial `C02ZL0GVMD6N` and UUID +`6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B`, along with the latter's derived fields. +It preserves other existing custom values, including imported serials and UUIDs +that do not follow the generated formats. A standalone custom `system-id` is +preserved by deriving its UUID instead of generating a different one. Model, +board and manufacturer values are only set when supplied by the user. + +The command reads an existing prefix `darling-smbios.conf` for migration, then +applies the canonical plist, an explicit import, and `--set` values in that +order. It writes: + +- `private/etc/darling-platform.plist`: canonical runtime overrides. +- `private/etc/darling-smbios.conf`: matching shell-quoted `SMBIOS_*` values, + including `SMBIOS_PROVISIONING_UDID`; unrelated legacy settings are not exported. +- `System/Library/CoreServices/SystemVersion.plist`: an exact copy of the selected + runtime's metadata, not a fabricated macOS release. The runtime root defaults + to the saved SDK path or `/usr/local/libexec/darling`; override with + `--runtime-root /path/to/runtime`. + +A valid runtime version file is required. The command checks all destinations +before changing identity files, rejects live prefixes and escaping symlinks, +backs up each previous file once with `.before-platform-change`, and replaces +each file atomically. The set of three files is not a single transaction: the +canonical identity is saved first so rerunning after interruption completes +provisioning with the same identity. Concurrent CLI writers serialize through +a per-prefix lock. Repeated writes preserve identifiers and unchanged files. +Previewing a fresh identity generates a sample; only `--write` makes it stable. +To restore host-derived defaults after provisioning, remove both identity files +while stopped, so a later provisioning run cannot reimport the legacy copy. diff --git a/docs/reviews/pr41-deferred-implementation.md b/docs/reviews/pr41-deferred-implementation.md index 9c2347d..ea0f50c 100644 --- a/docs/reviews/pr41-deferred-implementation.md +++ b/docs/reviews/pr41-deferred-implementation.md @@ -53,7 +53,8 @@ above the 32-bit range should use the named 64-bit API. [Platform identity configuration](../PLATFORM_IDENTITY.md) documents the schema, OpenCore/GenSMBIOS/legacy imports, startup snapshot, defaults, and rollback. Neither installation nor launch generates an override file. Existing host-based -identifiers remain stable unless the user explicitly supplies replacements. +identifiers remain stable unless the user explicitly supplies replacements or +requests synthetic per-prefix provisioning. ## Validation @@ -96,3 +97,12 @@ darling shell /Volumes/SystemRoot/ABSOLUTE_PROJECT_PATH/build/riot-cpu-frequency Local validation also ran the fd helper inside Darling and used an isolated kernel override for the frequency probe. The configured production runtime was not replaced, no real identity was changed, and no match capture is required. + + +Opt-in provisioning now fills missing synthetic serial/MLB, UUID, system-id, +provisioning UUID alias and ROM fields. It repairs the two known shared defaults +while retaining custom values, synchronizes the canonical and legacy config, +and copies actual runtime SystemVersion metadata. Disposable-prefix tests cover +concurrent writers, idempotency, migration, backups, live-prefix refusal, and +preflight failures. The registry probe validates all ten allowed properties. +See [platform identity usage](../PLATFORM_IDENTITY.md#opt-in-provisioning). diff --git a/patches/iokitd.patch b/patches/iokitd.patch index c3333e1..5607ad6 100644 --- a/patches/iokitd.patch +++ b/patches/iokitd.patch @@ -3522,10 +3522,10 @@ index 90155ed..e955bd4 100644 static std::unordered_map m_entriesById; diff --git a/src/PlatformIdentity.h b/src/PlatformIdentity.h new file mode 100644 -index 0000000..1f83b65 +index 0000000..6c87959 --- /dev/null +++ b/src/PlatformIdentity.h -@@ -0,0 +1,78 @@ +@@ -0,0 +1,81 @@ +#ifndef DARLING_PLATFORM_IDENTITY_H +#define DARLING_PLATFORM_IDENTITY_H +#import @@ -3553,17 +3553,18 @@ index 0000000..1f83b65 + if (![schema isKindOfClass:[NSNumber class]] || CFGetTypeID((CFTypeRef)schema) == CFBooleanGetTypeID() || + strchr("fd", [schema objCType][0]) || [schema longLongValue] != 1) return nil; + id source = [configuration objectForKey:@"properties"]; -+ if (![source isKindOfClass:[NSDictionary class]] || [source count] > 9) return nil; ++ if (![source isKindOfClass:[NSDictionary class]] || [source count] > 10) return nil; + NSMutableDictionary* result = [NSMutableDictionary dictionary]; + for (id key in source) { + if (![key isKindOfClass:[NSString class]]) return nil; + id value = [source objectForKey:key]; -+ if ([key isEqual:@"IOPlatformUUID"]) { ++ if ([key isEqual:@"IOPlatformUUID"] || [key isEqual:@"provisioning_UDID"]) { + if (!printable(value, 36) || [value length] != 36) return nil; + uuid_t uuid; + if (uuid_parse([value UTF8String], uuid) || uuid_is_null(uuid)) return nil; + uuid_string_t text; uuid_unparse_upper(uuid, text); + result[key] = [NSString stringWithUTF8String:text]; ++ if ([key isEqual:@"provisioning_UDID"]) continue; + unsigned char bytes[16]; memcpy(bytes, uuid, sizeof(bytes)); + for (int i = 0; i < 4; ++i) bytes[i] = uuid[3-i]; + bytes[4] = uuid[5]; bytes[5] = uuid[4]; bytes[6] = uuid[7]; bytes[7] = uuid[6]; @@ -3583,6 +3584,8 @@ index 0000000..1f83b65 + result[key] = [NSData dataWithBytes:text length:strlen(text)+1]; + } else return nil; + } ++ if (result[@"provisioning_UDID"] && result[@"IOPlatformUUID"] && ++ ![result[@"provisioning_UDID"] isEqual:result[@"IOPlatformUUID"]]) return nil; + if (result[@"model"] && !result[@"product-name"]) result[@"product-name"] = result[@"model"]; + return result; +} diff --git a/tests/test_platform_provisioning.py b/tests/test_platform_provisioning.py new file mode 100644 index 0000000..48bd0c0 --- /dev/null +++ b/tests/test_platform_provisioning.py @@ -0,0 +1,159 @@ +"""Provisioning contracts using disposable prefixes and runtime metadata.""" +import importlib.util +import json +import os +from pathlib import Path +import plistlib +import subprocess +import sys +import tempfile +import unittest +import uuid + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'tools/configure-darling-platform.py' +spec = importlib.util.spec_from_file_location('provisioning', SCRIPT) +platform = importlib.util.module_from_spec(spec) +spec.loader.exec_module(platform) + + +class ProvisioningChecks(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.prefix = self.root / 'prefix with spaces' + self.runtime = self.root / 'runtime with spaces' + self.version = self.runtime / 'System/Library/CoreServices/SystemVersion.plist' + self.version.parent.mkdir(parents=True) + self.metadata = {'ProductName': 'Darling', 'ProductVersion': '11.7.4', 'ProductBuildVersion': 'Darling-test'} + self.version.write_bytes(plistlib.dumps(self.metadata)) + self.command = [sys.executable, str(SCRIPT), '--prefix', str(self.prefix), + '--provision', '--runtime-root', str(self.runtime)] + + def run_cli(self, *args): + result = subprocess.run([*self.command, *args], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + return result + + def test_generated_shapes_uuid_byte_order_and_prefix_independence(self): + values = [platform.provision({}) for _ in range(20)] + for value in values: + for key, length in [('IOPlatformSerialNumber', 12), ('mlb-serial-number', 17)]: + self.assertEqual(len(value[key]), length) + self.assertTrue(set(value[key]) <= set(platform.BASE34)) + parsed = uuid.UUID(value['IOPlatformUUID']) + self.assertEqual(parsed.version, 4) + self.assertEqual(parsed.variant, uuid.RFC_4122) + self.assertEqual(value['system-id'], parsed.bytes_le) + self.assertEqual(value['provisioning_UDID'], str(parsed).upper()) + self.assertEqual(len(value['ROM']), 6) + self.assertEqual(value['ROM'][0] & 3, 2) + self.assertEqual(len({v['IOPlatformUUID'] for v in values}), len(values)) + + def test_preview_does_not_create_prefix(self): + preview = json.loads(self.run_cli().stdout) + self.assertIn('provisioning_UDID', preview['properties']) + self.assertFalse(self.prefix.exists()) + + def test_write_three_files_idempotent_and_importable(self): + self.run_cli('--write') + canonical = platform.destination(self.prefix) + legacy = self.prefix / 'private/etc/darling-smbios.conf' + version = self.prefix / 'System/Library/CoreServices/SystemVersion.plist' + self.assertEqual(platform.load(canonical), platform.load(legacy)) + self.assertEqual(version.read_bytes(), self.version.read_bytes()) + files = [canonical, legacy, version] + snapshot = [(f.read_bytes(), f.stat().st_mtime_ns) for f in files] + self.assertIn('unchanged', self.run_cli('--write').stdout) + self.assertEqual(snapshot, [(f.read_bytes(), f.stat().st_mtime_ns) for f in files]) + for f in [canonical, legacy]: + self.assertEqual(f.stat().st_mode & 0o777, 0o600) + + def test_known_defaults_replaced_and_custom_values_preserved(self): + legacy = self.prefix / 'private/etc/darling-smbios.conf' + legacy.parent.mkdir(parents=True) + original = (f'SMBIOS_SERIAL_NUMBER={platform.KNOWN_SERIAL}\n' + f'SMBIOS_PLATFORM_UUID={platform.KNOWN_UUID}\n' + 'SMBIOS_MLB=CUSTOM-BOARD\nSMBIOS_MODEL=CustomModel\nSMBIOS_ROM=001122334455\n').encode() + legacy.write_bytes(original) + self.run_cli('--write') + result = platform.load(platform.destination(self.prefix)) + self.assertNotEqual(result['IOPlatformSerialNumber'], platform.KNOWN_SERIAL) + self.assertNotEqual(result['IOPlatformUUID'], platform.KNOWN_UUID) + self.assertEqual(result['mlb-serial-number'], 'CUSTOM-BOARD') + self.assertEqual(result['model'], 'CustomModel') + self.assertEqual(result['ROM'], bytes.fromhex('001122334455')) + self.assertEqual(legacy.with_name(legacy.name+'.before-platform-change').read_bytes(), original) + before = result.copy() + self.run_cli('--write') + self.assertEqual(platform.load(platform.destination(self.prefix)), before) + + def test_custom_canonical_identity_wins_over_legacy_defaults(self): + custom = platform.provision({}) + platform.save(self.prefix, custom) + legacy = self.prefix / 'private/etc/darling-smbios.conf' + legacy.write_text(f'SMBIOS_SERIAL_NUMBER={platform.KNOWN_SERIAL}\nSMBIOS_PLATFORM_UUID={platform.KNOWN_UUID}\n') + self.run_cli('--write') + self.assertEqual(platform.load(platform.destination(self.prefix)), custom) + + def test_explicit_oui_and_existing_rom(self): + value = platform.provision({}, bytes.fromhex('001122')) + self.assertEqual(value['ROM'][:3], bytes.fromhex('001122')) + self.assertEqual(platform.provision(value, bytes.fromhex('AABBCC')), value) + for oui in ['1234', '011122', 'garbage']: + result = subprocess.run([*self.command, '--rom-oui', oui, '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(platform.destination(self.prefix).exists()) + + def test_version_source_required_before_identity_written(self): + for raw in [b'not a plist', plistlib.dumps({'ProductVersion': '99.0'})]: + self.version.write_bytes(raw) + result = subprocess.run([*self.command, '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(platform.destination(self.prefix).exists()) + self.assertFalse((self.prefix / 'private/etc/darling-smbios.conf').exists()) + self.version.unlink() + result = subprocess.run([*self.command, '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn('Traceback', result.stderr) + + def test_system_version_backup_and_symlink_preflight(self): + target = self.prefix / 'System/Library/CoreServices/SystemVersion.plist' + target.parent.mkdir(parents=True) + original = plistlib.dumps({'ProductVersion': 'old'}) + target.write_bytes(original) + self.run_cli('--write') + self.assertEqual(target.with_name(target.name+'.before-platform-change').read_bytes(), original) + self.assertEqual(plistlib.loads(target.read_bytes()), self.metadata) + target.unlink() + target.symlink_to(self.version) + before = platform.destination(self.prefix).read_bytes() + result = subprocess.run([*self.command, '--set', 'model=Changed', '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(platform.destination(self.prefix).read_bytes(), before) + + def test_live_prefix_is_not_changed(self): + self.prefix.mkdir() + (self.prefix / '.init.pid').write_text(str(os.getpid())) + result = subprocess.run([*self.command, '--write'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(platform.destination(self.prefix).exists()) + + def test_concurrent_writers_share_one_identity(self): + workers = [subprocess.Popen([*self.command, '--write'], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) for _ in range(4)] + for worker in workers: + output, error = worker.communicate(timeout=15) + self.assertEqual(worker.returncode, 0, error) + canonical = platform.destination(self.prefix) + self.assertEqual(platform.load(canonical), platform.load(self.prefix / 'private/etc/darling-smbios.conf')) + self.assertFalse(canonical.with_name(canonical.name+'.before-platform-change').exists()) + + def test_custom_system_id_is_preserved_and_udid_mismatch_rejected(self): + value = platform.provision({}) + self.assertEqual(platform.provision({'provisioning_UDID': value['provisioning_UDID']})['IOPlatformUUID'], value['IOPlatformUUID']) + supplied = {'system-id': value['system-id']} + self.assertEqual(platform.provision(supplied)['IOPlatformUUID'], value['IOPlatformUUID']) + with self.assertRaises(ValueError): + platform.validate({'IOPlatformUUID': value['IOPlatformUUID'], 'provisioning_UDID': str(uuid.uuid4())}) diff --git a/tools/configure-darling-platform.py b/tools/configure-darling-platform.py index 9ba1c87..7332dca 100644 --- a/tools/configure-darling-platform.py +++ b/tools/configure-darling-platform.py @@ -2,6 +2,9 @@ """Preview or explicitly save per-prefix IOKit platform overrides; defaults stay unchanged.""" import argparse import json +import fcntl +import secrets +from contextlib import nullcontext import os from pathlib import Path import plistlib @@ -11,6 +14,9 @@ import uuid from xml.parsers.expat import ExpatError MAX_SIZE = 65536 +BASE34 = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ' +KNOWN_SERIAL = 'C02ZL0GVMD6N' +KNOWN_UUID = '6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B' STRINGS = {'IOPlatformSerialNumber': 64, 'manufacturer': 128, 'model': 128, 'product-name': 128, 'board-id': 128, 'mlb-serial-number': 64} BYTES = {'ROM': 6, 'system-id': 16} @@ -22,23 +28,24 @@ ALIASES = { 'SystemUUID': 'IOPlatformUUID', 'SmUUID': 'IOPlatformUUID', 'SMBIOS_PLATFORM_UUID': 'IOPlatformUUID', 'MLB': 'mlb-serial-number', 'Board Serial': 'mlb-serial-number', 'SMBIOS_MLB': 'mlb-serial-number', 'SMBIOS_ROM': 'ROM', 'SMBIOS_SYSTEM_ID': 'system-id', + 'SMBIOS_PROVISIONING_UDID': 'provisioning_UDID', } def validate(properties): - if not isinstance(properties, dict) or len(properties) > 9: + if not isinstance(properties, dict) or len(properties) > 10: raise ValueError('properties must be a dictionary of supported identity fields') result = {} for key, value in properties.items(): if key in STRINGS: if not isinstance(value, str) or not 1 <= len(value) <= STRINGS[key] or any(not 32 <= ord(c) <= 126 for c in value): raise ValueError('Invalid printable string for ' + key) - elif key == 'IOPlatformUUID': + elif key in ('IOPlatformUUID', 'provisioning_UDID'): if not isinstance(value, str): - raise ValueError('IOPlatformUUID must be a UUID string') + raise ValueError(key + ' must be a UUID string') parsed = uuid.UUID(value) if not parsed.int: - raise ValueError('IOPlatformUUID must not be zero') + raise ValueError(key + ' must not be zero') value = str(parsed).upper() elif key in BYTES: if isinstance(value, str): @@ -55,6 +62,8 @@ def validate(properties): if 'system-id' in result and result['system-id'] != system_id: raise ValueError('system-id does not match IOPlatformUUID') result['system-id'] = system_id + if result.get('provisioning_UDID', result['IOPlatformUUID']) != result['IOPlatformUUID']: + raise ValueError('provisioning_UDID must match IOPlatformUUID') return result @@ -101,7 +110,7 @@ def load(source): result = {} for key, value in payload.items(): name = ALIASES.get(key, key) - if name not in {*STRINGS, *BYTES, 'IOPlatformUUID'}: + if name not in {*STRINGS, *BYTES, 'IOPlatformUUID', 'provisioning_UDID'}: continue # OpenCore includes unrelated boot, firmware and CPU settings. if name in result and result[name] != value: raise ValueError('Conflicting imports for ' + name) @@ -111,50 +120,49 @@ def load(source): return validate(result) -def destination(prefix): +def checked_path(prefix, relative): prefix = Path(prefix).expanduser() if not prefix.is_absolute(): raise ValueError('Prefix must be absolute') - path = prefix / 'private/etc/darling-platform.plist' + path = prefix / relative if path.is_symlink() or not path.resolve().is_relative_to(prefix.resolve()): raise ValueError('Identity destination must remain inside the selected prefix') return path -def save(prefix, properties): - path = destination(prefix) - prefix = Path(prefix) - # A host write behind Darling's mounted overlay may not be visible. Never - # stop a user's session implicitly or write a second copy behind its back. - marker = prefix / '.init.pid' +def destination(prefix): + return checked_path(prefix, 'private/etc/darling-platform.plist') + + +def ensure_stopped(prefix): + marker = Path(prefix) / '.init.pid' if marker.exists(): + text = marker.read_text().strip() + if not text.isdecimal() or int(text) <= 1: + raise ValueError('Invalid .init.pid; verify the selected prefix is stopped before writing') try: - text = marker.read_text().strip() - if not text.isdecimal() or int(text) <= 1: - raise ValueError('Invalid .init.pid; verify the selected prefix is stopped before writing') - pid = int(text) - os.kill(pid, 0) + os.kill(int(text), 0) except ProcessLookupError: - pass - else: - raise ValueError('Shut down the selected Darling prefix before writing platform identity') - encoded = plistlib.dumps({'schema': 1, 'properties': validate(properties)}, sort_keys=True) - if path.exists() and path.read_bytes() == encoded: + return + raise ValueError('Shut down the selected Darling prefix before writing platform identity') + + +def atomic_write(path, data): + if path.exists() and path.read_bytes() == data: return False path.parent.mkdir(parents=True, exist_ok=True) - # Recheck after creating parents, before opening a temporary file. - path = destination(prefix) if path.exists(): - load(path) # refuse to silently overwrite an unknown schema backup = path.with_name(path.name + '.before-platform-change') if not backup.exists(): fd = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, 'wb') as stream: stream.write(path.read_bytes()) + stream.flush() + os.fsync(stream.fileno()) fd, temporary = tempfile.mkstemp(prefix='.darling-platform-', dir=path.parent) try: with os.fdopen(fd, 'wb') as stream: - stream.write(encoded) + stream.write(data) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, path) @@ -163,38 +171,162 @@ def save(prefix, properties): return True +def save(prefix, properties): + path = destination(prefix) + ensure_stopped(prefix) + encoded = plistlib.dumps({'schema': 1, 'properties': validate(properties)}, sort_keys=True) + checked_path(prefix, 'private/etc/darling-platform.plist.before-platform-change') + if path.exists(): + load(path) # refuse to silently overwrite an unknown schema + path.parent.mkdir(parents=True, exist_ok=True) + return atomic_write(destination(prefix), encoded) + + +def provision(properties, rom_oui=None): + """Fill missing identifiers, replacing only the two known shared defaults.""" + result = validate(properties) + if result.get('IOPlatformSerialNumber') == KNOWN_SERIAL: + result.pop('IOPlatformSerialNumber') + if result.get('IOPlatformUUID', '').upper() == KNOWN_UUID: + result.pop('IOPlatformUUID') + result.pop('system-id', None) + result.pop('provisioning_UDID', None) + for key, length in [('IOPlatformSerialNumber', 12), ('mlb-serial-number', 17)]: + if key not in result: + result[key] = ''.join(secrets.choice(BASE34) for _ in range(length)) + if 'IOPlatformUUID' not in result: + # Preserve a custom standalone EFI system ID during legacy migration. + result['IOPlatformUUID'] = (result['provisioning_UDID'] if 'provisioning_UDID' in result else + str(uuid.UUID(bytes_le=result['system-id']) if 'system-id' in result else uuid.uuid4()).upper()) + result.setdefault('provisioning_UDID', result['IOPlatformUUID']) + if 'ROM' not in result: + if rom_oui is not None: + result['ROM'] = rom_oui + secrets.token_bytes(3) + else: + value = bytearray(secrets.token_bytes(6)) + value[0] = (value[0] | 2) & 0xfe # locally administered, unicast + result['ROM'] = bytes(value) + return validate(result) + + +def provisioning_files(prefix, properties, runtime_root): + """Preflight every file before making any identity changes.""" + version = Path(runtime_root) / 'System/Library/CoreServices/SystemVersion.plist' + with version.open('rb') as stream: + raw = stream.read(MAX_SIZE + 1) + if len(raw) > MAX_SIZE: + raise ValueError('Runtime SystemVersion.plist exceeds 64 KiB') + metadata = plistlib.loads(raw) + if not isinstance(metadata, dict) or any(not isinstance(metadata.get(key), str) or not metadata[key] + for key in ('ProductName', 'ProductVersion', 'ProductBuildVersion')): + raise ValueError('Runtime SystemVersion.plist is missing version metadata') + reverse = {value: key for key, value in ALIASES.items() if key.startswith('SMBIOS_')} + lines = ['# Synthetic per-prefix identity; generated by configure-darling-platform.py.'] + for key, value in sorted(properties.items()): + value = value.hex().upper() if isinstance(value, bytes) else value + lines.append(reverse[key] + '=' + shlex.quote(value)) + files = { + 'private/etc/darling-smbios.conf': ('\n'.join(lines) + '\n').encode(), + 'System/Library/CoreServices/SystemVersion.plist': raw, + 'private/etc/darling-platform.plist': plistlib.dumps({'schema': 1, 'properties': properties}, sort_keys=True), + } + for relative in files: + path = checked_path(prefix, relative) + checked_path(prefix, relative + '.before-platform-change') + if path.exists(): + if relative.endswith('darling-platform.plist'): + load(path) + elif relative.endswith('.conf'): + load(path) + else: + if not isinstance(plistlib.loads(path.read_bytes()), dict): + raise ValueError('Existing SystemVersion.plist is invalid') + return files + + +def save_provisioning(prefix, properties, runtime_root): + ensure_stopped(prefix) + files = provisioning_files(prefix, properties, runtime_root) + # Save the canonical identity first: an interrupted run reuses it on retry. + order = ['private/etc/darling-platform.plist', 'private/etc/darling-smbios.conf', + 'System/Library/CoreServices/SystemVersion.plist'] + changed = False + for relative in order: + changed = atomic_write(checked_path(prefix, relative), files[relative]) or changed + return changed + + +def prefix_lock(prefix): + ensure_stopped(prefix) + path = checked_path(prefix, 'private/etc/.darling-platform.lock') + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + stream = os.fdopen(fd, 'r+') + fcntl.flock(stream, fcntl.LOCK_EX) + return stream + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--prefix', type=Path) parser.add_argument('--import', dest='source', type=Path, help='OpenCore plist, GenSMBIOS JSON, legacy .conf, or schema-1 plist/JSON') parser.add_argument('--set', action='append', default=[], metavar='KEY=VALUE') + parser.add_argument('--provision', action='store_true', help='Generate missing synthetic identifiers and provision prefix metadata') + parser.add_argument('--runtime-root', type=Path, help='Runtime supplying SystemVersion.plist (default: saved SDK or /usr/local/libexec/darling)') + parser.add_argument('--rom-oui', help='Explicit three-byte unicast OUI for a missing ROM; default is locally administered') parser.add_argument('--write', action='store_true', help='Write validated overrides to a stopped prefix (otherwise preview only)') args = parser.parse_args(argv) config = Path(__file__).resolve().parents[1] / 'build/config.json' saved = json.loads(config.read_text()) if config.exists() else {} prefix = args.prefix or Path(os.environ.get('DPREFIX') or saved.get('prefix', str(Path.home() / '.darling'))) try: - path = destination(prefix) - properties = load(path) if path.exists() else {} - if args.source: - incoming = load(args.source) - # Replacing a UUID also replaces its previously derived byte form. - if 'IOPlatformUUID' in incoming: - properties.pop('system-id', None) - properties.update(incoming) - for assignment in args.set: - key, sep, value = assignment.partition('=') - if not sep: - raise ValueError('--set requires KEY=VALUE') - if key == 'IOPlatformUUID': properties.pop('system-id', None) - if key == 'model': properties.pop('product-name', None) - properties[key] = value - properties = validate(properties) - if args.write: - changed = save(prefix, properties) - print('Platform identity ' + ('saved' if changed else 'unchanged') + '; restart iokitd to apply.') - else: - print(json.dumps({'schema': 1, 'properties': properties}, default=lambda value: value.hex().upper(), indent=2)) + prefix = prefix.expanduser() + rom_oui = None + if args.rom_oui: + if not args.provision: + raise ValueError('--rom-oui requires --provision') + rom_oui = bytes.fromhex(args.rom_oui.replace(':', '').replace('-', '')) + if len(rom_oui) != 3 or rom_oui[0] & 1: + raise ValueError('--rom-oui requires exactly three unicast bytes') + with prefix_lock(prefix) if args.write else nullcontext(): + path = destination(prefix) + properties = {} + if args.provision: + legacy = checked_path(prefix, 'private/etc/darling-smbios.conf') + if legacy.exists(): + properties.update(load(legacy)) + if path.exists(): + incoming = load(path) + if 'IOPlatformUUID' in incoming: + properties.pop('system-id', None) + properties.pop('provisioning_UDID', None) + properties.update(incoming) + if args.source: + incoming = load(args.source) + if 'IOPlatformUUID' in incoming: + properties.pop('system-id', None) + properties.pop('provisioning_UDID', None) + properties.update(incoming) + for assignment in args.set: + key, sep, value = assignment.partition('=') + if not sep: + raise ValueError('--set requires KEY=VALUE') + if key == 'IOPlatformUUID': + properties.pop('system-id', None) + properties.pop('provisioning_UDID', None) + if key == 'model': properties.pop('product-name', None) + properties[key] = value + runtime_root = args.runtime_root or Path(saved.get('sdk', '/usr/local/libexec/darling')) + if args.provision: + properties = provision(properties, rom_oui) + provisioning_files(prefix, properties, runtime_root) + else: + properties = validate(properties) + if args.write: + changed = (save_provisioning(prefix, properties, runtime_root) if args.provision else save(prefix, properties)) + print('Platform identity ' + ('saved' if changed else 'unchanged') + '; restart iokitd to apply.') + else: + print(json.dumps({'schema': 1, 'properties': properties}, default=lambda value: value.hex().upper(), indent=2)) except (OSError, ValueError, TypeError, plistlib.InvalidFileException, ExpatError) as error: parser.exit(1, str(error) + '\n') return 0 diff --git a/tools/riot-registry-compatibility-check.mm b/tools/riot-registry-compatibility-check.mm index ee8d157..8103281 100644 --- a/tools/riot-registry-compatibility-check.mm +++ b/tools/riot-registry-compatibility-check.mm @@ -64,12 +64,23 @@ static void checkProperties(Fixture& fixture) { } static void checkIdentity() { NSString* uuid = @"00112233-4455-4677-8899-AABBCCDDEEFF"; - auto* valid = DarlingPlatformIdentity::properties(@{@"schema": @1, @"properties": @{@"model": @"ExampleModel1,1", @"IOPlatformUUID": uuid, @"IOPlatformSerialNumber": @"SUPPLIED"}}); + auto* valid = DarlingPlatformIdentity::properties(@{@"schema": @1, @"properties": @{@"model": @"ExampleModel1,1", @"provisioning_UDID": uuid, @"IOPlatformUUID": uuid, @"IOPlatformSerialNumber": @"SUPPLIED"}}); assert(valid && [valid[@"IOPlatformUUID"] isEqual:uuid]); + assert([valid[@"provisioning_UDID"] isEqual:uuid]); + NSMutableDictionary* complete = [@{@"IOPlatformSerialNumber": @"SYNTHETIC123", @"IOPlatformUUID": uuid, + @"provisioning_UDID": uuid, @"model": @"ExampleModel", @"product-name": @"ExampleModel", + @"manufacturer": @"Example", @"board-id": @"Board", @"mlb-serial-number": @"SYNTHETIC-BOARD", + @"ROM": [NSData dataWithBytes:"abcdef" length:6]} mutableCopy]; + complete[@"system-id"] = valid[@"system-id"]; + assert([complete count] == 10); + assert(DarlingPlatformIdentity::properties(@{@"schema": @1, @"properties": complete})); + [complete release]; assert([valid[@"model"] isKindOfClass:[NSData class]] && [valid[@"model"] isEqual:valid[@"product-name"]]); const unsigned char bytes[] = {0x33,0x22,0x11,0,0x55,0x44,0x77,0x46,0x88,0x99,0xaa,0xbb,0xcc,0xdd,0xee,0xff}; assert([valid[@"system-id"] isEqual:[NSData dataWithBytes:bytes length:16]]); for (NSDictionary* invalid in @[ + @{@"schema": @1, @"properties": @{@"provisioning_UDID": @"bad"}}, + @{@"schema": @1, @"properties": @{@"IOPlatformUUID": uuid, @"provisioning_UDID": @"11112233-4455-4677-8899-AABBCCDDEEFF"}}, @{@"schema": @2, @"properties": @{}}, @{@"schema": @YES, @"properties": @{}}, @{@"schema": @1, @"properties": @{@"unknown": @"value"}}, @{@"schema": @1, @"properties": @{@"ROM": [NSData data]}}, diff --git a/tools/run-riot-native-check.py b/tools/run-riot-native-check.py index 2eafd83..964ecc6 100644 --- a/tools/run-riot-native-check.py +++ b/tools/run-riot-native-check.py @@ -9,6 +9,7 @@ import tempfile root = Path(__file__).resolve().parent.parent checks = { + "riot-registry-compatibility-check": "REGISTRY_COMPAT", "riot-cfhost-cancel-check": "CFHOST_CANCEL_CHECK", "riot-launchservices-null-error-check": "LAUNCHSERVICES_NULL_ERROR", "m327-cgl-resolver-check": "GL_RESOLVER", From 8b24ecbcd3d29a0fa818ae1b4a2254c9243d4e09 Mon Sep 17 00:00:00 2001 From: Kalah <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:11:42 +0200 Subject: [PATCH 066/160] Update README with contact info and project goals Added contact information for job opportunities and clarified project goals regarding Linux compatibility. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 77babf8..ccf8377 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,15 @@ > - don't use your main account. > > 3-Day Suspension ("UNSUPPORTED_THIRD_PARTY_TOOLS") DON'T USE IT RN +> > Even with all of that, you will probably still get permabanned. > > **3. Takedown.** Riot may well shut this project down. If that happens, I will publish a fork of Darling containing only the changes made here (none of them breach anything), and release the small script as a gist or similar. > > **4. To Riot.** If you are reading this, it means a lot of people want to play your game on Linux and are disappointed that they currently can't. As with macOS for your other games (TFT, Valorant), it feels like Riot doesn't care about a part of its players who have been waiting the longest. If you want to fix this, make LoL Next compatible with Linux, either natively or through a layer like Proton. Yes, that includes your kernel anticheat: Epic Games did it with Easy Anti-Cheat. Follow the trend. This is the year of Linux, and people shouldn't be locked out of a platform or forced to dual boot for a single game. +> Since Riot have heard of the repo if you want to contact me you can at darling-lolriot@proton.me (especially if you want to do an official or supported version of league of legends on linux) + +If someone else wants to contact me know that i'm open to jobs, part-time remote only as a employee and full time remote as a freelancer for a mission https://github.com/user-attachments/assets/140df3e0-3790-46b6-bab5-578139b84a7c From f5e65e3cf1d93f36131e536dce419075d23bb3bd Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sun, 18 Oct 2026 00:21:30 +0200 Subject: [PATCH 067/160] feat(nix): add experimental rootless FHS package and desktop launcher --- .gitattributes | 3 + README.md | 29 +- darling-lol-launcher.sh | 60 ++++ darling-lol.png | Bin 0 -> 44526 bytes darling-lol.svg | 4 + docs/SETUP.md | 88 ++++- docs/TROUBLESHOOTING.md | 42 +-- flake.lock | 27 ++ flake.nix | 328 ++++++++++++++++++ patches/cctools-port.patch | 38 +++ patches/darling.patch | 8 +- scripts/build.sh | 6 +- scripts/darling-rootless.sh | 14 + scripts/fhs-entry.sh | 9 + scripts/install-runtime.sh | 15 +- scripts/installer.sh | 9 +- scripts/nix-copy.py | 44 +++ scripts/nix-idle.py | 36 ++ scripts/nix-session.sh | 32 ++ scripts/nix-state.py | 164 +++++++++ scripts/prepare.py | 36 +- scripts/run.sh | 5 + scripts/update.sh | 21 +- scripts/validate.py | 3 + tests/test_nix_launcher.py | 90 +++++ tests/test_nix_repairs.py | 519 +++++++++++++++++++++++++++++ tests/test_nix_runtime_identity.py | 111 ++++++ upstream/manifest.json | 6 +- 28 files changed, 1684 insertions(+), 63 deletions(-) create mode 100644 .gitattributes create mode 100644 darling-lol-launcher.sh create mode 100644 darling-lol.png create mode 100644 darling-lol.svg create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 patches/cctools-port.patch create mode 100644 scripts/darling-rootless.sh create mode 100644 scripts/fhs-entry.sh create mode 100644 scripts/nix-copy.py create mode 100644 scripts/nix-idle.py create mode 100755 scripts/nix-session.sh create mode 100644 scripts/nix-state.py create mode 100644 tests/test_nix_launcher.py create mode 100644 tests/test_nix_repairs.py create mode 100644 tests/test_nix_runtime_identity.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..00c1a6e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Unified-diff context uses a single space for an empty source line. +# Do not report that patch syntax as trailing source whitespace. +patches/*.patch whitespace=-blank-at-eol diff --git a/README.md b/README.md index ccf8377..d997fb1 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,34 @@ Game files, Riot installers, account databases, tokens, local prefixes, captures Use the dependency list in the [pinned Darling source](https://github.com/darlinghq/darling/tree/60ba801decee7a00782f74f6be4c8ffb013f79ff) for your distribution. `tools/env-check.sh` provides a starting check; its legacy package names may need adapting to your distro. -## Build on the other PC +## Experimental Nix/rootless package + +```sh +nix run github:lucannez64/darling-lol#installer +nix run github:lucannez64/darling-lol#default -- --update +nix run github:lucannez64/darling-lol#default +nix run github:lucannez64/darling-lol#shell +``` + +Or from a local checkout: + +```sh +nix run .#installer +nix run .#default +``` + +Nix supplies the launcher/dependencies, not a prebuilt Darling or game. First +setup is interactive and builds into the writable per-user workspace. The +desktop entry requests a terminal; EOF stdin produces an actionable error. +Run updates only after manually closing the clients and shutting Darling down +in the original namespace. A live prefix marker blocks a second launch rather +than killing or attempting to reconnect. One workspace/foreground namespace is +supported at a time. Readiness is per canonical prefix, configured guest and +compatible package/runtime/source identity. Store closure roots are retained +for mutable binaries; do not delete `build/nix-gcroots/` while using them. +See [Nix setup and operational limits](docs/SETUP.md#nixos-package). + +## Build on the other PC (non-Nix) ```sh git clone https://github.com/lucannez64/darling-lol.git diff --git a/darling-lol-launcher.sh b/darling-lol-launcher.sh new file mode 100644 index 0000000..604c9f0 --- /dev/null +++ b/darling-lol-launcher.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +WORKSPACE="${XDG_DATA_HOME:-$HOME/.local/share}/darling-lol" +SHARE_DIR="@SHARE_DIR@" +FHS_BIN="@FHS_BIN@" +UNSHARE_BIN="@UNSHARE_BIN@" +# The store package is both a compatibility identity and a closure GC root. +export DARLING_PACKAGE_ID="$SHARE_DIR" +export DISPLAY="${DISPLAY:-:0}" +export EGL_PLATFORM="${EGL_PLATFORM:-x11}" +export DPREFIX="${DPREFIX:-$HOME/.darling}" +export DARLING_HOST_UID="$(id -u)" +export DARLING_HOST_GID="$(id -g)" +export DARLING_WORKSPACE="$WORKSPACE" + +python3 - "$WORKSPACE" <<'PY' +import pathlib, sys +workspace = pathlib.Path(sys.argv[1]) +for path in [workspace, *workspace.parents]: + if path.is_symlink(): + raise SystemExit(f'Refusing symlink workspace ancestor: {path}') +if (workspace / '.launcher.lock').is_symlink(): + raise SystemExit('Refusing symlink workspace lock') +PY +mkdir -p "$WORKSPACE" +# One mutable base runtime per workspace; do not refresh scripts mid-session. +exec 9<>"$WORKSPACE/.launcher.lock" +flock -n 9 || { echo 'Another darling-lol workspace session is active; use its terminal.' >&2; exit 1; } +# Do not attempt to reconnect across separately created user namespaces. No kill, +# sudo, or shutdown is attempted here (the PID might even be stale/reused). +if [ -f "$DPREFIX/.init.pid" ]; then + read -r server_pid < "$DPREFIX/.init.pid" || true + if [[ "${server_pid:-}" =~ ^[1-9][0-9]*$ ]] && [ -d "/proc/$server_pid" ]; then + state=$(awk '/^State:/ {print $2}' "/proc/$server_pid/status") + if [ "$state" != Z ]; then + echo 'Prefix has a live server marker; namespace reconnect is unsupported.' >&2 + echo 'Use the original darling-lol-shell terminal to run darling shutdown, then retry. No processes were stopped.' >&2 + exit 1 + fi + fi +fi +python3 "$SHARE_DIR/scripts/nix-copy.py" "$SHARE_DIR" "$WORKSPACE" +mkdir -p "$WORKSPACE/usr-local" +cd "$WORKSPACE" +# Map subordinate IDs before bubblewrap sets no_new_privs. UID 0 is not host root. +fhs_command=("$UNSHARE_BIN" --user --map-auto --map-current-user --setuid=0 --setgid=0 "$FHS_BIN") +# Keep this supervisor alive to hold the lock: bubblewrap may close extra FDs. +# It never owns or signals game processes; foreground execution carries SIGINT. +case "${1:-}" in + --shell) shift; "${fhs_command[@]}" bash "$@";; + --update) shift; "${fhs_command[@]}" ./scripts/update.sh "$@";; + --installer) shift; "${fhs_command[@]}" bash ./scripts/nix-session.sh --installer "$@";; + *) + if [ $# -gt 0 ]; then + "${fhs_command[@]}" "$@" + else + "${fhs_command[@]}" bash ./scripts/nix-session.sh + fi;; +esac diff --git a/darling-lol.png b/darling-lol.png new file mode 100644 index 0000000000000000000000000000000000000000..3f62d293f0406e3af0a86625e003db7207f38768 GIT binary patch literal 44526 zcmZ^Kbxa&g^eyhPxXZ#Kg+&&3U1V`9R@|+{wYa;xyOrV;EAH-ETw1ia6n)?Cz2qhD zk2lGkGr5y9H<@HIlY7rZsj0|eV~}IO!NFm}Vc`E50Oh|vrM4^bKcF?2*HDIo z^I`nY9|{Ne^gr`QaB%LNaBwFkaBxDIaBw6}xgF{v|4pEnDat|r!~fbp>CS-v0NqJm z&-Fhv`QLVFFE#Vu0M!kqEQ5N20l>n5KU8}`_|L`QCZp?SVd`cnWbR`5AHZ>Oa`CZ2 zIN2b4S`dg3504OpiTTbagZR z?DYR|xP&18Ef4uWL0q?gw*Svwz@U;^Uh8LF?mrEeS{`1hT*vOa5)MF2U*u`qq(6l1 zX_rbg(1jJ3&88DacM?VuMo6{GJ9~Ntf29Quo`XhOFD&<8%`XU~<~O$fbhTb@Fo}!N z$S&(YpYcBAn#T+N&Xk7UQNE$9)3~BC@V^sKUBzFrUHI%&DD8NvtuOcpypbv?rSs*T z3kcsa=F*h%+s4R6T-$TPu%oQ6`}NMKn{i|m&l-}#7~bjz zi6NFP6`L1q=9-V3Bj5diXJ9w^34m8oQ!tgS5-!3qyjEX5|QQ-p6@chI}l!M?*rD6g;5{MZwL`8vw0Gd!7?po za|6*7ZOLO15B#@AoDU{uCB{GY3>A9wH;Q8DP{aqp+MCGlsP_@JoG9<1;F6~;Py z@BG?exW5OYUHGd>f=?gng_|A$2bNp23zBu^aWtp)zR0mlbeLCUltz;L)Kh)nr^>m5 za|vxyoWIS5^f=2}K zmuAnaP$>yuGPdGcG8tx^6C7aFHLqiQ)n$5H06(AO_ntV7AYv5xH1%Z5-Tdl`LXL zZGtv3+I>HRo;lc=9W73@C3*`Lt@pCiIun@q*~{yaU2AGd+E?zDsbe7`eZYb&r@?})!fhUX0yuc~PDJnU@%Bm;>(mXE!4#K#bf-4h{PM}cZNj{nqjK*4 zT3*3*n$rFs7hD+eWwkr9tzENVfE9>L;fHDvF~%}u^PIxc!Dtd4k3ue*9I=UM>YYGhYyZGiYRpN*Pp2e_6dErd zlh+kTmtQvC=x=Oyfv!~NlM6C`$3T~>%pRCxz%~d1-5|BBe5h_G01ucRhfnvLH5CmK zfJ_I~$0Ib&Kw(>M0sCQu(5{Dt(fTOn$$sTMVg^eTyEdqyZ4Uq>9~U|TapabII9Blh zpw(OeD#1n-ug2;dp9|*q!Qj`d8363r{xX)bsK+e-V%RE6Tvi(Wz=2P#(nb6xgIrH~ zvCti8Pj1fgo;ucghQJm0e#-O)rwxMhJ+;Uqe$N0^HO5}QFNuW&Tk|~mbd#vxW9eFom^%dkF7yhUbr}xEca&m%^~cst{nQE^fGdcX|1{o!8oP!+blA*!|-6L&n6^?&&WbTPl7iGLc^OSH6m& zk)*A3sbuWDScVR^ht9gp;_mqjl4)H7n33E;(f=}o&jzOo7rd1(JlHebGsjaOJ1v@( z*tTROJs9yWEW|`DHa;Eq_chGWtA>TXB796O{m2MX?avKPWcs4gUlcnf2D*g&q$fjC zOnLf%lfI>jiN0Y&Wv1IU7uO~ru#0Phe8wq_Nv+M9%czi9n8JgyKhrlfa4(HuHu(DO z*n&s!qScp0m~+v7*B{3O`$AtdJ*DBNx!}k0(M?99b7k-Q!x}N0o3IS6A9wl3f*zLx zS0t-?4%l93q(wKCaOgglfr31E@bR(#m}39JCCIS0y$6VD+aT7z*A#@2gkUXgpN>eR zWr|^Y5XdzFy*|5Ca~lLuzQ%{XaQ;k;$eL7c`Qxr=HTpCpryJpsyR~FnoWuUdjV$52 z@h8V7V2RiEYv5hwrKt#&=-+RTH-KU7$~>LiRX9`#I`f;hoGVz~ zUy0vx7MJ|hS2bj7&LpOXua?Gfww3($6vc3{5V3-Hi_5KdmGmX%@q{LxhyvbmInJFV zkxfue92EhH;y{p!rX`RhbLvdoDSxXMsz;fxnEc_^JEM^dz94nW0*(4+!?+&8_Ve6V z^`eX=q9esZ9C{%iX9^(3%wgmtG}RVNkb@rhDTS)CHN)jp^DXyzCCG4sLWZAu z{_+*B>;*SrTj`@*1y;4N&2Jp@s)=jSBw4UeCu%~>1jAcqEK`qv132?QKU|6PNb$9^ zSw76aq@HfOY&e86CnkKV zjFg#-+-RDdnB@`{!sLwIP__Z{cz*|6OEbx}?~H!T$`!{_gQ?%S35h5e>+L;@y8NP3!1=TgONvM+ymXGppuT2Gk3AE{WmQz1MFw*9qPFl5BtZ}t!A)HjLf zMDol}nYGG07qk?sGF_WKI<0YhabM^MNAKV8U0ajpyG?|%mGReDE)N-704ZIjM3QzE%Do`=03k*k6mt5C-IYaF|lADpAk=2PRM}$>98oC6lM{)rV`-LVm=2l1b*M zJd43O)~|MX9?M{Zmpl&+%neo zx~DQ#u%Fq5a>>nF%S(D#mQxCt#*pm$6J#YTaMlSV6piSjx}zk~6H%|@>eUonUZ8(T zhCELAQgYN=wD63s$$UaNbCmM9Va#erBHiA}r_`E^dJBCoInkUB>tlafuMJ74Qhv_b zdKzlJXSe6E=G zxNX_jCz_|9gfCe<3$Vt<@D16Rx1etaPGl7_ng{0EyIK#TIM%FriGCR@A@vvp=rB!i zfD#ny_!%BZjaV zgiPimnO~77!kHux_*aTT4_Z(XO*@wbC2f2nKf~$#Na4GX_C*L^6ssusY*Rb@m<{^A#C=2y>uBUA zGHXklf-6wCBcSvxKDlr6f~MIwpv{dpL7CMp8R79vy$N{^Q z#iQOeA2F`Ui(~x~yy=yG@Wzpv@x?bpSm)qB_33TTt$l`vAO9jG9ov>v<@4yx#E>3|9DeLJ4jLgS1z%#8=WpHVy(#wVtoqw{vyHqh-tS|Y6 z?mT;`fzfOhc5~?z6>JpRM;bsNf{^_7g)mBfg|7lPQir2F)=cMJ9vYg?F|dP|#xV#4 zu^2`uB9&1^!fTtd;ja#2h*~(wAEq?#&MyXV*_%Kn{d1;{Gq43E@qMVGE=@c;3vH;# zah@jHzf#^Wf1K;?+`Rl^CDjyf?4)!${Li!S-dau&p;&P;YmT5--JmK(!q|+e8~%$q zrWj)Qf+F!_xW|vzjLTo|%qU2`F5fIt8@7H#gyc(B{FL*XSX!B6Wow{#*i1a$WVTmYO=DLvu#D&{ct@U!n zR@eXVedHJc`GmYTCD80v6d{&Yz2V&b3U9C(&rEGY zXbD0}cqqGvgf?To3onCDx`QZ%Ul@VEOJ|^YAty@JC*?(sNJt`cACegHVg zp-ihHuPcxOq773WGa)$Lj<(vfcwRPRMql^ww4}2oBY4g`3;$M^qm5G$@_Hzf@`oh1 zI6;s?#!*+V-RhvskmQhZ;{9@NX?P}J>)qQeLr?Ppw=VVAr^&bIBFnu0)I#_!g}&=F zH)a*`bNr0rJEj5a*9FAb!Wha?<@FCbL9Y>jHCuEVEH8f?u+01Y8^VuK!GN1SZcWF( z$}~v5U$?#M`7bHp{vwxmiGID|_y5tgF5Yny^}+hfAn(QJJDHIw%-*iauZ^XP4_ACRs$#8XY#hV2{h^rj$CXqP>15z*jMBirJF;e>w_%@l@lA`5Nx>i? znn-!sha%=60~sV{Pk#Noi3_)9af(KZI{vf{TCX3@({FD3Z`DxUP!|d@ShW=kk&AwP zEr>(8G5>}-Jjrcvx>zZ;{1N06XyJ=sSUNgvJu8!2zDnk{yVEW_A6BT6i;uS_A{Cg> z@bl?k&tjgXR>X>`QgH}`xnEL%Iebhvut+Mj4}ga!XYorGEE_Mh;17H4b$8q6j#gu! z+d>;&CkzRujE9HbOTxu)oJeZ92mx`3X8?4%b4#HTU~)-lDABUtIZqpbbl{&?6q1Dc zAH2@K)n&h6czA>~v@v}LGlgjaRf{rQlo@?d1MWg(!^3rb6@8Z@}x0Z>FIy>y+CW)UiZjg!5b{jFTgry(c z!m(qux_e;aw)&GyCBmM-BeK?RA65qZQ7Ps&hhb-Tx{#OqPn zf_-*pTlg#H(w4kkDPSu+&(*kGuBuLjsTm;F(Hvz;o!@{2^+w7$+R>b(tTph?MXL5;l@JvcKxDgp<@+qL-WM5a#ygn+63BIpod6=$0-4hQ1do#Ka-E?3D5E zMFNqP>N(EOWKtxuHY4n}){cxnGmSLJiiK(5K)k@kR0tt!M6SbqkwKO76MszpAFuHj z&W<<;^UXkv?NB*KM=&Y-5_4rig&gYMKH#XX%9P;5Zm~YHt>(>u+Ll=_ji>|p^-lxh zyQVx3AN40QfR>6~WauyQ2geH8K)ZAm3>z=7pIh)dGBBpz?vI8!#N(f5>X>Pb_Ed=t zX$fXVbpTD1O_7>+hFEBRzj-RmR>uKb>N>f(!ek*~0yk zLJm5fh7Ue94tlrbSmK_PZ*u9Ow-lIC5Tz0@Dv#wysLX-$id0S{O%vqKEG7Q&8$k}- z8B9PIlvm*-zazk=I|vi5Fm;j_M8z-Cou;{}0<3ZDB#yY|4>GC_8Ij~5Sl)p6K1P2* z$%zbZVds+?`Uej&aPVX%!*3q+LrGy!FbtQYXrBxKKr-*o+*b$5HhGMu=e`iP8c8Yq zDExN8ra^do5s(EAIZS-Rv|-o_^u^_GNCbwV$L=W(6RMbD-Pp2W%SP^}F>ia~=XzA@mDQFgeAo+yThasPzNqzeUxn z2}Z-g&&So0wWfAnii=yRWi*@5$e#7rl1{d&J2u-B;THe7I+h$rIkLprQ!b0)`O=<6 zohKPw@_{Q8Iy=Rk+?U!~E}N@wYV1pMKI7$2eB`C~t&Fb&MBZ)>W0-OLH~m%@%H{YNk>wi30r)XNEt zmE!z;2&BTq#;?pH)k}r%sUf~k=Mzz;>OZ>aWP|m68DYt*(-S;NMGSA8)Za@rdL(Vk z)jXK+c_3}**-l5)ZoF)SWCjA6l9QzsP9FqCFkW+#8D(aLgOKpP%$VTcrkoxpky16^ zl}6c%XLTomn9bT%=fgrA{Sw$6q}Vh~*tOe&zOGbUU<-j(v0?@Ub!nJDhMte<0t^!F z_+5dC7JV3k)+VM@nU~=KT-wZV*^YlV)_=K;HIu6;qlgrz%jf5dV}Is0>=m=Z&cbwq z#D0{(-LnxSY_>SJQ$fwD?mTkzPBuPqO%+UD`WM;dt<;DFo?obMs5(9kB|SS*s3r|Q zQf4zDI2MKqRK}NwBm0o!f(S9}MtaJzolRTUrwyrIX zjyP#@o0TS{9LSgq-a+i{-GQ!fr?8CesC9futjSNP+tgjYY2(UH7LDhjXa0pJs2_E9 zF$blbwB;GYZMY-AYHBF{W;?Ii5MmG0prej0$qG9Nwrxvwm%XJ$hAuCvV6@c~bIi|} z$c{Pc^)hQ`D3219tlgS61p2#z$%Hb^-jlU4z(2k)O@kGtTLhFA*qIcQxjbsJuwZ0l zpTY=3B8OUmDiq(N9_mLS0^VfNVKDz#xvbg)L#?9W3Tk5gG-x$SG(JRl$ zq+ZWIfPp^Uk)+3gq644NFK&+cpTfGi>?+hJFy2$6w^^J<5}4>Ez5xQ<`^Y! z#4#A=(91<60sWCEZVaIFhJ=|RISbFCCBdLLCQz)gL8V$N@>h;Jus02aM+8lvoqZ;w zFq$aq6La~zI^exN?nA8v5pE(-vkJgcOG&AOcm82Y4)@#91_ALOHcH#lpPuk{NS;R+ z!cJPlS8PF>UzYt&=?_uqNfk3f2PIjR)EvBen3aucjlC=m<#p1W<9zj|9NLXsTmV|_f*_j4 zGf>P{q)A*^VvLfB-lohXuTNLIXgifmnwzMcGngd_he;NTplb#|7ECcoUp14AE>;|7 zv>wucZw+s>_$|+(xv~qXdF=1{h#6Qr4Pn8?OtWg1;X^nnLv53;1wNKc<$4HAZHLvK z7fh_?!3V5pI&y2RNJEJKDx#;MW%f%!Biw|EZ@5gBU0s@aF1;-RfSUYEvqVAAuRDE= z!21t9A+r}h-D2~vugI9vIxYa>!YgxtMyWZjnVQ2NX3Ef-S4_f~Qg;G&zND^1O1gmq zHW%nR(NrNiuJE()m`4Bi&nmLd%)i-t@gg|82oxfT0+5X-26GkBj_5z*7AuI`o{~!` z3{Jj5QzOU7NHN+0AC?~yo~I&bF4Uh7g)6>{ zGMV&TtyWO@7Pto81%+XY{c{cZt<$TD_`3&wYKP`WlwlEEeLULMt^V8J?Qi*@&P?_l zj~}}YvgQ|XbnP;~rOuZg=|HKfavYj87x$n3vnZ~ZOtL@AqNyfcdYAe zsaURP^|lPE+6hc3gSV0>Nm3;wWbA@=eb`*`&sRfqLCP}Iv~m)?AE5_Hl@tYN{4WgA z$bIF^yo1}6MX@@aI5`;quHOWZ+%ni12g_NV*{Ru8_J~5suO4}!TF(0g1ZqVeeqaDC zvp3iaV+GiGt4s5&EBD5nIu*zYRK8+HKd$eCY5PkK!2RGViR&!YS%wp-X&+PzD0WpF}}m+m{> z!2~1o0nb`mo}Pp*9U*`8fLH6?(eTr^ckaIdyk1sevS?kBDk;`Mo41mpn1hVHi037G z;nIutpDhjHmLHuGc0`G12RMfTcsuw^pkBIRDT(?hewD`-$%^GTyC)BN@Z*uJq7eCT z3p|bQYlhOZ<+HktJ>|Qy4c0Vy+#i}kw4$wu2x&=GkZLq-b{SRk5f9Z60amiIJ3!Nx zk&^c4rFnYJ=M)Dcxp-Mx0cv4D9RPQU&K*PBM zPTKU0nigO|%5PziM)aaJVrETlIdxNj%q!-r7vU=c!&4jPz}y6#y+m6-A$McGrv7(JM(jOSB-W+Tl2E;>=ADGvL=z8-t0^Tv`t2C50wun#BJ zv4@#?7s_3H~*;l<#LFhBk={j zU`5?9d-~N8?DPJ5BnYG{t#y%$lV$I6j#U5LHU_2|Ps z;N?B)?Ir64Tw*7d8Ye)>l&qTt;mzyaPIhtf*`}2cthiP?P!j5FGd@;v#wC?9b0y5ZBuT_@rK_h7S+aTbG7VZ` zhhoTNJLyeIePI}*Rd7pWl;+?bOQxOS$%YX$)HXwc+SpA#YXhg`^_kV@_%Dy+VG&@rR$^GUW9DI&q`Fe+};>8kx(>l4bf%BFZl1VHB zn<0A8xVfmI$&!rp#d5dC$g3<(=BCJoEGq|$wk(n{{F2pMp6|``Yqtz+%}oLioPu;6 znj9%`3inBE(8Z0W`pYXWzHpYH7BXT6x(77MYny9=RKW)I}j$KN{pL>#24LXMhaYHdk?JFA6T=vlidiI{ZAs}=CuEP6C1LB zSx17hUeC|G!@K^`Zi9p4jw~jT&tRTV51L)%Hpezq6Rv7dV5RKPp5;GwlwpMlK2LYP ze~L8`^KigKxgnaW$~}0@{_ViDpEZ%VIOBX|BLCtXD$^_slOl#J>Z2yGhv#He+Q4K- zi9;V_Qwcxb$&Y=9CPUY7DS$Klpex-{CO~pUFO;h}DIuSJO6}=RU*jOM7z4H*8OEPP zCnI#m2!ng$mOwobmxKlfKjacKH??_?YwF2w72M;A4J+n34Y0=Af6(=J?h_ z4@>5M{Znbyphja6udTaH-DmCA=xk=)U0Q%w<(d`IDCvP@iPB*8MFgTp&{`FtJ%5|} z#R`pHS4}vcuNsWDQW%yveT2pZy{-5Gl#!nkivjT}K!C8U#yi>%3R;_=VCsXa{*lT- z%q^mL$Vw*`g@HM_7+OxrJs`S4(9AHsN@}J;7N5ejH&RXUjfHfy1FI@3rt_f(_G_p4 zLyef*Jm`YQd(5xq$v2YPs8bLR&Y06^8r9##zf3sTPttxnc(a!>`5Z?MtbX@3W4GNg z?yB|uA@iZyhK7g)*8{{9&&AMN5GhjI#1x<(}HqvOhIh&3Y)aiLil#0s|p z$}pXeaXTNCh%N`InMB@Tz*B!-7Fy)w$x&7}nE#FGixvMu94MhOJX9htI$H6`5rr~( zjz&JElu%SMIMD1-$G{-?(g;ggpl^aX1a#~tA8FI~OeV&zO~jIV$wEbVRJbx(62lX( zVkL(2Bcm{oQ`{FmWX^ku@PpaLN0Hc23$Jf4SPYH6R4-vOXlgeCb=(n%o1gCJ%OULU zLara0^scNQu}oj*YY%R@YsN{q4EzWCO?YMJ24asW!ZjHuh_Qz02ZEY4N`%)7h!LKW z6J`~%j2^6;HHv5ah+^=E$&`Ze(^jb^vN(T0q>!UtNzv*zI#8>lx)kG(PHJIE#3Bz? zBC@j_Kx%skn@F_({Z8%rNSjNeN~Iy)>4*nw-HBh1cJ`stOL>Qn%b%#rr^xNn~#bM@6T)x^g@oC zeLC;{g)=(o?0eP(#UZ=UD}y+%b%y6d&6!rAD0nG|mS2}af6qn`u*M!&kU*o1G_>(D zicc<#_z-695G&vCgRNrZ0-&;7B7=$3I6ZvxF!f-6(OOp|T2hTWa*zd?g|Yo|-XS2I5Ooj~=Xi=~Vxc!i-P<*AI>qwyZ%2 zGuP3skciLKEQhl4+H(?xC-To|Bo*~85b~%7c=1t(SNDh;CJ%Y-N0CC;-|;pFVR8X` z+_uBNt;CiR5}jDZ=q3Awlm2-%YdiKbCGXyg@$B!fKAh-ixd)|K@>o{ipL0diS>CuR z+-lAZRN>=2S{FWgZN-C*N^@&sQzlo%{PM1qtdaynl0)VyL3KZ@k;=W^^Hc25Wl&Zv4DtSr2g-AepEF0{m`LS~lO z^PbOGP<=dS%L90yBZjxzzit8zpE?8?JWT(&_$9cWZ5D?RmdmV+t4mZ~aG?O^wSt}T`xP6;ad72}s;FxK9uf`I3|HZiz_4t&G<>4;0gL=ti00yIz zDdnc}#<vS&XV-Zv$m>rJ9L=DB`PHH=&t399#$qZJx;9SY9-mbp6%-rJI9J$uU_l%xucw z|I(?~jFgnc2PbV@@Z$fNsqvyxm9tD^RU-(at_J39gdj>a_nJj{AN#fU8qpzsF~sLq z`R-hnEJ{r@CGtvt#=S$iH1vOJ9$QkqNbwWn^-JZ!_=hnqLF^hnKf9;Sgi|PagUc)G zNB4PKl8SKaY+1hF?p5#?$B9UudAOuTPO_JxK^g`)2WKbuwQPW9s0d^*TK)w&Cd)OF zC1f{f2%5ac4S!Lzazqbm{EJZ!jf%P7m&LeA>)50(YlDK)zen}ve9F4?LL?ouh2zOY z1{k(la;d3C6L;vX#&vvXQ^-u8+!1KkVfb(ffJ;k^9z+pYh&MV74y7FKp zAG4nta4$23i=%M2P`{OOAh2*9YKZZGKhmH6nL#->_tp9?Dv8&z34_nvoMZvF(Wg32 z%;bDh{dSK_?1rghl(YO$`pL#r%aw^CoU^zrDiGtm{k%SMF8OB|&&RQ0BU)LB<=P&8 z)Af_AT#J&9wEmZDw7!(i20Vo`Me-0A&S+^F{z0aoIdT}+{804ew#{kz71m+NsKo%V!!3A*Mp86SNli#UT!a^aJ`FNXX-urG_nc#c}x z@anZI5|aEs0m8DLCuEpQvyZndXK3a_QO$==PFB*on6YaWFG!b#Q}Hu%Q=LEaFLmHF zf132sK-tLYpgH^Izho6_>PpFqBt5$Z#;Twd@Fh14E)*G0 zAOrW5S=9{#w&scd_+nLD#x*7BmS*J!gph^@|DBP|E%XfU^5Ok6T7jWVIrTA+t*2OF z^K=mo`M)cF2a+|X_{h0dh)90Z=uW7O5f}i7#>U>68y+`S)eYKOZVQMRIZ+e*(9wr0BrR(oI zdJ`B#Z^q03@ph5WgpgPsSA;{w=_De<7Tm>xsk#T~M3m}35FrVBQ6>>PYs_Hlg<+v5r=8=4@1@?;4R{e}#HZcq#i>q= zsGETF^Ob3w!#}Fddf)D7WEmJy1E4On3G38j7?X`lK^ z6GN}d8%#)Dhk!%ujWRg~m>&>YWL5WNg?mdlwfRm6_s&gPf8P^WT$wKuTAaJqRxmKs zpN`kOm)PK8CP4iCF9KI(Gt;tUlrFz$C|!Tz8L3dc%|x070y@f}e+~E8d^Uku^K7zk z;+N|AT)%7#h)a}JD!KEsq5~cHhVqUwx&RLt`DI~LV`Clw$6=0$*P0?xITsRRa^0GIxFdsbQuO+tT$?T29CZy6@9g(IEDqsW9zOM0J zBm1{8mcg2-F)k#X>0(&|6*pu<9@DlMF^$YY;YIo2l;Y5^f)|F5zP>kLsbrBHJVc=q zW3w^RJQ~r66ALR&asaWeBOO+eT)c#Cj+vXVLR@b^APb+z9dCC!($|lkrm_B25y+?t zIn-l(fog`yq+LdKR$#$dSfkpOUVnrkc1I1+Hw?x3d8$^iqUNpXkYg=++$VdE~7YX{%4!;c)2aDeF_X3`?Z9eDzZOQM>fJfW? zo!?u-+9tHD=S8b%;W{vLJpPN>Xqn-B1x%^wP2!N`IDSp3F;K!_f1-+*JaUAVOC%PZ z3QN}V8i*k+S1FD#gn*w83LbpQMAwwf4{0Z zT<1%O(0>w*+P?{kQpxH;r1``lr0576>*u;4&LQJskhA7j^HD%YokBY-u8vE9!^cdjfuwyT}p!bm`-u^#HLV$yVi(4A;JzG=F?QSY@d z5Ala&Z_HD_Q<7HQ{1dfNd?Eiz88P}}PT$T;{kf|ZhiHq7nQ#23QFyuC z!F!l$77nE*B0$Fs95J;tHxLNBZZW|1YFGyzH0Rj_0P<-$|~#!2G1Z+;JdS|?v# zg70|^N^@J|nS8JIeSiPRbFZyvkkV?Gi{Lx=Xeilr8BV!g>HQT49cctii`=#ha3EZ% z{UhvlwlanopTZE;=iJH|6t-U;X}T{UK0MUt0k&&fz5m2~$DM2gyFkNhEhL40fk#Sn zl+z(EFm!%d^ECf}asq+I3B%*{hlWqwz~s#>xkz_LhbiJMb`FN^z_@%6texau_&pFz zZAKu?3_&XLQzuOUkJ&q#v=sie93>W+UvWMs)NycG#1b1_p5T(#mKY!Td=Ow4L@&%8 zue(~ry&48wmQ60gUD*!Xgpi(cfI+bJ&QE`e2d^K-k$V49s^DPGeXPV|y4ER?$krFY zD4^sO#g-;uA&A<=FJJz~#MQtqClS-&PswsyAqPyQ^+KU3uxMtq{O=+1)IyAAgR_M^ zFR*0FY;Ma=1q&k3D|187Hvs)w5|MQRGs*r5|1SNYK*M6+RN`%ngQ=SQ{SM|QZ#~+l z{;_A-dHUvl=!xlb?dIt=sG=!Q(Zca}V5t+jB*$;Pa6~7>RyO_u3kf)^*~8-3H+H^7 z^%o2;)xQMU7I)WQB%1QXi}rF4T+jrX_vR=-of+6h0@ZSRXf+zPPt zDH*;4C74zPfkd94>nM=nLpgvR6#HEfiV;OUOef3bixvUk7m6o+MXNLAE|@)Gpp)IH zQO>3w6pZBoqCa?InJKRm)Qz*dG%KBL2Tk1?{u~#<+Jx@XiUGq$P3UCl%erm_*KOxz znzA|ZSGR!sZm zbJaZ~C2_>HGS2lOWXvHjogawH>3wA}YWEf`^XI)x4tkwI0(|jC@-ek#D><8`qCT0} zb~+7sUmHCVEzQ~gBSPt0@wJmEHnPExp7G2i_&MB@9wYSP z*m_eC0x!v1PexupjMLn@cuA0{zRhK``dId7Nz?P#JMQ_v!tSqGkcWcn z^Y`r05PZO3NBzI&U*CezU(UB*&MTwKVw!82J6!$^vTiTYDoGQ9S>aH&tO*9-ed(mc zWTNQfP4EWd#p9#{zco1@4K@DvLE)kO6Tnn@515=b&U-@=tl>8(=s zU(NRXl>r{fBi2W#jeAM1!Tgun(1<&Axe`>Qg~ifr&{9Xl*UW_y_yLBJQH;f;BSyL! z_%UXHvk~4rI;8#)nXTpSG)kjbv(j0-fJ+P0Cs*tPx1|W=x~1oudgi7Y2lEXuohVll z=S}JcajfV2Jc*!z4PVb|-a_CN#eW!nDCe<;C64!A@hG)*phRmyzlCVlj0Z2MaSI;- zG=LKcHYNPU?|=|O9?+#C>S@rmM>)G#IL+m#_rve~%a7ooW6nezp(YTP*N(Se@sz#N zs@%y)sL00|nhFZdI)+C<_`)amO?3V9r<)_sP-gP7li{HwCT6^9dWvl(Y#;oIy0Pm# zVw{(+|DxVo8=56513cEuzE9luZ@-lXJ*?`kO5_UMy#x`XL~4?RyYC;%xKAW04eyw2 zpf+4KOq%Q7nhK+?h~Dmb5}2AMdlJz1s%#tcv;Vdmv;}UtP<_|~J*iYAT($YFKnegA zH!Dig_1-zH&a>lSzXGg(QaDA(Gd-XDr7+9pJ1OO&y*n^EcV>@`Xa@rAUQAH%3L8Mg zL}o5Oc?gn+=8b%__j}4hlbwvqWTmpFs#Db)t=L4kuy7lpv2>rP!#Km2&4j}}8i#sJ z>LcyyOfsddc?e=if?AI(8E-0&e?h(GMz~}vcEW_8M?z;5S=w~UC!~M&61@a{K1R)z z6`6`~@g1OXZz_{Dhbi?nR=>lcXx9drq{Hve)}v6(6`qG>eR60yF=L-j-SU3VK@8gU z;hI5^&YY=8bOL>R5!9+UO4TZuETpn5_f?6Fj(RsQ0&dFh-v{-X5LXE#OOvHB0s;#0GRUy*ZHo8g>5uHt>a8hWZ%|L-74zI9 zTwJlG0#^qB(=;dWif91{jIekCfOscM@F}i0Ay3gr&`5Us!W+7DS^#uQ)86ny9TYq^RCFcs&CjUO$WNw(zMd zH;^N;Kji8TxnrQIDv^Quzj8ds@U{-_z7n{Hsv4(B%`~Zpca-c|8MI@tkO*?&>1G z>@z}Rs|LPO!?31Q3(QuNu(fb%(hD%mM z?|G4QACo~bz?#2sgAH*|ed z^(XR?ZWj!n(8acAb-t!@iK%$O`}u~Q;+j$8IbU!wLQ))B3=`(Xn-nZxFIz@aew7M) zd`pWaW|<4?;3q*ap7;#v6JZfQ#JcoIsLeRfu&GoeR{M2!l5m-z|E0~K!@b2u;QO_~ zZKlzv0C6K9MPruOmmgf#V{g6|%UnIb@u{ZS=7AU2AOCI*Qp(6a(0uB~pQM^AD30ny z64k`z>2yUBjOWkySBY|;OrZG%4M&Z;SL7>^SdR^l9d+OFsWAP!N! zZ~j1duQQkV)a*#0@9X&#(8;UYT{F?BN8O`%`YBBM^bY4t8XlPZ92hyvQ^edYSd3@* zBnXrGsb*UgZk#o2!;%_ZG4XI@t?td_mqp!;^TmbGg zu9ZpSI}#)-0DbpiRX}Pnx<(ra`CkBJK%2j3>BYj5*>vL&cP7z`kZ?YxAB(uV3l$u! z_}SEeAo~VYDwJCvqN*KKYTcA%1l8am`dB~|J;(wK#@~fc$&;-7K>wnj(q4Q>yE&Ov ztZRZ6eP5oFJMDYSuxKF!Uq+#61$~LxG=X)GJgmc^w4r~ob?9=5MX>cvnJr%|H;_&8 z5QAk^Hs)^gLpJ9a7PC5Q$qm$Gv^dgFr+h50$q#r(wlJUaJ$>|p^XFU>cDoO(_mj4O z*7hf@b$cS1QS5QsAGIdyy(GE8>Xny>WCQbC?IhI4%X)T{q(-e^bI zV2k+;x}ZV7wXUzk|65qvZZU8@aC#A0?_y8_LTo}j#-S)d^a)Bt6oxcYp;X&~G4#+~ z+XC4r4Kk`7HGkNtEu=WvojZU9}lRtjf$_4ee(Qiaj67s*Ge%*~TY{ zTYNIZx$$yJ@$|XdM#H1luYZ}?W%yX$w5K?+aK6>d zG(>)6-NSqwUB|kFJQnR{0yTt1W+xH|RT+ha5;5RFQ)~?KyzkI|<63L~kK^y3&J3Jd zENy&Lmd@t9ycgTj#>~HUM)B>2>O2G`F!$GM2-<4_0mQNhK3=pZN`ZWE0EQmAqDiD# ztc&GNMzDx)VmDBxgHrlMEGk63_t45hwEHM5y6Ek@o-T^|P2qi=O+c)F5M_qT_QhVN zVN=oHFqa4?F^x_jmi>l2f|K2pGJ?3xREhANt*PKqi3gM^(4YMYB?9e=)+bLty*ip6 zA{GT#(n%4kn5Khy+TC+Cv-v<)Pj08P8I8K{m?P!S+++Q5=-Bj2)uRLE-jd%behBF5 za=B~`&4j{ z^&2Mgk##4}ae|y~9^+#9KyH&+>|~xKh~Bjmwk-#So2Zy+VJp943gh)fdplOA#3@$& zh|o3rpdf?H#Q-{~5%1s6Edcm3V6l9zK^uvcwUJa&8%9c5QBuW~_}AdSeoXuA@#XUO z|7-79`fuKEI4P|KLx+cfwGi6m{ZKro=s@DO0hajS7D35&~+N z#H~P?xI2;64|IL<%(XH^7;NVSb|%XRr^H-_v$=huej>^% zvIdW_zMK|z%Wy_veubhRAG13qBdyfjAK{uEs*K zWZQaU|7F{jm2p9Wjs8F@-77mbb9-Bczl+03W4TxQh_MPJKXxL&Z7?@F~R?BKTUh=V4>hlQ^JmrpsPnI`&)vW2HQ zS9g;=b(_I^rgo^!;TG#yIYqx04Xpg<40NEa_nQM`hvho~nYmmn_A@?wi!Pa6K%5Xf> zPctq_*@zYdTW)E%ksewvZpr(NsU0@XxH}uyT@7_{*+d~L@xK$_=Dw?AVCy$v%t|ly z*>81+Z{4Bm_am;TU)w*bA3*AD7+hT02qi^Ieg-ttEIXoDMA0S)HSEgg^<%~COl*=w z>C16wmmegq#9=rem9C>qf?;e;ksA4MUjoX^ubcwZbWWiiA&|Dd?JyYJ_|q6%0(k(7 z1Qm)n#HqzRV#E;JCxIamKbkl`R(fY#Dr7R7$_Dr{D7%Y(gu`07Q{_G;U}evgE96Rj zwX#qu@)+kw@7bryrr~_f z6s!~xP;n2a-%j3z0TQVvuz;5MHn>E5W{#mvOPu08&mQz~d%42zC^5CViPZE2=ISJ# zmn|4?CUFFcC)1fl3o$vJ9uin`GX)%k*@W1z#L6FiXX1Yw?KI$@u&l$-O8oD_vd5*t z-~Edf`!*Oveuv=T*uMu7BxE#@lO~yoV68mJ!vVq)1W=enyN=DrOxz>13Kei#)vqMW|5I&wBrak0E8>K|l&eXAzRa;$|YU z)YyZN6i#dhLL8FLX81(JY79w}Jea1g%Y@m@VUw=5v{^Z!opi!$FMV z#^@Y=C{vguPsj`$-L~4wQ9Q)eUN6Ei`hN7@ce@pA=5ho-U9K1B;9*g*_8>s;Vh`1C zrwld`Zf*_!WvIi0E46?pK32C++ z6eVZ%q9i0^ZbyMk>^zi^!KpWi5Zbbz5|SX>bPNH*(B>Z#$VM!v{YL(*^Yq>D7$SNY zqJ2Pw9X*Sog^uu1EE&wq;K60P%%+=lSl$LOhblHTdry1}eym=3M^bSdBAnbJQUoM2 zmtaw0u$e$WFN0%GNR|ZfN3NuYdEvTpGF280VJvAHb=-&Z2mchoc0uEVd2mg{s%F9W);80P-D7o zh(jOqbW;+{WnQ_30-e~sUr@m?DEbvXLm4l76JcYykE6tCN<4)UlVtpP1dBm29}sfR zIv7Hl6ZIgmT>45JToO%3;LwcUax)I2>8y>xmWhaW6ov|my>)>I<2^t`lDxMWA>-(k zinM%0*MHU^bXsbGQCjEBK;kEwTS@{zg#$lg{j?UCDzBw(z)DO1IF-ohtA zx4$ZQh!P`Ak08LNx#4Tze!?!Kyq@5|94yMWD>Q>h;9wqc3`m8iw9r0X|vM)^mi^7@fKV zCBVsjgA$O+766}Y{3$@l+V^4zNGCSLpd`}wVPhlNBe6(PZQ2rxfS&Tb7~%wFAHk!i z{4#pFFlIGsL`3dab=4~Q zG(HhlW(~ln*7RFa)G+26`pL6czSM3?%r1|UTd6T6_7RH5?CRN!ra~yYGlCik7SlwC z(L;z4A#B=M`I{PplU!!-23%yl1~Y{J=}@o4|7#fFgaI4Dvauoq$ud`Gz|&wLf5Ep{ z$`{c&s%}V(-H%Uzof<+!5i7T0Bd6i6ln59V-h)F~HaEY=BClVXZR>dtio-ArqF9|t z8Yoq7AcckR9Y#nymUAf)O{8)=5D_C=xq}EH7wn7@kYNuZAZ1qw35dsTBp||0O|6&A z%brV>IOXh91Vot9?+J-hEq;g+;pQ&_ENu54Y%*9@k|dijRz78hZq1QER(4}6u=PE@ zpa5LP$E1MG_yOO@^-QLVw6$}+(3fl1UzE70UQs6VTOcHsTayAcs@7KY(yw#%8#?Hg z?F);UNs%vOKVtztGp!qFC!nTtNup$#)mZWGuLlO}b&by7t1*~M_INt5uj~HKK(E9~ zERAKWSeKW1zI0I5ibBg90a-*C(@aE&>#R$FM7yL!1%L)hI5*n#U7j$>7ozga{-2T?vTD z=)rhIB&|-$__(nA%pAODQ{qA`p~(YDAR#7ZV`3iT4rWxX$m0GlZNO zMLYYmj;zl+9HZv|!}Sw6t8N3BO9wEUnb^Sh>13ANhsOe%tq>0n+jQ(GPg(uULppkbR$Cpi(@a5@4ma z!NbCipMVf2=B`4LG?BF%o(v=8c4p`%e8~=Y)N~%$vL?z@(AOAF2k;+!NS@g&rk|>{ z5k(@@nLsXyvC>bVgt(bIDdCf`{z#q@mY1WGKK&-~DYLXJN4me&ula^_|nYQObPa)o%_iqjv&-KS+meVg|}&{oGDFsB)xAaQ^gJ!YaT_M6nP@leuaRD zAa*7tgj#A0eF$OteY&vF%u5tdNR6m(k`sHO_*l*kBd86&&`E%F<__C98a{*M0g-PB}Vr1B62eF&?1KQ@by+9e2i z@HZlk5V)gp0M9&$05$g=6s4M|+X!muhlq&q(_0V`;HUmVh))n7LqwIDvo&QV(`OE% zM-S8E<#Tp5Q_K;-OnpjE2O}GDj9v!zI_7uLi=Z9)XV#Ky^f6{rvV5IEAB&tex+yb1 zekw(3%t<~>6_44eAyo01m9nT3Fe7muB|^T5twJBFU)!hAjWF99ga=eOzv1!sRwI|) zW%ck-y|XbmQ?5#+2oHG=>{WM7D25yUscVB;k($09~GH3y3rmDFFcaH%Aw zV`K1Q?bt+!)kzGC3?1YcPFwUJG&7v0;4Lz=lJX{!BulI|h&U;n+UvwfBDIqxMw}>k zfEW&*o=5^i(f*1I&>Jt)M8KldOj@WiFZ~dM$TKr}60I!c%Xl+cKGC<_0};JOZ?w92 zN6*&xWgp2nv#o1^Bh4SA1#B}+9%KthzhF%bw}z@0?qzN?uS=jaxZm1}l&r}@=M(J! zin009j!TgE1RH~ujFTiHWxDG|h{P3CfUJ9~77)I&U3nVA44H+8kdplyNg(RM8n}9!H6Q>Hq$dIR=-8iyt z!^NZIT!5bI!3}tH&}#pjkPe3N1&T$^ z9|bgFS02K`Cn#T!gP{_;gA@^Y>s^|u((SwlC>_p4q^RoR#1@n&Fe`Ni6&AB7@hV0A zhUxL&(8-;=;G9FJo~##GZG0SU9c^V!bRHl2a?58BU0E5r@H4QTM@^?54%birD81$h z^7tCp=H4p4VBN}#xYVqZnNC@|oNHKzfDU&GgMmtH5LGNRc6&X`uPIU^)H&|DRB>c| z=eLx_WtO!wMg5BTas)m;RcUT)0UmB>0FMJ;8r6XZ3QK&O@@pkl;(r?YH!Vp^i-Cx- z7;JpNCP9U|Av$VYSAmEC$LeeZ85MA_skv8>z$YKumKM$$eU@rN1*!rS zxr-`_>m;cVquY9g5-y!)4ka9V%`gH&Ja$A-472N=O1xf|y82P0^9KY#hLu=}e=NQg z1WOBpxbl3CYKIr-n~|d@5;bbi1!(Q>MdN#5gMCtUs{_M>5g+!Sla4_;dSu)1G;L zJ8$ESqYnG0Kfd?Ejko8P^Jnk;z~u4HxiXD0yz0zkA|>+9YkWzFZ@)r_Md-Xh4VTF2 z(h4!uo+N`YsM&}z7Lk1xC453_9z~J_W&tJQ_`E}aN5p-N4qn_SVo_^!|M12T48#DW z|Ni)`#7g`RVOftrWylgg0nj;~LG@!0lt2R0YI^;I|EgUwO0{53VscXkZQDg zy>^26y|@_E`HT=3ac%J}rP$chMz=?!dj={wtVgHPP1jhOe zWzt}EP{1M~$CJk=Yo^iVhqcxy};gH6G-|(tA5_#!R|L1nB+rBP*GQO*vD;e&Ioh>(UC@;AWax@FbyHBw?5OWfwh|y%vCm>B| zy-A52#(blBY?4%EB)$yc3i>dtBg5#YKpQ(zB1Mi2A-&in5h5Z23`&UKIESq@r0~id ze=G6Nz;|7M{_}z}%cnV8QWi~q+|UH9dd)(sK#Br^Q=x^$1hF?6qBHR0A?-}3;@yi& ziYkv_%bFOIpu$2ty_+Iq^{b!@rG<<)7#|1Qd!7nP-n&yP1ZXf9A4uw66iMOA`v@^o zW_w&Rq|8Ue7>r}}Fj5YuVt2DOpUH8~FS*jZLaVd0*~;0|{=nJP`@{Yxj2j05->s>E0wPftN)JpJL} zgbbtE|2{RE$$2|zi7Z+Fc?zUR2WwFyPAa^GkTkYg4GS0LFw(@RnH#W4(qcW&5L!sv z?QF>^1opY|8?z&4IWzea=b91juh`e#)ctjMj4drPm8)Z&d?YusYU}|X<_%VL_u?6* zFv{6WKamVA_F@*WrKGGYD3T#=ZlOw+A)Jrkkmd%>6VT3EvJpxf<0zewH-jxG%rFY*-{By9Z=irLHOP8&VytMXUc)1=6ATJ>Vh}jUq$Jj{>KE|Dd z;9k7}!f?dDh8{+e^7p4eJ8m$R zDoM#khZB+_sS9w(V$+00E0JuCGLotpN16?^+ggp$atKY%A(ZHqYn`)c62ma(csS3 zaLDK|D)=<5sJiGL2{?_4hp{hObz3xs;H zSCIz@5sX<64;y1$Pl*_|Yvb(Cn8RM<@oLrWARB2HQlA|Qh+Cy<~GF+ZWkSORMnzHB68?G#nc zq|08Dd2+hWb{^-i(#}L@H(iA@WOBHv&1O>MZF?_1=Xu0gi?uk57W-*N$u11HPN9kK zF~s@>aoUN@j}bChW+x)zI8+EQ#CQ-NCC)4OSj5N^)SV;=zdhw|{I_%_`0sjQuf$6H zyJ7jszqGb6ln@Jenkq5GJU|~=v8;o1E<<&mvylvwRY^M^(4k+`>Ie?4#PvR^6tLu6 z`VlhPN)HaLVTbmi3<@qIPehx)8zoc*2fw0*&>EKTu*gKa5MdM7eNkfA{D2B6jQP3d zX(2K*WFto6SPS`xHF4~BxEV{hV5Yt}g5FVhLb;2LmjF7axYn{qSzLIUMNQQGW zBdzgz0f(`=^*9-t8Ew9gOOl$o6@x=0H9!#gMMHr7lry3 zE7$&&_;4NZL~f=?3S%Ci%3y434|*`f?b-SkDRRzPxUvnAy$?1E zkZ?4ji)hk8CmS8$HQlx?os>E46n1F|a9gZW!#zfnrSO^y(*oZtt z1sCK-x=Da_8()*fu|MExT1eUF>1iaf?LC;tVj1b=Ih(1}qKP(^@$ZfA9tPG?*I5N<#>YlDhcam_sZk|~Yi&=7R+82j%8aJPzLgqzQr2t?i^*CW zlaOs?ba)0SI#@-wBTb%Nqce$NXbR56VlW!a0J4F_QioAxWM@CkUPv6nB@t@^qcAwZdj5TfK8=lwndD*tyq zum9G+S7Igp{UCg|W>JIyX##AF>_-s?OOo^u$Cc~pAR#I991C%^WR9bk2(gYv>6M}K zFd62tzU(sD=iG$wknUagfPh6ttFkeuOi-8s;y zE3p#)j`;RqP``7%^a3WhjrX)P0Nr{m5TmFcBP8k8p$IOv9swAHM}Y{HUlZaH@c=bS zgwjlbE+V;_E~cZViv_$%U^Zu=-b-NirBhBNu#UI6MW5mWovp9)8WY%`C-{t+Wci9Y zbdsQhUJ4irRPZR^5fV~stme^@scafyKC*-wzO4nna)n=s|9i3YuK&BdTz4QcjpzWI z#ek4tDuThGfMBDH0}{k}i3o=r69`dUz9zun(Th)#J}i9llxZQrqldPJ9^FiYP$7w8 z4J5I!ffN=N zN*uwUO9aF6-MSJh@$ZPgRfTwY)UZ$tlW+-ed59D>Vmg6l%2?6OwBuk$qp?xkXcPes zNxhl`DGGWHO$=k2tVMzxbL0kW%Ba~Np-kDl1C;5P+kq+tc?Ae4$sZBGV}A@G5r4&| zNW|O3m`|X$k=Bo>=&Q8J#uRlFtI(@Gx&@K$POpYUtU*89*}6rvm=~fZHj`ocUNnw~ z@p4VH4FRjssi*QeBPnT;h-N$;gCR-CSvc4zZPW$8niwopni>M2qahH|4IgUz?{@D> zti-=31`e*54qClTF5f(nV~MTU^V3cnyAix*voO_mN_ ziv%rvWlkr}VDjd55(L!DnK%@{?17;N$9xay#Wlwu6tQJDgepe%ZNw~AaLNCF?VV+y z97Wdmf7LdA{l;C81VV6kSRl9scXwFa-4|HM;_eXKEx1e2kc5PUxLm*E(^BXCFg=kQ zvc3!Zy!n5~x!paJsXkTbRMoLUIF=|`0(qPi^C-$oG{~LOETUTW_L<(7^V=^$cfkX6s8?m7_@qqhH}ORyc20L2AF@v-$2Sma6QlS!bd z4lEilNcvmh5{KYWe7dn97>&kZw#)_^eWstN+jurfr}-haL9q2LlvEfTtzRM2y|2pXBxTSQFiBRT7q}kM80BHuo~+y zfnS1Ji=CwxU;3xZ>Fmr*YYcm-D@X@ls@>>?)~m4r6EXDlKqIC+j73tJav~8N`pI7i za4>i;Oz^1=%OpE!0u(jjwZ*dMzOS$itib;QmN!NPXhxw?ayT|6OgWMW1)N|r6j|ba z7K0K=?+OG5&uhTPr4$^6M=f*JR9qAjcpG0Om}IPl5G7-cMM($s)?OH7DOlG55otCa zL}RFa#@1*8Vtj~Dz~*ceZCElHhgsNqKUKx|Sixy*C@*0KK5H=s6+Fhy(hnVcpmyO! zTz`}yxAC4V^zY(jrXc?Nd@HR~`!VKFpw7RXc8XL5Ym%i%Ttk685&03995&a6G>u0d zlL5HoFj+ag?uAc@a-}rAsN@n$g!mcJ9wmjS`Y_(W*b&rrgpN*c+NAPDPn2$Ss*8!z z2d$nXA}vJC6R=2O+qWTfP;GZ3po64+83FCYto~@)iJ0%=(n3USN0DY?GM6IriON;v znS~vkO@>K0!7=hC@36XewOWlPb%M7OU&}@`dY8&*`G#)p_Lkbx_<+6KE4h$e)p5r5 ztV^T%(7T_0oXJgIk|gUe+>Zjb>K*J%37=kaCwV$Z$Yde}MAaxVl!&MyWaz+A&17i8 zVQ2EvfsKP^AqG}x6x1R0L&5jEbj%8@z<&~56+q?s*Nz?Ust79IR0xCc5PaF2JT6+F zNhfJse>0kxN7~zy1u~Jq&C^LMouxBKs8wZ#yh~5I^ON}wz3D@gPiY`YPwQN4YB24C zfDDb!v1k_3-CmP`g{szy;j(~U#!YxEAg;b5pqT{Y@t8-IJ`q=DQyVNK&$IOLr_shI ztm(~?ZFxiO@2FxZ^=V?W%X$35sNp>ht!j${nQ@vBy-hed?G!#+ueo{aw>bc z&r;3HYNWq47E|dPICwNuqZ`SSB_aK>DG=u!GUSPJiezZVp}#tT1voTPqyw8QLHSt2 zaOP)`dyp`0U}%7|>WDjSD00+0qwtxhdf1zxSwMGd z79|!^ZSF;ZCX%X=0!^gkZ89`br$1vM3+NX-!enN%j(3^7Am6HEyazc#p5YhQlfLq- zaZKrUeM_*iak9IwW!xXN)t>HGGR57LtWn^rU}JTwyDhW1i5I+2>5u~%;a!YN6OKQd zfcZGVL`rnxNKXo6h;b2lGDM{pdD@A}ZWN@22uI^G4Tt_=tiW)}OQ=Yw0L+lF@sO>5 z`%JeLSb={X%bRf2GJh!+CjnK`fT3K)LUSMrA0&>8hZk&2J9*MxGt;F_7P=eD$GpVM z5=>$u-79gSz) zNO5lA#@Ji#G{gS5_ZahjZ<_KgOAYec)c&d;mwPD&u|J!8o06h81N{rIuxRkVL@1H; zM-Y%D=0AW>Cy}6Und+DE=^!HeqUj_eMIeVGm!R-5m>#mTI%H|T@VZCH?l>#30{=ZM zuM$vtk-(b-7=VqB1w`<$pzQb84-mn{3SPj%L-~CP@bTPTXvd?o_#sotF{kJ;o(`rL z&t)1}TD*7YBnx^|0y?NNdScL`8tqdsX;wWWhhWf3_sC8tI%#lTK$D}!TpN!PRqAYt zcqH{e@&qLP*Xcx4@4Z~HG$%@I7|$B0luc_(0zC*wsB^7!6R4Du*% zn{w$7<48>MME%pSq(C&7hs^>N(~1Zs&X)}_urav?K_ToKvUk<+x+Y|NWH}wO0xR(E z#gb({Dg|m5&-7t{4^>3aq2QM@|1JE`Qs9D&B}xHHd$@Q6{>#*1;*_o;u2N7ul#;wk zHoreD^rW>g3zr@kat9GoIJ%lR0V(q-qI6Odak0@<$4)>KP#t?3MS+xa6hc7CTm>JC zq`ZntjASs2B1w||@f4{h>5ZaD192}+fnLPCJ!oM7)&8mS3{%xk{{G(D{!8|UzI}G} zd7oe1T{1?p&-U@y(_lOwdr7_F??{pvYM?(5gHK8NUtuzxm^TWC7NXub95O`R?TJtz z>J21HiI{sKFNHX4(6!@X1AkZu@u!HBRU>VNBTu>$|c zv1A1bS?XFX;gs&c3TOT(B`hFCo+wa9nmEvnPLldGdXb?j7)d`os=XwGh*4d-nSt~t zkw2LRHiF!qIE@&NsB$+ZpdWF!15>Sx=^aBg>r(L6W3bwpUwngJYHyhtoS(Tn zHzMf<&$jkzd$c|YhLKNvX*>YQL$(;6*fW^J0@+O3{o5&U2PHp(!Fce7;V=)&U56-X zZ1*7Il!%l%h~pD+Hz6qjQEyw7A|URKqndz3uou+?#N}Na1XG?5t*3*_Q2}bZatEkT z5vcBAcdS?etiXQ?OXv3qOXsAPR{%2>DS#LyY@n)Kw2S((0#H5ZAf?w*eaKMb*D(l> zI(KJQB}HR#A4W1lbt}BDhO0xU&G%%Wx`T-d<=KE>Vrd~G)U%BgH<|$`K&~~_&DpcEkjCAvo>qc?6xz4&*|&GN?$&q zL9IbGGf7&VL`V~fzJ!U79a{+l6D!()B8Fps3B-u0M}Y*%;3k9^sZt|gk}QtH!6#bW zgd?@6I;1d}45hFl~_mLpLa0^u9 z3R8r)xjUZ5Os`ND|keWU}yPSeqjs7 zP^}uMle<)d`DY@u(cpZCNftZ0JD@QmAE7WYon{OyEOSix6yYF`K^;-|dMs*)7kXn8 zBc5+1hKW-!s7E8ECx|kXW^YxsKZE4;;9ag#135=$3L}aWqW6L9Aa|&p!D5h_Lkt*V zj$jVf&PsB%K26HIkVS67^q)4=|Dh_$#*{kpoqS-Yl5XH>jAe}e_-f4V7cjEdTvaEt( zUPP3^YD;@nZ2C})JcOo!#>mM8B#A~h$0v&Ad{b6D&KZCW<|CLyL7v7Tf#F%isKG2Y zlb{hNe+xp2NN!UcqB!}okx&)ar4fUI_Z&kR!IU7cK2kr+Uiw&_({qhl9%Ua6;Pvv+ zhok8L8n~Jo;BziwqRb&CGl}Zc&@>?4{unr@;yMT>R&GyhAd=aLfPgCVP82?|{8vQr z=`8L;Js#SN;o_6j?^BNg`GN?FB$LaQRkdN5EPQ7e4wMhOVtMJqD^$S>{2D6bq7qTs zwFYIyy}Y!ae{pTUt`U9~E(-L8WC5cz4ik-4I9;0XiDdR8j!Uv*ARr_5>18GsG~%|T^kFRXfT}`z~nS66fyN41`y%CvaeO06S`oY3A6ez>A9{7VA=D3S#)P{p?U=bDB$VxVRimTHnr09;Mcfg|_()|!p7}*#~#V`sz zaEM}fC(u9&f-4x#0hFWp?q5RC1WO1j87z7w%m1Czd@BvXb zhm1cn?4prjw{$M*pRl{6<+mPNEAT&n%EZxi$-m^xUs>=kHvTPH?pp)Mv$%lr`%*xl zki$bT)3Yez3@hP91Kx2ha__^LS9* zkn|Q9RAXevW6%dHKLZOJ#37Ck=KA-*i5E_#e5kjUjYkMEhgYH zkr{FUB~08Phf4|HT@6JEmA?@OurfE}p^4k`iQ>yd^BXdx)oPjdDbmWS`3{68`Nh2j zk2dvmun+So;VYX3Xlkh?gOKGETm+Y$(12Eh!%q3UOZS;e(8aj}D@=teP)21WM7q$f zaKleJO9lP_OVqavFoGB!0k|W{5kPJx?Re1nB~27C+g@NESt6}pF$Yb&V`XMgB%a%s zU+{_Q2W375yoiG@^U;yxRh6noV)wGPv|-dffX7c5RjW|IL=p#*$H8|t#zqqup8yV` zzeW)w;x^!rppd(s2vwBQ7LKaN>%0et-blxr*wmrY2@K*`g`H3sSnmEnkyNm~OyMWi zlS7!MUXp$I3sv0BM6g)E6s7=3Md&iX<`l@{27kas;ko5!D!)1r1S@?r1vD{pZA?7c zjh$$sLndYVGM_nW$J`;zVk)1M7BG_x&jjblo2*2qxgjT0!e<_H37El&WDq*lR(J?* z*@hXOm$qcb+DlROt45vQ1G;9Jb*+Y6d5`Myd#JwQYo@~Y3i*s1ey*~V-6W(9;pdMC zf9s_1`j4R->xAE$0lztZt?W#h3gBQY3h^bRm2U(XvK;{~rk5h1gi$&dO#ziVi7XkU zvx>R2q1$$2A`6h#lbDVmnX$~HmY{TyWbw)Cv6#4IBR8rZbkY{-OMhu4oxF=$S}4>V zg++-{^$Ga+xbZ{C6Hu}b$3)|)eF@Nb{swqxtWpONAeUPWADevo7c?onj*&nkx^oMl zJ4R+NOscT*PPr(r`vE>_>|iKa+K96mb&O=;V)j@};|pdj!^Qp-QFy@%xEKVbKbEy+ zE?+C@ijU!i*p+uXp8V1c2@?+#3 zV+dn$=e?n(MlQXm$yj9HsvbJ=6YpnTo=+E&}f7UmN;mwJfO=}W5ZKy ze{1&q2l2NKma9B7!Wq7jgX)Aw(mG+lkA;Wn?~0~~?f!s82D{Lm0v(Xq0ig-6V`VyI z2Km+zOk*;gEq|6t7})uCifSWr@~v|4@FMTimw-%k6swV^t$HRsFlep$hVEo&t-qKA zc{*!PMw2IPmK6SRGY zq8CQzcoYppa^CEuGW zis@=Q#_2g+|a&Xw_cf^UqW^uigHqq2_*2absZ6k*xtSvP)Xn`ozvM8_{Q;nKaWjwa%`?ZauO zH&*9S0dn0Z-$$U%`*`K>T?OWHX{_3_~apG0tH)K1Sk1`cWjP?L`8i)Q|x3 zbk;ZGk|SNSFGVt>;{kcv$y%#Xpi@eIH8}upHf`iptndc2F_6q4exMt%j{Y)^4!XCE z;RPb(+cx4=24i;?naGCJR`#3bcLNkTJr4turqXSAlw^W&FF!DYJH5wc9Ic{Ui}$hR3hx-^b0rxgp>|Un zs@C98d6Xh!;KP=AzjRd0j{ZxIKQG9CeHxGwC*ZlT~;hewRmg!g0ozKV*5)@M~6 zUT+oBJ|Q*3!TBK|1Tq>y@Z~lH!PDivy{}IOAPCk$q0s(OA$_#`{{H>tYRlfyWw)%? z4HFa7T91GLlP?yn0F1Ekim}LG`g`Hke+GJyJ8$Xttei+;qA~QD z?2@BWq;ax)5TOk_vpy~hkMi+{hh*d6M|P7F&ruB1 z?F_|Wwp>F7MMjRm20R|d1cV>Mc5D#R#;|M4f})7C*mL`P7}yo#WJSYnU2d1ED1uGO z1tjDkG(N7Zi^eDC?~M?U^-e(0WJ{+4Ci!Bya#E@A9i}n~avx#K&)|%}#>TWB#vy{O zF2cdZ@q3_9I94Q70MC^bfMhW+2;};*7|6-LHKYDXEN}Bt5&A)8!B?M!bbR>SDO*DH znb;^y|56-WEH8mWfL(eXhXQtCWgI%OG8^I0jOsWEm`l(x7>DVU(x+iFf!6$NqKucR z?hqQ7!k0Qnlp>ya6)U4;y8Rq|@zs#n_cT%@NY+tHfq2zJWyg2b!x$8C6Pr_@MBofY zC?M)qO60)1kPK-kG}1~5oxP8_Wbra{(x>nz>QsGt7de=DG-c4o6QeCRR3D+v zr6&Jp{}{C$2bGGP$2RoUcUkkfhDO=I*iVL%=IpKvv}Cy4&Qr_q`&LBeF(B(`^gRT)GzU*2lqi+<0DPI1yB8DGn)VQEEb7$`M2XN#e@~n^ZZMQ2 zNeq84s;MSc+6k8uRk=27ENUX7@bQV5x0V%vy1%?~kW|?iC}HI#srilh_0OVf0N9en zJ3;7G9zSYir~n$n`}JYPo#J`jgA zR`OjSf*HTDTt~y1g`$AXXf#D)u8T)OCGx9NBugp%XL964cRoWW2C8!e&1hw(_hkxm zNM>j7CbP-r<6;w|weXO;k@8f3_w2bxMGiC$^1tZV(LPq~Dw_{0WcLhKS7RcEq~s{M zJ>=UC6~2mTI|H-J^#~ zPkAwj`=@%-{g;Ci8D>liMnPmN^DSo=^%Qq-7LNz3VW{4$8k|9rE$Oa5!oeXaH)CRA z%esI<$%A1#T0>g4UWzJ*UZVVP5G?qw4#Q>F8pUu(P7KHA#Bkv4i~yQ(XarZ^N`Oa) z-#|c~8Se11tJ8k~g-2d%ER=k!HBq8!{nACG&@|*HQ%60kW-lg7cg##w^}?qnxizgg z^o$=V1qw7q4#mSGW}Xw zP?giDYY;rNmGXUtD; z!~%Yi*}-eXXyBJ5LK zQB-6jG+Aup6Ep=(|3d;wL<$q|DU#@Xu6(#>>)DjZs9r5c;UUmA1&v1l>w^^l04%LZ zL_t*d&Vx}TadO|$&M(-dl_iEp&TZ)!Y;MYa{#o0%wvF`GlZQ(`s}~v0!ykN8J>Gm+ z4(M}oX(TW6R#ygFJk4oLUxxADQ{Yp2@n=f%6G^E?VdLqoh^UpBC2NsjN2ck6c#LU` zQ?~?Ld8g^mgSSr`cTu2x>3!O z`a|MGsny3~VG@za2nvH&!**;GcI2RNI1CE!SKv({QpFdoQsMRPhOD_oNx8bLb zLgSIsqwvTu(;I+GJ5BDB1d?HvcP@rnlg`ovqB!zV?htBl)Gm>E_y}t!x}nqn?2Z&k zn&=+?oNm&|kl21|I7Rx{I}i|%G_C=_P^Y0#1o9;1W2Ao$#i#Qhdb{5qi+%J<(@Ml& zG^BaJL{w0Y5mAwFYfw!<5npO?X~FeOvIyDYNNM92I`SnJ(nM4CFy_h^r1Qrzj~AF* zypU$bGv9xXcBaXKU=wm=SqLOp1rmQ|Wmcylb{#1;psM;a8dNt@J$jH}8|=oLFv+2+ zA3~Fc_=N;`81|V2?2q zP#lc>ZuDU)R_S9R%)toO+i#mUS1%o|=YIXjrn@^kfJ|f#=fPnU7c>P^dGyUMYHN&V z`9oI*Tk2%qV)bPhKTAA*WF@&3UwzL&9z>_&Gsd2-fu z>rYJw^X}jI*3OYvFo8Omuj(NGPT|z-)xkw-9FMAK@Di8sj(RpYSM{KV^@1Bn5ur}{ zU|+nMbQ0~CZLOLMyZ}$(c{VoIHzdIZkBO1;{+l8j4H_PWD8|z|XU>loYpopmN z5CR-rN@OVNi^;H%>E3p9GM{Pgml(9tTI@rNTD<&aI8^bK{U_>aWSh)PqQo&{FVT$# zdc|&_s75j@0s%fltpx}|wQ&@JV6tC%=I1m3T-hcRpEv&Qcl~`}=@o$LvIP*V@bCHM zlJhDZQZIbo6OTN;K9LOb$@}XvU&hl~D5)ghF)RCve8y}tnIrj#=V;A8$`{-$Kl+pD zkg3cz_a~+5`6V`*o~((LJda+iL!xRB^=v?@b`w&JBvro!NxG}5?lXyDQ&s;Y1|C+` zSqKHp$SeW`OASX;Boq5vOfC8Za=^ zE*q-`bRoiMrr>#7sT||+gCoBmwaEnMZO$&es2=82;DK_T&98NGW8)T3DdrJf*5Pl_ zYK-CFWf(tSrD}}-8frKzvyJ|qI%3qwXiO~PdM+9Rn`#2Upl8^QaE}*O z3KI4T?>8;={I?pGWt)UTsV~cdZ}z~%z%f6>#v)9ZQrtQpJH7Ku_l$3={** zjZi4+jXwflhVD{N%5@fsoQfa>i)t_aub^vee}(GMD^v(+1`7vUO~fLC#g7;$Os3&c zMC&wZYAATu6J-c3`P*4Q9f|bnw8&hV(+hZ&5p?Ef$rExK-|1mgF_z}oS87u{>eTA= zW;ng7U#6OkX-r;1J?m0kzbOf|vPv|LAi^qCH}=CONwj7g6ef1;1~eMeybFbor9THW z(b9ZCQJwvO0F$2W!*H?a(|j*E4Ei)>$s#m1=gSJAZ8MSt*y+<)Pg)5IZR%0AGM)bB z<{|PBO?u}{#@v&UAQ$ryoj`NhwOr8V9VEa!k3aF+k}-f~SX9$%QGDHEv;q-q-UKpO zyh+)iavG4r7J!~Od~Dt1JYy)(`vjNj*`EY4W(8;AV3XovOiVJm9ARj(cG=I$ zF{^jLq#I`9QxqvodlfWMY&ji;jqO!Y!XTQThDn0zw2P*Oo^3~xr-4;lzMz>5Bb(FA zQgv!*OFcj}BB0bFYwq|N{~Qu|RyS+#V4d@u9#{-FA)!;CfL zRN7d`4>FVhk1TuQBWSvZ4!!k4np^TG=$Z@g-#tjA5@M@<#p$?nIYQCsfsKLVB#B{> zjBP*?haRcVR26ZC*KAES2Cgom@zvh;leE#nPlYpNE;*U!4rLB=CRRg=Uv`w=C=RO64Bm{h6HLj}-S_C7EijmBjZ0GgTq z1%CZIIUfrn9J_@IpgQb>JO(DVe1?ThocD;4pkBEoNYb4TiIbq4>_$Y{)CFf?h)+^( z#;1j-aUSpk-KCe>fZf^FpCT`@1-;x4)g9^_b(21e^BBU!=&lUr9)zBBS6gC7J|f9r z)<|AYlbe)e$17031xB#oY+d#ItV@Awg}&K!hay zTW8Th73(zzGKEI9d(&xrN;7A-+{CMDW7ciIoisW6WyjM84_$hQUi4#XP-E{aS1VKO z(j0XW+ko1ix$F%@%AwBkJU!HQAv-r;#Mz46NB;GLI83atQw%C6s4PiMOe`uf+ZDm> zD!|LD&;Hv(y8K;b~L7e{1z5Ib=)jGze>POYWMsf(Nas>2%;b2m*@5RBOq{m@k zqD#Z@FtD>yiXaqBMfqjRVzVK!P-V*%6VR_8op}-#29C2D4wgzL{zMd!mg?R(2wD%u zK!`gUAD3?V7swMZpmYWuU7+0VWH?T0Lj}-3T>9JPrN7L< zAwms3i4#}7WLHvDv4DfAqMGL5Dr%^v)g3`CHS~72k?Ay$a}QK&GlXQi#M){ns+@Uh zEPrCObvH+;(^<=3O&*t<)v@L`YIkmxzt|&qn{W6!wyw;S`OHs@rIQSu$v7I9eEeW+ zJhG8Zh!T+DTVe#{y}NJ#H-7>F3OoHBiU{%c!)T?N`qq_DsyD-0#xRo+9NhdEx3Q!8 zp>3D z5LlPBS*W(hCcssxu=>in02ma)->#)>>vg*POhxd!`h)*lgR%UtTIw2L&+>pY#+6Tx zu}{RrARfzLV5^$c>VQZmJ@5&LMxMsSqPqAoMGP9;PbeUy-CHT(k@f#To&u#HMTQbu z9>Yar%Z=rglzNgh7CBXogGr!vMWL{Gq1+BNBy5)xwzDGenX*8M`}e%=?*^gt# zaUPa)tN^%@-l{-(MeYMd7D@m#2FEVZ`4#!`tS!}9qbKgYtl*CwJ9g~edjrc)I4J`3 zJY(+p6Az$VKYi()dnJ?aSndK8pYT|C24E1H9R-ucb=k|O1eb-(T|`-mR@{HfEB^Q& z9*|vcsB&>r!zh=NumdY){6KkAwQc$L)cI%x#Tk?jTW=4!@@=JIU0Ph>*PQ<J$QitTlMxfC&0ln*F;l5{L6s=iKH-z z5|4a_Ne}rZay}VMb)j>YG_fji^$0sNl3$$h)G&~Yy(tOmDcEyVlmsRFC|QVuYaRfY zc&v+sf#;u#gTgOdsiJ`2`7kNKYkdh518KTc)es?Q`dKE^NTj7dHtRC5{YidOf8yx$ zXEN0|z}zYKTK2i>!@Er=^c*sJ%26Nf8GKWGF!p)XDWE>qf7I8(?F&x+?AiB8?>gw& z$1mKAfCGTD0Qib^y72Kh*y+l{ULE(qvK;=GZ2g*3dDivRN9@P0PDu54Xo3E}+rD21*f zq<-}?8=wKD_QeNGa|A^cjx%1e7>I1F5*R3BEMTc>XO^UKD0;gwhqx5HQ&701f|ZG* zDfmB9i-oJN!obDT2jUVCuo)m|nS_Y~^#%qC2}6!$k@2<=(u%-$b)Wq_tRc$jEmn zS}1sNZ1Uv2Q*bDf?Qn_Wk!i}3AfPzkBZ7^w;4TadV)O2ymICSKgGf+Kq~km0a|rw8 zTz#2&uK8|p@|qWZddh2ULCu0g`(*}v1$tM*Fh2l#(nI!!VkcA z6KUFsF@SPiz$o(o6NC8#fG{N-*h9k5)t^H;E2I_we>OBR{@vb+e;Qr)|4VaX&9X<@ z7>x%QmQ4<@q%Qp2&(ey4VYDMySkBLM5+UNBNs)xA3tldlVGG_Qg)OeWjz*GrdKD6Q z1ae(?{Pb7=H1~$>xgg|IRoDydi}+H$t_03>O*{VIQ2=GdyKkrfLOZq!L&W9T^10Ib z0~}10KZO`3X6Z>%SV&P(Lqsy_ca1g+k9ifjA9xHXWvV$~W|DYLSQ3MNIE_3WJqN6|R=Ajl6Mu zFrqcyE!%h(=rxT;%ymHKs9n{Uz&pg%81N3U2SigaZOp3A)h@Q&RlIqX{;uT8M8e=l709a)MlYt*aMXIq}J@4s)&UR*zZNvsmC0Qyn_ z4C5LUU|AV_K&i{IF)*y35lpOLKZ-UCUj8Gv~1Uwc#uW-K7(Ul&tmUxT*4V1^mz%tK`%|ZqIRrvlVW1^5?C=QCi zJ&ibqc!eZY7?jiyYOpEzwbT=%)7pdvs$`bgQ#F#rux2tC2PbkXEu^W6AH#cmt{UA} zsCY=uD0wbFj6j%Co_K}qPA7nmG)FuN|V^ShxAiJ zjRo8xFEWBZg7l_4G#q7I?Yn83?qCNg1ZY~FZuL4-^f1r+& zt7LoNk9@~lz@T#1sgW4+1Q_oH9|R`>(|DFk>4k-XPdx0%k(3i(v-p6)sPcgf>=e=g z<-M-jC`@x59k!$x4v?}N-j&l(9DkV48OW zCR&~*Kq0ab9tK)$Ol(9}C4olpanNYFJ9OE_LgU@G;jdOoja8O<6~XTsGvBrK%e(%4 zL#b=Zg0DU+zoE*7Z!if1h4v4|LE)BeA`U90#!!utwAV&0CV8udI-+Eav8s+FIn{#( zBKSrM3yrciCrt~{NE?$GFB4(~X3;^?TAPJ@jvZ;CnQu|%(-fG54jw`?nV@(WieK)p?e;7CgdGUYBn=Uq|sWx%{>2 z0&FIhO#11g<$muA<52Gq?>^ZybwcO(rrzKVkKUfx8+eB;WKG}_7D^o$YXz@(S3(bG zc6G~Ei#XP`mgeFK^p-{2!=P~jjxOpY_vm^=c2ys%Er1d+l>&yyWcd&x zEpCt85Gaj}e387G&C~{bA`dZ?YC4(6o>;h)XhhH`bs{ccsT3syLwzD1p!f@|C}4P4 z$?f6r=X-?hIAzH;b$R)J(_sA7yJdk_j4cmTW!f*eOD z-;*`&q#!@x2AAORHf|}2&m7$J4fxEaq(4BjfP%M?c+8=_d7P?dGP4%$i6L+C*}}Ey z&XMxxrW!^|i6>jnRnO?1^=H|)RW>+RPb%HYY4S8%2BYO#(=*1i34fN0Rmd!Kpti6>n^QNCg{^`N#QstyJt8i<|+AN+BL{X%0s{8jJR{iC0HYT3KXRRCMQ z_VUM%-nLADPGt|dXvUewu<0wE2<&Y1H@^j%=_&JpLu4KYf_;v=NAW-?^@%=}x=#Kq zAIc8w#!;-wck-sXR8`T&e04rHE+y4WfW}gNDPmx%-sG_`RC~EjyLuWQ1H}=cB6v4! zhrOtcOP~F+WCF=Qg9<|{-iNN)Xt9uP6@GH7aG+Mce?{1aE6VLzbbpUU8>R}%i}|g? ztF0FWQvF}W@~ZbPekLf+Tk3Na8;tNvIsU?$Q4T{h%SPis)erzl*_Z-`s`jrXgDD-Q z3#1bvQanS|lSjMtBw2vtuZouMN$Fj2)Ie256A9v2yh2H4N;-HIo6l(~zJ4`g6IIqq+=@iDIpAxI`9-%U`qF|KnH`FcH#wf915y1bSrmr*1$hjj#g9pY8knC? z2Oc`#SLV@%lV4K}r9gG*QVeF%;O#<#Jj@^byXcMve+o$+RqN`LXktF!r)S}_Kqds| z(?lnkTr;!rnPWae23Sx!2mxkw{E-wc-!p2vg6Am>hQuG-?O zb8aX`XBnu*%4T|=I-OqBt98^bl$ar}s&i?;!#D0EN|Az5wgA}b zeLOU#aRx31hFS>^8^!hE9{IWO)!LS5*}roO&~@cg;g|V0zp<<$T6_TYuXWSn$V2J! z|EXX3T3DG~#U8U(Sd__f!LnM<0zlb_0+n)h)&z;C^PG8vg{03-#*m#=!Q*3C2a>on)OF zT^XRuAv*scma716zx@1z#~!T_GuiAT?jk0MLobUh{JFfcPu+L_4E zoK}Z2o&kj@gXA04;9kJGOapfmIXs!{ZOknC5f5&|WgRsv7|ttdBe_a{D;F}7xBU`N z(_Nl*NAV*gc&~U1MKzk!3*D)fF+5cmp$4)A+qjSGugr&x8S-T3SA)m&TmZErzn?N~ z8&Fs2t@#Y}>S{87fde+)wEoUhFrK~evGaFZ86EBCZko-3F*3N`n*(g3Ep-sEiF=QE z1^9k^_1MV2%F(}y+HbN4@Gv7;$zF+hSfuQ$X~v+tHG()$Yh6l!M$AodP#EUk z7-%BK<$%Uk>x5SJKZineXE-R9E_V6v6Pe0z`}^Ice{E?if93A~T`~XXuvqxjGFz(3 zA)cnZh>PF^=aNH{@II##p}u$oE%@}y|Ai)U49nb(PZJ~4vq>;njm%s`QQl)j{t{BW z${_a;25=kudwI16-+E0AMkS#dNKb9A;b=>PxC_(!x_`yh5jav$uIM0N#eN>B}vH9DTj zdypSrGwxrh4R48D@~aQtd$M$S?pokNR_16ZM>GMq@dMX^f08=Xyp|6y1lHWr*w(8&8s-Xpg;yONqXb zw~f=kg-P5Phr+-y_Mz<64zms9tgyMMr3TIaAG_%PZFCjt zieOi$4h265sUH?N_rvfeAHilCE<#MMC67&rGI@cUilzG2M8L{@x&Z@dgRMk$&BoV`m_aKv_RNjU_kq&S)^oi=W3VO!t~o(;J)gWCb* z$B=zZdMg+`di3b0?)&Gvq3e=%(L7{Y!72XFAa|;*7!K6q(g~_gUg1mdelkxqX0nF0 zwx4j5YPVG`o;+&Y{^r#WPnw*2doyE; z0gDW4C!Plswb45eBC0;JB|;$0_6`W3t&h;yM9lXvuvILq{ZthvH68(R`3jeSn&37(O4O8I zBT6d`g>UJ>RC;HRAjKyP%d|5>USfmHwQ5bCWW&N{oW;xZ@Sjut z6zWP_)02SN#jBYvx3Okv5B-L^M@}pC$Zl#p;O_0t*=bt)pkdGH*M90A9oDofuX%?0 zFqi~#9AC&Iydy(o7d-^r&)L_#1&ZTUH+M3)dvL6GI_RR>N^b&YTAncX2Ky8lXfy#g z)4_?0xYl(A`fp2$Tkg_UeJ_^20qBWVmjm?(X;5*o$zLLl&igEmj1T3KFu!jBf%3 zOdKGMO$^9j5~G~;5J%zRlaLosl&JBa#HL7%`ye$epr)`2efXMgxd&N8#xgW}gxZMczmTgi{o8)RE`xIO}Oiv;ows5!C^Gr>4X zX3Ip7joD1QpiZHi(F=G;%??YQrv3@e{=-;)Z1`o2V*xNv9^_tNnmoe8!0~)Q5zPJM zW$mz&s$p|^!02X-B&j!c)=)!;==EY9YNo6ux2JC57CBYA*X+t}@+4Q*9Li88%D2@Q zQ%eW=)Mi8xqVYdq<4_&h9FsV;_O}=$sI|7nC?^%6a6+%U6`JG}2FHYd)v)9|zhYPY zdxse=_bV&7N^wjw%JS6H}q47XHHg6$RF4{`z^ckh*~9k8&z~*!+D!FxKy{O+cm`A_FEIwx z9C=Y)0O|z3W(e>lE2BXkRyVOL@Rs^SJqSFgE@2ZOhx04*de<5M-!`@@D}ZJGLwyb- z)ns6OI@t;E%1bfXiA_`q>hS zSX3swu*6%e5d8lo{CcsKxn=hry>TfQw#cKx6@Wns$e^gw*WjZ_1@93@Q|;YKEk&wI z2U159$--J{W4@rOuq7YJISkFcVf2%+>bTs~YNW)}55*HH(vxVQ#b8f4SniaOs);}h z-ee~xYMaN^Vcf4-;My6s)dJ=pp}}RKchcC(QQIAoAVk&zrRmiBgd*8CeKoR zs$Dj8@uVp+!G@A_$#xzZ`*h2v3x7zjeBKPeO~{4 zr=HU-&h87Z=rg0EAFO`ZoLBp;4(~s)%Apf)WvITL&GhjUN;jM5dr30I>Bg(x7)ts= zb*X5A*e^lCX+}F2=t4k{y6ot*Cvmc5wnh$)Xt2GY{CunR(mFU$arcPQu{pfNm12!DMeDKZqpE| za2tAHQbnP9JSIsB@gycDzVlT%&&|A!@*aPiC9(`FkYAbUE5pcN^qDf2SA%c`etZ17 z`Yx~c9>hRnNDoYWZ1pV`LPU1K#wYImjE#^iW>J(#6?Vj+NV1>-pLk(&GzzCQpln$v z-O3j7A@!w;%`w6IYJK;v2dYng=T}$)#R{nYCfibjh$%4#l~>H!Nf58iy2HK3OKqqaUfRw5ubo${&759Qib6}$dfEIpwPsM?~^Bv zDt^jr29R^-(xm#);g7J7a>p8v`Zw=d|JdiN90%4Lz8DKYjnHSP3&Gqa_)gsdoJ<-Y z_=3%u0`fTB_y9OZ?S}>ia4)@q4S`|*G8g_+xvYN^%QFI6L5g>cDv+Dh!KwuM40WVR zf`7l7VjKkKopOL~8WSNB6yg=F~lk0xLcDVL$tITRisH2ie;LAfH-*)g>^8{IK?CAqy}+k z%D1dfi+?b;a6Vu7H?Fzs%N>Ky)7RVQcxiJq80p|W6$Q1PT*O$QCzBSLlm+my34kII zJRnDe65wJkT5taAT=>l#*2`M~So(W)gSrZINw!uO0Doi%?}NUO9gJJS_>mpOR%28g zPwp_z<8IRG4clN1$@6{eAihz(=$RTzCnZ$%O$a55wcDU@DONYAI$UW<&XR@rsy@0a zB?3%q5+yX2Dmy}!-RmzNGQX}2mZ#8GD1zS~mKOY_vo{~bM5FWsY&^_hFJhFiyp`}N z5h)#nqC~88FfK0f;=@3RSm|jLg5$14Cv^ngZhWRTl_vjG8O>&DPkqt2YYxv%n8DXh zq4b3*KwlnU3~)EAkpp?j*g+lw2J6q11&rh~27}t27#o1xBhS+ac1(2XZtksZ&6UI!vttY{X}>E5~sL1Nqo^os}st%|i9TWnSbR zxf7p-vHfVkry;R2?f66!ZCC0t-wS|Cy1dncz{ElGnqzitG76|4D78|H8AtS)9w(z>(a@ zhQP&~!glb>#G&)shA-oHvbxa!o!W|j1It$ckErf!4l;>2y@3fN=m*?JZ$<$dkY*x- zsK=vI{YAym}vpg&W-@FG4Auy+6B50!-13{D#iuazs zHDgxbKZd_&4c8p1g=a!Vuxr^Xpyy)}VCt*N3PHBQ#K+VlFwvN@4GO`~XW?UmyhaCg zl*HvRj^=w=Q=P))Y7<}sc~A8RM)EnmfsN&DH4xNi^j;zax6;-~7y5su64U + League of Legends + + diff --git a/docs/SETUP.md b/docs/SETUP.md index 7c669a2..e0a7b18 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -1,5 +1,74 @@ # Setup and portability +## NixOS package + +Use `darling-lol`, `darling-lol --installer`, or `darling-lol-shell` rather than +invoking the raw FHS environment. The launcher uses `unshare --map-auto` before +bubblewrap, so the user needs subordinate UID/GID ranges (`/etc/subuid` and +`/etc/subgid`, or NixOS `users.users..autoSubUidGidRange = true`). +Namespace root maps to a subordinate host ID, not host root. The FHS entrypoint +restores the real Linux user's IDs while retaining namespace-only capabilities; +the Darling shim temporarily switches effective IDs for mounts. No `sudo`, +root-owned binary in a writable workspace, or host setuid installation is needed. + +The launcher refreshes managed scripts with executable bits and owner-write access, +rejecting symlinks in that managed tree. It preserves `build/`, `usr-local/`, and +the guest prefix. Edit the source checkout, not copied workspace tools. + +First setup: run `darling-lol --installer` in an interactive terminal, choose the +build when prompted, then choose the Riot region. The desktop entry uses +`Terminal=true` and delegates terminal selection to the desktop environment. +A launch with EOF stdin fails with a command to run in a terminal; it does not +try a hard-coded list of terminal emulators or silently abandon a prompt. + +Update: after manually exiting League and both Riot clients, shut down Darling +in the ORIGINAL `darling-lol-shell` terminal with `darling shutdown`, then exit +that shell. Upgrade the Nix package and run `darling-lol --update` in a terminal. +This rebuilds incrementally, installs into the user-owned `/usr/local` bind, +and installs paired per-prefix overlays without sudo. For a first-time setup, +the same rootless update path performs the base build/install. Native/manual +(non-Nix) instructions below still use their normal privileged base install. + +Readiness JSON under `build/nix-ready/` is keyed by canonical prefix and checks +the configured guest and resolved host guest home, UID/GID, package path, +maintained patch/tool identities, executable modes, base SDK/runtime metadata +and required-overlay inventory. Stamping reuses required-artifact validation. +Changing `RIOT_GUEST_HOME` invalidates readiness. Explicit `DPREFIX` and +`RIOT_GUEST_HOME` environment overrides take precedence over saved configuration; +clear stale values before selecting a different installation. A separate base +manifest avoids rebuilding when only a new prefix needs overlays. +Updates invalidate readiness before replacing the base; failed/interrupted setup +cannot leave the previous prefix stamp authoritative. Existing app bundles never +bypass these checks. Normal ready launches only check state, not rebuild. + +Operational limits: each workspace supports one foreground namespace session. +The launcher holds a lock while its child runs, even if bubblewrap closes file +descriptors. It refuses a live `.init.pid` rather than reconnecting across new +user namespaces or killing anything. A stale/reused live PID can therefore block +a launch conservatively; inspect it before taking manual action. Never remove a +marker just to force another launch. Manual stop/reconnect and game lifecycle +acceptance have not been exercised with this repaired package. Keep the original +shell open if you need to stop a session. Maintenance requires that prefix idle; +rootless CMake's shutdown hook checks only that prefix, never enumerates and +shuts down every user's launchd. The inherited scoped stop helper remains used +by overlay installers; it is fixture-tested, not live-game tested here. + +Mutable Linux ELF outputs can retain Nix-store interpreter/library references. +A native compiler probe executed successfully with its store-path interpreter; +there is no evidence that all such outputs require `patchelf`. The update path +creates indirect closure roots under `build/nix-gcroots/` before compiling and +retains old package roots for incremental outputs. Keep those roots while using +the workspace; remove them only when discarding/rebuilding all dependent mutable +outputs. This trades disk retention for GC safety, not a claim of full runtime +relocatability. A full Darling build/upgrade and GC acceptance test remains open. + +The FFmpeg dependency is explicitly 8.x: generated Darling AV wrappers reference +`libavformat.so.62`, `libavcodec.so.62`, and `libavutil.so.60`; FFmpeg 9 cannot +replace those ABI versions. + +Run the package regression suite with `python3 -m unittest discover -s tests -v`. + + The patch manifest pins Darling to `60ba801decee7a00782f74f6be4c8ffb013f79ff` and records every changed submodule's revision. `scripts/prepare.py` checks each revision before applying patches and never resets an existing checkout to make a patch apply. Re-running it accepts an already-applied patch. Git LFS must be available before fetching the upstream submodules. The pinned `darling-swift` submodule's `.lfsconfig` points at the retired `git-lfs.darlinghq.org`, which no longer accepts connections; `prepare.py` redirects that submodule's LFS requests to GitHub, which still serves the objects. Without that override a fresh `git submodule update` blocks with no output right after `src/external/sqlite` is checked out. The local development checkout had removed prebuilt Swift files from its working directory; that deletion is deliberately not exported. A fresh upstream checkout keeps its original Swift assets and licensing. Do not install Git LFS pointer text as a library. @@ -12,22 +81,7 @@ python3 scripts/configure.py --guest-user my-linux-user python3 scripts/configure.py --sdk /usr/local/libexec/darling ``` -The runtime installation wrappers resolve `DPREFIX` first, otherwise the saved -`configure.py --prefix` setting. The configured guest account supplies the -default guest home. `RIOT_GUEST_HOME`, when set, is an absolute **host** path -inside the selected prefix, not a guest `/Users/...` path. Installers, runtime -validation and the Riot launcher's guest library paths use that selection. -Paths containing spaces are supported by isolated regression fixtures. Explicit -environment overrides win over saved configuration; clear stale values before -selecting a different installation. - -A complete real installation using a custom prefix has not been verified. -Archived tools may still assume the default layout. Runtime validation fails on -missing expected binaries, unreadable/missing directories or checker errors; -an empty input set is not a successful validation. - -Runtime installation requires the selected runtime to stop before overlay -replacement. Failed shutdown blocks replacement. +The normal wrappers currently support the default `~/.darling` prefix and `/usr/local` base installation. The configurator exposes a custom prefix for developers, but several archived tools assume the default layout. Custom prefixes/install locations have not been tested end to end. The build uses Unix Makefiles because the incremental runtime helpers read CMake `flags.make` and `link.txt`. Ninja is not a drop-in replacement for those helpers. @@ -39,7 +93,7 @@ Linux 5.14 or newer is required for `MADV_POPULATE_WRITE`, used when Darwin's al The runtime installer requires native `pkg-config` entries for `x11`, `x11-xcb`, `xcb-shm` and `xext`, plus the base Darling SDK. -Run configuration, builds and the per-user runtime installation as your ordinary account. Use sudo only for the base CMake installation and the incremental server installer. Running the whole build or per-user install under sudo can create root-owned files and configure the wrong guest home. `scripts/install-runtime.sh` initializes the selected user prefix and creates its `private/tmp` directory with mode `1777`, so the installer can write guest temporary files. It then shuts down Darling before replacing the paired runtime: first exit the game and both clients manually. +Run configuration, builds and the per-user runtime installation as your ordinary account. Use sudo only for the base CMake installation and the incremental server installer. Running the whole build or per-user install under sudo can create root-owned files and configure the wrong guest home. `scripts/install-runtime.sh` initializes the default user prefix and creates `~/.darling/private/tmp` with mode `1777`, so the installer can write guest temporary files. It then shuts down Darling before replacing the paired runtime: first exit the game and both clients manually. The normal launcher starts neither an Electron inspector nor client API/UI helpers. It clears inherited `DYLD_INSERT_LIBRARIES`, retains Darling runtime library/framework overrides, supplies an absolute log directory to the launcher, and selects the League product. The log-directory/Chromium argument shim, empty-autofill hook and optional native VPX bridge are no longer injected or built by runtime installation. Existing copies on disk are inactive and may remain. Child-process log-directory rewriting and Chromium feature overrides previously supplied by the shim are removed; client startup without those workarounds needs manual validation. The first-time installer uses `--skip-to-install` and omits the installed client's `--app-root`, avoiding an immediate exit or inaccessible intro screen. The project ships no inspector hooks, client API helpers, input automation or client binary patches. Keep account data, logs and captures private. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 954d6e7..6c24424 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -1,5 +1,23 @@ # Troubleshooting +## NixOS launcher fails before Riot opens + +A message saying Darling must be setuid root is not fixed by running `sudo` +inside `buildFHSEnv`: bubblewrap enables `no_new_privs`, so setuid elevation is +ignored there. Use the package's rootless launcher with subordinate UID/GID +mapping enabled. Never make a mutable workspace executable host-setuid-root. + +If a newly installed client aborts with `Cannot load libavformat.so.62 (ELF)`, +check the FHS runtime dependencies. The generated Darling wrappers use FFmpeg 8 +SONAMEs, so an unversioned nixpkgs `ffmpeg` dependency can break them when its +major changes. The package pins `ffmpeg_8`; do not create fake ABI symlinks. + +Older packages used `cp -n` and never refreshed already-copied tools. The current +launcher replaces packaged source files while retaining compiled/runtime data. +An incomplete prefix can be backed up by renaming it with Darling stopped; +never delete a populated Riot/game prefix just to repair the launcher. + + Use the [setup instructions](SETUP.md) from the repository root as your ordinary user. Exit the game and both clients manually before a full installation or Darling restart. Incremental server/memory installers can replace files without interrupting processes, but their changes need a later full restart to activate. ## Memory installer: permission denied @@ -56,18 +74,6 @@ The installer fetcher no longer treats a missing archive `Info.plist` or the inf There is also a separate known compatibility gap: native LaunchAgent watch registration can return HTTP 409 after the League install path is accepted, leaving the download idle. A historical inspector hook handles this and interrupted `pending_verify` states in 28 mock cases, but that hook is disabled in normal launches and excluded from the manual workflow. There is no verified runtime-only replacement for that handoff in this snapshot. Do not assume the native permission and launcher fixes resolve every idle Install case, or enable debugger/API automation to work around it. -## `Unable to read system root certificates` or `Error -25263` - -Riot logs `CertBundle: Unable to read system root certificates` and Chromium logs `[known_roots_mac.cc(60)] Unable to determine trusted roots; assuming all roots are trusted! Error -25263` when the prefix has no system trust domain. Security reads that domain from `/System/Library/Keychains/SystemRootCertificates.keychain` and `SystemTrustSettings.plist`, which Darling does not ship, so `SecTrustSettingsCopyCertificates` returns `errSecNoTrustSettings`. - -`tools/ensure-darling-root-certificates.py` builds both from the host CA bundle (`/etc/ssl/cert.pem` or whichever bundle Python's `ssl` reports; `--bundle` overrides it). The launcher and `scripts/install-runtime.sh` run it. Each self-signed root gets an unrestricted trust entry. The keychain is built with the guest `security` tool in a single `darling shell`. A stamp in the same directory records the bundle's digest, so later launches skip it without starting Darling until the host bundle changes. If Darling was already running and the guest could not write the directory, the script installs the files on the host and says they apply after `darling shutdown`. A failure prints `Darling system root certificates were not installed: …` and the launch continues. To rebuild by hand: - -```sh -python3 build/runtime/tools/ensure-darling-root-certificates.py --force -``` - -`python3 -m unittest tests.test_root_certificates` compares the generated SHA-1 keys, issuer names and serial numbers with OpenSSL, and checks that an unchanged bundle is skipped. It uses stub `darling` and `security` commands. Reading the keychain and the system trust domain inside a real guest (`security find-certificate -a /System/Library/Keychains/SystemRootCertificates.keychain`, `security dump-trust-settings -s`) is not yet confirmed. - ## Riot closes after a network lookup is cancelled Two local crashes were traced to `CFNetwork::_HostCancel` calling `CFRetain(NULL)`. @@ -246,18 +252,6 @@ A non-zero error or `FAILED` means the frames cannot reach the window. Before 20 An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Confirmation on the reporter's NVIDIA system is still needed. -### What Metal reports about the GPU - -`MTLDevice.name` is the selected Vulkan device's name in macOS style: the trailing driver note in brackets and the `(R)`/`(TM)` marks are removed (`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`, `Intel(R) UHD Graphics 630 (CML GT2)` becomes `Intel UHD Graphics 630`, `llvmpipe (LLVM …)` becomes `llvmpipe`). NVIDIA names are unchanged. The name is never replaced with another vendor's GPU. Before 2026-10-17 it was always `Darling Indium Device`. - -`recommendedMaxWorkingSetSize` is the size of the device-local Vulkan heaps (the VRAM) on a discrete GPU. On integrated and software GPUs it is the size of all heaps, because their device-local heap can be just the firmware carve-out while they also use system RAM; macOS likewise reports a large share of system RAM for unified-memory GPUs. It falls back to the old 1 GiB when Vulkan reports no usable heap. `hasUnifiedMemory` stays `YES` by default: with `NO`, apps switch to managed and private resource paths (`synchronizeResource:`, private buffers created with data) that are not implemented here. Set these in the game's environment to override: - -- `DARLING_METAL_GPU_NAME=`: report this name. -- `DARLING_METAL_VRAM_MB=`: report this working-set size in MiB. Other values are ignored. -- `DARLING_METAL_HOST_UNIFIED_MEMORY=1`: report `hasUnifiedMemory` from the GPU type (`YES` for integrated and software GPUs, `NO` for discrete ones). This is experimental; on a discrete GPU it can break rendering. - -Each process reads these once. - ### Host-side checks These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6197b4b --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1791956154, + "narHash": "sha256-jBYGPi4udQ9uo4KQp9m2J0546nJVQitG15gvZSaVVAs=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "55bfb4d7437bd4e09ab7cba58a43bd68eb310044", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4fe1a74 --- /dev/null +++ b/flake.nix @@ -0,0 +1,328 @@ +{ + description = "Darling League of Legends - experimental macOS LoL runner on Linux via Darling"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + + # Python environment with dependencies required by tooling + pythonEnv = pkgs.python3.withPackages ( + ps: with ps; [ + requests + urllib3 + websocket-client + pillow + ] + ); + + darlingEntry = pkgs.writeShellScript "darling-lol-entry" (builtins.readFile ./scripts/fhs-entry.sh); + darlingRunner = pkgs.runCommand "darling-lol-rootless-runner" { } '' + mkdir -p $out/bin + cp ${./scripts/darling-rootless.sh} $out/bin/darling + chmod +x $out/bin/darling + patchShebangs $out/bin/darling + ''; + + # Common runtime libraries needed by Darling, Vulkan, X11, Audio, and graphics + buildDeps = with pkgs; [ + # Build tools & compilers + cmake + gnumake + ninja + pkg-config + clang + lld + llvm + llvm.dev + llvmPackages.clang-unwrapped + bison + flex + git + git-lfs + nix + curl + unzip + file + patchelf + util-linux + + # System & core libraries + glibc + glibc.dev + glibc.static + zlib + zlib.dev + libffi + libffi.dev + libbsd + libbsd.dev + libmd + libmd.dev + libcap + libelf + fuse + systemdLibs # libudev + dbus + libxml2 + openssl + libedit + + # Graphics & Display + libGL + libGLU + mesa + vulkan-headers + vulkan-loader + vulkan-tools + cairo + freetype + fontconfig + libpng + libpng.dev + libjpeg + libjpeg.dev + libtiff + giflib + # Darling's generated AV wrappers bind FFmpeg 8 SONAMEs (e.g. .62). + # Do not let nixpkgs' default FFmpeg major silently break installed builds. + ffmpeg_8 + + # X11 & input + xorgproto + libx11 + libxext + libxrandr + libxcursor + libxinerama + libxcomposite + libxdamage + libxfixes + libxi + libxrender + libxtst + libxcb + xcbutil + xcbutilwm + xcbutilimage + xcbutilkeysyms + xcbutilrenderutil + libxkbfile + libxkbcommon + + # Audio + pulseaudio + libpulseaudio + alsa-lib + + # Python + pythonEnv + ]; + + # FHS environment for building and running Darling and League of Legends. + # Darling requires root/multitasking mount & namespace semantics, /usr/local layout, + # and dynamically compiled overlays. An FHS environment provides the standard standard + # filesystem hierarchies (/usr/include, /usr/lib, /usr/bin) and native dynamic linker. + darlingLolFhs = pkgs.buildFHSEnv { + name = "darling-lol-fhs"; + targetPkgs = pkgs: buildDeps; + multiPkgs = pkgs: [ pkgs.glibc ]; + + extraOutputsToInstall = [ "dev" "out" ]; + + extraBwrapArgs = [ + # The outer user namespace maps subordinate IDs before no_new_privs. + # These capabilities apply only inside that namespace, not on the host. + "--cap-add" "ALL" + "--bind-try" (''$'' + ''{DARLING_WORKSPACE}/usr-local'') "/usr-local" + ]; + + extraBuildCommands = '' + mkdir -p bin usr/bin + rm -f bin/clang bin/clang++ usr/bin/clang usr/bin/clang++ + ln -sfn /usr-local $out/usr/local + cat << 'EOF' > usr/bin/clang +#!/usr/bin/env bash +is_darwin=0 +for a in "$@"; do + if [[ "$a" == *darwin* ]] || [[ "$a" == *apple* ]] || [[ "$a" == *macosx* ]]; then + is_darwin=1 + break + fi +done +if [[ "$is_darwin" -eq 1 ]]; then + exec ${pkgs.llvmPackages.clang-unwrapped}/bin/clang "$@" +else + exec ${pkgs.clang}/bin/clang -idirafter /usr/include "$@" +fi +EOF + chmod +x usr/bin/clang + + cat << 'EOF' > usr/bin/clang++ +#!/usr/bin/env bash +is_darwin=0 +for a in "$@"; do + if [[ "$a" == *darwin* ]] || [[ "$a" == *apple* ]] || [[ "$a" == *macosx* ]]; then + is_darwin=1 + break + fi +done +if [[ "$is_darwin" -eq 1 ]]; then + exec ${pkgs.llvmPackages.clang-unwrapped}/bin/clang++ "$@" +else + exec ${pkgs.clang}/bin/clang++ -idirafter /usr/include "$@" +fi +EOF + chmod +x usr/bin/clang++ + cp -f usr/bin/clang bin/clang + cp -f usr/bin/clang++ bin/clang++ + ''; + + profile = '' + export DPREFIX="''${DPREFIX:-$HOME/.darling}" + export LC_ALL=C.UTF-8 + export LANG=C.UTF-8 + export DARLING_RUNNER_DIR=${darlingRunner}/bin + export PATH="$DARLING_RUNNER_DIR:/usr/local/bin:''${PATH:-}" + export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/pkgconfig:/usr/lib64/pkgconfig:/usr/share/pkgconfig:''${PKG_CONFIG_PATH:-}" + export CMAKE_PREFIX_PATH="/usr/local:/usr:/usr/lib/cmake/llvm:''${CMAKE_PREFIX_PATH:-}" + unset NIX_LDFLAGS NIX_CFLAGS_COMPILE NIX_HARDENING_ENABLE CPATH LIBRARY_PATH + ''; + + runScript = pkgs.writeShellScript "darling-lol-run" '' + if [ $# -eq 0 ]; then + exec ${darlingEntry} bash + else + exec ${darlingEntry} "$@" + fi + ''; + }; + + # Wrapper script package to run scripts directly + darlingLol = pkgs.stdenv.mkDerivation { + pname = "darling-lol"; + version = "0.1.0"; + src = pkgs.lib.cleanSourceWith { + src = ./.; + filter = path: type: + let name = baseNameOf path; in + !(builtins.elem name [ ".git" "build" "__pycache__" "usr-local" "darling-install" ]) + && !(pkgs.lib.hasPrefix "result" name); + }; + + nativeBuildInputs = [ + pkgs.makeWrapper + ]; + + installPhase = '' + runHook preInstall + + mkdir -p $out/bin $out/share/darling-lol $out/share/icons/hicolor/scalable/apps $out/share/icons/hicolor/128x128/apps $out/share/pixmaps $out/share/applications + cp -r * $out/share/darling-lol/ + cp darling-lol.svg $out/share/icons/hicolor/scalable/apps/darling-lol.svg + cp darling-lol.png $out/share/icons/hicolor/128x128/apps/darling-lol.png + cp darling-lol.png $out/share/pixmaps/darling-lol.png + cp darling-lol.svg $out/share/pixmaps/darling-lol.svg + + # Install configured launcher + substitute ./darling-lol-launcher.sh $out/bin/darling-lol \ + --replace-fail "@SHARE_DIR@" "$out/share/darling-lol" \ + --replace-fail "@FHS_BIN@" "${darlingLolFhs}/bin/darling-lol-fhs" \ + --replace-fail "@UNSHARE_BIN@" "${pkgs.util-linux}/bin/unshare" + chmod +x $out/bin/darling-lol + wrapProgram $out/bin/darling-lol --prefix PATH : ${pkgs.lib.makeBinPath [ pythonEnv pkgs.coreutils pkgs.util-linux pkgs.gawk ]} + + makeWrapper $out/bin/darling-lol $out/bin/darling-lol-installer \ + --add-flags "--installer" + + makeWrapper $out/bin/darling-lol $out/bin/darling-lol-shell \ + --add-flags "--shell" + + makeWrapper $out/bin/darling-lol $out/bin/darling-lol-build \ + --add-flags "./scripts/build.sh" + + cat << 'DESKTOP_EOF' > $out/share/applications/darling-lol.desktop +[Desktop Entry] +Categories=Game; +Comment=Play League of Legends macOS client on Linux via Darling +Exec=darling-lol +GenericName=MOBA Game +Icon=darling-lol +Keywords=League;LoL;Riot;Darling; +Name=League of Legends +StartupWMClass=RiotClientServices +Terminal=true +Type=Application +Version=1.5 +DESKTOP_EOF + + runHook postInstall + ''; + + meta = with pkgs.lib; { + description = "Tooling and patches to run macOS League of Legends on Linux with Darling"; + homepage = "https://github.com/lucannez64/darling-lol"; + license = licenses.gpl3Only; + platforms = [ "x86_64-linux" ]; + }; + }; + in + { + checks.${system}.packaging = pkgs.runCommand "darling-lol-packaging-tests" { + nativeBuildInputs = [ pythonEnv pkgs.bash pkgs.git pkgs.coreutils pkgs.util-linux pkgs.gawk ]; + } '' + cp -R ${darlingLol}/share/darling-lol source + chmod -R u+w source + cd source + export HOME="$TMPDIR/home" PYTHONDONTWRITEBYTECODE=1 + mkdir -p "$HOME" + # Tests use private stub binaries/repositories, never Darling itself. + python3 -m unittest discover -s tests -v + python3 scripts/validate.py + grep -q '^Terminal=true$' ${darlingLol}/share/applications/darling-lol.desktop + touch "$out" + ''; + + packages.${system} = { + darling-lol = darlingLol; + darling-lol-fhs = darlingLolFhs; + default = darlingLol; + }; + + apps.${system} = { + default = { + type = "app"; + program = "${darlingLol}/bin/darling-lol"; + meta.description = "Launch League of Legends via Darling"; + }; + shell = { + type = "app"; + program = "${darlingLol}/bin/darling-lol-shell"; + meta.description = "Open an interactive shell inside the Darling FHS environment"; + }; + installer = { + type = "app"; + program = "${darlingLol}/bin/darling-lol-installer"; + meta.description = "Run the Darling and League of Legends rootless installer"; + }; + build = { + type = "app"; + program = "${darlingLol}/bin/darling-lol-build"; + meta.description = "Build the Darling runtime tree inside the FHS sandbox"; + }; + }; + + devShells.${system}.default = pkgs.mkShell { + packages = buildDeps ++ [ darlingLolFhs ]; + shellHook = '' + echo "Darling LoL build & development environment loaded." + echo "Tip: Run 'darling-lol-fhs' to enter the FHS build sandbox if standard paths (/usr/include, /usr/lib) are needed." + ''; + }; + }; +} diff --git a/patches/cctools-port.patch b/patches/cctools-port.patch new file mode 100644 index 0000000..eacef6a --- /dev/null +++ b/patches/cctools-port.patch @@ -0,0 +1,38 @@ +diff --git a/cctools/ld64/src/3rd/strlcpy.h b/cctools/ld64/src/3rd/strlcpy.h +--- a/cctools/ld64/src/3rd/strlcpy.h ++++ b/cctools/ld64/src/3rd/strlcpy.h +@@ -7,8 +7,15 @@ + extern "C" { + #endif + #include ++#include + ++#if defined(__GLIBC__) ++# if !__GLIBC_PREREQ(2, 38) + size_t strlcpy(char *dst, const char *src, size_t siz); ++# endif ++#else ++size_t strlcpy(char *dst, const char *src, size_t siz); ++#endif + + #ifdef __cplusplus + } +diff --git a/cctools/ld64/src/3rd/strlcat.h b/cctools/ld64/src/3rd/strlcat.h +--- a/cctools/ld64/src/3rd/strlcat.h ++++ b/cctools/ld64/src/3rd/strlcat.h +@@ -7,8 +7,15 @@ + extern "C" { + #endif + #include ++#include + ++#if defined(__GLIBC__) ++# if !__GLIBC_PREREQ(2, 38) + size_t strlcat(char *dst, const char *src, size_t siz); ++# endif ++#else ++size_t strlcat(char *dst, const char *src, size_t siz); ++#endif + + #ifdef __cplusplus + } diff --git a/patches/darling.patch b/patches/darling.patch index 00f4482..b399000 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -10588,13 +10588,19 @@ diff --git a/tools/shutdown-user.sh b/tools/shutdown-user.sh index 072a6e2..c804100 100755 --- a/tools/shutdown-user.sh +++ b/tools/shutdown-user.sh -@@ -1,26 +1,34 @@ +@@ -1,26 +1,40 @@ #!/bin/sh -if [ "$(whoami)" != "root" ] -then - echo "$0 must be run as root, invoking sudo" - exec sudo su -c "$0" "$@" ++# Rootless installs own only this workspace/prefix, never all launchd users. ++if [ -n "${DARLING_WORKSPACE:-}" ]; then ++ : "${DPREFIX:?rootless install requires an explicit prefix}" ++ exec python3 "$DARLING_WORKSPACE/scripts/nix-idle.py" ++fi ++ +if [ "$(whoami)" != "root" ]; then + echo "$0 must be run as root, invoking sudo" + exec sudo sh "$0" "$@" diff --git a/scripts/build.sh b/scripts/build.sh index 6113553..6bb9a13 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -20,4 +20,8 @@ cmake -S build/darling-src -B build/darling-src/build -G 'Unix Makefiles' \ -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' +if [ -n "${DARLING_WORKSPACE:-}" ]; then + echo 'Build complete. Install the base runtime with: cmake --install build/darling-src/build' +else + echo 'Build complete. Install the base runtime with: sudo cmake --install build/darling-src/build' +fi diff --git a/scripts/darling-rootless.sh b/scripts/darling-rootless.sh new file mode 100644 index 0000000..6414a70 --- /dev/null +++ b/scripts/darling-rootless.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${DARLING_HOST_UID:?missing Darling user namespace}" +: "${DARLING_WORKSPACE:?missing Darling workspace}" +if [ "${DARLING_SETUP:-0}" != 1 ] && [ "${1:-}" != shutdown ]; then + python3 "$DARLING_WORKSPACE/scripts/nix-state.py" check || { + echo 'Rootless runtime readiness missing; run darling-lol --installer in a terminal.' >&2 + exit 1 + } +fi +# This is namespace root (a subordinate host UID), not host root. Keep real +# UID/GID intact so Darling initializes the correct guest account and ownership. +exec setpriv --euid 0 --egid 0 --keep-groups \ + "$DARLING_WORKSPACE/usr-local/bin/darling" "$@" diff --git a/scripts/fhs-entry.sh b/scripts/fhs-entry.sh new file mode 100644 index 0000000..c6950ba --- /dev/null +++ b/scripts/fhs-entry.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${DARLING_HOST_UID:?start this environment with darling-lol}" +: "${DARLING_HOST_GID:?start this environment with darling-lol}" +# Carry only namespace-scoped capabilities into the ordinary-user process. +# Darling's shim temporarily switches effective IDs; real IDs stay the user's. +exec setpriv --ruid "$DARLING_HOST_UID" --euid "$DARLING_HOST_UID" \ + --rgid "$DARLING_HOST_GID" --egid "$DARLING_HOST_GID" --keep-groups \ + --inh-caps +all --ambient-caps +all "$@" diff --git a/scripts/install-runtime.sh b/scripts/install-runtime.sh index 6d3c345..677b2ab 100755 --- a/scripts/install-runtime.sh +++ b/scripts/install-runtime.sh @@ -3,6 +3,13 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +if [ -n "${DARLING_WORKSPACE:-}" ]; then + python3 scripts/nix-idle.py + python3 scripts/nix-state.py check-base || { echo 'Base runtime readiness missing; run darling-lol --update.' >&2; exit 1; } + python3 scripts/nix-state.py invalidate + # Prefix initialization is a setup-only exception to the runner launch gate. + export DARLING_SETUP=1 +fi python3 scripts/configure.py . build/runtime/tools/runtime-paths.sh if [ -d build/venv/bin ]; then PATH="$repo/build/venv/bin:$PATH"; export PATH; fi @@ -16,7 +23,7 @@ prefix=$prefix_dir mkdir -p "$prefix/private/tmp" chmod 1777 "$prefix/private/tmp" # Build the system root store now that the prefix exists; Darling is stopped next anyway. -python3 build/runtime/tools/ensure-darling-root-certificates.py || +python3 build/runtime/tools/ensure-darling-root-certificates.py || \ echo 'Darling system root store not installed; the launcher retries it' >&2 python3 build/runtime/tools/stop-darling-runtime.py sh build/runtime/tools/install-riot-presentation-runtime.sh @@ -29,3 +36,9 @@ fi python3 build/runtime/tools/install-darling-system-profiler.py # Every guest process loads these overlays; refuse a set the compiler broke (check-darwin-tlv-calls.py). python3 build/runtime/tools/validate-riot-runtime.py +# Mark this canonical prefix and configured guest ready only after validation. +if [ -n "${DARLING_WORKSPACE:-}" ]; then + # Overlay installation can also refresh darlingserver; record the final base. + python3 scripts/nix-state.py stamp-base + python3 scripts/nix-state.py stamp +fi diff --git a/scripts/installer.sh b/scripts/installer.sh index 62c7ed6..48f607c 100755 --- a/scripts/installer.sh +++ b/scripts/installer.sh @@ -2,10 +2,13 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +if [ -n "${DARLING_WORKSPACE:-}" ] && ! python3 scripts/nix-state.py check; then + echo 'Rootless runtime readiness is missing or incompatible; run darling-lol --installer in a terminal.' >&2 + exit 1 +fi +export PATH="${DARLING_RUNNER_DIR:+$DARLING_RUNNER_DIR:}$repo/usr-local/bin:/usr/local/bin:$PATH" region=${1:-euw} -# Riot publishes LAN, LAS and OCE installers under their platform codes. -case "$region" in lan) region=la1;; las) region=la2;; oce) region=oc1;; esac -case "$region" in euw|eune|na|br|kr|jp|la1|la2|oc1|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac +case "$region" in euw|eune|na|br|kr|jp|lan|las|oce|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac python3 scripts/configure.py >/dev/null bash build/runtime/tools/fetch-installer.sh "$region" build/installer unzip -q -o "build/installer/live.$region.zip" -d build/installer diff --git a/scripts/nix-copy.py b/scripts/nix-copy.py new file mode 100644 index 0000000..17874cb --- /dev/null +++ b/scripts/nix-copy.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Refresh managed package files without following mutable destination symlinks.""" +import os +from pathlib import Path +import shutil +import stat +import sys +import tempfile + +source, destination = map(Path, sys.argv[1:]) +items = 'docs patches research scripts tests tools upstream LICENSE NOTICE.md README.md requirements.txt'.split() +# Preflight the entire managed tree before writing anything. Never chmod or copy +# through a link planted by a previous installation (including directory links). +for root in [destination, *destination.parents]: + if root.is_symlink(): + raise SystemExit(f'Refusing symlink workspace ancestor: {root}') +for item in items: + for base in [source / item, destination / item]: + for path in [base, *base.rglob('*')] if base.is_dir() else [base]: + if path.is_symlink(): + raise SystemExit(f'Refusing symlink in managed package tree: {path}') + if path.exists() and not (path.is_file() or path.is_dir()): + raise SystemExit(f'Refusing special file in managed package tree: {path}') +for item in items: + src = source / item + if not src.exists(): + continue + for path in [src, *src.rglob('*')] if src.is_dir() else [src]: + target = destination / path.relative_to(source) + if path.is_dir(): + target.mkdir(parents=True, exist_ok=True) + target.chmod(0o755) + else: + target.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix='.package-', dir=target.parent) + try: + with os.fdopen(fd, 'wb') as output, path.open('rb') as input_file: + shutil.copyfileobj(input_file, output) + # Keep executable bits, remove special bits, and keep owner writable. + os.chmod(temporary, stat.S_IMODE(path.stat().st_mode) & 0o777 | 0o600) + os.replace(temporary, target) + finally: + if os.path.exists(temporary): + os.unlink(temporary) diff --git a/scripts/nix-idle.py b/scripts/nix-idle.py new file mode 100644 index 0000000..8ce0dc0 --- /dev/null +++ b/scripts/nix-idle.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Read-only, fail-closed rootless maintenance preflight. Never signal a process.""" +import os +from pathlib import Path + +prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))).resolve() +try: + pid = int((prefix / '.init.pid').read_text().strip()) +except FileNotFoundError: + pid = 0 +except (OSError, ValueError): + raise SystemExit('Cannot validate prefix server marker; inspect it before maintenance.') +if pid > 0: + try: + status = (Path('/proc') / str(pid) / 'status').read_text() + if not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines()): + raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling shutdown in the ORIGINAL namespace before updating. No process was stopped.') + except FileNotFoundError: + pass +# Only inspect same-user mappings under this exact prefix. An unrelated game's +# command name is not grounds to stop or modify it. Unreadable same-user maps +# fail closed because we cannot rule out a client still using these files. +paths = [str(prefix / name) + '/' for name in ( + 'Applications/Riot Client.app', 'Applications/League of Legends.app', + 'Users/Shared/Riot Games')] +for process in Path('/proc').iterdir(): + if not process.name.isdigit(): + continue + try: + if process.stat().st_uid != os.getuid(): + continue + mappings = (process / 'maps').read_text() + if any(path in mappings for path in paths): + raise SystemExit('A client maps this prefix; exit it before rootless maintenance. No process was stopped.') + except (FileNotFoundError, ProcessLookupError, PermissionError): + continue diff --git a/scripts/nix-session.sh b/scripts/nix-session.sh new file mode 100755 index 0000000..9626465 --- /dev/null +++ b/scripts/nix-session.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Run setup and the client in the SAME namespace. Never reconnect by killing it. +set -euo pipefail +cd "${DARLING_WORKSPACE:?start with darling-lol}" +ready() { python3 scripts/nix-state.py check; } +interactive() { + if [ ! -t 0 ]; then + echo 'Setup needs an interactive terminal. Run darling-lol --installer in a terminal (or use the desktop entry with Terminal=true).' >&2 + exit 1 + fi +} +if ! ready; then + interactive + echo "Prefix: $DPREFIX" + if ! python3 scripts/nix-state.py check-base; then + echo 'Base runtime missing or incompatible; building requires about 40–50 GB.' + read -r -p 'Build/update the rootless runtime? [y/N] ' reply || exit 1 + case "$reply" in y|Y) ;; *) exit 1;; esac + ./scripts/update.sh + else + ./scripts/install-runtime.sh + fi +fi +# Ready existing installations never rebuild merely because the launcher opens. +if [ "${1:-}" = --installer ] || + { [ ! -e "$DPREFIX/Applications/Riot Client.app" ] && + [ ! -d "$DPREFIX/Users/Shared/Riot Games/Riot Client.app" ]; }; then + interactive + read -r -p 'Region [euw]: ' region || exit 1 + exec ./scripts/installer.sh "${region:-euw}" +fi +exec ./scripts/run.sh diff --git a/scripts/nix-state.py b/scripts/nix-state.py new file mode 100644 index 0000000..e52dff4 --- /dev/null +++ b/scripts/nix-state.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Per-prefix rootless readiness; no Darling execution or prefix writes.""" +import hashlib +import json +import os +from pathlib import Path +import pwd +import sys + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +from runtime_paths import runtime_paths + + +def selected_paths(): + config_path = ROOT / 'build/config.json' + config = json.loads(config_path.read_text()) if config_path.exists() else {} + guest = config.get('guest_user') or pwd.getpwuid(os.getuid()).pw_name + config.setdefault('prefix', str(Path.home() / '.darling')) + config.setdefault('guest_home', '/Users/' + guest) + configured_prefix = Path(os.environ.get('DPREFIX') or config['prefix']).resolve() + env_prefix = os.environ.get('DPREFIX') + try: + os.environ['DPREFIX'] = str(configured_prefix) + prefix, home = runtime_paths(config) + finally: + if env_prefix is None: + os.environ.pop('DPREFIX', None) + else: + os.environ['DPREFIX'] = env_prefix + return prefix.resolve(), home.resolve(), guest + + +def guest_user(): + return selected_paths()[2] + + +def digest(paths): + result = hashlib.sha256() + for path in sorted(paths): + if path.is_file(): + result.update(str(path.relative_to(ROOT)).encode() + b'\0' + path.read_bytes()) + return result.hexdigest() + + +def base_identity(): + runtime_tree = ROOT / 'usr-local/libexec/darling' + runtime_modes = {name: (ROOT / 'usr-local/bin' / name).stat().st_mode & 0o777 + if (ROOT / 'usr-local/bin' / name).exists() else None + for name in ('darling', 'darlingserver')} + return {'schema': 2, 'package': os.environ.get('DARLING_PACKAGE_ID', ''), + 'runtime_modes': runtime_modes, + 'runtime_tree': inventory(runtime_tree), + 'sources': digest([* (ROOT / 'patches').glob('*.patch'), + ROOT / 'upstream/manifest.json', + * (ROOT / 'scripts').glob('*.py'), + * (ROOT / 'scripts').glob('*.sh'), + * (ROOT / 'tools').glob('*')]), + 'runtime': digest([ROOT / 'usr-local/bin/darling', + ROOT / 'usr-local/bin/darlingserver'])} + + +def inventory(root): + """Include all required trees and modes without hashing large installed binaries.""" + if not root.is_dir(): + return None + result = hashlib.sha256() + def enumeration_error(error): + raise error + + for directory, dirs, files in os.walk(root, followlinks=False, + onerror=enumeration_error): + for name in sorted(dirs + files): + path = Path(directory) / name + if any(token in path.name for token in ('.before-', '.rt-', '.llvm')): + continue + info = path.lstat() + target_str = '' + if path.is_symlink(): + try: + target_str = f':{os.readlink(path)}' + except OSError: + pass + result.update(str(path.relative_to(root)).encode() + b'\0') + result.update(f'{info.st_mode}:{info.st_size}:{info.st_mtime_ns}{target_str}'.encode()) + return result.hexdigest() + + +def overlay_identity(home): + result: dict[str, object] = {name: inventory(home / name) for name in + ('RuntimeLibraries', 'RuntimeFrameworks', + 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component')} + helper = home / 'libDarlingXShm.dylib' + result['xshm'] = ([helper.stat().st_mode, helper.stat().st_size, + helper.stat().st_mtime_ns] if helper.is_file() else None) + return result + + +def identity(): + prefix, home, guest = selected_paths() + return dict(base_identity(), prefix=str(prefix), guest=guest, guest_home=str(home), + overlays=overlay_identity(home), uid=os.getuid(), gid=os.getgid()) + + +def read(path): + try: + return json.loads(path.read_text()) + except (OSError, ValueError): + return None + + +def atomic(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix('.tmp') + temporary.write_text(json.dumps(value, sort_keys=True) + '\n') + temporary.replace(path) + + +def main(): + action = sys.argv[1] + state = identity() + key = hashlib.sha256(state['prefix'].encode()).hexdigest() + marker = ROOT / 'build/nix-ready' / (key + '.json') + base_marker = ROOT / 'build/nix-base.json' + if action == 'guest': + print(state['guest']) + elif action == 'check-base': + return 0 if read(base_marker) == base_identity() else 1 + elif action == 'check': + return 0 if read(marker) == state and read(base_marker) == base_identity() else 1 + elif action == 'stamp-base': + if (not all((ROOT / 'usr-local/bin' / name).is_file() and + os.access(ROOT / 'usr-local/bin' / name, os.X_OK) + for name in ('darling', 'darlingserver')) or + not (ROOT / 'usr-local/libexec/darling').is_dir() or not state['package']): + raise SystemExit('Cannot stamp readiness without executable base runtime, SDK and package identity') + atomic(base_marker, base_identity()) + elif action == 'stamp': + if read(base_marker) != base_identity(): + raise SystemExit('Base runtime readiness is missing; run darling-lol --update in a terminal.') + home = Path(str(state['guest_home'])) + if not home.is_dir() or not any((home / 'RuntimeLibraries').glob('*')) or not any((home / 'RuntimeFrameworks').glob('*')): + raise SystemExit('Cannot stamp an empty or missing prefix runtime') + # Reuse the complete installation gate; readiness must never mint an + # apparently valid manifest for partial overlays, even when called directly. + import importlib.util + spec = importlib.util.spec_from_file_location('runtime_validation', ROOT / 'tools/validate-riot-runtime.py') + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.inputs(Path(str(state['prefix'])), home) + atomic(marker, state) + elif action in ('invalidate', 'invalidate-base'): + marker.unlink(missing_ok=True) + if action == 'invalidate-base': + base_marker.unlink(missing_ok=True) + else: + raise SystemExit(f'Unknown state action: {action}') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/prepare.py b/scripts/prepare.py index 0e8bc72..dca0a83 100755 --- a/scripts/prepare.py +++ b/scripts/prepare.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 """Fetch pinned Darling sources and apply each patch to its owning repository.""" -import argparse,json,os,subprocess,tempfile +import argparse,json,os,subprocess,tempfile,sys from pathlib import Path +# --check-only must not create import caches in the source package either. +sys.dont_write_bytecode=True from common import ROOT,render p=argparse.ArgumentParser(description=__doc__) p.add_argument('--source',type=Path,default=ROOT/'build/darling-src') @@ -25,19 +27,27 @@ def previous_patches(component): if shown.returncode==0:yield render(shown.stdout) if not (source/'.git').exists(): if a.check_only:p.error('check-only requires an existing pinned source checkout') + if source.exists():p.error('refusing non-checkout destination; choose an empty source path') source.parent.mkdir(parents=True,exist_ok=True) - run(['git','clone',manifest['upstream'],str(source)]) - run(['git','-C',str(source),'checkout','--detach',manifest['revision']]) - # Recursive initialization can spend a long time fetching nested repos (and - # Git LFS assets). Explicit progress keeps the last completed submodule - # from looking like the operation that is currently stuck. The pinned - # darling-swift .lfsconfig points at the retired git-lfs.darlinghq.org, so - # fetch that submodule's LFS objects from GitHub, which still serves them; - # otherwise the checkout blocks on the dead host right after sqlite. - print('Initializing Darling submodules (large repositories/LFS assets may take a while)...',flush=True) - run(['git','-C',str(source),'-c','lfs.url=https://github.com/darlinghq/darling-swift.git/info/lfs', - 'submodule','update','--init','--recursive','--progress']) - print('Darling submodules initialized.',flush=True) + # Fetch the exact pin, not today's master. Publish only a complete checkout; + # failed downloads are removed without touching an existing user checkout. + with tempfile.TemporaryDirectory(prefix='darling-clone-',dir=source.parent) as temporary: + checkout=Path(temporary)/'source' + run(['git','init',str(checkout)]) + run(['git','-C',str(checkout),'remote','add','origin',manifest['upstream']]) + run(['git','-C',str(checkout),'fetch','--depth','1','origin',manifest['revision']]) + run(['git','-C',str(checkout),'checkout','--detach','FETCH_HEAD']) + print('Initializing Darling submodules (large repositories/LFS assets may take a while)...',flush=True) + for attempt in range(1,4): + try: + run(['git','-C',str(checkout),'-c','lfs.url=https://github.com/darlinghq/darling-swift.git/info/lfs', + 'submodule','update','--init','--recursive','--depth','1','--progress']) + break + except subprocess.CalledProcessError: + if attempt==3:raise + print(f'Submodule fetch interrupted (attempt {attempt}/3). Retrying...',flush=True) + checkout.rename(source) + # Never reset someone else's checkout to make a patch apply. for component in manifest['components']: repo=source/component['path'] diff --git a/scripts/run.sh b/scripts/run.sh index 56b3665..08f513b 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -2,6 +2,11 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +if [ -n "${DARLING_WORKSPACE:-}" ] && ! python3 scripts/nix-state.py check; then + echo 'Rootless runtime readiness is missing or incompatible; run darling-lol --installer in a terminal.' >&2 + exit 1 +fi +export PATH="${DARLING_RUNNER_DIR:+$DARLING_RUNNER_DIR:}$repo/usr-local/bin:/usr/local/bin:$PATH" python3 scripts/configure.py >/dev/null if [ -d build/venv/bin ]; then PATH="$repo/build/venv/bin:$PATH"; export PATH; fi # darling-lol perf: optionally wrap with gamemoderun for automatic diff --git a/scripts/update.sh b/scripts/update.sh index 5658d37..f8e997c 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -1,10 +1,29 @@ #!/bin/sh # Bring an existing build and installation up to date after `git pull`: re-apply the patches that changed # (prepare.py reverts the earlier version it finds applied), rebuild incrementally, and reinstall the base -# runtime and the per-user overrides. Run it with no Riot/League session open; it never stops one. +# runtime, and the per-user overrides. Run it after manually closing Riot/League. +# Rootless callers must remain in their original namespace; the outer launcher +# refuses a live prefix marker instead of trying to reconnect or stop a session. set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +if [ -n "${DARLING_WORKSPACE:-}" ]; then + python3 scripts/nix-idle.py + # Initial setup and upgrades use the same supported user-owned install path. + # Keep each package closure rooted: copied ELF files can retain its loader. + package=${DARLING_PACKAGE_ID%/share/darling-lol} + case "$package" in /nix/store/*) ;; *) echo 'Missing Nix package identity' >&2; exit 1;; esac + mkdir -p build/nix-gcroots + nix-store --realise "$package" --add-root "$repo/build/nix-gcroots/$(basename "$package")" --indirect >/dev/null + python3 scripts/nix-state.py invalidate-base + ./scripts/build.sh + python3 build/runtime/tools/stop-darling-runtime.py + cmake --install build/darling-src/build + python3 scripts/nix-state.py stamp-base + ./scripts/install-runtime.sh + echo 'Rootless update installed. In this shell use scripts/run.sh; otherwise exit this shell and start darling-lol.' + exit 0 +fi if [ ! -d build/darling-src/build ]; then echo 'No existing build; follow the first-time setup in README.md instead.' >&2 exit 1 diff --git a/scripts/validate.py b/scripts/validate.py index df0584b..57fcf9f 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -12,6 +12,9 @@ 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-----'] for name in filter(None,files): p=ROOT/name;data=p.read_bytes() + # The Nix desktop package intentionally ships this icon, not a runtime binary. + if name == 'darling-lol.png' and data.startswith(b'\x89PNG\r\n\x1a\n'): + continue if data.startswith((b'\x7fELF',b'MZ',b'\xcf\xfa\xed\xfe',b'\xca\xfe\xba\xbe')) or b'\0' in data: errors.append(name+': binary content is not part of this source package');continue try:text=data.decode() diff --git a/tests/test_nix_launcher.py b/tests/test_nix_launcher.py new file mode 100644 index 0000000..b4190fc --- /dev/null +++ b/tests/test_nix_launcher.py @@ -0,0 +1,90 @@ +"""Regression tests for the Nix launcher; no root or game account required.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class LauncherTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.folder = Path(self.tmp.name) + self.home = self.folder / 'home' + self.home.mkdir() + self.share = self.folder / 'share' + (self.share / 'scripts').mkdir(parents=True) + (self.share / 'scripts/run.sh').write_text('new packaged script\n') + import shutil + shutil.copy(ROOT / 'scripts/nix-copy.py', self.share / 'scripts/nix-copy.py') + self.workspace = self.home / '.local/share/darling-lol' + self.log = self.folder / 'calls' + self.bin = self.folder / 'bin' + self.bin.mkdir() + for name in ['unshare', 'fhs']: + executable = self.bin / name + executable.write_text('#!/bin/sh\nprintf "%s\\n" "$0" "$@" > "$TEST_LOG"\n') + executable.chmod(0o755) + self.launcher = self.folder / 'launcher' + self.launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text() + .replace('@SHARE_DIR@', str(self.share)) + .replace('@FHS_BIN@', str(self.bin / 'fhs')) + .replace('@UNSHARE_BIN@', str(self.bin / 'unshare'))) + self.env = dict(os.environ, HOME=str(self.home), XDG_DATA_HOME=str(self.home / '.local/share'), + TEST_LOG=str(self.log), PATH=str(self.bin) + ':' + os.environ['PATH']) + + def launch(self, *args): + return subprocess.run(['bash', str(self.launcher), *args], env=self.env, + text=True, capture_output=True, timeout=10) + + def test_package_upgrade_refreshes_tools_without_touching_build(self): + (self.workspace / 'scripts').mkdir(parents=True) + (self.workspace / 'scripts/run.sh').write_text('stale script\n') + (self.workspace / 'build').mkdir() + binary = self.workspace / 'build/keep-me' + binary.write_text('compiled runtime') + result = self.launch('true') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.workspace / 'scripts/run.sh').read_text(), 'new packaged script\n') + self.assertEqual(binary.read_text(), 'compiled runtime') + + def test_fhs_enters_subuid_namespace_before_bubblewrap(self): + result = self.launch('echo', 'hello world') + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.log.read_text().splitlines() + self.assertEqual(calls[0], str(self.bin / 'unshare')) + self.assertIn('--map-auto', calls) + self.assertIn('--map-current-user', calls) + self.assertIn('--setuid=0', calls) + self.assertIn('--setgid=0', calls) + self.assertEqual(calls[-3:], [str(self.bin / 'fhs'), 'echo', 'hello world']) + + def test_setup_never_makes_a_user_writable_runtime_setuid(self): + result = self.launch('--installer') + self.assertEqual(result.returncode, 0, result.stderr) + setup = (ROOT / 'scripts/nix-session.sh').read_text() + (ROOT / 'scripts/update.sh').read_text().split('if [ ! -d build/darling-src/build ]; then')[0] + self.assertNotIn(' sudo ', setup) + self.assertNotIn('\nsudo ', setup) + self.assertNotIn('chmod 4755', setup) + self.assertIn('install-runtime.sh', setup) + + +class RuntimeEntryTests(unittest.TestCase): + def test_entry_preserves_host_identity_and_uses_namespace_only_privileges(self): + entry = ROOT / 'scripts/fhs-entry.sh' + self.assertTrue(entry.is_file(), 'missing rootless FHS entrypoint') + text = entry.read_text() + self.assertIn('DARLING_HOST_UID', text) + self.assertIn('DARLING_HOST_GID', text) + self.assertIn('--ambient-caps', text) + shim = ROOT / 'scripts/darling-rootless.sh' + self.assertTrue(shim.is_file(), 'missing namespace-local Darling shim') + self.assertIn('setpriv', shim.read_text()) + self.assertIn('--euid', shim.read_text()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py new file mode 100644 index 0000000..f0ee4f5 --- /dev/null +++ b/tests/test_nix_repairs.py @@ -0,0 +1,519 @@ +"""Safe packaging regressions: only temporary repositories, homes and stub commands.""" +import hashlib +import importlib.util +import json +import getpass +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def run(*args, cwd=None, env=None): + return subprocess.run(args, cwd=cwd, env=env, input='', text=True, + capture_output=True, timeout=30) + + +def git(repo, *args): + result = run('git', '-C', str(repo), *args) + if result.returncode: + raise AssertionError(result.stderr) + return result.stdout.strip() + + +def init(repo): + repo.mkdir(parents=True) + git(repo, 'init', '-b', 'master') + git(repo, 'config', 'user.name', 'Fixture') + git(repo, 'config', 'user.email', 'fixture@example.invalid') + + +def shutdown_patch(): + return 'diff --git a/tools/shutdown-user.sh b/tools/shutdown-user.sh\n' + ( + ROOT / 'patches/darling.patch').read_text().split( + 'diff --git a/tools/shutdown-user.sh b/tools/shutdown-user.sh\n', 1)[1].split('\ndiff --git ', 1)[0] + '\n' + + +def patch_base(patch): + lines = patch.splitlines() + start = next(i for i, line in enumerate(lines) if line.startswith('@@')) + 1 + return '\n'.join(line[1:] for line in lines[start:] if line.startswith((' ', '-'))) + '\n' + + +class PrepareTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='nix-prepare-test-') + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.project = self.root / 'project' + for name in ['scripts', 'patches', 'upstream']: + (self.project / name).mkdir(parents=True) + for name in ['prepare.py', 'common.py']: + shutil.copy(ROOT / 'scripts' / name, self.project / 'scripts' / name) + self.source = self.root / 'source' + init(self.source) + self.env = dict(os.environ, PYTHONDONTWRITEBYTECODE='1', HOME=str(self.root)) + + def prepare(self, check=False): + return run(sys.executable, 'scripts/prepare.py', '--source', str(self.source), + *(['--check-only'] if check else []), cwd=self.project, env=self.env) + + def manifest(self, revision, components, upstream=None): + (self.project / 'upstream/manifest.json').write_text(json.dumps({ + 'upstream': upstream or self.source.as_uri(), 'revision': revision, 'components': components})) + + def test_prepare_twice_and_check_only_preserves_all_files(self): + patch = shutdown_patch() + (self.source / 'tools').mkdir() + (self.source / 'tools/shutdown-user.sh').write_text(patch_base(patch)) + git(self.source, 'add', '.') + git(self.source, 'commit', '-m', 'base') + rev = git(self.source, 'rev-parse', 'HEAD') + (self.project / 'patches/darling.patch').write_text(patch) + self.manifest(rev, [{'path': '.', 'patch': 'patches/darling.patch', 'revision': rev}]) + for _ in range(2): + result = self.prepare() + self.assertEqual(result.returncode, 0, result.stderr) + before = {str(p): (p.read_bytes(), p.stat().st_mtime_ns) for p in self.root.rglob('*') if p.is_file()} + result = self.prepare(True) + self.assertEqual(result.returncode, 0, result.stderr) + after = {str(p): (p.read_bytes(), p.stat().st_mtime_ns) for p in self.root.rglob('*') if p.is_file()} + self.assertEqual(before, after) + + def test_check_only_does_not_rewrite_compatibility_headers(self): + path = self.source / 'src/external/cctools-port/cctools/ld64/src/3rd/strlcpy.h' + path.parent.mkdir(parents=True) + path.write_text('size_t strlcpy(char *dst, const char *src, size_t siz);\n') + git(self.source, 'add', '.') + git(self.source, 'commit', '-m', 'header') + self.manifest(git(self.source, 'rev-parse', 'HEAD'), []) + before = path.read_bytes(), path.stat().st_mtime_ns + self.assertEqual(self.prepare(True).returncode, 0) + self.assertEqual(before, (path.read_bytes(), path.stat().st_mtime_ns)) + + def test_rootless_shutdown_dispatches_only_scoped_helper_and_propagates_failure(self): + with tempfile.TemporaryDirectory(prefix='nix-hook-test-') as directory: + root = Path(directory) + (root / 'scripts').mkdir() + helper = root / 'scripts/nix-idle.py' + helper.write_text('import os, sys\nprint(os.environ["DPREFIX"])\nsys.exit(int(os.environ["TEST_EXIT"]))\n') + patch = shutdown_patch().splitlines() + start = next(i for i, line in enumerate(patch) if line.startswith('@@')) + 1 + script = root / 'shutdown.sh' + script.write_text('\n'.join(line[1:] for line in patch[start:] if line.startswith((' ', '+'))) + '\n') + bin_dir = root / 'bin' + bin_dir.mkdir() + # These commands must never be reached by rootless preparation. + for name in ['sudo', 'ps', 'su', 'darling', 'killall', 'pkill']: + stub = bin_dir / name + stub.write_text('#!/bin/sh\necho unexpected-' + name + ' >&2\nexit 99\n') + stub.chmod(0o755) + env = dict(os.environ, DARLING_WORKSPACE=str(root), DPREFIX=str(root / 'prefix'), + TEST_EXIT='0', PATH=str(bin_dir) + ':' + os.environ['PATH']) + success = run('sh', str(script), env=env) + self.assertEqual(success.returncode, 0, success.stderr) + self.assertEqual(success.stdout.strip(), env['DPREFIX']) + env['TEST_EXIT'] = '23' + self.assertEqual(run('sh', str(script), env=env).returncode, 23) + + def test_cctools_patch_is_maintained_and_repeatable(self): + component = next(c for c in json.loads((ROOT / 'upstream/manifest.json').read_text())['components'] + if c['path'] == 'src/external/cctools-port') + patch = (ROOT / component['patch']).read_text() + # Reconstruct exactly the base of each maintained hunk in a local repo. + for section in patch.split('diff --git ')[1:]: + lines = section.splitlines() + path = lines[0].split(' b/')[1] + header = next(i for i, line in enumerate(lines) if line.startswith('@@')) + # Header hunk starts at line six; use a neutral prefix of that length. + start = int(lines[header].split('-')[1].split(',')[0]) + base = ['/* untouched */\n'] * (start - 1) + base += [line[1:] + '\n' if line else '\n' for line in lines[header + 1:] if not line or line.startswith((' ', '-'))] + target = self.source / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(''.join(base)) + git(self.source, 'add', '.') + git(self.source, 'commit', '-m', 'cctools') + rev = git(self.source, 'rev-parse', 'HEAD') + (self.project / 'patches/cctools-port.patch').write_text(patch) + self.manifest(rev, [{'path': '.', 'revision': rev, 'patch': 'patches/cctools-port.patch'}]) + for check in [False, False, True]: + result = self.prepare(check) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_fetch_pin_older_than_advanced_remote_and_failed_retry(self): + (self.source / 'file').write_text('pinned') + git(self.source, 'add', '.') + git(self.source, 'commit', '-m', 'pin') + pin = git(self.source, 'rev-parse', 'HEAD') + (self.source / 'file').write_text('advanced') + git(self.source, 'commit', '-am', 'advance') + remote = self.source + self.source = self.root / 'clone' + self.manifest('0' * 40, [], remote.as_uri()) + failed = self.prepare() + self.assertNotEqual(failed.returncode, 0) + self.assertFalse(self.source.exists(), 'failed initial checkout must not poison retries') + self.manifest(pin, [], remote.as_uri()) + result = self.prepare() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(git(self.source, 'rev-parse', 'HEAD'), pin) + self.assertEqual((self.source / 'file').read_text(), 'pinned') + + +class CopyTests(unittest.TestCase): + def test_symlink_workspace_is_rejected_without_external_writes(self): + with tempfile.TemporaryDirectory(prefix='nix-symlink-test-') as directory: + root = Path(directory) + share = root / 'share' + (share / 'scripts').mkdir(parents=True) + shutil.copy(ROOT / 'scripts/nix-copy.py', share / 'scripts/nix-copy.py') + outside = root / 'outside' + outside.mkdir() + data = root / 'data' + data.mkdir() + (data / 'darling-lol').symlink_to(outside, target_is_directory=True) + launcher = root / 'launcher' + launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text().replace('@SHARE_DIR@', str(share))) + env = dict(os.environ, HOME=str(root), XDG_DATA_HOME=str(data), DPREFIX=str(root / 'prefix')) + result = run('bash', str(launcher), 'true', env=env) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(list(outside.iterdir()), []) + + def test_copied_scripts_execute_and_upgrade_without_following_symlinks(self): + with tempfile.TemporaryDirectory(prefix='nix-copy-test-') as directory: + root = Path(directory) + share, workspace = root / 'share', root / 'workspace' + (share / 'scripts').mkdir(parents=True) + shutil.copy(ROOT / 'scripts/nix-copy.py', share / 'scripts/nix-copy.py') + entry = share / 'scripts/run.sh' + entry.write_text('#!/bin/sh\nprintf first\n') + entry.chmod(0o555) + env = dict(os.environ, HOME=str(root), XDG_DATA_HOME=str(root / 'data')) + # Use real cp/filesystem execution, but never a real namespace or Darling. + launcher = root / 'launcher' + launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text() + .replace('@SHARE_DIR@', str(share)).replace('@FHS_BIN@', shutil.which('true')) + .replace('@UNSHARE_BIN@', shutil.which('true')).replace('@TERMINAL_BIN@', shutil.which('false'))) + workspace = root / 'data/darling-lol' + result = run('bash', str(launcher), 'true', env=env) + self.assertEqual(result.returncode, 0, result.stderr) + target = workspace / 'scripts/run.sh' + self.assertTrue(os.access(target, os.X_OK), 'copied entrypoint lost executable bits') + self.assertTrue(target.stat().st_mode & 0o200) + self.assertEqual(run(str(target), env=env).stdout, 'first') + entry.chmod(0o755) + entry.write_text('#!/bin/sh\nprintf second\n') + entry.chmod(0o555) + self.assertEqual(run('bash', str(launcher), 'true', env=env).returncode, 0) + self.assertEqual(run(str(target), env=env).stdout, 'second') + external = root / 'external' + external.write_text('untouched') + target.unlink() + target.symlink_to(external) + result = run('bash', str(launcher), 'true', env=env) + self.assertNotEqual(result.returncode, 0, 'reject managed destination symlinks') + self.assertEqual(external.read_text(), 'untouched') + + +def populate_overlays(prefix, home, tools): + for name in ('validate-riot-runtime.py', 'runtime_paths.py'): + shutil.copy(ROOT / 'tools' / name, tools / name) + sys.path.insert(0, str(tools)) + try: + spec = importlib.util.spec_from_file_location('validation_fixture', tools / 'validate-riot-runtime.py') + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + guest_req = getattr(module, 'GUEST_REQUIRED', getattr(module, 'REQUIRED', ())) + prefix_req = getattr(module, 'PREFIX_REQUIRED', ()) + for relative in guest_req: + path = home / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'inert test fixture') + for relative in prefix_req: + path = prefix / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'inert test fixture') + finally: + sys.path.remove(str(tools)) + + +class ReadinessTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='nix-state-test-') + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.repo = self.root / 'repo' + shutil.copytree(ROOT / 'scripts', self.repo / 'scripts', ignore=shutil.ignore_patterns('__pycache__')) + for folder in ['upstream', 'patches', 'tools', 'usr-local/bin']: + (self.repo / folder).mkdir(parents=True) + (self.repo / 'upstream/manifest.json').write_text('{}') + shutil.copy(ROOT / 'tools/runtime_paths.py', self.repo / 'tools/runtime_paths.py') + (self.repo / 'usr-local/bin/darling').write_text('fixture, never executed') + (self.repo / 'usr-local/bin/darlingserver').write_text('server fixture, never executed') + (self.repo / 'usr-local/libexec/darling/usr/lib').mkdir(parents=True) + (self.repo / 'usr-local/libexec/darling/usr/lib/libSystem.B.dylib').write_text('SDK fixture') + for name in ['darling', 'darlingserver']: + (self.repo / 'usr-local/bin' / name).chmod(0o755) + (self.repo / 'usr-local/libexec/darling').mkdir(parents=True, exist_ok=True) + (self.repo / 'build').mkdir() + (self.repo / 'build/config.json').write_text(json.dumps({'guest_user': 'guest-one'})) + self.env = dict(os.environ, HOME=str(self.root), DPREFIX=str(self.root / 'prefix'), + DARLING_WORKSPACE=str(self.repo), DARLING_PACKAGE_ID='package-one', + DARLING_GUEST_USER='guest-one', PYTHONDONTWRITEBYTECODE='1') + self.env.pop('RIOT_GUEST_HOME', None) + populate_overlays(Path(self.env['DPREFIX']), Path(self.env['DPREFIX']) / 'Users/guest-one', self.repo / 'tools') + + def state(self, action): + return run(sys.executable, str(self.repo / 'scripts/nix-state.py'), action, env=self.env) + + def test_prefix_guest_package_source_and_runtime_identity(self): + self.assertNotEqual(self.state('check').returncode, 0) + (Path(self.env['DPREFIX']) / 'Applications/Riot Client.app').mkdir(parents=True) + self.assertNotEqual(self.state('check').returncode, 0, 'existing app is not readiness') + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + self.assertEqual(self.state('check').returncode, 0) + # Canonical aliases share readiness, but a different prefix must not. + self.env['DPREFIX'] += '/../prefix' + self.assertEqual(self.state('check').returncode, 0) + for key, value in [('DPREFIX', str(self.root / 'new-prefix')), + ('DARLING_PACKAGE_ID', 'package-two')]: + old = self.env[key] + self.env[key] = value + self.assertNotEqual(self.state('check').returncode, 0, key) + self.env[key] = old + (self.repo / 'build/config.json').write_text(json.dumps({'guest_user': 'guest-two'})) + self.assertNotEqual(self.state('check').returncode, 0) + (self.repo / 'build/config.json').write_text(json.dumps({'guest_user': 'guest-one'})) + (self.repo / 'patches/change.patch').write_text('upgrade') + self.assertNotEqual(self.state('check').returncode, 0) + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + (self.repo / 'usr-local/bin/darlingserver').write_text('server-one') + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + (self.repo / 'usr-local/bin/darlingserver').write_text('server-two') + self.assertNotEqual(self.state('check').returncode, 0) + (self.repo / 'usr-local/bin/darling').write_text('changed runtime') + self.assertNotEqual(self.state('check').returncode, 0) + + def test_deleted_overlay_invalidates_readiness(self): + overlay = Path(self.env['DPREFIX']) / 'Users/guest-one/RuntimeLibraries/library.dylib' + overlay.parent.mkdir(parents=True, exist_ok=True) + overlay.write_text('fixture overlay') + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + self.assertEqual(self.state('check').returncode, 0) + overlay.unlink() + self.assertNotEqual(self.state('check').returncode, 0) + + def test_missing_runtime_and_noop_checks_do_not_create_or_rebuild(self): + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + before = {str(p): p.stat().st_mtime_ns for p in self.repo.rglob('*') if p.is_file()} + for _ in range(2): + self.assertEqual(self.state('check').returncode, 0) + after = {str(p): p.stat().st_mtime_ns for p in self.repo.rglob('*') if p.is_file()} + self.assertEqual(before, after) + (self.repo / 'usr-local/bin/darling').unlink() + self.assertNotEqual(self.state('check').returncode, 0) + self.assertNotEqual(self.state('stamp-base').returncode, 0) + + def test_interrupted_setup_invalidates_previous_stamp(self): + self.assertEqual(self.state('stamp-base').returncode, 0) + self.assertEqual(self.state('stamp').returncode, 0) + self.assertEqual(self.state('invalidate').returncode, 0) + self.assertNotEqual(self.state('check').returncode, 0) + + def test_runner_refuses_unready_even_for_raw_launch_paths(self): + runner = ROOT / 'scripts/darling-rootless.sh' + env = dict(self.env, DARLING_HOST_UID=str(os.getuid())) + result = run('bash', str(runner), 'shell', '/usr/bin/true', env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('readiness', result.stderr) + + def test_run_and_installer_refuse_unready_before_configure_or_darling(self): + for name in ['run.sh', 'installer.sh']: + result = run('sh', str(self.repo / 'scripts' / name), env=self.env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('readiness', result.stderr.lower()) + self.assertFalse((self.repo / 'build/runtime').exists()) + + +class SessionTests(unittest.TestCase): + def test_workspace_lock_survives_namespace_closing_extra_fds(self): + with tempfile.TemporaryDirectory(prefix='nix-lock-test-') as directory: + root = Path(directory) + share = root / 'share' + (share / 'scripts').mkdir(parents=True) + shutil.copy(ROOT / 'scripts/nix-copy.py', share / 'scripts/nix-copy.py') + stub = root / 'namespace' + stub.write_text('#!/bin/sh\nexec 9>&-\ntouch "$TEST_ENTERED"\nsleep 1\n') + stub.chmod(0o755) + launcher = root / 'launcher' + launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text() + .replace('@SHARE_DIR@', str(share)).replace('@UNSHARE_BIN@', str(stub))) + entered = root / 'entered' + env = dict(os.environ, HOME=str(root), XDG_DATA_HOME=str(root / 'data'), TEST_ENTERED=str(entered)) + first = subprocess.Popen(['bash', str(launcher), 'true'], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + import time + for _ in range(100): + if entered.exists(): + break + time.sleep(.01) + self.assertTrue(entered.exists()) + second = run('bash', str(launcher), 'true', env=env) + self.assertNotEqual(second.returncode, 0) + self.assertIn('session is active', second.stderr) + finally: + first.communicate(timeout=10) + + def test_live_prefix_marker_refuses_before_refresh_or_namespace(self): + with tempfile.TemporaryDirectory(prefix='nix-marker-test-') as directory: + root = Path(directory) + prefix = root / 'prefix' + prefix.mkdir() + # This fixture's own PID is harmless; the launcher must never signal it. + (prefix / '.init.pid').write_text(str(os.getpid())) + launcher = root / 'launcher' + launcher.write_text((ROOT / 'darling-lol-launcher.sh').read_text() + .replace('@SHARE_DIR@', str(root / 'missing-share')) + .replace('@UNSHARE_BIN@', 'must-not-execute')) + env = dict(os.environ, HOME=str(root), DPREFIX=str(prefix), XDG_DATA_HOME=str(root / 'data')) + result = run('bash', str(launcher), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('reconnect is unsupported', result.stderr) + self.assertFalse((root / 'data/darling-lol/scripts').exists()) + + def test_ready_session_launches_without_rebuild_and_missing_stamp_refuses_eof(self): + with tempfile.TemporaryDirectory(prefix='nix-dispatch-test-') as directory: + root = Path(directory) + (root / 'scripts').mkdir() + for name in ['nix-state.py', 'nix-session.sh']: + shutil.copy(ROOT / 'scripts' / name, root / 'scripts' / name) + (root / 'usr-local/bin').mkdir(parents=True) + (root / 'usr-local/bin/darling').write_text('never executed') + (root / 'usr-local/bin/darlingserver').write_text('never executed') + for name in ['darling', 'darlingserver']: + (root / 'usr-local/bin' / name).chmod(0o755) + (root / 'usr-local/libexec/darling/usr/lib').mkdir(parents=True, exist_ok=True) + (root / 'usr-local/libexec/darling/usr/lib/libSystem.B.dylib').write_text('SDK fixture') + (root / 'tools').mkdir() + (root / 'usr-local/libexec/darling').mkdir(parents=True, exist_ok=True) + shutil.copy(ROOT / 'tools/runtime_paths.py', root / 'tools/runtime_paths.py') + (root / 'prefix/Applications/Riot Client.app').mkdir(parents=True) + for name in ['run.sh', 'update.sh', 'install-runtime.sh', 'installer.sh']: + path = root / 'scripts' / name + path.write_text('#!/bin/sh\necho ' + name + ' >> "$TEST_LOG"\n') + path.chmod(0o755) + log = root / 'calls' + env = dict(os.environ, HOME=str(root), DPREFIX=str(root / 'prefix'), + DARLING_WORKSPACE=str(root), DARLING_PACKAGE_ID='fixture', TEST_LOG=str(log), + PYTHONDONTWRITEBYTECODE='1') + env.pop('RIOT_GUEST_HOME', None) + populate_overlays(root / 'prefix', root / 'prefix/Users' / getpass.getuser(), root / 'tools') + for action in ['stamp-base', 'stamp']: + self.assertEqual(run(sys.executable, str(root / 'scripts/nix-state.py'), action, env=env).returncode, 0) + for _ in range(2): + result = run('bash', str(root / 'scripts/nix-session.sh'), env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(log.read_text().splitlines(), ['run.sh', 'run.sh']) + run(sys.executable, str(root / 'scripts/nix-state.py'), 'invalidate', env=env) + result = run('bash', str(root / 'scripts/nix-session.sh'), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('terminal', result.stderr) + self.assertEqual(log.read_text().splitlines(), ['run.sh', 'run.sh']) + env['DPREFIX'] = str(root / 'other-prefix') + result = run('bash', str(root / 'scripts/nix-session.sh'), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((root / 'other-prefix').exists()) + + def test_first_run_eof_is_actionable_and_desktop_requests_terminal(self): + with tempfile.TemporaryDirectory(prefix='nix-session-test-') as directory: + root = Path(directory) + (root / 'scripts').mkdir() + for p in (ROOT / 'scripts').glob('*'): + if p.is_file(): + shutil.copy(p, root / 'scripts' / p.name) + env = dict(os.environ, HOME=str(root), DPREFIX=str(root / 'prefix'), + DARLING_WORKSPACE=str(root), DARLING_PACKAGE_ID='fixture') + result = run('bash', str(root / 'scripts/nix-session.sh'), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('terminal', result.stderr.lower()) + self.assertFalse((root / 'build').exists()) + self.assertIn('Terminal=true', (ROOT / 'flake.nix').read_text()) + self.assertIn('checks.${system}.packaging', (ROOT / 'flake.nix').read_text()) + + def test_rootless_update_refuses_live_marker_without_build_or_stop(self): + with tempfile.TemporaryDirectory(prefix='nix-update-idle-') as directory: + root = Path(directory) + (root / 'scripts').mkdir() + for name in ['update.sh', 'nix-idle.py']: + if (ROOT / 'scripts' / name).exists(): + shutil.copy(ROOT / 'scripts' / name, root / 'scripts' / name) + prefix = root / 'prefix' + prefix.mkdir() + (prefix / '.init.pid').write_text(str(os.getpid())) + env = dict(os.environ, DARLING_WORKSPACE=str(root), DPREFIX=str(prefix), + DARLING_PACKAGE_ID='/nix/store/fixture-package/share/darling-lol') + result = run('sh', str(root / 'scripts/update.sh'), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertIn('live server', result.stderr.lower()) + self.assertFalse((root / 'build').exists()) + + def test_rootless_update_executes_without_sudo_and_fails_closed(self): + with tempfile.TemporaryDirectory(prefix='nix-update-test-') as directory: + root = Path(directory) + scripts, tools, bin_dir = root / 'scripts', root / 'build/runtime/tools', root / 'bin' + scripts.mkdir() + tools.mkdir(parents=True) + bin_dir.mkdir() + shutil.copy(ROOT / 'scripts/update.sh', scripts / 'update.sh') + # Process discovery has its own read-only marker test; isolate this + # installer-order fixture from unrelated host /proc permissions. + (scripts / 'nix-idle.py').write_text('pass\n') + log = root / 'calls' + for name, body in [('build.sh', 'echo build >> "$TEST_LOG"'), + ('install-runtime.sh', 'echo overlays >> "$TEST_LOG"')]: + path = scripts / name + path.write_text('#!/bin/sh\n' + body + '\n') + path.chmod(0o755) + (scripts / 'nix-state.py').write_text('import os, sys\nwith open(os.environ["TEST_LOG"], "a") as f: f.write(sys.argv[1]+"\\n")\n') + (tools / 'stop-darling-runtime.py').write_text('import os\nwith open(os.environ["TEST_LOG"], "a") as f: f.write("stop\\n")\n') + for name in ['cmake', 'sudo', 'nix-store']: + path = bin_dir / name + path.write_text('#!/bin/sh\necho ' + name + ' >> "$TEST_LOG"\n' + + ('exit "${INSTALL_EXIT:-0}"\n' if name == 'cmake' else 'exit 0\n')) + path.chmod(0o755) + env = dict(os.environ, DARLING_WORKSPACE=str(root), HOME=str(root), DPREFIX=str(root / 'prefix'), + DARLING_PACKAGE_ID='/nix/store/fixture-package/share/darling-lol', + TEST_LOG=str(log), PATH=str(bin_dir) + ':' + os.environ['PATH']) + result = run('sh', str(scripts / 'update.sh'), env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(log.read_text().splitlines(), + ['nix-store', 'invalidate-base', 'build', 'stop', 'cmake', 'stamp-base', 'overlays']) + log.write_text('') + env['INSTALL_EXIT'] = '23' + self.assertEqual(run('sh', str(scripts / 'update.sh'), env=env).returncode, 23) + self.assertNotIn('stamp-base', log.read_text()) + self.assertNotIn('overlays', log.read_text()) + self.assertNotIn('sudo', log.read_text()) + + def test_rootless_update_never_uses_sudo_and_stamps_only_after_success(self): + text = (ROOT / 'scripts/update.sh').read_text() + self.assertIn('DARLING_WORKSPACE', text) + self.assertIn('stamp-base', text) + self.assertIn('invalidate-base', text) + self.assertIn('nix-state.py', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_nix_runtime_identity.py b/tests/test_nix_runtime_identity.py new file mode 100644 index 0000000..e292085 --- /dev/null +++ b/tests/test_nix_runtime_identity.py @@ -0,0 +1,111 @@ +"""Readiness regression fixtures: inert runtime bytes, no Darling execution.""" +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class RuntimeIdentityTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='nix-identity-test-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + for name in ('scripts', 'tools'): + shutil.copytree(ROOT / name, self.root / name) + self.prefix = self.root / 'prefix' + self.guest = self.prefix / 'Users/tester' + (self.guest / 'RuntimeLibraries').mkdir(parents=True) + (self.guest / 'RuntimeFrameworks').mkdir() + (self.guest / 'RuntimeLibraries/test.dylib').write_bytes(b'\xcf\xfa\xed\xfe' + b'fixture') + (self.guest / 'libDarlingXShm.dylib').write_bytes(b'\xcf\xfa\xed\xfe' + b'fixture') + audio = self.guest / 'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component/Contents/MacOS/CoreAudio' + audio.parent.mkdir(parents=True) + audio.write_bytes(b'\xcf\xfa\xed\xfe' + b'fixture') + spec = importlib.util.spec_from_file_location('validation_fixture', self.root / 'tools/validate-riot-runtime.py') + sys.path.insert(0, str(self.root / 'tools')) + self.addCleanup(lambda: sys.path.remove(str(self.root / 'tools'))) + validation = importlib.util.module_from_spec(spec) + spec.loader.exec_module(validation) + guest_req = getattr(validation, 'GUEST_REQUIRED', getattr(validation, 'REQUIRED', ())) + prefix_req = getattr(validation, 'PREFIX_REQUIRED', ()) + for relative in guest_req: + path = self.guest / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'inert fixture') + for relative in prefix_req: + path = self.prefix / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'\xcf\xfa\xed\xfe' + b'inert fixture') + for name in ('usr-local/bin/darling', 'usr-local/bin/darlingserver', + 'usr-local/libexec/darling/usr/lib/libSystem.B.dylib'): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('inert runtime fixture\n') + path.chmod(0o755) + (self.root / 'build').mkdir() + self.config = self.root / 'build/config.json' + self.config.write_text(json.dumps({'prefix': str(self.prefix), 'guest_user': 'tester', + 'guest_home': '/Users/tester'})) + self.env = dict(os.environ, DPREFIX=str(self.prefix), + DARLING_PACKAGE_ID='/nix/store/fixture-package/share/darling-lol') + self.env.pop('RIOT_GUEST_HOME', None) + for action in ('stamp-base', 'stamp'): + result = self.state(action) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.state('check').returncode, 0) + + def state(self, action, env=None): + return subprocess.run([sys.executable, '-B', 'scripts/nix-state.py', action], + cwd=self.root, env=env or self.env, text=True, + capture_output=True, timeout=20) + + def test_host_guest_home_override_invalidates_readiness(self): + env = dict(self.env, RIOT_GUEST_HOME=str(self.prefix / 'Users/alternate')) + self.assertNotEqual(self.state('check', env).returncode, 0) + + def test_configured_guest_home_change_invalidates_readiness(self): + config = json.loads(self.config.read_text()) + config['guest_home'] = '/Users/alternate' + self.config.write_text(json.dumps(config)) + self.assertNotEqual(self.state('check').returncode, 0) + + def test_required_xshm_loss_invalidates_readiness(self): + (self.guest / 'libDarlingXShm.dylib').unlink() + self.assertNotEqual(self.state('check').returncode, 0) + + def test_audio_component_loss_invalidates_readiness(self): + shutil.rmtree(self.guest / 'Library') + self.assertNotEqual(self.state('check').returncode, 0) + + def test_launcher_execute_permission_loss_invalidates_readiness(self): + (self.root / 'usr-local/bin/darling').chmod(0o644) + self.assertNotEqual(self.state('check').returncode, 0) + + def test_base_runtime_tree_loss_invalidates_readiness(self): + shutil.rmtree(self.root / 'usr-local/libexec') + self.assertNotEqual(self.state('check').returncode, 0) + + def test_missing_base_server_cannot_be_restamped(self): + (self.root / 'usr-local/bin/darlingserver').unlink() + self.assertNotEqual(self.state('stamp-base').returncode, 0) + + def test_empty_prefix_cannot_be_stamped(self): + shutil.rmtree(self.prefix) + self.prefix.mkdir() + self.assertNotEqual(self.state('stamp').returncode, 0) + + def test_configured_prefix_used_without_environment_override(self): + env = dict(self.env) + env.pop('DPREFIX') + self.assertEqual(self.state('check', env).returncode, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/upstream/manifest.json b/upstream/manifest.json index 09e7fa8..8a83a47 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -246,7 +246,6 @@ "private-include/Metal/MTLArgumentInternal.h", "private-include/Metal/MTLBlitCommandEncoderInternal.h", "private-include/Metal/MTLDepthStencilInternal.h", - "private-include/Metal/MTLDeviceInfo.h", "private-include/Metal/MTLSamplerStateInternal.h", "private-include/Metal/MTLVertexDescriptorInternal.h", "src/Metal/MTLArgument.mm", @@ -317,6 +316,11 @@ "patch": "patches/cfnetwork.patch", "modified_files": 1, "new_files": [] + }, + { + "path": "src/external/cctools-port", + "revision": "d9456c221e1f462e17c0b3297748bc089d5a861e", + "patch": "patches/cctools-port.patch" } ], "omitted": [ From d898703fcae422af748da41634d40fb0daefaac6 Mon Sep 17 00:00:00 2001 From: Hamza Jermouni Date: Sun, 18 Oct 2026 12:28:12 +0200 Subject: [PATCH 068/160] Define PROCMAP_QUERY when the kernel headers lack it Ubuntu 24.04 ships Linux 6.8 headers, which predate the PROCMAP_QUERY ioctl (6.11), so darlingserver failed to build. The ioctl ABI is fixed, so define it locally; on an older running kernel the ioctl fails and the /proc//maps text parser is used as before. Co-Authored-By: Claude Opus 5.5 --- patches/darlingserver.patch | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/patches/darlingserver.patch b/patches/darlingserver.patch index 93505c0..18cbda5 100644 --- a/patches/darlingserver.patch +++ b/patches/darlingserver.patch @@ -2336,7 +2336,7 @@ index 9cd7b81..180f372 100644 parent->_notifyListeningKqchannels(NOTE_FORK, nsid()); } } -@@ -465,7 +524,75 @@ DarlingServer::Process::MemoryInfo DarlingServer::Process::memoryInfo() const { +@@ -465,7 +524,106 @@ DarlingServer::Process::MemoryInfo DarlingServer::Process::memoryInfo() const { return info; }; @@ -2364,6 +2364,37 @@ index 9cd7b81..180f372 100644 +#include +#include +#include ++// Distribution headers older than Linux 6.11 (Ubuntu 24.04 ships 6.8) lack the ioctl; its ABI is ++// fixed, so define it here. On an older running kernel the ioctl fails and the text parser is used. ++#ifndef PROCMAP_QUERY ++#include ++enum procmap_query_flags { ++ PROCMAP_QUERY_VMA_READABLE = 0x01, ++ PROCMAP_QUERY_VMA_WRITABLE = 0x02, ++ PROCMAP_QUERY_VMA_EXECUTABLE = 0x04, ++ PROCMAP_QUERY_VMA_SHARED = 0x08, ++ PROCMAP_QUERY_COVERING_OR_NEXT_VMA = 0x10, ++ PROCMAP_QUERY_FILE_BACKED_VMA = 0x20, ++}; ++struct procmap_query { ++ __u64 size; ++ __u64 query_flags; ++ __u64 query_addr; ++ __u64 vma_start; ++ __u64 vma_end; ++ __u64 vma_flags; ++ __u64 vma_page_size; ++ __u64 vma_offset; ++ __u64 inode; ++ __u32 dev_major; ++ __u32 dev_minor; ++ __u32 vma_name_size; ++ __u32 build_id_size; ++ __u64 vma_name_addr; ++ __u64 build_id_addr; ++}; ++#define PROCMAP_QUERY _IOWR('f', 17, struct procmap_query) ++#endif +static std::atomic procmapQueryUnsupported(false); + +// Returns 0 on success, ENOENT when no VMA matches, -1 when the ioctl cannot be used (callers fall back to parsing @@ -2413,7 +2444,7 @@ index 9cd7b81..180f372 100644 DarlingServer::Process::MemoryRegionInfo DarlingServer::Process::memoryRegionInfo(uintptr_t address) const { if (isDead()) { -@@ -473,31 +600,55 @@ DarlingServer::Process::MemoryRegionInfo DarlingServer::Process::memoryRegionInf +@@ -473,31 +631,55 @@ DarlingServer::Process::MemoryRegionInfo DarlingServer::Process::memoryRegionInf } MemoryRegionInfo info; @@ -2476,7 +2507,7 @@ index 9cd7b81..180f372 100644 info.protection = 0; -@@ -657,27 +808,38 @@ void DarlingServer::Process::syncMemory(uintptr_t address, size_t size, int sync +@@ -657,27 +839,38 @@ void DarlingServer::Process::syncMemory(uintptr_t address, size_t size, int sync return thread->syncMemory(address, size, sync_flags); }; From 9a320f17cbecad05168d62a09e5a2f0f83a3e62b Mon Sep 17 00:00:00 2001 From: Hamza Jermouni Date: Sun, 18 Oct 2026 12:28:12 +0200 Subject: [PATCH 069/160] Implement CTFontCopyLocalizedName with the canonical name The stub returned nil and left the language output untouched. Chromium pre-fills that output with a sentinel and reads it whenever it is non-NULL, so the League client UI read garbage. Return CTFontCopyName and always write NULL to the language output. The CoreText font-list check now covers both. Co-Authored-By: Claude Opus 5.5 --- patches/cocotron.patch | 25 ++++++++++++++----------- tools/riot-coretext-font-list-check.m | 8 ++++++++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 84e76c6..b7f467b 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -6813,7 +6813,7 @@ index 94ddabf9..fa018d3d 100644 printf("STUB %s\n", __PRETTY_FUNCTION__); return nil; } -@@ -128,157 +206,275 @@ CTFontRef CTFontCreateForString(CTFontRef currentFont, CFStringRef string, CFRan +@@ -128,157 +206,276 @@ CTFontRef CTFontCreateForString(CTFontRef currentFont, CFStringRef string, CFRan CTFontRef CTFontCreateForStringWithLanguage(CTFontRef currentFont, CFStringRef string, CFRange range, CFStringRef language) { @@ -7004,9 +7004,12 @@ index 94ddabf9..fa018d3d 100644 CFStringRef CTFontCopyLocalizedName(CTFontRef font, CFStringRef nameKey, CFStringRef _Nullable *actualLanguage) { -+ font = normalizedFont(font); - printf("STUB %s\n", __PRETTY_FUNCTION__); - return nil; +- printf("STUB %s\n", __PRETTY_FUNCTION__); +- return nil; ++ /* Fall back to the canonical name. Chromium pre-fills the language output with a ++ * sentinel and reads it whenever it is non-NULL, so it must always be written. */ ++ if (actualLanguage) *actualLanguage = NULL; ++ return CTFontCopyName(font, nameKey); } CFCharacterSetRef CTFontCopyCharacterSet(CTFontRef font) @@ -7106,7 +7109,7 @@ index 94ddabf9..fa018d3d 100644 printf("STUB %s\n", __PRETTY_FUNCTION__); return CGNullGlyph; } -@@ -287,20 +483,44 @@ CGRect CTFontGetBoundingRectsForGlyphs(CTFontRef font, CTFontOrientation orienta +@@ -287,20 +484,44 @@ CGRect CTFontGetBoundingRectsForGlyphs(CTFontRef font, CTFontOrientation orienta const CGGlyph *glyphs, CGRect *boundingRects, CFIndex count) { @@ -7159,7 +7162,7 @@ index 94ddabf9..fa018d3d 100644 } return sum; -@@ -310,6 +530,7 @@ CGRect CTFontGetOpticalBoundsForGlyphs(CTFontRef font, const CGGlyph *glyphs, +@@ -310,6 +531,7 @@ CGRect CTFontGetOpticalBoundsForGlyphs(CTFontRef font, const CGGlyph *glyphs, CGRect *boundingRects, CFIndex count, CFOptionFlags options) { @@ -7167,7 +7170,7 @@ index 94ddabf9..fa018d3d 100644 printf("STUB %s\n", __PRETTY_FUNCTION__); return CGRectMake(0, 0, 0, 0); } -@@ -317,29 +538,34 @@ CGRect CTFontGetOpticalBoundsForGlyphs(CTFontRef font, const CGGlyph *glyphs, +@@ -317,29 +539,34 @@ CGRect CTFontGetOpticalBoundsForGlyphs(CTFontRef font, const CGGlyph *glyphs, void CTFontGetVerticalTranslationsForGlyphs(CTFontRef font, const CGGlyph *glyphs, CGSize *translations, CFIndex count) { @@ -7202,7 +7205,7 @@ index 94ddabf9..fa018d3d 100644 printf("STUB %s\n", __PRETTY_FUNCTION__); return nil; } -@@ -347,6 +573,7 @@ CFArrayRef CTFontCopyFeatureSettings(CTFontRef font) +@@ -347,6 +574,7 @@ CFArrayRef CTFontCopyFeatureSettings(CTFontRef font) bool CTFontGetGlyphsForCharacters(CTFontRef font, const UniChar *characters, CGGlyph *glyphs, CFIndex count) { @@ -7210,7 +7213,7 @@ index 94ddabf9..fa018d3d 100644 [font getGlyphs: glyphs forCharacters: characters length: count]; // FIXME: change getGlyphs: to return a BOOL return YES; -@@ -355,20 +582,36 @@ bool CTFontGetGlyphsForCharacters(CTFontRef font, const UniChar *characters, +@@ -355,20 +583,36 @@ bool CTFontGetGlyphsForCharacters(CTFontRef font, const UniChar *characters, void CTFontDrawGlyphs(CTFontRef font, const CGGlyph *glyphs, const CGPoint *positions, size_t count, CGContextRef context) { @@ -7250,7 +7253,7 @@ index 94ddabf9..fa018d3d 100644 } CTFontRef -@@ -381,6 +624,7 @@ CTFontCreateWithGraphicsFont(CGFontRef cgFont, CGFloat size, +@@ -381,6 +625,7 @@ CTFontCreateWithGraphicsFont(CGFontRef cgFont, CGFloat size, ATSFontRef CTFontGetPlatformFont(CTFontRef font, CTFontDescriptorRef _Nullable *attributes) { @@ -7258,7 +7261,7 @@ index 94ddabf9..fa018d3d 100644 printf("STUB %s\n", __PRETTY_FUNCTION__); return 0; } -@@ -402,14 +646,27 @@ CTFontRef CTFontCreateWithQuickdrawInstance(ConstStr255Param name, int16_t ident +@@ -402,14 +647,27 @@ CTFontRef CTFontCreateWithQuickdrawInstance(ConstStr255Param name, int16_t ident CFArrayRef CTFontCopyAvailableTables(CTFontRef font, CTFontTableOptions options) { diff --git a/tools/riot-coretext-font-list-check.m b/tools/riot-coretext-font-list-check.m index 1e0f537..28a16da 100644 --- a/tools/riot-coretext-font-list-check.m +++ b/tools/riot-coretext-font-list-check.m @@ -1,4 +1,5 @@ #import +#import #import #import #include @@ -34,6 +35,13 @@ int main(void) { /* The single-match result must outlive its temporary array. */ CFTypeRef name = CTFontDescriptorCopyAttribute(first, kCTFontNameAttribute); verifyFontList(name != NULL, "retained single descriptor and normalized PostScript name"); + /* Chromium pre-fills the language output with a sentinel and reads it when non-NULL. */ + CTFontRef font = CTFontCreateWithFontDescriptor(first, 12, NULL); + CFStringRef sentinel = CFSTR("sentinel"), language = sentinel; + CFStringRef familyName = CTFontCopyLocalizedName(font, kCTFontFamilyNameKey, &language); + verifyFontList(language != sentinel, "localized font name initializes language output"); + verifyFontList(familyName && CFEqual(familyName, (CFStringRef)family), "localized font name is the family"); + CFRelease(familyName); CFRelease(font); CFRelease(name); CFRelease(first); } NSDictionary *missing = [NSDictionary dictionaryWithObject:@"DarlingMissingFont-7259AEB1" forKey:(id)key]; From 57223d4c0ad7075dac4f4f0574929b421ce50e14 Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sun, 18 Oct 2026 13:35:15 +0200 Subject: [PATCH 070/160] fix(nix): simplify compiler setup and complete rootless session lifecycle --- docs/NIX.md | 229 ++++++++++++++++++++++++++++++++ docs/SETUP.md | 78 +++-------- flake.nix | 66 +++------ scripts/nix-compiler.sh | 8 ++ scripts/nix-copy.py | 204 +++++++++++++++++++++++----- scripts/nix-idle.py | 85 +++++++----- scripts/nix-lifecycle.py | 78 +++++++++++ scripts/nix-session.sh | 4 +- tests/test_nix_compilers.py | 52 ++++++++ tests/test_nix_copy_manifest.py | 101 ++++++++++++++ tests/test_nix_lifecycle.py | 90 +++++++++++++ tests/test_nix_repairs.py | 5 +- 12 files changed, 824 insertions(+), 176 deletions(-) create mode 100644 docs/NIX.md create mode 100644 scripts/nix-compiler.sh create mode 100644 scripts/nix-lifecycle.py create mode 100644 tests/test_nix_compilers.py create mode 100644 tests/test_nix_copy_manifest.py create mode 100644 tests/test_nix_lifecycle.py diff --git a/docs/NIX.md b/docs/NIX.md new file mode 100644 index 0000000..853d844 --- /dev/null +++ b/docs/NIX.md @@ -0,0 +1,229 @@ +# Nix setup + +The experimental Nix package provides a rootless build and launch environment +for x86_64 Linux. It supplies the dependencies, not a prebuilt Darling runtime +or game files. Initial setup builds Darling in a writable per-user workspace. + +## Prerequisites + +Enable Nix flakes and configure subordinate UID/GID ranges for your user. On +NixOS, add this to your system configuration and rebuild: + +```nix +users.users..autoSubUidGidRange = true; +``` + +On other Linux distributions, [install Nix using the official instructions](https://nixos.org/download/). +You do not need to install NixOS. If you already use `nix-shell`, Nix is installed. +Enable the modern CLI and flakes in `~/.config/nix/nix.conf`: + +```ini +experimental-features = nix-command flakes +``` + +Alternatively, enable them for an individual command: + +```sh +nix --extra-experimental-features 'nix-command flakes' run github:lucannez64/darling-lol +``` + +The rootless launcher requires unprivileged user namespaces, subordinate UID/GID +ranges, and working `newuidmap`/`newgidmap` helpers. If these are already configured +on your distribution, no additional host setup is needed. + +Run setup in an interactive terminal and allow roughly 40–50 GB for the runtime +build. + +## Install and launch + +Run the flake directly from GitHub; no local checkout is needed: + +```sh +nix run github:lucannez64/darling-lol +``` + +On first launch, setup prompts to build Darling and install the Riot client. +To explicitly open the installer, including for another region: + +```sh +nix run github:lucannez64/darling-lol#installer +``` + +Setup prompts before building. Subsequent launches reuse a compatible runtime; +they do not automatically rebuild it. If the package is installed in your +profile or system configuration, the equivalent commands are +`darling-lol --installer` and `darling-lol`. The desktop entry opens a terminal +for prompts and session output. + +Keep the terminal open while clients are running and until cleanup completes. +Default and installer sessions wait for clients to exit, then shut Darling down +in the same namespace. A failed shutdown is retried without discarding that +namespace. + +## Install permanently + +`nix run` does not add commands to your profile or install a desktop menu entry. +It does reuse downloaded store paths and the persistent Darling workspace. +For commands on your `PATH` and desktop-menu access, install the package using +one of the following methods. + +### User profile (NixOS or other Linux distributions) + +```sh +nix profile add github:lucannez64/darling-lol#default +``` + +Then launch from your application menu or terminal: + +```sh +darling-lol +# Explicit setup or runtime shell: +darling-lol --installer +darling-lol-shell +``` + +If the desktop entry does not appear, ensure your desktop session includes +`~/.nix-profile/share` in `XDG_DATA_DIRS`, then log out and back in. Non-NixOS +systems may need this desktop-session integration after installing Nix. + +To upgrade, find the package's name with `nix profile list`, then run +`nix profile upgrade `. Close the clients first; after upgrading, run +`darling-lol --update` to rebuild the mutable runtime when required. + +### NixOS system configuration + +In your system flake, add the input and pass it to your NixOS modules: + +```nix +inputs.darling-lol.url = "github:lucannez64/darling-lol"; + +outputs = inputs@{ nixpkgs, ... }: { + nixosConfigurations. = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + specialArgs = { inherit inputs; }; + modules = [ ./configuration.nix ]; + }; +}; +``` + +Merge these entries into your existing flake rather than replacing its outputs. +In `configuration.nix`, add the package and subordinate-ID configuration: + +```nix +{ inputs, pkgs, ... }: +{ + environment.systemPackages = [ + inputs.darling-lol.packages.${pkgs.stdenv.hostPlatform.system}.default + ]; + users.users..autoSubUidGidRange = true; + nix.settings.experimental-features = [ "nix-command" "flakes" ]; +} +``` + +Apply with `sudo nixos-rebuild switch --flake .#`. The launcher and +desktop entry are then installed system-wide; each user still has a separate +workspace and prefix. Start `darling-lol` as your normal user, not with sudo. + +To upgrade, update the input with `nix flake update darling-lol`, rebuild your +system, and run `darling-lol --update` after closing the clients. + +## Update + +Close League and both Riot clients and wait for the launcher to finish cleanup. +Then run the updated flake and rebuild the runtime: + +```sh +nix run --refresh github:lucannez64/darling-lol -- --update +# Installed package: darling-lol --update +``` + +This rebuilds incrementally, installs the base runtime into the user-owned +workspace, and refreshes the per-prefix overlays. No `sudo` is needed. + +## Local checkout and scripts + +You can also launch the flake directly from an already checked-out repository: + +```sh +cd /path/to/darling-lol +nix run . +``` + +Or clone it first: + +```sh +git clone https://github.com/lucannez64/darling-lol.git +cd darling-lol +nix run . +``` + +The same checkout also provides installer and update commands: + +```sh +nix run .#installer +# After updating the checkout: +nix run . -- --update +``` + +These commands work on NixOS and other Linux distributions with Nix installed. +Use `nix run` for this flake rather than `nix-shell`: the launcher sets up the +rootless namespace in addition to supplying dependencies. `nix develop` is for +development tools only; use `nix run .#shell` for an actual runtime shell. + +To run a repository script inside the rootless FHS environment, pass its command +after `--`. Commands run from the managed workspace, using the tools copied from +the selected flake: + +```sh +nix run github:lucannez64/darling-lol -- python3 scripts/validate.py +# Equivalent from a local checkout: +nix run . -- python3 scripts/validate.py +``` + +Use the supported installer/update commands for maintenance. Arbitrary script +commands do not get the default launcher's automatic client-session cleanup; if +a script starts Darling, shut it down in the same namespace before returning. +For multiple commands, use an interactive runtime shell: + +## Runtime shell + +```sh +nix run github:lucannez64/darling-lol#shell +# Local checkout: nix run .#shell +# Installed package: darling-lol-shell +``` + +Use the launcher-provided shell rather than entering the raw FHS environment. +If you start Darling manually in this shell, run `darling shutdown` there +before exiting. A separately launched shell cannot reconnect to an existing +Darling namespace. + +## Files and operational limits + +- Workspace: `${XDG_DATA_HOME:-$HOME/.local/share}/darling-lol`. +- Default guest prefix: `$HOME/.darling`; set `DPREFIX` to use another prefix. + Explicit `DPREFIX` and `RIOT_GUEST_HOME` overrides take precedence over saved + configuration, so clear stale overrides before switching installations. +- Package upgrades refresh managed tools and remove previously tracked obsolete + files. Builds, the installed runtime, guest prefixes, and unmanaged files are + preserved. Edit the source checkout rather than copied workspace tools. +- One active session is supported per workspace. A workspace lock and live + prefix marker prevent concurrent launches; the launcher does not kill an + existing session or reconnect across namespaces. Inspect an unexpected live + marker rather than deleting it to force a launch. +- Readiness records in `build/nix-ready/` validate the selected prefix, guest, + package, base runtime, and required overlays. A new prefix can reuse a + compatible base runtime; failed setup invalidates readiness. +- Keep `build/nix-gcroots/` while using the workspace. Mutable compiled binaries + can reference Nix-store libraries, so these roots retain the package closures + they need. Remove them only when discarding the dependent build outputs. + +The launcher uses subordinate-ID user namespaces before entering the FHS +environment. Mount privileges are scoped to that namespace, not host root; +workspace and prefix files retain the Linux user's ownership. The setup does +not install a host setuid binary or require a privileged runtime installation. + +Darling compilation still happens outside Nix derivations and uses the host CPU; +the resulting runtime is not a portable, binary-cacheable Nix package. The +FFmpeg dependency is pinned to 8.x because the generated AV wrappers require +its library ABI. diff --git a/docs/SETUP.md b/docs/SETUP.md index e0a7b18..5998a08 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -1,73 +1,33 @@ # Setup and portability -## NixOS package + -Use `darling-lol`, `darling-lol --installer`, or `darling-lol-shell` rather than -invoking the raw FHS environment. The launcher uses `unshare --map-auto` before -bubblewrap, so the user needs subordinate UID/GID ranges (`/etc/subuid` and -`/etc/subgid`, or NixOS `users.users..autoSubUidGidRange = true`). -Namespace root maps to a subordinate host ID, not host root. The FHS entrypoint -restores the real Linux user's IDs while retaining namespace-only capabilities; -the Darling shim temporarily switches effective IDs for mounts. No `sudo`, -root-owned binary in a writable workspace, or host setuid installation is needed. +## Nix package -The launcher refreshes managed scripts with executable bits and owner-write access, -rejecting symlinks in that managed tree. It preserves `build/`, `usr-local/`, and -the guest prefix. Edit the source checkout, not copied workspace tools. +See [prerequisites](NIX.md#prerequisites) before starting, and the +[Nix setup guide](NIX.md) for local checkout usage, updates, scripts, and runtime +details. -First setup: run `darling-lol --installer` in an interactive terminal, choose the -build when prompted, then choose the Riot region. The desktop entry uses -`Terminal=true` and delegates terminal selection to the desktop environment. -A launch with EOF stdin fails with a command to run in a terminal; it does not -try a hard-coded list of terminal emulators or silently abandon a prompt. +Run the experimental rootless package on x86_64 Linux: -Update: after manually exiting League and both Riot clients, shut down Darling -in the ORIGINAL `darling-lol-shell` terminal with `darling shutdown`, then exit -that shell. Upgrade the Nix package and run `darling-lol --update` in a terminal. -This rebuilds incrementally, installs into the user-owned `/usr/local` bind, -and installs paired per-prefix overlays without sudo. For a first-time setup, -the same rootless update path performs the base build/install. Native/manual -(non-Nix) instructions below still use their normal privileged base install. +```sh +nix --extra-experimental-features 'nix-command flakes' run github:lucannez64/darling-lol +``` -Readiness JSON under `build/nix-ready/` is keyed by canonical prefix and checks -the configured guest and resolved host guest home, UID/GID, package path, -maintained patch/tool identities, executable modes, base SDK/runtime metadata -and required-overlay inventory. Stamping reuses required-artifact validation. -Changing `RIOT_GUEST_HOME` invalidates readiness. Explicit `DPREFIX` and -`RIOT_GUEST_HOME` environment overrides take precedence over saved configuration; -clear stale values before selecting a different installation. A separate base -manifest avoids rebuilding when only a new prefix needs overlays. -Updates invalidate readiness before replacing the base; failed/interrupted setup -cannot leave the previous prefix stamp authoritative. Existing app bundles never -bypass these checks. Normal ready launches only check state, not rebuild. +To install permanently, adding the launcher to your user profile: -Operational limits: each workspace supports one foreground namespace session. -The launcher holds a lock while its child runs, even if bubblewrap closes file -descriptors. It refuses a live `.init.pid` rather than reconnecting across new -user namespaces or killing anything. A stale/reused live PID can therefore block -a launch conservatively; inspect it before taking manual action. Never remove a -marker just to force another launch. Manual stop/reconnect and game lifecycle -acceptance have not been exercised with this repaired package. Keep the original -shell open if you need to stop a session. Maintenance requires that prefix idle; -rootless CMake's shutdown hook checks only that prefix, never enumerates and -shuts down every user's launchd. The inherited scoped stop helper remains used -by overlay installers; it is fixture-tested, not live-game tested here. +```sh +nix profile add github:lucannez64/darling-lol#default +``` -Mutable Linux ELF outputs can retain Nix-store interpreter/library references. -A native compiler probe executed successfully with its store-path interpreter; -there is no evidence that all such outputs require `patchelf`. The update path -creates indirect closure roots under `build/nix-gcroots/` before compiling and -retains old package roots for incremental outputs. Keep those roots while using -the workspace; remove them only when discarding/rebuilding all dependent mutable -outputs. This trades disk retention for GC safety, not a claim of full runtime -relocatability. A full Darling build/upgrade and GC acceptance test remains open. +Then run `darling-lol` or use the desktop application menu. See +[permanent installation](NIX.md#install-permanently) for details. -The FFmpeg dependency is explicitly 8.x: generated Darling AV wrappers reference -`libavformat.so.62`, `libavcodec.so.62`, and `libavutil.so.60`; FFmpeg 9 cannot -replace those ABI versions. - -Run the package regression suite with `python3 -m unittest discover -s tests -v`. +First launch builds Darling and guides you through Riot client installation. +NixOS is not required; other Linux distributions with Nix installed are also +supported. +## Native setup notes The patch manifest pins Darling to `60ba801decee7a00782f74f6be4c8ffb013f79ff` and records every changed submodule's revision. `scripts/prepare.py` checks each revision before applying patches and never resets an existing checkout to make a patch apply. Re-running it accepts an already-applied patch. diff --git a/flake.nix b/flake.nix index 4fe1a74..54f5ec8 100644 --- a/flake.nix +++ b/flake.nix @@ -10,6 +10,7 @@ let system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; + llvmPackages = pkgs.llvmPackages_21; # Python environment with dependencies required by tooling pythonEnv = pkgs.python3.withPackages ( @@ -36,10 +37,9 @@ gnumake ninja pkg-config - clang - lld - llvm - llvm.dev + llvmPackages.clang + llvmPackages.lld + llvmPackages.llvm llvmPackages.clang-unwrapped bison flex @@ -54,16 +54,11 @@ # System & core libraries glibc - glibc.dev glibc.static zlib - zlib.dev libffi - libffi.dev libbsd - libbsd.dev libmd - libmd.dev libcap libelf fuse @@ -131,8 +126,7 @@ darlingLolFhs = pkgs.buildFHSEnv { name = "darling-lol-fhs"; targetPkgs = pkgs: buildDeps; - multiPkgs = pkgs: [ pkgs.glibc ]; - + # multiArch is disabled; glibc is already in targetPkgs and the FHS base. extraOutputsToInstall = [ "dev" "out" ]; extraBwrapArgs = [ @@ -143,44 +137,18 @@ ]; extraBuildCommands = '' - mkdir -p bin usr/bin - rm -f bin/clang bin/clang++ usr/bin/clang usr/bin/clang++ - ln -sfn /usr-local $out/usr/local - cat << 'EOF' > usr/bin/clang -#!/usr/bin/env bash -is_darwin=0 -for a in "$@"; do - if [[ "$a" == *darwin* ]] || [[ "$a" == *apple* ]] || [[ "$a" == *macosx* ]]; then - is_darwin=1 - break - fi -done -if [[ "$is_darwin" -eq 1 ]]; then - exec ${pkgs.llvmPackages.clang-unwrapped}/bin/clang "$@" -else - exec ${pkgs.clang}/bin/clang -idirafter /usr/include "$@" -fi -EOF - chmod +x usr/bin/clang - - cat << 'EOF' > usr/bin/clang++ -#!/usr/bin/env bash -is_darwin=0 -for a in "$@"; do - if [[ "$a" == *darwin* ]] || [[ "$a" == *apple* ]] || [[ "$a" == *macosx* ]]; then - is_darwin=1 - break - fi -done -if [[ "$is_darwin" -eq 1 ]]; then - exec ${pkgs.llvmPackages.clang-unwrapped}/bin/clang++ "$@" -else - exec ${pkgs.clang}/bin/clang++ -idirafter /usr/include "$@" -fi -EOF - chmod +x usr/bin/clang++ - cp -f usr/bin/clang bin/clang - cp -f usr/bin/clang++ bin/clang++ + mkdir -p "$out/usr/bin" + rm -f "$out/usr/bin/clang" "$out/usr/bin/clang++" + ln -sfn /usr-local "$out/usr/local" + { + printf '%s\n' '#!/bin/sh' \ + 'wrapped=${llvmPackages.clang}/bin' \ + 'unwrapped=${llvmPackages.clang-unwrapped}/bin' + cat ${./scripts/nix-compiler.sh} + } > "$out/usr/bin/clang" + chmod +x "$out/usr/bin/clang" + ln -s clang "$out/usr/bin/clang++" + # /bin is already a guest-absolute symlink to /usr/bin. ''; profile = '' diff --git a/scripts/nix-compiler.sh b/scripts/nix-compiler.sh new file mode 100644 index 0000000..91178c6 --- /dev/null +++ b/scripts/nix-compiler.sh @@ -0,0 +1,8 @@ +# Invoked under clang or clang++: keep the driver's original name. +driver=${0##*/} +for arg in "$@"; do + case "$arg" in + *darwin*|*apple*|*macosx*) exec "$unwrapped/$driver" "$@" ;; + esac +done +exec "$wrapped/$driver" -idirafter /usr/include "$@" diff --git a/scripts/nix-copy.py b/scripts/nix-copy.py index 17874cb..a7eb2bd 100644 --- a/scripts/nix-copy.py +++ b/scripts/nix-copy.py @@ -1,44 +1,178 @@ #!/usr/bin/env python3 -"""Refresh managed package files without following mutable destination symlinks.""" +"""Refresh tracked package files, never recursively deleting workspace data. + +Legacy workspaces have no ownership inventory: adopt only files in the current +package, preserving unknown legacy files rather than guessing what is disposable. +The manifest is untrusted input, not authority to delete arbitrary workspace paths. +This guards pre-existing links; concurrent hostile workspace mutation is unsupported. +""" +import errno +import json import os -from pathlib import Path +from pathlib import Path, PurePosixPath import shutil import stat import sys import tempfile -source, destination = map(Path, sys.argv[1:]) -items = 'docs patches research scripts tests tools upstream LICENSE NOTICE.md README.md requirements.txt'.split() -# Preflight the entire managed tree before writing anything. Never chmod or copy -# through a link planted by a previous installation (including directory links). -for root in [destination, *destination.parents]: - if root.is_symlink(): - raise SystemExit(f'Refusing symlink workspace ancestor: {root}') -for item in items: - for base in [source / item, destination / item]: - for path in [base, *base.rglob('*')] if base.is_dir() else [base]: - if path.is_symlink(): - raise SystemExit(f'Refusing symlink in managed package tree: {path}') - if path.exists() and not (path.is_file() or path.is_dir()): - raise SystemExit(f'Refusing special file in managed package tree: {path}') -for item in items: - src = source / item - if not src.exists(): - continue - for path in [src, *src.rglob('*')] if src.is_dir() else [src]: - target = destination / path.relative_to(source) - if path.is_dir(): - target.mkdir(parents=True, exist_ok=True) - target.chmod(0o755) +ITEMS = 'docs patches research scripts tests tools upstream LICENSE NOTICE.md README.md requirements.txt'.split() +DIRECTORY_ITEMS = set(ITEMS[:7]) +MANIFEST = '.nix-managed-files.json' + + +def refuse(message): + raise ValueError(message) + + +def check_ancestors(path): + for ancestor in [path, *path.parents]: + if ancestor.is_symlink(): + refuse(f'Refusing symlink workspace ancestor: {ancestor}') + + +def inventory(root): + files, directories = set(), set() + + def visit(path): + mode = path.lstat().st_mode + relative = path.relative_to(root).as_posix() + if stat.S_ISREG(mode): + files.add(relative) + elif stat.S_ISDIR(mode): + directories.add(relative) + for child in sorted(path.iterdir()): + visit(child) else: - target.parent.mkdir(parents=True, exist_ok=True) - fd, temporary = tempfile.mkstemp(prefix='.package-', dir=target.parent) + refuse(f'Refusing symlink or special file in managed package tree: {path}') + + for item in ITEMS: + path = root / item + if path.exists() or path.is_symlink(): + if path.is_dir() != (item in DIRECTORY_ITEMS) and not path.is_symlink(): + refuse(f'Invalid managed root type: {path}') + visit(path) + return files, directories + + +def load_manifest(path): + if path.is_symlink(): + refuse(f'Refusing symlink manifest: {path}') + if not path.exists(): + return set(), set() + if not path.is_file(): + refuse(f'Refusing special manifest: {path}') + data = json.loads(path.read_text()) + if (not isinstance(data, dict) or set(data) != {'version', 'files', 'directories'} + or type(data['version']) is not int or data['version'] != 1): + refuse('Invalid managed-file manifest schema') + result = [] + for field in ('files', 'directories'): + entries = data[field] + if not isinstance(entries, list): + refuse(f'Invalid manifest {field}') + validated = set() + for name in entries: + if not isinstance(name, str): + refuse('Non-string manifest path') + parts = PurePosixPath(name).parts + if not parts: + refuse('Empty manifest path') + continue + if (name != '/'.join(parts) or '\\' in name or '\x00' in name + or any(part in ('.', '..', '/') for part in parts) + or parts[0] not in ITEMS + or (len(parts) > 1 and parts[0] not in DIRECTORY_ITEMS) + or (field == 'directories' and parts[0] not in DIRECTORY_ITEMS)): + refuse(f'Unsafe manifest path: {name!r}') + if name in validated: + refuse(f'Duplicate manifest path: {name}') + validated.add(name) + result.append(validated) + files, directories = result + if files & directories: + refuse('Conflicting manifest file/directory paths') + for name in files | directories: + if any(parent.as_posix() in files for parent in PurePosixPath(name).parents): + refuse(f'Manifest file used as directory: {name}') + return files, directories + + +def atomic_write(target, writer, mode): + fd, temporary = tempfile.mkstemp(prefix='.package-', dir=target.parent) + try: + with os.fdopen(fd, 'wb') as output: + writer(output) + os.chmod(temporary, mode) + os.replace(temporary, target) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def refresh(source, destination): + for root in (source, destination): + if '..' in root.parts: + refuse(f'Refusing path traversal: {root}') + check_ancestors(root) + source, destination = source.absolute(), destination.absolute() + if not source.is_dir(): + refuse(f'Missing package source: {source}') + if source == destination or source in destination.parents or destination in source.parents: + refuse('Package source and workspace must not overlap') + old_files, old_directories = load_manifest(destination / MANIFEST) + files, directories = inventory(source) + present_files, present_directories = inventory(destination) + + # Validate ownership/type conflicts before copying or pruning anything. + if old_files & present_directories or old_directories & present_files: + refuse('Tracked path changed type in workspace') + stale_files = old_files - files + stale_directories = old_directories - directories + if directories & (present_files - stale_files): + refuse('Unmanaged file blocks package directory') + replaced_directories = files & present_directories + for name in replaced_directories: + if name not in stale_directories: + refuse(f'Unmanaged directory blocks package file: {name}') + prefix = name + '/' + if (any(p.startswith(prefix) and p not in stale_files for p in present_files) + or any(p.startswith(prefix) and p not in stale_directories for p in present_directories)): + refuse(f'Unmanaged data blocks directory replacement: {name}') + + for name in sorted(stale_files): + if name in present_files: + (destination / name).unlink() + # rmdir only: a user-added child always keeps its containing tree alive. + for name in sorted(stale_directories, key=lambda p: (-p.count('/'), p)): + if name in present_directories: try: - with os.fdopen(fd, 'wb') as output, path.open('rb') as input_file: - shutil.copyfileobj(input_file, output) - # Keep executable bits, remove special bits, and keep owner writable. - os.chmod(temporary, stat.S_IMODE(path.stat().st_mode) & 0o777 | 0o600) - os.replace(temporary, target) - finally: - if os.path.exists(temporary): - os.unlink(temporary) + (destination / name).rmdir() + except OSError as error: + if error.errno not in (errno.ENOTEMPTY, errno.EEXIST): + raise + destination.mkdir(parents=True, exist_ok=True) + for name in sorted(directories, key=lambda p: (p.count('/'), p)): + target = destination / name + target.mkdir(parents=True, exist_ok=True) + target.chmod(0o755) + for name in sorted(files): + path, target = source / name, destination / name + target.parent.mkdir(parents=True, exist_ok=True) + + def copy(output): + with path.open('rb') as input_file: + shutil.copyfileobj(input_file, output) + + atomic_write(target, copy, (stat.S_IMODE(path.stat().st_mode) & 0o777) | 0o600) + state = json.dumps({'version': 1, 'files': sorted(files), + 'directories': sorted(directories)}, indent=2) + '\n' + atomic_write(destination / MANIFEST, lambda output: output.write(state.encode()), 0o600) + + +if __name__ == '__main__': + try: + if len(sys.argv) != 3: + refuse('Usage: nix-copy.py SOURCE DESTINATION') + refresh(*map(Path, sys.argv[1:])) + except (ValueError, OSError) as error: + raise SystemExit(f'Package refresh refused: {error}') diff --git a/scripts/nix-idle.py b/scripts/nix-idle.py index 8ce0dc0..b827ffd 100644 --- a/scripts/nix-idle.py +++ b/scripts/nix-idle.py @@ -3,34 +3,59 @@ import os from pathlib import Path -prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))).resolve() -try: - pid = int((prefix / '.init.pid').read_text().strip()) -except FileNotFoundError: - pid = 0 -except (OSError, ValueError): - raise SystemExit('Cannot validate prefix server marker; inspect it before maintenance.') -if pid > 0: - try: - status = (Path('/proc') / str(pid) / 'status').read_text() - if not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines()): - raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling shutdown in the ORIGINAL namespace before updating. No process was stopped.') - except FileNotFoundError: - pass -# Only inspect same-user mappings under this exact prefix. An unrelated game's -# command name is not grounds to stop or modify it. Unreadable same-user maps -# fail closed because we cannot rule out a client still using these files. -paths = [str(prefix / name) + '/' for name in ( - 'Applications/Riot Client.app', 'Applications/League of Legends.app', - 'Users/Shared/Riot Games')] -for process in Path('/proc').iterdir(): - if not process.name.isdigit(): - continue - try: - if process.stat().st_uid != os.getuid(): + +def client_active(prefix, *, processes=None): + """Check exact-prefix mappings; only vanished processes may be skipped.""" + paths = [str(prefix / name) + '/' for name in ( + 'Applications/Riot Client.app', 'Applications/League of Legends.app', + 'Users/Shared/Riot Games')] + namespace_map = Path('/proc/self/uid_map').read_text() + for process in Path('/proc').iterdir() if processes is None else processes: + if not process.name.isdigit(): continue - mappings = (process / 'maps').read_text() - if any(path in mappings for path in paths): - raise SystemExit('A client maps this prefix; exit it before rootless maintenance. No process was stopped.') - except (FileNotFoundError, ProcessLookupError, PermissionError): - continue + try: + if process.stat().st_uid != os.getuid(): + continue + # The outer live-marker gate excludes earlier Darling namespaces. + # Host processes are not ours to inspect; their maps are commonly + # unreadable from a child user namespace (including systemd --user). + if processes is None and (process / 'uid_map').read_text() != namespace_map: + continue + mappings = (process / 'maps').read_text() + if any(path in mappings for path in paths): + return True + except (FileNotFoundError, ProcessLookupError): + # Process exited between enumeration and inspection. + continue + except OSError as error: + raise RuntimeError(f'Cannot inspect process {process.name}; cannot establish that the prefix is idle: {error}') from error + return False + + +def main(): + prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))).resolve() + try: + pid = int((prefix / '.init.pid').read_text().strip()) + except FileNotFoundError: + pid = 0 + except (OSError, ValueError): + raise SystemExit('Cannot validate prefix server marker; inspect it before maintenance.') + if pid > 0: + try: + status = (Path('/proc') / str(pid) / 'status').read_text() + if not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines()): + raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling shutdown in the ORIGINAL namespace before updating. No process was stopped.') + except (FileNotFoundError, ProcessLookupError): + pass + except OSError as error: + raise SystemExit(f'Cannot validate prefix server marker: {error}') from error + try: + active = client_active(prefix) + except (OSError, RuntimeError) as error: + raise SystemExit(str(error)) from error + if active: + raise SystemExit('A client maps this prefix; exit it before rootless maintenance. No process was stopped.') + + +if __name__ == '__main__': + main() diff --git a/scripts/nix-lifecycle.py b/scripts/nix-lifecycle.py new file mode 100644 index 0000000..598ad00 --- /dev/null +++ b/scripts/nix-lifecycle.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Keep namespace-local shutdown authority until the foreground and clients exit. + +Never kill by name or use a marker PID as authority to signal a host process. +Uninspectable mappings retain this namespace rather than guessing it is idle. +""" +import importlib.util +import os +from pathlib import Path +import signal +import subprocess +import sys +import time + + +def main(): + scripts = Path(__file__).resolve().parent + spec = importlib.util.spec_from_file_location('nix_idle', scripts / 'nix-idle.py') + assert spec is not None and spec.loader is not None + idle = importlib.util.module_from_spec(spec) + spec.loader.exec_module(idle) + prefix = Path(os.environ['DPREFIX']).resolve() + child = None + interrupted = 0 + + def interrupted_by(signum, _frame): + nonlocal interrupted + interrupted = 128 + signum + # Only our own foreground child, never a marker PID or host-wide search. + if child is not None and child.poll() is None: + try: + child.send_signal(signum) + except ProcessLookupError: + pass + + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(signum, interrupted_by) + result = 1 + try: + child = subprocess.Popen(sys.argv[1:]) + result = child.wait() + if result < 0: + result = 128 - result + except OSError as error: + print(f'Cannot start client: {error}', file=sys.stderr) + finally: + # A launcher can return while guest clients remain. Do not tear down + # their server, or discard the only namespace capable of shutting it down. + last_error = None + while True: + try: + if not idle.client_active(prefix): + break + last_error = None + except (OSError, RuntimeError) as error: + if str(error) != last_error: + print(f'{error}; retaining original namespace and retrying. ' + 'Exit clients to allow safe shutdown.', file=sys.stderr) + last_error = str(error) + time.sleep(1) + while True: + try: + shutdown = subprocess.run(['bash', str(scripts / 'darling-rootless.sh'), 'shutdown']).returncode + except OSError as error: + print(f'Cannot run namespace-local shutdown: {error}', file=sys.stderr) + shutdown = 1 + if not shutdown: + break + print(f'Darling namespace-local shutdown failed (status {shutdown}); ' + 'retaining the original namespace and retrying in 5 seconds. ' + 'Keep this terminal open.', file=sys.stderr) + time.sleep(5) + result = interrupted or result + return result + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/nix-session.sh b/scripts/nix-session.sh index 9626465..f9fca2d 100755 --- a/scripts/nix-session.sh +++ b/scripts/nix-session.sh @@ -27,6 +27,6 @@ if [ "${1:-}" = --installer ] || [ ! -d "$DPREFIX/Users/Shared/Riot Games/Riot Client.app" ]; }; then interactive read -r -p 'Region [euw]: ' region || exit 1 - exec ./scripts/installer.sh "${region:-euw}" + exec python3 scripts/nix-lifecycle.py ./scripts/installer.sh "${region:-euw}" fi -exec ./scripts/run.sh +exec python3 scripts/nix-lifecycle.py ./scripts/run.sh diff --git a/tests/test_nix_compilers.py b/tests/test_nix_compilers.py new file mode 100644 index 0000000..a7a900d --- /dev/null +++ b/tests/test_nix_compilers.py @@ -0,0 +1,52 @@ +"""Direct regression for FHS compiler wrapper installation and dispatch.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class CompilerTests(unittest.TestCase): + def test_build_hook_and_compiler_dispatch(self): + with tempfile.TemporaryDirectory(prefix="nix-compilers-") as tmp: + root = Path(tmp) + out = root / "out" + cwd = root / "build" + llvm = root / "llvm" + for directory in (out / "usr/bin", out / "usr/lib", cwd, llvm / "bin"): + directory.mkdir(parents=True) + for name in ("clang", "clang++"): + target = llvm / "bin" / name + target.write_text("#!/bin/sh\nprintf '%s\\n' \"$0\" \"$@\"\n") + target.chmod(0o755) + (out / "usr/bin" / name).symlink_to(target) + flake = (ROOT / "flake.nix").read_text() + hook = flake.split("extraBuildCommands = ''", 1)[1].split("\n '';", 1)[0] + for variable in ("llvmPackages.clang", "llvmPackages.clang-unwrapped"): + hook = hook.replace("${" + variable + "}", str(llvm)) + hook = hook.replace("${./scripts/nix-compiler.sh}", str(ROOT / "scripts/nix-compiler.sh")) + hook = hook.replace("''${", "${") + result = subprocess.run(["bash", "-eu", "-c", hook], cwd=cwd, + env={**os.environ, "out": str(out)}, + text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(list(cwd.iterdir()), [], "hook must operate on $out, not cwd") + for name, args, expected in ( + ("clang", ["-c", "source with spaces.c"], + ["-idirafter", "/usr/include", "-c", "source with spaces.c"]), + ("clang++", ["-target", "x86_64-apple-darwin", "source.cc"], + ["-target", "x86_64-apple-darwin", "source.cc"]), + ): + wrapper = out / "usr/bin" / name + self.assertFalse(wrapper.is_symlink() and wrapper.resolve().is_relative_to(llvm)) + script = wrapper.read_text().replace("/usr/bin/", str(out / "usr/bin") + "/") + wrapper.write_text(script) + result = subprocess.run([str(wrapper), *args], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), [str(llvm / "bin" / name), *expected]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nix_copy_manifest.py b/tests/test_nix_copy_manifest.py new file mode 100644 index 0000000..d87f69c --- /dev/null +++ b/tests/test_nix_copy_manifest.py @@ -0,0 +1,101 @@ +"""Managed refresh regressions; all installations are disposable fixtures.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +COPY = ROOT / 'scripts/nix-copy.py' +MANIFEST = '.nix-managed-files.json' + + +class ManagedCopyTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR')) + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.source = self.root / 'source' + self.destination = self.root / 'workspace' + self.source.mkdir() + self.destination.mkdir() + self.put(self.source, 'scripts/current.sh', 'current') + + def put(self, root, name, content='user data'): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return path + + def refresh(self, success=True, source=None, destination=None): + result = subprocess.run([sys.executable, str(COPY), str(source or self.source), + str(destination or self.destination)], + capture_output=True, text=True) + if success: + self.assertEqual(result.returncode, 0, result.stderr) + else: + self.assertNotEqual(result.returncode, 0) + return result + + def manifest(self, files=(), directories=()): + path = self.destination / MANIFEST + path.write_text(json.dumps({'version': 1, 'files': list(files), + 'directories': list(directories)})) + return path + + def test_refresh_prunes_removed_files_and_empty_directories(self): + stale = self.put(self.source, 'scripts/obsolete/nested/old.sh') + self.put(self.source, 'NOTICE.md') + self.refresh() + stale.unlink() + stale.parent.rmdir() + stale.parent.parent.rmdir() + (self.source / 'NOTICE.md').unlink() + self.refresh() + self.assertFalse((self.destination / 'scripts/obsolete').exists()) + self.assertFalse((self.destination / 'NOTICE.md').exists()) + self.assertEqual((self.destination / 'scripts/current.sh').read_text(), 'current') + state = json.loads((self.destination / MANIFEST).read_text()) + self.assertEqual(state['files'], ['scripts/current.sh']) + self.assertEqual(state['directories'], ['scripts']) + + def test_removed_tree_retains_unmanaged_children_and_mutable_roots(self): + self.put(self.source, 'docs/old.txt') + self.refresh() + (self.source / 'docs/old.txt').unlink() + (self.source / 'docs').rmdir() + preserved = ['docs/personal.txt', 'scripts/local.sh', 'build/cache', + 'usr-local/bin/tool', 'prefix/game/save', 'other/data'] + for name in preserved: + self.put(self.destination, name) + self.refresh() + self.assertFalse((self.destination / 'docs/old.txt').exists()) + for name in preserved: + self.assertEqual((self.destination / name).read_text(), 'user data') + + def test_migration_preserves_unknown_legacy_files_then_tracks_new_files(self): + self.put(self.destination, 'scripts/legacy.sh') + self.refresh() + self.assertTrue((self.destination / MANIFEST).is_file()) + (self.source / 'scripts/current.sh').unlink() + self.refresh() + self.assertTrue((self.destination / 'scripts/legacy.sh').is_file()) + self.assertFalse((self.destination / 'scripts/current.sh').exists()) + + + + + + + + + + + + + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_nix_lifecycle.py b/tests/test_nix_lifecycle.py new file mode 100644 index 0000000..72ebd3e --- /dev/null +++ b/tests/test_nix_lifecycle.py @@ -0,0 +1,90 @@ +"""Inert fixtures: no real Darling execution or live prefix modification.""" +import importlib.util +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import MagicMock + +ROOT = Path(__file__).resolve().parents[1] + + +class IdleTests(unittest.TestCase): + def test_unreadable_maps_fail_closed_but_disappearing_process_is_skipped(self): + spec = importlib.util.spec_from_file_location('nix_idle_fixture', ROOT / 'scripts/nix-idle.py') + assert spec is not None and spec.loader is not None + idle = importlib.util.module_from_spec(spec) + # Older versions run on import: point even those at an inert prefix. + with tempfile.TemporaryDirectory() as folder: + from unittest.mock import patch + with patch.dict(os.environ, DPREFIX=folder): + spec.loader.exec_module(idle) + process = MagicMock() + process.name = '123' + process.stat.return_value.st_uid = os.getuid() + (process / 'maps').read_text.side_effect = PermissionError('denied') + with self.assertRaisesRegex(RuntimeError, 'Cannot inspect'): + idle.client_active(Path('/fixture/prefix'), processes=[process]) + (process / 'maps').read_text.side_effect = FileNotFoundError() + self.assertFalse(idle.client_active(Path('/fixture/prefix'), processes=[process])) + + +class LifecycleTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + scripts = self.root / 'scripts' + scripts.mkdir() + for name in ('nix-session.sh', 'nix-lifecycle.py'): + if (ROOT / 'scripts' / name).exists(): + shutil.copy(ROOT / 'scripts' / name, scripts / name) + self.log = self.root / 'log' + self.env = dict(os.environ, DARLING_WORKSPACE=str(self.root), + DPREFIX=str(self.root / 'prefix'), TEST_LOG=str(self.log)) + (self.root / 'prefix/Applications/Riot Client.app').mkdir(parents=True) + (scripts / 'nix-state.py').write_text('raise SystemExit(0)\n') + (scripts / 'nix-idle.py').write_text('''import os +calls = 0 +def client_active(prefix, **kwargs): + global calls + calls += 1 + with open(os.environ['TEST_LOG'], 'a') as f: + f.write('scan\\n') + return calls < 2 +''') + (scripts / 'darling-rootless.sh').write_text('''#!/bin/bash +echo "shutdown:$*" >> "$TEST_LOG" +if [ "${SHUTDOWN_RC:-0}" != 0 ] && [ ! -e "$TEST_LOG.retried" ]; then + touch "$TEST_LOG.retried" + exit "$SHUTDOWN_RC" +fi +exit 0 +''') + client = scripts / 'run.sh' + client.write_text('''#!/usr/bin/env bash +echo client-finished >> "$TEST_LOG" +exit "${CLIENT_RC:-0}" +'''.replace('#!/usr/bin/env bash', '#!' + str(shutil.which('bash')))) + client.chmod(0o755) + + def run_session(self, **environment): + return subprocess.run(['bash', str(self.root / 'scripts/nix-session.sh')], + env=dict(self.env, **environment), text=True, + capture_output=True, timeout=10) + + def test_exit_waits_for_command_and_clients_before_shutdown(self): + result = self.run_session() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.log.read_text().splitlines(), + ['client-finished', 'scan', 'scan', 'shutdown:shutdown']) + + def test_command_failure_preserved_and_shutdown_failure_reported(self): + result = self.run_session(CLIENT_RC='17', SHUTDOWN_RC='23') + self.assertEqual(result.returncode, 17, result.stderr) + self.assertIn('shutdown failed', result.stderr) + self.assertIn('23', result.stderr) + self.assertEqual(self.log.read_text().splitlines().count('shutdown:shutdown'), 2) + self.assertIn('shutdown:shutdown', self.log.read_text()) diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index f0ee4f5..1a6e8c4 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -397,8 +397,11 @@ class SessionTests(unittest.TestCase): with tempfile.TemporaryDirectory(prefix='nix-dispatch-test-') as directory: root = Path(directory) (root / 'scripts').mkdir() - for name in ['nix-state.py', 'nix-session.sh']: + for name in ['nix-state.py', 'nix-session.sh', 'nix-lifecycle.py']: shutil.copy(ROOT / 'scripts' / name, root / 'scripts' / name) + # Keep lifecycle dispatch inert: no real /proc scan or Darling call. + (root / 'scripts/nix-idle.py').write_text('def client_active(prefix): return False\n') + (root / 'scripts/darling-rootless.sh').write_text('exit 0\n') (root / 'usr-local/bin').mkdir(parents=True) (root / 'usr-local/bin/darling').write_text('never executed') (root / 'usr-local/bin/darlingserver').write_text('never executed') From 6909c7f83b4687cb0db0efe006fff9f82b1726c5 Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Sun, 18 Oct 2026 13:51:58 +0200 Subject: [PATCH 071/160] fix(nix): address review feedback on setup and runtime safety --- docs/NIX.md | 2 ++ docs/SETUP.md | 2 ++ scripts/installer.sh | 3 ++- scripts/nix-lifecycle.py | 22 +++++++++++++++------- scripts/nix-state.py | 6 ++++-- upstream/manifest.json | 1 + 6 files changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/NIX.md b/docs/NIX.md index 853d844..197120d 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -71,6 +71,8 @@ one of the following methods. ```sh nix profile add github:lucannez64/darling-lol#default +# Older Nix/Lix versions without `profile add`: +# nix profile install github:lucannez64/darling-lol#default ``` Then launch from your application menu or terminal: diff --git a/docs/SETUP.md b/docs/SETUP.md index 5998a08..d427c62 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -18,6 +18,8 @@ To install permanently, adding the launcher to your user profile: ```sh nix profile add github:lucannez64/darling-lol#default +# Older Nix/Lix versions without `profile add`: +# nix profile install github:lucannez64/darling-lol#default ``` Then run `darling-lol` or use the desktop application menu. See diff --git a/scripts/installer.sh b/scripts/installer.sh index 48f607c..9bbadbc 100755 --- a/scripts/installer.sh +++ b/scripts/installer.sh @@ -8,7 +8,8 @@ if [ -n "${DARLING_WORKSPACE:-}" ] && ! python3 scripts/nix-state.py check; then fi export PATH="${DARLING_RUNNER_DIR:+$DARLING_RUNNER_DIR:}$repo/usr-local/bin:/usr/local/bin:$PATH" region=${1:-euw} -case "$region" in euw|eune|na|br|kr|jp|lan|las|oce|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac +case "$region" in lan) region=la1;; las) region=la2;; oce) region=oc1;; esac +case "$region" in euw|eune|na|br|kr|jp|la1|la2|oc1|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac python3 scripts/configure.py >/dev/null bash build/runtime/tools/fetch-installer.sh "$region" build/installer unzip -q -o "build/installer/live.$region.zip" -d build/installer diff --git a/scripts/nix-lifecycle.py b/scripts/nix-lifecycle.py index 598ad00..321ef07 100644 --- a/scripts/nix-lifecycle.py +++ b/scripts/nix-lifecycle.py @@ -13,6 +13,14 @@ import sys import time +def warn(message): + """Diagnostics must not discard the namespace after a terminal hangup.""" + try: + print(message, file=sys.stderr, flush=True) + except OSError: + pass + + def main(): scripts = Path(__file__).resolve().parent spec = importlib.util.spec_from_file_location('nix_idle', scripts / 'nix-idle.py') @@ -42,7 +50,7 @@ def main(): if result < 0: result = 128 - result except OSError as error: - print(f'Cannot start client: {error}', file=sys.stderr) + warn(f'Cannot start client: {error}') finally: # A launcher can return while guest clients remain. Do not tear down # their server, or discard the only namespace capable of shutting it down. @@ -54,21 +62,21 @@ def main(): last_error = None except (OSError, RuntimeError) as error: if str(error) != last_error: - print(f'{error}; retaining original namespace and retrying. ' - 'Exit clients to allow safe shutdown.', file=sys.stderr) + warn(f'{error}; retaining original namespace and retrying. ' + 'Exit clients to allow safe shutdown.') last_error = str(error) time.sleep(1) while True: try: shutdown = subprocess.run(['bash', str(scripts / 'darling-rootless.sh'), 'shutdown']).returncode except OSError as error: - print(f'Cannot run namespace-local shutdown: {error}', file=sys.stderr) + warn(f'Cannot run namespace-local shutdown: {error}') shutdown = 1 if not shutdown: break - print(f'Darling namespace-local shutdown failed (status {shutdown}); ' - 'retaining the original namespace and retrying in 5 seconds. ' - 'Keep this terminal open.', file=sys.stderr) + warn(f'Darling namespace-local shutdown failed (status {shutdown}); ' + 'retaining the original namespace and retrying in 5 seconds. ' + 'Keep this terminal open.') time.sleep(5) result = interrupted or result return result diff --git a/scripts/nix-state.py b/scripts/nix-state.py index e52dff4..a9dbab5 100644 --- a/scripts/nix-state.py +++ b/scripts/nix-state.py @@ -71,10 +71,12 @@ def inventory(root): for directory, dirs, files in os.walk(root, followlinks=False, onerror=enumeration_error): + excluded = ('.before-', '.rt-', '.llvm') + dirs[:] = sorted(name for name in dirs if not any(token in name for token in excluded)) for name in sorted(dirs + files): - path = Path(directory) / name - if any(token in path.name for token in ('.before-', '.rt-', '.llvm')): + if any(token in name for token in excluded): continue + path = Path(directory) / name info = path.lstat() target_str = '' if path.is_symlink(): diff --git a/upstream/manifest.json b/upstream/manifest.json index 8a83a47..7ad829f 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -245,6 +245,7 @@ "include/Metal/MTLTrace.h", "private-include/Metal/MTLArgumentInternal.h", "private-include/Metal/MTLBlitCommandEncoderInternal.h", + "private-include/Metal/MTLDeviceInfo.h", "private-include/Metal/MTLDepthStencilInternal.h", "private-include/Metal/MTLSamplerStateInternal.h", "private-include/Metal/MTLVertexDescriptorInternal.h", From 8dc17b8e806d40949aecaf64d141549df79d9477 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:54:18 +0200 Subject: [PATCH 072/160] test(nix): cover region routing, stable readiness and lost terminals --- tests/test_installer_regions.py | 33 ++++++++++++++++++++++++++++++ tests/test_nix_lifecycle.py | 21 ++++++++++++++++++- tests/test_nix_runtime_identity.py | 33 ++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/test_installer_regions.py diff --git a/tests/test_installer_regions.py b/tests/test_installer_regions.py new file mode 100644 index 0000000..5d06082 --- /dev/null +++ b/tests/test_installer_regions.py @@ -0,0 +1,33 @@ +"""Stop at a stub fetcher: verify installer routing without network or runtime use.""" +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class InstallerRegions(unittest.TestCase): + def test_aliases_and_platform_codes_reach_the_expected_cdn_region(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + (root / 'scripts').mkdir() + (root / 'build/runtime/tools').mkdir(parents=True) + shutil.copy(ROOT / 'scripts/installer.sh', root / 'scripts') + (root / 'scripts/configure.py').write_text('') + (root / 'build/runtime/tools/fetch-installer.sh').write_text( + 'printf "%s" "$1" > "$TEST_REGION_LOG"\nexit 23\n') + log = root / 'region' + env = dict(os.environ, TEST_REGION_LOG=str(log)) + env.pop('DARLING_WORKSPACE', None) + for region, expected in [('lan', 'la1'), ('las', 'la2'), ('oce', 'oc1'), + ('la1', 'la1'), ('la2', 'la2'), ('oc1', 'oc1'), + ('euw', 'euw')]: + with self.subTest(region=region): + log.unlink(missing_ok=True) + result = subprocess.run(['sh', str(root / 'scripts/installer.sh'), region], + env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 23, result.stderr) + self.assertEqual(log.read_text(), expected) diff --git a/tests/test_nix_lifecycle.py b/tests/test_nix_lifecycle.py index 72ebd3e..f339e07 100644 --- a/tests/test_nix_lifecycle.py +++ b/tests/test_nix_lifecycle.py @@ -6,7 +6,7 @@ import shutil import subprocess import tempfile import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch ROOT = Path(__file__).resolve().parents[1] @@ -88,3 +88,22 @@ exit "${CLIENT_RC:-0}" self.assertIn('23', result.stderr) self.assertEqual(self.log.read_text().splitlines().count('shutdown:shutdown'), 2) self.assertIn('shutdown:shutdown', self.log.read_text()) + + def test_shutdown_retries_when_terminal_diagnostics_fail(self): + spec = importlib.util.spec_from_file_location('lifecycle_fixture', self.root / 'scripts/nix-lifecycle.py') + lifecycle = importlib.util.module_from_spec(spec) + spec.loader.exec_module(lifecycle) + child = MagicMock() + child.wait.return_value = 17 + stderr = MagicMock() + stderr.write.side_effect = OSError('terminal disconnected') + with patch.dict(os.environ, self.env), \ + patch.object(lifecycle.signal, 'signal'), \ + patch.object(lifecycle.subprocess, 'Popen', return_value=child), \ + patch.object(lifecycle.subprocess, 'run', side_effect=[ + subprocess.CompletedProcess([], 23), subprocess.CompletedProcess([], 0)]) as shutdown, \ + patch.object(lifecycle.time, 'sleep'), \ + patch.object(lifecycle.sys, 'stderr', stderr): + self.assertEqual(lifecycle.main(), 17) + self.assertEqual(shutdown.call_count, 2) + stderr.write.assert_called() diff --git a/tests/test_nix_runtime_identity.py b/tests/test_nix_runtime_identity.py index e292085..e58448c 100644 --- a/tests/test_nix_runtime_identity.py +++ b/tests/test_nix_runtime_identity.py @@ -106,6 +106,39 @@ class RuntimeIdentityTests(unittest.TestCase): env.pop('DPREFIX') self.assertEqual(self.state('check', env).returncode, 0) + def test_inventory_ignores_changes_inside_backup_directories(self): + spec = importlib.util.spec_from_file_location('inventory_fixture', self.root / 'scripts/nix-state.py') + state = importlib.util.module_from_spec(spec) + spec.loader.exec_module(state) + runtime = self.root / 'usr-local/libexec/darling' + for suffix in ('.before-test', '.rt-test', '.llvm-test'): + backup = runtime / ('backup' + suffix) / 'nested' + backup.mkdir(parents=True) + saved = backup / 'saved.dylib' + saved.write_text('old backup') + before = state.inventory(runtime) + saved.write_text('changed backup') + self.assertEqual(state.inventory(runtime), before) + + def test_inventory_is_independent_of_filesystem_directory_order(self): + from unittest.mock import patch + spec = importlib.util.spec_from_file_location('inventory_fixture', self.root / 'scripts/nix-state.py') + state = importlib.util.module_from_spec(spec) + spec.loader.exec_module(state) + runtime = self.root / 'usr-local/libexec/darling' + for name in ('a', 'b'): + folder = runtime / name + folder.mkdir() + (folder / 'file').write_text(name) + original_walk = os.walk + def reversed_walk(*args, **kwargs): + for directory, dirs, files in original_walk(*args, **kwargs): + dirs.reverse() + yield directory, dirs, files + before = state.inventory(runtime) + with patch.object(state.os, 'walk', reversed_walk): + self.assertEqual(state.inventory(runtime), before) + if __name__ == '__main__': unittest.main() From a5ceabb8e83c0eedb51d2fb73cf4700484e95b77 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:59:49 +0200 Subject: [PATCH 073/160] fix(nvidia): retain readback, resize and bounded completion fixes Rebuild the remaining NVIDIA changes on current main, preserving the merged visual, draw-buffer, descriptor ownership and mixed Metal-tree fixes. Scope native-thread forwarding to resize notifications and make completion polling per device, bounded and part of the caller timeout budget. Co-authored-by: Pirrandi --- docs/TROUBLESHOOTING.md | 24 ++++ patches/cocotron.patch | 30 ++-- patches/darling.patch | 16 +++ patches/metal--deps--indium.patch | 109 ++++++++++++--- patches/xnu.patch | 21 ++- tests/test_nvidia_runtime.py | 199 +++++++++++++++++++++++++++ tools/riot-ca-render-policy-check.mm | 19 +++ upstream/manifest.json | 5 +- 8 files changed, 384 insertions(+), 39 deletions(-) create mode 100644 tests/test_nvidia_runtime.py diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 6c24424..0cc5bcf 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -252,6 +252,30 @@ A non-zero error or `FAILED` means the frames cannot reach the window. Before 20 An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Confirmation on the reporter's NVIDIA system is still needed. +### NVIDIA resize signals, CPU readback and completion latency + +The EGL-compatible window visual, compositor back buffer and collision-safe RPC +socket allocation fixes are already part of the runtime. The remaining NVIDIA +work covers these independent paths: + +- A terminal resize can deliver `SIGWINCH` to a native driver thread without a + Darling RPC socket. The runtime forwards that resize notification to the guest + main thread; it keeps the existing handling for other signals and faults. +- With `RCLOL_GPU_DIRECT_PRESENT=0`, Chromium frames arrive through CPU readback. + The local compositor skips non-Metal trees to avoid covering the live client + with stale cached textures. Mixed trees still prepare and present Metal layers. +- NVIDIA timeline waits can wake late after GPU completion. Each device now polls + briefly while completions are pending before blocking: 2000 microseconds by + default on NVIDIA and zero on other vendors. An idle device blocks immediately. + The guest file `/tmp/metal-wait-spin-us` overrides the budget when the device is + created; `0` disables polling. Values are capped at 10000 microseconds and + malformed values use the vendor default. Restart Darling to change the budget. + +The contributor reported custom matches on a single-GPU RTX 3060, driver 615.71, +with sway/Xwayland using CPU readback. The initial polling change improved their +reported rate from about 20 to 27 FPS. Those observations predate the narrowed +integration; they do not establish hybrid-GPU or general NVIDIA support. + ### Host-side checks These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index b70d394..351e394 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -8756,10 +8756,10 @@ index 5dcac115..5de4f03f 100644 + @end diff --git a/QuartzCore/CALayerContext.m b/QuartzCore/CALayerContext.m -index 5dce8c56..d325ba5f 100644 +index 5dce8c5..3a7f937 100644 --- a/QuartzCore/CALayerContext.m +++ b/QuartzCore/CALayerContext.m -@@ -1,47 +1,69 @@ +@@ -1,47 +1,77 @@ #import +#import #import @@ -8799,6 +8799,14 @@ index 5dce8c56..d325ba5f 100644 + for(CALayer *child in layer.sublayers) + if(hasFullWindowSurface(child,size))return YES; + return NO; ++} ++/* CPU readback already presents non-Metal trees remotely. Locally ++ * compositing their cached textures can leave a frozen child over the client. ++ * Metal trees must always prepare their drawables, including mixed trees. */ ++static BOOL skipLocalRender(CALayer *layer, CGSize size) { ++ return layer && getenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER") && ++ !hasMetalLayer(layer) && ++ (!getenv("RCLOL_GPU_DIRECT_PRESENT") || hasFullWindowSurface(layer, size)); +} @implementation CALayerContext @@ -8848,7 +8856,7 @@ index 5dce8c56..d325ba5f 100644 [super dealloc]; } -@@ -51,12 +73,22 @@ +@@ -51,12 +81,22 @@ } - (void) setLayer: (CALayer *) layer { @@ -8871,7 +8879,7 @@ index 5dce8c56..d325ba5f 100644 } - (void) setSubwindow: (CGSubWindow*) subwindow -@@ -68,16 +100,50 @@ +@@ -68,16 +108,50 @@ } _subwindow = [subwindow retain]; @@ -8923,7 +8931,7 @@ index 5dce8c56..d325ba5f 100644 } - (void) assignTextureIdsToLayerTree: (CALayer *) layer { -@@ -93,8 +159,27 @@ +@@ -93,8 +167,27 @@ [self assignTextureIdsToLayerTree: child]; } @@ -8951,7 +8959,7 @@ index 5dce8c56..d325ba5f 100644 glEnable(GL_DEPTH_TEST); glShadeModel(GL_SMOOTH); -@@ -118,27 +203,63 @@ +@@ -118,27 +211,62 @@ [_deleteTextureIds removeAllObjects]; @@ -8975,8 +8983,7 @@ index 5dce8c56..d325ba5f 100644 - (void) render { + if (_cglWindow == NULL || _layer == nil) + return; -+ if(getenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER") && -+ !hasMetalLayer(_layer) && hasFullWindowSurface(_layer,_frame.size))return; ++ if(skipLocalRender(_layer, _frame.size))return; [self renderLayer: _layer]; } @@ -9020,7 +9027,7 @@ index 5dce8c56..d325ba5f 100644 } - (void) startTimerIfNeeded { -@@ -147,7 +268,7 @@ +@@ -147,7 +275,7 @@ target: self selector: @selector(timer:) userInfo: nil @@ -9029,13 +9036,12 @@ index 5dce8c56..d325ba5f 100644 } - (void) deleteTextureId: (NSNumber *) textureId { -@@ -155,7 +276,10 @@ +@@ -155,7 +283,9 @@ } - (void) flush { - CGLFlushDrawable(_glContext); -+ if(getenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER") && _layer && -+ !hasMetalLayer(_layer) && hasFullWindowSurface(_layer,_frame.size))return; ++ if(skipLocalRender(_layer, _frame.size))return; + if (_glContext != NULL && _cglWindow != NULL) + CGLFlushDrawable(_glContext); } diff --git a/patches/darling.patch b/patches/darling.patch index b399000..66477f9 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -10706,3 +10706,19 @@ index 0000000..1f3c473 + } +} +#endif +diff --git a/src/startup/mldr/elfcalls/threads.c b/src/startup/mldr/elfcalls/threads.c +index e789986..0d56b4e 100644 +--- a/src/startup/mldr/elfcalls/threads.c ++++ b/src/startup/mldr/elfcalls/threads.c +@@ -387,8 +387,9 @@ int __darling_thread_rpc_socket(void) { + // this is the main thread + t_server_socket = __dserver_main_thread_socket_fd; + } else { +- // threads should already have a per-thread socket assigned when they're created +- abort(); ++ // Native host-library threads do not have a Darling RPC connection. ++ // Let the caller report EPIPE, including the resize-signal bridge. ++ return -1; + } + } + return t_server_socket; diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index 09cd9a4..9ae8d9b 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -781,7 +781,7 @@ index 8546daa..7e4993c 100644 void updateBindings(); diff --git a/private-include/indium/device.private.hpp b/private-include/indium/device.private.hpp -index 58f1794..be7dbdc 100644 +index 58f1794..1f4bd5a 100644 --- a/private-include/indium/device.private.hpp +++ b/private-include/indium/device.private.hpp @@ -13,6 +13,8 @@ @@ -793,7 +793,7 @@ index 58f1794..be7dbdc 100644 namespace Indium { class PrivateDevice; -@@ -25,6 +27,8 @@ namespace Indium { +@@ -25,8 +27,11 @@ namespace Indium { class PrivateDevice: public Device, public std::enable_shared_from_this { private: @@ -801,8 +801,11 @@ index 58f1794..be7dbdc 100644 + std::vector> _scratchFree; std::mutex _eventLoopMutex; std::mutex _pollingMutex; ++ uint64_t _completionSpinNanoseconds = 0; -@@ -59,6 +63,8 @@ namespace Indium { + // these are stored separately (rather than in a single structure) + // so that we can easily copy them individually in the event loop polling function. +@@ -59,6 +64,8 @@ namespace Indium { } virtual std::string name() const override; @@ -811,7 +814,7 @@ index 58f1794..be7dbdc 100644 virtual std::shared_ptr newCommandQueue() override; virtual std::shared_ptr newRenderPipelineState(const RenderPipelineDescriptor& descriptor) override; virtual std::shared_ptr newComputePipelineState(const ComputePipelineDescriptor& descriptor, PipelineOption options, std::shared_ptr reflection) override; -@@ -73,6 +79,12 @@ namespace Indium { +@@ -73,6 +80,12 @@ namespace Indium { virtual void pollEvents(uint64_t timeoutNanoseconds) override; virtual void wakeupEventLoop() override; @@ -824,7 +827,7 @@ index 58f1794..be7dbdc 100644 void waitForSemaphore(VkSemaphore semaphore, uint64_t targetValue, std::function callback); /** -@@ -86,6 +98,11 @@ namespace Indium { +@@ -86,6 +99,11 @@ namespace Indium { * Similar to getTimelineSemaphore(), but wraps it in a shared pointer * that automatically releases the semaphore when all references to it die out. */ @@ -836,7 +839,7 @@ index 58f1794..be7dbdc 100644 std::shared_ptr getWrappedTimelineSemaphore(); BinarySemaphore getBinarySemaphore(bool exportable = false); -@@ -111,5 +128,34 @@ namespace Indium { +@@ -111,5 +129,34 @@ namespace Indium { INDIUM_PROPERTY(VkPhysicalDeviceMemoryProperties, m, M,emoryProperties); INDIUM_PROPERTY_READONLY(Feature, f, F,eatures); @@ -2835,17 +2838,20 @@ index 5f62ad0..5957bec 100644 DynamicVK::vkCmdBindDescriptorSets(buf->commandBuffer(), VK_PIPELINE_BIND_POINT_COMPUTE, _pso->layout(), 0, descriptorSets.size(), descriptorSets.data(), 0, nullptr); }; diff --git a/src/indium/device.cpp b/src/indium/device.cpp -index 322e186..78b77ae 100644 +index 322e186..4da420a 100644 --- a/src/indium/device.cpp +++ b/src/indium/device.cpp -@@ -1,4 +1,6 @@ +@@ -1,4 +1,9 @@ ++#include ++#include ++#include #include +#include +#include #include #include #include -@@ -12,39 +14,207 @@ +@@ -12,39 +17,207 @@ #include @@ -3059,7 +3065,7 @@ index 322e186..78b77ae 100644 continue; } -@@ -67,12 +237,105 @@ void Indium::initGlobalDeviceList() { +@@ -67,12 +240,105 @@ void Indium::initGlobalDeviceList() { DynamicVK::vkGetPhysicalDeviceFeatures2(device, &features); if (!features12.timelineSemaphore) { @@ -3155,11 +3161,11 @@ index 322e186..78b77ae 100644 + } + creationOrder.push_back(i); + } -+ } + } + + for (size_t index: creationOrder) { + globalDeviceList.push_back(std::make_shared(candidates[index].device)); - } ++ } + + appendDeviceLog("created VkDevice for " + std::to_string(globalDeviceList.size()) + " of " + + std::to_string(candidates.size()) + " supported device(s)" @@ -3167,7 +3173,7 @@ index 322e186..78b77ae 100644 }; void Indium::finitGlobalDeviceList() { -@@ -83,7 +346,8 @@ Indium::Device::~Device() {}; +@@ -83,8 +349,16 @@ Indium::Device::~Device() {}; Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): _physicalDevice(physicalDevice) @@ -3175,9 +3181,17 @@ index 322e186..78b77ae 100644 +{ Indium::SubmitThread::ensureStarted(); + DynamicVK::vkGetPhysicalDeviceProperties(_physicalDevice, &_properties); ++ _completionSpinNanoseconds = completionSpinBudget(_properties.vendorID, nullptr); ++ if (FILE* file = fopen("/tmp/metal-wait-spin-us", "r")) { ++ char value[64] = {}; ++ if (fscanf(file, "%63s", value) == 1) ++ _completionSpinNanoseconds = completionSpinBudget(_properties.vendorID, value); ++ fclose(file); ++ } std::vector queueFamilies; -@@ -233,6 +497,36 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): + uint32_t count = 0; +@@ -233,6 +507,36 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): } } @@ -3214,7 +3228,7 @@ index 322e186..78b77ae 100644 VkDeviceCreateInfo deviceCreateInfo {}; deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; deviceCreateInfo.pQueueCreateInfos = queueCreateInfos.data(); -@@ -246,6 +540,7 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): +@@ -246,6 +550,7 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): } _features = indiumFeatures; @@ -3222,7 +3236,7 @@ index 322e186..78b77ae 100644 for (const auto& index: queueFamilyIndices) { VkQueue queue; -@@ -297,6 +592,9 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): +@@ -297,6 +602,9 @@ Indium::PrivateDevice::PrivateDevice(VkPhysicalDevice physicalDevice): }; Indium::PrivateDevice::~PrivateDevice() { @@ -3232,7 +3246,7 @@ index 322e186..78b77ae 100644 if (_oneshotCommandPool) { DynamicVK::vkDestroyCommandPool(_device, _oneshotCommandPool, nullptr); } -@@ -308,6 +606,23 @@ std::string Indium::PrivateDevice::name() const { +@@ -308,6 +616,23 @@ std::string Indium::PrivateDevice::name() const { return _properties.deviceName; }; @@ -3256,7 +3270,7 @@ index 322e186..78b77ae 100644 std::shared_ptr Indium::PrivateDevice::newCommandQueue() { return std::make_shared(shared_from_this()); }; -@@ -317,9 +632,9 @@ std::shared_ptr Indium::PrivateDevice::newRenderPip +@@ -317,9 +642,9 @@ std::shared_ptr Indium::PrivateDevice::newRenderPip }; std::shared_ptr Indium::PrivateDevice::newComputePipelineState(const ComputePipelineDescriptor& descriptor, PipelineOption options, std::shared_ptr reflection) { @@ -3269,7 +3283,7 @@ index 322e186..78b77ae 100644 return std::make_shared(shared_from_this(), descriptor); }; -@@ -357,6 +672,7 @@ std::shared_ptr Indium::PrivateDevice::newLibrary(const void* d +@@ -357,6 +682,7 @@ std::shared_ptr Indium::PrivateDevice::newLibrary(const void* d break; } @@ -3277,7 +3291,34 @@ index 322e186..78b77ae 100644 funcInfo.bindings.insert(funcInfo.bindings.end(), info.bindings.begin(), info.bindings.end()); funcInfo.embeddedSamplers.insert(funcInfo.embeddedSamplers.end(), info.embeddedSamplers.begin(), info.embeddedSamplers.end()); } -@@ -480,9 +796,18 @@ void Indium::PrivateDevice::waitForSemaphore(VkSemaphore semaphore, uint64_t tar +@@ -396,6 +722,26 @@ void Indium::PrivateDevice::pollEvents(uint64_t timeoutNanoseconds) { + + lock.unlock(); + ++ // NVIDIA's blocking timeline wait can wake 1-2 ms after completion. Poll ++ // briefly while work is pending; an idle device should block immediately. ++ if (_completionSpinNanoseconds && timeoutNanoseconds && semaphores.size() > 1) { ++ const auto started = std::chrono::steady_clock::now(); ++ const auto deadline = started + std::chrono::nanoseconds( ++ std::min(_completionSpinNanoseconds, timeoutNanoseconds)); ++ bool anyReady = false; ++ do { ++ for (size_t i = 0; i < semaphores.size() && !anyReady; ++i) { ++ uint64_t count = 0; ++ anyReady = DynamicVK::vkGetSemaphoreCounterValue(_device, semaphores[i], &count) == VK_SUCCESS && count >= values[i]; ++ } ++ if (anyReady) break; ++ __builtin_ia32_pause(); ++ } while (std::chrono::steady_clock::now() < deadline); ++ const uint64_t elapsed = std::chrono::duration_cast( ++ std::chrono::steady_clock::now() - started).count(); ++ timeoutNanoseconds = anyReady ? 0 : timeoutNanoseconds - std::min(timeoutNanoseconds, elapsed); ++ } ++ + VkSemaphoreWaitInfo info {}; + info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO; + info.semaphoreCount = semaphores.size(); +@@ -480,9 +826,18 @@ void Indium::PrivateDevice::waitForSemaphore(VkSemaphore semaphore, uint64_t tar wakeupEventLoop(); }; @@ -3297,7 +3338,7 @@ index 322e186..78b77ae 100644 VkSemaphoreTypeCreateInfo typeInfo {}; typeInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO; typeInfo.semaphoreType = VK_SEMAPHORE_TYPE_TIMELINE; -@@ -505,7 +830,14 @@ Indium::TimelineSemaphore Indium::PrivateDevice::getTimelineSemaphore() { +@@ -505,7 +860,14 @@ Indium::TimelineSemaphore Indium::PrivateDevice::getTimelineSemaphore() { }; void Indium::PrivateDevice::putTimelineSemaphore(const TimelineSemaphore& semaphore) { @@ -3313,7 +3354,7 @@ index 322e186..78b77ae 100644 }; std::shared_ptr Indium::PrivateDevice::getWrappedTimelineSemaphore() { -@@ -551,3 +883,160 @@ std::shared_ptr Indium::PrivateDevice::getWrappedBinary +@@ -551,3 +913,160 @@ std::shared_ptr Indium::PrivateDevice::getWrappedBinary delete ptr; }); }; @@ -8767,3 +8808,27 @@ index 1b5876e..03af560 100644 }; void Iridium::DynamicLLVM::finit() { +diff --git a/private-include/indium/completion-spin.private.hpp b/private-include/indium/completion-spin.private.hpp +new file mode 100644 +index 0000000..962fdad +--- /dev/null ++++ b/private-include/indium/completion-spin.private.hpp +@@ -0,0 +1,18 @@ ++#pragma once ++#include ++ ++namespace Indium { ++// A malformed override uses the vendor default. Saturation bounds both CPU use ++// and the microseconds-to-nanoseconds conversion, even for arbitrarily long input. ++inline uint64_t completionSpinBudget(uint32_t vendorID, const char* overrideValue) { ++ const uint64_t fallback = vendorID == 0x10de ? 2000000 : 0; ++ if (!overrideValue || !*overrideValue) return fallback; ++ uint64_t microseconds = 0; ++ for (const char* p = overrideValue; *p; ++p) { ++ if (*p < '0' || *p > '9') return fallback; ++ microseconds = microseconds * 10 + (*p - '0'); ++ if (microseconds > 10000) microseconds = 10000; ++ } ++ return microseconds * 1000; ++} ++} diff --git a/patches/xnu.patch b/patches/xnu.patch index be0c967..efbf7e3 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -1576,7 +1576,7 @@ index dc8fbb8..171746a 100644 + __linux_sched_yield_site("SWTYLD"); +} diff --git a/darling/src/libsystem_kernel/emulation/src/linux_premigration/signal/sigexc.c b/darling/src/libsystem_kernel/emulation/src/linux_premigration/signal/sigexc.c -index 206e92e..0646cb7 100644 +index 206e92e..1a763fe 100644 --- a/darling/src/libsystem_kernel/emulation/src/linux_premigration/signal/sigexc.c +++ b/darling/src/libsystem_kernel/emulation/src/linux_premigration/signal/sigexc.c @@ -151,8 +151,13 @@ void sigrt_handler(int signum, struct linux_siginfo* info, struct linux_ucontext @@ -1595,7 +1595,7 @@ index 206e92e..0646cb7 100644 } if (signum == SIGNAL_SIGEXC_SUSPEND) { -@@ -267,13 +272,150 @@ static void state_from_kernel(struct linux_ucontext* ctxt, const void* tstate, c +@@ -267,13 +272,165 @@ static void state_from_kernel(struct linux_ucontext* ctxt, const void* tstate, c #endif } @@ -1672,10 +1672,25 @@ index 206e92e..0646cb7 100644 +} + +#endif ++ ++/* Native driver threads have no Darling RPC connection. Forward resize ++ * notifications to the guest main thread without allocating or using RPC. ++ * Other signals, including synchronous faults and queued payloads, retain ++ * their existing handling. Never swallow a failed forward. */ ++static int forward_native_resize_signal(int linux_signum, int status) { ++ if (status != -32 /* -LINUX_EPIPE */ || linux_signum != LINUX_SIGWINCH) ++ return 0; ++ long pid = LINUX_SYSCALL(__NR_getpid); ++ if (LINUX_SYSCALL(__NR_gettid) == pid) ++ return 0; ++ return LINUX_SYSCALL(__NR_tgkill, pid, pid, linux_signum) == 0; ++} + void sigexc_handler(int linux_signum, struct linux_siginfo* info, struct linux_ucontext* ctxt) { int status = dserver_rpc_interrupt_enter(); ++ if (forward_native_resize_signal(linux_signum, status)) ++ return; + int relaxed = 0; + +#ifdef __x86_64__ @@ -1747,7 +1762,7 @@ index 206e92e..0646cb7 100644 } kern_printf("sigexc_handler(%d, %p, %p)\n", linux_signum, info, ctxt); -@@ -359,7 +501,13 @@ void sigexc_handler(int linux_signum, struct linux_siginfo* info, struct linux_u +@@ -359,7 +516,13 @@ void sigexc_handler(int linux_signum, struct linux_siginfo* info, struct linux_u sizeof(sa.sa_mask)); // Resend signal to self diff --git a/tests/test_nvidia_runtime.py b/tests/test_nvidia_runtime.py new file mode 100644 index 0000000..aaa37dd --- /dev/null +++ b/tests/test_nvidia_runtime.py @@ -0,0 +1,199 @@ +"""Compile the patched native-thread signal bridge and completion budget policy.""" +import shutil +import subprocess +from pathlib import Path +import tempfile +import unittest +import test_runtime_compatibility as sources + + +class NativeThreadChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + run_c = sources.CompatibilityChecks.run_c + + def test_resize_forwarding_is_limited_to_foreign_threads_and_successful_delivery(self): + source = self.patched('xnu', 'darling/src/libsystem_kernel/emulation/src/linux_premigration/signal/sigexc.c') + bridge = sources.function(source, 'forward_native_resize_signal') + self.run_c(''' +#include +#define LINUX_SIGWINCH 28 +#define __NR_getpid 1 +#define __NR_gettid 2 +#define __NR_tgkill 3 +static long tid=101, result=0; +static int sends; +static long fake_syscall(int number, ...) { + if (number==__NR_getpid) return 100; + if (number==__NR_gettid) return tid; + ++sends; return result; +} +#define LINUX_SYSCALL(...) fake_syscall(__VA_ARGS__) +''' + bridge + ''' +int main(void) { + assert(forward_native_resize_signal(28,-32)==1 && sends==1); + assert(forward_native_resize_signal(28,0)==0 && sends==1); + assert(forward_native_resize_signal(28,-9)==0 && sends==1); + for(int sig=1;sig<=64;++sig) + if(sig!=28) assert(forward_native_resize_signal(sig,-32)==0); + assert(sends==1); + tid=100; assert(forward_native_resize_signal(28,-32)==0 && sends==1); + tid=101; result=-3; + assert(forward_native_resize_signal(28,-32)==0 && sends==2); +} +''') + + def test_native_thread_without_rpc_socket_returns_an_error_instead_of_aborting(self): + source = self.patched('darling', 'src/startup/mldr/elfcalls/threads.c') + self.run_c(''' +#include +#include +#define SYS_gettid 1 +static int t_server_socket=-1, __dserver_main_thread_socket_fd=71; +static int tid=101; +static int getpid(void) { return 100; } +static int syscall(int number) { return tid; } +''' + sources.function(source, '__darling_thread_rpc_socket') + ''' +int main(void) { + assert(__darling_thread_rpc_socket()==-1); + t_server_socket=73; assert(__darling_thread_rpc_socket()==73); + t_server_socket=-1; tid=100; assert(__darling_thread_rpc_socket()==71); +} +''') + + def test_cpu_readback_skips_cached_surfaces_but_preserves_mixed_metal_trees(self): + source = self.patched('cocotron', 'QuartzCore/CALayerContext.m') + self.run_c('''#define _POSIX_C_SOURCE 200809L +#include +#include +typedef int BOOL; +typedef struct { int metal, full; } CALayer; +typedef struct { int width, height; } CGSize; +static BOOL hasMetalLayer(CALayer* layer) { return layer->metal; } +static BOOL hasFullWindowSurface(CALayer* layer, CGSize size) { return layer->full; } +''' + sources.function(source, 'skipLocalRender') + ''' +int main(void) { + CGSize size={16,16}; + assert(!skipLocalRender(NULL,size)); + for(int shortcut=0;shortcut<=1;++shortcut) + for(int direct=0;direct<=1;++direct) + for(int metal=0;metal<=1;++metal) + for(int full=0;full<=1;++full) { + CALayer layer={metal,full}; + if(shortcut) setenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER","1",1); + else unsetenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER"); + if(direct) setenv("RCLOL_GPU_DIRECT_PRESENT","1",1); + else unsetenv("RCLOL_GPU_DIRECT_PRESENT"); + assert(!!skipLocalRender(&layer,size)==(shortcut && !metal && (!direct || full))); + } +} +''') + + +@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required') +class CompletionBudgetChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def test_vendor_defaults_disable_clamping_and_malformed_overrides(self): + header = self.patched('metal--deps--indium', 'private-include/indium/completion-spin.private.hpp') + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) + (path / 'budget.hpp').write_text(header) + (path / 'check.cpp').write_text(''' +#include "budget.hpp" +#include +#include +int main() { + using Indium::completionSpinBudget; + assert(completionSpinBudget(0x1002,nullptr)==0); + assert(completionSpinBudget(0x10de,nullptr)==2000000); + assert(completionSpinBudget(0x1002,nullptr)==0); + assert(completionSpinBudget(0x10de,"0")==0); + assert(completionSpinBudget(0x1002,"15")==15000); + assert(completionSpinBudget(0x10de,"10001")==10000000); + const std::string huge(10000,'9'); + assert(completionSpinBudget(0x10de,huge.c_str())==10000000); + for(const char* invalid : {"", "-1", "1ms", "18446744073709551616x"}) { + assert(completionSpinBudget(0x10de,invalid)==2000000); + assert(completionSpinBudget(0x1002,invalid)==0); + } +} +''') + subprocess.run(['c++','-std=c++17',str(path/'check.cpp'),'-o',str(path/'check')], check=True, capture_output=True) + subprocess.run([str(path/'check')], check=True, capture_output=True, timeout=10) + + def test_polling_preserves_idle_blocking_callbacks_and_timeout_budget(self): + source = self.patched('metal--deps--indium', 'src/indium/device.cpp') + poll = sources.function(source, 'Indium::PrivateDevice::pollEvents') + harness = ''' +#include +#include +#include +#include +#include +#include +#include +#include +using VkSemaphore = int; +struct VkSemaphoreWaitInfo { + int sType, flags; + size_t semaphoreCount; + const VkSemaphore* pSemaphores; + const uint64_t* pValues; +}; +constexpr int VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO=1, VK_SEMAPHORE_WAIT_ANY_BIT=1; +constexpr int VK_SUCCESS=0, VK_TIMEOUT=1; +static int counterQueries, waitResult=VK_TIMEOUT; +static uint64_t lastTimeout; +static bool ready; +namespace DynamicVK { +int vkGetSemaphoreCounterValue(int, int semaphore, uint64_t* count) { + ++counterQueries; *count=ready && semaphore==2 ? 1 : 0; return VK_SUCCESS; +} +int vkWaitSemaphores(int, const VkSemaphoreWaitInfo*, uint64_t timeout) { + lastTimeout=timeout; return waitResult; +} +} +namespace Indium { +class PrivateDevice { +public: + std::mutex _pollingMutex, _eventLoopMutex; + int _device=0; + uint64_t _completionSpinNanoseconds=1000000; + std::vector _eventLoopSemaphores={1}; + std::vector _eventLoopWaitValues={1}; + std::vector> _eventLoopCallbacks={nullptr}; + void pollEvents(uint64_t timeoutNanoseconds); +}; +} +''' + poll + ''' +int main() { + Indium::PrivateDevice device; + device.pollEvents(2000000); + assert(counterQueries==0 && lastTimeout==2000000); + int callbacks=0; + device._eventLoopSemaphores.push_back(2); + device._eventLoopWaitValues.push_back(1); + device._eventLoopCallbacks.push_back([&]{++callbacks;}); + device.pollEvents(2000000); + assert(counterQueries>0 && lastTimeout<2000000 && callbacks==0); + ready=true; waitResult=VK_SUCCESS; + device.pollEvents(2000000); + assert(lastTimeout==0 && callbacks==1); + assert(device._eventLoopSemaphores.size()==1); + counterQueries=0; waitResult=VK_TIMEOUT; + device._eventLoopSemaphores.push_back(2); + device._eventLoopWaitValues.push_back(1); + device._eventLoopCallbacks.push_back(nullptr); + device.pollEvents(0); + assert(counterQueries==0 && lastTimeout==0); + device._completionSpinNanoseconds=0; + device.pollEvents(2000000); + assert(counterQueries==0 && lastTimeout==2000000); +} +''' + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) + (path / 'poll.cpp').write_text(harness) + subprocess.run(['c++', '-std=c++17', '-pthread', str(path/'poll.cpp'), '-o', str(path/'poll')], + check=True, capture_output=True) + subprocess.run([str(path/'poll')], check=True, capture_output=True, timeout=10) diff --git a/tools/riot-ca-render-policy-check.mm b/tools/riot-ca-render-policy-check.mm index 5d3d23a..ad21b97 100644 --- a/tools/riot-ca-render-policy-check.mm +++ b/tools/riot-ca-render-policy-check.mm @@ -38,6 +38,7 @@ int main() { // No NSApplication, window, graphics context, GPU work, or published IOSurface. // Objects intentionally live until process exit; the sentinel is never passed to CGL. [NSAutoreleasePool new]; + unsetenv("RCLOL_GPU_DIRECT_PRESENT"); int dimensions[]={16,16,4,64}; const void* keys[]={kIOSurfaceWidth,kIOSurfaceHeight,kIOSurfaceBytesPerElement,kIOSurfaceBytesPerRow}; const void* values[4]; @@ -77,6 +78,24 @@ int main() { [surfaceOnly render]; printf("surface_only_shortcut_enabled: renderCalls=%u expected=0\n",renderCalls); if(renderCalls!=0) return 6; + // Partial IOSurfaces bypass local rendering only in CPU-readback mode. + int partialDimensions[]={8,8,4,32}; + for(unsigned i=0;i<4;++i) values[i]=CFNumberCreate(NULL,kCFNumberIntType,&partialDimensions[i]); + CFDictionaryRef partialProperties=CFDictionaryCreate(NULL,keys,values,4,&kCFTypeDictionaryKeyCallBacks,&kCFTypeDictionaryValueCallBacks); + IOSurfaceRef partial=IOSurfaceCreate(partialProperties); + if(!partial) return 8; + RenderPolicySurfaceLayer* partialLayer=[[RenderPolicySurfaceLayer alloc] initWithSurface:partial]; + RenderPolicyContext* partialContext=[[RenderPolicyContext alloc] initWithRoot:partialLayer]; + renderCalls=0; + [partialContext render]; + if(renderCalls!=0) return 9; + setenv("RCLOL_GPU_DIRECT_PRESENT","1",1); + [partialContext render]; + if(renderCalls!=1) return 10; + unsetenv("DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER"); + unsetenv("RCLOL_GPU_DIRECT_PRESENT"); + [partialContext render]; + if(renderCalls!=2) return 11; printf("PASS: mixed-tree Metal preparation survives IOSurface shortcut\n"); return 0; } diff --git a/upstream/manifest.json b/upstream/manifest.json index 7ad829f..0629351 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -8,7 +8,7 @@ "revision": "60ba801decee7a00782f74f6be4c8ffb013f79ff", "repository": "https://github.com/darlinghq/darling.git", "patch": "patches/darling.patch", - "modified_files": 88, + "modified_files": 89, "new_files": [ "src/CoreAudio/CoreAudio/AudioConverterShim.c", "src/CoreAudio/AudioTrace.h", @@ -307,7 +307,8 @@ "private-include/indium/submission-batch.private.hpp", "private-include/indium/submit-batch.private.hpp", "private-include/indium/submit-thread.private.hpp", - "private-include/indium/waits.private.hpp" + "private-include/indium/waits.private.hpp", + "private-include/indium/completion-spin.private.hpp" ] }, { From 0087384a02eb6d22caba8a59fff4bf400d6ae06e Mon Sep 17 00:00:00 2001 From: Daniel Souza <78171631+dnsouzadev@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:39:41 -0300 Subject: [PATCH 074/160] Fix Riot session aborts, CDN downloads and hidden client recovery --- .github/workflows/source-check.yml | 4 + docs/SETUP.md | 14 ++ docs/TESTING.md | 48 +++++ docs/TROUBLESHOOTING.md | 67 +++++++ patches/Libinfo.patch | 32 +++- patches/cfnetwork.patch | 275 +++++++++++++++++++++++++++ scripts/desktop-run.sh | 11 ++ tests/test_desktop_run.py | 36 ++++ tests/test_hidden_riot_recovery.py | 116 +++++++++++ tests/test_urlsession_runner.py | 55 ++++++ tools/build-riot-urlsession-check.py | 32 ++++ tools/recover-hidden-riot-client.py | 57 ++++++ tools/riot-cdn-dns-offline-check.py | 51 +++++ tools/riot-desktop-entry.py | 2 +- tools/riot-urlsession-check.m | 67 +++++++ tools/riot-urlsession-check.py | 86 +++++++++ tools/run-riot-client.sh | 6 + 17 files changed, 948 insertions(+), 11 deletions(-) create mode 100755 scripts/desktop-run.sh create mode 100644 tests/test_desktop_run.py create mode 100644 tests/test_hidden_riot_recovery.py create mode 100644 tests/test_urlsession_runner.py create mode 100644 tools/build-riot-urlsession-check.py create mode 100644 tools/recover-hidden-riot-client.py create mode 100644 tools/riot-cdn-dns-offline-check.py create mode 100644 tools/riot-urlsession-check.m create mode 100644 tools/riot-urlsession-check.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index a9f4428..1d02ffc 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -13,3 +13,7 @@ jobs: - run: python scripts/validate.py - run: python tools/riot-input-offline-check.py - run: python tools/riot-runtime-cleanup-offline-check.py + - run: python tools/riot-cdn-dns-offline-check.py + - run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' + - run: python -m unittest discover -s tests -p 'test_desktop_run.py' + - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' diff --git a/docs/SETUP.md b/docs/SETUP.md index d427c62..2bc1d15 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -59,6 +59,20 @@ Run configuration, builds and the per-user runtime installation as your ordinary The normal launcher starts neither an Electron inspector nor client API/UI helpers. It clears inherited `DYLD_INSERT_LIBRARIES`, retains Darling runtime library/framework overrides, supplies an absolute log directory to the launcher, and selects the League product. The log-directory/Chromium argument shim, empty-autofill hook and optional native VPX bridge are no longer injected or built by runtime installation. Existing copies on disk are inactive and may remain. Child-process log-directory rewriting and Chromium feature overrides previously supplied by the shim are removed; client startup without those workarounds needs manual validation. The first-time installer uses `--skip-to-install` and omits the installed client's `--app-root`, avoiding an immediate exit or inaccessible intro screen. The project ships no inspector hooks, client API helpers, input automation or client binary patches. Keep account data, logs and captures private. +## Application-menu diagnostics and recovery options + +`scripts/create-desktop-entry.sh` creates an application-menu entry that runs +`scripts/desktop-run.sh`, preserving the normal launcher behavior and saving +stdout/stderr in `build/desktop-launch.log` with mode 0600. Logs can contain +private session details; do not attach raw launcher logs to a public issue. +Region/locale remain the existing `RCLOL_REGION`/`RCLOL_LOCALE` options. + +`RCLOL_CDN_IPV4_ONLY` defaults to `1` in the Riot launcher and can be set to `0` +to disable the CDN-only resolver workaround. `RCLOL_RECOVER_HIDDEN_CLIENT=1` +opts in to Hyprland-only recovery of a default-prefix Riot session with no +visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection) +for the observed failures, recovery guards and NSURLSession limitations. + ## Updating an existing runtime `./scripts/update.sh` updates everything after `git pull`: `build.sh`, then the base install, then `install-runtime.sh`. When a pull changes a patch, `prepare.py` finds the earlier version applied in that component (its copy in `build/applied-patches`, or a version from this repository's history), reverts it, and applies the new one. It never resets a repository; if neither version matches, it stops and names the repository with other edits. The steps below update single components instead. diff --git a/docs/TESTING.md b/docs/TESTING.md index 49c5d0c..7ff2ef6 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -29,6 +29,54 @@ The LLVM version check compiles Iridium's actual dynamic loader against small st The patch check uses temporary base indexes and does not reset or modify the source checkout. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, and preserves Darling runtime library/framework paths. The memory-installer check imports the installer and exercises its policy (default source is the prepared artifact; the artifact is never a destination of a different library) in temporary directories. The Foundation-installer check does the same for the overlay installer: it seeds a temporary framework from a stub SDK, replaces `Versions/C/Foundation`, keeps the standard symlinks, stages a backup, treats a re-run as current and refuses a non-Mach-O source. The installer handoff mock covers 28 scenarios, including HTTP 409, interrupted verification and preserving active downloads. +## NSURLSession lifecycle regression + +After configuring and building the patched Darling source: + +```sh +python3 tools/build-riot-urlsession-check.py +python3 tools/riot-urlsession-check.py +``` + +The runner starts a loopback-only HTTP fixture, reserves a second non-listening +port for the transport-error case, and creates a fresh private DPREFIX. It runs +only the independent probe, then shuts down that private prefix. It never +starts Riot/League, queries their APIs, sends game input or shuts down the +normal prefix. The fixture delays its response so session invalidation is +tested with a request still outstanding. Run native guest probes with clients +and the game closed; source-only CI does not run them. + +The builder accepts `--build-dir` and `--sdk` for an existing configured tree. +The runner accepts `--frameworks` and `--libraries` host paths for installed +overlays, and keeps diagnostics in mode-0600 `build/urlsession-check.log`. + +Expected checks: five successful HTTP GETs with exact response bytes/status; +connection refusal delivered as an error; repeated resume/cancel without +duplicate completion; finish waiting for task completion; and session +invalidation delivered once for both graceful finish and cancellation. +Expected output includes `URLSESSION_PASS` and `LIFECYCLE_PASS`. The original +runtime aborts on missing data-task initialization or session invalidation. +These checks do not establish full NSURLSession conformance or transport-level +interruption of an already-running request. + +Host-only checks, which do not start Darling: + +```sh +python3 tools/riot-cdn-dns-offline-check.py +python3 -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' +python3 -m unittest discover -s tests -p 'test_desktop_run.py' +python3 -m unittest discover -s tests -p 'test_urlsession_runner.py' +``` + +The DNS check compiles the actual `dr_query` addition from `Libinfo.patch` +against stub resolver functions. It covers default/opt-out behavior, case +insensitivity, A queries, non-CDN names and misleading domain suffixes without +sending a DNS query. Recovery tests use fake `/proc` entries and mock every +compositor/shutdown command. The desktop test checks exit/argument forwarding, +append-only output and tightening an existing public log to mode 0600. The +runner test uses a fake `darling` executable to verify loopback fixtures and +private-prefix-only shutdown/cleanup. + ## CoreText font-list and AppKit resize regressions After preparing the source and configuring the runtime tools: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 0cc5bcf..7a2aa9c 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -74,6 +74,73 @@ The installer fetcher no longer treats a missing archive `Info.plist` or the inf There is also a separate known compatibility gap: native LaunchAgent watch registration can return HTTP 409 after the League install path is accepted, leaving the download idle. A historical inspector hook handles this and interrupted `pending_verify` states in 28 mock cases, but that hook is disabled in normal launches and excluded from the manual workflow. There is no verified runtime-only replacement for that handoff in this snapshot. Do not assume the native permission and launcher fixes resolve every idle Install case, or enable debugger/API automation to work around it. +## Riot exits and League reports that it lost its connection + +Check the launcher stderr or `build/desktop-launch.log` when launched through +the generated application-menu entry. The following Objective-C exceptions +were observed immediately before RiotClientServices aborted: + +``` +-[NSURLSessionDataTask initWithRequest:completionHandler:]: unrecognized selector +-[NSURLSession finishTasksAndInvalidate]: unrecognized selector +``` + +League then reports localhost connection refusal and loses its external-client +authorization. Restarting temporarily clears the broken session, but does not +implement the missing methods. The CFNetwork patch now supplies a minimal +completion-handler data-task implementation, uses the existing NSURLConnection +transport on a worker, and tracks outstanding tasks while a session finishes +or cancels. Session factories create the concrete CFNetwork session so its +delegate-queue storage is not confused with the public class's synthesized +properties. The URL convenience method also preserves GET rather than forcing POST. + +Rebuild and install the base and paired runtime with both clients and the game +closed. Run the standalone NSURLSession regression in [testing](TESTING.md#nsurlsession-lifecycle-regression). +This is partial NSURLSession compatibility: in-flight cancellation reports the +cancellation once but does not interrupt the synchronous transport already in +progress. Completion handlers use a global worker queue rather than the +session's delegate queue. Delegate-only data tasks, suspension, background +sessions, downloads and uploads are not established by these tests. + +### Client remains running without a window + +New launches can exit with `Client already running, exiting` when a stale Riot +session has no visible window. After exiting the game and clients, `darling +shutdown` followed by `scripts/run.sh` recovers this case without deleting game +files. For a dedicated Riot installation in the default prefix, Hyprland users +can opt in to automatic recovery: + +```sh +RCLOL_RECOVER_HIDDEN_CLIENT=1 ./scripts/run.sh +``` + +The helper requires an owned Riot process in the default prefix and a successful +Hyprland window query. It preserves a running game, any visible Riot/League +window, foreign-owned processes and custom prefixes, and takes no action when +ownership or window information cannot be read. A hidden background client or +download is restarted when recovery is enabled. Other desktop environments are +not supported by this helper; recovery is off by default. + +### Downloads repeatedly fail with an IPv6 CONNECT_TO error + +The bundled downloader was observed retrying CDN chunks with: + +``` +Use of IPv6 in *_CONNECT_TO without IPv6 support built-in +``` + +The launcher now defaults `RCLOL_CDN_IPV4_ONLY=1`. The Libinfo resolver suppresses +AAAA queries only for subdomains of `riotcdn.net`, retaining IPv4 DNS rotation, +TLS and the normal resolver for all other names. It does not pin CDN addresses +or disable IPv6 on the Linux host. Opt out with: + +```sh +RCLOL_CDN_IPV4_ONLY=0 ./scripts/run.sh +``` + +The resolver change requires a rebuilt/installed base runtime. The environment +flag alone cannot alter an older Libinfo binary. + ## Riot closes after a network lookup is cancelled Two local crashes were traced to `CFNetwork::_HostCancel` calling `CFRetain(NULL)`. diff --git a/patches/Libinfo.patch b/patches/Libinfo.patch index 71e83dd..72f97fe 100644 --- a/patches/Libinfo.patch +++ b/patches/Libinfo.patch @@ -1,11 +1,15 @@ diff --git a/darling-resolver/resolver.c b/darling-resolver/resolver.c -index f3ae33d..75944eb 100644 +index f3ae33d..6fb6e9e 100644 --- a/darling-resolver/resolver.c +++ b/darling-resolver/resolver.c -@@ -23,6 +23,21 @@ +@@ -22,6 +22,33 @@ + #include #include #include - ++#include ++#include ++#include ++ +/* res_query shares the global resolver's sockets. Concurrent lookups must + * own their resolver state so they cannot consume each other's replies. */ +static int @@ -13,6 +17,15 @@ index f3ae33d..75944eb 100644 +{ + struct __res_state state = {0}; + int result; ++ /* Riot's bundled downloader cannot use IPv6 CONNECT_TO addresses. ++ * Restrict this opt-in workaround to its CDN; keep other DNS unchanged. */ ++ const char *ipv4_only = getenv("RCLOL_CDN_IPV4_ONLY"); ++ size_t name_len = strlen(name); ++ const char suffix[] = ".riotcdn.net"; ++ if (type == ns_t_aaaa && ipv4_only && strcmp(ipv4_only, "1") == 0 && ++ name_len >= sizeof(suffix) - 1 && ++ strcasecmp(name + name_len - (sizeof(suffix) - 1), suffix) == 0) ++ return -1; + + if (res_ninit(&state) < 0) + return -1; @@ -20,11 +33,10 @@ index f3ae33d..75944eb 100644 + res_ndestroy(&state); + return result; +} -+ + static int dr_is_valid(si_mod_t *si, si_item_t *item) - { -@@ -58,7 +73,7 @@ dr_hostbyname(si_mod_t *si, const char *name, int af, const char *interface, uin +@@ -58,7 +85,7 @@ dr_hostbyname(si_mod_t *si, const char *name, int af, const char *interface, uin else return NULL; @@ -33,7 +45,7 @@ index f3ae33d..75944eb 100644 if (l < 0) return NULL; -@@ -144,7 +159,7 @@ dr_hostbyaddr(si_mod_t *si, const void *addr, int af, const char *interface, uin +@@ -144,7 +171,7 @@ dr_hostbyaddr(si_mod_t *si, const void *addr, int af, const char *interface, uin else return NULL; @@ -42,7 +54,7 @@ index f3ae33d..75944eb 100644 if (l < 0) return NULL; -@@ -240,7 +255,7 @@ dr_addrinfo(si_mod_t *si, const void *node, const void *serv, uint32_t family, u +@@ -240,7 +267,7 @@ dr_addrinfo(si_mod_t *si, const void *node, const void *serv, uint32_t family, u if (resolveV6) { @@ -51,7 +63,7 @@ index f3ae33d..75944eb 100644 if (l < 0) goto after_v6; -@@ -279,7 +294,7 @@ dr_addrinfo(si_mod_t *si, const void *node, const void *serv, uint32_t family, u +@@ -279,7 +306,7 @@ dr_addrinfo(si_mod_t *si, const void *node, const void *serv, uint32_t family, u after_v6: if (resolveV4) { @@ -60,7 +72,7 @@ index f3ae33d..75944eb 100644 if (l < 0) goto after_v4; -@@ -375,4 +390,3 @@ si_mod_t *si_module_static_darling(void) +@@ -375,4 +402,3 @@ si_mod_t *si_module_static_darling(void) return (si_mod_t*)&si; } diff --git a/patches/cfnetwork.patch b/patches/cfnetwork.patch index 4654032..4085574 100644 --- a/patches/cfnetwork.patch +++ b/patches/cfnetwork.patch @@ -1,3 +1,25 @@ +diff --git a/include/CFNetwork/NSCFURLSession.h b/include/CFNetwork/NSCFURLSession.h +index e2326a3..b7489da 100644 +--- a/include/CFNetwork/NSCFURLSession.h ++++ b/include/CFNetwork/NSCFURLSession.h +@@ -2,7 +2,7 @@ + #import + #import + +-@class NSOperationQueue, NSString, __NSCFSessionBridge; ++@class NSOperationQueue, NSString, NSMutableSet, __NSCFSessionBridge; + + __attribute__((visibility("hidden"))) + @interface __NSCFURLSessionConfiguration : NSObject +@@ -19,6 +19,8 @@ __attribute__((visibility("hidden"))) + __NSCFSessionBridge *_connectionSession; + __NSCFURLSession *_extraRetain; + NSOperationQueue *_realDelegateQueue; ++ NSMutableSet *_activeDataTasks; ++ BOOL _invalidationNotified; + } + + @property(retain) NSOperationQueue *realDelegateQueue; // @synthesize realDelegateQueue=_realDelegateQueue; diff --git a/src/Host/CFHost.c b/src/Host/CFHost.c index 91c0d0f..d6e3daf 100644 --- a/src/Host/CFHost.c @@ -15,3 +37,256 @@ index 91c0d0f..d6e3daf 100644 NULL, // copyDescription NULL, // equal NULL, // hash +diff --git a/src/URL/NSCFURLSession.m b/src/URL/NSCFURLSession.m +index 95a726b..f5ff824 100644 +--- a/src/URL/NSCFURLSession.m ++++ b/src/URL/NSCFURLSession.m +@@ -9,6 +9,8 @@ + + #import + #import ++#import ++#import + + @implementation __NSCFURLSessionConfiguration + @end +@@ -20,19 +22,19 @@ + static dispatch_once_t once = 0L; + static NSURLSession *sharedSession = nil; + dispatch_once(&once, ^{ +- sharedSession = (NSURLSession *)[[__NSCFURLSession alloc] initWithConfiguration:(NSURLSessionConfiguration *)[__NSCFURLSessionConfiguration defaultSessionConfiguration] delegate:nil delegateQueue:nil]; ++ sharedSession = (NSURLSession *)[[__NSCFURLSession alloc] initWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:nil delegateQueue:nil]; + }); + return sharedSession; + } + + + (NSURLSession *)sessionWithConfiguration:(NSURLSessionConfiguration *)configuration + { +- return (NSURLSession *)[[[self alloc] initWithConfiguration:configuration delegate:nil delegateQueue:nil] autorelease]; ++ return (NSURLSession *)[[[__NSCFURLSession alloc] initWithConfiguration:configuration delegate:nil delegateQueue:nil] autorelease]; + } + + + (NSURLSession *)sessionWithConfiguration:(NSURLSessionConfiguration *)configuration delegate:(id )delegate delegateQueue:(NSOperationQueue *)queue + { +- return (NSURLSession *)[[[self alloc] initWithConfiguration:configuration delegate:delegate delegateQueue:queue] autorelease]; ++ return (NSURLSession *)[[[__NSCFURLSession alloc] initWithConfiguration:configuration delegate:delegate delegateQueue:queue] autorelease]; + } + + + (void)_releaseProcessAssertionForSessionIdentifier:(NSString *)identifier +@@ -56,12 +58,62 @@ + return nil; + } + _nsCFConfig = (__NSCFURLSessionConfiguration *)[configuration retain]; +- _delegateQueue = [queue retain]; +- _delegate = delegate; ++ _delegateQueue = queue ? [queue retain] : [[NSOperationQueue alloc] init]; ++ if (!queue) [_delegateQueue setMaxConcurrentOperationCount:1]; ++ _delegate = [delegate retain]; ++ _activeDataTasks = [[NSMutableSet alloc] init]; + } + return self; + } + ++- (void)_notifyInvalidationIfReady ++{ ++ @synchronized(self) { ++ if (!_invalid || [_activeDataTasks count] || _invalidationNotified) return; ++ _invalidationNotified = YES; ++ } ++ [self.delegateQueue addOperationWithBlock:^{ ++ if ([self.delegate respondsToSelector:@selector(URLSession:didBecomeInvalidWithError:)]) ++ [self.delegate URLSession:(NSURLSession *)self didBecomeInvalidWithError:nil]; ++ [_delegate release]; ++ _delegate = nil; ++ }]; ++} ++ ++- (void)_taskDidComplete:(NSURLSessionTask *)task ++{ ++ @synchronized(self) { [_activeDataTasks removeObject:task]; } ++ [self _notifyInvalidationIfReady]; ++} ++ ++- (void)finishTasksAndInvalidate ++{ ++ @synchronized(self) { _invalid = YES; } ++ [self _notifyInvalidationIfReady]; ++} ++ ++- (void)invalidateAndCancel ++{ ++ NSArray *tasks; ++ @synchronized(self) { ++ _invalid = YES; ++ tasks = [[_activeDataTasks allObjects] copy]; ++ } ++ for (NSURLSessionTask *task in tasks) [task cancel]; ++ [tasks release]; ++ [self _notifyInvalidationIfReady]; ++} ++ ++- (void)dealloc ++{ ++ [_nsCFConfig release]; ++ [_delegateQueue release]; ++ [_delegate release]; ++ [_sessionDescription release]; ++ [_activeDataTasks release]; ++ [super dealloc]; ++} ++ + - (void)delegate_didFinishEventsForBackgroundURLSession + { + if ([self can_delegate_didFinishEventsForBackgroundURLSession]) +@@ -364,7 +416,13 @@ + + - (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))completionHandler + { +- return [[[NSURLSessionDataTask alloc] initWithRequest:request completionHandler:completionHandler] autorelease]; ++ @synchronized(self) { ++ if (_invalid) return nil; ++ NSURLSessionDataTask *task = [[[NSURLSessionDataTask alloc] initWithRequest:request completionHandler:completionHandler] autorelease]; ++ [task _setSession:self]; ++ [_activeDataTasks addObject:task]; ++ return task; ++ } + } + + - (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)url +@@ -374,9 +432,7 @@ + + - (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)url completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))completionHandler + { +- NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; +- [request setHTTPMethod:@"POST"]; +- return [self dataTaskWithRequest:request completionHandler:completionHandler]; ++ return [self dataTaskWithRequest:[NSURLRequest requestWithURL:url] completionHandler:completionHandler]; + } + + - (NSURLSessionDataTask *)dataTaskWithHTTPGetRequest:(NSURL *)url +diff --git a/src/URL/NSURLSession.m b/src/URL/NSURLSession.m +index 39367a5..625e88d 100644 +--- a/src/URL/NSURLSession.m ++++ b/src/URL/NSURLSession.m +@@ -7,6 +7,11 @@ + + #import + #import ++#import ++#import ++#import ++#import ++#import + + const int64_t NSURLSessionTransferSizeUnknown = -1LL; + NSString* const NSURLSessionDownloadTaskResumeData = @"NSURLSessionDownloadTaskResumeData"; +@@ -28,7 +33,108 @@ NSString* const NSURLSessionDownloadTaskResumeData = @"NSURLSessionDownloadTaskR + @implementation NSURLSessionTask + @end + +-@implementation NSURLSessionDataTask ++@implementation NSURLSessionDataTask { ++ NSURLRequest *_taskRequest; ++ NSURLResponse *_taskResponse; ++ NSError *_taskError; ++ NSString *_taskDescription; ++ void (^_taskCompletion)(NSData *, NSURLResponse *, NSError *); ++ NSURLSessionTaskState _taskState; ++ NSUInteger _taskIdentifier; ++ int64_t _taskReceived; ++ id _taskSession; ++} ++ ++- (id)initWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData *, NSURLResponse *, NSError *))handler ++{ ++ self = [super init]; ++ if (self) { ++ static NSUInteger nextIdentifier; ++ _taskIdentifier = __sync_add_and_fetch(&nextIdentifier, 1); ++ _taskRequest = [request copy]; ++ _taskCompletion = [handler copy]; ++ _taskState = NSURLSessionTaskStateSuspended; ++ } ++ return self; ++} ++ ++- (void)_finishWithData:(NSData *)data response:(NSURLResponse *)response error:(NSError *)error ++{ ++ void (^handler)(NSData *, NSURLResponse *, NSError *) = nil; ++ id session = nil; ++ @synchronized(self) { ++ if (_taskState == NSURLSessionTaskStateCompleted) ++ return; ++ _taskState = NSURLSessionTaskStateCompleted; ++ _taskResponse = [response retain]; ++ _taskError = [error retain]; ++ _taskReceived = [data length]; ++ handler = [_taskCompletion copy]; ++ [_taskCompletion release]; ++ _taskCompletion = nil; ++ session = _taskSession; ++ _taskSession = nil; ++ } ++ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ++ @autoreleasepool { ++ if (handler) handler(data, response, error); ++ [session _taskDidComplete:self]; ++ } ++ }); ++ [handler release]; ++ [session release]; ++} ++ ++- (void)_setSession:(id)session { _taskSession = [session retain]; } ++ ++- (void)resume ++{ ++ @synchronized(self) { ++ if (_taskState != NSURLSessionTaskStateSuspended) ++ return; ++ _taskState = NSURLSessionTaskStateRunning; ++ } ++ // Use the existing CFNetwork transport off the caller's thread. The block ++ // keeps the task alive even if the caller releases it immediately. ++ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ++ @autoreleasepool { ++ NSURLResponse *response = nil; ++ NSError *error = nil; ++ NSData *data = [NSURLConnection sendSynchronousRequest:_taskRequest returningResponse:&response error:&error]; ++ [self _finishWithData:data response:response error:error]; ++ } ++ }); ++} ++ ++- (void)cancel ++{ ++ [self _finishWithData:nil response:nil error:[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCancelled userInfo:nil]]; ++} ++ ++- (NSUInteger)taskIdentifier { return _taskIdentifier; } ++- (NSURLRequest *)originalRequest { return _taskRequest; } ++- (NSURLRequest *)currentRequest { return _taskRequest; } ++- (NSURLResponse *)response { @synchronized(self) { return _taskResponse; } } ++- (NSError *)error { @synchronized(self) { return _taskError; } } ++- (NSURLSessionTaskState)state { @synchronized(self) { return _taskState; } } ++- (int64_t)countOfBytesReceived { @synchronized(self) { return _taskReceived; } } ++- (NSString *)taskDescription { return _taskDescription; } ++- (void)setTaskDescription:(NSString *)value { ++ NSString *copy = [value copy]; ++ [_taskDescription release]; ++ _taskDescription = copy; ++} ++- (id)copyWithZone:(NSZone *)zone { return [self retain]; } ++- (void)dealloc ++{ ++ [_taskRequest release]; ++ [_taskResponse release]; ++ [_taskError release]; ++ [_taskDescription release]; ++ [_taskCompletion release]; ++ [_taskSession release]; ++ [super dealloc]; ++} + @end + + @implementation NSURLSessionDownloadTask diff --git a/scripts/desktop-run.sh b/scripts/desktop-run.sh new file mode 100755 index 0000000..66bb345 --- /dev/null +++ b/scripts/desktop-run.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Preserve diagnostics when a menu launcher has no terminal attached. +set -eu +umask 077 +repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +mkdir -p "$repo/build" +log="$repo/build/desktop-launch.log" +touch "$log" +chmod 600 "$log" +exec >>"$log" 2>&1 +exec "$repo/scripts/run.sh" "$@" diff --git a/tests/test_desktop_run.py b/tests/test_desktop_run.py new file mode 100644 index 0000000..4d2940c --- /dev/null +++ b/tests/test_desktop_run.py @@ -0,0 +1,36 @@ +"""Verify menu launch diagnostics without starting Darling or a client.""" +import os +from pathlib import Path +import shutil +import stat +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class DesktopRunTests(unittest.TestCase): + def test_private_log_and_arguments(self): + with tempfile.TemporaryDirectory(prefix='desktop runner ') as folder: + root = Path(folder) + (root / 'scripts').mkdir() + shutil.copy2(ROOT / 'scripts/desktop-run.sh', root / 'scripts/desktop-run.sh') + run = root / 'scripts/run.sh' + run.write_text('#!/bin/sh\nprintf "stdout:%s\\n" "$1"\nprintf "stderr\\n" >&2\nexit 7\n') + run.chmod(0o755) + (root / 'build').mkdir() + log = root / 'build/desktop-launch.log' + log.write_text('previous\n') + log.chmod(0o644) + result = subprocess.run(['sh', str(root / 'scripts/desktop-run.sh'), 'two words'], + capture_output=True, text=True) + self.assertEqual(result.returncode, 7) + self.assertEqual(result.stdout, '') + self.assertEqual(result.stderr, '') + self.assertEqual(stat.S_IMODE(log.stat().st_mode), 0o600) + self.assertEqual(log.read_text(), 'previous\nstdout:two words\nstderr\n') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_hidden_riot_recovery.py b/tests/test_hidden_riot_recovery.py new file mode 100644 index 0000000..eecf09e --- /dev/null +++ b/tests/test_hidden_riot_recovery.py @@ -0,0 +1,116 @@ +"""Exercise desktop recovery with fake /proc entries and mocked commands only.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('recovery', ROOT / 'tools/recover-hidden-riot-client.py') +recovery = importlib.util.module_from_spec(spec) +spec.loader.exec_module(recovery) + + +class RecoveryTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + self.home = self.root / 'home' + self.home_patch = patch.object(Path, 'home', return_value=self.home) + self.home_patch.start() + self.addCleanup(self.home_patch.stop) + self.env_patch = patch.dict(os.environ, {'DPREFIX': str(self.home / '.darling')}) + self.env_patch.start() + self.addCleanup(self.env_patch.stop) + + def process(self, pid, executable, prefix=None): + folder = self.proc / str(pid) + folder.mkdir() + (folder / 'cmdline').write_bytes(executable.encode() + b'\0') + (folder / 'environ').write_bytes(b'' if prefix is None else b'DPREFIX=' + os.fsencode(prefix) + b'\0') + return folder + + def riot(self): + return self.process(123, '/Users/Shared/Riot Games/Riot Client.app/Contents/MacOS/RiotClientServices') + + def run_recovery(self, windows=None, error=None, raw=None): + def command(args, **kwargs): + if args[0] == 'hyprctl': + if error: + raise error + return subprocess.CompletedProcess(args, 0, stdout=raw if raw is not None else json.dumps(windows or [])) + return subprocess.CompletedProcess(args, 0) + with patch.object(recovery.subprocess, 'run', side_effect=command) as run: + result = recovery.recover(self.proc) + return result, [call.args[0] for call in run.call_args_list] + + def test_windowless_client_is_recovered(self): + self.riot() + result, calls = self.run_recovery() + self.assertTrue(result) + self.assertEqual(calls, [['hyprctl', 'clients', '-j'], ['darling', 'shutdown']]) + + def test_visible_client_is_preserved(self): + self.riot() + for identity in ('Riot Client', 'League of Legends'): + result, calls = self.run_recovery([{'class': identity, 'mapped': True, 'hidden': False}]) + self.assertFalse(result) + self.assertEqual(len(calls), 1) + + def test_game_is_preserved_even_without_windows(self): + self.riot() + self.process(124, '/Applications/LeagueofLegends.app/Contents/MacOS/LeagueofLegends') + self.assertEqual(self.run_recovery(), (False, [])) + + def test_no_riot_is_preserved(self): + self.process(124, '/usr/bin/editor') + self.assertEqual(self.run_recovery(), (False, [])) + + def test_foreign_owned_process_is_preserved(self): + self.riot() + with patch.object(recovery.os, 'getuid', return_value=os.getuid() + 1): + self.assertEqual(self.run_recovery(), (False, [])) + + def test_custom_prefix_is_preserved(self): + self.riot() + with patch.dict(os.environ, {'DPREFIX': str(self.root / 'other-prefix')}): + self.assertEqual(self.run_recovery(), (False, [])) + + def test_riot_in_another_prefix_is_preserved(self): + self.process(123, '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices', self.root / 'other-prefix') + self.assertEqual(self.run_recovery(), (False, [])) + + def test_missing_or_failed_compositor_is_preserved(self): + self.riot() + for error in (FileNotFoundError(), subprocess.TimeoutExpired('hyprctl', 5), + subprocess.CalledProcessError(1, 'hyprctl')): + result, calls = self.run_recovery(error=error) + self.assertFalse(result) + self.assertEqual(len(calls), 1) + + def test_malformed_compositor_response_is_preserved(self): + self.riot() + for raw in ('invalid', '{}', '[null]'): + result, calls = self.run_recovery(raw=raw) + self.assertFalse(result) + self.assertEqual(len(calls), 1) + + def test_unreadable_environment_is_preserved(self): + self.riot() + original = Path.read_bytes + def read(path): + if path.name == 'environ': + raise PermissionError() + return original(path) + with patch.object(Path, 'read_bytes', read): + self.assertEqual(self.run_recovery(), (False, [])) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_urlsession_runner.py b/tests/test_urlsession_runner.py new file mode 100644 index 0000000..633eb88 --- /dev/null +++ b/tests/test_urlsession_runner.py @@ -0,0 +1,55 @@ +"""Check fixture traffic and private-prefix cleanup with a fake darling executable.""" +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class URLSessionRunnerTests(unittest.TestCase): + def test_fixture_and_shutdown_use_only_a_private_prefix(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + executable = root / 'darling' + trace = root / 'trace.jsonl' + executable.write_text(f'#!{sys.executable}\n' + f'TRACE = {str(trace)!r}\n' + ''' +import json, os, sys, urllib.request +with open(TRACE, 'a') as stream: + stream.write(json.dumps({'args':sys.argv[1:], 'prefix':os.environ['DPREFIX']}) + '\\n') +if sys.argv[1] == 'shutdown': + raise SystemExit(0) +assert urllib.request.urlopen(sys.argv[2]).read() == b'darling-session-ok' +try: + urllib.request.urlopen(sys.argv[3], timeout=2) +except OSError: + pass +else: + raise AssertionError('reserved error endpoint unexpectedly accepted a connection') +print('LIFECYCLE_PASS: fixture') +print('URLSESSION_PASS: fixture') +''') + executable.chmod(0o755) + probe = root / 'probe' + probe.write_text('fake probe; never executed') + log = root / 'probe.log' + result = subprocess.run([sys.executable, str(ROOT / 'tools/riot-urlsession-check.py'), + '--probe', str(probe), '--log', str(log)], + env=dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH']), + capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + calls = [json.loads(line) for line in trace.read_text().splitlines()] + self.assertEqual(len(calls), 2) + self.assertEqual(calls[1]['args'], ['shutdown']) + self.assertEqual(calls[0]['prefix'], calls[1]['prefix']) + self.assertNotEqual(Path(calls[0]['prefix']), Path.home() / '.darling') + self.assertFalse(Path(calls[0]['prefix']).exists()) + self.assertEqual(stat.S_IMODE(log.stat().st_mode), 0o600) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/build-riot-urlsession-check.py b/tools/build-riot-urlsession-check.py new file mode 100644 index 0000000..2f98d4a --- /dev/null +++ b/tools/build-riot-urlsession-check.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Build the standalone NSURLSession probe against a configured Darling tree.""" +import argparse +import json +from pathlib import Path +import shlex +import subprocess + +root = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--build-dir', type=Path, default=root / 'build/darling-src/build') +parser.add_argument('--sdk', type=Path) +args = parser.parse_args() +config_file = root / 'build/config.json' +config = json.loads(config_file.read_text()) if config_file.exists() else {} +sdk = args.sdk or Path(config.get('sdk', '/usr/local/libexec/darling')) +flags = {} +flags_file = args.build_dir / 'src/external/cfnetwork/src/CMakeFiles/CFNetwork.dir/flags.make' +for line in flags_file.read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +output = root / 'build' +output.mkdir(exist_ok=True) +obj = output / 'riot-urlsession-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-c', str(root / 'tools/riot-urlsession-check.m'), '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', str(sdk), str(obj), '-framework', 'Foundation', + '-framework', 'CFNetwork', '-lobjc', '-lSystem', + '-o', str(output / 'riot-urlsession-check')], check=True) +print('Built', output / 'riot-urlsession-check') diff --git a/tools/recover-hidden-riot-client.py b/tools/recover-hidden-riot-client.py new file mode 100644 index 0000000..58870ea --- /dev/null +++ b/tools/recover-hidden-riot-client.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Opt-in recovery of a windowless Riot session on Hyprland; fail closed elsewhere.""" +import json +import os +from pathlib import Path +import subprocess + + +def recover(proc_root=Path('/proc')): + home = Path.home() + prefix = Path(os.environ.get('DPREFIX', str(home / '.darling'))).expanduser() + if prefix != home / '.darling': + return False # Custom prefixes are not validated by this desktop helper. + riot_running = False + try: + for proc in proc_root.iterdir(): + if not proc.name.isdigit(): + continue + try: + if proc.stat().st_uid != os.getuid(): + continue + argv = (proc / 'cmdline').read_bytes().split(b'\0') + executable = argv[0].decode(errors='replace') + if executable.endswith('/LeagueofLegends'): + return False # Never restart a running game, even without a window. + if '/Riot Client.app/' not in executable: + continue + environment = dict(item.split(b'=', 1) for item in + (proc / 'environ').read_bytes().split(b'\0') if b'=' in item) + process_prefix = Path(os.fsdecode(environment.get(b'DPREFIX', os.fsencode(home / '.darling')))) + if process_prefix == prefix: + riot_running = True + except FileNotFoundError: + continue + except (OSError, ValueError): + return False # Unknown ownership/visibility must not trigger shutdown. + if not riot_running: + return False + try: + result = subprocess.run(['hyprctl', 'clients', '-j'], check=True, + capture_output=True, text=True, timeout=5) + windows = json.loads(result.stdout) + if not isinstance(windows, list): + return False + for window in windows: + identity = (window.get('class', '') + ' ' + window.get('title', '')).lower() + if any(name in identity for name in ('riot', 'league')) and window.get('mapped') and not window.get('hidden'): + return False + except (OSError, ValueError, TypeError, AttributeError, subprocess.SubprocessError): + return False + print('Recovering a Riot session with no visible client window.', flush=True) + subprocess.run(['darling', 'shutdown'], check=True, timeout=25) + return True + + +if __name__ == '__main__': + recover() diff --git a/tools/riot-cdn-dns-offline-check.py b/tools/riot-cdn-dns-offline-check.py new file mode 100644 index 0000000..41c687a --- /dev/null +++ b/tools/riot-cdn-dns-offline-check.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Compile the patch's actual DNS-query guard against resolver stubs; no DNS traffic.""" +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + +root = Path(__file__).resolve().parents[1] +patch = (root / 'patches/Libinfo.patch').read_text() +added = '\n'.join(line[1:] for line in patch.splitlines() if line.startswith('+') and not line.startswith('+++')) +match = re.search(r'static int\ndr_query\([^}]*\{.*?\n\}', added, re.S) +if not match: + raise SystemExit('dr_query implementation missing from patch') +source = r''' +#include +#include +#include +#include +#define ns_t_a 1 +#define ns_t_aaaa 28 +struct __res_state {int unused;}; +static int res_ninit(struct __res_state *s) {return 0;} +static int res_nquery(struct __res_state *s,const char *n,int c,int t,unsigned char *a,int l) {return 42;} +static void res_ndestroy(struct __res_state *s) {} +''' + match.group() + r''' +#define EXPECT(host,type,result) do { if(dr_query(host,1,type,0,0)!=(result)) {fprintf(stderr,"FAIL %s\n",host);return 1;} } while(0) +int main(void) { + unsetenv("RCLOL_CDN_IPV4_ONLY"); + EXPECT("lol.dyn.riotcdn.net",ns_t_aaaa,42); + setenv("RCLOL_CDN_IPV4_ONLY","1",1); + EXPECT("lol.dyn.riotcdn.net",ns_t_aaaa,-1); + EXPECT("LOL.SECURE.DYN.RIOTCDN.NET",ns_t_aaaa,-1); + EXPECT("lol.dyn.riotcdn.net",ns_t_a,42); + EXPECT("example.com",ns_t_aaaa,42); + EXPECT("riotcdn.net.example.com",ns_t_aaaa,42); + EXPECT("notriotcdn.net",ns_t_aaaa,42); + setenv("RCLOL_CDN_IPV4_ONLY","0",1); + EXPECT("lol.dyn.riotcdn.net",ns_t_aaaa,42); + puts("CDN_DNS_OFFLINE pass=1 opt_out=1 other_domains_preserved=1"); + return 0; +} +''' +compiler = shutil.which('cc') or shutil.which('clang') +if not compiler: + raise SystemExit('A native C compiler is required') +with tempfile.TemporaryDirectory(prefix='riot-cdn-dns-check-') as directory: + folder = Path(directory) + (folder / 'check.c').write_text(source) + subprocess.run([compiler, str(folder / 'check.c'), '-o', str(folder / 'check')], check=True) + subprocess.run([str(folder / 'check')], check=True) diff --git a/tools/riot-desktop-entry.py b/tools/riot-desktop-entry.py index 1626937..049e94d 100755 --- a/tools/riot-desktop-entry.py +++ b/tools/riot-desktop-entry.py @@ -140,7 +140,7 @@ def main(): shutil.copyfile(png_target, icons_base / "league-of-legends.png") # 3. Create .desktop file - run_script = PROJECT_ROOT / "scripts/run.sh" + run_script = PROJECT_ROOT / "scripts/desktop-run.sh" exec_path = str(run_script).replace("%", "%%") exec_cmd = f'"{exec_path}"' if " " in exec_path else exec_path desktop_content = f"""[Desktop Entry] diff --git a/tools/riot-urlsession-check.m b/tools/riot-urlsession-check.m new file mode 100644 index 0000000..ac80798 --- /dev/null +++ b/tools/riot-urlsession-check.m @@ -0,0 +1,67 @@ +// Standalone CFNetwork regression: only the host runner's loopback fixture is used. +#import +#import +#include +#include +static NSString *probeURL; +static NSString *failureURL; + +static int session_check(NSString *url, int cancelled) { + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block int ok = 0; + __block int callbacks = 0; + NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithURL:[NSURL URLWithString:url] completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + __sync_add_and_fetch(&callbacks, 1); + if (cancelled == 1) ok = error && [error code] == NSURLErrorCancelled; + else if (cancelled == 2) ok = error != nil; + else ok = !error && [data length] == 18 && memcmp([data bytes], "darling-session-ok", 18) == 0 && [(NSHTTPURLResponse *)response statusCode] == 200; + dispatch_semaphore_signal(sem); + }]; + if (!task || [task state] != NSURLSessionTaskStateSuspended) return 1; + if (cancelled == 1) { [task cancel]; [task cancel]; } + [task resume]; [task resume]; + if (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 15 * NSEC_PER_SEC))) { printf("TIMEOUT\n"); return 2; } + if (!ok || callbacks != 1 || [task state] != NSURLSessionTaskStateCompleted) {printf("FAIL callback=%d ok=%d\n",callbacks,ok);return 3;} + return 0; +} +static dispatch_semaphore_t invalidated; +static volatile int invalidationCount; +@interface LifecycleProbe : NSObject +@end +@implementation LifecycleProbe +- (void)URLSession:(NSURLSession *)session didBecomeInvalidWithError:(NSError *)error { + __sync_add_and_fetch(&invalidationCount, 1); + dispatch_semaphore_signal(invalidated); +} +@end +static int lifecycle_check(int cancel) { + invalidated = dispatch_semaphore_create(0); + invalidationCount = 0; + dispatch_semaphore_t completed = dispatch_semaphore_create(0); + __block int ok = 0; + LifecycleProbe *delegate = [[LifecycleProbe alloc] init]; + NSURLSession *session = [[NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:delegate delegateQueue:nil] retain]; + NSURLSessionDataTask *task = [session dataTaskWithURL:[NSURL URLWithString:probeURL] completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + ok = cancel ? (error && [error code] == NSURLErrorCancelled) : (!error && [data length] == 18); + dispatch_semaphore_signal(completed); + }]; + if (cancel) { [session invalidateAndCancel]; [session invalidateAndCancel]; } + else { [task resume]; [session finishTasksAndInvalidate]; [session finishTasksAndInvalidate]; } + if (dispatch_semaphore_wait(completed, dispatch_time(DISPATCH_TIME_NOW, 15*NSEC_PER_SEC))) return 10; + if (dispatch_semaphore_wait(invalidated, dispatch_time(DISPATCH_TIME_NOW, 15*NSEC_PER_SEC))) return 11; + if (!ok || invalidationCount != 1) return 12; + [session release]; [delegate release]; + return 0; +} +int main(int argc, const char **argv) { @autoreleasepool { + if (argc != 3) { puts("Usage: probe "); return 64; } + probeURL = [NSString stringWithUTF8String:argv[1]]; + failureURL = [NSString stringWithUTF8String:argv[2]]; + for (int i=0;i<5;i++) if (session_check(probeURL,0)) return 1; + if (session_check(probeURL,1)) return 2; + if (session_check(failureURL,2)) return 3; + if (lifecycle_check(0) || lifecycle_check(1)) { puts("LIFECYCLE_FAIL"); return 4; } + puts("LIFECYCLE_PASS: finish waits for request; cancel delivers callback; invalidation once"); + puts("URLSESSION_PASS: 5 HTTP requests and cancel/resume, callbacks exactly once"); + return 0; +}} diff --git a/tools/riot-urlsession-check.py b/tools/riot-urlsession-check.py new file mode 100644 index 0000000..6738c94 --- /dev/null +++ b/tools/riot-urlsession-check.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Run only the standalone NSURLSession probe, against a loopback HTTP fixture. + +Uses a fresh private DPREFIX and never shuts down the user's normal prefix. +No Riot processes, account data, client APIs or client input are used. +""" +import argparse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import tempfile +from threading import Thread +import time + +root = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--probe', type=Path, default=root / 'build/riot-urlsession-check') +parser.add_argument('--frameworks', type=Path, help='Host directory containing the installed framework overlays') +parser.add_argument('--libraries', type=Path, help='Host directory containing the installed dylib overlays') +parser.add_argument('--log', type=Path, default=root / 'build/urlsession-check.log') +args = parser.parse_args() +if not args.probe.is_file(): + parser.error('build the probe first') +config_file = root / 'build/config.json' +config = json.loads(config_file.read_text()) if config_file.exists() else {} +guest_user = config.get('guest_user', os.environ.get('USER', '')) +runtime = Path.home() / '.darling/Users' / guest_user +frameworks = args.frameworks or runtime / 'RuntimeFrameworks' +libraries = args.libraries or runtime / 'RuntimeLibraries' + + +class Fixture(BaseHTTPRequestHandler): + def do_GET(self): + time.sleep(0.05) # Ensure finishTasksAndInvalidate sees an in-flight task. + body = b'darling-session-ok' + self.send_response(200) + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *unused): + pass + + +server = ThreadingHTTPServer(('127.0.0.1', 0), Fixture) +reserved = socket.socket() +reserved.bind(('127.0.0.1', 0)) # Reserved but not listening: deterministic connection refusal. +Thread(target=server.serve_forever, daemon=True).start() +(root / 'build').mkdir(exist_ok=True) +prefix = Path(tempfile.mkdtemp(prefix='urlsession-prefix-', dir=root / 'build')) +env = {key: os.environ[key] for key in ('PATH', 'HOME', 'USER', 'LOGNAME', 'DISPLAY', + 'XAUTHORITY', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'LANG') if key in os.environ} +env.update(DPREFIX=str(prefix), EGL_PLATFORM='x11', LC_ALL='en_US.UTF-8', + DYLD_FRAMEWORK_PATH='/Volumes/SystemRoot' + str(frameworks.resolve()), + DYLD_LIBRARY_PATH='/Volumes/SystemRoot' + str(libraries.resolve())) +log = args.log +result = None +try: + with log.open('w') as stream: + os.chmod(log, 0o600) + result = subprocess.run(['darling', str(args.probe.resolve()), + f'http://127.0.0.1:{server.server_port}/payload', + f'http://127.0.0.1:{reserved.getsockname()[1]}/payload'], + env=env, stdout=stream, stderr=subprocess.STDOUT, timeout=90) + text = log.read_text(errors='replace') + for line in text.splitlines(): + if line.startswith(('LIFECYCLE_PASS:', 'URLSESSION_PASS:', 'LIFECYCLE_FAIL', 'FAIL ', 'TIMEOUT')): + print(line) + if result.returncode or 'LIFECYCLE_PASS:' not in text or 'URLSESSION_PASS:' not in text: + raise SystemExit(f'Probe failed (exit {result.returncode}); inspect private log {log}') +finally: + server.shutdown() + server.server_close() + reserved.close() + try: + stopped = subprocess.run(['darling', 'shutdown'], env=env, capture_output=True, timeout=25) + if stopped.returncode == 0: + shutil.rmtree(prefix) + else: + print('Private test prefix retained for inspection:', prefix) + except (OSError, subprocess.SubprocessError): + print('Private test prefix retained for inspection:', prefix) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 546bfea..75b6ec7 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -9,6 +9,12 @@ if [ "${RCLOL_CORE_DUMPS:-0}" != 1 ]; then fi runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) . "$runner_dir/runtime-paths.sh" +if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then + python3 "$runner_dir/recover-hidden-riot-client.py" +fi +# The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN +# AAAA lookups are suppressed; set this to 0 to retain the upstream resolver. +export RCLOL_CDN_IPV4_ONLY="${RCLOL_CDN_IPV4_ONLY:-1}" python3 "$runner_dir/ensure-darling-runtime.py" python3 "$runner_dir/ensure-darling-service-accounts.py" python3 "$runner_dir/ensure-league-runtime-layout.py" From e07cf72a15e631a2f326fd2da5f605bf2908644f Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:16:32 +0200 Subject: [PATCH 075/160] fix(cfnetwork): preserve shared sessions and delegate-queue completions Ignore both invalidation methods on the shared session; deliver task completion through the owning session delegate queue. Extend the standalone probe to exercise singleton reuse and a suspended custom queue. Require stable old-client process identities and complete window data before optional recovery, and continue launch after shutdown failures. --- docs/TESTING.md | 11 +++-- docs/TROUBLESHOOTING.md | 5 ++ patches/cfnetwork.patch | 39 ++++++++++------ tests/test_hidden_riot_recovery.py | 49 ++++++++++++++++++-- tests/test_urlsession_runner.py | 2 + tools/recover-hidden-riot-client.py | 72 +++++++++++++++++++++-------- tools/riot-urlsession-check.m | 28 +++++++++++ tools/riot-urlsession-check.py | 6 ++- 8 files changed, 171 insertions(+), 41 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 7ff2ef6..e0824de 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -31,7 +31,9 @@ The patch check uses temporary base indexes and does not reset or modify the sou ## NSURLSession lifecycle regression -After configuring and building the patched Darling source: +After configuring, building and installing the patched Darling runtime (including +CFNetwork), run the probe. Supply `--frameworks` and `--libraries` when using +staged overlays instead of the installed runtime: ```sh python3 tools/build-riot-urlsession-check.py @@ -53,8 +55,11 @@ overlays, and keeps diagnostics in mode-0600 `build/urlsession-check.log`. Expected checks: five successful HTTP GETs with exact response bytes/status; connection refusal delivered as an error; repeated resume/cancel without duplicate completion; finish waiting for task completion; and session -invalidation delivered once for both graceful finish and cancellation. -Expected output includes `URLSESSION_PASS` and `LIFECYCLE_PASS`. The original +invalidation delivered once for both graceful finish and cancellation; shared-session +invalidation leaving later requests usable; and completion delivery honoring a +suspended custom delegate queue. +Expected output includes `URLSESSION_PASS`, `LIFECYCLE_PASS`, +`SHARED_SESSION_PASS` and `QUEUE_PASS`. The original runtime aborts on missing data-task initialization or session invalidation. These checks do not establish full NSURLSession conformance or transport-level interruption of an already-running request. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 7a2aa9c..04242ba 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -412,3 +412,8 @@ If a black fullscreen game has only its normal Metal child and becomes visible a ### Visible cursor while relative on Xwayland (PR #18) When Xwayland does not honour warps, the virtual cursor follows real pointer motion and application warps leave it in place. Expected warp echoes are still discarded using their request serial and coordinates. This keeps clicks and camera edge panning aligned with the visible cursor on GNOME/gamescope. The contributor confirmed this path in a gamescope practice-tool session; plain GNOME and NVIDIA still require manual verification. + +Recovery requires every matching client to be at least 60 seconds old and a +complete Hyprland window record (`class`, `title`, `mapped`, `hidden`). A second +process scan after window inspection must find the same client identities and +no game. Missing information or shutdown failure leaves normal launch in charge. diff --git a/patches/cfnetwork.patch b/patches/cfnetwork.patch index 4085574..46ce9c0 100644 --- a/patches/cfnetwork.patch +++ b/patches/cfnetwork.patch @@ -1,5 +1,5 @@ diff --git a/include/CFNetwork/NSCFURLSession.h b/include/CFNetwork/NSCFURLSession.h -index e2326a3..b7489da 100644 +index e2326a3..32efa89 100644 --- a/include/CFNetwork/NSCFURLSession.h +++ b/include/CFNetwork/NSCFURLSession.h @@ -2,7 +2,7 @@ @@ -11,12 +11,13 @@ index e2326a3..b7489da 100644 __attribute__((visibility("hidden"))) @interface __NSCFURLSessionConfiguration : NSObject -@@ -19,6 +19,8 @@ __attribute__((visibility("hidden"))) +@@ -19,6 +19,9 @@ __attribute__((visibility("hidden"))) __NSCFSessionBridge *_connectionSession; __NSCFURLSession *_extraRetain; NSOperationQueue *_realDelegateQueue; + NSMutableSet *_activeDataTasks; + BOOL _invalidationNotified; ++ BOOL _isSharedSession; } @property(retain) NSOperationQueue *realDelegateQueue; // @synthesize realDelegateQueue=_realDelegateQueue; @@ -38,7 +39,7 @@ index 91c0d0f..d6e3daf 100644 NULL, // equal NULL, // hash diff --git a/src/URL/NSCFURLSession.m b/src/URL/NSCFURLSession.m -index 95a726b..f5ff824 100644 +index 95a726b..86291e8 100644 --- a/src/URL/NSCFURLSession.m +++ b/src/URL/NSCFURLSession.m @@ -9,6 +9,8 @@ @@ -50,12 +51,13 @@ index 95a726b..f5ff824 100644 @implementation __NSCFURLSessionConfiguration @end -@@ -20,19 +22,19 @@ +@@ -20,19 +22,20 @@ static dispatch_once_t once = 0L; static NSURLSession *sharedSession = nil; dispatch_once(&once, ^{ - sharedSession = (NSURLSession *)[[__NSCFURLSession alloc] initWithConfiguration:(NSURLSessionConfiguration *)[__NSCFURLSessionConfiguration defaultSessionConfiguration] delegate:nil delegateQueue:nil]; + sharedSession = (NSURLSession *)[[__NSCFURLSession alloc] initWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:nil delegateQueue:nil]; ++ ((__NSCFURLSession *)sharedSession)->_isSharedSession = YES; }); return sharedSession; } @@ -73,7 +75,7 @@ index 95a726b..f5ff824 100644 } + (void)_releaseProcessAssertionForSessionIdentifier:(NSString *)identifier -@@ -56,12 +58,62 @@ +@@ -56,12 +59,66 @@ return nil; } _nsCFConfig = (__NSCFURLSessionConfiguration *)[configuration retain]; @@ -109,7 +111,10 @@ index 95a726b..f5ff824 100644 + +- (void)finishTasksAndInvalidate +{ -+ @synchronized(self) { _invalid = YES; } ++ @synchronized(self) { ++ if (_isSharedSession) return; ++ _invalid = YES; ++ } + [self _notifyInvalidationIfReady]; +} + @@ -117,6 +122,7 @@ index 95a726b..f5ff824 100644 +{ + NSArray *tasks; + @synchronized(self) { ++ if (_isSharedSession) return; + _invalid = YES; + tasks = [[_activeDataTasks allObjects] copy]; + } @@ -138,7 +144,7 @@ index 95a726b..f5ff824 100644 - (void)delegate_didFinishEventsForBackgroundURLSession { if ([self can_delegate_didFinishEventsForBackgroundURLSession]) -@@ -364,7 +416,13 @@ +@@ -364,7 +421,13 @@ - (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))completionHandler { @@ -153,7 +159,7 @@ index 95a726b..f5ff824 100644 } - (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)url -@@ -374,9 +432,7 @@ +@@ -374,9 +437,7 @@ - (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)url completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))completionHandler { @@ -165,10 +171,10 @@ index 95a726b..f5ff824 100644 - (NSURLSessionDataTask *)dataTaskWithHTTPGetRequest:(NSURL *)url diff --git a/src/URL/NSURLSession.m b/src/URL/NSURLSession.m -index 39367a5..625e88d 100644 +index 39367a5..ef3fdbc 100644 --- a/src/URL/NSURLSession.m +++ b/src/URL/NSURLSession.m -@@ -7,6 +7,11 @@ +@@ -7,6 +7,12 @@ #import #import @@ -176,11 +182,12 @@ index 39367a5..625e88d 100644 +#import +#import +#import ++#import +#import const int64_t NSURLSessionTransferSizeUnknown = -1LL; NSString* const NSURLSessionDownloadTaskResumeData = @"NSURLSessionDownloadTaskResumeData"; -@@ -28,7 +33,108 @@ NSString* const NSURLSessionDownloadTaskResumeData = @"NSURLSessionDownloadTaskR +@@ -28,7 +34,114 @@ NSString* const NSURLSessionDownloadTaskResumeData = @"NSURLSessionDownloadTaskR @implementation NSURLSessionTask @end @@ -227,12 +234,18 @@ index 39367a5..625e88d 100644 + session = _taskSession; + _taskSession = nil; + } -+ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ++ void (^completion)(void) = ^{ + @autoreleasepool { + if (handler) handler(data, response, error); + [session _taskDidComplete:self]; + } -+ }); ++ }; ++ // Completion handlers follow the session's delegate queue, including its ++ // suspension and serialization. The queued block retains task/session/data. ++ if (session) ++ [[session delegateQueue] addOperationWithBlock:completion]; ++ else ++ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), completion); + [handler release]; + [session release]; +} diff --git a/tests/test_hidden_riot_recovery.py b/tests/test_hidden_riot_recovery.py index eecf09e..247047c 100644 --- a/tests/test_hidden_riot_recovery.py +++ b/tests/test_hidden_riot_recovery.py @@ -21,6 +21,7 @@ class RecoveryTests(unittest.TestCase): self.root = Path(self.temp.name) self.proc = self.root / 'proc' self.proc.mkdir() + (self.proc / "uptime").write_text("1000 0\n") self.home = self.root / 'home' self.home_patch = patch.object(Path, 'home', return_value=self.home) self.home_patch.start() @@ -29,11 +30,13 @@ class RecoveryTests(unittest.TestCase): self.env_patch.start() self.addCleanup(self.env_patch.stop) - def process(self, pid, executable, prefix=None): + def process(self, pid, executable, prefix=None, age=120): folder = self.proc / str(pid) folder.mkdir() (folder / 'cmdline').write_bytes(executable.encode() + b'\0') (folder / 'environ').write_bytes(b'' if prefix is None else b'DPREFIX=' + os.fsencode(prefix) + b'\0') + fields = ['S'] + ['0'] * 18 + [str(int((1000 - age) * os.sysconf('SC_CLK_TCK')))] + (folder / 'stat').write_text(str(pid) + ' (Riot Client (worker)) ' + ' '.join(fields)) return folder def riot(self): @@ -59,7 +62,7 @@ class RecoveryTests(unittest.TestCase): def test_visible_client_is_preserved(self): self.riot() for identity in ('Riot Client', 'League of Legends'): - result, calls = self.run_recovery([{'class': identity, 'mapped': True, 'hidden': False}]) + result, calls = self.run_recovery([{'class': identity, 'title': identity, 'mapped': True, 'hidden': False}]) self.assertFalse(result) self.assertEqual(len(calls), 1) @@ -96,7 +99,8 @@ class RecoveryTests(unittest.TestCase): def test_malformed_compositor_response_is_preserved(self): self.riot() - for raw in ('invalid', '{}', '[null]'): + for raw in ('invalid', '{}', '[null]', '[{}]', '[{"class":"editor"}]', + '[{"class":"Riot","title":"Riot","mapped":"true","hidden":false}]'): result, calls = self.run_recovery(raw=raw) self.assertFalse(result) self.assertEqual(len(calls), 1) @@ -111,6 +115,45 @@ class RecoveryTests(unittest.TestCase): with patch.object(Path, 'read_bytes', read): self.assertEqual(self.run_recovery(), (False, [])) + def test_young_client_prevents_recovery_even_if_an_old_client_exists(self): + self.riot() + self.process(125, '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices', age=5) + self.assertEqual(self.run_recovery(), (False, [])) + + def test_missing_or_invalid_age_information_prevents_recovery(self): + proc = self.riot() + for value in ('garbage', '123 (worker) S'): + (proc / 'stat').write_text(value) + self.assertEqual(self.run_recovery(), (False, [])) + (proc / 'stat').unlink() + self.assertEqual(self.run_recovery(), (False, [])) + + def test_shutdown_failures_are_reported_without_aborting_launch(self): + self.riot() + for error in (FileNotFoundError(), subprocess.TimeoutExpired('darling', 25), + subprocess.CalledProcessError(1, 'darling')): + def command(args, **kwargs): + if args[0] == 'hyprctl': + return subprocess.CompletedProcess(args, 0, stdout='[]') + raise error + with patch.object(recovery.subprocess, 'run', side_effect=command): + self.assertFalse(recovery.recover(self.proc)) + + def test_client_started_during_window_inspection_is_preserved(self): + self.riot() + def command(args, **kwargs): + self.assertEqual(args[0], 'hyprctl') + self.process(125, '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices', age=1) + return subprocess.CompletedProcess(args, 0, stdout='[]') + with patch.object(recovery.subprocess, 'run', side_effect=command): + self.assertFalse(recovery.recover(self.proc)) + + def test_valid_unrelated_window_does_not_prevent_recovery(self): + self.riot() + result, calls = self.run_recovery([{'class':'editor', 'title':'file', 'mapped':True, 'hidden':False}]) + self.assertTrue(result) + self.assertEqual(calls[-1], ['darling', 'shutdown']) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_urlsession_runner.py b/tests/test_urlsession_runner.py index 633eb88..1c960e4 100644 --- a/tests/test_urlsession_runner.py +++ b/tests/test_urlsession_runner.py @@ -32,6 +32,8 @@ else: raise AssertionError('reserved error endpoint unexpectedly accepted a connection') print('LIFECYCLE_PASS: fixture') print('URLSESSION_PASS: fixture') +print('SHARED_SESSION_PASS: fixture') +print('QUEUE_PASS: fixture') ''') executable.chmod(0o755) probe = root / 'probe' diff --git a/tools/recover-hidden-riot-client.py b/tools/recover-hidden-riot-client.py index 58870ea..47bf93f 100644 --- a/tools/recover-hidden-riot-client.py +++ b/tools/recover-hidden-riot-client.py @@ -1,40 +1,60 @@ #!/usr/bin/env python3 -"""Opt-in recovery of a windowless Riot session on Hyprland; fail closed elsewhere.""" +"""Opt-in recovery of an old windowless Riot session on Hyprland; fail closed.""" import json +import math import os from pathlib import Path import subprocess +MIN_AGE_SECONDS = 60 -def recover(proc_root=Path('/proc')): - home = Path.home() - prefix = Path(os.environ.get('DPREFIX', str(home / '.darling'))).expanduser() - if prefix != home / '.darling': - return False # Custom prefixes are not validated by this desktop helper. - riot_running = False + +def recoverable_processes(proc_root, prefix): + """Return stable process identities, or None when shutdown is not justified.""" + candidates = {} try: + uptime = float((proc_root / 'uptime').read_text().split()[0]) + ticks = os.sysconf('SC_CLK_TCK') + if not math.isfinite(uptime) or uptime < 0 or ticks <= 0: + return None for proc in proc_root.iterdir(): if not proc.name.isdigit(): continue try: if proc.stat().st_uid != os.getuid(): continue - argv = (proc / 'cmdline').read_bytes().split(b'\0') - executable = argv[0].decode(errors='replace') + executable = (proc / 'cmdline').read_bytes().split(b'\0')[0].decode(errors='replace') if executable.endswith('/LeagueofLegends'): - return False # Never restart a running game, even without a window. + return None if '/Riot Client.app/' not in executable: continue environment = dict(item.split(b'=', 1) for item in (proc / 'environ').read_bytes().split(b'\0') if b'=' in item) - process_prefix = Path(os.fsdecode(environment.get(b'DPREFIX', os.fsencode(home / '.darling')))) - if process_prefix == prefix: - riot_running = True + process_prefix = Path(os.fsdecode(environment.get(b'DPREFIX', os.fsencode(Path.home() / '.darling')))) + if process_prefix != prefix: + continue + # Field 22 follows a comm field that may contain spaces/parentheses. + start_ticks = int((proc / 'stat').read_text().rsplit(')', 1)[1].split()[19]) + age = uptime - start_ticks / ticks + if start_ticks < 0 or age < MIN_AGE_SECONDS: + return None # One young client prevents restarting the entire prefix. + candidates[proc.name] = start_ticks except FileNotFoundError: + if proc.exists(): + return None continue - except (OSError, ValueError): - return False # Unknown ownership/visibility must not trigger shutdown. - if not riot_running: + except (OSError, ValueError, IndexError): + return None + return candidates or None + + +def recover(proc_root=Path('/proc')): + home = Path.home() + prefix = Path(os.environ.get('DPREFIX', str(home / '.darling'))).expanduser() + if prefix != home / '.darling': + return False + processes = recoverable_processes(proc_root, prefix) + if not processes: return False try: result = subprocess.run(['hyprctl', 'clients', '-j'], check=True, @@ -43,13 +63,25 @@ def recover(proc_root=Path('/proc')): if not isinstance(windows, list): return False for window in windows: - identity = (window.get('class', '') + ' ' + window.get('title', '')).lower() - if any(name in identity for name in ('riot', 'league')) and window.get('mapped') and not window.get('hidden'): + if (not isinstance(window, dict) or + not all(isinstance(window.get(key), str) for key in ('class', 'title')) or + not all(type(window.get(key)) is bool for key in ('mapped', 'hidden'))): return False - except (OSError, ValueError, TypeError, AttributeError, subprocess.SubprocessError): + identity = (window['class'] + ' ' + window['title']).lower() + if any(name in identity for name in ('riot', 'league')) and window['mapped'] and not window['hidden']: + return False + except (OSError, ValueError, subprocess.SubprocessError): + return False + # Compositor inspection can take seconds: refuse if a game/client started, + # a PID was reused, or any relevant process disappeared during that interval. + if recoverable_processes(proc_root, prefix) != processes: return False print('Recovering a Riot session with no visible client window.', flush=True) - subprocess.run(['darling', 'shutdown'], check=True, timeout=25) + try: + subprocess.run(['darling', 'shutdown'], check=True, timeout=25) + except (OSError, subprocess.SubprocessError) as error: + print(f'Riot session recovery failed: {error}', flush=True) + return False return True diff --git a/tools/riot-urlsession-check.m b/tools/riot-urlsession-check.m index ac80798..f3f8bdc 100644 --- a/tools/riot-urlsession-check.m +++ b/tools/riot-urlsession-check.m @@ -53,6 +53,29 @@ static int lifecycle_check(int cancel) { [session release]; [delegate release]; return 0; } +static int queue_check(void) { + NSOperationQueue *queue = [[NSOperationQueue alloc] init]; + [queue setMaxConcurrentOperationCount:1]; + [queue setSuspended:YES]; + NSURLSession *session = [[NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:nil delegateQueue:queue] retain]; + dispatch_semaphore_t completed = dispatch_semaphore_create(0); + __block int callbacks = 0, correctQueue = 0; + NSURLSessionDataTask *task = [session dataTaskWithURL:[NSURL URLWithString:probeURL] completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + correctQueue = [NSOperationQueue currentQueue] == queue; + ++callbacks; + dispatch_semaphore_signal(completed); + }]; + [task cancel]; + [session finishTasksAndInvalidate]; + // A suspended delegate queue must also suspend task completion delivery. + if (!dispatch_semaphore_wait(completed, dispatch_time(DISPATCH_TIME_NOW, 100*NSEC_PER_MSEC))) return 20; + [queue setSuspended:NO]; + if (dispatch_semaphore_wait(completed, dispatch_time(DISPATCH_TIME_NOW, 15*NSEC_PER_SEC))) return 21; + if (callbacks != 1 || !correctQueue) return 22; + [session release]; [queue release]; + puts("QUEUE_PASS: completion obeys delegate queue and suspension"); + return 0; +} int main(int argc, const char **argv) { @autoreleasepool { if (argc != 3) { puts("Usage: probe "); return 64; } probeURL = [NSString stringWithUTF8String:argv[1]]; @@ -60,6 +83,11 @@ int main(int argc, const char **argv) { @autoreleasepool { for (int i=0;i<5;i++) if (session_check(probeURL,0)) return 1; if (session_check(probeURL,1)) return 2; if (session_check(failureURL,2)) return 3; + [[NSURLSession sharedSession] finishTasksAndInvalidate]; + [[NSURLSession sharedSession] invalidateAndCancel]; + if (session_check(probeURL,0)) { puts("SHARED_SESSION_FAIL"); return 5; } + puts("SHARED_SESSION_PASS: invalidation leaves singleton usable"); + if (queue_check()) { puts("QUEUE_FAIL"); return 6; } if (lifecycle_check(0) || lifecycle_check(1)) { puts("LIFECYCLE_FAIL"); return 4; } puts("LIFECYCLE_PASS: finish waits for request; cancel delivers callback; invalidation once"); puts("URLSESSION_PASS: 5 HTTP requests and cancel/resume, callbacks exactly once"); diff --git a/tools/riot-urlsession-check.py b/tools/riot-urlsession-check.py index 6738c94..7545ba1 100644 --- a/tools/riot-urlsession-check.py +++ b/tools/riot-urlsession-check.py @@ -68,9 +68,11 @@ try: env=env, stdout=stream, stderr=subprocess.STDOUT, timeout=90) text = log.read_text(errors='replace') for line in text.splitlines(): - if line.startswith(('LIFECYCLE_PASS:', 'URLSESSION_PASS:', 'LIFECYCLE_FAIL', 'FAIL ', 'TIMEOUT')): + if line.startswith(('LIFECYCLE_PASS:', 'URLSESSION_PASS:', 'SHARED_SESSION_PASS:', 'QUEUE_PASS:', + 'LIFECYCLE_FAIL', 'SHARED_SESSION_FAIL', 'QUEUE_FAIL', 'FAIL ', 'TIMEOUT')): print(line) - if result.returncode or 'LIFECYCLE_PASS:' not in text or 'URLSESSION_PASS:' not in text: + required = ('LIFECYCLE_PASS:', 'URLSESSION_PASS:', 'SHARED_SESSION_PASS:', 'QUEUE_PASS:') + if result.returncode or not all(marker in text for marker in required): raise SystemExit(f'Probe failed (exit {result.returncode}); inspect private log {log}') finally: server.shutdown() From 8d462f22467cca6d83ce5088192a58992503652b Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:16:31 +0200 Subject: [PATCH 076/160] perf(indium): rebase lazy wakeups and batch tuning onto current polling Keep the merged per-device completion budget, bounded parsing, idle blocking and timeout accounting. Scope the registration/blocking handshake to each device, restrict live batch windows to the timer resolution, and test registration interleavings, spin-disabled polling, shutdown, opt-out and independent devices with actual patched methods. Co-authored-by: SypherrDev --- docs/SETUP.md | 3 + docs/TROUBLESHOOTING.md | 5 + patches/metal--deps--indium.patch | 147 +++++++++++++++++++++++++----- tests/fixtures/indium-wakeup.cpp | 146 +++++++++++++++++++++++++++++ tests/test_indium_wakeup.py | 63 +++++++++++++ tests/test_nvidia_runtime.py | 3 + 6 files changed, 342 insertions(+), 25 deletions(-) create mode 100644 tests/fixtures/indium-wakeup.cpp create mode 100644 tests/test_indium_wakeup.py diff --git a/docs/SETUP.md b/docs/SETUP.md index d427c62..1d0cbd0 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -135,6 +135,9 @@ Indium also contains experimental hand-off changes. They are **off by default**: | `/tmp/metal-gpu` | diagnostic: selects the Vulkan device used for Metal by index or case-insensitive name substring instead of the loader's first device. The OpenGL context that presents does not follow it; see [hybrid laptops](TROUBLESHOOTING.md#hybrid-graphics-laptops-amdintel-igpu--nvidia). `METAL_INDIUM_GPU` is the environment equivalent; the file wins | | `/tmp/metal-all-devices` | a `VkDevice` is created for every supported Vulkan device, as before 2026-10-16. Without it only the selected device gets one; on a machine with lavapipe installed this also drops lavapipe's idle rasterizer threads | | `/tmp/metal-no-drawable-vram` | drawables of a layer with `framebufferOnly` off (League sets it off) go back to host-visible system RAM, as before 2026-10-16. Without the file they are allocated in GPU memory like `framebufferOnly` drawables; nothing maps drawable memory from the CPU. On the RX 6600 this gave about 20 FPS more in a practice-tool fight and the same FPS when idle, not yet measured in an ARAM. `/tmp/metal-device.log` shows `drawable-vram=on` and `storage=vram` | +| `/tmp/metal-no-lazy-wakeup` | every completion registration signals the event loop's wake-up semaphore from the CPU again, as before 2026-10-18. The handshake is per device. Without the file a registration signals only while the poller has announced a blocking wait (a spinning poller re-reads the registrations on its next pass, and its spin ends when one arrives). On the NVIDIA proprietary driver each `vkSignalSemaphore` is an RM control ioctl that waits on a driver-wide lock, about 0.3 ms each and ~25 per League frame, and it was the largest kernel cost of the submit worker. Re-read every 256 registrations; the wait log counts `wakeupSignalled` and `wakeupSkipped` | +| `/tmp/metal-submit-batch-us` | the submit batch window in microseconds (500–100000, default 500); re-read every 256 checks, so it can be changed during a match. A presenting commit and every CPU wait still flush the batch at once. On the NVIDIA proprietary driver every `vkQueueSubmit2` is an RM control ioctl that waits on a driver-wide lock (about 0.4 ms each in a League match); with the default window a frame took about 8.5 submits, with `5000` about 2.5 (RTX 4090: 89 → 113 FPS, same match) | +| `/tmp/metal-submit-batch-max` | command buffers per batch (1–256, default 16), re-read like the window; `64` lets one batch hold a whole frame | | `/tmp/metal-debug-waits` | diagnostic: counts how often and how long threads block at the hand-off waits and in `nextDrawable`, every 5 s in `/tmp/metal-trace.log`; the first check writes a `waits enabled` line | Create these files from inside the guest, for example `darling shell touch /tmp/metal-debug-waits`, or while Darling is shut down. The guest `/tmp` is `~/.darling/private/tmp` seen through Darling's overlay mount. A file created on the host in that directory while Darling runs may stay invisible to guest processes that already looked for it. The same applies to removing a file. The older `/tmp/metal-no-present-dispatch-wait`, `/tmp/metal-no-deferred-blit` and `/tmp/metal-no-skip-cpu-devices` files are ignored. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 0cc5bcf..d995a32 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -202,6 +202,11 @@ Compare matches on the same map: Bilgewater runs a few percent below Hexgate. On Hyprland, also check that direct scanout is off (`hyprctl getoption render:direct_scanout` prints `int: 0`). The first match with it off ran about 16 FPS faster than the previous one on the same build; see the next section. +On the NVIDIA proprietary driver, check the submit worker's kernel time: a thread of the game process spending most of its time in the kernel (`/proc//task//stat` system time, or `perf trace -s -p `) points at driver calls. Each `vkQueueSubmit2` and each CPU-side `vkSignalSemaphore` is an RM control ioctl (command `0xc020462a`) that queues on a driver-wide lock. Before 2026-10-18 every command buffer signalled the event loop's wake-up semaphore, ~25 times per frame; with lazy wake-ups (default) and a 5 ms batch window (`/tmp/metal-submit-batch-us` = `5000`, `/tmp/metal-submit-batch-max` = `64`) an RTX 4090 ran at 206–552 FPS against 91–147 FPS with `/tmp/metal-no-lazy-wakeup`, in alternating 20 s phases of one match (the later phases with wake-ups on were capped at 240 FPS) (see [setup](SETUP.md#metal-and-indium-overlays)). + +The NVIDIA performance figures above were reported on an earlier runtime base; +they have not been remeasured after integration with current `main`. + ## Hybrid graphics laptops (AMD/Intel iGPU + NVIDIA) Metal renders with Vulkan, and each finished frame is handed to an OpenGL (EGL) context that presents it. The hand-off uses opaque file descriptors, which only work when Vulkan and OpenGL run on the same GPU. Indium takes the first Vulkan device the loader lists, and on a hybrid laptop both that device and the EGL context are normally the integrated GPU, so the discrete GPU stays idle. A report in [issue #3](https://github.com/lucannez64/darling-lol/issues/3) (Ryzen 9 5900HS, RTX 3060 Laptop) measured 60-70 FPS in ARAM on the integrated GPU; on the NVIDIA GPU the game showed a black screen. **Running on an NVIDIA GPU is not validated.** diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index 9ae8d9b..72665e9 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -781,7 +781,7 @@ index 8546daa..7e4993c 100644 void updateBindings(); diff --git a/private-include/indium/device.private.hpp b/private-include/indium/device.private.hpp -index 58f1794..1f4bd5a 100644 +index 58f1794..3e7ee47 100644 --- a/private-include/indium/device.private.hpp +++ b/private-include/indium/device.private.hpp @@ -13,6 +13,8 @@ @@ -793,7 +793,7 @@ index 58f1794..1f4bd5a 100644 namespace Indium { class PrivateDevice; -@@ -25,8 +27,11 @@ namespace Indium { +@@ -25,8 +27,14 @@ namespace Indium { class PrivateDevice: public Device, public std::enable_shared_from_this { private: @@ -802,10 +802,13 @@ index 58f1794..1f4bd5a 100644 std::mutex _eventLoopMutex; std::mutex _pollingMutex; + uint64_t _completionSpinNanoseconds = 0; ++ // Sequentially consistent handshake: either re-snapshot or signal. ++ std::atomic _eventLoopRegistrations {0}; ++ std::atomic _eventLoopBlockedPollers {0}; // these are stored separately (rather than in a single structure) // so that we can easily copy them individually in the event loop polling function. -@@ -59,6 +64,8 @@ namespace Indium { +@@ -59,6 +67,8 @@ namespace Indium { } virtual std::string name() const override; @@ -814,7 +817,7 @@ index 58f1794..1f4bd5a 100644 virtual std::shared_ptr newCommandQueue() override; virtual std::shared_ptr newRenderPipelineState(const RenderPipelineDescriptor& descriptor) override; virtual std::shared_ptr newComputePipelineState(const ComputePipelineDescriptor& descriptor, PipelineOption options, std::shared_ptr reflection) override; -@@ -73,6 +80,12 @@ namespace Indium { +@@ -73,6 +83,12 @@ namespace Indium { virtual void pollEvents(uint64_t timeoutNanoseconds) override; virtual void wakeupEventLoop() override; @@ -827,7 +830,7 @@ index 58f1794..1f4bd5a 100644 void waitForSemaphore(VkSemaphore semaphore, uint64_t targetValue, std::function callback); /** -@@ -86,6 +99,11 @@ namespace Indium { +@@ -86,6 +102,11 @@ namespace Indium { * Similar to getTimelineSemaphore(), but wraps it in a shared pointer * that automatically releases the semaphore when all references to it die out. */ @@ -839,7 +842,7 @@ index 58f1794..1f4bd5a 100644 std::shared_ptr getWrappedTimelineSemaphore(); BinarySemaphore getBinarySemaphore(bool exportable = false); -@@ -111,5 +129,34 @@ namespace Indium { +@@ -111,5 +132,34 @@ namespace Indium { INDIUM_PROPERTY(VkPhysicalDeviceMemoryProperties, m, M,emoryProperties); INDIUM_PROPERTY_READONLY(Feature, f, F,eatures); @@ -1934,7 +1937,7 @@ index 23fd2d3..1a48b81 100644 + return _gpuAddress; }; diff --git a/src/indium/command-buffer.cpp b/src/indium/command-buffer.cpp -index 43f4a84..aaa1acb 100644 +index 43f4a84..f7640c5 100644 --- a/src/indium/command-buffer.cpp +++ b/src/indium/command-buffer.cpp @@ -1,3 +1,8 @@ @@ -2271,7 +2274,7 @@ index 43f4a84..aaa1acb 100644 auto encoder = std::make_shared(shared_from_this(), descriptor); { std::scoped_lock lock(_mutex); -@@ -91,10 +354,239 @@ std::shared_ptr Indium::PrivateCommandBuffer::com +@@ -91,10 +354,269 @@ std::shared_ptr Indium::PrivateCommandBuffer::com return computeCommandEncoder(ComputePassDescriptor { {}, dispatchType }); }; @@ -2393,6 +2396,35 @@ index 43f4a84..aaa1acb 100644 + } +} + ++// darling-lol: tunable batch window and size. On the NVIDIA proprietary driver every vkQueueSubmit2 is an RM control ++// ioctl that queues on a driver-wide lock (~0.4 ms each in a League match, ~8.5 submits per frame with the 500 us ++// window), so fewer, larger batches matter there. Guest files, re-read every 256 checks: /tmp/metal-submit-batch-us ++// (window in microseconds, 500-100000, default 500) and /tmp/metal-submit-batch-max (command buffers per batch, ++// 1-256, default 16). A presenting commit and every CPU wait still flush the batch at once. ++long readTuning(const char* path, long fallback, long low, long high) { ++ FILE* file = fopen(path, "r"); ++ if (!file) return fallback; ++ long value = fallback; ++ if (fscanf(file, "%ld", &value) != 1) value = fallback; ++ fclose(file); ++ return std::clamp(value, low, high); ++} ++ ++struct SubmitBatchTuning { ++ std::atomic windowMicroseconds {500}; ++ std::atomic maxItems {16}; ++ std::atomic calls {0}; ++}; ++ ++SubmitBatchTuning& submitBatchTuning() { ++ static auto* tuning = new SubmitBatchTuning; ++ if ((tuning->calls.fetch_add(1, std::memory_order_relaxed) & 255) == 0) { ++ tuning->windowMicroseconds.store(readTuning("/tmp/metal-submit-batch-us", 500, 500, 100000), std::memory_order_relaxed); ++ tuning->maxItems.store(readTuning("/tmp/metal-submit-batch-max", 16, 1, 256), std::memory_order_relaxed); ++ } ++ return *tuning; ++} ++ +void startSubmitBatchTimer(const std::shared_ptr& device) { + auto& state = submitBatchState(); + if (state.timerStarted) return; @@ -2407,8 +2439,9 @@ index 43f4a84..aaa1acb 100644 + * merging with latency; the old 2ms added visible input lag. */ + submitBatchWake().wait_for(lock, std::chrono::microseconds(500)); + } ++ const auto window = std::chrono::microseconds(submitBatchTuning().windowMicroseconds.load(std::memory_order_relaxed)); + std::unique_lock queueLock(device->graphicsQueueMutex()); -+ if (!state.items.empty() && std::chrono::steady_clock::now() - state.since >= std::chrono::microseconds(500)) flushSubmitBatchLocked(); ++ if (!state.items.empty() && std::chrono::steady_clock::now() - state.since >= window) flushSubmitBatchLocked(); + } + }).detach(); +} @@ -2511,7 +2544,7 @@ index 43f4a84..aaa1acb 100644 for (const auto& encoder: _commandEncoders) { if (auto renderEncoder = std::dynamic_pointer_cast(encoder)) { -@@ -110,16 +602,28 @@ void Indium::PrivateCommandBuffer::commit() { +@@ -110,16 +632,28 @@ void Indium::PrivateCommandBuffer::commit() { } } @@ -2541,7 +2574,7 @@ index 43f4a84..aaa1acb 100644 // for the event loop auto timelineSemaphore = _privateDevice->getWrappedTimelineSemaphore(); -@@ -138,14 +642,49 @@ void Indium::PrivateCommandBuffer::commit() { +@@ -138,14 +672,49 @@ void Indium::PrivateCommandBuffer::commit() { readWriteTextures.insert(readWriteTextures.end(), readWrite.begin(), readWrite.end()); } @@ -2594,7 +2627,7 @@ index 43f4a84..aaa1acb 100644 } std::vector signalInfos; -@@ -202,11 +741,13 @@ void Indium::PrivateCommandBuffer::commit() { +@@ -202,11 +771,13 @@ void Indium::PrivateCommandBuffer::commit() { auto privateTexture = std::dynamic_pointer_cast(texture); const auto& presentSema = presentationSemaphores[i]; @@ -2613,7 +2646,7 @@ index 43f4a84..aaa1acb 100644 handleTextureSemaphores(privateTexture); } -@@ -215,7 +756,15 @@ void Indium::PrivateCommandBuffer::commit() { +@@ -215,7 +786,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. @@ -2630,7 +2663,7 @@ index 43f4a84..aaa1acb 100644 { std::unique_lock lock(self->_mutex); self->_completed = true; -@@ -234,8 +783,47 @@ void Indium::PrivateCommandBuffer::commit() { +@@ -234,8 +813,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(); @@ -2668,7 +2701,7 @@ index 43f4a84..aaa1acb 100644 + } + batch.items.push_back(std::move(item)); + startSubmitBatchTimer(_privateDevice); -+ if (batch.items.size() >= 16) flushSubmitBatchLocked(); ++ if (batch.items.size() >= (size_t) submitBatchTuning().maxItems.load(std::memory_order_relaxed)) flushSubmitBatchLocked(); + return; + } + } @@ -2678,7 +2711,7 @@ index 43f4a84..aaa1acb 100644 commandBufferInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO; commandBufferInfo.commandBuffer = _commandBuffer; -@@ -252,23 +840,41 @@ void Indium::PrivateCommandBuffer::commit() { +@@ -252,23 +870,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). @@ -2725,7 +2758,7 @@ index 43f4a84..aaa1acb 100644 }; void Indium::PrivateCommandBuffer::presentDrawable(std::shared_ptr drawable) { -@@ -307,9 +913,12 @@ void Indium::PrivateCommandBuffer::addCompletedHandler(std::functioncommandBuffer(), VK_PIPELINE_BIND_POINT_COMPUTE, _pso->layout(), 0, descriptorSets.size(), descriptorSets.data(), 0, nullptr); }; diff --git a/src/indium/device.cpp b/src/indium/device.cpp -index 322e186..4da420a 100644 +index 322e186..f712c7a 100644 --- a/src/indium/device.cpp +++ b/src/indium/device.cpp @@ -1,4 +1,9 @@ @@ -3161,11 +3194,11 @@ index 322e186..4da420a 100644 + } + creationOrder.push_back(i); + } - } ++ } + + for (size_t index: creationOrder) { + globalDeviceList.push_back(std::make_shared(candidates[index].device)); -+ } + } + + appendDeviceLog("created VkDevice for " + std::to_string(globalDeviceList.size()) + " of " + + std::to_string(candidates.size()) + " supported device(s)" @@ -3291,7 +3324,32 @@ index 322e186..4da420a 100644 funcInfo.bindings.insert(funcInfo.bindings.end(), info.bindings.begin(), info.bindings.end()); funcInfo.embeddedSamplers.insert(funcInfo.embeddedSamplers.end(), info.embeddedSamplers.begin(), info.embeddedSamplers.end()); } -@@ -396,6 +722,26 @@ void Indium::PrivateDevice::pollEvents(uint64_t timeoutNanoseconds) { +@@ -382,6 +708,19 @@ std::shared_ptr Indium::createSystemDefaultDevice() { + return globalDeviceList.empty() ? nullptr : globalDeviceList.front(); + }; + ++namespace { ++bool lazyWakeupEnabled() { ++ static std::atomic enabled {true}; ++ static std::atomic calls {0}; ++ if ((calls.fetch_add(1, std::memory_order_relaxed) & 255) == 0) ++ enabled.store(access("/tmp/metal-no-lazy-wakeup", F_OK) != 0, std::memory_order_relaxed); ++ return enabled.load(std::memory_order_relaxed); ++} ++void noteWakeup(const char* site) { ++ if (Indium::Waits::enabled()) Indium::Waits::record(site, 0); ++} ++} ++ + void Indium::PrivateDevice::pollEvents(uint64_t timeoutNanoseconds) { + // this is held for the entire duration of the poll so we can + // ensure we're the only one polling, which allows us to avoid +@@ -393,9 +732,35 @@ void Indium::PrivateDevice::pollEvents(uint64_t timeoutNanoseconds) { + const std::vector semaphores = _eventLoopSemaphores; + const std::vector values = _eventLoopWaitValues; + const std::vector> callbacks = _eventLoopCallbacks; ++ // All registrations covered by this snapshot, under the same mutex. ++ const uint64_t snapshotRegistrations = _eventLoopRegistrations.load(std::memory_order_seq_cst); lock.unlock(); @@ -3308,6 +3366,10 @@ index 322e186..4da420a 100644 + anyReady = DynamicVK::vkGetSemaphoreCounterValue(_device, semaphores[i], &count) == VK_SUCCESS && count >= values[i]; + } + if (anyReady) break; ++ if (_eventLoopRegistrations.load(std::memory_order_relaxed) != snapshotRegistrations) { ++ anyReady = true; // Re-snapshot registrations instead of waiting on stale handles. ++ break; ++ } + __builtin_ia32_pause(); + } while (std::chrono::steady_clock::now() < deadline); + const uint64_t elapsed = std::chrono::duration_cast( @@ -3318,8 +3380,43 @@ index 322e186..4da420a 100644 VkSemaphoreWaitInfo info {}; info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO; info.semaphoreCount = semaphores.size(); -@@ -480,9 +826,18 @@ void Indium::PrivateDevice::waitForSemaphore(VkSemaphore semaphore, uint64_t tar - wakeupEventLoop(); +@@ -403,7 +768,19 @@ void Indium::PrivateDevice::pollEvents(uint64_t timeoutNanoseconds) { + info.pValues = values.data(); + info.flags = VK_SEMAPHORE_WAIT_ANY_BIT; + +- auto result = DynamicVK::vkWaitSemaphores(_device, &info, timeoutNanoseconds); ++ // A registrant increments its counter before inspecting blocked pollers. ++ // The poller announces its wait before rechecking registrations. With both ++ // operations seq_cst, either this wait becomes nonblocking or the registrant ++ // signals the wake-up semaphore. The counters belong to this device only. ++ const bool announcedBlock = timeoutNanoseconds > 0; ++ if (announcedBlock) { ++ _eventLoopBlockedPollers.fetch_add(1, std::memory_order_seq_cst); ++ if (_eventLoopRegistrations.load(std::memory_order_seq_cst) != snapshotRegistrations) ++ timeoutNanoseconds = 0; ++ } ++ auto result = DynamicVK::vkWaitSemaphores(_device, &info, timeoutNanoseconds); ++ if (announcedBlock) ++ _eventLoopBlockedPollers.fetch_sub(1, std::memory_order_seq_cst); + + if (result == VK_TIMEOUT) { + // timed out with no semaphores ready +@@ -474,15 +851,30 @@ void Indium::PrivateDevice::waitForSemaphore(VkSemaphore semaphore, uint64_t tar + _eventLoopSemaphores.push_back(semaphore); + _eventLoopWaitValues.push_back(targetValue); + _eventLoopCallbacks.push_back(callback); ++ _eventLoopRegistrations.fetch_add(1, std::memory_order_seq_cst); + } + +- // now wakeup the event loop so it can start waiting on this new semaphore +- wakeupEventLoop(); ++ // A spinning poller re-snapshots registrations without a driver signal. ++ if (!lazyWakeupEnabled() || _eventLoopBlockedPollers.load(std::memory_order_seq_cst) > 0) { ++ noteWakeup("wakeupSignalled"); ++ wakeupEventLoop(); ++ } else { ++ noteWakeup("wakeupSkipped"); ++ } }; -// TODO: create a semaphore pool to avoid constantly creating and destroying semaphores @@ -3338,7 +3435,7 @@ index 322e186..4da420a 100644 VkSemaphoreTypeCreateInfo typeInfo {}; typeInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO; typeInfo.semaphoreType = VK_SEMAPHORE_TYPE_TIMELINE; -@@ -505,7 +860,14 @@ Indium::TimelineSemaphore Indium::PrivateDevice::getTimelineSemaphore() { +@@ -505,7 +897,14 @@ Indium::TimelineSemaphore Indium::PrivateDevice::getTimelineSemaphore() { }; void Indium::PrivateDevice::putTimelineSemaphore(const TimelineSemaphore& semaphore) { @@ -3354,7 +3451,7 @@ index 322e186..4da420a 100644 }; std::shared_ptr Indium::PrivateDevice::getWrappedTimelineSemaphore() { -@@ -551,3 +913,160 @@ std::shared_ptr Indium::PrivateDevice::getWrappedBinary +@@ -551,3 +950,160 @@ std::shared_ptr Indium::PrivateDevice::getWrappedBinary delete ptr; }); }; diff --git a/tests/fixtures/indium-wakeup.cpp b/tests/fixtures/indium-wakeup.cpp new file mode 100644 index 0000000..1d89100 --- /dev/null +++ b/tests/fixtures/indium-wakeup.cpp @@ -0,0 +1,146 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using VkSemaphore = int; +struct VkSemaphoreWaitInfo { int sType, flags; size_t semaphoreCount; const VkSemaphore* pSemaphores; const uint64_t* pValues; }; +struct VkSemaphoreSignalInfo { int sType; VkSemaphore semaphore; uint64_t value; }; +constexpr int VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO=1, VK_SEMAPHORE_WAIT_ANY_BIT=1; +constexpr int VK_STRUCTURE_TYPE_SEMAPHORE_SIGNAL_INFO=2, VK_SUCCESS=0, VK_TIMEOUT=1; +static std::array,512> counters {}; +static std::atomic signalCalls {0}; +static std::atomic lastTimeout {0}; +static std::mutex kernelMutex; +static std::condition_variable kernelWake; +static std::function onWait, onCounter; +static bool lazy = true; +static bool lazyWakeupEnabled() { return lazy; } +static void noteWakeup(const char*) {} +struct TestMutex { + std::mutex mutex; + std::function afterUnlock; + void lock() { mutex.lock(); } + void unlock() { + auto hook=std::move(afterUnlock); + afterUnlock=nullptr; + mutex.unlock(); + if(hook) hook(); + } +}; +namespace DynamicVK { +int vkGetSemaphoreCounterValue(int, int semaphore, uint64_t* value) { + auto hook=std::move(onCounter); onCounter=nullptr; + if(hook) hook(); + *value=counters[semaphore].load(); return VK_SUCCESS; +} +int vkSignalSemaphore(int, const VkSemaphoreSignalInfo* info) { + { std::lock_guard lock(kernelMutex); counters[info->semaphore]=info->value; ++signalCalls; } + kernelWake.notify_all(); return VK_SUCCESS; +} +int vkWaitSemaphores(int, const VkSemaphoreWaitInfo* info, uint64_t timeout) { + lastTimeout=timeout; + auto hook=std::move(onWait); onWait=nullptr; + if(hook) hook(); + auto ready=[&]{ + for(size_t i=0;isemaphoreCount;++i) + if(counters[info->pSemaphores[i]].load()>=info->pValues[i]) return true; + return false; + }; + if(ready()) return VK_SUCCESS; + if(!timeout) return VK_TIMEOUT; + std::unique_lock lock(kernelMutex); + // A missed wakeup fails in a bounded interval instead of hanging the suite. + assert(kernelWake.wait_for(lock,std::chrono::seconds(2),ready)); + return VK_SUCCESS; +} +} +namespace Indium { +class PrivateDevice { +public: + std::mutex _pollingMutex; + TestMutex _eventLoopMutex; + int _device=0; + uint64_t _completionSpinNanoseconds=0; + std::atomic _eventLoopRegistrations {0}; + std::atomic _eventLoopBlockedPollers {0}; + std::vector _eventLoopSemaphores={1}; + std::vector _eventLoopWaitValues={1}; + std::vector> _eventLoopCallbacks={nullptr}; + void pollEvents(uint64_t); + void waitForSemaphore(VkSemaphore,uint64_t,std::function); + void wakeupEventLoop(); +}; +} +/* ACTUAL_PATCHED_METHODS */ +static void reset() { + for(auto& value:counters) value=0; + signalCalls=0; lastTimeout=0; lazy=true; onWait=nullptr; onCounter=nullptr; +} +int main() { + // Registration before snapshot is included without a host-side wakeup. + reset(); { + Indium::PrivateDevice d; int done=0; counters[2]=1; + d.waitForSemaphore(2,1,[&]{++done;}); d.pollEvents(UINT64_MAX); + assert(done==1 && signalCalls==0 && d._eventLoopBlockedPollers==0); + } + // Registration after snapshot but before announcing a blocking wait. + reset(); { + Indium::PrivateDevice d; int done=0; + d._eventLoopMutex.afterUnlock=[&]{ counters[2]=1; d.waitForSemaphore(2,1,[&]{++done;}); }; + d.pollEvents(UINT64_MAX); + assert(lastTimeout==0 && signalCalls==0 && done==0 && d._eventLoopBlockedPollers==0); + d.pollEvents(UINT64_MAX); assert(done==1); + } + // Registration after announcing a blocking wait must signal that wait. + reset(); { + Indium::PrivateDevice d; int done=0; + onWait=[&]{ assert(d._eventLoopBlockedPollers==1); counters[2]=1; d.waitForSemaphore(2,1,[&]{++done;}); }; + d.pollEvents(UINT64_MAX); + assert(signalCalls==1 && d._eventLoopBlockedPollers==0 && done==0); + d.pollEvents(UINT64_MAX); assert(done==1); + } + // A registration during the spin ends it and causes a fresh snapshot. + reset(); { + Indium::PrivateDevice d; int done=0; d._completionSpinNanoseconds=1000000; + d.waitForSemaphore(2,1,nullptr); + onCounter=[&]{ counters[3]=1; d.waitForSemaphore(3,1,[&]{++done;}); }; + d.pollEvents(UINT64_MAX); assert(lastTimeout==0 && signalCalls==0); + d.pollEvents(UINT64_MAX); assert(done==1); + } + // Zero-timeout polls never announce a blocking wait. + reset(); { + Indium::PrivateDevice d; + onWait=[&]{ assert(d._eventLoopBlockedPollers==0); d.waitForSemaphore(2,1,nullptr); }; + d.pollEvents(0); assert(lastTimeout==0 && signalCalls==0); + } + // Opt-out restores unconditional registration wakeups; shutdown always wakes. + reset(); { + Indium::PrivateDevice d; lazy=false; d.waitForSemaphore(2,1,nullptr); + assert(signalCalls==1); lazy=true; d.wakeupEventLoop(); assert(signalCalls==2); + } + // A blocked poller on another device cannot change this device's policy. + reset(); { + Indium::PrivateDevice a,b; b._eventLoopBlockedPollers=1; + a.waitForSemaphore(2,1,nullptr); assert(signalCalls==0 && b._eventLoopRegistrations==0); + } + // Exercise real producer/poller concurrency with spinning enabled and disabled. + for(uint64_t spin:{0ULL,1000000ULL}) { + reset(); Indium::PrivateDevice d; d._completionSpinNanoseconds=spin; + std::atomic done {0}; constexpr int count=200; + std::thread poller([&]{ while(done +#include +#include +#include +static const char* fixturePath; +static FILE* test_fopen(const char*, const char* mode) { return ::fopen(fixturePath,mode); } +#define fopen test_fopen +''' + tuning + r''' +#undef fopen +static void write(const char* text) { + FILE* f=fopen(fixturePath,"w"); assert(f); fputs(text,f); fclose(f); +} +int main(int argc,char** argv) { + assert(argc==2); fixturePath=argv[1]; + write("100"); + auto& initial=submitBatchTuning(); + assert(initial.windowMicroseconds==500 && initial.maxItems==100); + write("200000"); + for(int i=0;i<256;++i) submitBatchTuning(); + assert(initial.windowMicroseconds==100000 && initial.maxItems==256); + write("invalid"); + for(int i=0;i<256;++i) submitBatchTuning(); + assert(initial.windowMicroseconds==500 && initial.maxItems==16); +} +''' + self.run_cpp(fixture, config=True) + + def run_cpp(self, text, config=False): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) + (path / 'check.cpp').write_text(text) + compile = subprocess.run(['c++', '-std=c++17', '-pthread', str(path/'check.cpp'), + '-o', str(path/'check')], capture_output=True, text=True) + self.assertEqual(compile.returncode, 0, compile.stderr) + result = subprocess.run([str(path/'check'), *([str(path/'tuning')] if config else [])], + capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stderr) diff --git a/tests/test_nvidia_runtime.py b/tests/test_nvidia_runtime.py index aaa37dd..85dc97d 100644 --- a/tests/test_nvidia_runtime.py +++ b/tests/test_nvidia_runtime.py @@ -126,6 +126,7 @@ int main() { poll = sources.function(source, 'Indium::PrivateDevice::pollEvents') harness = ''' #include +#include #include #include #include @@ -159,6 +160,8 @@ public: std::mutex _pollingMutex, _eventLoopMutex; int _device=0; uint64_t _completionSpinNanoseconds=1000000; + std::atomic _eventLoopRegistrations {0}; + std::atomic _eventLoopBlockedPollers {0}; std::vector _eventLoopSemaphores={1}; std::vector _eventLoopWaitValues={1}; std::vector> _eventLoopCallbacks={nullptr}; From fb12b2aa07149d70a6f2e48bc034b1f4c2748b2d Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:18:35 +0200 Subject: [PATCH 077/160] chore(manifest): record CFNetwork session patch ownership --- upstream/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upstream/manifest.json b/upstream/manifest.json index 0629351..1dd1cac 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -316,7 +316,7 @@ "revision": "e7e3db881008d883f82914765a72ce842bcba735", "repository": "https://github.com/darlinghq/darling-cfnetwork.git", "patch": "patches/cfnetwork.patch", - "modified_files": 1, + "modified_files": 4, "new_files": [] }, { From 70d2d3121f67e6763a09050a922fdee433163b78 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:20:40 +0200 Subject: [PATCH 078/160] test(cfnetwork): scope probe overlays after private-prefix startup --- tests/test_urlsession_runner.py | 8 ++++++-- tools/riot-urlsession-check.py | 11 +++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/test_urlsession_runner.py b/tests/test_urlsession_runner.py index 1c960e4..dc4abf0 100644 --- a/tests/test_urlsession_runner.py +++ b/tests/test_urlsession_runner.py @@ -23,9 +23,13 @@ with open(TRACE, 'a') as stream: stream.write(json.dumps({'args':sys.argv[1:], 'prefix':os.environ['DPREFIX']}) + '\\n') if sys.argv[1] == 'shutdown': raise SystemExit(0) -assert urllib.request.urlopen(sys.argv[2]).read() == b'darling-session-ok' +assert sys.argv[1:3] == ['shell', 'env'] +assert 'DYLD_FRAMEWORK_PATH' not in os.environ and 'DYLD_LIBRARY_PATH' not in os.environ +urls = [arg for arg in sys.argv[1:] if arg.startswith('http://')] +assert len(urls) == 2 +assert urllib.request.urlopen(urls[0]).read() == b'darling-session-ok' try: - urllib.request.urlopen(sys.argv[3], timeout=2) + urllib.request.urlopen(urls[1], timeout=2) except OSError: pass else: diff --git a/tools/riot-urlsession-check.py b/tools/riot-urlsession-check.py index 7545ba1..1f974af 100644 --- a/tools/riot-urlsession-check.py +++ b/tools/riot-urlsession-check.py @@ -54,15 +54,18 @@ Thread(target=server.serve_forever, daemon=True).start() prefix = Path(tempfile.mkdtemp(prefix='urlsession-prefix-', dir=root / 'build')) env = {key: os.environ[key] for key in ('PATH', 'HOME', 'USER', 'LOGNAME', 'DISPLAY', 'XAUTHORITY', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'LANG') if key in os.environ} -env.update(DPREFIX=str(prefix), EGL_PLATFORM='x11', LC_ALL='en_US.UTF-8', - DYLD_FRAMEWORK_PATH='/Volumes/SystemRoot' + str(frameworks.resolve()), - DYLD_LIBRARY_PATH='/Volumes/SystemRoot' + str(libraries.resolve())) +env.update(DPREFIX=str(prefix), EGL_PLATFORM='x11', LC_ALL='en_US.UTF-8') log = args.log result = None try: with log.open('w') as stream: os.chmod(log, 0o600) - result = subprocess.run(['darling', str(args.probe.resolve()), + # Apply overlays to the probe only, after the private namespace boots. + # launchd/shellspawn must start against the installed base runtime. + result = subprocess.run(['darling', 'shell', 'env', + 'DYLD_FRAMEWORK_PATH=/Volumes/SystemRoot' + str(frameworks.resolve()), + 'DYLD_LIBRARY_PATH=/Volumes/SystemRoot' + str(libraries.resolve()), + '/Volumes/SystemRoot' + str(args.probe.resolve()), f'http://127.0.0.1:{server.server_port}/payload', f'http://127.0.0.1:{reserved.getsockname()[1]}/payload'], env=env, stdout=stream, stderr=subprocess.STDOUT, timeout=90) From 9c2ceeafd0d071e804d4647da90883a08be65f1f Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:27:41 +0200 Subject: [PATCH 079/160] fix(indium): preserve device field offsets and clarify tuning defaults --- docs/SETUP.md | 2 +- patches/metal--deps--indium.patch | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 1d0cbd0..3a246f9 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -124,7 +124,7 @@ sh build/runtime/tools/install-riot-metal-pair.sh The installer copies `build/riot-Metal` and `build/riot-libindium.dylib` into the guest `RuntimeFrameworks/Metal.framework` and `RuntimeLibraries`. It backs up the pair it replaces in `build/metal-pair-backups`; a repeated install keeps the older backup. `sh build/runtime/tools/install-riot-metal-pair.sh --rollback` restores that backup. `tools/install-riot-gpu-runtime.sh` still builds and installs the whole GPU set, including these two. Activation needs the namespace restart below. -Indium also contains experimental hand-off changes. They are **off by default**: without the files below, libindium behaves like the build measured before them, and two matches with them on had lower FPS. Each one is a guest file, read once per game process when the game starts. +The hand-off experiments (`metal-present-dispatch-wait`, `metal-deferred-blit` and `metal-worker-lease`) are **off by default**; two matches with them enabled had lower FPS. The table also lists diagnostics and tuning controls with their own defaults. Lazy event-loop wake-ups are **on by default** and `metal-no-lazy-wakeup` disables them. Files are read once per game process at startup unless an entry specifies a different refresh interval. | Guest file | Effect when present | |---|---| diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index 72665e9..f3c149f 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -781,7 +781,7 @@ index 8546daa..7e4993c 100644 void updateBindings(); diff --git a/private-include/indium/device.private.hpp b/private-include/indium/device.private.hpp -index 58f1794..3e7ee47 100644 +index 58f1794..75266fd 100644 --- a/private-include/indium/device.private.hpp +++ b/private-include/indium/device.private.hpp @@ -13,6 +13,8 @@ @@ -793,7 +793,7 @@ index 58f1794..3e7ee47 100644 namespace Indium { class PrivateDevice; -@@ -25,8 +27,14 @@ namespace Indium { +@@ -25,8 +27,11 @@ namespace Indium { class PrivateDevice: public Device, public std::enable_shared_from_this { private: @@ -802,13 +802,10 @@ index 58f1794..3e7ee47 100644 std::mutex _eventLoopMutex; std::mutex _pollingMutex; + uint64_t _completionSpinNanoseconds = 0; -+ // Sequentially consistent handshake: either re-snapshot or signal. -+ std::atomic _eventLoopRegistrations {0}; -+ std::atomic _eventLoopBlockedPollers {0}; // these are stored separately (rather than in a single structure) // so that we can easily copy them individually in the event loop polling function. -@@ -59,6 +67,8 @@ namespace Indium { +@@ -59,6 +64,8 @@ namespace Indium { } virtual std::string name() const override; @@ -817,7 +814,7 @@ index 58f1794..3e7ee47 100644 virtual std::shared_ptr newCommandQueue() override; virtual std::shared_ptr newRenderPipelineState(const RenderPipelineDescriptor& descriptor) override; virtual std::shared_ptr newComputePipelineState(const ComputePipelineDescriptor& descriptor, PipelineOption options, std::shared_ptr reflection) override; -@@ -73,6 +83,12 @@ namespace Indium { +@@ -73,6 +80,12 @@ namespace Indium { virtual void pollEvents(uint64_t timeoutNanoseconds) override; virtual void wakeupEventLoop() override; @@ -830,7 +827,7 @@ index 58f1794..3e7ee47 100644 void waitForSemaphore(VkSemaphore semaphore, uint64_t targetValue, std::function callback); /** -@@ -86,6 +102,11 @@ namespace Indium { +@@ -86,6 +99,11 @@ namespace Indium { * Similar to getTimelineSemaphore(), but wraps it in a shared pointer * that automatically releases the semaphore when all references to it die out. */ @@ -842,7 +839,7 @@ index 58f1794..3e7ee47 100644 std::shared_ptr getWrappedTimelineSemaphore(); BinarySemaphore getBinarySemaphore(bool exportable = false); -@@ -111,5 +132,34 @@ namespace Indium { +@@ -111,5 +129,37 @@ namespace Indium { INDIUM_PROPERTY(VkPhysicalDeviceMemoryProperties, m, M,emoryProperties); INDIUM_PROPERTY_READONLY(Feature, f, F,eatures); @@ -875,6 +872,9 @@ index 58f1794..3e7ee47 100644 + std::vector> _availableTimelineSemaphores; + std::mutex _renderPassMutex; + std::unordered_map _renderPasses; ++ // Sequentially consistent handshake: either re-snapshot or signal. ++ std::atomic _eventLoopRegistrations {0}; ++ std::atomic _eventLoopBlockedPollers {0}; }; }; diff --git a/private-include/indium/dynamic-vk.hpp b/private-include/indium/dynamic-vk.hpp From 4c1f4a44c55be44cac8cf6d6fc5f480fb8ddbc60 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:38:15 +0200 Subject: [PATCH 080/160] fix(test): let Darling initialize the private session prefix --- docs/TESTING.md | 10 +++++++++- tests/test_urlsession_runner.py | 6 +++++- tools/riot-urlsession-check.py | 11 +++++++---- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index e0824de..8a544f9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -41,7 +41,9 @@ python3 tools/riot-urlsession-check.py ``` The runner starts a loopback-only HTTP fixture, reserves a second non-listening -port for the transport-error case, and creates a fresh private DPREFIX. It runs +port for the transport-error case, and reserves a private parent directory with +an initially absent DPREFIX. Darling must create DPREFIX itself to initialize +guest accounts and configuration; an existing empty directory skips that setup. It runs only the independent probe, then shuts down that private prefix. It never starts Riot/League, queries their APIs, sends game input or shuts down the normal prefix. The fixture delays its response so session invalidation is @@ -61,6 +63,12 @@ suspended custom delegate queue. Expected output includes `URLSESSION_PASS`, `LIFECYCLE_PASS`, `SHARED_SESSION_PASS` and `QUEUE_PASS`. The original runtime aborts on missing data-task initialization or session invalidation. +Native validation on 2026-10-18 passed all four markers using a staged, patched +CFNetwork framework and the installed Darling base runtime. No installed SDK +libraries were replaced. Darling shutdown may leave a root-owned overlay work +directory; the runner reports retained private artifacts if cleanup cannot +remove it. + These checks do not establish full NSURLSession conformance or transport-level interruption of an already-running request. diff --git a/tests/test_urlsession_runner.py b/tests/test_urlsession_runner.py index dc4abf0..ac7d900 100644 --- a/tests/test_urlsession_runner.py +++ b/tests/test_urlsession_runner.py @@ -23,6 +23,10 @@ with open(TRACE, 'a') as stream: stream.write(json.dumps({'args':sys.argv[1:], 'prefix':os.environ['DPREFIX']}) + '\\n') if sys.argv[1] == 'shutdown': raise SystemExit(0) +from pathlib import Path +prefix = Path(os.environ['DPREFIX']) +assert not prefix.exists(), 'Darling must receive an absent prefix for first-time initialization' +prefix.mkdir() assert sys.argv[1:3] == ['shell', 'env'] assert 'DYLD_FRAMEWORK_PATH' not in os.environ and 'DYLD_LIBRARY_PATH' not in os.environ urls = [arg for arg in sys.argv[1:] if arg.startswith('http://')] @@ -53,7 +57,7 @@ print('QUEUE_PASS: fixture') self.assertEqual(calls[1]['args'], ['shutdown']) self.assertEqual(calls[0]['prefix'], calls[1]['prefix']) self.assertNotEqual(Path(calls[0]['prefix']), Path.home() / '.darling') - self.assertFalse(Path(calls[0]['prefix']).exists()) + self.assertFalse(Path(calls[0]['prefix']).parent.exists()) self.assertEqual(stat.S_IMODE(log.stat().st_mode), 0o600) diff --git a/tools/riot-urlsession-check.py b/tools/riot-urlsession-check.py index 1f974af..dddcf77 100644 --- a/tools/riot-urlsession-check.py +++ b/tools/riot-urlsession-check.py @@ -51,7 +51,10 @@ reserved = socket.socket() reserved.bind(('127.0.0.1', 0)) # Reserved but not listening: deterministic connection refusal. Thread(target=server.serve_forever, daemon=True).start() (root / 'build').mkdir(exist_ok=True) -prefix = Path(tempfile.mkdtemp(prefix='urlsession-prefix-', dir=root / 'build')) +# Darling initializes account/configuration files only for an absent prefix. +# Reserve its private parent, not DPREFIX itself. +private_root = Path(tempfile.mkdtemp(prefix='urlsession-', dir=root / 'build')) +prefix = private_root / 'prefix' env = {key: os.environ[key] for key in ('PATH', 'HOME', 'USER', 'LOGNAME', 'DISPLAY', 'XAUTHORITY', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'LANG') if key in os.environ} env.update(DPREFIX=str(prefix), EGL_PLATFORM='x11', LC_ALL='en_US.UTF-8') @@ -84,8 +87,8 @@ finally: try: stopped = subprocess.run(['darling', 'shutdown'], env=env, capture_output=True, timeout=25) if stopped.returncode == 0: - shutil.rmtree(prefix) + shutil.rmtree(private_root) else: - print('Private test prefix retained for inspection:', prefix) + print('Private test artifacts retained for inspection:', private_root) except (OSError, subprocess.SubprocessError): - print('Private test prefix retained for inspection:', prefix) + print('Private test artifacts retained for inspection:', private_root) From ac89ac78ed8721c0e6b76d7711dcc91a74255943 Mon Sep 17 00:00:00 2001 From: David Gheghea <71325082+DavidutzDev@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:54:03 +0200 Subject: [PATCH 081/160] fix(nvidia): keep threaded optimizations off for driver 580 With __GL_THREADED_OPTIMIZATIONS=1, NVIDIA 580 (the last branch for Pascal GPUs) with egl-x11 1.0.6 rejects the X11 EGLDisplay with EGL_BAD_DISPLAY on every thread that has a context current. A native EGL program reproduces it outside Darling. In the Riot client the main thread loses EGL after its first window frame, so CGLCreateContext fails with error 10004 and the game window stays black. Default the launcher to 0; setting the variable restores 1. CGLDestroyWindow also returns early for the NULL window CGLGetWindow gives when eglCreateWindowSurface fails, instead of passing EGL_NO_SURFACE on. Co-Authored-By: Claude Opus 5.5 --- docs/TROUBLESHOOTING.md | 21 +++++++++++++++++++++ patches/darling.patch | 27 +++++++++++++++------------ tools/run-riot-client.sh | 5 ++++- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 7d8b3f4..3075637 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -348,6 +348,27 @@ with sway/Xwayland using CPU readback. The initial polling change improved their reported rate from about 20 to 27 FPS. Those observations predate the narrowed integration; they do not establish hybrid-GPU or general NVIDIA support. +### NVIDIA 580 (Pascal and older): black game window + +Driver 580 is the last branch for Pascal (GTX 10-series) and older GPUs. With +`__GL_THREADED_OPTIMIZATIONS=1`, this driver with egl-x11 1.0.6 rejects the X11 +`EGLDisplay` with `EGL_BAD_DISPLAY` on every thread that has a context current. +The display stays valid on other threads, and `eglReleaseThread()` makes it work +again on the failing one. A small native EGL program outside Darling shows the +same: `eglQueryString(display, EGL_VENDOR)` after `eglMakeCurrent` returns +`NVIDIA` without the variable and fails with `EGL_BAD_DISPLAY` with it. + +In the Riot client the main thread loses EGL right after its first window frame; +`CGLCreateContext` then fails with CGL error 10004 and, if another display is +opened, glvnd hands it to Mesa's software renderer, which cannot present the +game's NVIDIA Vulkan frames. The launcher therefore leaves threaded optimizations +off (`__GL_THREADED_OPTIMIZATIONS=0`). Setting the variable to `1` before launch +restores the old behaviour. + +Tested on a GTX 1060 6GB with driver 580.178.04 on NixOS, Hyprland and Xwayland: +the client and the tutorial match render on the NVIDIA GPU. Newer drivers were not +retested with the option off. + ### Host-side checks These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice. diff --git a/patches/darling.patch b/patches/darling.patch index 66477f9..49e767f 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -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..1ff212d34 100644 +index ffd7fe054..5b5712ea2 100644 --- a/src/frameworks/OpenGL/OpenGL.c +++ b/src/frameworks/OpenGL/OpenGL.c @@ -2,11 +2,22 @@ @@ -6527,11 +6527,14 @@ index ffd7fe054..1ff212d34 100644 if (surface == EGL_NO_SURFACE) { return NULL; -@@ -171,14 +309,18 @@ CGLWindowRef CGLGetWindow(void *native_window) { +@@ -171,14 +309,21 @@ CGLWindowRef CGLGetWindow(void *native_window) { } CGL_EXPORT void CGLDestroyWindow(CGLWindowRef window) { - eglDestroySurface(display, (EGLSurface) window); ++ /* CGLGetWindow returns NULL when eglCreateWindowSurface fails. */ ++ if (window == NULL) ++ return; + p_eglDestroySurface(display, (EGLSurface) window); } @@ -6548,7 +6551,7 @@ index ffd7fe054..1ff212d34 100644 } static pthread_key_t current_context_key; -@@ -198,18 +340,42 @@ CGLContextObj CGLGetCurrentContext(void) { +@@ -198,18 +343,42 @@ CGLContextObj CGLGetCurrentContext(void) { return pthread_getspecific(key); } @@ -6596,7 +6599,7 @@ index ffd7fe054..1ff212d34 100644 } CGLError CGLSetFullScreen(CGLContextObj ctx) { -@@ -223,11 +389,13 @@ CGLError CGLChoosePixelFormat( +@@ -223,11 +392,13 @@ CGLError CGLChoosePixelFormat( CGLPixelFormatObj *result, GLint *number_of_screens ) { @@ -6611,7 +6614,7 @@ index ffd7fe054..1ff212d34 100644 for (int i = 0; i < count; i++) { format->attributes[i] = attrs[i]; } -@@ -251,6 +419,10 @@ CGLError CGLDescribePixelFormat( +@@ -251,6 +422,10 @@ CGLError CGLDescribePixelFormat( CGLPixelFormatAttribute attr, GLint *value ) { @@ -6622,7 +6625,7 @@ index ffd7fe054..1ff212d34 100644 for (int i = 0; format->attributes[i] != 0; i++) { int has_arg = attribute_has_argument(format->attributes[i]); -@@ -304,24 +476,67 @@ GLuint CGLGetPixelFormatRetainCount(CGLPixelFormatObj pixelFormat) { +@@ -304,24 +479,67 @@ GLuint CGLGetPixelFormatRetainCount(CGLPixelFormatObj pixelFormat) { } CGLError CGLCreateContext(CGLPixelFormatObj pixelFormat, CGLContextObj share, CGLContextObj *resultp) { @@ -6691,7 +6694,7 @@ index ffd7fe054..1ff212d34 100644 *resultp = context; -@@ -333,7 +548,7 @@ CGLContextObj CGLRetainContext(CGLContextObj context) { +@@ -333,7 +551,7 @@ CGLContextObj CGLRetainContext(CGLContextObj context) { return NULL; } @@ -6700,7 +6703,7 @@ index ffd7fe054..1ff212d34 100644 return context; } -@@ -342,11 +557,17 @@ void CGLReleaseContext(CGLContextObj context) { +@@ -342,11 +560,17 @@ void CGLReleaseContext(CGLContextObj context) { return; } @@ -6721,7 +6724,7 @@ index ffd7fe054..1ff212d34 100644 if (CGLGetCurrentContext() == context) { CGLSetCurrentContext(NULL); -@@ -354,7 +575,11 @@ void CGLReleaseContext(CGLContextObj context) { +@@ -354,7 +578,11 @@ void CGLReleaseContext(CGLContextObj context) { pthread_mutex_destroy(&(context->lock)); @@ -6734,7 +6737,7 @@ index ffd7fe054..1ff212d34 100644 free(context); } -@@ -383,9 +608,330 @@ CGLError CGLUnlockContext(CGLContextObj context) { +@@ -383,9 +611,330 @@ CGLError CGLUnlockContext(CGLContextObj context) { return kCGLNoError; } @@ -7067,7 +7070,7 @@ index ffd7fe054..1ff212d34 100644 } CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, const GLint *value) { -@@ -394,8 +940,9 @@ CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, c +@@ -394,8 +943,9 @@ CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, c if (parameter == kCGLCPSwapInterval) { @@ -7078,7 +7081,7 @@ index ffd7fe054..1ff212d34 100644 if (success) context->swap_interval = v; return success ? kCGLNoError : kCGLBadValue; -@@ -417,14 +964,19 @@ CGLError CGLGetParameter(CGLContextObj context, CGLContextParameter parameter, G +@@ -417,14 +967,19 @@ CGLError CGLGetParameter(CGLContextObj context, CGLContextParameter parameter, G return kCGLNoError; } diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 75b6ec7..94ea1be 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -92,7 +92,10 @@ if [ -n "${RCLOL_REFRESH_HZ-}" ]; then export RCLOL_REFRESH_HZ; fi export MESA_NO_ERROR="${MESA_NO_ERROR:-1}" # NVIDIA proprietary driver: limit pre-rendered frames. export __GL_MaxFramesAllowed="${__GL_MaxFramesAllowed:-1}" -export __GL_THREADED_OPTIMIZATIONS="${__GL_THREADED_OPTIMIZATIONS:-1}" +# Threaded optimizations stay off: with them, NVIDIA 580 (the last branch for +# Pascal GPUs) with egl-x11 1.0.6 rejects the X11 EGLDisplay with EGL_BAD_DISPLAY +# on every thread that has a context current, and the game window stays black. +export __GL_THREADED_OPTIMIZATIONS="${__GL_THREADED_OPTIMIZATIONS:-0}" export __GL_YIELD="${__GL_YIELD:-NOTHING}" # Reduce timer slack for tighter scheduling in the game process tree. if [ -w /proc/self/timerslack_ns ]; then From eb17acce6ede93e36d056421f1c8a4c5626b31c4 Mon Sep 17 00:00:00 2001 From: David Gheghea <71325082+DavidutzDev@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:54:25 +0200 Subject: [PATCH 082/160] fix(launcher): give Chromium a usable temp directory Chromium on macOS takes its temp directory from NSTemporaryDirectory(), which is unusable under Darling. Electron opens files in app.asar asynchronously by copying them to a temp file, and reports the failed copy as "not found", so the Riot client showed a "Not Found" page instead of its UI. Running the client binary as Node reproduces it: fs.readFileSync on app.asar/dist/renderer/index.html works, fs.createReadStream fails with ENOENT, and MAC_CHROMIUM_TMPDIR fixes it. Co-Authored-By: Claude Opus 5.5 --- docs/TROUBLESHOOTING.md | 6 ++++++ tools/run-riot-client.sh | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 7d8b3f4..fbde23d 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -161,6 +161,12 @@ LaunchServices dereferenced a nullable error-output pointer in six paths. The pa LaunchServices now opens `http`, `https` and `mailto` links on the Linux desktop itself. It loads the host's `libsystemd.so.0` and asks `xdg-desktop-portal` (`org.freedesktop.portal.OpenURI`) on the user session bus to open the link, so your default browser starts outside Darling and survives `darling shutdown`. Without a portal, it starts `xdg-open` in a transient systemd user unit instead. It also reports a handler for these schemes, since Electron checks for one before opening a link. Other schemes, control characters and URLs over 4096 bytes are rejected. If a link still does nothing, check the client's stderr for a `LaunchServices:` line and that `busctl --user status org.freedesktop.portal.Desktop` works on the host. Both LaunchServices and AppKit must be rebuilt. The hand-off passed the host-side D-Bus regression and opened the host browser from a guest through both `open(1)` and `NSWorkspace`; a link clicked in the Riot client is not yet confirmed. The null-pointer fix does not explain every unrelated button crash. +## Riot client shows "Not Found" instead of its UI + +The client window opens but only shows `Not Found`, and its log has `ENOENT, dist/renderer/index.html not found in …/app.asar` although the archive contains that file. Electron cannot hand out a descriptor into `app.asar`, so its asynchronous `fs.open` copies the file to a temporary file first (`copyFileOut`) and reports a failed copy as "not found". Chromium on macOS takes its temporary directory from `NSTemporaryDirectory()`, not `TMPDIR`, and that directory is unusable here, so every copy fails. Synchronous reads such as `fs.readFileSync` do not copy and keep working, which is why the rest of the client starts. + +The launcher sets `MAC_CHROMIUM_TMPDIR=/private/tmp`, which Chromium checks first; `scripts/install-runtime.sh` already creates that guest directory with mode `1777`. To check the archive outside the client, run the client binary as Node (`ELECTRON_RUN_AS_NODE=1`) with a script that calls `fs.createReadStream` on a file inside `app.asar`: it fails without the variable and reads the file with it. + ## Language dropdown closes Riot, or client resize leaves a clipped UI The [report in issue #1](https://github.com/lucannez64/darling-lol/issues/1#issuecomment-6194498757) contains an Electron renderer abort with `Symbol not found: _CTFontDescriptorCreateMatchingFontDescriptors`. CoreText now exports font-descriptor matching and localized-attribute lookup, and enumerates installed font families through fontconfig instead of returning a null stub. Localized lookup currently falls back to canonical font names. The standalone font-list check exercises Chromium's sequence, including mandatory family matching and retained results. diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 75b6ec7..21fc89f 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -98,7 +98,13 @@ export __GL_YIELD="${__GL_YIELD:-NOTHING}" if [ -w /proc/self/timerslack_ns ]; then echo 1 > /proc/self/timerslack_ns 2>/dev/null || true fi +# Chromium takes its temp directory from NSTemporaryDirectory(), which is unusable +# under Darling, so every temp file fails. Electron opens files in app.asar +# asynchronously by copying them to a temp file and reports the failed copy as +# "not found": the Riot client then shows a "Not Found" page instead of its UI. +# MAC_CHROMIUM_TMPDIR overrides the directory. exec env -u ELECTRON_RUN_AS_NODE -u DYLD_INSERT_LIBRARIES EGL_PLATFORM=x11 \ + MAC_CHROMIUM_TMPDIR="${MAC_CHROMIUM_TMPDIR:-/private/tmp}" \ LANG="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" LC_ALL="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" \ mesa_glthread="${RCLOL_MESA_GLTHREAD-${mesa_glthread-false}}" \ XMODIFIERS="${RCLOL_XMODIFIERS:-@im=none}" DISPLAY="${DISPLAY:-:1}" \ From 4d9a5d4b507d009e0f7ce0e40aa90d51256b8700 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:10:04 +0200 Subject: [PATCH 083/160] fix(launcher): disable inherited MangoHud fdinfo scans --- docs/TROUBLESHOOTING.md | 33 ++++++++++++++++++++++++++++ tools/riot-launcher-offline-check.py | 16 +++++++++++--- tools/run-riot-client.sh | 5 +++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 7d8b3f4..14515f8 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -422,3 +422,36 @@ Recovery requires every matching client to be at least 60 seconds old and a complete Hyprland window record (`class`, `title`, `mapped`, `hidden`). A second process scan after window inspection must find the same client identities and no game. Missing information or shutdown failure leaves normal launch in charge. + +## Installer aborts on `/proc//fdinfo` + +[Issue #38](https://github.com/lucannez64/darling-lol/issues/38) reports an +uncaught `ghc::filesystem::filesystem_error` with `Permission denied` on +`/proc//fdinfo`. `ghc::filesystem` is a C++ filesystem library, not evidence +that the Riot installer is written in Haskell. + +An independent guest/native Vulkan probe reproduced this exact exception with +MangoHud 0.8.4 enabled on 2026-10-18. MangoHud's `GPU_fdinfo::find_fd()` builds +`/proc//fdinfo` and uses a throwing directory iterator. Inside Darling, +the native library's PID is in the guest PID namespace while its native `/proc` +view can still refer to host processes: the probe's native PID 35 resolved to an +inaccessible host process. The same probe completed Vulkan instance, device and +swapchain creation with MangoHud disabled. This does not establish the cause of +every installer abort; confirmation from the original reporter remains pending. + +The normal launcher now unsets `MANGOHUD` and exports `DISABLE_MANGOHUD=1` before +runtime helpers start, including first-time installation. This disables inherited +implicit MangoHud activation for the launched session without changing the host +configuration. Do not wrap the launcher in `mangohud` or explicitly force its +Vulkan layer. Updating these launcher scripts needs no Darling rebuild. For an +older checkout, use: + +```sh +env -u MANGOHUD DISABLE_MANGOHUD=1 ./scripts/installer.sh euw +``` + +Do not run the installer as root or change `/proc` permissions. If it still +fails, include the exception and whether a host overlay was enabled; redact +account tokens from logs. + +Upstream source: [MangoHud 0.8.4 fdinfo scan](https://github.com/flightlessmango/MangoHud/blob/v0.8.4/src/gpu_fdinfo.cpp#L9). diff --git a/tools/riot-launcher-offline-check.py b/tools/riot-launcher-offline-check.py index 1254b7a..1d661e7 100644 --- a/tools/riot-launcher-offline-check.py +++ b/tools/riot-launcher-offline-check.py @@ -22,9 +22,9 @@ def check(): stub = f'''#!{sys.executable} import json, os, sys record = {{"command": os.path.basename(sys.argv[0]), "args": sys.argv[1:]}} -if record["command"] == "darling": +if record["command"] in ("darling", "python3"): record["flags"] = {{key: os.environ.get(key) for key in - ("RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT", "RCLOL_UX_ARG1", "RCLOL_APP_ARGS", "RCLOL_APP_ARG2", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "DARLING_XSHM_LIBRARY")}} + ("RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT", "RCLOL_UX_ARG1", "RCLOL_APP_ARGS", "RCLOL_APP_ARG2", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "DARLING_XSHM_LIBRARY", "MANGOHUD", "DISABLE_MANGOHUD")}} with open({str(log)!r}, "a") as output: output.write(json.dumps(record) + "\\n") ''' @@ -46,6 +46,8 @@ with open({str(log)!r}, "a") as output: env["DYLD_INSERT_LIBRARIES"] = "/Users/offline-check/zzlogdir.dylib:/tmp/inherited.dylib" env["RCLOL_APP_ARGS"] = "--inherited-shim-argument" env["RCLOL_APP_ARG2"] = "--inherited-shim-argument-2" + env["MANGOHUD"] = "1" + env.pop("DISABLE_MANGOHUD", None) env["RCLOL_NATIVE_VPX"] = "1" env["RCLOL_LEAGUE_UX_TUNING"] = "1" # Inherited legacy settings must not re-enable inspection or unlimited frames. @@ -69,6 +71,9 @@ with open({str(log)!r}, "a") as output: ], "unexpected automatic helper" launches = [r for r in records if r["command"] == "darling"] assert len(launches) == 1, "unexpected launch count" + for record in records: + assert record["flags"]["MANGOHUD"] is None, "inherited MangoHud activation survived" + assert record["flags"]["DISABLE_MANGOHUD"] == "1", "MangoHud not disabled before a runtime helper" launch = launches[0] assert all(launch["flags"][key] is None for key in ( "RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT" @@ -84,18 +89,23 @@ with open({str(log)!r}, "a") as output: assert "--log-dir=/Users/offline-check/Library/Logs/Riot Games/Riot Client" in launch["args"], "absolute log directory lost" # An inherited injection must also be absent for first-time installation. env["RIOT_LAUNCHER_HOST"] = str(folder / "Install Riot.app/Contents/MacOS/RiotClientServices") + env["DISABLE_MANGOHUD"] = "0" # An inherited value cannot override launcher policy. subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True) install = json.loads(log.read_text().splitlines()[-1]) assert install["args"][0] == env["RIOT_LAUNCHER_HOST"] assert "--skip-to-install" in install["args"] assert not any(arg.startswith("--app-root=") for arg in install["args"]) assert install["flags"]["DYLD_INSERT_LIBRARIES"] is None + assert install["flags"]["MANGOHUD"] is None + assert install["flags"]["DISABLE_MANGOHUD"] == "1" env.pop("RIOT_LAUNCHER_HOST") env.pop("DYLD_INSERT_LIBRARIES") subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True) clean = json.loads(log.read_text().splitlines()[-1]) assert clean["flags"]["DYLD_INSERT_LIBRARIES"] is None - print("LAUNCHER_OFFLINE pass=1 installed=PASS installer=PASS inherited_injection=absent runtime=preserved") + assert clean["flags"]["MANGOHUD"] is None + assert clean["flags"]["DISABLE_MANGOHUD"] == "1" + print("LAUNCHER_OFFLINE pass=1 installed=PASS installer=PASS inherited_injection=absent mangohud=disabled runtime=preserved") if __name__ == "__main__": diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 75b6ec7..f33a612 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -2,6 +2,11 @@ # Launch Riot Client using the Darling runtime, without library injection. # Darling supplies Xlib Display pointers to EGL, so select X11 explicitly. set -eu +# Native MangoHud uses the guest PID against the host /proc view and can abort +# in GPU_fdinfo::find_fd (issue #38). Disable inherited implicit activation +# before any helper can start Darling, including first-time installation. +unset MANGOHUD +export DISABLE_MANGOHUD=1 # Native guest crashes created large system core files and exhausted this disk. # Address-only diagnostics suffice; bound this runner and its new namespace. if [ "${RCLOL_CORE_DUMPS:-0}" != 1 ]; then From c29f2fc81d6fe1ef83d8af3839897d0e1b2b6cf5 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:17:46 +0200 Subject: [PATCH 084/160] docs(launcher): scope Chromium temp failures and use standalone checks --- docs/TROUBLESHOOTING.md | 4 ++-- tools/run-riot-client.sh | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index fbde23d..d01d86e 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -163,9 +163,9 @@ LaunchServices now opens `http`, `https` and `mailto` links on the Linux desktop ## Riot client shows "Not Found" instead of its UI -The client window opens but only shows `Not Found`, and its log has `ENOENT, dist/renderer/index.html not found in …/app.asar` although the archive contains that file. Electron cannot hand out a descriptor into `app.asar`, so its asynchronous `fs.open` copies the file to a temporary file first (`copyFileOut`) and reports a failed copy as "not found". Chromium on macOS takes its temporary directory from `NSTemporaryDirectory()`, not `TMPDIR`, and that directory is unusable here, so every copy fails. Synchronous reads such as `fs.readFileSync` do not copy and keep working, which is why the rest of the client starts. +On affected setups, including the reported NixOS installation, the client window opens but only shows `Not Found`, and its log has `ENOENT, dist/renderer/index.html not found in …/app.asar` although the archive contains that file. Electron's asynchronous file opening can copy an archive entry to a temporary file first (`copyFileOut`), and a failed copy can be reported as "not found". Chromium checks `MAC_CHROMIUM_TMPDIR` before falling back to `NSTemporaryDirectory()`; an unusable default temporary directory can therefore produce this symptom even while synchronous archive reads work. -The launcher sets `MAC_CHROMIUM_TMPDIR=/private/tmp`, which Chromium checks first; `scripts/install-runtime.sh` already creates that guest directory with mode `1777`. To check the archive outside the client, run the client binary as Node (`ELECTRON_RUN_AS_NODE=1`) with a script that calls `fs.createReadStream` on a file inside `app.asar`: it fails without the variable and reads the file with it. +The launcher defaults `MAC_CHROMIUM_TMPDIR` to `/private/tmp`, preserving a non-empty override. `scripts/install-runtime.sh` creates that guest directory with mode `1777`. Verify directory creation and temporary-file writes with a standalone guest probe, then launch the client normally and check whether its UI loads. Do not run diagnostic scripts through Riot's executable. This workaround addresses temporary-file failures; an actually missing or damaged archive needs separate investigation. ## Language dropdown closes Riot, or client resize leaves a clipped UI diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 21fc89f..770568d 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -98,11 +98,9 @@ export __GL_YIELD="${__GL_YIELD:-NOTHING}" if [ -w /proc/self/timerslack_ns ]; then echo 1 > /proc/self/timerslack_ns 2>/dev/null || true fi -# Chromium takes its temp directory from NSTemporaryDirectory(), which is unusable -# under Darling, so every temp file fails. Electron opens files in app.asar -# asynchronously by copying them to a temp file and reports the failed copy as -# "not found": the Riot client then shows a "Not Found" page instead of its UI. -# MAC_CHROMIUM_TMPDIR overrides the directory. +# On affected setups Chromium's default temp directory is unusable. Electron +# can copy app.asar entries to temporary files for async reads and report a +# failed copy as "not found". Use a writable guest directory by default. exec env -u ELECTRON_RUN_AS_NODE -u DYLD_INSERT_LIBRARIES EGL_PLATFORM=x11 \ MAC_CHROMIUM_TMPDIR="${MAC_CHROMIUM_TMPDIR:-/private/tmp}" \ LANG="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" LC_ALL="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" \ From fe90039a6377b25edae271b5efedb385a124819a Mon Sep 17 00:00:00 2001 From: David Gheghea <71325082+DavidutzDev@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:47:45 +0200 Subject: [PATCH 085/160] fix(nix): let EGL find NixOS GPU drivers in the FHS environment NixOS keeps the GPU driver's EGL vendor files and external-platform files under /run/opengl-driver, which glvnd and NVIDIA's driver inside the FHS environment do not search. glvnd then only knows Mesa, and NVIDIA cannot open X11 displays because its X11 support is the egl-x11 external platform. The client fell back to Mesa's software rasterizer: garbled text in the client and no way to present the game's NVIDIA Vulkan frames. When /run/opengl-driver provides them, prepend those directories to __EGL_VENDOR_LIBRARY_DIRS and __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS, keeping the FHS paths and any caller-provided value after them. Co-Authored-By: Claude Opus 5.5 --- flake.nix | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/flake.nix b/flake.nix index 54f5ec8..cb1b11f 100644 --- a/flake.nix +++ b/flake.nix @@ -160,6 +160,18 @@ export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/pkgconfig:/usr/lib64/pkgconfig:/usr/share/pkgconfig:''${PKG_CONFIG_PATH:-}" export CMAKE_PREFIX_PATH="/usr/local:/usr:/usr/lib/cmake/llvm:''${CMAKE_PREFIX_PATH:-}" unset NIX_LDFLAGS NIX_CFLAGS_COMPILE NIX_HARDENING_ENABLE CPATH LIBRARY_PATH + # NixOS keeps the GPU driver's EGL vendor and external-platform files under + # /run/opengl-driver, which glvnd and NVIDIA's driver in this FHS environment + # do not search. Without them glvnd only finds Mesa, and NVIDIA cannot open + # X11 displays (its X11 support is the egl-x11 external platform): the + # client renders with Mesa's software rasterizer, which garbles text and + # cannot present the game's NVIDIA Vulkan frames. FHS paths stay as fallback. + if [ -d /run/opengl-driver/share/glvnd/egl_vendor.d ]; then + export __EGL_VENDOR_LIBRARY_DIRS="/run/opengl-driver/share/glvnd/egl_vendor.d:/etc/glvnd/egl_vendor.d:/usr/share/glvnd/egl_vendor.d''${__EGL_VENDOR_LIBRARY_DIRS:+:$__EGL_VENDOR_LIBRARY_DIRS}" + fi + if [ -d /run/opengl-driver/share/egl/egl_external_platform.d ]; then + export __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS="/run/opengl-driver/share/egl/egl_external_platform.d:/etc/egl/egl_external_platform.d:/usr/share/egl/egl_external_platform.d''${__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:+:$__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS}" + fi ''; runScript = pkgs.writeShellScript "darling-lol-run" '' From 15c7d79e16a4b6203083a618787576801e701b5a Mon Sep 17 00:00:00 2001 From: Polar <343746874+PolarSeeker77@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:54:46 +0000 Subject: [PATCH 086/160] AppKit: run drags in-process when the backend has no drag manager The X11 backend has no NSDraggingManager, so -[NSWindow dragImage:...] returned at once without calling -draggedImage:endedAt:operation:. Chromium waits for that callback before the page takes mouse input again, so any HTML5 drag in a CEF/Electron window (the League client's emote wheel) left the page ignoring the mouse. NSWindow now runs the drag itself between the app's own windows, with the drag image under the pointer, and always ends it with the callback. X11Pasteboard sends the optional -pasteboardChangedOwner: only to owners that implement it (Chromium's drag source doesn't; the second drag crashed). NSView's default -prepareForDragOperation: returns YES, as AppKit does for destinations that don't implement it. --- patches/cocotron.patch | 427 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 392 insertions(+), 35 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 83d64b3..9e67845 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -1377,7 +1377,7 @@ index c3f5d056..dbd93174 100644 @"NSTouchBarItemIdentifierCharacterPicker"; diff --git a/AppKit/NSView.m b/AppKit/NSView.m -index 8ac2bbdc..08197fbc 100644 +index 8ac2bbdc..a0fb4f37 100644 --- a/AppKit/NSView.m +++ b/AppKit/NSView.m @@ -46,6 +46,12 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ @@ -1590,11 +1590,47 @@ index 8ac2bbdc..08197fbc 100644 } [_subviews +@@ -2687,6 +2786,17 @@ static NSView *viewBeingPrinted = nil; + slideBack: (BOOL) slideBack + { + location = [self convertPoint: location toView: nil]; ++ if ([NSDraggingManager draggingManager] == nil) { ++ // darling-lol: no drag manager on X11; NSWindow runs the drag in-process. ++ [[self window] dragImage: image ++ at: location ++ offset: offset ++ event: event ++ pasteboard: pasteboard ++ source: source ++ slideBack: slideBack]; ++ return; ++ } + [[NSDraggingManager draggingManager] dragImage: image + at: location + offset: offset +@@ -2783,7 +2893,8 @@ static NSView *viewBeingPrinted = nil; + } + + - (BOOL) prepareForDragOperation: (id) sender { +- return NO; ++ // darling-lol: AppKit goes ahead when a destination doesn't implement this (Chromium). ++ return YES; + } + + - (BOOL) performDragOperation: (id) sender { diff --git a/AppKit/NSWindow.m b/AppKit/NSWindow.m -index d545120a..b2845478 100644 +index d545120a..79760f47 100644 --- a/AppKit/NSWindow.m +++ b/AppKit/NSWindow.m -@@ -45,6 +45,21 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@@ -27,6 +27,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + #import + #import + #import ++#import + #import + #import + #import +@@ -45,6 +46,21 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #import #import #import @@ -1616,7 +1652,7 @@ index d545120a..b2845478 100644 const NSNotificationName NSWindowDidBecomeKeyNotification = @"NSWindowDidBecomeKeyNotification"; -@@ -204,6 +219,25 @@ NSInteger NSBitsPerPixelFromDepth(NSWindowDepth depth) { +@@ -204,6 +220,122 @@ NSInteger NSBitsPerPixelFromDepth(NSWindowDepth depth) { @end @@ -1638,11 +1674,108 @@ index d545120a..b2845478 100644 + } + return view; +} ++ ++/* darling-lol: the X11 backend has no NSDraggingManager, so drags run in-process ++ * (see -_localDragImage:...): this is the NSDraggingInfo handed to destinations. */ ++@interface _NSLocalDraggingInfo : NSObject { ++ @public ++ NSPasteboard *_pasteboard; ++ NSImage *_image; ++ id _source; ++ NSWindow *_destinationWindow; ++ NSPoint _screenPoint; ++ NSDragOperation _sourceMask; ++} ++@end ++ ++@implementation _NSLocalDraggingInfo ++ ++- (NSPasteboard *) draggingPasteboard { ++ return _pasteboard; ++} ++ ++- (NSDragOperation) draggingSourceOperationMask { ++ return _sourceMask; ++} ++ ++- (NSPoint) draggingLocation { ++ if (_destinationWindow == nil) ++ return _screenPoint; ++ return [_destinationWindow convertScreenToBase: _screenPoint]; ++} ++ ++- (NSImage *) draggedImage { ++ return _image; ++} ++ ++- (NSPoint) draggedImageLocation { ++ return [self draggingLocation]; ++} ++ ++- (NSWindow *) draggingDestinationWindow { ++ return _destinationWindow; ++} ++ ++- (id) draggingSource { ++ return _source; ++} ++ ++- (void) slideDraggedImageTo: (NSPoint) point { ++} ++ ++- (int) draggingSequenceNumber { ++ return 0; ++} ++ ++- (NSArray *) namesOfPromisedFilesDroppedAtDestination: (NSURL *) destination { ++ return nil; ++} ++ ++@end ++ ++// Shows the drag image under the pointer. Can't become key, so X11 makes it ++// override-redirect and the window manager leaves it alone. ++@interface _NSDragImageWindow : NSWindow ++@end ++ ++@implementation _NSDragImageWindow ++ ++- (BOOL) canBecomeKeyWindow { ++ return NO; ++} ++ ++- (BOOL) canBecomeMainWindow { ++ return NO; ++} ++ ++@end ++ ++/* One -nextEventMatchingMask: pass handles a single run loop source, and Chromium ++ * runs one task per pass: during a drag its drop-target replies fall behind. ++ * Run sources until idle (wait == 0) or until the deadline (wait > 0). */ ++static void _NSLocalDragRunPending(NSTimeInterval budget, BOOL wait) { ++ CFAbsoluteTime deadline = CFAbsoluteTimeGetCurrent() + budget; ++ for (;;) { ++ CFTimeInterval left = deadline - CFAbsoluteTimeGetCurrent(); ++ if (left <= 0) ++ break; ++ SInt32 result = CFRunLoopRunInMode(kCFRunLoopDefaultMode, wait ? MIN(left, 0.01) : 0, true); ++ if (!wait && result != kCFRunLoopRunHandledSource) ++ break; ++ } ++} ++ ++static BOOL _NSLocalDragTrace(void) { ++ static int trace = -1; ++ if (trace < 0) ++ trace = getenv("DARLING_DRAG_TRACE") != NULL; ++ return trace; ++} + @implementation NSWindow @synthesize identifier = _identifier; -@@ -218,7 +252,8 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -218,7 +350,8 @@ static BOOL _allowsAutomaticWindowTabbing; /* This method is Cococtron specific and can be override by subclasses, do not * change method name */ + (BOOL) hasMainMenuForStyleMask: (NSWindowStyleMask) styleMask { @@ -1652,7 +1785,7 @@ index d545120a..b2845478 100644 } /* This method is Cococtron specific and can be override by subclasses, do not -@@ -318,6 +353,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -318,6 +451,7 @@ static BOOL _allowsAutomaticWindowTabbing; [NSApplication sharedApplication]; } _styleMask = styleMask; @@ -1660,7 +1793,7 @@ index d545120a..b2845478 100644 _backingType = backing; _isDeferred = defer; -@@ -351,7 +387,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -351,7 +485,7 @@ static BOOL _allowsAutomaticWindowTabbing; [_backgroundView _setWindow: self]; [_backgroundView setNextResponder: self]; @@ -1669,7 +1802,7 @@ index d545120a..b2845478 100644 _contentView = [[NSView alloc] initWithFrame: contentViewFrame]; [_contentView setAutoresizesSubviews: YES]; [_contentView -@@ -399,6 +435,8 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -399,6 +533,8 @@ static BOOL _allowsAutomaticWindowTabbing; } - (void) dealloc { @@ -1678,7 +1811,7 @@ index d545120a..b2845478 100644 [[NSNotificationCenter defaultCenter] removeObserver: self]; [_childWindows release]; [_title release]; -@@ -934,6 +972,22 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -934,6 +1070,22 @@ static BOOL _allowsAutomaticWindowTabbing; return _animationContext; } @@ -1701,7 +1834,7 @@ index d545120a..b2845478 100644 - (void) setFrame: (NSRect) newFrame display: (BOOL) display animate: (BOOL) animate -@@ -944,8 +998,19 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -944,8 +1096,19 @@ static BOOL _allowsAutomaticWindowTabbing; _frame = newFrame; _makeSureIsOnAScreen = YES; @@ -1722,7 +1855,7 @@ index d545120a..b2845478 100644 if (didSize) { [self resetCursorRects]; -@@ -1025,7 +1090,34 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1025,7 +1188,34 @@ static BOOL _allowsAutomaticWindowTabbing; } - (void) setMovable: (BOOL) movable { @@ -1758,7 +1891,7 @@ index d545120a..b2845478 100644 } - (void) setBackingType: (NSBackingStoreType) value { -@@ -1234,6 +1326,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1234,6 +1424,7 @@ static BOOL _allowsAutomaticWindowTabbing; - (void) setIgnoresMouseEvents: (BOOL) value { _ignoresMouseEvents = value; @@ -1766,7 +1899,7 @@ index d545120a..b2845478 100644 } - (void) setAspectRatio: (NSSize) value { -@@ -1413,8 +1506,14 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1413,8 +1604,14 @@ static BOOL _allowsAutomaticWindowTabbing; } - (NSButton *) standardWindowButton: (NSWindowButton) value { @@ -1783,7 +1916,7 @@ index d545120a..b2845478 100644 } - (NSButtonCell *) defaultButtonCell { -@@ -1425,6 +1524,17 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1425,6 +1622,17 @@ static BOOL _allowsAutomaticWindowTabbing; return [_sheetContext sheet]; } @@ -1801,7 +1934,7 @@ index d545120a..b2845478 100644 - (id) windowController { return _windowController; } -@@ -1511,10 +1621,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1511,10 +1719,7 @@ static BOOL _allowsAutomaticWindowTabbing; return [_platformWindow isMiniaturized]; } @@ -1813,7 +1946,7 @@ index d545120a..b2845478 100644 - (BOOL) inLiveResize { return _isInLiveResize; -@@ -1561,6 +1668,117 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1561,6 +1766,117 @@ static BOOL _allowsAutomaticWindowTabbing; return point; } @@ -1931,7 +2064,7 @@ index d545120a..b2845478 100644 - (NSRect) frameRectForContentRect: (NSRect) contentRect { // hasMainMenu is an instance method so we can't just use the class method // frameRectForContentRect:styleMask: -@@ -1580,6 +1798,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1580,6 +1896,10 @@ static BOOL _allowsAutomaticWindowTabbing; } - (NSRect) contentRectForFrameRect: (NSRect) frameRect { @@ -1942,7 +2075,7 @@ index d545120a..b2845478 100644 NSRect result = [[NSDisplay currentDisplay] insetRect: frameRect forNativeWindowBorderWithStyle: [self styleMask]]; -@@ -2155,6 +2377,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2155,6 +2475,7 @@ static BOOL _allowsAutomaticWindowTabbing; - (void) orderWindow: (NSWindowOrderingMode) place relativeTo: (NSInteger) relativeTo { @@ -1950,7 +2083,7 @@ index d545120a..b2845478 100644 // The move notifications are sent under unknown conditions around // orderFront: in the Apple AppKit, we do them all the time here until it's // figured out. I suspect it is a side effect of off-screen windows being at -@@ -2208,6 +2431,8 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2208,6 +2529,8 @@ static BOOL _allowsAutomaticWindowTabbing; break; } @@ -1959,7 +2092,7 @@ index d545120a..b2845478 100644 [self postNotificationName: NSWindowDidMoveNotification]; } -@@ -2257,6 +2482,12 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2257,6 +2580,12 @@ static BOOL _allowsAutomaticWindowTabbing; } - (void) sendEvent: (NSEvent *) event { @@ -1972,7 +2105,7 @@ index d545120a..b2845478 100644 // Some events can cause our window to be destroyed // So make sure self lives at least through this current run loop... [[self retain] autorelease]; -@@ -2300,6 +2531,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2300,6 +2629,10 @@ static BOOL _allowsAutomaticWindowTabbing; case NSLeftMouseDown: { NSView *view = [_backgroundView hitTest: [event locationInWindow]]; @@ -1983,7 +2116,7 @@ index d545120a..b2845478 100644 if ([view acceptsFirstResponder]) { if ([view needsPanelToBecomeKey]) { -@@ -2307,6 +2542,27 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2307,6 +2640,27 @@ static BOOL _allowsAutomaticWindowTabbing; } } @@ -2011,7 +2144,7 @@ index d545120a..b2845478 100644 // Event goes to view, not first responder. [view mouseDown: event]; _mouseDownLocationInWindow = [event locationInWindow]; -@@ -2330,6 +2586,31 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2330,6 +2684,31 @@ static BOOL _allowsAutomaticWindowTabbing; _mouseDownLocationInWindow = NSMakePoint(NAN, NAN); break; @@ -2043,7 +2176,209 @@ index d545120a..b2845478 100644 case NSMouseMoved: { NSView *hit = [_backgroundView hitTest: [event locationInWindow]]; -@@ -2536,6 +2817,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2478,6 +2857,190 @@ static BOOL _allowsAutomaticWindowTabbing; + _draggedTypes = nil; + } + ++// The registered drag destination under screenPoint in this window, if any. ++- (NSView *) _localDragDestinationAt: (NSPoint) screenPoint types: (NSArray *) types { ++ NSView *root = [self contentView]; ++ while ([root superview] != nil) ++ root = [root superview]; ++ NSView *view = [root hitTest: [self convertScreenToBase: screenPoint]]; ++ while (view != nil && [[view registeredDraggedTypes] firstObjectCommonWithArray: types] == nil) ++ view = [view superview]; ++ return view; ++} ++ ++/* darling-lol: drag loop for backends without an NSDraggingManager (X11). Covers drags ++ * between this app's own windows; nothing crosses to other X clients. Like AppKit's, ++ * it returns once the drag is over and always ends with -draggedImage:endedAt:operation:, ++ * which Chromium waits for before the page takes mouse input again. */ ++- (void) _localDragImage: (NSImage *) image ++ at: (NSPoint) imageLocation ++ event: (NSEvent *) event ++ pasteboard: (NSPasteboard *) pasteboard ++ source: (id) source ++{ ++ _NSLocalDraggingInfo *info = [[_NSLocalDraggingInfo alloc] init]; ++ info->_pasteboard = pasteboard; ++ info->_image = image; ++ info->_source = source; ++ info->_sourceMask = NSDragOperationEvery; ++ if ([source respondsToSelector: @selector(draggingSourceOperationMaskForLocal:)]) ++ info->_sourceMask = [source draggingSourceOperationMaskForLocal: YES]; ++ ++ NSPoint screenPoint; ++ if (event != nil && [event window] != nil) ++ screenPoint = [[event window] convertBaseToScreen: [event locationInWindow]]; ++ else ++ screenPoint = [self convertBaseToScreen: [self mouseLocationOutsideOfEventStream]]; ++ ++ // The image keeps its offset from the pointer, as on macOS. ++ _NSDragImageWindow *imageWindow = nil; ++ NSPoint imageOffset = NSZeroPoint; ++ NSSize imageSize = [image size]; ++ if (imageSize.width >= 1 && imageSize.height >= 1) { ++ NSPoint imageOrigin = [self convertBaseToScreen: imageLocation]; ++ imageOffset = NSMakePoint(screenPoint.x - imageOrigin.x, screenPoint.y - imageOrigin.y); ++ imageWindow = [[_NSDragImageWindow alloc] ++ initWithContentRect: NSMakeRect(imageOrigin.x, imageOrigin.y, imageSize.width, imageSize.height) ++ styleMask: NSBorderlessWindowMask ++ backing: NSBackingStoreBuffered ++ defer: NO]; ++ [imageWindow setReleasedWhenClosed: NO]; ++ [imageWindow setIgnoresMouseEvents: YES]; ++ [imageWindow setLevel: NSPopUpMenuWindowLevel]; ++ [imageWindow setHasShadow: NO]; ++ NSImageView *imageView = [[NSImageView alloc] ++ initWithFrame: NSMakeRect(0, 0, imageSize.width, imageSize.height)]; ++ [imageView setImage: image]; ++ [imageView setImageScaling: NSImageScaleNone]; ++ [imageWindow setContentView: imageView]; ++ [imageView release]; ++ [imageWindow orderFront: nil]; ++ } ++ ++ NSArray *types = [pasteboard types]; ++ id destination = nil; ++ NSDragOperation operation = NSDragOperationNone; ++ BOOL dropped = NO; ++ BOOL moved = YES; ++ const char *ending = "release"; ++ ++ if (_NSLocalDragTrace()) ++ NSLog(@"DRAGTRACE start at %.0f,%.0f window=%@ image=%.0fx%.0f mask=0x%lx types=%@", ++ screenPoint.x, screenPoint.y, [self className], imageSize.width, imageSize.height, ++ (unsigned long) info->_sourceMask, types); ++ ++ for (;;) { ++ if (moved) { ++ NSWindow *window = nil; ++ if (NSPointInRect(screenPoint, [self frame])) ++ window = self; ++ else { ++ for (NSWindow *candidate in [NSApp orderedWindows]) { ++ if (candidate != imageWindow && [candidate isVisible] && ++ NSPointInRect(screenPoint, [candidate frame])) { ++ window = candidate; ++ break; ++ } ++ } ++ } ++ info->_screenPoint = screenPoint; ++ info->_destinationWindow = window; ++ NSView *view = [window _localDragDestinationAt: screenPoint types: types]; ++ ++ if (view != destination) { ++ if ([destination respondsToSelector: @selector(draggingExited:)]) ++ [destination draggingExited: info]; ++ destination = view; ++ operation = NSDragOperationNone; ++ if ([destination respondsToSelector: @selector(draggingEntered:)]) ++ operation = [destination draggingEntered: info]; ++ if (_NSLocalDragTrace()) ++ NSLog(@"DRAGTRACE destination %@ at %.0f,%.0f operation=0x%lx", [view className], ++ screenPoint.x, screenPoint.y, (unsigned long) operation); ++ } else if ([destination respondsToSelector: @selector(draggingUpdated:)]) { ++ operation = [destination draggingUpdated: info]; ++ } ++ [imageWindow setFrameOrigin: NSMakePoint(screenPoint.x - imageOffset.x, ++ screenPoint.y - imageOffset.y)]; ++ if ([source respondsToSelector: @selector(draggedImage:movedTo:)]) ++ [source draggedImage: image movedTo: screenPoint]; ++ _NSLocalDragRunPending(0.008, NO); ++ } ++ if (dropped) ++ break; ++ ++ // Short timeout: the wait below doesn't wake for run loop sources, and ++ // a release delivered before the loop started never shows up as an event. ++ NSEvent *next = [NSApp nextEventMatchingMask: NSLeftMouseUpMask | NSLeftMouseDraggedMask | ++ NSMouseMovedMask | NSKeyDownMask ++ untilDate: [NSDate dateWithTimeIntervalSinceNow: 0.05] ++ inMode: NSDefaultRunLoopMode ++ dequeue: YES]; ++ moved = NO; ++ if (next == nil) { ++ NSUInteger buttons = [NSEvent pressedMouseButtons]; ++ if ((buttons & 1) == 0) { ++ dropped = moved = YES; ++ ending = "button up, no event"; ++ } ++ continue; ++ } ++ if ([next type] == NSKeyDown) { ++ if ([next keyCode] == 53 || [[next characters] isEqual: @"\033"]) { ++ ending = "escape"; ++ break; ++ } ++ continue; ++ } ++ if ([next window] != nil) ++ screenPoint = [[next window] convertBaseToScreen: [next locationInWindow]]; ++ else ++ screenPoint = [next locationInWindow]; ++ moved = YES; ++ if (_NSLocalDragTrace()) ++ NSLog(@"DRAGTRACE event type=%d window=%@ at %.0f,%.0f", (int) [next type], ++ [[next window] className], screenPoint.x, screenPoint.y); ++ if ([next type] == NSLeftMouseUp) ++ dropped = YES; ++ } ++ ++ [imageWindow orderOut: nil]; ++ [imageWindow release]; ++ ++ // The operation for the release point arrives asynchronously (Chromium): ++ // let it come in, then ask again. ++ if (dropped && [destination respondsToSelector: @selector(draggingUpdated:)]) { ++ _NSLocalDragRunPending(0.1, YES); ++ operation = [destination draggingUpdated: info]; ++ if (_NSLocalDragTrace()) ++ NSLog(@"DRAGTRACE release operation=0x%lx", (unsigned long) operation); ++ } ++ ++ BOOL performed = NO; ++ if (dropped && destination != nil && operation != NSDragOperationNone) { ++ performed = YES; ++ if ([destination respondsToSelector: @selector(prepareForDragOperation:)]) ++ performed = [destination prepareForDragOperation: info]; ++ if (performed) ++ performed = [destination respondsToSelector: @selector(performDragOperation:)] && ++ [destination performDragOperation: info]; ++ if (performed && [destination respondsToSelector: @selector(concludeDragOperation:)]) ++ [destination concludeDragOperation: info]; ++ } else if ([destination respondsToSelector: @selector(draggingExited:)]) { ++ [destination draggingExited: info]; ++ } ++ if (!performed) ++ operation = NSDragOperationNone; ++ ++ if (_NSLocalDragTrace()) ++ NSLog(@"DRAGTRACE end (%s) at %.0f,%.0f destination=%@ performed=%d operation=0x%lx", ending, ++ screenPoint.x, screenPoint.y, [destination className], performed, (unsigned long) operation); ++ ++ if ([source respondsToSelector: @selector(draggedImage:endedAt:operation:)]) ++ [source draggedImage: image endedAt: screenPoint operation: operation]; ++ [info release]; ++} ++ + - (void) dragImage: (NSImage *) image + at: (NSPoint) location + offset: (NSSize) offset +@@ -2486,6 +3049,10 @@ static BOOL _allowsAutomaticWindowTabbing; + source: (id) source + slideBack: (BOOL) slideBack + { ++ if ([NSDraggingManager draggingManager] == nil) { ++ [self _localDragImage: image at: location event: event pasteboard: pasteboard source: source]; ++ return; ++ } + [[NSDraggingManager draggingManager] dragImage: image + at: location + offset: offset +@@ -2536,6 +3103,7 @@ static BOOL _allowsAutomaticWindowTabbing; } else { [[self platformWindow] hideWindow]; } @@ -2051,7 +2386,7 @@ index d545120a..b2845478 100644 } - (void) orderFront: (id) sender { -@@ -3064,6 +3346,13 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3064,6 +3632,13 @@ static BOOL _allowsAutomaticWindowTabbing; if (didSize) { // Don't redraw everything unless we really have to. [_backgroundView setFrameSize: _frame.size]; @@ -2065,7 +2400,7 @@ index d545120a..b2845478 100644 [_backgroundView setNeedsDisplay: YES]; [self resetCursorRects]; [self saveFrameUsingName: _autosaveFrameName]; -@@ -3260,8 +3549,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3260,8 +3835,7 @@ static BOOL _allowsAutomaticWindowTabbing; mouseWasInside && !mouseIsInside) { [exited addObject: area]; } @@ -2075,7 +2410,7 @@ index d545120a..b2845478 100644 [moved addObject: area]; } if (options & NSTrackingCursorUpdate && !mouseWasInside && -@@ -3459,7 +3747,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3459,7 +4033,7 @@ static BOOL _allowsAutomaticWindowTabbing; } - (CGSubWindow *) _createSubWindowWithFrame: (CGRect) frame { @@ -4130,7 +4465,7 @@ index 411903cc..4b196afd 100644 NSMutableDictionary *_typeToData; NSMutableDictionary> diff --git a/AppKit/X11.backend/X11Pasteboard.m b/AppKit/X11.backend/X11Pasteboard.m -index 0e91a5d1..1e4cec1b 100644 +index 0e91a5d1..9feeffa1 100644 --- a/AppKit/X11.backend/X11Pasteboard.m +++ b/AppKit/X11.backend/X11Pasteboard.m @@ -18,6 +18,8 @@ along with Darling. If not, see . @@ -4161,17 +4496,39 @@ index 0e91a5d1..1e4cec1b 100644 if (_typeToData != nil) { // Already an owner. return; -@@ -93,7 +94,8 @@ static NSMutableDictionary *nameToPboard; +@@ -78,12 +79,19 @@ static NSMutableDictionary *nameToPboard; + _changeCount++; + } + ++// pasteboardChangedOwner: is optional (Chromium's drag source lacks it); once per owner. ++- (void) notifyOwnersChanged { ++ NSArray *owners = [[NSSet setWithArray: [_typeToOwner allValues]] allObjects]; ++ for (id owner in owners) { ++ if ([owner respondsToSelector: @selector(pasteboardChangedOwner:)]) ++ [owner pasteboardChangedOwner: self]; ++ } ++} ++ + - (NSInteger) clearContents { + [self ensureSelectionOwner]; + +- for (id owner in [_typeToOwner allValues]) { +- [owner pasteboardChangedOwner: self]; +- } ++ [self notifyOwnersChanged]; + [_typeToOwner removeAllObjects]; + [_typeToData removeAllObjects]; + +@@ -93,7 +101,7 @@ static NSMutableDictionary *nameToPboard; - (void) giveUpSelectionOwner { _changeCount++; - [self clearContents]; -+ for (id owner in [_typeToOwner allValues]) -+ [owner pasteboardChangedOwner: self]; ++ [self notifyOwnersChanged]; [_typeToOwner release]; _typeToOwner = nil; -@@ -115,12 +117,47 @@ static NSMutableDictionary *nameToPboard; +@@ -115,12 +123,47 @@ static NSMutableDictionary *nameToPboard; [super dealloc]; } @@ -4220,7 +4577,7 @@ index 0e91a5d1..1e4cec1b 100644 } + (NSArray *) targetsForType: (NSPasteboardType) type { -@@ -177,7 +214,7 @@ static NSMutableDictionary *nameToPboard; +@@ -177,7 +220,7 @@ static NSMutableDictionary *nameToPboard; } - (NSData *) dataForType: (NSPasteboardType) type { @@ -4229,7 +4586,7 @@ index 0e91a5d1..1e4cec1b 100644 if (_typeToOwner != nil) { // We're the owner; just fetch the data directly. id owner = _typeToOwner[type]; -@@ -185,25 +222,26 @@ static NSMutableDictionary *nameToPboard; +@@ -185,25 +228,26 @@ static NSMutableDictionary *nameToPboard; return _typeToData[type]; } @@ -4270,7 +4627,7 @@ index 0e91a5d1..1e4cec1b 100644 NSStringEncoding encoding = NSUnicodeStringEncoding; if ([type isEqual: NSStringPboardType]) { -@@ -252,8 +290,37 @@ static NSMutableDictionary *nameToPboard; +@@ -252,8 +296,37 @@ static NSMutableDictionary *nameToPboard; } - (NSArray *) types { From 6b753e87c5a914dea0cdd2efce788fe363ef1dc8 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:11:27 +0200 Subject: [PATCH 087/160] Close inherited host file descriptors before starting the guest A terminal, IDE or service manager can leave descriptors open in the launcher, and the guest inherited all of them. tools/exec-guest.py closes every descriptor above stderr and then execs the guest command. A shell loop over /proc/self/fd cannot do this portably: dash only parses single-digit descriptors in redirections, so "exec 10>&-" runs a command named 10 and ends the launcher on Debian and Ubuntu. The launcher offline check now leaks descriptors 3, 4, 12 and 200 into the launcher and asserts the guest sees only 0, 1 and 2. It runs in CI. Split from #61. Co-authored-by: DevWebeloper Co-Authored-By: Claude Opus 5.5 --- .github/workflows/source-check.yml | 1 + docs/TESTING.md | 4 ++-- tools/exec-guest.py | 30 ++++++++++++++++++++++++++++ tools/riot-launcher-offline-check.py | 25 +++++++++++++++++++++-- tools/run-riot-client.sh | 4 +++- 5 files changed, 59 insertions(+), 5 deletions(-) create mode 100755 tools/exec-guest.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 1d02ffc..1413f33 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -12,6 +12,7 @@ jobs: python-version: '3.12' - run: python scripts/validate.py - run: python tools/riot-input-offline-check.py + - run: python tools/riot-launcher-offline-check.py - run: python tools/riot-runtime-cleanup-offline-check.py - run: python tools/riot-cdn-dns-offline-check.py - run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' diff --git a/docs/TESTING.md b/docs/TESTING.md index 8a544f9..e725c49 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -27,7 +27,7 @@ Expected: `METAL_DEVICE_INFO failures=0 pass=1`. It covers how `MTLDevice.name` The LLVM version check compiles Iridium's actual dynamic loader against small stub libraries. It verifies that a matching major initializes, older/newer majors produce a version mismatch, and a missing library fails initialization. It does not start Darling. The native AIR translator (`python3 tools/build-riot-air-check.py --native`) selects both headers and the shared library from the same `llvm-config`; its build prints those paths so shader results can be attributed to the tested LLVM installation. -The patch check uses temporary base indexes and does not reset or modify the source checkout. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, and preserves Darling runtime library/framework paths. The memory-installer check imports the installer and exercises its policy (default source is the prepared artifact; the artifact is never a destination of a different library) in temporary directories. The Foundation-installer check does the same for the overlay installer: it seeds a temporary framework from a stub SDK, replaces `Versions/C/Foundation`, keeps the standard symlinks, stages a backup, treats a re-run as current and refuses a non-Mach-O source. The installer handoff mock covers 28 scenarios, including HTTP 409, interrupted verification and preserving active downloads. +The patch check uses temporary base indexes and does not reset or modify the source checkout. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, preserves Darling runtime library/framework paths, and checks that host file descriptors the launcher inherited (including multi-digit ones) are closed before the guest starts. The memory-installer check imports the installer and exercises its policy (default source is the prepared artifact; the artifact is never a destination of a different library) in temporary directories. The Foundation-installer check does the same for the overlay installer: it seeds a temporary framework from a stub SDK, replaces `Versions/C/Foundation`, keeps the standard symlinks, stages a backup, treats a re-run as current and refuses a non-Mach-O source. The installer handoff mock covers 28 scenarios, including HTTP 409, interrupted verification and preserving active downloads. ## NSURLSession lifecycle regression @@ -181,7 +181,7 @@ build/riot-host-receive-check python3 tools/riot-launcher-offline-check.py ``` -Expected: `warm_empty_allocations=0 single_packet_allocations=2`, correct credentials, descriptor ownership/CLOEXEC, sender addresses, multiple batches, truncation flags, callback reentrancy and exception cleanup. The old receive implementation makes 32 allocations for a warmed empty receive and 64 for a single-packet receive in the same fixture. These are allocation counts, not an FPS benchmark. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, and preserves Darling runtime library/framework paths. +Expected: `warm_empty_allocations=0 single_packet_allocations=2`, correct credentials, descriptor ownership/CLOEXEC, sender addresses, multiple batches, truncation flags, callback reentrancy and exception cleanup. The old receive implementation makes 32 allocations for a warmed empty receive and 64 for a single-packet receive in the same fixture. These are allocation counts, not an FPS benchmark. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, preserves Darling runtime library/framework paths, and checks that host file descriptors the launcher inherited (including multi-digit ones) are closed before the guest starts. The same source passed AddressSanitizer/UndefinedBehaviorSanitizer and leak detection. Allocation counts depend on this fixture/compiler/library; preserve the no-wasted-slot property rather than interpreting them as game performance. See [setup](SETUP.md#updating-an-existing-runtime) for server build/install/activation. diff --git a/tools/exec-guest.py b/tools/exec-guest.py new file mode 100755 index 0000000..2e23c43 --- /dev/null +++ b/tools/exec-guest.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Run a command without the host file descriptors this launcher inherited (anything above stderr).""" +import os +import sys + + +def close_inherited_fds(): + """A terminal, IDE or service manager can leak descriptors; the guest must not inherit them.""" + try: + names = os.listdir('/proc/self/fd') + except OSError: + os.closerange(3, 65536) + return + for name in names: + if name.isdigit() and int(name) > 2: + try: + os.close(int(name)) + except OSError: + pass # the descriptor listdir used is already closed + + +def main(argv): + if len(argv) < 2: + sys.exit('usage: exec-guest.py COMMAND [ARGUMENT]...') + close_inherited_fds() + os.execvp(argv[1], argv[1:]) + + +if __name__ == '__main__': + main(sys.argv) diff --git a/tools/riot-launcher-offline-check.py b/tools/riot-launcher-offline-check.py index 1254b7a..46a8a09 100644 --- a/tools/riot-launcher-offline-check.py +++ b/tools/riot-launcher-offline-check.py @@ -16,13 +16,25 @@ def check(): folder = Path(directory) launcher = folder / "launch.sh" launcher.write_text((ROOT / "tools/run-riot-client.sh").read_text().replace("@GUEST_HOME@", "/Users/offline-check")) + (folder / "exec-guest.py").write_text((ROOT / "tools/exec-guest.py").read_text()) binary_folder = folder / "bin" binary_folder.mkdir() log = folder / "calls.jsonl" stub = f'''#!{sys.executable} import json, os, sys record = {{"command": os.path.basename(sys.argv[0]), "args": sys.argv[1:]}} +# The launcher starts the guest through exec-guest.py; run it for real. +if record["command"] == "python3" and sys.argv[1:] and os.path.basename(sys.argv[1]) == "exec-guest.py": + os.execv(sys.executable, [sys.executable] + sys.argv[1:]) if record["command"] == "darling": + # Descriptors open now were inherited; the one listdir used is closed again before this check. + def is_open(fd): + try: + os.fstat(fd) + return True + except OSError: + return False + record["fds"] = sorted(fd for fd in map(int, os.listdir("/proc/self/fd")) if is_open(fd)) record["flags"] = {{key: os.environ.get(key) for key in ("RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT", "RCLOL_UX_ARG1", "RCLOL_APP_ARGS", "RCLOL_APP_ARG2", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "DARLING_XSHM_LIBRARY")}} with open({str(log)!r}, "a") as output: @@ -60,7 +72,15 @@ with open({str(log)!r}, "a") as output: "RCLOL_OPEN_URL_BRIDGE": "1", "RCLOL_DISABLE_GPU_VSYNC": "1", }) - subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True) + # Leaked host descriptors, including multi-digit ones, must not reach the guest. + leaked = list(os.pipe()) + for target in (12, 200): + leaked.append(os.dup2(leaked[0], target, inheritable=False)) + try: + subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True, pass_fds=leaked) + finally: + for fd in leaked: + os.close(fd) records = [json.loads(line) for line in log.read_text().splitlines()] maintenance = [r for r in records if r["command"] == "python3"] assert [Path(r["args"][0]).name for r in maintenance] == [ @@ -70,6 +90,7 @@ with open({str(log)!r}, "a") as output: launches = [r for r in records if r["command"] == "darling"] assert len(launches) == 1, "unexpected launch count" launch = launches[0] + assert launch["fds"] == [0, 1, 2], f"inherited descriptors reached the guest: {launch['fds']}" assert all(launch["flags"][key] is None for key in ( "RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT" )), "inherited inspector/unlimited-frame argument survived" @@ -95,7 +116,7 @@ with open({str(log)!r}, "a") as output: subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True) clean = json.loads(log.read_text().splitlines()[-1]) assert clean["flags"]["DYLD_INSERT_LIBRARIES"] is None - print("LAUNCHER_OFFLINE pass=1 installed=PASS installer=PASS inherited_injection=absent runtime=preserved") + print("LAUNCHER_OFFLINE pass=1 installed=PASS installer=PASS inherited_injection=absent runtime=preserved inherited_fds=closed") if __name__ == "__main__": diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 6889e0b..1540673 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -104,7 +104,9 @@ fi # On affected setups Chromium's default temp directory is unusable. Electron # can copy app.asar entries to temporary files for async reads and report a # failed copy as "not found". Use a writable guest directory by default. -exec env -u ELECTRON_RUN_AS_NODE -u DYLD_INSERT_LIBRARIES EGL_PLATFORM=x11 \ +# exec-guest.py closes host file descriptors above stderr before starting the guest. A shell +# loop cannot: dash only accepts single-digit descriptors in redirections. +exec python3 "$runner_dir/exec-guest.py" env -u ELECTRON_RUN_AS_NODE -u DYLD_INSERT_LIBRARIES EGL_PLATFORM=x11 \ MAC_CHROMIUM_TMPDIR="${MAC_CHROMIUM_TMPDIR:-/private/tmp}" \ LANG="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" LC_ALL="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" \ mesa_glthread="${RCLOL_MESA_GLTHREAD-${mesa_glthread-false}}" \ From 03fdd25bc0a0dc6a70cffeb3d29cb2ad91865e34 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:13:24 +0200 Subject: [PATCH 088/160] Add an opt-in filter for host desktop-session variables RCLOL_FILTER_HOST_ENV=1 makes exec-guest.py drop host session metadata (WAYLAND_DISPLAY, XDG_SESSION_*, XDG_CURRENT_DESKTOP, GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit, Flatpak/Snap/AppImage, exported shell functions) and set SHELL to the guest's /bin/zsh. It is off by default; RCLOL_KEEP_HOST_ENV lists names to keep anyway. Unlike the denylist in #61, the filter runs after the launcher sets its GPU tuning, keeps everything Darling and the host graphics and audio libraries read (DISPLAY, XAUTHORITY, XDG_RUNTIME_DIR and the XDG base directories, D-Bus, PulseAudio/PipeWire/ALSA, DRI_PRIME, MESA_*, __GL_*, __NV_*, VK_*, LD_PRELOAD for gamemoderun, LD_LIBRARY_PATH), and lives in one place instead of three. tests/test_exec_guest.py runs in CI; the launcher check also verifies that MESA_NO_ERROR=1 and __GL_THREADED_OPTIMIZATIONS=0 reach the guest with the filter on. Split from #61. Co-authored-by: DevWebeloper Co-Authored-By: Claude Opus 5.5 --- .github/workflows/source-check.yml | 1 + docs/SETUP.md | 11 ++++ tests/test_exec_guest.py | 90 ++++++++++++++++++++++++++++ tools/exec-guest.py | 43 ++++++++++++- tools/riot-launcher-offline-check.py | 15 ++++- 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 tests/test_exec_guest.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 1413f33..3c5dead 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -18,3 +18,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' - run: python -m unittest discover -s tests -p 'test_desktop_run.py' - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' + - run: python -m unittest discover -s tests -p 'test_exec_guest.py' diff --git a/docs/SETUP.md b/docs/SETUP.md index aa3d7db..4d85c6b 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -73,6 +73,17 @@ opts in to Hyprland-only recovery of a default-prefix Riot session with no visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection) for the observed failures, recovery guards and NSURLSession limitations. +`RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before +the guest starts: `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `XDG_CURRENT_DESKTOP`, +GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit (`QT_*`, `GTK_*`, `GDK_*`), +Flatpak/Snap/AppImage and exported shell functions, and `SHELL` becomes `/bin/zsh`. +It is off by default. Variables that Darling or the host graphics and audio libraries read +are always kept: `DISPLAY`, `XAUTHORITY`, `XDG_RUNTIME_DIR` and the XDG base directories, +D-Bus, PulseAudio/PipeWire/ALSA, `DRI_PRIME`, `MESA_*`, `__GL_*`, `__NV_*`, `VK_*`, +`LD_PRELOAD` and `LD_LIBRARY_PATH`. List names to keep anyway in `RCLOL_KEEP_HOST_ENV`, +for example `RCLOL_KEEP_HOST_ENV=WAYLAND_DISPLAY`. The launcher always closes inherited +file descriptors above stderr before the guest starts. + ## Updating an existing runtime `./scripts/update.sh` updates everything after `git pull`: `build.sh`, then the base install, then `install-runtime.sh`. When a pull changes a patch, `prepare.py` finds the earlier version applied in that component (its copy in `build/applied-patches`, or a version from this repository's history), reverts it, and applies the new one. It never resets a repository; if neither version matches, it stops and names the repository with other edits. The steps below update single components instead. diff --git a/tests/test_exec_guest.py b/tests/test_exec_guest.py new file mode 100644 index 0000000..9582245 --- /dev/null +++ b/tests/test_exec_guest.py @@ -0,0 +1,90 @@ +"""exec-guest.py: inherited descriptors and the opt-in host environment filter.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / 'tools/exec-guest.py' +spec = importlib.util.spec_from_file_location('exec_guest', HELPER) +exec_guest = importlib.util.module_from_spec(spec) +spec.loader.exec_module(exec_guest) + +# What the guest and the host libraries Darling loads still need. +REQUIRED = { + 'DISPLAY': ':0', 'XAUTHORITY': '/run/user/1000/.mutter-Xwaylandauth.A', 'XCURSOR_SIZE': '24', + 'XDG_RUNTIME_DIR': '/run/user/1000', 'XDG_DATA_DIRS': '/usr/share', 'XDG_CONFIG_HOME': '/home/u/.config', + 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'PULSE_SERVER': 'unix:/run/pulse', + 'PIPEWIRE_LATENCY': '128/48000', 'ALSA_CONFIG_PATH': '/usr/share/alsa/alsa.conf', + 'DRI_PRIME': '1', 'MESA_NO_ERROR': '1', 'mesa_glthread': 'false', '__GL_THREADED_OPTIMIZATIONS': '0', + '__NV_PRIME_RENDER_OFFLOAD': '1', '__EGL_VENDOR_LIBRARY_FILENAMES': '/usr/share/glvnd/egl_vendor.d/10_nvidia.json', + 'VK_ICD_FILENAMES': '/usr/share/vulkan/icd.d/nvidia_icd.json', 'LIBGL_ALWAYS_SOFTWARE': '0', + 'LD_PRELOAD': 'libgamemodeauto.so.0', 'LD_LIBRARY_PATH': '/run/opengl-driver/lib', + 'HOME': '/home/u', 'USER': 'u', 'PATH': '/usr/bin:/bin', 'LANG': 'en_US.UTF-8', 'TERM': 'xterm', + 'DPREFIX': '/home/u/.darling', 'RCLOL_REFRESH_HZ': '144', 'DYLD_LIBRARY_PATH': '/Users/u/RuntimeLibraries', +} +SESSION = { + 'WAYLAND_DISPLAY': 'wayland-0', 'XDG_CURRENT_DESKTOP': 'GNOME', 'XDG_SESSION_TYPE': 'wayland', + 'XDG_SESSION_ID': '2', 'XDG_SEAT': 'seat0', 'DESKTOP_SESSION': 'gnome', 'GDMSESSION': 'gnome', + 'GDM_LANG': 'en_US.UTF-8', 'SESSION_MANAGER': 'local/host:@/tmp/.ICE-unix/1', 'GNOME_SETUP_DISPLAY': ':1', + 'KDE_FULL_SESSION': 'true', 'SSH_AUTH_SOCK': '/run/user/1000/ssh', 'SYSTEMD_EXEC_PID': '1', + 'INVOCATION_ID': 'abc', 'JOURNAL_STREAM': '8:1', 'MANAGERPID': '1', 'NOTIFY_SOCKET': '/run/notify', + 'LISTEN_FDS': '1', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup', 'QT_QPA_PLATFORM': 'wayland', + 'GTK_MODULES': 'canberra', 'GDK_BACKEND': 'wayland', 'GIO_LAUNCHED_DESKTOP_FILE': '/x.desktop', + 'FLATPAK_ID': 'x', 'SNAP': '/snap/x', 'APPIMAGE': '/x.AppImage', 'VIRTUAL_ENV': '/home/u/.venv', + 'COLORTERM': 'truecolor', 'VTE_VERSION': '7600', 'WINDOWID': '1', 'TERM_PROGRAM': 'vscode', + 'LS_COLORS': 'di=01;34', 'SHLVL': '2', 'BASH_FUNC_f%%': '() { :; }', +} + + +class FilterTests(unittest.TestCase): + def test_strips_session_metadata_and_keeps_runtime_variables(self): + environ = {**REQUIRED, **SESSION, 'SHELL': '/bin/fish'} + removed = exec_guest.filter_host_environment(environ) + self.assertEqual(set(removed), set(SESSION)) + self.assertEqual(environ, {**REQUIRED, 'SHELL': '/bin/zsh'}) + + def test_keep_list(self): + environ = {'WAYLAND_DISPLAY': 'wayland-0', 'SSH_AUTH_SOCK': '/s', 'SHELL': '/bin/fish', + 'RCLOL_KEEP_HOST_ENV': 'WAYLAND_DISPLAY, SHELL'} + self.assertEqual(exec_guest.filter_host_environment(environ), ['SSH_AUTH_SOCK']) + self.assertEqual(environ['WAYLAND_DISPLAY'], 'wayland-0') + self.assertEqual(environ['SHELL'], '/bin/fish') + + +class ExecTests(unittest.TestCase): + DUMP = ('import json, os; print(json.dumps({"env": dict(os.environ), "fds": sorted(' + 'fd for fd in map(int, os.listdir("/proc/self/fd")) if fd < 3 or os.path.exists("/proc/self/fd/%d" % fd))}))') + + def run_helper(self, environ): + leaked = list(os.pipe()) + leaked.append(os.dup2(leaked[0], 41, inheritable=False)) + try: + result = subprocess.run([sys.executable, str(HELPER), sys.executable, '-c', self.DUMP], + env=environ, pass_fds=leaked, capture_output=True, text=True, check=True) + finally: + for fd in leaked: + os.close(fd) + return json.loads(result.stdout) + + def test_closes_descriptors_and_keeps_environment_by_default(self): + environ = {**REQUIRED, **SESSION, 'SHELL': '/bin/fish'} + child = self.run_helper(environ) + self.assertEqual(child['fds'], [0, 1, 2]) + for name, value in environ.items(): + self.assertEqual(child['env'].get(name), value, name) + + def test_opt_in_filter(self): + child = self.run_helper({**REQUIRED, **SESSION, 'SHELL': '/bin/fish', 'RCLOL_FILTER_HOST_ENV': '1'}) + self.assertEqual(child['fds'], [0, 1, 2]) + self.assertFalse(set(SESSION) & set(child['env'])) + for name, value in REQUIRED.items(): + self.assertEqual(child['env'].get(name), value, name) + self.assertEqual(child['env']['SHELL'], '/bin/zsh') + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/exec-guest.py b/tools/exec-guest.py index 2e23c43..331f754 100755 --- a/tools/exec-guest.py +++ b/tools/exec-guest.py @@ -1,8 +1,47 @@ #!/usr/bin/env python3 -"""Run a command without the host file descriptors this launcher inherited (anything above stderr).""" +"""Run a command without the host file descriptors this launcher inherited (anything above stderr). + +With RCLOL_FILTER_HOST_ENV=1 it also drops host desktop-session variables first (see +host_session_variable). RCLOL_KEEP_HOST_ENV lists names to keep anyway, separated by spaces or commas. +""" import os import sys +# Desktop-session, service-manager, terminal and packaging metadata that nothing in the guest reads. +# Everything Darling and the host libraries it loads need is kept: DISPLAY, XAUTHORITY, XCURSOR_*, +# XDG_RUNTIME_DIR, XDG_DATA_DIRS and the other XDG base directories (the Vulkan loader searches +# them), DBUS_SESSION_BUS_ADDRESS, PULSE_*, PIPEWIRE_*, ALSA_*, DRI_PRIME, MESA_*, mesa_glthread, +# __GL_*, __NV_*, __EGL_*, VK_*, LIBGL_*, LD_PRELOAD (gamemoderun) and LD_LIBRARY_PATH. +SESSION_PREFIXES = ( + 'XDG_SESSION_', 'XDG_SEAT', 'XDG_VTNR', 'XDG_CURRENT_DESKTOP', 'XDG_MENU_PREFIX', + 'GNOME_', 'KDE_', 'GDM', 'SSH_', 'SYSTEMD_', 'MEMORY_PRESSURE_', 'QT_', 'GTK_', 'GDK_', + 'GIO_', 'GSETTINGS_', 'GJS_', 'FLATPAK_', 'SNAP_', 'KONSOLE_', 'TERM_PROGRAM', 'BASH_FUNC_', +) +SESSION_NAMES = { + 'WAYLAND_DISPLAY', 'DESKTOP_SESSION', 'DESKTOP_STARTUP_ID', 'SESSION_MANAGER', + 'INVOCATION_ID', 'JOURNAL_STREAM', 'MANAGERPID', 'MANAGERPIDFDID', 'NOTIFY_SOCKET', + 'LISTEN_FDS', 'LISTEN_PID', 'LISTEN_FDNAMES', 'COLORTERM', 'VTE_VERSION', 'WINDOWID', + 'WINDOWPATH', 'CHROME_DESKTOP', 'NO_AT_BRIDGE', 'MOTD_SHOWN', 'SNAP', 'APPIMAGE', 'APPDIR', + 'OWD', 'VIRTUAL_ENV', 'DEBUGINFOD_URLS', 'LS_COLORS', 'COLORFGBG', 'OLDPWD', 'SHLVL', +} +GUEST_SHELL = '/bin/zsh' + + +def host_session_variable(name): + return name in SESSION_NAMES or name.startswith(SESSION_PREFIXES) + + +def filter_host_environment(environ): + """Return the names removed from environ (a mutable mapping), honoring RCLOL_KEEP_HOST_ENV.""" + keep = set(environ.get('RCLOL_KEEP_HOST_ENV', '').replace(',', ' ').split()) + removed = sorted(name for name in environ if host_session_variable(name) and name not in keep) + for name in removed: + del environ[name] + # The host login shell (for example /bin/fish) does not exist in the guest. + if 'SHELL' not in keep: + environ['SHELL'] = GUEST_SHELL + return removed + def close_inherited_fds(): """A terminal, IDE or service manager can leak descriptors; the guest must not inherit them.""" @@ -22,6 +61,8 @@ def close_inherited_fds(): def main(argv): if len(argv) < 2: sys.exit('usage: exec-guest.py COMMAND [ARGUMENT]...') + if os.environ.get('RCLOL_FILTER_HOST_ENV') == '1': + filter_host_environment(os.environ) close_inherited_fds() os.execvp(argv[1], argv[1:]) diff --git a/tools/riot-launcher-offline-check.py b/tools/riot-launcher-offline-check.py index 46a8a09..0280d4e 100644 --- a/tools/riot-launcher-offline-check.py +++ b/tools/riot-launcher-offline-check.py @@ -36,7 +36,8 @@ if record["command"] == "darling": return False record["fds"] = sorted(fd for fd in map(int, os.listdir("/proc/self/fd")) if is_open(fd)) record["flags"] = {{key: os.environ.get(key) for key in - ("RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT", "RCLOL_UX_ARG1", "RCLOL_APP_ARGS", "RCLOL_APP_ARG2", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "DARLING_XSHM_LIBRARY")}} + ("RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT", "RCLOL_UX_ARG1", "RCLOL_APP_ARGS", "RCLOL_APP_ARG2", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "DARLING_XSHM_LIBRARY", + "WAYLAND_DISPLAY", "XAUTHORITY", "DRI_PRIME", "MESA_NO_ERROR", "__GL_THREADED_OPTIMIZATIONS", "SHELL")}} with open({str(log)!r}, "a") as output: output.write(json.dumps(record) + "\\n") ''' @@ -116,7 +117,17 @@ with open({str(log)!r}, "a") as output: subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True) clean = json.loads(log.read_text().splitlines()[-1]) assert clean["flags"]["DYLD_INSERT_LIBRARIES"] is None - print("LAUNCHER_OFFLINE pass=1 installed=PASS installer=PASS inherited_injection=absent runtime=preserved inherited_fds=closed") + # The opt-in host environment filter drops session metadata but never the launcher's GPU tuning. + env.update({"RCLOL_FILTER_HOST_ENV": "1", "WAYLAND_DISPLAY": "wayland-0", "XAUTHORITY": "/tmp/xauth", + "DRI_PRIME": "1", "SHELL": "/bin/fish"}) + env.pop("MESA_NO_ERROR", None) + env.pop("__GL_THREADED_OPTIMIZATIONS", None) + subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True) + filtered = json.loads(log.read_text().splitlines()[-1])["flags"] + assert filtered["WAYLAND_DISPLAY"] is None and filtered["SHELL"] == "/bin/zsh", "host session metadata survived" + assert (filtered["XAUTHORITY"], filtered["DRI_PRIME"]) == ("/tmp/xauth", "1"), "X11/GPU selection lost" + assert (filtered["MESA_NO_ERROR"], filtered["__GL_THREADED_OPTIMIZATIONS"]) == ("1", "0"), "launcher GPU tuning lost" + print("LAUNCHER_OFFLINE pass=1 installed=PASS installer=PASS inherited_injection=absent runtime=preserved inherited_fds=closed host_env_filter=PASS") if __name__ == "__main__": From 0a9ec5f67ee9b745ee288b1777417df8e1fa9aff Mon Sep 17 00:00:00 2001 From: David Gheghea <71325082+DavidutzDev@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:14:26 +0200 Subject: [PATCH 089/160] Stop Darling on Ctrl+C, terminal hangup and darling-lol --stop After the clients quit, the session kept Darling running: Riot and League crash handlers never exit under Darling, and the idle check counted them as clients. Ctrl+C or closing the terminal killed bubblewrap, whose --die-with-parent then killed nix-lifecycle.py before it could shut Darling down, which left a live server marker behind. - nix-idle.py ignores crash handlers when deciding whether a client still uses the prefix. - The launcher starts bubblewrap with INT, QUIT, HUP and TERM ignored, and env restores them inside. The supervisor turns them into a stop request. - nix-lifecycle.py polls for a signal or stop request and then shuts Darling down without waiting for clients. It stops retrying a failed shutdown once the server is gone, and waits for or kills its own command afterwards. - darling-lol --stop makes the stop request and waits for the session. If the session does not exit within 30 seconds, or no session holds the lock, it kills the prefix's darlingserver and launchd like darling shutdown, after checking that the marker PID is this user's darlingserver for this prefix. Co-Authored-By: Claude Opus 5.5 --- darling-lol-launcher.sh | 31 +++++++++-- docs/NIX.md | 13 +++-- scripts/nix-idle.py | 45 +++++++++++----- scripts/nix-lifecycle.py | 34 ++++++++++-- scripts/nix-stop.py | 104 ++++++++++++++++++++++++++++++++++++ tests/test_nix_launcher.py | 57 ++++++++++++++++++++ tests/test_nix_lifecycle.py | 60 +++++++++++++++++++++ 7 files changed, 318 insertions(+), 26 deletions(-) create mode 100644 scripts/nix-stop.py diff --git a/darling-lol-launcher.sh b/darling-lol-launcher.sh index 604c9f0..ddc8df7 100644 --- a/darling-lol-launcher.sh +++ b/darling-lol-launcher.sh @@ -24,6 +24,9 @@ if (workspace / '.launcher.lock').is_symlink(): raise SystemExit('Refusing symlink workspace lock') PY mkdir -p "$WORKSPACE" +if [ "${1:-}" = --stop ]; then + exec python3 "$SHARE_DIR/scripts/nix-stop.py" "$WORKSPACE" "$DPREFIX" +fi # One mutable base runtime per workspace; do not refresh scripts mid-session. exec 9<>"$WORKSPACE/.launcher.lock" flock -n 9 || { echo 'Another darling-lol workspace session is active; use its terminal.' >&2; exit 1; } @@ -35,7 +38,7 @@ if [ -f "$DPREFIX/.init.pid" ]; then state=$(awk '/^State:/ {print $2}' "/proc/$server_pid/status") if [ "$state" != Z ]; then echo 'Prefix has a live server marker; namespace reconnect is unsupported.' >&2 - echo 'Use the original darling-lol-shell terminal to run darling shutdown, then retry. No processes were stopped.' >&2 + echo 'Run darling-lol --stop to stop it, then retry. No processes were stopped.' >&2 exit 1 fi fi @@ -45,16 +48,36 @@ mkdir -p "$WORKSPACE/usr-local" cd "$WORKSPACE" # Map subordinate IDs before bubblewrap sets no_new_privs. UID 0 is not host root. fhs_command=("$UNSHARE_BIN" --user --map-auto --map-current-user --setuid=0 --setgid=0 "$FHS_BIN") +stop_request="$WORKSPACE/.stop-request" +rm -f "$stop_request" +# Ctrl+C, a closed terminal or SIGTERM must reach nix-lifecycle.py, which shuts +# Darling down in its namespace. They would kill bubblewrap first, and +# bubblewrap's --die-with-parent would kill the lifecycle with it. So bubblewrap +# starts with them ignored and env restores them inside. This supervisor turns +# them into a stop request, the same one darling-lol --stop makes. +session() { + (trap '' INT QUIT HUP TERM + exec "${fhs_command[@]}" env --default-signal=INT,QUIT,HUP,TERM bash ./scripts/nix-session.sh "$@") 0<&0 & + local pid=$! status + trap 'touch "$stop_request"' INT HUP TERM + while :; do + wait "$pid" && status=0 || status=$? + kill -0 "$pid" 2>/dev/null || break + done + trap - INT HUP TERM + rm -f "$stop_request" + return "$status" +} # Keep this supervisor alive to hold the lock: bubblewrap may close extra FDs. -# It never owns or signals game processes; foreground execution carries SIGINT. +# It never owns or signals game processes. case "${1:-}" in --shell) shift; "${fhs_command[@]}" bash "$@";; --update) shift; "${fhs_command[@]}" ./scripts/update.sh "$@";; - --installer) shift; "${fhs_command[@]}" bash ./scripts/nix-session.sh --installer "$@";; + --installer) shift; session --installer "$@";; *) if [ $# -gt 0 ]; then "${fhs_command[@]}" "$@" else - "${fhs_command[@]}" bash ./scripts/nix-session.sh + session fi;; esac diff --git a/docs/NIX.md b/docs/NIX.md index 197120d..a83e126 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -57,8 +57,15 @@ for prompts and session output. Keep the terminal open while clients are running and until cleanup completes. Default and installer sessions wait for clients to exit, then shut Darling down -in the same namespace. A failed shutdown is retried without discarding that -namespace. +in the same namespace. Crash handlers do not count as clients, because they do +not exit on their own under Darling. A failed shutdown is retried without +discarding that namespace. + +To end a session while clients are still running, press Ctrl+C in its terminal, +close the terminal, or run `darling-lol --stop` from anywhere. Each of these +makes the session shut Darling down and exit. If the session does not exit +within 30 seconds, `darling-lol --stop` kills the prefix's `darlingserver` +itself. It also stops a server that outlived its session. ## Install permanently @@ -198,7 +205,7 @@ nix run github:lucannez64/darling-lol#shell Use the launcher-provided shell rather than entering the raw FHS environment. If you start Darling manually in this shell, run `darling shutdown` there before exiting. A separately launched shell cannot reconnect to an existing -Darling namespace. +Darling namespace; `darling-lol --stop` can still stop it. ## Files and operational limits diff --git a/scripts/nix-idle.py b/scripts/nix-idle.py index b827ffd..7e26f82 100644 --- a/scripts/nix-idle.py +++ b/scripts/nix-idle.py @@ -3,6 +3,30 @@ import os from pathlib import Path +# Crash handlers do not exit after their clients under Darling, so a mapping by +# one of them alone does not mean that a client still uses the prefix. +CRASH_HANDLERS = {'RiotClientCrashHandler', 'LeagueCrashHandler', 'chrome_crashpad_handler'} + + +def crash_handler(process): + executable = (process / 'cmdline').read_bytes().split(b'\0', 1)[0] + return os.path.basename(executable.decode(errors='replace')) in CRASH_HANDLERS + + +def server_running(prefix): + """Whether the prefix marker names a live (not zombie) process.""" + try: + pid = int((prefix / '.init.pid').read_text().strip()) + except FileNotFoundError: + return False + if pid <= 0: + return False + try: + status = (Path('/proc') / str(pid) / 'status').read_text() + except (FileNotFoundError, ProcessLookupError): + return False + return not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines()) + def client_active(prefix, *, processes=None): """Check exact-prefix mappings; only vanished processes may be skipped.""" @@ -22,7 +46,7 @@ def client_active(prefix, *, processes=None): if processes is None and (process / 'uid_map').read_text() != namespace_map: continue mappings = (process / 'maps').read_text() - if any(path in mappings for path in paths): + if any(path in mappings for path in paths) and not crash_handler(process): return True except (FileNotFoundError, ProcessLookupError): # Process exited between enumeration and inspection. @@ -35,20 +59,13 @@ def client_active(prefix, *, processes=None): def main(): prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))).resolve() try: - pid = int((prefix / '.init.pid').read_text().strip()) - except FileNotFoundError: - pid = 0 - except (OSError, ValueError): + running = server_running(prefix) + except ValueError: raise SystemExit('Cannot validate prefix server marker; inspect it before maintenance.') - if pid > 0: - try: - status = (Path('/proc') / str(pid) / 'status').read_text() - if not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines()): - raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling shutdown in the ORIGINAL namespace before updating. No process was stopped.') - except (FileNotFoundError, ProcessLookupError): - pass - except OSError as error: - raise SystemExit(f'Cannot validate prefix server marker: {error}') from error + except OSError as error: + raise SystemExit(f'Cannot validate prefix server marker: {error}') from error + if running: + raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling-lol --stop before updating. No process was stopped.') try: active = client_active(prefix) except (OSError, RuntimeError) as error: diff --git a/scripts/nix-lifecycle.py b/scripts/nix-lifecycle.py index 321ef07..a9d46c6 100644 --- a/scripts/nix-lifecycle.py +++ b/scripts/nix-lifecycle.py @@ -28,9 +28,14 @@ def main(): idle = importlib.util.module_from_spec(spec) spec.loader.exec_module(idle) prefix = Path(os.environ['DPREFIX']).resolve() + # darling-lol --stop creates this file; the launcher removes it. + stop_request = Path(os.environ['DARLING_WORKSPACE']) / '.stop-request' child = None interrupted = 0 + def stopping(): + return interrupted or stop_request.exists() + def interrupted_by(signum, _frame): nonlocal interrupted interrupted = 128 + signum @@ -46,16 +51,18 @@ def main(): result = 1 try: child = subprocess.Popen(sys.argv[1:]) - result = child.wait() - if result < 0: - result = 128 - result + # Poll rather than wait: guest processes can outlive the command, and a + # signal or stop request must still reach the shutdown below. + while child.poll() is None and not stopping(): + time.sleep(0.5) except OSError as error: warn(f'Cannot start client: {error}') finally: # A launcher can return while guest clients remain. Do not tear down - # their server, or discard the only namespace capable of shutting it down. + # their server, or discard the only namespace capable of shutting it down, + # unless the user asked to stop. last_error = None - while True: + while not stopping(): try: if not idle.client_active(prefix): break @@ -66,6 +73,8 @@ def main(): 'Exit clients to allow safe shutdown.') last_error = str(error) time.sleep(1) + if stopping(): + warn('Stopping Darling.') while True: try: shutdown = subprocess.run(['bash', str(scripts / 'darling-rootless.sh'), 'shutdown']).returncode @@ -74,10 +83,25 @@ def main(): shutdown = 1 if not shutdown: break + try: + # Nothing is left to shut down, for example after the server crashed. + if not idle.server_running(prefix): + break + except (OSError, ValueError): + pass warn(f'Darling namespace-local shutdown failed (status {shutdown}); ' 'retaining the original namespace and retrying in 5 seconds. ' 'Keep this terminal open.') time.sleep(5) + if child is not None: + # Without its server the command exits; it is our own child to kill. + try: + result = child.wait(timeout=10) + except subprocess.TimeoutExpired: + child.kill() + result = child.wait() + if result < 0: + result = 128 - result result = interrupted or result return result diff --git a/scripts/nix-stop.py b/scripts/nix-stop.py new file mode 100644 index 0000000..e614f66 --- /dev/null +++ b/scripts/nix-stop.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""darling-lol --stop: end the workspace's session and its Darling server. + +The session shuts Darling down in its own namespace when it finds the stop +request. Only if it does not finish in time, or if no session holds the lock +but the prefix server is still alive, is that server killed from here. The +marker PID alone is not trusted: the process must be a darlingserver of this +user for this prefix. +""" +import fcntl +import os +from pathlib import Path +import signal +import sys +import time + +WAIT_SECONDS = 30 + + +def session_running(lock): + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return True + fcntl.flock(lock, fcntl.LOCK_UN) + return False + + +def wait_for_session(lock): + deadline = time.monotonic() + WAIT_SECONDS + while session_running(lock): + if time.monotonic() > deadline: + return False + time.sleep(0.5) + return True + + +def prefix_server(prefix): + """PID of this prefix's darlingserver, or 0 if none is running.""" + try: + pid = int((prefix / '.init.pid').read_text().strip()) + except (FileNotFoundError, ValueError): + return 0 + process = Path('/proc') / str(pid) + try: + argv = (process / 'cmdline').read_bytes().split(b'\0') + owner = process.stat().st_uid + except (FileNotFoundError, ProcessLookupError): + return 0 + if (pid <= 0 or owner != os.getuid() or len(argv) < 2 + or os.path.basename(argv[0]) != b'darlingserver' + or Path(os.fsdecode(argv[1])).resolve() != prefix): + return 0 + return pid + + +def kill_server(pid): + """Same as darling shutdown: kill launchd, which ends its PID namespace, then the server.""" + children = [] + for task in (Path('/proc') / str(pid) / 'task').glob('*/children'): + try: + children += [int(child) for child in task.read_text().split()] + except (FileNotFoundError, ProcessLookupError): + pass + for target in [*children, pid]: + try: + os.kill(target, signal.SIGKILL) + except ProcessLookupError: + pass + + +def main(): + workspace, prefix = Path(sys.argv[1]), Path(sys.argv[2]).resolve() + request = workspace / '.stop-request' + workspace.mkdir(parents=True, exist_ok=True) + with open(workspace / '.launcher.lock', 'a+') as lock: + if session_running(lock): + print('Asking the darling-lol session to shut Darling down...', flush=True) + request.touch() + if wait_for_session(lock): + request.unlink(missing_ok=True) + print('Stopped.') + return 0 + print(f'The session did not stop within {WAIT_SECONDS} seconds.', flush=True) + server = prefix_server(prefix) + if server: + print(f'Killing the Darling server of {prefix} (PID {server}).', flush=True) + kill_server(server) + elif not session_running(lock): + print('No darling-lol session is running.') + return 0 + if wait_for_session(lock): + request.unlink(missing_ok=True) + print('Stopped.') + return 0 + # Do not leave a request that would stop the next session at once. + request.unlink(missing_ok=True) + print('Darling is stopped, but the session that started it is still running. ' + 'Close its terminal.', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_nix_launcher.py b/tests/test_nix_launcher.py index b4190fc..dc94160 100644 --- a/tests/test_nix_launcher.py +++ b/tests/test_nix_launcher.py @@ -1,8 +1,11 @@ """Regression tests for the Nix launcher; no root or game account required.""" +import fcntl import os from pathlib import Path +import signal import subprocess import tempfile +import time import unittest ROOT = Path(__file__).resolve().parents[1] @@ -20,6 +23,7 @@ class LauncherTests(unittest.TestCase): (self.share / 'scripts/run.sh').write_text('new packaged script\n') import shutil shutil.copy(ROOT / 'scripts/nix-copy.py', self.share / 'scripts/nix-copy.py') + shutil.copy(ROOT / 'scripts/nix-stop.py', self.share / 'scripts/nix-stop.py') self.workspace = self.home / '.local/share/darling-lol' self.log = self.folder / 'calls' self.bin = self.folder / 'bin' @@ -62,6 +66,59 @@ class LauncherTests(unittest.TestCase): self.assertIn('--setgid=0', calls) self.assertEqual(calls[-3:], [str(self.bin / 'fhs'), 'echo', 'hello world']) + def test_session_restores_signals_inside_the_namespace(self): + result = self.launch() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.log.read_text().splitlines()[-5:], + [str(self.bin / 'fhs'), 'env', '--default-signal=INT,QUIT,HUP,TERM', + 'bash', './scripts/nix-session.sh']) + + def test_interrupt_becomes_a_stop_request_and_namespace_survives_it(self): + # Stands in for bubblewrap: it must outlive Ctrl+C and see the request. + (self.bin / 'unshare').write_text( + '#!/bin/sh\necho started > "$TEST_LOG"\n' + 'while [ ! -e .stop-request ]; do sleep 0.1; done\n' + 'echo stopped >> "$TEST_LOG"\n') + launcher = subprocess.Popen(['bash', str(self.launcher)], env=self.env, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True) + for _ in range(100): + if self.log.exists(): + break + time.sleep(0.1) + os.killpg(launcher.pid, signal.SIGINT) + _, stderr = launcher.communicate(timeout=10) + self.assertEqual(launcher.returncode, 0, stderr) + self.assertEqual(self.log.read_text().splitlines(), ['started', 'stopped']) + self.assertFalse((self.workspace / '.stop-request').exists()) + + def test_stop_without_session_does_not_enter_the_namespace(self): + result = self.launch('--stop') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('No darling-lol session is running', result.stdout) + self.assertFalse(self.log.exists()) + + def test_stop_asks_the_session_holding_the_lock_to_exit(self): + self.workspace.mkdir(parents=True) + lock = self.workspace / '.launcher.lock' + session = subprocess.Popen(['flock', str(lock), 'sh', '-c', + 'while [ ! -e "$0/.stop-request" ]; do sleep 0.1; done', + str(self.workspace)]) + self.addCleanup(session.kill) + with open(lock, 'a+') as handle: + for _ in range(100): + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(handle, fcntl.LOCK_UN) + time.sleep(0.1) + except BlockingIOError: + break + result = self.launch('--stop') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('Stopped.', result.stdout) + self.assertEqual(session.wait(timeout=5), 0) + self.assertFalse(self.log.exists()) + def test_setup_never_makes_a_user_writable_runtime_setuid(self): result = self.launch('--installer') self.assertEqual(result.returncode, 0, result.stderr) diff --git a/tests/test_nix_lifecycle.py b/tests/test_nix_lifecycle.py index f339e07..0671dea 100644 --- a/tests/test_nix_lifecycle.py +++ b/tests/test_nix_lifecycle.py @@ -3,8 +3,10 @@ import importlib.util import os from pathlib import Path import shutil +import signal import subprocess import tempfile +import time import unittest from unittest.mock import MagicMock, patch @@ -30,6 +32,26 @@ class IdleTests(unittest.TestCase): (process / 'maps').read_text.side_effect = FileNotFoundError() self.assertFalse(idle.client_active(Path('/fixture/prefix'), processes=[process])) + def test_crash_handlers_alone_do_not_keep_the_prefix_active(self): + spec = importlib.util.spec_from_file_location('nix_idle_handlers', ROOT / 'scripts/nix-idle.py') + assert spec is not None and spec.loader is not None + idle = importlib.util.module_from_spec(spec) + spec.loader.exec_module(idle) + with tempfile.TemporaryDirectory() as folder: + prefix = Path(folder) / 'prefix' + app = prefix / 'Applications/League of Legends.app' + + def process(pid, executable): + entry = Path(folder) / 'proc' / str(pid) + entry.mkdir(parents=True) + (entry / 'maps').write_text(f'7f00-7f01 r-xp 0 0:0 1 {app}/Contents/MacOS/{executable}\n') + (entry / 'cmdline').write_bytes(f'/Applications/League of Legends.app/Contents/MacOS/{executable}\0--flag\0'.encode()) + return entry + + handlers = [process(1, 'LeagueCrashHandler'), process(2, 'chrome_crashpad_handler')] + self.assertFalse(idle.client_active(prefix, processes=handlers)) + self.assertTrue(idle.client_active(prefix, processes=[*handlers, process(3, 'LeagueClientUx')])) + class LifecycleTests(unittest.TestCase): def setUp(self): @@ -54,9 +76,16 @@ def client_active(prefix, **kwargs): with open(os.environ['TEST_LOG'], 'a') as f: f.write('scan\\n') return calls < 2 +def server_running(prefix): + return os.environ.get('SERVER_RUNNING', '1') == '1' ''') (scripts / 'darling-rootless.sh').write_text('''#!/bin/bash echo "shutdown:$*" >> "$TEST_LOG" +# Like darling shutdown, end the client command that the server ran. +if [ -n "${CLIENT_SLEEP:-}" ]; then + for _ in 1 2 3 4 5 6 7 8 9 10; do [ -e "$TEST_LOG.pid" ] && break; sleep 0.2; done +fi +[ -e "$TEST_LOG.pid" ] && kill "$(cat "$TEST_LOG.pid")" if [ "${SHUTDOWN_RC:-0}" != 0 ] && [ ! -e "$TEST_LOG.retried" ]; then touch "$TEST_LOG.retried" exit "$SHUTDOWN_RC" @@ -65,6 +94,10 @@ exit 0 ''') client = scripts / 'run.sh' client.write_text('''#!/usr/bin/env bash +if [ -n "${CLIENT_SLEEP:-}" ]; then + echo "$$" > "$TEST_LOG.pid" + exec sleep "$CLIENT_SLEEP" +fi echo client-finished >> "$TEST_LOG" exit "${CLIENT_RC:-0}" '''.replace('#!/usr/bin/env bash', '#!' + str(shutil.which('bash')))) @@ -89,11 +122,38 @@ exit "${CLIENT_RC:-0}" self.assertEqual(self.log.read_text().splitlines().count('shutdown:shutdown'), 2) self.assertIn('shutdown:shutdown', self.log.read_text()) + def test_stop_request_shuts_down_without_waiting_for_command_or_clients(self): + (self.root / '.stop-request').touch() + result = self.run_session(CLIENT_SLEEP='60') + self.assertEqual(result.returncode, 128 + 15, result.stderr) + self.assertIn('Stopping Darling', result.stderr) + self.assertEqual(self.log.read_text().splitlines(), ['shutdown:shutdown']) + + def test_interrupt_shuts_down_without_waiting_for_command_or_clients(self): + session = subprocess.Popen(['bash', str(self.root / 'scripts/nix-session.sh')], + env=dict(self.env, CLIENT_SLEEP='60'), text=True, + stderr=subprocess.PIPE) + pid_file = Path(str(self.log) + '.pid') + for _ in range(50): + if pid_file.exists(): + break + time.sleep(0.1) + session.send_signal(signal.SIGINT) + _, stderr = session.communicate(timeout=10) + self.assertEqual(session.returncode, 128 + signal.SIGINT, stderr) + self.assertEqual(self.log.read_text().splitlines(), ['shutdown:shutdown']) + + def test_failed_shutdown_is_not_retried_once_the_server_is_gone(self): + result = self.run_session(SHUTDOWN_RC='23', SERVER_RUNNING='0') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.log.read_text().splitlines().count('shutdown:shutdown'), 1) + def test_shutdown_retries_when_terminal_diagnostics_fail(self): spec = importlib.util.spec_from_file_location('lifecycle_fixture', self.root / 'scripts/nix-lifecycle.py') lifecycle = importlib.util.module_from_spec(spec) spec.loader.exec_module(lifecycle) child = MagicMock() + child.poll.return_value = 17 child.wait.return_value = 17 stderr = MagicMock() stderr.write.side_effect = OSError('terminal disconnected') From 3752d9e812e9b7cd9efac35925645220345744cf Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:14:47 +0200 Subject: [PATCH 090/160] Fix Metal device edge cases; keep reporting the host GPU From #61, without the default "AMD Radeon Pro 5500M" / 4 GB identity: the host GPU's name and Vulkan working set stay the default for every app, and the existing DARLING_METAL_GPU_NAME and DARLING_METAL_VRAM_MB overrides remain the opt-in. Reporting 4 GB on a 2 GB card invites allocation failures, and under-reports larger cards. Kept from #61: - registryID returns MTLDeviceInfo::kDefaultRegistryID instead of 0; a real Mac never reports 0 and apps key devices by it. - Vertex buffer layout and attribute arrays release the old item after retiring it; the retire step read the item after the release could free it. - newDepthStencilState, newSamplerState and the device name return nil (or the fallback name) when Indium throws, instead of unwinding through Objective-C. - MTLDepthStencilGetIndium is declared only with DARLING_METAL_ENABLED. - The replaceRegion trace probes at most `width` bytes, which stays inside the first row for every pixel format. metal.patch is regenerated in its existing file order, so the diff is these changes only. tests/test_metal_device_info.py compiles the host check against MTLDeviceInfo.h as shipped in the patch and runs in CI. Split from #61. Co-authored-by: DevWebeloper Co-Authored-By: Claude Opus 5.5 --- .github/workflows/source-check.yml | 1 + docs/TESTING.md | 2 +- docs/TROUBLESHOOTING.md | 14 +++++++ patches/metal.patch | 62 ++++++++++++++++++------------ tests/test_metal_device_info.py | 46 ++++++++++++++++++++++ tools/test-metal-device-info.cpp | 7 ++++ 6 files changed, 107 insertions(+), 25 deletions(-) create mode 100644 tests/test_metal_device_info.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 1d02ffc..893aafd 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -17,3 +17,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' - run: python -m unittest discover -s tests -p 'test_desktop_run.py' - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' + - run: python -m unittest discover -s tests -p 'test_metal_device_info.py' diff --git a/docs/TESTING.md b/docs/TESTING.md index 8a544f9..78b7f54 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -23,7 +23,7 @@ c++ -std=c++17 -I build/darling-src/src/external/metal/private-include \ build/test-metal-device-info ``` -Expected: `METAL_DEVICE_INFO failures=0 pass=1`. It covers how `MTLDevice.name` turns Vulkan names into macOS style (`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`, `Intel(R) UHD Graphics 630 (CML GT2)` becomes `Intel UHD Graphics 630`, NVIDIA names stay as they are), the `DARLING_METAL_VRAM_MB` parser, and the working-set sum over Vulkan heaps. +Expected: `METAL_DEVICE_INFO failures=0 pass=1`. It covers how `MTLDevice.name` turns Vulkan names into macOS style (`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`, `Intel(R) UHD Graphics 630 (CML GT2)` becomes `Intel UHD Graphics 630`, NVIDIA names stay as they are), the `DARLING_METAL_VRAM_MB` parser, the working-set sum over Vulkan heaps, and a non-zero `registryID`. `python3 -m unittest tests.test_metal_device_info` runs the same check against the header in `patches/metal.patch`, so it also works without a prepared source tree; CI runs it. The LLVM version check compiles Iridium's actual dynamic loader against small stub libraries. It verifies that a matching major initializes, older/newer majors produce a version mismatch, and a missing library fails initialization. It does not start Darling. The native AIR translator (`python3 tools/build-riot-air-check.py --native`) selects both headers and the shared library from the same `llvm-config`; its build prints those paths so shader results can be attributed to the tested LLVM installation. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index e9494f3..a498826 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -375,6 +375,20 @@ Tested on a GTX 1060 6GB with driver 580.178.04 on NixOS, Hyprland and Xwayland: the client and the tutorial match render on the NVIDIA GPU. Newer drivers were not retested with the option off. +### Metal device name and memory budget + +`MTLDevice` reports the host GPU: its Vulkan name in macOS style (for example +`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`) and a working set +(`recommendedMaxWorkingSetSize`) from the GPU's Vulkan memory heaps. Apps size their GPU +allocations from the working set, so only override it to match the card's real memory. +Both can be overridden per launch: + +```sh +DARLING_METAL_GPU_NAME='AMD Radeon Pro 5500M' DARLING_METAL_VRAM_MB=4096 ./scripts/run.sh +``` + +`registryID` is a fixed non-zero value, as on a real Mac. + ### Host-side checks These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice. diff --git a/patches/metal.patch b/patches/metal.patch index adeb448..a5292d3 100644 --- a/patches/metal.patch +++ b/patches/metal.patch @@ -1134,7 +1134,7 @@ index 2a57796..0cbedf2 100644 - @end diff --git a/src/Metal/MTLDevice.mm b/src/Metal/MTLDevice.mm -index f58184a..a80a625 100644 +index f58184a..ed918ed 100644 --- a/src/Metal/MTLDevice.mm +++ b/src/Metal/MTLDevice.mm @@ -2,14 +2,32 @@ @@ -1218,7 +1218,7 @@ index f58184a..a80a625 100644 } @synthesize device = _device; -@@ -116,6 +168,39 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -116,6 +168,44 @@ void MTLRemoveDeviceObserver(id observer) { [_threadExitCondition unlock]; } @@ -1229,7 +1229,12 @@ index f58184a..a80a625 100644 + static const char* const nameOverride = getenv("DARLING_METAL_GPU_NAME"); + static const uint64_t vramOverride = MTLDeviceInfo::parseMegabytes(getenv("DARLING_METAL_VRAM_MB")); + -+ std::string vulkanName = _device->name(); ++ std::string vulkanName; ++ try { ++ vulkanName = _device ? _device->name() : ""; ++ } catch (...) { ++ vulkanName = ""; ++ } + _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::resolveName(nameOverride, vulkanName.c_str()).c_str()]; + if (_name == nil) { + _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::kFallbackName]; @@ -1258,7 +1263,7 @@ index f58184a..a80a625 100644 - (instancetype)initWithDevice: (std::shared_ptr)device { self = [super init]; -@@ -123,6 +208,7 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -123,6 +213,7 @@ void MTLRemoveDeviceObserver(id observer) { _device = device; _threadExitCondition = [NSCondition new]; _threadIsRunning = NO; @@ -1266,7 +1271,7 @@ index f58184a..a80a625 100644 _pollingThread = [[NSThread alloc] initWithTarget: self selector: @selector(pollingLoop) object: nil]; [_pollingThread start]; } -@@ -133,6 +219,7 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -133,6 +224,7 @@ void MTLRemoveDeviceObserver(id observer) { { [_pollingThread release]; [_threadExitCondition release]; @@ -1274,7 +1279,7 @@ index f58184a..a80a625 100644 [super dealloc]; } -@@ -153,12 +240,60 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -153,12 +245,60 @@ void MTLRemoveDeviceObserver(id observer) { [_threadExitCondition unlock]; } @@ -1311,7 +1316,7 @@ index f58184a..a80a625 100644 +- (BOOL)isRemovable { return NO; } +- (BOOL)isHeadless { return NO; } +- (NSString*)name { return _name; } -+- (uint64_t)registryID { return 0; } ++- (uint64_t)registryID { return MTLDeviceInfo::kDefaultRegistryID; } +- (BOOL)supportsFamily: (NSUInteger)family { return YES; } +- (BOOL)supportsFeatureSet: (NSUInteger)featureSet { return YES; } +- (BOOL)supportsTextureSampleCount: (NSUInteger)sampleCount { return sampleCount <= 4; } @@ -1336,7 +1341,7 @@ index f58184a..a80a625 100644 if (!pso) { if (error) { // TODO: better error and/or match what the official Metal method does -@@ -166,6 +301,7 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -166,6 +306,7 @@ void MTLRemoveDeviceObserver(id observer) { } return nil; } @@ -1344,7 +1349,7 @@ index f58184a..a80a625 100644 return [[MTLComputePipelineStateInternal alloc] initWithState: pso device: self label: descriptor.label]; } -@@ -183,7 +319,13 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -183,7 +324,13 @@ void MTLRemoveDeviceObserver(id observer) { reflection: (MTLAutoreleasedComputePipelineReflection*)reflection error: (NSError**)error { @@ -1359,7 +1364,7 @@ index f58184a..a80a625 100644 if (!pso) { if (error) { // TODO: better error and/or match what the official Metal method does -@@ -191,13 +333,44 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -191,13 +338,44 @@ void MTLRemoveDeviceObserver(id observer) { } return nil; } @@ -1405,7 +1410,7 @@ index f58184a..a80a625 100644 if (!pso) { if (error) { // TODO: better error and/or match what the official Metal method does -@@ -205,7 +378,114 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -205,7 +383,118 @@ void MTLRemoveDeviceObserver(id observer) { } return nil; } @@ -1453,7 +1458,9 @@ index f58184a..a80a625 100644 +- (id)newDepthStencilStateWithDescriptor:(MTLDepthStencilDescriptor*)descriptor +{ + if (!descriptor) return nil; -+ auto state = _device->newDepthStencilState([descriptor asIndiumDescriptor]); ++ std::shared_ptr state; ++ try { state = _device->newDepthStencilState([descriptor asIndiumDescriptor]); } ++ catch (...) { return nil; } + if (!state) return nil; + return [[MTLDepthStencilStateInternal alloc] initWithState:state device:self label:descriptor.label]; +} @@ -1475,7 +1482,9 @@ index f58184a..a80a625 100644 + desc.lodMaxClamp = descriptor.lodMaxClamp; + desc.supportArgumentBuffers = descriptor.supportArgumentBuffers; + desc.compareFunction = static_cast(descriptor.compareFunction); -+ auto sampler = _device->newSamplerState(desc); ++ std::shared_ptr sampler; ++ try { sampler = _device->newSamplerState(desc); } ++ catch (...) { return nil; } + if (!sampler) return nil; + return [[MTLSamplerStateInternal alloc] initWithSampler:sampler device:self label:descriptor.label]; +} @@ -1521,7 +1530,7 @@ index f58184a..a80a625 100644 } - (id)newCommandQueue -@@ -275,8 +555,21 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -275,8 +564,21 @@ void MTLRemoveDeviceObserver(id observer) { - (id)newLibraryWithData: (dispatch_data_t)data error: (NSError**)error { @@ -2581,7 +2590,7 @@ index 66a9056..f28235c 100644 - @end diff --git a/src/Metal/MTLTexture.mm b/src/Metal/MTLTexture.mm -index 6c71fb9..822c112 100644 +index 6c71fb9..186b8c0 100644 --- a/src/Metal/MTLTexture.mm +++ b/src/Metal/MTLTexture.mm @@ -1,3 +1,5 @@ @@ -2590,7 +2599,7 @@ index 6c71fb9..822c112 100644 // SPDX-FileCopyrightText: 2026 Darling Developers // SPDX-License-Identifier: MPL-2.0 -@@ -7,11 +9,37 @@ +@@ -7,11 +9,38 @@ #import #import @@ -2606,7 +2615,8 @@ index 6c71fb9..822c112 100644 + if (FILE* log = fopen("/tmp/metal-trace.log", "a")) { + const unsigned char* b = (const unsigned char*) bytes; + unsigned long nonZero = 0; -+ size_t probe = std::min(4096, (size_t) region.size.width * 4); ++ // Every pixel format takes at least one byte per pixel, so the first `width` bytes lie inside the first row. ++ size_t probe = std::min(4096, (size_t) region.size.width); + for (size_t i = 0; i < probe; ++i) if (b[i]) ++nonZero; + fprintf(log, "replaceRegion tex=%p %lux%lu fmt=%lu level=%lu region=(%lu,%lu %lux%lu) firstBytesNonZero=%lu/%zu took=%.1fms\n", texture, + (unsigned long) texture.width, (unsigned long) texture.height, (unsigned long) texture.pixelFormat, (unsigned long) level, @@ -2628,7 +2638,7 @@ index 6c71fb9..822c112 100644 @synthesize device = _device; @synthesize resourceOptions = _resourceOptions; @synthesize label = _label; -@@ -164,7 +192,9 @@ +@@ -164,7 +193,9 @@ withBytes: (const void*)pixelBytes bytesPerRow: (NSUInteger)bytesPerRow { @@ -2638,7 +2648,7 @@ index 6c71fb9..822c112 100644 } - (void)replaceRegion: (MTLRegion)region -@@ -174,7 +204,9 @@ +@@ -174,7 +205,9 @@ bytesPerRow: (NSUInteger)bytesPerRow bytesPerImage: (NSUInteger)bytesPerImage { @@ -2736,7 +2746,7 @@ index 37cccb3..79b2350 100644 - @end diff --git a/src/Metal/MTLVertexDescriptor.mm b/src/Metal/MTLVertexDescriptor.mm -index cde968d..aa7fe05 100644 +index cde968d..0a016c6 100644 --- a/src/Metal/MTLVertexDescriptor.mm +++ b/src/Metal/MTLVertexDescriptor.mm @@ -1,24 +1,435 @@ @@ -3015,8 +3025,8 @@ index cde968d..aa7fe05 100644 + if (index >= 31) [NSException raise:NSRangeException format:@"Vertex descriptor index %lu exceeds 30", (unsigned long)index]; + MTLVertexBufferLayoutDescriptor* old = _items[index]; + _items[index] = [descriptor retain]; -+ [old release]; + MTLVertexBufferLayoutArrayRetireItem(self, old); ++ [old release]; + const uint32_t bit = 1u << index; + if (descriptor) _present |= bit; else _present &= ~bit; + if (descriptor && object_getClass(descriptor) == [MTLVertexBufferLayoutDescriptor class]) _concrete |= bit; else _concrete &= ~bit; @@ -3077,8 +3087,8 @@ index cde968d..aa7fe05 100644 + if (index >= 31) [NSException raise:NSRangeException format:@"Vertex descriptor index %lu exceeds 30", (unsigned long)index]; + MTLVertexAttributeDescriptor* old = _items[index]; + _items[index] = [descriptor retain]; -+ [old release]; + MTLVertexAttributeArrayRetireItem(self, old); ++ [old release]; + const uint32_t bit = 1u << index; + if (descriptor) _present |= bit; else _present &= ~bit; + if (descriptor && object_getClass(descriptor) == [MTLVertexAttributeDescriptor class]) _concrete |= bit; else _concrete &= ~bit; @@ -3440,7 +3450,7 @@ diff --git a/private-include/Metal/MTLDepthStencilInternal.h b/private-include/M new file mode 100644 --- /dev/null +++ b/private-include/Metal/MTLDepthStencilInternal.h -@@ -0,0 +1,25 @@ +@@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Darling Developers +// SPDX-License-Identifier: MPL-2.0 + @@ -3462,15 +3472,17 @@ new file mode 100644 +- (instancetype)initWithState:(std::shared_ptr)state device:(id)device label:(NSString*)label; +#endif +@end ++#if DARLING_METAL_ENABLED +// Direct access for the encoders: the property getter is a message send that returns the shared_ptr by value. +const std::shared_ptr& MTLDepthStencilGetIndium(id object); ++#endif + +#endif diff --git a/private-include/Metal/MTLDeviceInfo.h b/private-include/Metal/MTLDeviceInfo.h new file mode 100644 --- /dev/null +++ b/private-include/Metal/MTLDeviceInfo.h -@@ -0,0 +1,148 @@ +@@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: 2026 Darling Developers +// SPDX-License-Identifier: MPL-2.0 + @@ -3489,6 +3501,8 @@ new file mode 100644 + +// Used when neither the cleaned nor the raw Vulkan name has any characters. +static constexpr const char* kFallbackName = "Darling Indium Device"; ++// A real Mac never reports registry ID 0, and apps use it as a key for the device. ++static constexpr uint64_t kDefaultRegistryID = 0x100000439ULL; + +static inline bool isSpace(char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; diff --git a/tests/test_metal_device_info.py b/tests/test_metal_device_info.py new file mode 100644 index 0000000..4eac0f9 --- /dev/null +++ b/tests/test_metal_device_info.py @@ -0,0 +1,46 @@ +"""Compile tools/test-metal-device-info.cpp against MTLDeviceInfo.h as shipped in patches/metal.patch.""" +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +HEADER = 'private-include/Metal/MTLDeviceInfo.h' + + +def header_from_patch(): + """MTLDeviceInfo.h is a new file in the patch, so its text is the section's added lines.""" + patch = (ROOT / 'patches/metal.patch').read_text() + section = re.search(r'(?ms)^diff --git a/%s b/%s\n.*?^@@ [^\n]*\n(.*?)(?=^diff --git |\Z)' % (HEADER, HEADER), patch) + if not section: + raise AssertionError(HEADER + ' is not a new file in patches/metal.patch') + lines = section.group(1).splitlines(keepends=True) + if not all(line.startswith('+') for line in lines): + raise AssertionError(HEADER + ' section has context or removed lines') + return ''.join(line[1:] for line in lines) + + +class MetalDeviceInfoTests(unittest.TestCase): + def test_host_check_passes(self): + compiler = shutil.which('c++') + if not compiler: + self.skipTest('no C++ compiler') + with tempfile.TemporaryDirectory() as folder: + include = Path(folder) / 'include/Metal' + include.mkdir(parents=True) + (include / 'MTLDeviceInfo.h').write_text(header_from_patch()) + binary = Path(folder) / 'test-metal-device-info' + build = subprocess.run([compiler, '-std=c++17', '-Wall', '-Werror', '-I', str(include.parent), + str(ROOT / 'tools/test-metal-device-info.cpp'), '-o', str(binary)], + capture_output=True, text=True) + self.assertEqual(build.returncode, 0, build.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn('METAL_DEVICE_INFO failures=0 pass=1', result.stdout) + self.assertIn('PASS registry ID is non-zero', result.stdout) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test-metal-device-info.cpp b/tools/test-metal-device-info.cpp index 926dd75..dd64710 100644 --- a/tools/test-metal-device-info.cpp +++ b/tools/test-metal-device-info.cpp @@ -49,6 +49,13 @@ int main() { expectName("", MTLDeviceInfo::kFallbackName); expectName(nullptr, MTLDeviceInfo::kFallbackName); + // -[MTLDevice registryID]: never 0, like a real Mac. + { + bool ok = MTLDeviceInfo::kDefaultRegistryID != 0; + failures += !ok; + std::printf("%s registry ID is non-zero\n", ok ? "PASS" : "FAIL"); + } + // DARLING_METAL_GPU_NAME override. { bool ok = MTLDeviceInfo::resolveName("My GPU", "AMD Radeon RX 6800 XT (RADV NAVI21)") == "My GPU" From 34c3e4ae9f00f3c176b5ab8b3e431ad13a33f804 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:17:05 +0200 Subject: [PATCH 091/160] Report hardware and software overviews from IOKit and sysctl in system_profiler system_profiler now answers SPHardwareDataType and SPSoftwareDataType (and lists them in -listDataTypes). Model, serial number, platform UUID and provisioning UDID come from IOKit's IOPlatformExpertDevice via ioreg; CPU, cache and memory from sysctl; the OS version from sw_vers and uname. A field the guest does not provide is left out. #61 instead read a separate darling-smbios.conf written by a new generator (gen-darling-smbios.py), with hardcoded fallbacks (an Intel Core i9, macOS 14.7, SIP enabled). iokitd reads darling-platform.plist, so system_profiler and IOKit could report different serials and models on the same prefix, and the generator overwrote identities imported with configure-darling-platform.py. Reading the guest's own APIs keeps a single source of truth: configure-darling-platform.py remains the only way to set or provision an identity, and it is still never run by install or launch. tests/test_system_profiler.py runs the script under sh with stub guest commands and runs in CI. Split from #61. Co-authored-by: DevWebeloper Co-Authored-By: Claude Opus 5.5 --- .github/workflows/source-check.yml | 1 + docs/PLATFORM_IDENTITY.md | 12 ++++ tests/test_system_profiler.py | 97 ++++++++++++++++++++++++++++++ tools/darling-system_profiler.sh | 86 +++++++++++++++++++++++++- 4 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 tests/test_system_profiler.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 1d02ffc..276adf0 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -17,3 +17,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' - run: python -m unittest discover -s tests -p 'test_desktop_run.py' - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' + - run: python -m unittest discover -s tests -p 'test_system_profiler.py' diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md index 9e623db..3d132ea 100644 --- a/docs/PLATFORM_IDENTITY.md +++ b/docs/PLATFORM_IDENTITY.md @@ -134,3 +134,15 @@ a per-prefix lock. Repeated writes preserve identifiers and unchanged files. Previewing a fresh identity generates a sample; only `--write` makes it stable. To restore host-derived defaults after provisioning, remove both identity files while stopped, so a later provisioning run cannot reimport the legacy copy. + +## system_profiler + +The prefix's `/usr/sbin/system_profiler` (installed by +`tools/install-darling-system-profiler.py`) answers `SPHardwareDataType` and +`SPSoftwareDataType` from the running guest: model, serial number, platform UUID +and provisioning UDID from IOKit's `IOPlatformExpertDevice` (via `ioreg`), CPU, +cache and memory from `sysctl`, and the OS version from `sw_vers` and `uname`. It +keeps no identity of its own, so it reports the same values as IOKit, including any +override saved by this tool. A field the guest does not provide is left out. +`python3 -m unittest tests.test_system_profiler` checks the output with stub +commands. diff --git a/tests/test_system_profiler.py b/tests/test_system_profiler.py new file mode 100644 index 0000000..49d022d --- /dev/null +++ b/tests/test_system_profiler.py @@ -0,0 +1,97 @@ +"""system_profiler hardware/software overviews, with stub guest commands; never starts Darling.""" +import os +from pathlib import Path +import plistlib +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'tools/darling-system_profiler.sh' + +IOREG = '''+-o MacBookPro16,1 + { + "IOPlatformSerialNumber" = "USER-SUPPLIED" + "model" = <"MacBookPro16,1"> + "manufacturer" = <"Apple Inc."> + "IOPlatformUUID" = "8E1F2A3B-4C5D-4E6F-8A7B-9C0D1E2F3A4B" + "provisioning_UDID" = "8E1F2A3B-4C5D-4E6F-8A7B-9C0D1E2F3A4B" + "ROM" = <0a1b2c3d4e5f> + } +''' +SYSCTL = { + 'hw.physicalcpu': '8', 'hw.packages': '1', 'hw.memsize': str(32 << 30), + 'machdep.cpu.brand_string': 'AMD Ryzen 7 5800X 8-Core Processor & more', + 'hw.l2cachesize': str(512 << 10), 'hw.l3cachesize': str(32 << 20), 'hw.cpufrequency': '3800000000', +} + + +class SystemProfilerTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.bin = Path(self.folder.name) + self.stub('sysctl', 'import sys\nvalues = %r\nname = sys.argv[-1]\n' + 'if name not in values: sys.exit(1)\nprint(values[name])' % SYSCTL) + self.stub('sw_vers', 'import sys\nprint({"-productName": "macOS", "-productVersion": "11.7.4", ' + '"-buildVersion": "20G1120"}[sys.argv[1]])') + self.stub('uname', 'print("Darwin 20.6.0")') + self.stub('hostname', 'print("darling")') + self.stub('ioreg', 'import sys\nsys.stdout.write(%r)' % IOREG) + + def tearDown(self): + self.folder.cleanup() + + def stub(self, name, body): + path = self.bin / name + path.write_text('#!%s\n%s\n' % (sys.executable, body)) + path.chmod(0o755) + + def run_profiler(self, *args): + env = {'PATH': '%s:%s' % (self.bin, os.environ['PATH']), 'USER': 'player'} + return subprocess.run(['sh', str(SCRIPT), *args], env=env, check=True, capture_output=True, text=True).stdout + + def xml_items(self, data_type): + report = plistlib.loads(self.run_profiler('-xml', data_type).encode()) + self.assertEqual(report[0]['_dataType'], data_type) + return report[0]['_items'][0] + + def test_hardware_reports_iokit_identity_and_sysctl_values(self): + item = self.xml_items('SPHardwareDataType') + self.assertEqual(item, { + '_name': 'hardware_overview', 'machine_name': 'MacBook Pro', 'machine_model': 'MacBookPro16,1', + 'cpu_type': 'AMD Ryzen 7 5800X 8-Core Processor & more', 'current_processor_speed': '3.8 GHz', + 'packages': 1, 'number_processors': 8, 'l2_cache_core': '512 KB', 'l3_cache': '32 MB', + 'physical_memory': '32 GB', 'serial_number': 'USER-SUPPLIED', + 'platform_UUID': '8E1F2A3B-4C5D-4E6F-8A7B-9C0D1E2F3A4B', + 'provisioning_UDID': '8E1F2A3B-4C5D-4E6F-8A7B-9C0D1E2F3A4B', + }) + text = self.run_profiler('SPHardwareDataType') + self.assertIn(' Serial Number (system): USER-SUPPLIED\n', text) + self.assertIn(' Model Name: MacBook Pro\n', text) + + def test_missing_iokit_and_sysctl_values_are_omitted(self): + self.stub('ioreg', 'import sys\nsys.exit(1)') + self.stub('sysctl', 'import sys\nsys.exit(1)') + self.assertEqual(self.xml_items('SPHardwareDataType'), {'_name': 'hardware_overview'}) + self.assertNotIn('Serial Number', self.run_profiler('SPHardwareDataType')) + + def test_software_overview(self): + item = self.xml_items('SPSoftwareDataType') + self.assertEqual(item, { + '_name': 'os_overview', 'os_version': 'macOS 11.7.4 (20G1120)', 'kernel_version': 'Darwin 20.6.0', + 'boot_volume': 'Macintosh HD', 'boot_mode': 'normal_boot', 'local_host_name': 'darling', + 'user_name': 'player', + }) + self.assertIn(' Boot Mode: Normal\n', self.run_profiler('SPSoftwareDataType')) + + def test_listed_and_default_types(self): + listed = self.run_profiler('-listDataTypes') + self.assertIn('SPHardwareDataType', listed) + self.assertIn('SPSoftwareDataType', listed) + types = [entry['_dataType'] for entry in plistlib.loads(self.run_profiler('-xml').encode())] + self.assertEqual(types[:2], ['SPHardwareDataType', 'SPSoftwareDataType']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/darling-system_profiler.sh b/tools/darling-system_profiler.sh index 3410740..540b101 100644 --- a/tools/darling-system_profiler.sh +++ b/tools/darling-system_profiler.sh @@ -1,6 +1,8 @@ #!/bin/sh # system_profiler for the Darling prefix: reports the host's real display and -# network hardware for the data types applications query at startup. +# network hardware for the data types applications query at startup. The hardware +# and software overviews come from the guest's own IOKit, sysctl and sw_vers, so they +# match what other APIs report (including a configure-darling-platform.py override). # Installed as /usr/sbin/system_profiler by tools/install-darling-system-profiler.py. host=/Volumes/SystemRoot xml=0 @@ -9,12 +11,12 @@ while [ $# -gt 0 ]; do case "$1" in -xml) xml=1;; -detailLevel|-timeout) shift;; - -listDataTypes) printf 'Available Datatypes:\nSPAirPortDataType\nSPDisplaysDataType\nSPEthernetDataType\n'; exit 0;; + -listDataTypes) printf 'Available Datatypes:\nSPAirPortDataType\nSPDisplaysDataType\nSPEthernetDataType\nSPHardwareDataType\nSPSoftwareDataType\n'; exit 0;; SP*DataType) types="$types $1";; esac shift done -[ -n "$types" ] || types=" SPDisplaysDataType SPEthernetDataType SPAirPortDataType" +[ -n "$types" ] || types=" SPHardwareDataType SPSoftwareDataType SPDisplaysDataType SPEthernetDataType SPAirPortDataType" escape() { sed -e 's/&/\&/g' -e 's//\>/g'; } @@ -46,6 +48,80 @@ displays() { # one line per GPU: vendor|device|vram MB or empty|name done } +load_platform() { # IOPlatformExpertDevice string properties, "key|value" per line + # String values print as "value", NUL-terminated data as <"value">; other data is skipped. + platform_properties=$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | + sed -n 's/^[ |]*"\([^"]*\)" = <\{0,1\}"\(.*\)">\{0,1\}$/\1|\2/p') +} +platform() { + printf '%s\n' "$platform_properties" | awk -v key="$1" 'index($0, key "|") == 1 { print substr($0, length(key) + 2); exit }' +} + +machine_name() { # model identifier -> marketing family, as in About This Mac + case "$1" in + MacBookPro*) echo 'MacBook Pro';; MacBookAir*) echo 'MacBook Air';; MacBook*) echo 'MacBook';; + iMacPro*) echo 'iMac Pro';; iMac*) echo 'iMac';; Macmini*) echo 'Mac mini';; MacPro*) echo 'Mac Pro';; + *) echo 'Mac';; + esac +} + +hardware() { # label|xml key|xml type|value for each known field + load_platform + model=$(platform model) + cores=$(sysctl -n hw.physicalcpu 2>/dev/null) + memory=$(sysctl -n hw.memsize 2>/dev/null | awk '$1 > 0 { printf "%d GB", $1 / 1073741824 + 0.5 }') + speed=$(sysctl -n hw.cpufrequency 2>/dev/null | awk '$1 > 0 { printf "%.1f GHz", $1 / 1e9 }') + l2=$(sysctl -n hw.l2cachesize 2>/dev/null | awk '$1 > 0 { printf "%d KB", $1 / 1024 }') + l3=$(sysctl -n hw.l3cachesize 2>/dev/null | awk '$1 > 0 { printf "%d MB", $1 / 1048576 }') + echo "Model Name|machine_name|string|${model:+$(machine_name "$model")}" + echo "Model Identifier|machine_model|string|$model" + echo "Processor Name|cpu_type|string|$(sysctl -n machdep.cpu.brand_string 2>/dev/null)" + echo "Processor Speed|current_processor_speed|string|$speed" + echo "Number of Processors|packages|integer|$(sysctl -n hw.packages 2>/dev/null)" + echo "Total Number of Cores|number_processors|integer|$cores" + echo "L2 Cache (per Core)|l2_cache_core|string|$l2" + echo "L3 Cache|l3_cache|string|$l3" + echo "Memory|physical_memory|string|$memory" + echo "Serial Number (system)|serial_number|string|$(platform IOPlatformSerialNumber)" + echo "Hardware UUID|platform_UUID|string|$(platform IOPlatformUUID)" + echo "Provisioning UDID|provisioning_UDID|string|$(platform provisioning_UDID)" +} + +software() { + name=$(sw_vers -productName 2>/dev/null); version=$(sw_vers -productVersion 2>/dev/null) + build=$(sw_vers -buildVersion 2>/dev/null) + echo "System Version|os_version|string|${version:+${name:-macOS} $version${build:+ ($build)}}" + echo "Kernel Version|kernel_version|string|$(uname -sr 2>/dev/null)" + echo "Boot Volume|boot_volume|string|Macintosh HD" + echo "Boot Mode|boot_mode|string|normal_boot" + echo "Computer Name|local_host_name|string|$(hostname -s 2>/dev/null)" + echo "User Name|user_name|string|${USER:-$(id -un 2>/dev/null)}" +} + +overview_xml() { # $1 = _name; fields on stdin; empty values are omitted + echo ' ' + echo " _name$1" + while IFS='|' read -r label key kind value; do + [ -n "$value" ] || continue + if [ "$kind" = integer ]; then + case "$value" in *[!0-9]*) continue;; esac + echo " $key$value" + else + echo " $key$(printf '%s' "$value" | escape)" + fi + done + echo ' ' +} + +overview_text() { # $1 = section title, $2 = overview title; fields on stdin + printf '%s:\n\n %s:\n\n' "$1" "$2" + while IFS='|' read -r label key kind value; do + [ "$key" != boot_mode ] || value=Normal + [ -z "$value" ] || printf ' %s: %s\n' "$label" "$value" + done + echo +} + interfaces() { # $1 = wired|wireless; physical host interfaces only for path in "$host"/sys/class/net/*; do [ -e "$path/device" ] || continue @@ -65,6 +141,8 @@ if [ "$xml" = 1 ]; then echo ' _items' echo ' ' case "$type" in + SPHardwareDataType) hardware | overview_xml hardware_overview;; + SPSoftwareDataType) software | overview_xml os_overview;; SPDisplaysDataType) displays | while IFS='|' read -r vendor device vram name; do name=$(printf '%s' "$name" | escape) @@ -108,6 +186,8 @@ fi for type in $types; do case "$type" in + SPHardwareDataType) hardware | overview_text Hardware 'Hardware Overview';; + SPSoftwareDataType) software | overview_text Software 'System Software Overview';; SPDisplaysDataType) printf 'Graphics/Displays:\n\n' displays | while IFS='|' read -r vendor device vram name; do From 59327fa9f0efd661dcec19dafda14f243b1e8feb Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:24:49 +0200 Subject: [PATCH 092/160] Report the MacBookPro16,1 GPU name for Riot on AMD and Intel hosts With DARLING_METAL_MAC_GPU_NAME=1, MTLDevice.name is the GPU the MacBookPro16,1 that IOKit reports shipped with, from the host's vendor: "AMD Radeon Pro 5500M" (vendor 0x1002) or "Intel UHD Graphics 630" (0x8086). NVIDIA keeps its own name so the game never takes another vendor's driver paths. recommendedMaxWorkingSetSize and every other value stay the host's, which was the risky part of #61's identity. The Riot launcher turns it on by default (0 or empty turns it off); other Darling apps keep the host name. DARLING_METAL_GPU_NAME still wins. Co-Authored-By: Claude Opus 5.5 --- docs/TROUBLESHOOTING.md | 7 +++++ patches/metal.patch | 50 +++++++++++++++++++++++--------- tests/test_metal_device_info.py | 1 + tools/run-riot-client.sh | 4 ++- tools/test-metal-device-info.cpp | 13 +++++++++ 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a498826..a6da2e1 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -387,6 +387,13 @@ Both can be overridden per launch: DARLING_METAL_GPU_NAME='AMD Radeon Pro 5500M' DARLING_METAL_VRAM_MB=4096 ./scripts/run.sh ``` +The Riot launcher sets `DARLING_METAL_MAC_GPU_NAME=1`: the name becomes the GPU that +the MacBookPro16,1 IOKit reports shipped with, from the host's vendor: `AMD Radeon Pro 5500M` +on AMD and `Intel UHD Graphics 630` on Intel. NVIDIA GPUs keep their own name, so the game +never takes AMD or Intel driver paths on an NVIDIA card. The working set is still the host's. +`DARLING_METAL_MAC_GPU_NAME=0 ./scripts/run.sh` reports the host name instead; other Darling +apps report it unless they set the variable. `DARLING_METAL_GPU_NAME` wins over both. + `registryID` is a fixed non-zero value, as on a real Mac. ### Host-side checks diff --git a/patches/metal.patch b/patches/metal.patch index a5292d3..597b074 100644 --- a/patches/metal.patch +++ b/patches/metal.patch @@ -1134,7 +1134,7 @@ index 2a57796..0cbedf2 100644 - @end diff --git a/src/Metal/MTLDevice.mm b/src/Metal/MTLDevice.mm -index f58184a..ed918ed 100644 +index f58184a..2c45608 100644 --- a/src/Metal/MTLDevice.mm +++ b/src/Metal/MTLDevice.mm @@ -2,14 +2,32 @@ @@ -1218,7 +1218,7 @@ index f58184a..ed918ed 100644 } @synthesize device = _device; -@@ -116,6 +168,44 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -116,6 +168,50 @@ void MTLRemoveDeviceObserver(id observer) { [_threadExitCondition unlock]; } @@ -1228,21 +1228,27 @@ index f58184a..ed918ed 100644 + // Overrides are read once per process. + static const char* const nameOverride = getenv("DARLING_METAL_GPU_NAME"); + static const uint64_t vramOverride = MTLDeviceInfo::parseMegabytes(getenv("DARLING_METAL_VRAM_MB")); ++ static const bool macName = [] { ++ const char* value = getenv("DARLING_METAL_MAC_GPU_NAME"); ++ return value && value[0] == '1' && value[1] == '\0'; ++ }(); + ++ auto priv = dynamic_cast(_device.get()); + std::string vulkanName; + try { + vulkanName = _device ? _device->name() : ""; + } catch (...) { + vulkanName = ""; + } -+ _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::resolveName(nameOverride, vulkanName.c_str()).c_str()]; ++ const char* mac = macName && priv ? MTLDeviceInfo::macGpuName(priv->properties().vendorID) : nullptr; ++ _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::resolveName(nameOverride, vulkanName.c_str(), mac).c_str()]; + if (_name == nil) { + _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::kFallbackName]; + } + + uint64_t workingSet = 0; + _sharesSystemMemory = NO; -+ if (auto priv = dynamic_cast(_device.get())) { ++ if (priv) { + const VkPhysicalDeviceType type = priv->properties().deviceType; + _sharesSystemMemory = type == VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU || type == VK_PHYSICAL_DEVICE_TYPE_CPU; + const VkPhysicalDeviceMemoryProperties memory = priv->memoryProperties(); @@ -1263,7 +1269,7 @@ index f58184a..ed918ed 100644 - (instancetype)initWithDevice: (std::shared_ptr)device { self = [super init]; -@@ -123,6 +213,7 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -123,6 +219,7 @@ void MTLRemoveDeviceObserver(id observer) { _device = device; _threadExitCondition = [NSCondition new]; _threadIsRunning = NO; @@ -1271,7 +1277,7 @@ index f58184a..ed918ed 100644 _pollingThread = [[NSThread alloc] initWithTarget: self selector: @selector(pollingLoop) object: nil]; [_pollingThread start]; } -@@ -133,6 +224,7 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -133,6 +230,7 @@ void MTLRemoveDeviceObserver(id observer) { { [_pollingThread release]; [_threadExitCondition release]; @@ -1279,7 +1285,7 @@ index f58184a..ed918ed 100644 [super dealloc]; } -@@ -153,12 +245,60 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -153,12 +251,60 @@ void MTLRemoveDeviceObserver(id observer) { [_threadExitCondition unlock]; } @@ -1341,7 +1347,7 @@ index f58184a..ed918ed 100644 if (!pso) { if (error) { // TODO: better error and/or match what the official Metal method does -@@ -166,6 +306,7 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -166,6 +312,7 @@ void MTLRemoveDeviceObserver(id observer) { } return nil; } @@ -1349,7 +1355,7 @@ index f58184a..ed918ed 100644 return [[MTLComputePipelineStateInternal alloc] initWithState: pso device: self label: descriptor.label]; } -@@ -183,7 +324,13 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -183,7 +330,13 @@ void MTLRemoveDeviceObserver(id observer) { reflection: (MTLAutoreleasedComputePipelineReflection*)reflection error: (NSError**)error { @@ -1364,7 +1370,7 @@ index f58184a..ed918ed 100644 if (!pso) { if (error) { // TODO: better error and/or match what the official Metal method does -@@ -191,13 +338,44 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -191,13 +344,44 @@ void MTLRemoveDeviceObserver(id observer) { } return nil; } @@ -1410,7 +1416,7 @@ index f58184a..ed918ed 100644 if (!pso) { if (error) { // TODO: better error and/or match what the official Metal method does -@@ -205,7 +383,118 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -205,7 +389,118 @@ void MTLRemoveDeviceObserver(id observer) { } return nil; } @@ -1530,7 +1536,7 @@ index f58184a..ed918ed 100644 } - (id)newCommandQueue -@@ -275,8 +564,21 @@ void MTLRemoveDeviceObserver(id observer) { +@@ -275,8 +570,21 @@ void MTLRemoveDeviceObserver(id observer) { - (id)newLibraryWithData: (dispatch_data_t)data error: (NSError**)error { @@ -3482,7 +3488,7 @@ diff --git a/private-include/Metal/MTLDeviceInfo.h b/private-include/Metal/MTLDe new file mode 100644 --- /dev/null +++ b/private-include/Metal/MTLDeviceInfo.h -@@ -0,0 +1,150 @@ +@@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2026 Darling Developers +// SPDX-License-Identifier: MPL-2.0 + @@ -3582,10 +3588,26 @@ new file mode 100644 +} + +// DARLING_METAL_GPU_NAME (used verbatim when non-empty) wins over the Vulkan name. -+static inline std::string resolveName(const char* overrideName, const char* vulkanName) { ++// With DARLING_METAL_MAC_GPU_NAME=1: the GPU from the host's vendor that the MacBookPro16,1 ++// iokitd reports shipped with, by Vulkan vendor ID. nullptr for other vendors (NVIDIA), which ++// keep their own name so apps never take another vendor's driver paths. ++static inline const char* macGpuName(uint32_t vendorID) { ++ switch (vendorID) { ++ case 0x1002: return "AMD Radeon Pro 5500M"; ++ case 0x8086: return "Intel UHD Graphics 630"; ++ default: return nullptr; ++ } ++} ++ ++// DARLING_METAL_GPU_NAME wins, then the Mac GPU name (when enabled and known), then the ++// cleaned Vulkan name. ++static inline std::string resolveName(const char* overrideName, const char* vulkanName, const char* macName = nullptr) { + if (overrideName && overrideName[0] != '\0') { + return overrideName; + } ++ if (macName && macName[0] != '\0') { ++ return macName; ++ } + return metalNameFromVulkanName(vulkanName); +} + diff --git a/tests/test_metal_device_info.py b/tests/test_metal_device_info.py index 4eac0f9..99e9b52 100644 --- a/tests/test_metal_device_info.py +++ b/tests/test_metal_device_info.py @@ -40,6 +40,7 @@ class MetalDeviceInfoTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stdout) self.assertIn('METAL_DEVICE_INFO failures=0 pass=1', result.stdout) self.assertIn('PASS registry ID is non-zero', result.stdout) + self.assertIn('PASS Mac GPU name', result.stdout) if __name__ == '__main__': diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 6889e0b..f4fe4e8 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -29,7 +29,9 @@ unset RCLOL_APP_ARG3 RCLOL_APP_ARG3_SLOT RCLOL_UX_ARG1 RCLOL_UX_ARG1_SLOT unset RCLOL_UX_ARG2 RCLOL_UX_ARG2_SLOT # The paired runtime presents completed GPU textures without CPU readback. # Explicit 0 or an empty value selects the CPU fallback for diagnostics. -for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC RCLOL_GPU_DIRECT_VSYNC DARLING_CA_PRESENT_AT_COMMIT DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER RCLOL_FORWARD_MOUSE_MOVED RCLOL_MOUSE_CONFINEMENT; do +# DARLING_METAL_MAC_GPU_NAME reports the MacBookPro16,1 GPU of the host vendor (AMD or Intel) as +# MTLDevice.name, matching the model IOKit reports; memory figures stay the host's. +for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC RCLOL_GPU_DIRECT_VSYNC DARLING_CA_PRESENT_AT_COMMIT DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER RCLOL_FORWARD_MOUSE_MOVED RCLOL_MOUSE_CONFINEMENT DARLING_METAL_MAC_GPU_NAME; do eval 'value=${'"$option"'-1}' case "$value" in 0|'') unset "$option";; *) export "$option=$value";; esac done diff --git a/tools/test-metal-device-info.cpp b/tools/test-metal-device-info.cpp index dd64710..45cd492 100644 --- a/tools/test-metal-device-info.cpp +++ b/tools/test-metal-device-info.cpp @@ -65,6 +65,19 @@ int main() { std::printf("%s name override\n", ok ? "PASS" : "FAIL"); } + // DARLING_METAL_MAC_GPU_NAME: MacBookPro16,1 GPUs for AMD and Intel hosts; NVIDIA keeps its name. + { + using MTLDeviceInfo::macGpuName; + bool ok = std::string(macGpuName(0x1002)) == "AMD Radeon Pro 5500M" + && std::string(macGpuName(0x8086)) == "Intel UHD Graphics 630" + && macGpuName(0x10de) == nullptr + && MTLDeviceInfo::resolveName(nullptr, "AMD Radeon RX 6600 (RADV NAVI23)", macGpuName(0x1002)) == "AMD Radeon Pro 5500M" + && MTLDeviceInfo::resolveName(nullptr, "NVIDIA GeForce GTX 1060 6GB", macGpuName(0x10de)) == "NVIDIA GeForce GTX 1060 6GB" + && MTLDeviceInfo::resolveName("My GPU", "AMD Radeon RX 6600 (RADV NAVI23)", macGpuName(0x1002)) == "My GPU"; + failures += !ok; + std::printf("%s Mac GPU name\n", ok ? "PASS" : "FAIL"); + } + // DARLING_METAL_VRAM_MB parsing: positive decimal integer only. const unsigned long long MiB = 1024ull * 1024ull; expectBytes("vram \"8192\"", MTLDeviceInfo::parseMegabytes("8192"), 8192 * MiB); From 7eabb937ec0e28db793e40d8f8447bdb60637d07 Mon Sep 17 00:00:00 2001 From: Polar <343746874+PolarSeeker77@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:08:26 +0000 Subject: [PATCH 093/160] AppKit: retain the drag destination and its window during a local drag The drag loop runs run loop sources and main queue blocks between moves, and Chromium can tear down a web view or close a window there. Hold both until the drag ends, after -draggedImage:endedAt:operation:. --- patches/cocotron.patch | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 9e67845..9c1f0ae 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2176,7 +2176,7 @@ index d545120a..79760f47 100644 case NSMouseMoved: { NSView *hit = [_backgroundView hitTest: [event locationInWindow]]; -@@ -2478,6 +2857,190 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2478,6 +2857,197 @@ static BOOL _allowsAutomaticWindowTabbing; _draggedTypes = nil; } @@ -2267,13 +2267,17 @@ index d545120a..79760f47 100644 + } + } + info->_screenPoint = screenPoint; -+ info->_destinationWindow = window; ++ if (info->_destinationWindow != window) { ++ [info->_destinationWindow release]; ++ info->_destinationWindow = [window retain]; ++ } + NSView *view = [window _localDragDestinationAt: screenPoint types: types]; + + if (view != destination) { + if ([destination respondsToSelector: @selector(draggingExited:)]) + [destination draggingExited: info]; -+ destination = view; ++ [destination release]; ++ destination = [view retain]; + operation = NSDragOperationNone; + if ([destination respondsToSelector: @selector(draggingEntered:)]) + operation = [destination draggingEntered: info]; @@ -2361,13 +2365,16 @@ index d545120a..79760f47 100644 + + if ([source respondsToSelector: @selector(draggedImage:endedAt:operation:)]) + [source draggedImage: image endedAt: screenPoint operation: operation]; ++ [destination release]; ++ [info->_destinationWindow release]; ++ info->_destinationWindow = nil; + [info release]; +} + - (void) dragImage: (NSImage *) image at: (NSPoint) location offset: (NSSize) offset -@@ -2486,6 +3049,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2486,6 +3056,10 @@ static BOOL _allowsAutomaticWindowTabbing; source: (id) source slideBack: (BOOL) slideBack { @@ -2378,7 +2385,7 @@ index d545120a..79760f47 100644 [[NSDraggingManager draggingManager] dragImage: image at: location offset: offset -@@ -2536,6 +3103,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2536,6 +3110,7 @@ static BOOL _allowsAutomaticWindowTabbing; } else { [[self platformWindow] hideWindow]; } @@ -2386,7 +2393,7 @@ index d545120a..79760f47 100644 } - (void) orderFront: (id) sender { -@@ -3064,6 +3632,13 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3064,6 +3639,13 @@ static BOOL _allowsAutomaticWindowTabbing; if (didSize) { // Don't redraw everything unless we really have to. [_backgroundView setFrameSize: _frame.size]; @@ -2400,7 +2407,7 @@ index d545120a..79760f47 100644 [_backgroundView setNeedsDisplay: YES]; [self resetCursorRects]; [self saveFrameUsingName: _autosaveFrameName]; -@@ -3260,8 +3835,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3260,8 +3842,7 @@ static BOOL _allowsAutomaticWindowTabbing; mouseWasInside && !mouseIsInside) { [exited addObject: area]; } @@ -2410,7 +2417,7 @@ index d545120a..79760f47 100644 [moved addObject: area]; } if (options & NSTrackingCursorUpdate && !mouseWasInside && -@@ -3459,7 +4033,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3459,7 +4040,7 @@ static BOOL _allowsAutomaticWindowTabbing; } - (CGSubWindow *) _createSubWindowWithFrame: (CGRect) frame { From 48ae75855f078bb274f426c858be3a92d44aa253 Mon Sep 17 00:00:00 2001 From: David Gheghea <71325082+DavidutzDev@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:32:14 +0200 Subject: [PATCH 094/160] Add darling-lol --set-prefix to keep the prefix on another drive The prefix holds the installed clients and game (about 40 GB). The Nix launcher always exported DPREFIX=${DPREFIX:-~/.darling}, so the prefix saved in build/config.json was never used and the only way to move it was an environment variable in every launch context. - The launcher uses DPREFIX if set, then the saved prefix, then ~/.darling. - darling-lol --set-prefix PATH runs scripts/nix-prefix.py in the namespace, whose root owns the prefix files, so cp -a keeps their owners and xattrs. It renames the prefix when possible; across filesystems it copies to PATH.partial and removes the old prefix and its overlay work directory only after the copy completes. A PATH that already holds a Darling prefix is used as it is. It refuses non-empty directories that are not prefixes, nested paths and paths with commas or colons (overlayfs separators), then saves the path with configure.py --prefix. Co-Authored-By: Claude Opus 5.5 --- darling-lol-launcher.sh | 12 +++++ docs/NIX.md | 11 +++- scripts/nix-prefix.py | 80 +++++++++++++++++++++++++++++ tests/test_nix_launcher.py | 19 +++++++ tests/test_nix_prefix.py | 100 +++++++++++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 scripts/nix-prefix.py create mode 100644 tests/test_nix_prefix.py diff --git a/darling-lol-launcher.sh b/darling-lol-launcher.sh index ddc8df7..f8a124c 100644 --- a/darling-lol-launcher.sh +++ b/darling-lol-launcher.sh @@ -9,7 +9,17 @@ UNSHARE_BIN="@UNSHARE_BIN@" export DARLING_PACKAGE_ID="$SHARE_DIR" export DISPLAY="${DISPLAY:-:0}" export EGL_PLATFORM="${EGL_PLATFORM:-x11}" +# The guest prefix: DPREFIX if set, else the one saved by --set-prefix, else ~/.darling. +if [ -z "${DPREFIX:-}" ] && [ -f "$WORKSPACE/build/config.json" ]; then + DPREFIX=$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1])).get("prefix", ""))' \ + "$WORKSPACE/build/config.json" 2>/dev/null) || DPREFIX= +fi export DPREFIX="${DPREFIX:-$HOME/.darling}" +if [ "${1:-}" = --set-prefix ]; then + [ $# -eq 2 ] || { echo 'Usage: darling-lol --set-prefix PATH' >&2; exit 2; } + # Resolve here: the namespace starts in the workspace, not in this directory. + set -- --set-prefix "$(realpath -m -- "$2")" +fi export DARLING_HOST_UID="$(id -u)" export DARLING_HOST_GID="$(id -g)" export DARLING_WORKSPACE="$WORKSPACE" @@ -73,6 +83,8 @@ session() { case "${1:-}" in --shell) shift; "${fhs_command[@]}" bash "$@";; --update) shift; "${fhs_command[@]}" ./scripts/update.sh "$@";; + # In the namespace, whose root owns the prefix files, so a copy keeps owners. + --set-prefix) "${fhs_command[@]}" python3 ./scripts/nix-prefix.py "$2";; --installer) shift; session --installer "$@";; *) if [ $# -gt 0 ]; then diff --git a/docs/NIX.md b/docs/NIX.md index a83e126..b0cd1e3 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -210,8 +210,15 @@ Darling namespace; `darling-lol --stop` can still stop it. ## Files and operational limits - Workspace: `${XDG_DATA_HOME:-$HOME/.local/share}/darling-lol`. -- Default guest prefix: `$HOME/.darling`; set `DPREFIX` to use another prefix. - Explicit `DPREFIX` and `RIOT_GUEST_HOME` overrides take precedence over saved +- Default guest prefix: `$HOME/.darling`. The prefix holds the installed + clients and game, about 40 GB. To keep it on another drive, close the clients + and run `darling-lol --set-prefix /mnt/games/league`. This moves an existing + prefix there and saves the path for later launches. Across filesystems it + copies first and removes the old prefix only after the copy completes. If the + path already holds a Darling prefix, it switches to it and moves nothing. The + path must not contain commas or colons (overlayfs uses them as separators). + The next launch sets up the runtime in the new prefix. +- Explicit `DPREFIX` and `RIOT_GUEST_HOME` overrides take precedence over saved configuration, so clear stale overrides before switching installations. - Package upgrades refresh managed tools and remove previously tracked obsolete files. Builds, the installed runtime, guest prefixes, and unmanaged files are diff --git a/scripts/nix-prefix.py b/scripts/nix-prefix.py new file mode 100644 index 0000000..20f2eca --- /dev/null +++ b/scripts/nix-prefix.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""darling-lol --set-prefix PATH: choose where the guest prefix, and with it the +installed games, is stored. + +An existing prefix is moved to PATH. Across filesystems it is copied first and +the old copy is removed only once the new one is complete. If PATH already holds +a Darling prefix, the launcher switches to it and leaves both prefixes alone. +""" +import errno +import os +from pathlib import Path +import shutil +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] + + +def has_files(path): + return path.is_dir() and any(path.iterdir()) + + +def is_prefix(path): + # Darling creates this mount point in every prefix it starts. + return (path / 'Volumes/SystemRoot').is_dir() + + +def move(old, new): + new.parent.mkdir(parents=True, exist_ok=True) + if new.is_dir(): + new.rmdir() + try: + os.rename(old, new) + return + except OSError as error: + if error.errno != errno.EXDEV: + raise + partial = new.with_name(new.name + '.partial') + if partial.exists(): + shutil.rmtree(partial) + print(f'Copying {old} to {new}; this can take several minutes.', flush=True) + try: + subprocess.run(['cp', '-a', '--', str(old), str(partial)], check=True) + except BaseException: + shutil.rmtree(partial, ignore_errors=True) + raise + os.rename(partial, new) + print(f'Removing {old}.', flush=True) + shutil.rmtree(old) + + +def main(): + old = Path(os.environ['DPREFIX']).resolve() + new = Path(sys.argv[1]).expanduser() + if not new.is_absolute(): + raise SystemExit('The prefix must be an absolute path.') + new = new.resolve() + # Darling passes the prefix to overlayfs, which splits its options on these. + if any(character in str(new) for character in ',:\n'): + raise SystemExit('The prefix path must not contain commas, colons or newlines.') + if new != old: + if new.is_relative_to(old) or old.is_relative_to(new): + raise SystemExit(f'{new} and {old} must not contain each other.') + if has_files(new): + if not is_prefix(new): + raise SystemExit(f'{new} is not empty and is not a Darling prefix; ' + 'choose a new or empty directory.') + print(f'Using the existing prefix in {new}; {old} is left as it is.') + elif has_files(old): + move(old, new) + # Darling's overlay work directory; it creates a new one next to the prefix. + shutil.rmtree(old.with_name(old.name + '.workdir'), ignore_errors=True) + subprocess.run([sys.executable, str(ROOT / 'scripts/configure.py'), '--prefix', str(new)], + cwd=ROOT, stdout=subprocess.DEVNULL, check=True) + print(f'The prefix is now {new}. The next darling-lol launch sets up the runtime there if needed.') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_nix_launcher.py b/tests/test_nix_launcher.py index dc94160..829079e 100644 --- a/tests/test_nix_launcher.py +++ b/tests/test_nix_launcher.py @@ -119,6 +119,25 @@ class LauncherTests(unittest.TestCase): self.assertEqual(session.wait(timeout=5), 0) self.assertFalse(self.log.exists()) + def test_saved_prefix_is_used_unless_dprefix_is_set(self): + (self.bin / 'unshare').write_text('#!/bin/sh\nprintf "%s\\n" "$DPREFIX" "$@" > "$TEST_LOG"\n') + (self.workspace / 'build').mkdir(parents=True) + (self.workspace / 'build/config.json').write_text('{"prefix": "/mnt/games/League Of Legends"}') + self.env.pop('DPREFIX', None) + self.assertEqual(self.launch('true').returncode, 0) + self.assertEqual(self.log.read_text().splitlines()[0], '/mnt/games/League Of Legends') + self.env['DPREFIX'] = '/explicit' + self.assertEqual(self.launch('true').returncode, 0) + self.assertEqual(self.log.read_text().splitlines()[0], '/explicit') + + def test_set_prefix_resolves_the_path_from_the_callers_directory(self): + result = subprocess.run(['bash', str(self.launcher), '--set-prefix', 'Games/League'], + env=self.env, cwd=self.folder, text=True, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.log.read_text().splitlines()[-3:], + ['python3', './scripts/nix-prefix.py', str(self.folder.resolve() / 'Games/League')]) + self.assertEqual(self.launch('--set-prefix').returncode, 2) + def test_setup_never_makes_a_user_writable_runtime_setuid(self): result = self.launch('--installer') self.assertEqual(result.returncode, 0, result.stderr) diff --git a/tests/test_nix_prefix.py b/tests/test_nix_prefix.py new file mode 100644 index 0000000..daf2493 --- /dev/null +++ b/tests/test_nix_prefix.py @@ -0,0 +1,100 @@ +"""darling-lol --set-prefix on temporary directories; no Darling or namespace.""" +import errno +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] + + +class PrefixTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.folder = Path(self.tmp.name) + self.workspace = self.folder / 'workspace' + (self.workspace / 'scripts').mkdir(parents=True) + (self.workspace / 'tools').mkdir() + for name in ('nix-prefix.py', 'configure.py', 'common.py'): + shutil.copy(ROOT / 'scripts' / name, self.workspace / 'scripts' / name) + self.old = self.folder / 'home/.darling' + (self.old / 'Volumes/SystemRoot').mkdir(parents=True) + (self.old / 'Applications/League of Legends.app').mkdir(parents=True) + (self.old / 'Applications/League of Legends.app/game').write_text('game data') + (self.folder / 'home/.darling.workdir/work').mkdir(parents=True) + + def set_prefix(self, new): + return subprocess.run([sys.executable, str(self.workspace / 'scripts/nix-prefix.py'), str(new)], + env=dict(os.environ, DPREFIX=str(self.old), HOME=str(self.folder / 'home'), + PYTHONDONTWRITEBYTECODE='1'), + text=True, capture_output=True, timeout=30) + + def saved_prefix(self): + return json.loads((self.workspace / 'build/config.json').read_text())['prefix'] + + def test_moves_the_prefix_and_saves_it(self): + new = self.folder / 'bulk/Games/League Of Legends' + result = self.set_prefix(new) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((new / 'Applications/League of Legends.app/game').read_text(), 'game data') + self.assertFalse(self.old.exists()) + self.assertFalse((self.folder / 'home/.darling.workdir').exists()) + self.assertEqual(self.saved_prefix(), str(new)) + + def test_copies_across_filesystems_and_removes_the_old_copy_last(self): + spec = importlib.util.spec_from_file_location('nix_prefix', self.workspace / 'scripts/nix-prefix.py') + prefix = importlib.util.module_from_spec(spec) + spec.loader.exec_module(prefix) + rename = os.rename + calls = [] + + def cross_device(source, target): + calls.append(Path(target).name) + if len(calls) == 1: + raise OSError(errno.EXDEV, 'cross-device link') + self.assertTrue(self.old.exists(), 'old prefix removed before the copy finished') + rename(source, target) + + new = self.folder / 'bulk/prefix' + with patch.object(prefix.os, 'rename', cross_device): + prefix.move(self.old, new) + # A failed rename of the prefix, then the rename of the finished copy. + self.assertEqual(calls, ['prefix', 'prefix']) + self.assertEqual((new / 'Applications/League of Legends.app/game').read_text(), 'game data') + self.assertFalse(self.old.exists()) + self.assertFalse((self.folder / 'bulk/prefix.partial').exists()) + + def test_switches_to_an_existing_prefix_without_moving(self): + other = self.folder / 'other' + (other / 'Volumes/SystemRoot').mkdir(parents=True) + result = self.set_prefix(other) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((self.old / 'Applications/League of Legends.app/game').exists()) + self.assertEqual(self.saved_prefix(), str(other)) + + def test_refuses_a_directory_with_other_files(self): + games = self.folder / 'Games' + games.mkdir() + (games / 'other-game').mkdir() + result = self.set_prefix(games) + self.assertNotEqual(result.returncode, 0) + self.assertIn('not a Darling prefix', result.stderr) + self.assertTrue(self.old.exists()) + self.assertFalse((self.workspace / 'build/config.json').exists()) + + def test_refuses_overlay_separators_and_nested_paths(self): + for new in (self.folder / 'a,b', self.folder / 'a:b', self.old / 'inner'): + result = self.set_prefix(new) + self.assertNotEqual(result.returncode, 0, new) + self.assertTrue(self.old.exists()) + + +if __name__ == '__main__': + unittest.main() From 8cad24c7062813fa0fe43605e11b54b6246eb2ea Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:30:15 +0200 Subject: [PATCH 095/160] Add standalone X11 drag-and-drop check for PR #63 Co-Authored-By: Claude Sonnet 5.5 --- docs/TESTING.md | 6 + tools/build-riot-x11-drag-check.py | 21 +++ tools/riot-x11-drag-check.m | 276 +++++++++++++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100755 tools/build-riot-x11-drag-check.py create mode 100644 tools/riot-x11-drag-check.m diff --git a/docs/TESTING.md b/docs/TESTING.md index 2fdedef..b1d7df4 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -129,6 +129,12 @@ With clients closed, run `build/riot-client-resize-check --handoff` through Darl The probe cycles through 1280×720, 1600×900, 1024×576 and 1280×720. Each stage changes the frame color and samples its own X11 window near the bottom-right corner, beyond the original frame's extent when growing. It waits up to 2.5 seconds for presentation and prints `RESIZE_PIXEL ... pass=1` for every stage. This verifies presented pixels rather than only view properties. Success does not reproduce League's own resize callbacks; the reported League UI failure remains open, with the opt-in [geometry trace](TROUBLESHOOTING.md#language-dropdown-closes-riot-or-client-resize-leaves-a-clipped-ui) needed to locate the remaining mismatch. +### X11 drag and drop + +`python3 build/runtime/tools/build-riot-x11-drag-check.py` builds an independent AppKit check for the in-process drag loop that the X11 backend uses (`-[NSWindow dragImage:at:offset:event:pasteboard:source:slideBack:]`). It needs the cocotron AppKit built with the drag-and-drop patch. With clients closed, run `build/riot-x11-drag-check` through Darling with the same framework/library overrides as the other probes. It opens its own source and destination windows, posts synthetic events to its own application queue, and does not inject X11 input or touch other clients. + +It prints `PASS` or `FAIL` for seven cases: a drop onto a destination without `prepareForDragOperation:` (the default must proceed), a drag ending outside any destination, Escape cancel, two drags back to back with a source lacking `pasteboardChangedOwner:`, main-queue blocks and run loop timers firing during the drag, a mouse-down reaching a view afterwards, and a destination view removed from its window mid-drag. Each drag must call `draggedImage:endedAt:operation:` exactly once. The process exits nonzero on any failure, and a case that makes no progress for 20 seconds is reported as a hang, which is the original failure mode. Success covers the AppKit drag loop only; it does not exercise Chromium's own drag source. + ## Legacy operating-system version query ```sh diff --git a/tools/build-riot-x11-drag-check.py b/tools/build-riot-x11-drag-check.py new file mode 100755 index 0000000..9eea4b8 --- /dev/null +++ b/tools/build-riot-x11-drag-check.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Build an owned AppKit check for the in-process X11 drag-and-drop loop.""" +from pathlib import Path +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +folder = root / 'build/darling-src/build/src/external/cocotron/AppKit' +flags = {} +for line in (folder / 'CMakeFiles/X11_backend.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +obj = root / 'build/riot-x11-drag-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-fblocks', '-Wno-macro-redefined', '-c', str(root / 'tools/riot-x11-drag-check.m'), + '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', '@DARLING_SDK@', str(obj), '-lSystem', '-lobjc', + '-framework', 'AppKit', '-framework', 'CoreFoundation', '-framework', 'Foundation', + '-o', str(root / 'build/riot-x11-drag-check')], check=True) diff --git a/tools/riot-x11-drag-check.m b/tools/riot-x11-drag-check.m new file mode 100644 index 0000000..2e7a841 --- /dev/null +++ b/tools/riot-x11-drag-check.m @@ -0,0 +1,276 @@ +/* Standalone check for the in-process X11 drag loop (-[NSWindow _localDragImage:...]). + * Opens its own windows, drives -dragImage:at:offset:event:pasteboard:source:slideBack: + * with synthetic NSEvents posted to this app's own queue, and asserts the outcome. + * It injects no X11 input and touches no other client. A hang is reported as FAIL. */ +#import +#include +#include +#include +#include +#include +#include + +#define CASE_TIMEOUT_SECONDS 20.0 + +@interface DragLog : NSObject { + @public + int entered, updated, exited, prepared, performed, concluded, ended, mouseDowns; + NSDragOperation endOperation; + BOOL removeWhenUpdated; +} +@end +@implementation DragLog @end + +@interface NSView (DragCheckDefaults) +- (BOOL)prepareForDragOperation:(id)sender; +@end + +/* Destination without -prepareForDragOperation:, so the NSView default applies. */ +@interface DropView : NSView { @public DragLog *log; } +@end +@implementation DropView +- (NSDragOperation)draggingEntered:(id)s { log->entered++; return NSDragOperationCopy; } +- (NSDragOperation)draggingUpdated:(id)s { log->updated++; return NSDragOperationCopy; } +- (void)draggingExited:(id)s { log->exited++; } +- (BOOL)performDragOperation:(id)s { log->performed++; return YES; } +- (void)concludeDragOperation:(id)s { log->concluded++; } +- (void)mouseDown:(NSEvent *)e { log->mouseDowns++; } +@end + +/* Source that, like Chromium's, does not implement -pasteboardChangedOwner:. */ +@interface DragSource : NSObject { @public DragLog *log; } +@end +@implementation DragSource +- (NSDragOperation)draggingSourceOperationMask { return NSDragOperationEvery; } +- (NSDragOperation)draggingSourceOperationMaskForLocal:(BOOL)local { return NSDragOperationEvery; } +- (void)draggedImage:(NSImage *)image endedAt:(NSPoint)p operation:(NSDragOperation)op { + log->ended++; log->endOperation = op; +} +@end + +static const char *currentCase = "startup"; +static volatile time_t caseStart; +static int failures = 0; + +static void *watchdog(void *unused) { + for (;;) { + usleep(100000); + if (time(NULL) - caseStart > CASE_TIMEOUT_SECONDS) { + printf("FAIL %s: no progress for %.0fs (drag hang)\n", currentCase, CASE_TIMEOUT_SECONDS); + fflush(stdout); + _exit(1); + } + } + return NULL; +} +static void beginCase(const char *name) { currentCase = name; caseStart = time(NULL); } +static void expect(BOOL ok, const char *what) { + if (!ok) { failures++; printf(" FAIL %s: %s\n", currentCase, what); } +} +static void endCase(int failuresBefore) { + printf("%s %s\n", failures == failuresBefore ? "PASS" : "FAIL", currentCase); + fflush(stdout); +} + +static void pump(double seconds) { + NSDate *end = [NSDate dateWithTimeIntervalSinceNow:seconds]; + while ([end timeIntervalSinceNow] > 0) { + NSEvent *e = [NSApp nextEventMatchingMask:NSAnyEventMask untilDate:[NSDate distantPast] + inMode:NSDefaultRunLoopMode dequeue:YES]; + if (e) [NSApp sendEvent:e]; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.005]]; + } +} + +static NSWindow *makeWindow(NSString *title, NSRect frame) { + NSWindow *w = [[NSWindow alloc] initWithContentRect:frame styleMask:NSBorderlessWindowMask + backing:NSBackingStoreBuffered defer:NO]; + [w setTitle:title]; + [w setReleasedWhenClosed:NO]; + [w makeKeyAndOrderFront:nil]; + return w; +} + +/* Destination window holding a registered DropView that only its superview owns. */ +static NSWindow *makeDestination(DragLog *log, DropView **viewOut) { + NSWindow *w = makeWindow(@"Darling drag check destination", NSMakeRect(500, 200, 240, 240)); + DropView *v = [[DropView alloc] initWithFrame:NSMakeRect(20, 20, 200, 200)]; + v->log = log; + [v registerForDraggedTypes:[NSArray arrayWithObject:NSStringPboardType]]; + [[w contentView] addSubview:v]; + if (viewOut) *viewOut = v; + [v release]; + return w; +} + +static NSEvent *mouse(NSEventType type, NSWindow *w, NSPoint p) { + return [NSEvent mouseEventWithType:type location:p modifierFlags:0 + timestamp:[[NSProcessInfo processInfo] systemUptime] + windowNumber:[w windowNumber] context:nil eventNumber:0 + clickCount:1 pressure:1.0]; +} +static NSEvent *escapeKey(NSWindow *w) { + return [NSEvent keyEventWithType:NSKeyDown location:NSMakePoint(0, 0) modifierFlags:0 + timestamp:[[NSProcessInfo processInfo] systemUptime] + windowNumber:[w windowNumber] context:nil characters:@"\033" + charactersIgnoringModifiers:@"\033" isARepeat:NO keyCode:53]; +} +static void post(NSEvent *e) { [NSApp postEvent:e atStart:NO]; } + +static NSPasteboard *declarePasteboard(id owner) { + NSPasteboard *pb = [NSPasteboard pasteboardWithName:NSDragPboard]; + [pb declareTypes:[NSArray arrayWithObject:NSStringPboardType] owner:owner]; + [pb setString:@"darling drag check" forType:NSStringPboardType]; + return pb; +} + +static void runDrag(NSWindow *sourceWindow, DragSource *source) { + NSPasteboard *pb = declarePasteboard(source); + NSImage *image = [[[NSImage alloc] initWithSize:NSMakeSize(32, 32)] autorelease]; + NSEvent *down = mouse(NSLeftMouseDown, sourceWindow, NSMakePoint(50, 50)); + [sourceWindow dragImage:image at:NSMakePoint(50, 50) offset:NSZeroSize event:down + pasteboard:pb source:source slideBack:YES]; +} + +static NSPoint inside = {120, 120}; /* inside DropView, in destination window base coordinates */ +static NSPoint outside = {-4000, -4000}; + +int main(int argc, const char **argv) { + @autoreleasepool { + [NSApplication sharedApplication]; + pthread_t thread; + beginCase("startup"); + pthread_create(&thread, NULL, watchdog, NULL); + NSWindow *sourceWindow = makeWindow(@"Darling drag check source", NSMakeRect(100, 200, 240, 240)); + pump(0.3); + int before; + + /* 1. Drop onto a destination that lacks prepareForDragOperation:. */ + { + beginCase("1 drop onto destination"); before = failures; + DragLog *log = [[DragLog alloc] init]; DragSource *src = [[DragSource alloc] init]; src->log = log; + DropView *view; NSWindow *dw = makeDestination(log, &view); pump(0.2); + expect([view prepareForDragOperation:nil] == YES, "NSView default prepareForDragOperation: is not YES"); + NSWindow *dest = dw; + post(mouse(NSLeftMouseDragged, dest, inside)); + post(mouse(NSLeftMouseDragged, dest, NSMakePoint(inside.x + 2, inside.y))); + post(mouse(NSLeftMouseUp, dest, inside)); + runDrag(sourceWindow, src); + expect(log->entered == 1, "draggingEntered: not called exactly once"); + expect(log->performed == 1, "performDragOperation: not called exactly once"); + expect(log->concluded == 1, "concludeDragOperation: not called exactly once"); + expect(log->ended == 1, "draggedImage:endedAt:operation: not called exactly once"); + expect(log->endOperation != NSDragOperationNone, "end operation is None after a successful drop"); + [dw close]; [dw release]; [src release]; [log release]; + endCase(before); + } + + /* 2. Drag leaves the destination and ends outside any window. */ + { + beginCase("2 end outside destination"); before = failures; + DragLog *log = [[DragLog alloc] init]; DragSource *src = [[DragSource alloc] init]; src->log = log; + NSWindow *dw = makeDestination(log, NULL); pump(0.2); + post(mouse(NSLeftMouseDragged, dw, inside)); + post(mouse(NSLeftMouseDragged, sourceWindow, outside)); + post(mouse(NSLeftMouseUp, sourceWindow, outside)); + runDrag(sourceWindow, src); + expect(log->entered == 1, "draggingEntered: not called exactly once"); + expect(log->exited >= 1, "draggingExited: not called"); + expect(log->performed == 0, "performDragOperation: called for a drop outside"); + expect(log->ended == 1, "draggedImage:endedAt:operation: not called exactly once"); + expect(log->endOperation == NSDragOperationNone, "end operation is not None"); + [dw close]; [dw release]; [src release]; [log release]; + endCase(before); + } + + /* 3. Escape cancels while over the destination. */ + { + beginCase("3 escape cancel"); before = failures; + DragLog *log = [[DragLog alloc] init]; DragSource *src = [[DragSource alloc] init]; src->log = log; + NSWindow *dw = makeDestination(log, NULL); pump(0.2); + post(mouse(NSLeftMouseDragged, dw, inside)); + post(escapeKey(sourceWindow)); + runDrag(sourceWindow, src); + expect(log->performed == 0, "performDragOperation: called after Escape"); + expect(log->ended == 1, "draggedImage:endedAt:operation: not called exactly once"); + expect(log->endOperation == NSDragOperationNone, "end operation is not None"); + [dw close]; [dw release]; [src release]; [log release]; + endCase(before); + } + + /* 4. Two drags back to back with sources lacking pasteboardChangedOwner:. */ + { + beginCase("4 back-to-back drags"); before = failures; + DragLog *log1 = [[DragLog alloc] init], *log2 = [[DragLog alloc] init]; + DragSource *s1 = [[DragSource alloc] init], *s2 = [[DragSource alloc] init]; + s1->log = log1; s2->log = log2; + expect(![s1 respondsToSelector:@selector(pasteboardChangedOwner:)], "fixture source implements pasteboardChangedOwner:"); + NSWindow *dw = makeDestination(log1, NULL); pump(0.2); + post(mouse(NSLeftMouseDragged, dw, inside)); post(mouse(NSLeftMouseUp, dw, inside)); + runDrag(sourceWindow, s1); + post(mouse(NSLeftMouseDragged, dw, inside)); post(mouse(NSLeftMouseUp, dw, inside)); + runDrag(sourceWindow, s2); /* declaring new owner notifies s1, which must be tolerated */ + expect(log1->ended == 1, "first drag did not end exactly once"); + expect(log2->ended == 1, "second drag did not end exactly once"); + expect(log1->performed == 2, "destination did not receive both drops"); + [dw close]; [dw release]; [s1 release]; [s2 release]; [log1 release]; [log2 release]; + endCase(before); + } + + /* 5. Main queue and run loop timers are serviced during the drag. */ + { + beginCase("5 main queue and timer serviced"); before = failures; + DragLog *log = [[DragLog alloc] init]; DragSource *src = [[DragSource alloc] init]; src->log = log; + NSWindow *dw = makeDestination(log, NULL); pump(0.2); + __block BOOL queueFired = NO, timerFired = NO, inDrag = YES; + __block BOOL queueDuring = NO, timerDuring = NO; + CFRunLoopTimerRef timer = CFRunLoopTimerCreateWithHandler(NULL, CFAbsoluteTimeGetCurrent() + 0.03, 0, 0, 0, + ^(CFRunLoopTimerRef t) { timerFired = YES; timerDuring = inDrag; }); + CFRunLoopAddTimer(CFRunLoopGetMain(), timer, kCFRunLoopDefaultMode); + dispatch_async(dispatch_get_main_queue(), ^{ queueFired = YES; queueDuring = inDrag; }); + post(mouse(NSLeftMouseDragged, dw, inside)); post(mouse(NSLeftMouseUp, dw, inside)); + runDrag(sourceWindow, src); + inDrag = NO; + expect(queueDuring, queueFired ? "dispatch_async block ran only after the drag returned" : "dispatch_async block never ran"); + expect(timerDuring, timerFired ? "run loop timer fired only after the drag returned" : "run loop timer never fired"); + CFRunLoopTimerInvalidate(timer); CFRelease(timer); + expect(log->ended == 1, "drag did not end exactly once"); + [dw close]; [dw release]; [src release]; [log release]; + endCase(before); + } + + /* 6. Mouse input reaches views normally after the drags. */ + { + beginCase("6 input after drags"); before = failures; + DragLog *log = [[DragLog alloc] init]; + DropView *view; NSWindow *dw = makeDestination(log, &view); pump(0.2); + post(mouse(NSLeftMouseDown, dw, inside)); post(mouse(NSLeftMouseUp, dw, inside)); + pump(0.3); + expect(log->mouseDowns == 1, "mouse-down was not delivered to the view after drags"); + [dw close]; [dw release]; [log release]; + endCase(before); + } + + /* 7. Destination view removed (and window closed) from a main-queue block mid-drag. */ + { + beginCase("7 destination removed mid-drag"); before = failures; + DragLog *log = [[DragLog alloc] init]; DragSource *src = [[DragSource alloc] init]; src->log = log; + DropView *view; NSWindow *dw = makeDestination(log, &view); pump(0.2); + post(mouse(NSLeftMouseDragged, dw, inside)); post(mouse(NSLeftMouseUp, dw, inside)); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 20 * NSEC_PER_MSEC), dispatch_get_main_queue(), ^{ + [view removeFromSuperview]; /* the drag loop must have retained it */ + [dw orderOut:nil]; + [dw close]; + }); + runDrag(sourceWindow, src); + expect(log->ended == 1, "drag did not end exactly once after the destination went away"); + pump(0.2); + [dw release]; [src release]; [log release]; + endCase(before); + } + + [sourceWindow close]; [sourceWindow release]; + printf("%s x11 drag check (%d failure%s)\n", failures ? "FAIL" : "PASS", failures, failures == 1 ? "" : "s"); + return failures ? 1 : 0; + } +} From 2be6d0d6bd120201f40fa21bdc005d2dd1a58f61 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:04:30 +0200 Subject: [PATCH 096/160] Make --set-prefix safe against data loss Stage a cross-filesystem copy in a private directory instead of deleting a pre-existing PATH.partial, reject destinations inside the overlay work directory, save the new path before deleting the old prefix, and verify that the target keeps user.* xattrs before and after copying. Add tests. Co-Authored-By: Claude Sonnet 5.5 --- docs/NIX.md | 3 ++ scripts/nix-prefix.py | 89 +++++++++++++++++++++++++++++------- tests/test_nix_prefix.py | 97 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 171 insertions(+), 18 deletions(-) diff --git a/docs/NIX.md b/docs/NIX.md index b0cd1e3..fe6e9e4 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -217,6 +217,9 @@ Darling namespace; `darling-lol --stop` can still stop it. copies first and removes the old prefix only after the copy completes. If the path already holds a Darling prefix, it switches to it and moves nothing. The path must not contain commas or colons (overlayfs uses them as separators). + Across filesystems the target must support `user.*` extended attributes (ext4, + btrfs, xfs; not NTFS or exFAT), which is checked before copying and again on + the finished copy. The new path is saved before the old prefix is deleted. The next launch sets up the runtime in the new prefix. - Explicit `DPREFIX` and `RIOT_GUEST_HOME` overrides take precedence over saved configuration, so clear stale overrides before switching installations. diff --git a/scripts/nix-prefix.py b/scripts/nix-prefix.py index 20f2eca..102b0d9 100644 --- a/scripts/nix-prefix.py +++ b/scripts/nix-prefix.py @@ -12,6 +12,7 @@ from pathlib import Path import shutil import subprocess import sys +import tempfile ROOT = Path(__file__).resolve().parents[1] @@ -25,28 +26,77 @@ def is_prefix(path): return (path / 'Volumes/SystemRoot').is_dir() +XATTR = 'user.darling-lol-prefix-check' + + +def user_xattrs(path): + try: + return {name: os.getxattr(path, name, follow_symlinks=False) + for name in os.listxattr(path, follow_symlinks=False) if name.startswith('user.')} + except OSError as error: + if error.errno in (errno.ENOTSUP, errno.ENOENT): + return {} + raise + + +def check_xattrs(folder): + """Darling keeps state in user.* xattrs; cp -a ignores a target that drops them.""" + probe = folder / '.xattr-check' + probe.touch() + try: + os.setxattr(probe, XATTR, b'1') + if os.getxattr(probe, XATTR) != b'1': + raise OSError(errno.ENOTSUP, 'xattr changed') + except OSError as error: + raise SystemExit(f'{folder.parent} does not support user extended attributes ({error.strerror}), ' + 'which the prefix needs. Choose a drive with a Linux filesystem ' + '(ext4, btrfs, xfs); nothing was moved.') + finally: + probe.unlink() + + +def verify_copy(old, new): + """The copy must match the original: names, types and user.* xattrs.""" + for folder, folders, files in os.walk(old): + target = new / Path(folder).relative_to(old) + for name in [*folders, *files]: + source = Path(folder) / name + copy = target / name + if not copy.is_symlink() and not copy.exists() or user_xattrs(source) != user_xattrs(copy): + raise SystemExit(f'The copy of {source} differs from the original; the old prefix was kept.') + if user_xattrs(folder) != user_xattrs(target): + raise SystemExit(f'The copy of {folder} differs from the original; the old prefix was kept.') + + def move(old, new): + """Put the prefix at new. Returns old if it still has to be removed, else None.""" new.parent.mkdir(parents=True, exist_ok=True) if new.is_dir(): new.rmdir() try: os.rename(old, new) - return + return None except OSError as error: if error.errno != errno.EXDEV: raise - partial = new.with_name(new.name + '.partial') - if partial.exists(): - shutil.rmtree(partial) - print(f'Copying {old} to {new}; this can take several minutes.', flush=True) + # A directory made for this move: never touch anything that was already there. + staging = Path(tempfile.mkdtemp(prefix=new.name + '.partial-', dir=new.parent)) try: - subprocess.run(['cp', '-a', '--', str(old), str(partial)], check=True) - except BaseException: - shutil.rmtree(partial, ignore_errors=True) - raise - os.rename(partial, new) - print(f'Removing {old}.', flush=True) - shutil.rmtree(old) + check_xattrs(staging) + print(f'Copying {old} to {new}; this can take several minutes.', flush=True) + subprocess.run(['cp', '-a', '--', str(old), str(staging / 'prefix')], check=True) + verify_copy(old, staging / 'prefix') + os.rename(staging / 'prefix', new) + finally: + shutil.rmtree(staging, ignore_errors=True) + return old + + +def remove(path): + try: + shutil.rmtree(path) + except OSError as error: + print(f'Could not remove {path} ({error}); delete it by hand to free the space.', file=sys.stderr) def main(): @@ -58,20 +108,29 @@ def main(): # Darling passes the prefix to overlayfs, which splits its options on these. if any(character in str(new) for character in ',:\n'): raise SystemExit('The prefix path must not contain commas, colons or newlines.') + # Darling's overlay work directory; it creates a new one next to the prefix. + workdir = old.with_name(old.name + '.workdir') + leftovers = [] if new != old: if new.is_relative_to(old) or old.is_relative_to(new): raise SystemExit(f'{new} and {old} must not contain each other.') + if new.is_relative_to(workdir) or workdir.is_relative_to(new): + raise SystemExit(f'{new} must not be inside {workdir}, which is removed after the move.') if has_files(new): if not is_prefix(new): raise SystemExit(f'{new} is not empty and is not a Darling prefix; ' 'choose a new or empty directory.') print(f'Using the existing prefix in {new}; {old} is left as it is.') elif has_files(old): - move(old, new) - # Darling's overlay work directory; it creates a new one next to the prefix. - shutil.rmtree(old.with_name(old.name + '.workdir'), ignore_errors=True) + print(f'Moving the prefix {old} to {new}.', flush=True) + if move(old, new): + leftovers.append(old) + leftovers.append(workdir) + # Save the new path before deleting anything, so a failed delete leaves a working install. subprocess.run([sys.executable, str(ROOT / 'scripts/configure.py'), '--prefix', str(new)], cwd=ROOT, stdout=subprocess.DEVNULL, check=True) + for path in leftovers: + remove(path) print(f'The prefix is now {new}. The next darling-lol launch sets up the runtime there if needed.') return 0 diff --git a/tests/test_nix_prefix.py b/tests/test_nix_prefix.py index daf2493..0b44330 100644 --- a/tests/test_nix_prefix.py +++ b/tests/test_nix_prefix.py @@ -64,12 +64,103 @@ class PrefixTests(unittest.TestCase): new = self.folder / 'bulk/prefix' with patch.object(prefix.os, 'rename', cross_device): - prefix.move(self.old, new) + leftover = prefix.move(self.old, new) # A failed rename of the prefix, then the rename of the finished copy. self.assertEqual(calls, ['prefix', 'prefix']) self.assertEqual((new / 'Applications/League of Legends.app/game').read_text(), 'game data') - self.assertFalse(self.old.exists()) - self.assertFalse((self.folder / 'bulk/prefix.partial').exists()) + # The caller removes the old prefix once the new path is saved. + self.assertEqual(leftover, self.old) + self.assertTrue(self.old.exists()) + self.assertEqual(list((self.folder / 'bulk').iterdir()), [new]) + + def load(self): + spec = importlib.util.spec_from_file_location('nix_prefix_unsafe', self.workspace / 'scripts/nix-prefix.py') + prefix = importlib.util.module_from_spec(spec) + spec.loader.exec_module(prefix) + return prefix + + def cross_device(self, prefix): + rename = os.rename + first = [] + + def rename_across(source, target): + if not first: + first.append(1) + raise OSError(errno.EXDEV, 'cross-device link') + rename(source, target) + return patch.object(prefix.os, 'rename', rename_across) + + def test_copy_leaves_an_existing_partial_directory_alone(self): + prefix = self.load() + new = self.folder / 'bulk/prefix' + unrelated = self.folder / 'bulk/prefix.partial' + unrelated.mkdir(parents=True) + (unrelated / 'keep').write_text('mine') + with self.cross_device(prefix): + prefix.move(self.old, new) + self.assertEqual((unrelated / 'keep').read_text(), 'mine') + self.assertEqual(sorted(path.name for path in (self.folder / 'bulk').iterdir()), + ['prefix', 'prefix.partial']) + + def test_failed_copy_keeps_the_old_prefix_and_removes_only_its_staging(self): + prefix = self.load() + new = self.folder / 'bulk/prefix' + new.parent.mkdir() + error = subprocess.CalledProcessError(1, 'cp') + with self.cross_device(prefix), patch.object(prefix.subprocess, 'run', side_effect=error): + with self.assertRaises(subprocess.CalledProcessError): + prefix.move(self.old, new) + self.assertEqual((self.old / 'Applications/League of Legends.app/game').read_text(), 'game data') + self.assertEqual(list(new.parent.iterdir()), []) + + def test_copy_refused_when_the_target_drops_user_xattrs(self): + prefix = self.load() + new = self.folder / 'bulk/prefix' + with self.cross_device(prefix), patch.object(prefix.os, 'setxattr', + side_effect=OSError(errno.ENOTSUP, 'not supported')): + with self.assertRaises(SystemExit) as raised: + prefix.move(self.old, new) + self.assertIn('extended attributes', str(raised.exception)) + self.assertTrue(self.old.exists()) + self.assertEqual(list(new.parent.iterdir()), []) + + def test_copy_that_lost_xattrs_is_refused(self): + prefix = self.load() + game = self.old / 'Applications/League of Legends.app/game' + try: + os.setxattr(game, 'user.test', b'1') + except OSError: + self.skipTest('the temporary directory has no user xattrs') + new = self.folder / 'bulk/prefix' + real = prefix.user_xattrs + stripped = lambda path: {} if Path(path).name == 'game' and Path(path).is_relative_to(new.parent) else real(path) + with self.cross_device(prefix), patch.object(prefix, 'user_xattrs', stripped): + with self.assertRaises(SystemExit) as raised: + prefix.move(self.old, new) + self.assertIn('old prefix was kept', str(raised.exception)) + self.assertTrue(game.exists()) + self.assertEqual(list(new.parent.iterdir()), []) + + def test_failed_delete_of_the_old_prefix_keeps_the_new_one_saved(self): + prefix = self.load() + new = self.folder / 'bulk/prefix' + with patch.dict(os.environ, {'DPREFIX': str(self.old)}), \ + patch.object(sys, 'argv', ['nix-prefix.py', str(new)]), \ + patch.object(prefix, 'ROOT', self.workspace), \ + patch.object(prefix, 'move', lambda old, target: (shutil.copytree(old, target), old)[1]), \ + patch.object(prefix.shutil, 'rmtree', side_effect=OSError('busy')): + self.assertEqual(prefix.main(), 0) + self.assertEqual(self.saved_prefix(), str(new)) + self.assertTrue((new / 'Applications/League of Legends.app/game').exists()) + self.assertTrue(self.old.exists()) + + def test_refuses_a_destination_inside_the_overlay_work_directory(self): + for new in (self.folder / 'home/.darling.workdir/games', self.folder / 'home/.darling.workdir'): + result = self.set_prefix(new) + self.assertNotEqual(result.returncode, 0, new) + self.assertIn('work', result.stderr) + self.assertTrue((self.old / 'Applications/League of Legends.app/game').exists()) + self.assertFalse((self.workspace / 'build/config.json').exists()) def test_switches_to_an_existing_prefix_without_moving(self): other = self.folder / 'other' From 57c65b8225e7cdac5ef0bb2ac0be5e1619fa1ecd Mon Sep 17 00:00:00 2001 From: Polar <343746874+PolarSeeker77@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:05:39 +0000 Subject: [PATCH 097/160] X11: alpha visual for transparent windows, float translucent splashes Riot's splash screens draw on a black rectangle: LeagueClient's is a borderless NSWindow with setOpaque:NO and a pattern-image background, the Riot Client's an Electron BrowserWindow with transparent: true. X11Window created every window with a 24-bit visual and -setOpaque: only stored the flag, so there was no alpha channel. A non-opaque window whose background can show through (alpha below 1, or a pattern image) now gets the 32-bit ARGB visual. League's game window is non-opaque on an opaque background and stays on the opaque visual, which X11IsLeagueGameWindow also enforces. The X window exists before apps set opacity and background, so the choice is checked again when the first subwindow is created (layer subwindows inherit the visual) and before the first map, and the window recreated while unmapped if it changed. Its background is transparent instead of None, so no black frame shows before the first draw. Its surface comes from an alpha EGL config for that visual (CGLGetWindowWithAlpha) and its compositing context from the surface's config (CGLCreateContextForWindow): NVIDIA refuses the default config on a 32-bit window, and no driver makes a context from it current on the alpha surface. Without an alpha config the window falls back to the opaque visual. A translucent window above the normal level (Electron's alwaysOnTop splash) is typed _NET_WM_WINDOW_TYPE_SPLASH with _NET_WM_STATE_ABOVE before it is mapped: window managers float it and draw no frame around it. Electron's level never arrived, though: CGWindowLevelForKey returned the normal level for every key, and the SDK turns NSFloatingWindowLevel into a call to it. It now returns the levels macOS does; Cocotron only stores levels, so nothing else changes. --- patches/cocotron.patch | 415 +++++++++++++++++++++++++++++++++++------ patches/darling.patch | 103 ++++++++-- 2 files changed, 443 insertions(+), 75 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 83d64b3..d10cf61 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -4506,7 +4506,7 @@ index 0000000..31fa646 + +#endif diff --git a/AppKit/X11.backend/X11Window.h b/AppKit/X11.backend/X11Window.h -index bfe3839a..e8c8828f 100644 +index bfe3839a..48cfac4c 100644 --- a/AppKit/X11.backend/X11Window.h +++ b/AppKit/X11.backend/X11Window.h @@ -26,7 +26,7 @@ @@ -4518,13 +4518,15 @@ index bfe3839a..e8c8828f 100644 @interface X11Window : CGWindow { int _level; // TODO: care about this value -@@ -47,8 +47,30 @@ +@@ -47,8 +47,32 @@ BOOL _mapped; CGPoint _lastMotionPos; BOOL _isModal; + BOOL _overrideRedirect; + BOOL _fullscreenRequested; BOOL _isOpaque; ++ BOOL _hasAlpha; // created with the 32-bit ARGB visual ++ BOOL _wasMapped; // the visual is settled at the first map BOOL _hasShadow; + NSTimer *_remoteFrameTimer; + Window _remoteFrameWindow; @@ -4549,7 +4551,7 @@ index bfe3839a..e8c8828f 100644 @public XIC _xic; -@@ -60,9 +82,9 @@ +@@ -60,9 +84,9 @@ - (Visual *) visual; - (Drawable) drawable; - (NSPoint) transformPoint: (NSPoint) pos; @@ -4561,10 +4563,17 @@ index bfe3839a..e8c8828f 100644 - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m -index f4925b66..0296203b 100644 +index f4925b66..730d4692 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m -@@ -23,6 +23,7 @@ +@@ -18,11 +18,14 @@ + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. */ + ++#import ++#import + #import + #import #import #import #import @@ -4572,7 +4581,7 @@ index f4925b66..0296203b 100644 #import #import #import -@@ -38,6 +39,34 @@ +@@ -38,6 +41,110 @@ #import "X11Window.h" #import #import @@ -4604,10 +4613,86 @@ index f4925b66..0296203b 100644 + uint32_t pixelFormat; + uint64_t dataSize; +} DarlingCARemoteFrame; ++ ++static XVisualInfo *X11OpaqueVisualInfo(Display *display) { ++ static XVisualInfo *visualInfo; ++ if (visualInfo != NULL) ++ return visualInfo; ++ ++ // TODO: get rid of these glX calls ++ GLint att[] = {GLX_RGBA, ++ GLX_DOUBLEBUFFER, ++ GLX_RED_SIZE, ++ 4, ++ GLX_GREEN_SIZE, ++ 4, ++ GLX_BLUE_SIZE, ++ 4, ++ GLX_DEPTH_SIZE, ++ 4, ++ None}; ++ ++ int screen = DefaultScreen(display); ++ ++ // Rendering goes through EGL. NVIDIA's glXChooseVisual can return a GLX-only visual that every ++ // eglCreateWindowSurface rejects (EGL_BAD_CONFIG), so prefer the default visual when usable. ++ XVisualInfo visualTemplate; ++ int visualCount = 0; ++ visualTemplate.visualid = XVisualIDFromVisual(DefaultVisual(display, screen)); ++ visualTemplate.screen = screen; ++ visualInfo = XGetVisualInfo(display, VisualIDMask | VisualScreenMask, &visualTemplate, &visualCount); ++ if (visualInfo != NULL && (visualInfo->class != TrueColor || visualInfo->depth < 24)) { ++ XFree(visualInfo); ++ visualInfo = NULL; ++ } ++ if (visualInfo == NULL && (visualInfo = glXChooseVisual(display, screen, att)) == NULL) { ++ NSLog(@"glXChooseVisual failed at %s %d", __FILE__, __LINE__); ++ } ++ return visualInfo; ++} ++ ++// 32-bit ARGB, for windows that let what is behind them show through. ++static XVisualInfo *X11AlphaVisualInfo(Display *display) { ++ static XVisualInfo visualInfo; ++ static BOOL checked; ++ if (!checked) { ++ checked = YES; ++ XVisualInfo visualTemplate = {.screen = DefaultScreen(display), .depth = 32, .class = TrueColor}; ++ int count = 0; ++ XVisualInfo *visuals = XGetVisualInfo(display, VisualScreenMask | VisualDepthMask | VisualClassMask, ++ &visualTemplate, &count); ++ for (int i = 0; i < count && visualInfo.visual == NULL; i++) { ++ if (visuals[i].red_mask == 0xff0000 && visuals[i].green_mask == 0x00ff00 && ++ visuals[i].blue_mask == 0x0000ff) ++ visualInfo = visuals[i]; ++ } ++ if (visuals) ++ XFree(visuals); ++ } ++ return visualInfo.visual ? &visualInfo : NULL; ++} ++ ++// Set when the driver has no alpha config for the ARGB visual: later windows stay opaque. ++static BOOL alphaSurfaceFailed; ++ ++static BOOL windowPropertyHasAtom(Display *display, Window window, Atom property, Atom atom) { ++ Atom type; ++ int format; ++ unsigned long count, bytesAfter; ++ unsigned char *data = NULL; ++ BOOL found = NO; ++ if (XGetWindowProperty(display, window, property, 0, 64, False, XA_ATOM, &type, &format, ++ &count, &bytesAfter, &data) == Success && data != NULL) { ++ for (unsigned long i = 0; i < count && !found; i++) ++ found = ((Atom *) data)[i] == atom; ++ XFree(data); ++ } ++ return found; ++} @implementation X11Window -@@ -158,7 +187,7 @@ static NSData *makeWindowIcon() { +@@ -158,53 +265,75 @@ static NSData *makeWindowIcon() { X11Display* x11disp = (X11Display *) [NSDisplay currentDisplay]; _display = [x11disp display]; @@ -4616,32 +4701,63 @@ index f4925b66..0296203b 100644 BOOL isPanel = [delegate isKindOfClass: [NSPanel class]]; if (isPanel && _styleMask & NSDocModalWindowMask) _styleMask = NSBorderlessWindowMask; -@@ -177,7 +206,18 @@ static NSData *makeWindowIcon() { +- // TODO: get rid of these glX calls +- GLint att[] = {GLX_RGBA, +- GLX_DOUBLEBUFFER, +- GLX_RED_SIZE, +- 4, +- GLX_GREEN_SIZE, +- 4, +- GLX_BLUE_SIZE, +- 4, +- GLX_DEPTH_SIZE, +- 4, +- None}; - int screen = DefaultScreen(_display); +- int screen = DefaultScreen(_display); ++ _hasAlpha = [self wantsAlphaVisual]; ++ [self createXWindow]; - if ((_visualInfo = glXChooseVisual(_display, screen, att)) == NULL) { -+ // Rendering goes through EGL. NVIDIA's glXChooseVisual can return a GLX-only visual that every -+ // eglCreateWindowSurface rejects (EGL_BAD_CONFIG), so prefer the default visual when usable. -+ XVisualInfo visualTemplate; -+ int visualCount = 0; -+ visualTemplate.visualid = XVisualIDFromVisual(DefaultVisual(_display, screen)); -+ visualTemplate.screen = screen; -+ _visualInfo = XGetVisualInfo(_display, VisualIDMask | VisualScreenMask, &visualTemplate, &visualCount); -+ if (_visualInfo != NULL && (_visualInfo->class != TrueColor || _visualInfo->depth < 24)) { -+ XFree(_visualInfo); -+ _visualInfo = NULL; -+ } -+ if (_visualInfo == NULL && (_visualInfo = glXChooseVisual(_display, screen, att)) == NULL) { - NSLog(@"glXChooseVisual failed at %s %d", __FILE__, __LINE__); - } +- NSLog(@"glXChooseVisual failed at %s %d", __FILE__, __LINE__); +- } ++ /* 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 (!X11IsLeagueGameWindow(_display, None, delegate)) ++ _remoteFrameTimer = [[NSTimer scheduledTimerWithTimeInterval: 1.0 / 60.0 ++ target: self selector: @selector(presentRemoteCAFrame:) ++ userInfo: nil repeats: YES] retain]; -@@ -193,8 +233,16 @@ static NSData *makeWindowIcon() { +- Colormap cmap = +- XCreateColormap(_display, RootWindow(_display, _visualInfo->screen), +- _visualInfo->visual, AllocNone); ++ return self; ++} + +- if (cmap < 0) { +- NSLog(@"XCreateColormap failed"); +- [self release]; +- return nil; +- } ++- (void) createXWindow { ++ NSWindow *delegate = _delegate; ++ X11Display* x11disp = (X11Display *) [NSDisplay currentDisplay]; ++ BOOL isPanel = [delegate isKindOfClass: [NSPanel class]]; XSetWindowAttributes xattr; unsigned long xattr_mask; - xattr.override_redirect = - _styleMask == NSBorderlessWindowMask ? True : False; +- xattr_mask = CWOverrideRedirect | CWColormap; +- xattr.colormap = cmap; +- +- _window = XCreateWindow( +- _display, DefaultRootWindow(_display), _frame.origin.x, +- _frame.origin.y, _frame.size.width, _frame.size.height, 0, +- (_visualInfo == NULL) ? CopyFromParent : _visualInfo->depth, +- InputOutput, +- (_visualInfo == NULL) ? CopyFromParent : _visualInfo->visual, +- xattr_mask, &xattr); + BOOL isGame = X11IsLeagueGameWindow(_display, None, delegate); + + // A borderless game is still a focusable application window. Leaving it @@ -4652,26 +4768,48 @@ index f4925b66..0296203b 100644 + [delegate isKindOfClass:[NSPopUpWindow class]] || + ![delegate canBecomeKeyWindow]) ? True : False; + _overrideRedirect = xattr.override_redirect; - xattr_mask = CWOverrideRedirect | CWColormap; - xattr.colormap = cmap; - -@@ -206,6 +254,12 @@ static NSData *makeWindowIcon() { - (_visualInfo == NULL) ? CopyFromParent : _visualInfo->visual, - xattr_mask, &xattr); - ++ xattr.border_pixel = 0; ++ ++ for (;;) { ++ _visualInfo = _hasAlpha ? X11AlphaVisualInfo(_display) : X11OpaqueVisualInfo(_display); ++ xattr.colormap = ++ XCreateColormap(_display, RootWindow(_display, _visualInfo->screen), ++ _visualInfo->visual, AllocNone); ++ // A depth other than the root's needs its own border pixel, or BadMatch. ++ xattr_mask = CWOverrideRedirect | CWColormap | (_hasAlpha ? CWBorderPixel : 0); ++ ++ _window = XCreateWindow( ++ _display, DefaultRootWindow(_display), _frame.origin.x, ++ _frame.origin.y, _frame.size.width, _frame.size.height, 0, ++ (_visualInfo == NULL) ? CopyFromParent : _visualInfo->depth, ++ InputOutput, ++ (_visualInfo == NULL) ? CopyFromParent : _visualInfo->visual, ++ xattr_mask, &xattr); ++ if (!_hasAlpha) ++ break; ++ // Without an alpha EGL config for this visual, draw opaque rather than not at all. ++ _cglWindow = CGLGetWindowWithAlpha((void *) _window, _visualInfo->visualid); ++ if (_cglWindow != NULL) ++ break; ++ XDestroyWindow(_display, _window); ++ XFreeColormap(_display, xattr.colormap); ++ _hasAlpha = NO; ++ alphaSurfaceFailed = YES; ++ } ++ + /* Keep Cocoa's process identifier available to cross-process activation. */ + unsigned long windowPID = (unsigned long)getpid(); + XChangeProperty(_display, _window, XInternAtom(_display, "_NET_WM_PID", False), + XA_CARDINAL, 32, PropModeReplace, + (unsigned char *)&windowPID, 1); -+ + [self syncDelegateProperties]; - Atom atm = XInternAtom(_display, "WM_DELETE_WINDOW", False); -@@ -217,6 +271,15 @@ static NSData *makeWindowIcon() { +@@ -217,7 +346,20 @@ static NSData *makeWindowIcon() { .res_class = (char *) name}; XSetClassHint(_display, _window, &classHint); +- XSetWindowBackgroundPixmap(_display, _window, None); + 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 @@ -4681,24 +4819,115 @@ index f4925b66..0296203b 100644 + XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&bypass, 1); + } + - XSetWindowBackgroundPixmap(_display, _window, None); ++ // A translucent window shows its background until the first frame: make it clear, not black. ++ if (_hasAlpha) ++ XSetWindowBackground(_display, _window, 0); ++ else ++ XSetWindowBackgroundPixmap(_display, _window, None); _isModal = [delegate isSheet] || [NSApp modalWindow] == delegate; -@@ -266,6 +329,13 @@ static NSData *makeWindowIcon() { + // FIXME: There should be no need for this. +@@ -254,7 +396,8 @@ static NSData *makeWindowIcon() { + XNFocusWindow, _window, + NULL); + +- _cglWindow = CGLGetWindow((void *) _window); ++ if (!_hasAlpha) ++ _cglWindow = CGLGetWindow((void *) _window); + + [(X11Display *) [NSDisplay currentDisplay] setWindow: self forID: _window]; + +@@ -265,8 +408,88 @@ static NSData *makeWindowIcon() { + [self setStyleMaskInternal: _styleMask force: YES]; [self setWindowIcon]; ++} -+ /* 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; ++// Only when the background can show through (a pattern image may): League's game window is ++// non-opaque on an opaque background, and a 32-bit parent costs Xwayland a copy of its GL child. ++- (BOOL) isTranslucent { ++ if ([_delegate isOpaque] || ++ X11IsLeagueGameWindow(_display, None, _delegate)) ++ return NO; ++ NSColor *background = [_delegate backgroundColor]; ++ return background == nil || [background alphaComponent] < 1.0 || ++ [[background colorSpaceName] isEqualToString: NSPatternColorSpace]; ++} + - return self; ++- (BOOL) wantsAlphaVisual { ++ return !alphaSurfaceFailed && X11AlphaVisualInfo(_display) != NULL && [self isTranslucent]; ++} ++ ++// The visual is fixed at creation, which often comes before the app sets its opacity and ++// background: swap the window while it is unmapped, keeping its children. ++- (void) recreateXWindowWithAlpha: (BOOL) alpha { ++ Window oldWindow = _window; ++ ++ [_caContext release]; ++ _caContext = nil; ++ if (_cglContext != NULL) { ++ CGLReleaseContext(_cglContext); ++ _cglContext = NULL; ++ } ++ if (_cglWindow != NULL) { ++ CGLDestroyWindow(_cglWindow); ++ _cglWindow = NULL; ++ } ++ XDestroyIC(_xic); ++ [(X11Display *) [NSDisplay currentDisplay] setWindow: nil forID: oldWindow]; ++ ++ _hasAlpha = alpha; ++ [self createXWindow]; ++ ++ Window root, parent, *children = NULL; ++ unsigned int count = 0; ++ if (XQueryTree(_display, oldWindow, &root, &parent, &children, &count)) { ++ for (unsigned int i = 0; i < count; i++) { ++ XWindowAttributes attributes; ++ if (XGetWindowAttributes(_display, children[i], &attributes)) ++ XReparentWindow(_display, children[i], _window, attributes.x, attributes.y); ++ } ++ if (children) ++ XFree(children); ++ } ++ XDestroyWindow(_display, oldWindow); ++ ++ NSString *title = [_delegate title]; ++ if (title != nil) ++ [self setTitle: title]; ++} ++ ++// Opacity and background are set by the time the window gets content or is first shown. ++- (void) settleVisual { ++ if (_wasMapped || _window == 0 || _delegate == nil) ++ return; ++ BOOL alpha = [self wantsAlphaVisual]; ++ if (alpha != _hasAlpha && _remoteFrameWindow == 0) ++ [self recreateXWindowWithAlpha: alpha]; ++} ++ ++// Translucent windows above the normal level (splashes, overlays) float over the others on macOS, ++// without a frame. Window managers float splash windows and leave them undecorated. ++- (void) markFloatingBeforeMap { ++ Atom typeProperty = XInternAtom(_display, "_NET_WM_WINDOW_TYPE", False); ++ if (windowPropertyHasAtom(_display, _window, typeProperty, ++ XInternAtom(_display, "_NET_WM_WINDOW_TYPE_NORMAL", False))) { ++ long splash = (long) XInternAtom(_display, "_NET_WM_WINDOW_TYPE_SPLASH", False); ++ XChangeProperty(_display, _window, typeProperty, XA_ATOM, 32, PropModeReplace, ++ (const unsigned char *) &splash, 1); ++ } ++ Atom stateProperty = XInternAtom(_display, "_NET_WM_STATE", False); ++ Atom above = XInternAtom(_display, "_NET_WM_STATE_ABOVE", False); ++ if (!windowPropertyHasAtom(_display, _window, stateProperty, above)) { ++ long state = (long) above; ++ XChangeProperty(_display, _window, stateProperty, XA_ATOM, 32, PropModeAppend, ++ (const unsigned char *) &state, 1); ++ } } -@@ -279,24 +349,30 @@ static NSData *makeWindowIcon() { + - (void) dealloc { +@@ -279,24 +502,30 @@ static NSData *makeWindowIcon() { return _styleMask; } @@ -4745,7 +4974,7 @@ index f4925b66..0296203b 100644 if (!_mapped) { long states[2]; int states_cnt = 0; -@@ -304,7 +380,7 @@ static NSData *makeWindowIcon() { +@@ -304,7 +533,7 @@ static NSData *makeWindowIcon() { states[states_cnt++] = (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); } @@ -4754,7 +4983,7 @@ index f4925b66..0296203b 100644 states[states_cnt++] = (long) XInternAtom( _display, "_NET_WM_STATE_FULLSCREEN", False); } -@@ -312,14 +388,13 @@ static NSData *makeWindowIcon() { +@@ -312,14 +541,13 @@ static NSData *makeWindowIcon() { XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, 32, PropModeReplace, (const unsigned char *) states, states_cnt); @@ -4771,7 +5000,7 @@ index f4925b66..0296203b 100644 event.data.l[1] = (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); event.data.l[3] = 1; -@@ -327,6 +402,35 @@ static NSData *makeWindowIcon() { +@@ -327,6 +555,35 @@ static NSData *makeWindowIcon() { SubstructureNotifyMask | SubstructureRedirectMask, (XEvent *) &event); } @@ -4807,7 +5036,7 @@ index f4925b66..0296203b 100644 } - (void) setStyleMask: (NSUInteger) mask { -@@ -338,7 +442,6 @@ static NSData *makeWindowIcon() { +@@ -338,7 +595,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -4815,15 +5044,22 @@ index f4925b66..0296203b 100644 struct { unsigned long flags; unsigned long functions; -@@ -358,6 +461,7 @@ static NSData *makeWindowIcon() { +@@ -356,8 +612,14 @@ static NSData *makeWindowIcon() { + + - (void) ensureMapped { if (!_mapped) { ++ [self settleVisual]; ++ if (!_wasMapped && _delegate != nil && [_delegate level] > NSNormalWindowLevel && ++ [self isTranslucent]) ++ [self markFloatingBeforeMap]; XMapWindow(_display, _window); ++ _wasMapped = YES; _mapped = YES; + _remoteFrameTargetRefresh = 0; } } -@@ -377,15 +481,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +639,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -4856,7 +5092,7 @@ index f4925b66..0296203b 100644 // TODO: background color } -@@ -393,6 +509,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +667,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. @@ -4885,7 +5121,7 @@ index f4925b66..0296203b 100644 [_context release]; _context = nil; -@@ -421,6 +559,392 @@ static NSData *makeWindowIcon() { +@@ -421,6 +717,392 @@ static NSData *makeWindowIcon() { } } @@ -5278,7 +5514,7 @@ index f4925b66..0296203b 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +994,32 @@ static NSData *makeWindowIcon() { +@@ -470,14 +1152,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -5312,7 +5548,7 @@ index f4925b66..0296203b 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1030,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1188,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -5338,7 +5574,7 @@ index f4925b66..0296203b 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1104,21 @@ static NSData *makeWindowIcon() { +@@ -543,6 +1262,21 @@ static NSData *makeWindowIcon() { - (void) makeKey { [self ensureMapped]; XRaiseWindow(_display, _window); @@ -5360,7 +5596,7 @@ index f4925b66..0296203b 100644 } - (void) makeMain { -@@ -559,7 +1135,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1293,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -5390,7 +5626,20 @@ index f4925b66..0296203b 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -615,6 +1213,12 @@ static NSData *makeWindowIcon() { +@@ -593,8 +1349,10 @@ static NSData *makeWindowIcon() { + if (_cglContext == NULL) { + CGLError error; + +- if ((error = CGLCreateContext(NULL, NULL, &_cglContext)) != +- kCGLNoError) { ++ // An alpha surface needs a context from its own config. ++ error = _hasAlpha ? CGLCreateContextForWindow(_cglWindow, &_cglContext) ++ : CGLCreateContext(NULL, NULL, &_cglContext); ++ if (error != kCGLNoError) { + NSLog(@"CGLCreateContext failed at %s %d with error %d", __FILE__, + __LINE__, error); + +@@ -615,6 +1373,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -5403,7 +5652,7 @@ index f4925b66..0296203b 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1256,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1416,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -5412,7 +5661,7 @@ index f4925b66..0296203b 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1270,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1430,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -5420,7 +5669,7 @@ index f4925b66..0296203b 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,14 +1315,6 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,19 +1475,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } @@ -5435,6 +5684,13 @@ index f4925b66..0296203b 100644 - (NSPoint) transformPoint: (NSPoint) pos; { return NSMakePoint(pos.x, _frame.size.height - pos.y); } + + - (X11SubWindow *) createSubWindowWithFrame: (CGRect) frame { ++ // Subwindows take the parent's visual: settle it before they exist. ++ [self settleVisual]; + return [[[X11SubWindow alloc] initWithParentWindow: self + frame: frame] autorelease]; + } diff --git a/AppKit/include/AppKit/NSAccessibilityConstants.h b/AppKit/include/AppKit/NSAccessibilityConstants.h index 282312de..03c75b2b 100644 --- a/AppKit/include/AppKit/NSAccessibilityConstants.h @@ -6439,6 +6695,45 @@ index e1da261d..f050b6e8 100644 CFArrayRef __nullable CGWindowListCreate(CGWindowListOption option, CGWindowID relativeToWindow) { printf("STUB %s\n", __PRETTY_FUNCTION__); +diff --git a/CoreGraphics/CGWindowLevel.m b/CoreGraphics/CGWindowLevel.m +index e03fbf80..a0206b07 100644 +--- a/CoreGraphics/CGWindowLevel.m ++++ b/CoreGraphics/CGWindowLevel.m +@@ -1,6 +1,33 @@ + #import + ++// The levels macOS returns: SDK headers turn NSFloatingWindowLevel and friends into calls to this ++// function, so returning the normal level for every key hid alwaysOnTop and panel levels. + CGWindowLevel CGWindowLevelForKey(CGWindowLevelKey key) + { +- return kCGNormalWindowLevel; ++ static const int32_t levels[kCGNumberOfWindowLevelKeys] = { ++ [kCGBaseWindowLevelKey] = INT32_MIN, ++ [kCGMinimumWindowLevelKey] = INT32_MIN + 1, ++ [kCGDesktopWindowLevelKey] = INT32_MIN + 1 + 20, ++ [kCGBackstopMenuLevelKey] = -20, ++ [kCGNormalWindowLevelKey] = 0, ++ [kCGFloatingWindowLevelKey] = 3, ++ [kCGTornOffMenuWindowLevelKey] = 3, ++ [kCGDockWindowLevelKey] = 20, ++ [kCGMainMenuWindowLevelKey] = 24, ++ [kCGStatusWindowLevelKey] = 25, ++ [kCGModalPanelWindowLevelKey] = 8, ++ [kCGPopUpMenuWindowLevelKey] = 101, ++ [kCGDraggingWindowLevelKey] = 500, ++ [kCGScreenSaverWindowLevelKey] = 1000, ++ [kCGMaximumWindowLevelKey] = INT32_MAX - 16, ++ [kCGOverlayWindowLevelKey] = 102, ++ [kCGHelpWindowLevelKey] = 200, ++ [kCGUtilityWindowLevelKey] = 19, ++ [kCGDesktopIconWindowLevelKey] = INT32_MIN + 1 + 20 + 20, ++ [kCGCursorWindowLevelKey] = INT32_MAX - 17, ++ [kCGAssistiveTechHighWindowLevelKey] = 1500, ++ }; ++ if ((int) key < 0 || key >= kCGNumberOfWindowLevelKeys) ++ return 0; ++ return levels[key]; + } diff --git a/CoreGraphics/X11.backend/CGSConnectionX11.h b/CoreGraphics/X11.backend/CGSConnectionX11.h index 5437acb5..07f7eb4f 100644 --- a/CoreGraphics/X11.backend/CGSConnectionX11.h diff --git a/patches/darling.patch b/patches/darling.patch index 49e767f..5194fda 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -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..5b5712ea2 100644 +index ffd7fe054..f25fd9aa5 100644 --- a/src/frameworks/OpenGL/OpenGL.c +++ b/src/frameworks/OpenGL/OpenGL.c @@ -2,11 +2,22 @@ @@ -6527,9 +6527,41 @@ index ffd7fe054..5b5712ea2 100644 if (surface == EGL_NO_SURFACE) { return NULL; -@@ -171,14 +309,21 @@ CGLWindowRef CGLGetWindow(void *native_window) { +@@ -170,15 +308,53 @@ CGLWindowRef CGLGetWindow(void *native_window) { + return (CGLWindowRef) surface; } ++/* The default config has no alpha, and NVIDIA rejects it on 32-bit windows (EGL_BAD_CONFIG). ++ * Translucent windows need an alpha config whose native visual is the window's. */ ++CGLWindowRef CGLGetWindowWithAlpha(void *native_window, unsigned long visual_id) { ++ static EGLint const alpha_attribute_list[] = { ++ EGL_RED_SIZE, 1, ++ EGL_GREEN_SIZE, 1, ++ EGL_BLUE_SIZE, 1, ++ EGL_ALPHA_SIZE, 1, ++ EGL_SURFACE_TYPE, EGL_WINDOW_BIT, ++ EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, ++ EGL_NONE ++ }; ++ EGLint count = 0; ++ if (!display || !p_eglGetConfigAttrib || ++ !p_eglChooseConfig(display, alpha_attribute_list, NULL, 0, &count) || count <= 0) ++ return NULL; ++ EGLConfig *configs = malloc(count * sizeof(EGLConfig)); ++ EGLSurface surface = EGL_NO_SURFACE; ++ if (configs && p_eglChooseConfig(display, alpha_attribute_list, configs, count, &count)) { ++ for (EGLint i = 0; i < count && surface == EGL_NO_SURFACE; i++) { ++ EGLint visual = 0; ++ if (p_eglGetConfigAttrib(display, configs[i], EGL_NATIVE_VISUAL_ID, &visual) && ++ (unsigned long) visual == visual_id) ++ surface = p_eglCreateWindowSurface(display, configs[i], ++ (EGLNativeWindowType) native_window, NULL); ++ } ++ } ++ free(configs); ++ return surface == EGL_NO_SURFACE ? NULL : (CGLWindowRef) surface; ++} ++ CGL_EXPORT void CGLDestroyWindow(CGLWindowRef window) { - eglDestroySurface(display, (EGLSurface) window); + /* CGLGetWindow returns NULL when eglCreateWindowSurface fails. */ @@ -6551,7 +6583,7 @@ index ffd7fe054..5b5712ea2 100644 } static pthread_key_t current_context_key; -@@ -198,18 +343,42 @@ CGLContextObj CGLGetCurrentContext(void) { +@@ -198,18 +374,42 @@ CGLContextObj CGLGetCurrentContext(void) { return pthread_getspecific(key); } @@ -6599,7 +6631,7 @@ index ffd7fe054..5b5712ea2 100644 } CGLError CGLSetFullScreen(CGLContextObj ctx) { -@@ -223,11 +392,13 @@ CGLError CGLChoosePixelFormat( +@@ -223,11 +423,13 @@ CGLError CGLChoosePixelFormat( CGLPixelFormatObj *result, GLint *number_of_screens ) { @@ -6614,7 +6646,7 @@ index ffd7fe054..5b5712ea2 100644 for (int i = 0; i < count; i++) { format->attributes[i] = attrs[i]; } -@@ -251,6 +422,10 @@ CGLError CGLDescribePixelFormat( +@@ -251,6 +453,10 @@ CGLError CGLDescribePixelFormat( CGLPixelFormatAttribute attr, GLint *value ) { @@ -6625,11 +6657,13 @@ index ffd7fe054..5b5712ea2 100644 for (int i = 0; format->attributes[i] != 0; i++) { int has_arg = attribute_has_argument(format->attributes[i]); -@@ -304,24 +479,67 @@ GLuint CGLGetPixelFormatRetainCount(CGLPixelFormatObj pixelFormat) { +@@ -303,37 +509,98 @@ GLuint CGLGetPixelFormatRetainCount(CGLPixelFormatObj pixelFormat) { + return pixelFormat->retain_count; } - CGLError CGLCreateContext(CGLPixelFormatObj pixelFormat, CGLContextObj share, CGLContextObj *resultp) { -+ /* Cocotron window compositing requests the default format with NULL. */ +-CGLError CGLCreateContext(CGLPixelFormatObj pixelFormat, CGLContextObj share, CGLContextObj *resultp) { ++static CGLError createContext(CGLPixelFormatObj pixelFormat, CGLContextObj share, ++ EGLConfig windowConfig, CGLContextObj *resultp) { + if (!resultp) + return kCGLBadAddress; + *resultp = NULL; @@ -6640,7 +6674,7 @@ index ffd7fe054..5b5712ea2 100644 EGLContext egl_share = EGL_NO_CONTEXT; + EGLDisplay contextDisplay = share ? share->egl_display : display; -+ EGLConfig contextConfig = share ? share->egl_config : config; ++ EGLConfig contextConfig = share ? share->egl_config : windowConfig ? windowConfig : config; if (share != NULL) { egl_share = share->egl_context; } @@ -6694,7 +6728,29 @@ index ffd7fe054..5b5712ea2 100644 *resultp = context; -@@ -333,7 +551,7 @@ CGLContextObj CGLRetainContext(CGLContextObj context) { + return kCGLNoError; + } + ++CGLError CGLCreateContext(CGLPixelFormatObj pixelFormat, CGLContextObj share, CGLContextObj *resultp) { ++ /* Cocotron window compositing requests the default format with NULL. */ ++ return createContext(pixelFormat, share, NULL, resultp); ++} ++ ++CGLError CGLCreateContextForWindow(CGLWindowRef window, CGLContextObj *resultp) { ++ EGLint id = 0, count = 0; ++ EGLConfig windowConfig = NULL; ++ ensureDefaultDisplay(); ++ if (window && display && p_eglQuerySurface && ++ p_eglQuerySurface(display, (EGLSurface) window, EGL_CONFIG_ID, &id)) { ++ EGLint const attributes[] = {EGL_CONFIG_ID, id, EGL_NONE}; ++ if (!p_eglChooseConfig(display, attributes, &windowConfig, 1, &count)) ++ count = 0; ++ } ++ return createContext(NULL, NULL, count == 1 ? windowConfig : NULL, resultp); ++} ++ + CGLContextObj CGLRetainContext(CGLContextObj context) { + if (context == NULL) { return NULL; } @@ -6703,7 +6759,7 @@ index ffd7fe054..5b5712ea2 100644 return context; } -@@ -342,11 +560,17 @@ void CGLReleaseContext(CGLContextObj context) { +@@ -342,11 +609,17 @@ void CGLReleaseContext(CGLContextObj context) { return; } @@ -6724,7 +6780,7 @@ index ffd7fe054..5b5712ea2 100644 if (CGLGetCurrentContext() == context) { CGLSetCurrentContext(NULL); -@@ -354,7 +578,11 @@ void CGLReleaseContext(CGLContextObj context) { +@@ -354,7 +627,11 @@ void CGLReleaseContext(CGLContextObj context) { pthread_mutex_destroy(&(context->lock)); @@ -6737,7 +6793,7 @@ index ffd7fe054..5b5712ea2 100644 free(context); } -@@ -383,9 +611,330 @@ CGLError CGLUnlockContext(CGLContextObj context) { +@@ -383,9 +660,330 @@ CGLError CGLUnlockContext(CGLContextObj context) { return kCGLNoError; } @@ -7070,7 +7126,7 @@ index ffd7fe054..5b5712ea2 100644 } CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, const GLint *value) { -@@ -394,8 +943,9 @@ CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, c +@@ -394,8 +992,9 @@ CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, c if (parameter == kCGLCPSwapInterval) { @@ -7081,7 +7137,7 @@ index ffd7fe054..5b5712ea2 100644 if (success) context->swap_interval = v; return success ? kCGLNoError : kCGLBadValue; -@@ -417,14 +967,19 @@ CGLError CGLGetParameter(CGLContextObj context, CGLContextParameter parameter, G +@@ -417,14 +1016,19 @@ CGLError CGLGetParameter(CGLContextObj context, CGLContextParameter parameter, G return kCGLNoError; } @@ -7107,6 +7163,23 @@ index ffd7fe054..5b5712ea2 100644 -} \ No newline at end of file +} +diff --git a/src/frameworks/OpenGL/include/OpenGL/CGLInternal.h b/src/frameworks/OpenGL/include/OpenGL/CGLInternal.h +index 1d0b10e65..5cdd9a89c 100644 +--- a/src/frameworks/OpenGL/include/OpenGL/CGLInternal.h ++++ b/src/frameworks/OpenGL/include/OpenGL/CGLInternal.h +@@ -14,8 +14,12 @@ typedef void *CGLWindowRef; + CGL_EXPORT CGLError CGLRegisterNativeDisplay(void *native_display); + + CGL_EXPORT CGLWindowRef CGLGetWindow(void *native_window); ++// A surface with an alpha channel, for a window created with the given 32-bit visual. ++CGL_EXPORT CGLWindowRef CGLGetWindowWithAlpha(void *native_window, unsigned long visual_id); + CGL_EXPORT void CGLDestroyWindow(CGLWindowRef window); + ++// A context that can be made current on the window: one created from its surface's config. ++CGL_EXPORT CGLError CGLCreateContextForWindow(CGLWindowRef window, CGLContextObj *result); + CGL_EXPORT CGLError CGLContextMakeCurrentAndAttachToWindow(CGLContextObj context, CGLWindowRef window); + CGLError CGLSetSurface(CGLContextObj gl, CGSConnectionID cid, CGSWindowID wid, CGSSurfaceID sid); + CGLContextObj CGWindowContextCreate(CGSConnectionID cid, CGSWindowID wid, CFDictionaryRef options); diff --git a/src/frameworks/OpenGL/include/OpenGL/CGLTypes.h b/src/frameworks/OpenGL/include/OpenGL/CGLTypes.h index 7d5eda01a..803ac02d3 100644 --- a/src/frameworks/OpenGL/include/OpenGL/CGLTypes.h From bc8fa71a7f0f78290b261ea17653d4973117d172 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Sun, 18 Oct 2026 11:53:00 -0600 Subject: [PATCH 098/160] fix(launcher): close the League client UI while a match runs The client does not come back properly after a match when its own "Close client during game" option is enabled. The UI (LeagueClientUx and its helpers) is a Chromium/CEF instance and, under Darling, the pre-match instance keeps running: the client the backend starts when the match ends is a second CEF instance on the same --user-data-dir, and its window never paints. Its DOM and audio work and the window stays black; closing the client by hand during a match leaves the same state for the same reason. RCLOL_CLOSE_CLIENT_IN_GAME=1 opts in to closing that UI from the host, and only while a match runs, so the client that appears afterwards is the only instance. The UI also competes with the game for CPU: on a four-core host with a Radeon RX 550 a practice match reported roughly 120 FPS with the UI running and roughly 180 FPS with it closed. This is not the helper-killing memory optimization the RAM section warns against, and the Riot client, the League backend and the game are never touched. The helper fails closed. It attributes the game and the client to the launcher's prefix through the loader's __mldr_sockpath, since guest processes do not carry DPREFIX (checked on a live session). It never closes a client younger than 60 seconds (the one the backend starts after a match), preserves a process of another installation or owner, acts only when the environment and the stat fields can be read, gives up after three closes in one match, and leaves after ten minutes without a client or a match. It is off by default. Tests use fake /proc entries and mock every signal. --- .github/workflows/source-check.yml | 1 + docs/TROUBLESHOOTING.md | 42 ++++ tests/test_close_league_client_in_game.py | 207 +++++++++++++++++ tools/close-league-client-in-game.py | 256 ++++++++++++++++++++++ tools/run-riot-client.sh | 7 + 5 files changed, 513 insertions(+) create mode 100644 tests/test_close_league_client_in_game.py create mode 100755 tools/close-league-client-in-game.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index d8de01d..20552db 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -21,3 +21,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_system_profiler.py' - run: python -m unittest discover -s tests -p 'test_metal_device_info.py' - run: python -m unittest discover -s tests -p 'test_exec_guest.py' + - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a6da2e1..778c1bc 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -121,6 +121,48 @@ ownership or window information cannot be read. A hidden background client or download is restarted when recovery is enabled. Other desktop environments are not supported by this helper; recovery is off by default. +### Client window stays black after a match + +The League client keeps its Chromium/CEF UI (`LeagueClientUx` and its helpers) +alive during a match. Riot's own "Close client during game" option closes that UI +on Windows, but under Darling the pre-match instance keeps running: the client the +backend starts when the match ends is a second CEF instance on the same +`--user-data-dir`, and its window never paints. Its DOM and audio work and the +window stays black. Closing the client by hand during the match leaves the same +state for the same reason. + +An opt-in helper closes that UI itself, from the host, and only while a match +runs, so the client that appears afterwards is the only instance: + +```sh +RCLOL_CLOSE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +This is not a memory measure: the UI is closed because it competes with the game +for CPU during the match. Measured on a four-core host with a Radeon RX 550, a +practice match reported roughly 120 FPS with the client UI running and roughly +180 FPS with it closed. On that host a local supervisor using the same detection +closed 13 client processes (2027 MiB resident) when a practice match started, and +the client came back as one instance with a painted window when it ended; the +helper described here has not been through a live match yet. Riot's own option is +the equivalent on Windows, so leave either one disabled if the in-client chat is +wanted during a match. + +The helper is off by default and fails closed: it attributes both the game and the +client to the launcher's prefix through the loader's `__mldr_sockpath` (guest +processes do not carry `DPREFIX`), never closes a client younger than 60 seconds +(the one the backend starts after a match), leaves the Riot client, the League +backend and the game untouched, and takes no action when ownership, the +environment or the stat fields cannot be read. The client UI is recognised by the +name of its executable, not by the command line: `LeagueCrashHandler` processes +carry the client UI name in their arguments only, so they are left alone. It +leaves by itself after ten minutes without a client or a match. Diagnose it in one +observation, without waiting for a match: + +```sh +python3 tools/close-league-client-in-game.py --once --dry-run +``` + ### Downloads repeatedly fail with an IPv6 CONNECT_TO error The bundled downloader was observed retrying CDN chunks with: diff --git a/tests/test_close_league_client_in_game.py b/tests/test_close_league_client_in_game.py new file mode 100644 index 0000000..0d774cb --- /dev/null +++ b/tests/test_close_league_client_in_game.py @@ -0,0 +1,207 @@ +"""Exercise the in-match client close with fake /proc entries and mocked signals only.""" +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('in_game', ROOT / 'tools/close-league-client-in-game.py') +in_game = importlib.util.module_from_spec(spec) +spec.loader.exec_module(in_game) + +MAIN = '/Applications/League of Legends.app/Contents/LoL/LeagueClientUx.app/Contents/MacOS/LeagueClientUx' +HELPER = ('/Applications/League of Legends.app/Contents/LoL/LeagueClientUx Helper.app' + '/Contents/MacOS/LeagueClientUx Helper') +GAME = '/Applications/LeagueofLegends.app/Contents/MacOS/LeagueofLegends' +BACKEND = '/Applications/League of Legends.app/Contents/LoL/LeagueClient.app/Contents/MacOS/LeagueClient' +RIOT = '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices' +CRASH_HANDLER = ('/Applications/League of Legends.app/Contents/LoL/LeagueClient.app' + '/Contents/MacOS/LeagueCrashHandler') +# A live crash handler carries the client UI name in its arguments only. +CRASH_ARGUMENTS = ('--attachment=/Applications/League of Legends.app/Contents/LoL/Logs/' + 'LeagueClientUx Logs/2026-10-18T09-49-13_659_LeagueClientUx.log', + '--handshake-fd=52') + + +class InGameCloseTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + (self.proc / 'uptime').write_text('1000 0\n') + self.home = self.root / 'home' + self.home_patch = patch.object(Path, 'home', return_value=self.home) + self.home_patch.start() + self.addCleanup(self.home_patch.stop) + self.prefix = self.home / '.darling' + self.env_patch = patch.dict(os.environ, {'DPREFIX': str(self.prefix)}) + self.env_patch.start() + self.addCleanup(self.env_patch.stop) + + def environment(self, prefix): + if prefix is None: + return b'' + return b'%s=%s\0' % (in_game.SOCK_VARIABLE, os.fsencode(prefix / in_game.SOCK_NAME)) + + def process(self, pid, executable, prefix='own', age=120, environ=None, args=('--flag',)): + folder = self.proc / str(pid) + folder.mkdir() + (folder / 'cmdline').write_bytes(executable.encode() + b'\0' + + b''.join(arg.encode() + b'\0' for arg in args)) + if environ is None: + environ = self.environment(self.prefix if prefix == 'own' else prefix) + (folder / 'environ').write_bytes(environ) + fields = ['S'] + ['0'] * 18 + [str(int((1000 - age) * os.sysconf('SC_CLK_TCK')))] + (folder / 'stat').write_text(str(pid) + ' (LeagueClientUx (helper)) ' + ' '.join(fields)) + return folder + + def client_ui(self, pid, prefix='own', age=120, environ=None): + return self.process(pid, MAIN if pid % 2 else HELPER, prefix=prefix, age=age, environ=environ) + + def run_once(self, dry_run=False): + self.output = io.StringIO() + with patch.object(in_game.time, 'sleep'), patch.object(in_game.os, 'kill') as kill: + result = in_game.supervise(self.proc, once=True, dry_run=dry_run, out=self.output) + return result, [call.args for call in kill.call_args_list] + + def test_client_ui_without_a_match_is_preserved(self): + self.client_ui(123) + result, calls = self.run_once() + self.assertEqual((result, calls), (0, [])) + self.assertIn('match=absent client_ui=1', self.output.getvalue()) + + def test_running_match_closes_only_the_client_ui(self): + self.client_ui(123) + self.process(124, HELPER) + self.process(125, GAME) + self.process(126, BACKEND) + self.process(127, RIOT) + self.process(128, MAIN, prefix=self.root / 'other-prefix') + result, calls = self.run_once() + self.assertEqual(result, 0) + self.assertEqual(sorted(calls), sorted([(123, in_game.signal.SIGTERM), + (124, in_game.signal.SIGTERM), + (123, in_game.signal.SIGKILL), + (124, in_game.signal.SIGKILL)])) + self.assertIn('closed the client UI for the running match (2 processes: 123,124)', + self.output.getvalue()) + + def test_marker_in_arguments_does_not_make_a_process_client_ui(self): + self.client_ui(123) + self.process(124, CRASH_HANDLER, args=CRASH_ARGUMENTS) + self.process(125, GAME) + result, calls = self.run_once() + self.assertEqual(result, 0) + self.assertEqual(sorted(calls), sorted([(123, in_game.signal.SIGTERM), + (123, in_game.signal.SIGKILL)])) + self.assertIn('closed the client UI for the running match (1 processes: 123)', + self.output.getvalue()) + + def test_game_argument_naming_the_client_ui_is_still_the_game(self): + self.process(125, GAME, args=('--parent=LeagueClientUx',)) + self.process(126, CRASH_HANDLER, args=CRASH_ARGUMENTS) + result, calls = self.run_once() + self.assertEqual((result, calls), (0, [])) + self.assertIn('match=running client_ui=0', self.output.getvalue()) + + def test_ui_identity_rejects_a_related_process_with_the_marker(self): + folder = self.client_ui(123) + self.assertIsNotNone(in_game.ui_identity(self.proc, '123')) + (folder / 'cmdline').write_bytes(CRASH_HANDLER.encode() + b'\0' + + b'\0'.join(a.encode() for a in CRASH_ARGUMENTS) + b'\0') + self.assertIsNone(in_game.ui_identity(self.proc, '123')) + with patch.object(in_game.time, 'sleep'), patch.object(in_game.os, 'kill') as kill: + in_game.close_ui(self.proc, {'123': 1}) + self.assertEqual(kill.call_args_list, []) + + def test_match_in_another_install_is_preserved(self): + self.client_ui(123) + self.process(125, GAME, prefix=self.root / 'other-prefix') + self.assertEqual(self.run_once()[1], []) + + def test_game_without_an_install_socket_is_preserved(self): + self.client_ui(123) + self.process(125, GAME, environ=b'') + self.assertEqual(self.run_once()[1], []) + + def test_young_client_ui_is_preserved(self): + self.client_ui(123, age=in_game.MIN_AGE_SECONDS - 1) + self.process(125, GAME) + self.assertEqual(self.run_once()[1], []) + + def test_client_ui_of_another_install_is_preserved(self): + self.client_ui(123, prefix=self.root / 'other-prefix') + self.process(125, GAME) + self.assertEqual(self.run_once()[1], []) + + def test_client_ui_without_an_install_socket_is_preserved(self): + self.client_ui(123, environ=b'') + self.process(125, GAME) + self.assertEqual(self.run_once()[1], []) + + def test_missing_prefix_prevents_any_action(self): + self.client_ui(123) + self.process(125, GAME) + with patch.dict(os.environ, {'DPREFIX': 'relative/path'}): + self.assertIsNone(in_game.target_prefix()) + self.assertEqual(self.run_once()[1], []) + + def test_foreign_owned_process_is_ignored(self): + self.client_ui(123) + self.process(125, GAME) + with patch.object(in_game.os, 'getuid', return_value=os.getuid() + 1): + result, calls = self.run_once() + self.assertEqual((result, calls), (0, [])) + + def test_unreadable_environment_prevents_action(self): + self.client_ui(123) + self.process(125, GAME) + original = Path.read_bytes + def read(path): + if path.name == 'environ': + raise PermissionError() + return original(path) + with patch.object(Path, 'read_bytes', read): + self.assertEqual(self.run_once()[1], []) + + def test_malformed_or_missing_age_prevents_action(self): + proc = self.client_ui(123) + self.process(125, GAME) + for value in ('garbage', '123 (worker) S'): + (proc / 'stat').write_text(value) + self.assertEqual(self.run_once()[1], []) + (proc / 'stat').unlink() + self.assertEqual(self.run_once()[1], []) + + def test_dry_run_reports_without_signalling(self): + self.client_ui(123) + self.process(125, GAME) + self.assertEqual(self.run_once(dry_run=True)[1], []) + self.assertIn('would close the client UI (1 processes: 123)', self.output.getvalue()) + + def test_exited_or_reused_processes_are_preserved(self): + folder = self.client_ui(123) + identity = in_game.ui_identity(self.proc, '123') + self.assertIsNotNone(identity) + (folder / 'cmdline').write_bytes(b'/usr/bin/editor\0') + self.assertIsNone(in_game.ui_identity(self.proc, '123')) + with patch.object(in_game.time, 'sleep'), patch.object(in_game.os, 'kill') as kill: + in_game.close_ui(self.proc, {'123': identity, '124': 1}) + self.assertEqual(kill.call_args_list, []) + + def test_close_decision_covers_limits_and_cooldown(self): + state = {'game': True, 'ui': {'123': 1}} + self.assertEqual(in_game.close_decision(state, 0, 0.0, 100.0), 'close') + self.assertIsNone(in_game.close_decision(state, 0, 90.0, 100.0)) + self.assertEqual(in_game.close_decision(state, in_game.MAX_CLOSES, 0.0, 100.0), 'limit') + self.assertIsNone(in_game.close_decision({'game': False, 'ui': {'123': 1}}, 0, 0.0, 100.0)) + self.assertIsNone(in_game.close_decision({'game': True, 'ui': {}}, 0, 0.0, 100.0)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/close-league-client-in-game.py b/tools/close-league-client-in-game.py new file mode 100755 index 0000000..bdf4508 --- /dev/null +++ b/tools/close-league-client-in-game.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Close the League client UI while a match runs; fail closed. + +Opt-in helper for `run-riot-client.sh` (`RCLOL_CLOSE_CLIENT_IN_GAME=1`). The +League client keeps its Chromium/CEF UI (executable `LeagueClientUx` and its +helpers) alive during a match. Those processes compete with the game for CPU, +and the client the backend starts when the match ends is a second CEF instance +on the same `--user-data-dir` as the pre-match one, so its window never paints: +the DOM and audio work, the window stays black. Riot's own "Close client during +game" option has both outcomes on a Windows client, but under Darling it leaves +the pre-match instance running, so the option reproduces the black window. + +This helper performs the close itself, from the host, while a match is running. +Only then is the client UI expendable, and the client that appears afterwards is +the only instance. It never touches the Riot client, the League backend +(`LeagueClient`) or the game, and it exits by itself when no client is left. + +It is fail closed: it acts only after positively identifying a running game and +the League client UI of its own prefix, and never closes a client younger than +MIN_AGE_SECONDS (the one the backend starts after a match). An unreadable +`/proc` entry, an unreadable environment, an unreadable or malformed stat field, +a process of another install and a missing game all lead to no action. + +The client UI is recognised by the name of its executable, not by the command +line: `LeagueCrashHandler` processes carry the `LeagueClientUx` string in their +arguments (attachment paths under `LeagueClientUx Logs`) and live under the same +`/League` paths, so a wider match would signal a related process. + +Diagnose with `--once` (one observation) or `--once --dry-run` (report only). +""" +import argparse +import math +import os +from pathlib import Path +import signal +import sys +import time + +UX_NAME = 'LeagueClientUx' +GAME_SUFFIX = '/LeagueofLegends' +# DPREFIX is not in the environment of guest processes (checked on a live session), but +# every process the loader starts carries the socket of the install it belongs to. +SOCK_VARIABLE = b'__mldr_sockpath' +SOCK_NAME = '.darlingserver.sock' + +MIN_AGE_SECONDS = 60 # a younger client UI is the one starting after a match +TERM_GRACE = 4.0 # seconds between SIGTERM and SIGKILL +KILL_GRACE = 2.0 +POLL_SECONDS = 5.0 +COOLDOWN_SECONDS = 30.0 # minimum between two closes in the same match +MAX_CLOSES = 3 # give up on a client that keeps reopening during a match +IDLE_SECONDS = 600.0 # leave after this long without a client or a game + + +def target_prefix(): + """The prefix this launcher owns, or None when it cannot be determined.""" + value = os.environ.get('DPREFIX', str(Path.home() / '.darling')) + prefix = Path(value).expanduser() + return prefix if prefix.is_absolute() else None + + +def is_client_ui(executable): + """True for the League client UI and its helpers, by executable name. + + The whole command line is not usable: `LeagueCrashHandler` carries the + `LeagueClientUx` string in its arguments (attachment paths under + `LeagueClientUx Logs`) and lives under the same `/League` paths, so only the + file name tells the UI apart from a related process. + """ + name = os.path.basename(executable) + return name == UX_NAME or name.startswith(UX_NAME + ' Helper') + + +def match_state(proc_root, prefix): + """Return {'game': bool, 'ui': {pid: start_ticks}}, or None if not reliable. + + None means the caller must not act. The game and the client UI are both + attributed to `prefix` through the loader's socket path: a process of + another install is not a match and its client is not touched. + """ + state = {'game': False, 'ui': {}} + owner = (prefix / SOCK_NAME).parent.resolve() + try: + uptime = float((proc_root / 'uptime').read_text().split()[0]) + ticks = os.sysconf('SC_CLK_TCK') + if not math.isfinite(uptime) or uptime < 0 or ticks <= 0: + return None + entries = list(proc_root.iterdir()) + except (OSError, ValueError, IndexError): + return None + for proc in entries: + if not proc.name.isdigit(): + continue + try: + if proc.stat().st_uid != os.getuid(): + continue + raw = (proc / 'cmdline').read_bytes() + executable = raw.split(b'\0')[0].decode(errors='replace') + client_ui = is_client_ui(executable) + if not client_ui and not executable.endswith(GAME_SUFFIX): + continue + environment = dict(item.split(b'=', 1) for item in + (proc / 'environ').read_bytes().split(b'\0') if b'=' in item) + socket = environment.get(SOCK_VARIABLE) + if socket is None or Path(os.fsdecode(socket)).parent.resolve() != owner: + continue + if not client_ui: + state['game'] = True + continue + # Field 22 follows a comm field that may contain spaces/parentheses. + start_ticks = int((proc / 'stat').read_text().rsplit(')', 1)[1].split()[19]) + if start_ticks < 0 or uptime - start_ticks / ticks < MIN_AGE_SECONDS: + continue + state['ui'][proc.name] = start_ticks + except FileNotFoundError: + if proc.exists(): + return None + continue + except (OSError, ValueError, IndexError): + return None + return state + + +def ui_identity(proc_root, pid): + """Start ticks of a live client UI process, or None when it is not one.""" + try: + raw = (proc_root / pid / 'cmdline').read_bytes() + executable = raw.split(b'\0')[0].decode(errors='replace') + if not is_client_ui(executable): + return None + return int((proc_root / pid / 'stat').read_text().rsplit(')', 1)[1].split()[19]) + except (OSError, ValueError, IndexError): + return None + + +def close_ui(proc_root, processes): + """Terminate the given client UI processes; return the pids signalled. + + Each pid is rechecked, including its start time, before every signal: a + process that exited, a reused pid and a process that is no longer a client + UI are all left alone. + """ + closed = set() + for sig, grace in ((signal.SIGTERM, TERM_GRACE), (signal.SIGKILL, KILL_GRACE)): + for pid, start_ticks in processes.items(): + if ui_identity(proc_root, pid) != start_ticks: + continue + try: + os.kill(int(pid), sig) + closed.add(pid) + except OSError: + continue + time.sleep(grace) + return closed + + +def close_decision(state, closes, last_close, now): + """Return 'close', 'limit' or None for one observation.""" + if not state['game'] or not state['ui']: + return None + if closes >= MAX_CLOSES: + return 'limit' + if now - last_close < COOLDOWN_SECONDS: + return None + return 'close' + + +def supervise(proc_root=Path('/proc'), prefix=None, once=False, dry_run=False, out=sys.stdout): + """Watch for a match and close the client UI while it runs.""" + prefix = prefix or target_prefix() + if prefix is None: + print('close-league-client-in-game: unusable DPREFIX: no action', file=out, flush=True) + return 0 + print('close-league-client-in-game: watching %s for a running match; ' + 'opt out with RCLOL_CLOSE_CLIENT_IN_GAME=0' % prefix, file=out, flush=True) + in_game = False + closes = 0 + last_close = 0.0 + limit_reported = False + idle_since = None + while True: + state = match_state(proc_root, prefix) + now = time.monotonic() + if state is None: + print('close-league-client-in-game: unreadable /proc state: no action', + file=out, flush=True) + else: + if state['game'] and not in_game: + in_game = True + closes = 0 + last_close = 0.0 + limit_reported = False + print('close-league-client-in-game: a match started', file=out, flush=True) + elif not state['game'] and in_game: + in_game = False + print('close-league-client-in-game: the match ended; the client can reopen', + file=out, flush=True) + decision = close_decision(state, closes, last_close, now) + if decision == 'limit': + if not limit_reported: + print('close-league-client-in-game: the client reopened %d times in one ' + 'match: no further action' % closes, file=out, flush=True) + limit_reported = True + elif decision == 'close': + pids = sorted(state['ui'], key=int) + if dry_run: + print('close-league-client-in-game: would close the client UI (%d ' + 'processes: %s)' % (len(pids), ','.join(pids)), file=out, flush=True) + else: + closed = close_ui(proc_root, state['ui']) + if closed: + closes += 1 + last_close = time.monotonic() + print('close-league-client-in-game: closed the client UI for the ' + 'running match (%d processes: %s)' + % (len(closed), ','.join(sorted(closed, key=int))), + file=out, flush=True) + idle = not state['game'] and not state['ui'] + if idle and idle_since is None: + idle_since = now + elif not idle: + idle_since = None + if once: + if state is not None: + print('close-league-client-in-game: one observation: match=%s client_ui=%d' + % ('running' if state['game'] else 'absent', len(state['ui'])), + file=out, flush=True) + return 0 + if idle_since is not None and time.monotonic() - idle_since > IDLE_SECONDS: + print('close-league-client-in-game: no client or match for %ds: leaving' + % IDLE_SECONDS, file=out, flush=True) + return 0 + time.sleep(POLL_SECONDS) + return 0 + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--once', action='store_true', + help='one observation and exit (diagnostics)') + parser.add_argument('--dry-run', action='store_true', + help='report what it would close, without signalling anything') + parser.add_argument('--detach', action='store_true', + help='leave the launcher session so a terminal hang-up cannot end it') + args = parser.parse_args() + if args.detach: + try: + os.setsid() + except OSError: + pass + return supervise(once=args.once, dry_run=args.dry_run) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 5fa75a5..83cf108 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -12,6 +12,13 @@ runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then python3 "$runner_dir/recover-hidden-riot-client.py" fi +# Opt-in: close the League client UI while a match runs. It keeps the CPU for the game +# and leaves the client the backend starts when the match ends as the only CEF instance; +# a second instance on the same profile never paints its window, which is the black +# window seen with Riot's own "Close client during game" option. Off by default. +if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then + python3 "$runner_dir/close-league-client-in-game.py" --detach & +fi # The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN # AAAA lookups are suppressed; set this to 0 to retain the upstream resolver. export RCLOL_CDN_IPV4_ONLY="${RCLOL_CDN_IPV4_ONLY:-1}" From efc92dfb04ebe53f8dde3aed01e566a063e2ef94 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Sun, 18 Oct 2026 11:53:00 -0600 Subject: [PATCH 099/160] fix(launcher): keep Mesa's shader cache on the host Mesa derives its shader disk cache from $HOME. Inside the guest that is /Users/, which does not exist on the host filesystem, so Mesa could not create the cache and disabled it, once per process: Failed to create /Users/ for shader cache (Permission denied)---disabling One launcher log contained 212 of those lines, and no shader compiled by one process was reused by another process or by the next start: the client and the game recompiled every shader on every launch, spending CPU at startup for no gain. Mesa is host code loaded into the guest and reads MESA_SHADER_CACHE_DIR verbatim, so point it at a host directory; Mesa creates mesa_shader_cache/ (client and game shaders) and radv_builtin_shaders/ below it. A non-empty value from the caller is preserved, and a directory that cannot be created leaves the previous behaviour instead of ending the launch. Observed on a Radeon RX 550 with radv: no shader-cache failure after the change, and 434 files / 5.6 MB written during one client start, still growing while the client ran. The cache is reused from the second start on, which lowers the client's CPU use while it starts; the startup-time gain itself has not been measured separately. --- docs/TROUBLESHOOTING.md | 28 ++++++++++++++++++++++++++++ tools/run-riot-client.sh | 10 ++++++++++ 2 files changed, 38 insertions(+) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a6da2e1..5057461 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -57,6 +57,34 @@ The old kernel library returned success for Darwin allocator free/reuse hints wi Build/install the kernel library using [the incremental update steps](SETUP.md#updating-an-existing-runtime), then perform the manual restart when finished playing. Linux 5.14+ is required. Do not kill client helpers as an attempted memory optimization during play. +## Shaders are recompiled on every start + +Mesa resolves its shader disk cache below `$HOME`. Inside the guest, `$HOME` is +`/Users/`, which does not exist on the host filesystem, so Mesa cannot create the +cache and disables it, once per process: + +``` +Failed to create /Users/ for shader cache (Permission denied)---disabling +``` + +One launcher log without the fix contained 212 of these lines, and no shader compiled by +one process was reused by another process or by the next start: the client and the game +recompiled every shader, on every launch. Mesa is host code loaded into the guest and +reads `MESA_SHADER_CACHE_DIR` verbatim, so the launcher now passes a host directory: + +```sh +MESA_SHADER_CACHE_DIR=${XDG_CACHE_HOME:-${HOME:-$prefix_dir}/.cache}/darling-lol # launcher default +``` + +Mesa creates `mesa_shader_cache/` (client and game shaders) and `radv_builtin_shaders/` +below it. With the change, one client start logged no shader-cache failure and wrote 434 +files / 5.6 MB, still growing while the client ran (Radeon RX 550, radv). Set +`MESA_SHADER_CACHE_DIR` yourself to move the cache, for example to another disk: the +launcher preserves a non-empty value, and a directory that cannot be created leaves the +previous behaviour. Reusing the cache removes the repeated compilation; the startup-time +gain has not been measured separately. Cache entries are keyed by driver and GPU, so a +driver update recompiles once and caches again. + ## Installer exits, cannot write temporary files, or Install appears idle Use `sh scripts/installer.sh euw` (or your supported region). The first-time launcher must omit the installed client's `--app-root` and pass `--skip-to-install`; the normal wrapper now does this. It also selects `league_of_legends` so the product installation UI appears after login. diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 5fa75a5..f3213dc 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -92,6 +92,16 @@ fi if [ -n "${RCLOL_REFRESH_HZ-}" ]; then export RCLOL_REFRESH_HZ; fi # Vulkan WSI & Mesa optimizations export MESA_NO_ERROR="${MESA_NO_ERROR:-1}" +# Mesa resolves its shader disk cache below $HOME, which inside the guest is +# /Users/: that path does not exist on the host filesystem, so every process logs +# "Failed to create /Users for shader cache (Permission denied)---disabling" and +# recompiles every shader on start (212 such lines in one launcher log). The host Mesa +# loaded into the guest reads MESA_SHADER_CACHE_DIR verbatim, so point it at a host +# directory; Mesa creates mesa_shader_cache/ and radv_builtin_shaders/ below it. +cache_root=${XDG_CACHE_HOME:-${HOME:-$prefix_dir}/.cache}/darling-lol +MESA_SHADER_CACHE_DIR="${MESA_SHADER_CACHE_DIR:=$cache_root}" +export MESA_SHADER_CACHE_DIR +mkdir -p "$MESA_SHADER_CACHE_DIR" 2>/dev/null || true # NVIDIA proprietary driver: limit pre-rendered frames. export __GL_MaxFramesAllowed="${__GL_MaxFramesAllowed:-1}" # Threaded optimizations stay off: with them, NVIDIA 580 (the last branch for From 957b960985a5eb29d591d79dd955ddf0e445d7ff Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:48:43 +0200 Subject: [PATCH 100/160] close-league-client-in-game: start the idle clock only after a client was seen A long patch or login can keep the League client closed for more than ten minutes after launch; the helper then left before the first match. Co-Authored-By: Claude Opus 5.5 --- docs/TROUBLESHOOTING.md | 3 ++- tests/test_close_league_client_in_game.py | 13 +++++++++++++ tools/close-league-client-in-game.py | 23 +++++++++++++++++------ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 778c1bc..0d2bdb0 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -156,7 +156,8 @@ backend and the game untouched, and takes no action when ownership, the environment or the stat fields cannot be read. The client UI is recognised by the name of its executable, not by the command line: `LeagueCrashHandler` processes carry the client UI name in their arguments only, so they are left alone. It -leaves by itself after ten minutes without a client or a match. Diagnose it in one +leaves by itself ten minutes after the last client or match is gone (the clock does +not run before the first client appears, so a long patch or login is fine). Diagnose it in one observation, without waiting for a match: ```sh diff --git a/tests/test_close_league_client_in_game.py b/tests/test_close_league_client_in_game.py index 0d774cb..aeddd38 100644 --- a/tests/test_close_league_client_in_game.py +++ b/tests/test_close_league_client_in_game.py @@ -194,6 +194,19 @@ class InGameCloseTests(unittest.TestCase): in_game.close_ui(self.proc, {'123': identity, '124': 1}) self.assertEqual(kill.call_args_list, []) + def test_idle_clock_starts_only_after_a_client_or_match(self): + empty = {'game': False, 'ui': {}} + client = {'game': False, 'ui': {'10': 5}} + match = {'game': True, 'ui': {}} + # Before any client (long patch or login): never idle, however long it takes. + self.assertEqual(in_game.update_idle(empty, False, None, 5000.0), (False, None)) + # A client or a match marks the session as seen and keeps the clock stopped. + self.assertEqual(in_game.update_idle(client, False, None, 10.0), (True, None)) + self.assertEqual(in_game.update_idle(match, True, 50.0, 60.0), (True, None)) + # Afterwards the clock starts once and keeps its start time. + self.assertEqual(in_game.update_idle(empty, True, None, 70.0), (True, 70.0)) + self.assertEqual(in_game.update_idle(empty, True, 70.0, 900.0), (True, 70.0)) + def test_close_decision_covers_limits_and_cooldown(self): state = {'game': True, 'ui': {'123': 1}} self.assertEqual(in_game.close_decision(state, 0, 0.0, 100.0), 'close') diff --git a/tools/close-league-client-in-game.py b/tools/close-league-client-in-game.py index bdf4508..3a7b008 100755 --- a/tools/close-league-client-in-game.py +++ b/tools/close-league-client-in-game.py @@ -49,7 +49,7 @@ KILL_GRACE = 2.0 POLL_SECONDS = 5.0 COOLDOWN_SECONDS = 30.0 # minimum between two closes in the same match MAX_CLOSES = 3 # give up on a client that keeps reopening during a match -IDLE_SECONDS = 600.0 # leave after this long without a client or a game +IDLE_SECONDS = 600.0 # leave after this long without a client or a game, once one was seen def target_prefix(): @@ -165,6 +165,20 @@ def close_decision(state, closes, last_close, now): return 'close' +def update_idle(state, seen, idle_since, now): + """Return (seen, idle_since) after one observation. + + The idle clock only starts once a client UI or a match has been seen: a long + patch or login can keep the League client closed for more than IDLE_SECONDS + after launch, and leaving then would skip the match that follows. + """ + active = state['game'] or bool(state['ui']) + seen = seen or active + if active or not seen: + return seen, None + return seen, now if idle_since is None else idle_since + + def supervise(proc_root=Path('/proc'), prefix=None, once=False, dry_run=False, out=sys.stdout): """Watch for a match and close the client UI while it runs.""" prefix = prefix or target_prefix() @@ -177,6 +191,7 @@ def supervise(proc_root=Path('/proc'), prefix=None, once=False, dry_run=False, o closes = 0 last_close = 0.0 limit_reported = False + seen = False idle_since = None while True: state = match_state(proc_root, prefix) @@ -215,11 +230,7 @@ def supervise(proc_root=Path('/proc'), prefix=None, once=False, dry_run=False, o 'running match (%d processes: %s)' % (len(closed), ','.join(sorted(closed, key=int))), file=out, flush=True) - idle = not state['game'] and not state['ui'] - if idle and idle_since is None: - idle_since = now - elif not idle: - idle_since = None + seen, idle_since = update_idle(state, seen, idle_since, now) if once: if state is not None: print('close-league-client-in-game: one observation: match=%s client_ui=%d' From 5c8226f3e25e52250e92c1549f34de84c7b21f24 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:55:11 +0200 Subject: [PATCH 101/160] Pass microphone input through CoreAudio and AVFoundation Voice chat (Vivox) saw no capture device and Chromium was told mic access was denied. Fixes for issue #75: - AVCaptureDevice: audio is Authorized when CoreAudio has a default input device; requestAccess answers asynchronously. Video stays Denied. - PulseAudio input device reports 2 channels on input scope, 0 on output (was copied from the output device). - PulseAudio input stream: real sample/host timestamps, one device buffer per IOProc call, holes as silence, IOProc errors no longer cork the stream, record buffer attrs sized to the device buffer. - AUHAL: two elements per scope like macOS, non-blocking AudioUnitRender on bus 1 with planar support, device records in the bus 1 format, input-only units route CurrentDevice/BufferFrameSize to the input device, input start errors handled. - New offline check tools/riot-audio-input-check.cpp. Co-Authored-By: Claude Opus 5.5 --- patches/darling.patch | 578 ++++++++++++++++++++++++++++-- tools/build-riot-audio-runtime.py | 25 +- tools/riot-audio-input-check.cpp | 150 ++++++++ 3 files changed, 711 insertions(+), 42 deletions(-) create mode 100644 tools/riot-audio-input-check.cpp diff --git a/patches/darling.patch b/patches/darling.patch index 5194fda..5914a9f 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -1642,10 +1642,40 @@ index 0425ae97f..fdf08d2dc 100644 #endif /* AUDIOHARDWARESTREAMPA_H */ - diff --git a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.cpp b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.cpp -index d6825f1d5..739b70011 100644 +index d6825f1d5..fd9efc988 100644 --- a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.cpp +++ b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.cpp -@@ -25,6 +25,7 @@ along with Darling. If not, see . +@@ -21,10 +21,37 @@ along with Darling. If not, see . + #include "AudioHardwareImplPA.h" + #include + #include ++#include ++#include ++#include ++#include "../../AudioTrace.h" ++ ++static const mach_timebase_info_data_t& timebase() { ++ static mach_timebase_info_data_t tb = [] { ++ mach_timebase_info_data_t t = {0, 0}; ++ mach_timebase_info(&t); ++ if (!t.numer || !t.denom) t.numer = t.denom = 1; ++ return t; ++ }(); ++ return tb; ++} ++static uint64_t hostToNs(uint64_t host) { ++ return uint64_t((unsigned __int128) host * timebase().numer / timebase().denom); ++} ++static uint64_t nsToHost(uint64_t ns) { ++ return uint64_t((unsigned __int128) ns * timebase().denom / timebase().numer); ++} ++static unsigned g_readEvents = 0; ++static bool readShouldTrace() { ++ if (!rclolAudioTraceEnabled()) return false; ++ unsigned n = g_readEvents++; ++ return n < 40 || n % 1000 == 0; ++} + AudioHardwareStreamPAInput::AudioHardwareStreamPAInput(AudioHardwareImplPA* hw, AudioDeviceIOProc callback, void* clientData) : AudioHardwareStreamPA(hw, callback, clientData) { @@ -1653,7 +1683,7 @@ index d6825f1d5..739b70011 100644 // m_dump.open("/tmp/dump.raw", std::ios_base::out | std::ios_base::binary); } -@@ -32,7 +33,6 @@ void AudioHardwareStreamPAInput::paStreamReadCB(pa_stream* s, size_t length, voi +@@ -32,25 +59,26 @@ void AudioHardwareStreamPAInput::paStreamReadCB(pa_stream* s, size_t length, voi { AudioHardwareStreamPAInput* This = static_cast(self); @@ -1661,14 +1691,166 @@ index d6825f1d5..739b70011 100644 if (!This->m_running) return; -@@ -74,6 +74,7 @@ void AudioHardwareStreamPAInput::paStreamReadCB(pa_stream* s, size_t length, voi - // std::cout << "AudioHardwareStreamPAInput::paStreamReadCB(): bytes=" << nbytes << std::endl; - OSStatus status = This->m_callback(This->m_hw->id(), &fake, abl, &fake, nullptr, nullptr, This->m_clientData); -+ if (!This->m_running) return; - pa_stream_drop(This->m_stream); +- AudioTimeStamp fake = {0}; ++ const pa_sample_spec* spec = pa_stream_get_sample_spec(This->m_stream); ++ const size_t frameBytes = spec ? pa_frame_size(spec) : 0; ++ const double rate = spec ? double(spec->rate) : 0; ++ if (!frameBytes || rate <= 0) ++ return; ++ + AudioBufferList* abl = static_cast(alloca(sizeof(AudioBufferList) + sizeof(AudioBuffer))); +- std::vector hole; - if (status != noErr) + // std::cout << "AudioHardwareStreamPAInput::paStreamReadCB()\n"; + + while (true) + { +- abl->mNumberBuffers = 1; +- abl->mBuffers[0].mNumberChannels = 2; +- +- size_t nbytes; ++ const void* data = nullptr; ++ size_t nbytes = 0; + +- int rv = pa_stream_peek(This->m_stream, (const void**) &abl->mBuffers[0].mData, &nbytes); ++ int rv = pa_stream_peek(This->m_stream, &data, &nbytes); + + if (rv < 0 || !nbytes) + { +@@ -58,29 +86,82 @@ void AudioHardwareStreamPAInput::paStreamReadCB(pa_stream* s, size_t length, voi + break; + } + +- if (nbytes && !abl->mBuffers[0].mData) ++ // The fragment at the read index was captured `latency` ago: source latency plus ++ // everything still unread. Without timing info yet, count only the unread data. ++ pa_usec_t usec = 0; ++ int negative = 0; ++ uint64_t latencyNs; ++ if (pa_stream_get_latency(This->m_stream, &usec, &negative) == 0) ++ latencyNs = negative ? 0 : uint64_t(usec) * 1000; ++ else + { +- // "If there is a hole, data will be NULL and nbytes will contain the length of the hole." +- hole.resize(nbytes); +- abl->mBuffers[0].mData = hole.data(); ++ size_t readable = pa_stream_readable_size(This->m_stream); ++ latencyNs = uint64_t(pa_bytes_to_usec(std::max(readable, nbytes), spec)) * 1000; + } +- abl->mBuffers[0].mDataByteSize = nbytes; +- +- // This->m_dump.write((char*) abl->mBuffers[0].mData, nbytes); +- // This->m_dump.flush(); +- if (This->m_convertSignedUnsigned) +- This->transformSignedUnsigned(abl); ++ const uint64_t nowHost = mach_absolute_time(); + +- // std::cout << "AudioHardwareStreamPAInput::paStreamReadCB(): bytes=" << nbytes << std::endl; +- OSStatus status = This->m_callback(This->m_hw->id(), &fake, abl, &fake, nullptr, nullptr, This->m_clientData); +- +- pa_stream_drop(This->m_stream); ++ const bool hole = !data; ++ if (hole || This->m_convertSignedUnsigned) ++ { ++ // "If there is a hole, data will be NULL and nbytes will contain the length of the hole." ++ // Holes are delivered as silence so the sample clock keeps counting real time. ++ // The peeked memory belongs to PulseAudio, so convert a private copy. ++ This->m_scratch.resize(nbytes); ++ if (data) ++ memcpy(This->m_scratch.data(), data, nbytes); ++ else ++ memset(This->m_scratch.data(), spec->format == PA_SAMPLE_U8 ? 0x80 : 0, nbytes); ++ data = This->m_scratch.data(); ++ } + +- if (status != noErr) ++ // Like a real HAL, hand the IOProc at most one device buffer per call. ++ const size_t block = std::max(frameBytes, This->m_blockBytes); ++ for (size_t offset = 0; offset + frameBytes <= nbytes; ) + { +- pa_stream_cork(This->m_stream, true, [](pa_stream*, int, void*) {}, nullptr); +- break; ++ size_t piece = std::min(block, nbytes - offset); ++ piece -= piece % frameBytes; ++ ++ abl->mNumberBuffers = 1; ++ abl->mBuffers[0].mNumberChannels = spec->channels; ++ abl->mBuffers[0].mData = const_cast(static_cast(data) + offset); ++ abl->mBuffers[0].mDataByteSize = piece; ++ ++ if (This->m_convertSignedUnsigned) ++ This->transformSignedUnsigned(abl); ++ ++ const uint64_t offsetNs = uint64_t(double(offset / frameBytes) * 1e9 / rate); ++ const uint64_t agoNs = latencyNs > offsetNs ? latencyNs - offsetNs : 0; ++ uint64_t inputHost = nowHost - std::min(nowHost, nsToHost(agoNs)); ++ // Keep host time monotonic even when PA's latency estimate jumps. ++ if (inputHost <= This->m_lastInputHost) ++ inputHost = This->m_lastInputHost + 1; ++ This->m_lastInputHost = inputHost; ++ ++ AudioTimeStamp input = {0}; ++ input.mSampleTime = double(This->m_frames); ++ input.mHostTime = inputHost; ++ input.mRateScalar = 1.0; ++ input.mFlags = kAudioTimeStampHostTimeValid | kAudioTimeStampSampleTimeValid | kAudioTimeStampRateScalarValid; ++ ++ AudioTimeStamp now = input; ++ now.mHostTime = std::max(nowHost, inputHost); ++ now.mSampleTime = input.mSampleTime + double(hostToNs(now.mHostTime - inputHost)) * rate / 1e9; ++ ++ // This->m_dump.write((char*) abl->mBuffers[0].mData, nbytes); ++ // This->m_dump.flush(); ++ // A real HAL ignores the IOProc's result; one failed callback must not ++ // silence the microphone for the rest of the session. ++ OSStatus status = This->m_callback(This->m_hw->id(), &now, abl, &input, nullptr, nullptr, This->m_clientData); ++ if (readShouldTrace()) ++ rclolAudioTrace("PA_IN iop bytes=%zu frames=%llu latencyMs=%.2f hole=%d status=%d\n", piece, ++ (unsigned long long) This->m_frames, double(latencyNs) / 1e6, hole, (int) status); ++ ++ if (!This->m_running) return; ++ This->m_frames += piece / frameBytes; ++ offset += piece; + } ++ ++ pa_stream_drop(This->m_stream); + } + } + +@@ -88,10 +169,19 @@ void AudioHardwareStreamPAInput::start() + { + struct pa_buffer_attr battr; + +- battr.fragsize = uint32_t(-1); +- battr.maxlength = uint32_t(m_bufferSize); ++ // Record streams only use maxlength and fragsize. Ask for fragments of one device buffer ++ // (kAudioDevicePropertyBufferFrameSize) for voice latency, and bound the server-side ++ // backlog so a stalled client can't accumulate seconds of delay. ++ const uint32_t bpf = m_hw->asbd().mBytesPerFrame; ++ uint32_t block = bpf ? (m_hw->bufferSize() / bpf) * bpf : m_bufferSize; ++ if (!block) ++ block = m_bufferSize; ++ m_blockBytes = block; ++ ++ battr.fragsize = block; ++ battr.maxlength = block * 16; + battr.minreq = uint32_t(-1); +- battr.prebuf = battr.maxlength; ++ battr.prebuf = uint32_t(-1); + battr.tlength = uint32_t(-1); + + // std::cout << "AudioHardwareStreamPAInput::start()\n"; +@@ -101,6 +191,9 @@ void AudioHardwareStreamPAInput::start() + int rv = pa_stream_connect_record(m_stream, nullptr, &battr, + pa_stream_flags_t(PA_STREAM_INTERPOLATE_TIMING | PA_STREAM_ADJUST_LATENCY | PA_STREAM_AUTO_TIMING_UPDATE) + ); ++ if (rclolAudioTraceEnabled()) ++ rclolAudioTrace("PA_IN start block=%u rate=%g channels=%u rv=%d\n", block, m_hw->asbd().mSampleRate, ++ m_hw->asbd().mChannelsPerFrame, rv); + + // std::cout << "AudioHardwareStreamPAInput::start() rv = " << rv << std::endl; + AudioHardwareStreamPA::start(); diff --git a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.cpp b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.cpp index 278630f02..54b463c2f 100644 --- a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.cpp @@ -2390,7 +2572,7 @@ index ff83d1e15..1e86f9e99 100644 bool m_suspended = true; }; diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp -index fe4d1eaa1..9321edcdf 100644 +index fe4d1eaa1..05088f87f 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp @@ -1,3 +1,4 @@ @@ -2414,7 +2596,20 @@ index fe4d1eaa1..9321edcdf 100644 #pragma GCC visibility push(default) AUDIOCOMPONENT_ENTRY(AUOutputBaseFactory, AUHAL); -@@ -42,6 +52,37 @@ AUHAL::~AUHAL() +@@ -29,8 +39,11 @@ enum { + kInputBus + }; + ++// Like macOS: element 0 is the output (app -> device) side, element 1 the input ++// (device -> app) side, on both scopes. Without output element 1 the app's ++// recording format and AudioUnitRender(bus 1) had nowhere to go. + AUHAL::AUHAL(AudioComponentInstance inInstance, bool supportRecording) +-: AUBase(inInstance, 1, supportRecording ? 1 : 0) ++: AUBase(inInstance, supportRecording ? 2 : 1, supportRecording ? 2 : 1) + { + UInt32 propSize = sizeof(AudioDeviceID); + AudioHardwareGetProperty(kAudioHardwarePropertyDefaultOutputDevice, &propSize, &m_outputDevice); +@@ -42,6 +55,37 @@ AUHAL::~AUHAL() Stop(); } @@ -2452,7 +2647,7 @@ index fe4d1eaa1..9321edcdf 100644 bool AUHAL::CanScheduleParameters() const { return true; -@@ -49,7 +90,7 @@ bool AUHAL::CanScheduleParameters() const +@@ -49,7 +93,7 @@ bool AUHAL::CanScheduleParameters() const bool AUHAL::StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) { @@ -2461,7 +2656,59 @@ index fe4d1eaa1..9321edcdf 100644 } OSStatus AUHAL::Version() -@@ -76,19 +117,26 @@ OSStatus AUHAL::Render(AudioUnitRenderActionFlags& ioActionFlags, const AudioTim +@@ -57,45 +101,112 @@ OSStatus AUHAL::Version() + return 1; + } + +-// Provide data from the microphone +-OSStatus AUHAL::Render(AudioUnitRenderActionFlags& ioActionFlags, const AudioTimeStamp& inTimeStamp, UInt32 inNumberFrames) ++// Provide data from the microphone: AudioUnitRender(bus 1), normally called from the input callback. ++// Never blocks; frames not recorded yet are silence. Bypasses NeedsToRender(), as a real AUHAL ++// renders whatever timestamp the client passes. ++OSStatus AUHAL::RenderBus(AudioUnitRenderActionFlags& ioActionFlags, const AudioTimeStamp& inTimeStamp, UInt32 inBusNumber, UInt32 inNumberFrames) + { +- std::unique_lock lk(m_dataAvailableMutex); ++ if (inBusNumber != kInputBus || !m_enableInput) ++ return kAudioUnitErr_NoConnection; + +- m_dataAvailableCV.wait(lk, [=]{ return m_dataAvailable; }); ++ const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Output, kInputBus); ++ const UInt32 sampleBytes = desc.mBitsPerChannel / 8; ++ const UInt32 frameBytes = desc.mChannelsPerFrame * sampleBytes; ++ if (!sampleBytes || !frameBytes) ++ return kAudioUnitErr_FormatNotSupported; ++ const bool planar = desc.mFormatFlags & kAudioFormatFlagIsNonInterleaved; + + AudioBufferList& abl = GetOutput(kInputBus)->GetBufferList(); ++ std::lock_guard lk(m_dataAvailableMutex); ++ const UInt32 frames = std::min(inNumberFrames, m_bufferUsed / frameBytes); ++ const uint8_t* source = m_buffer.get(); + +- // TODO: Prepare for non-interleaved audio +- UInt32 howMuch = std::min(abl.mBuffers[0].mDataByteSize, m_bufferUsed); +- memcpy(abl.mBuffers[0].mData, m_buffer.get(), howMuch); +- abl.mBuffers[0].mDataByteSize = howMuch; ++ if (planar) ++ { ++ for (UInt32 c = 0; c < abl.mNumberBuffers && c < desc.mChannelsPerFrame; c++) ++ { ++ uint8_t* destination = static_cast(abl.mBuffers[c].mData); ++ for (UInt32 f = 0; f < frames; f++) ++ memcpy(destination + f * sampleBytes, source + f * frameBytes + c * sampleBytes, sampleBytes); ++ memset(destination + frames * sampleBytes, 0, (inNumberFrames - frames) * sampleBytes); ++ abl.mBuffers[c].mDataByteSize = inNumberFrames * sampleBytes; ++ } ++ } ++ else ++ { ++ uint8_t* destination = static_cast(abl.mBuffers[0].mData); ++ memcpy(destination, source, frames * frameBytes); ++ memset(destination + frames * frameBytes, 0, (inNumberFrames - frames) * frameBytes); ++ abl.mBuffers[0].mDataByteSize = inNumberFrames * frameBytes; ++ } + + return noErr; + } OSStatus AUHAL::Start() { @@ -2491,7 +2738,53 @@ index fe4d1eaa1..9321edcdf 100644 } if (m_enableInput) { -@@ -111,20 +159,42 @@ OSStatus AUHAL::Stop() +- AudioDeviceCreateIOProcID(m_inputDevice, recordCallback, this, &m_inputProcID); +- const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Output, kInputBus); +- AudioDeviceSetProperty(m_inputDevice, nullptr, 0, true, kAudioDevicePropertyStreamFormat, sizeof(AudioStreamBasicDescription), &desc); +- AudioDeviceStart(m_inputDevice, m_inputProcID); ++ // The device records directly in the client's bus 1 format (PulseAudio converts rate, ++ // channels and sample type); only planar layouts are converted, in RenderBus(). ++ OSStatus status = AudioDeviceCreateIOProcID(m_inputDevice, recordCallback, this, &m_inputProcID); ++ if (!status) ++ { ++ const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Output, kInputBus); ++ AudioStreamBasicDescription hardware = desc; ++ hardware.mFormatFlags &= ~kAudioFormatFlagIsNonInterleaved; ++ hardware.mBytesPerFrame = hardware.mChannelsPerFrame * hardware.mBitsPerChannel / 8; ++ hardware.mBytesPerPacket = hardware.mBytesPerFrame; ++ // The device keeps its buffer size in bytes; keep the frame count across the format change. ++ AudioObjectPropertyAddress p = {kAudioDevicePropertyBufferFrameSize, kAudioObjectPropertyScopeInput, kAudioObjectPropertyElementMaster}; ++ UInt32 frames = 0, size = sizeof(frames); ++ const bool haveFrames = AudioObjectGetPropertyData(m_inputDevice, &p, 0, nullptr, &size, &frames) == noErr; ++ status = AudioDeviceSetProperty(m_inputDevice, nullptr, 0, true, kAudioDevicePropertyStreamFormat, sizeof(hardware), &hardware); ++ if (!status && haveFrames) ++ AudioObjectSetPropertyData(m_inputDevice, &p, 0, nullptr, sizeof(frames), &frames); ++ { ++ std::lock_guard lk(m_dataAvailableMutex); ++ m_bufferUsed = 0; ++ } ++ if (!status) ++ status = AudioDeviceStart(m_inputDevice, m_inputProcID); ++ if (status) ++ { ++ AudioDeviceDestroyIOProcID(m_inputDevice, m_inputProcID); ++ m_inputProcID = 0; ++ } ++ } ++ if (status) ++ { ++ if (m_outputProcID) ++ { ++ AudioDeviceStop(m_outputDevice, m_outputProcID); ++ AudioDeviceDestroyIOProcID(m_outputDevice, m_outputProcID); ++ m_outputProcID = 0; ++ } ++ return status; ++ } + } + + m_running = m_enableOutput || m_enableInput; +@@ -111,20 +222,49 @@ OSStatus AUHAL::Stop() { AudioDeviceStop(m_outputDevice, m_outputProcID); AudioDeviceDestroyIOProcID(m_outputDevice, m_outputProcID); @@ -2529,13 +2822,36 @@ index fe4d1eaa1..9321edcdf 100644 switch (inID) { + case kAudioDevicePropertyBufferFrameSize: { ++ // An input-only unit (EnableIO output=0) sizes its input device's IO cycle. + AudioObjectPropertyAddress p={inID,kAudioObjectPropertyScopeOutput,kAudioObjectPropertyElementMaster}; -+ return AudioObjectSetPropertyData(m_outputDevice,&p,0,nullptr,inDataSize,inData); ++ OSStatus status=noErr; ++ if(m_enableOutput || !m_enableInput) status=AudioObjectSetPropertyData(m_outputDevice,&p,0,nullptr,inDataSize,inData); ++ if(!status && m_enableInput) { ++ p.mScope=kAudioObjectPropertyScopeInput; ++ status=AudioObjectSetPropertyData(m_inputDevice,&p,0,nullptr,inDataSize,inData); ++ } ++ return status; + } case kAudioOutputUnitProperty_SetInputCallback: { ca_require(inDataSize == sizeof(AURenderCallbackStruct), InvalidPropertyValue); -@@ -176,15 +246,23 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -161,7 +301,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au + ca_require(inDataSize == sizeof(AudioDeviceID), InvalidPropertyValue); + const AudioDeviceID* dev = static_cast(inData); + +- if (inElement == kOutputBus) ++ // macOS AUHAL has one current device, normally set on element 0; for an ++ // input-only unit (EnableIO input=1, output=0) that is the capture device. ++ if (inElement == kOutputBus && m_enableInput && !m_enableOutput) ++ { ++ m_inputDevice = *dev; ++ PropertyChanged(inID, inScope, inElement); ++ } ++ else if (inElement == kOutputBus) + { + m_outputDevice = *dev; + PropertyChanged(inID, inScope, inElement); +@@ -176,20 +323,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au return noErr; } } @@ -2560,7 +2876,13 @@ index fe4d1eaa1..9321edcdf 100644 case kAudioOutputUnitProperty_SetInputCallback: { outDataSize = sizeof(AURenderCallbackStruct); -@@ -210,13 +288,22 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope + outWritable = true; +- break; ++ return noErr; + } + case kAudioOutputUnitProperty_EnableIO: + { +@@ -210,13 +365,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope return noErr; } } @@ -2577,14 +2899,15 @@ index fe4d1eaa1..9321edcdf 100644 switch (inID) { + case kAudioDevicePropertyBufferFrameSize: { -+ AudioObjectPropertyAddress p={inID,kAudioObjectPropertyScopeOutput,kAudioObjectPropertyElementMaster}; ++ const bool input=m_enableInput && !m_enableOutput; ++ AudioObjectPropertyAddress p={inID,input?kAudioObjectPropertyScopeInput:kAudioObjectPropertyScopeOutput,kAudioObjectPropertyElementMaster}; + UInt32 size=sizeof(UInt32); -+ return AudioObjectGetPropertyData(m_outputDevice,&p,0,nullptr,&size,outData); ++ return AudioObjectGetPropertyData(input?m_inputDevice:m_outputDevice,&p,0,nullptr,&size,outData); + } case kAudioOutputUnitProperty_SetInputCallback: { memcpy(outData, &m_outputAvailableCb, sizeof(m_outputAvailableCb)); -@@ -228,12 +315,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -228,12 +393,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au if (inElement == kOutputBus) { UInt32 value = m_enableOutput; @@ -2599,7 +2922,20 @@ index fe4d1eaa1..9321edcdf 100644 } else return kAudioUnitErr_InvalidElement; -@@ -277,9 +364,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, +@@ -241,7 +406,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au + } + case kAudioOutputUnitProperty_CurrentDevice: + { +- if (inElement == kOutputBus) ++ if (inElement == kOutputBus && m_enableInput && !m_enableOutput) ++ { ++ memcpy(outData, &m_inputDevice, sizeof(m_inputDevice)); ++ } ++ else if (inElement == kOutputBus) + { + memcpy(outData, &m_outputDevice, sizeof(m_outputDevice)); + } +@@ -277,9 +446,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, return This->doRecord(inNow, inInputData, inInputTime); } @@ -2689,7 +3025,7 @@ index fe4d1eaa1..9321edcdf 100644 if (!HasInput(0)) { // std::cerr << "No connection\n"; -@@ -290,9 +457,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -290,9 +539,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp AudioUnitRenderActionFlags flags = kAudioUnitRenderAction_PreRender; const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Input, kOutputBus); @@ -2768,8 +3104,75 @@ index fe4d1eaa1..9321edcdf 100644 // std::cout << "Pull result: " << result << std::endl; // std::cout << "Bytes: " << outOutputData->mBuffers[0].mDataByteSize << std::endl; // m_auhalData.write((char*) outOutputData->mBuffers[0].mData, outOutputData->mBuffers[0].mDataByteSize); +@@ -303,30 +618,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp + + OSStatus AUHAL::doRecord(const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime) + { +- // TODO: Prepare for non-interleaved audio +- std::unique_lock lk(m_dataAvailableMutex); +- +- if (m_bufferSize < inInputData->mBuffers[0].mDataByteSize) +- { +- m_buffer.reset(new uint8_t[inInputData->mBuffers[0].mDataByteSize]); +- m_bufferSize = inInputData->mBuffers[0].mDataByteSize; +- } +- +- m_bufferUsed = inInputData->mBuffers[0].mDataByteSize; +- memcpy(m_buffer.get(), inInputData->mBuffers[0].mData, m_bufferUsed); +- +- m_dataAvailable = true; ++ // The device records interleaved data in the bus 1 output format (see Start()). ++ const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Output, kInputBus); ++ const UInt32 frameBytes = desc.mChannelsPerFrame * (desc.mBitsPerChannel / 8); ++ if (!inInputData || !inInputData->mNumberBuffers || !frameBytes) ++ return noErr; + +- lk.unlock(); +- m_dataAvailableCV.notify_one(); ++ const uint8_t* source = static_cast(inInputData->mBuffers[0].mData); ++ const UInt32 totalFrames = inInputData->mBuffers[0].mDataByteSize / frameBytes; ++ // AudioUnitRender refuses more than kAudioUnitProperty_MaximumFramesPerSlice frames. ++ const UInt32 maxFrames = std::max(1, GetMaxFramesPerSlice()); ++ static mach_timebase_info_data_t tb = {0, 0}; ++ if (!tb.denom) mach_timebase_info(&tb); + +- if (m_outputAvailableCb.inputProc) ++ for (UInt32 offset = 0; offset < totalFrames; ) + { +- AudioUnitRenderActionFlags flags = kAudioUnitRenderAction_PostRender; +- const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Output, kInputBus); +- UInt32 numFrames = m_bufferUsed / desc.mBytesPerFrame; ++ const UInt32 numFrames = std::min(maxFrames, totalFrames - offset); ++ const size_t bytes = size_t(numFrames) * frameBytes; ++ { ++ std::lock_guard lk(m_dataAvailableMutex); ++ if (m_bufferSize < bytes) ++ { ++ m_buffer.reset(new uint8_t[bytes]); ++ m_bufferSize = bytes; ++ } ++ memcpy(m_buffer.get(), source + size_t(offset) * frameBytes, bytes); ++ m_bufferUsed = bytes; ++ } + +- m_outputAvailableCb.inputProc(m_outputAvailableCb.inputProcRefCon, &flags, inInputTime, kInputBus, numFrames, nullptr); ++ AudioTimeStamp time = *inInputTime; ++ time.mSampleTime += offset; ++ if (offset && desc.mSampleRate > 0 && tb.numer) ++ time.mHostTime += uint64_t(double(offset) / desc.mSampleRate * 1e9 * tb.denom / tb.numer); ++ ++ // The client pulls the slice with AudioUnitRender(bus 1, numFrames) from this callback. ++ if (m_outputAvailableCb.inputProc) ++ { ++ AudioUnitRenderActionFlags flags = 0; ++ m_outputAvailableCb.inputProc(m_outputAvailableCb.inputProcRefCon, &flags, &time, kInputBus, numFrames, nullptr); ++ } ++ offset += numFrames; + } + + return noErr; diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.h b/src/CoreAudio/CoreAudioComponent/AUHAL.h -index bc21bcbbb..e369f95d1 100644 +index bc21bcbbb..3d6ae48f8 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.h +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.h @@ -25,6 +25,7 @@ along with Darling. If not, see . @@ -2790,6 +3193,15 @@ index bc21bcbbb..e369f95d1 100644 bool CanScheduleParameters() const override; bool StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) override; OSStatus Version() override; +@@ -43,7 +47,7 @@ public: + OSStatus GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope, AudioUnitElement inElement, UInt32& outDataSize, Boolean& outWritable) override; + OSStatus GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, AudioUnitElement inElement, void* outData) override; + +- OSStatus Render(AudioUnitRenderActionFlags& ioActionFlags, const AudioTimeStamp& inTimeStamp, UInt32 inNumberFrames) override; ++ OSStatus RenderBus(AudioUnitRenderActionFlags& ioActionFlags, const AudioTimeStamp& inTimeStamp, UInt32 inBusNumber, UInt32 inNumberFrames) override; + + static OSStatus ComponentEntryDispatch(ComponentParameters *params, AUHAL *This); + private: @@ -59,6 +63,7 @@ private: void* inClientData); @@ -2798,8 +3210,17 @@ index bc21bcbbb..e369f95d1 100644 OSStatus doRecord(const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime); protected: -@@ -76,6 +81,24 @@ protected: - // TODO: Prepare for non-interleaved audio +@@ -69,13 +74,29 @@ protected: + AudioDeviceID m_outputDevice, m_inputDevice; + AudioDeviceIOProcID m_outputProcID = 0, m_inputProcID = 0; + +- bool m_dataAvailable = false; +- std::condition_variable m_dataAvailableCV; ++ // The latest recorded slice (interleaved, in the bus 1 output format), handed to ++ // AudioUnitRender(bus 1) from inside the input callback. + std::mutex m_dataAvailableMutex; +- +- // TODO: Prepare for non-interleaved audio std::unique_ptr m_buffer; size_t m_bufferSize = 0, m_bufferUsed = 0; + @@ -2984,10 +3405,16 @@ index cb2896957..b2c25fbd4 100644 + @end diff --git a/src/frameworks/AVFoundation/src/AVCaptureDevice.m b/src/frameworks/AVFoundation/src/AVCaptureDevice.m -index cab525b91..2fcab003d 100644 +index cab525b91..4b3dd6541 100644 --- a/src/frameworks/AVFoundation/src/AVCaptureDevice.m +++ b/src/frameworks/AVFoundation/src/AVCaptureDevice.m -@@ -21,6 +21,37 @@ +@@ -18,9 +18,75 @@ + */ + + #import ++#include ++#include ++#include @implementation AVCaptureDevice @@ -3006,20 +3433,52 @@ index cab525b91..2fcab003d 100644 +} + + ++// Darling has no TCC consent store. Microphone input works through CoreAudio ++// (PulseAudio), so audio is authorized whenever CoreAudio reports a default input ++// device. CoreAudio is looked up at run time: AVFoundation does not link it, and ++// this query only reads its device table (no PulseAudio connection is made). ++static BOOL hasDefaultInputDevice(void) ++{ ++ static OSStatus (*getProperty)(AudioObjectID, const AudioObjectPropertyAddress*, UInt32, const void*, UInt32*, void*); ++ static dispatch_once_t once; ++ dispatch_once(&once, ^{ ++ // Never closed: CoreAudio keeps process-wide state once loaded. ++ void* coreAudio = dlopen("/System/Library/Frameworks/CoreAudio.framework/CoreAudio", RTLD_LAZY | RTLD_LOCAL); ++ if (coreAudio) ++ getProperty = dlsym(coreAudio, "AudioObjectGetPropertyData"); ++ }); ++ if (!getProperty) ++ return NO; ++ AudioDeviceID device = kAudioObjectUnknown; ++ UInt32 size = sizeof(device); ++ AudioObjectPropertyAddress address = { ++ kAudioHardwarePropertyDefaultInputDevice, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMaster ++ }; ++ return getProperty(kAudioObjectSystemObject, &address, 0, NULL, &size, &device) == noErr ++ && device != kAudioObjectUnknown; ++} ++ ++ (AVAuthorizationStatus)authorizationStatusForMediaType:(AVMediaType)mediaType +{ -+ // Darling has no camera/microphone capture or TCC consent store; -+ // report Denied so callers take their no-access path instead of -+ // raising an unrecognized-selector exception. -+ (void)mediaType; ++ // There is no camera capture; report Denied so callers take their ++ // no-access path instead of raising an unrecognized-selector exception. ++ if ([mediaType isEqualToString: AVMediaTypeAudio] && hasDefaultInputDevice()) ++ return AVAuthorizationStatusAuthorized; + return AVAuthorizationStatusDenied; +} + ++ (void)requestAccessForMediaType:(AVMediaType)mediaType completionHandler:(void (^)(BOOL))handler +{ -+ (void)mediaType; -+ if (handler) -+ handler(NO); ++ if (!handler) ++ return; ++ BOOL granted = [self authorizationStatusForMediaType: mediaType] == AVAuthorizationStatusAuthorized; ++ // macOS calls the handler later on an arbitrary queue, never inside this call; ++ // callers may hold locks here or wait on the main thread for the result. ++ void (^copied)(BOOL) = [handler copy]; ++ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ++ copied(granted); ++ [copied release]; ++ }); +} + - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { @@ -10798,3 +11257,54 @@ index e789986..0d56b4e 100644 } } return t_server_socket; +diff --git a/src/CoreAudio/CoreAudio/pulse/AudioHardwareImplPAInput.cpp b/src/CoreAudio/CoreAudio/pulse/AudioHardwareImplPAInput.cpp +index c1fd1d402..777ca7c38 100644 +--- a/src/CoreAudio/CoreAudio/pulse/AudioHardwareImplPAInput.cpp ++++ b/src/CoreAudio/CoreAudio/pulse/AudioHardwareImplPAInput.cpp +@@ -31,15 +31,18 @@ OSStatus AudioHardwareImplPAInput::getPropertyData(const AudioObjectPropertyAddr + { + if (inAddress->mSelector == kAudioDevicePropertyStreamConfiguration) + { ++ // A capture device has its channels on the input scope only. Reporting them on the ++ // output scope (copied from the output device) made capture enumerators such as ++ // Vivox and Chromium see a playback-only device with no input channels. + size_t size = sizeof(AudioBufferList); + +- if (inAddress->mScope == kAudioDevicePropertyScopeOutput) ++ if (inAddress->mScope == kAudioDevicePropertyScopeInput) + size += 1* sizeof(AudioBuffer); + + // Number of returned buffers is the number of channels + if (AudioBufferList* abl = static_cast(outData); abl && *ioDataSize >= size) + { +- if (inAddress->mScope == kAudioDevicePropertyScopeOutput) ++ if (inAddress->mScope == kAudioDevicePropertyScopeInput) + { + abl->mNumberBuffers = 1; + abl->mBuffers[0].mNumberChannels = 2; +diff --git a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.h b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.h +index 87db50d12..01a7b2a3e 100644 +--- a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.h ++++ b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.h +@@ -21,6 +21,8 @@ along with Darling. If not, see . + #define AUDIOHARDWARESTREAMPAINPUT_H + #include "AudioHardwareStreamPA.h" + #include ++#include ++#include + + class AudioHardwareStreamPAInput : public AudioHardwareStreamPA + { +@@ -30,6 +32,12 @@ private: + void start() override; + static void paStreamReadCB(pa_stream* s, size_t length, void* self); + ++ // Only touched on the PA queue. ++ uint64_t m_frames = 0; ++ uint64_t m_lastInputHost = 0; ++ size_t m_blockBytes = 0; ++ std::vector m_scratch; ++ + // std::ofstream m_dump; + }; + diff --git a/tools/build-riot-audio-runtime.py b/tools/build-riot-audio-runtime.py index 38bff39..c7b2488 100644 --- a/tools/build-riot-audio-runtime.py +++ b/tools/build-riot-audio-runtime.py @@ -43,14 +43,16 @@ if matrix_obj in link: link[link.index(matrix_obj)]=str(obj) else: link.append(str(obj)) -# The default-output unit must be recompiled too: its SetProperty refuses kAudioOutputUnitProperty_CurrentDevice unless patched. -obj=project/'build/riot-DefaultOutputAU.o' -subprocess.run(['clang++',*component_flags['CXX_DEFINES'],*component_flags['CXX_INCLUDES'], - *component_flags['CXX_FLAGS'],'-O2','-c',str(project/'build/darling-src/src/CoreAudio/CoreAudioComponent/DefaultOutputAU.cpp'), - '-o',str(obj)],check=True) -default_obj='CMakeFiles/CoreAudioComponent.dir/DefaultOutputAU.cpp.o' -assert default_obj in link, 'DefaultOutputAU object missing from the link line' -link[link.index(default_obj)]=str(obj) +# The AUHAL subclasses must be recompiled too: they share AUHAL.h's layout and vtable, and the default-output +# unit's SetProperty refuses kAudioOutputUnitProperty_CurrentDevice unless patched. +for name in ['DefaultOutputAU','SystemOutputAU']: + obj=project/f'build/riot-{name}.o' + subprocess.run(['clang++',*component_flags['CXX_DEFINES'],*component_flags['CXX_INCLUDES'], + *component_flags['CXX_FLAGS'],'-O2','-c',str(project/f'build/darling-src/src/CoreAudio/CoreAudioComponent/{name}.cpp'), + '-o',str(obj)],check=True) + default_obj=f'CMakeFiles/CoreAudioComponent.dir/{name}.cpp.o' + assert default_obj in link, f'{name} object missing from the link line' + link[link.index(default_obj)]=str(obj) link[link.index('-o')+1]=str(project/'build/riot-CoreAudioComponent') subprocess.run(link,cwd=component,check=True) toolbox=build/'src/CoreAudio/AudioToolbox' @@ -90,3 +92,10 @@ subprocess.run(['clang++',*cpp_flags['CXX_DEFINES'],*cpp_flags['CXX_INCLUDES'], subprocess.run(['clang++','-target','x86_64-apple-macosx11.0','-fuse-ld=lld','-nostdlib', '-isysroot','@DARLING_SDK@',str(obj),'-lSystem','-lc++', str(project/'build/riot-AudioToolbox'),'-o',str(project/'build/riot-audio-graph-check')],check=True) + +obj=project/'build/riot-audio-input-check.o' +subprocess.run(['clang++',*cpp_flags['CXX_DEFINES'],*cpp_flags['CXX_INCLUDES'], + *cpp_flags['CXX_FLAGS'],'-O2','-c',str(project/'tools/riot-audio-input-check.cpp'),'-o',str(obj)],check=True) +subprocess.run(['clang++','-target','x86_64-apple-macosx11.0','-fuse-ld=lld','-nostdlib', + '-isysroot','@DARLING_SDK@',str(obj),'-lSystem','-lc++','-lobjc','-framework','CoreAudio', + '-framework','AudioToolbox','-framework','AVFoundation','-o',str(project/'build/riot-audio-input-check')],check=True) diff --git a/tools/riot-audio-input-check.cpp b/tools/riot-audio-input-check.cpp new file mode 100644 index 0000000..c2ccf99 --- /dev/null +++ b/tools/riot-audio-input-check.cpp @@ -0,0 +1,150 @@ +// Microphone path used by voice chat (Vivox/Chromium): AVFoundation audio authorization, the +// input device's channels, and an AUHAL input unit delivering recorded frames through +// AudioUnitRender(bus 1). Samples are only counted, never stored or printed. +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" void* const AVMediaTypeAudio; +extern "C" void* const AVMediaTypeVideo; + +static AudioUnit unit; +static std::atomic callbacks{0}, renderErrors{0}, nonIncreasing{0}, zeroFrames{0}, badBuffers{0}; +static std::atomic totalFrames{0}; +static double lastSample=-1; +static bool planar; +static float samples[2][4096]; + +static OSStatus input(void*,AudioUnitRenderActionFlags* flags,const AudioTimeStamp* time,UInt32 bus,UInt32 frames,AudioBufferList*) { + ++callbacks; + if(!frames || frames>4096) {++zeroFrames;return noErr;} + if(!(time->mFlags&kAudioTimeStampSampleTimeValid) || time->mSampleTime<=lastSample) ++nonIncreasing; + lastSample=time->mSampleTime; + unsigned char storage[sizeof(AudioBufferList)+sizeof(AudioBuffer)]; + auto* list=(AudioBufferList*)storage; + list->mNumberBuffers=planar?2:1; + for(unsigned b=0;bmNumberBuffers;++b) { + list->mBuffers[b].mNumberChannels=1; + list->mBuffers[b].mDataByteSize=frames*sizeof(float); + list->mBuffers[b].mData=samples[b]; + } + AudioUnitRenderActionFlags renderFlags=0; + OSStatus status=AudioUnitRender(unit,&renderFlags,time,bus,frames,list); + if(status) ++renderErrors; + else for(unsigned b=0;bmNumberBuffers;++b) + if(list->mBuffers[b].mDataByteSize!=frames*sizeof(float)) ++badBuffers; + totalFrames+=frames; + return noErr; +} + +static int authorization() { + Class device=objc_getClass("AVCaptureDevice"); + if(!device) {printf("AUDIO_INPUT_AUTH class=0\n");return 1;} + SEL status=sel_registerName("authorizationStatusForMediaType:"); + auto query=(long(*)(id,SEL,void*))objc_msgSend; + long audio=query((id)device,status,AVMediaTypeAudio),video=query((id)device,status,AVMediaTypeVideo); + __block int granted=-1; + __block bool returned=false; + __block bool synchronous=false; + dispatch_semaphore_t done=dispatch_semaphore_create(0); + auto request=(void(*)(id,SEL,void*,void(^)(BOOL)))objc_msgSend; + request((id)device,sel_registerName("requestAccessForMediaType:completionHandler:"),AVMediaTypeAudio,^(BOOL value) { + synchronous=!returned;granted=value;dispatch_semaphore_signal(done); + }); + returned=true; + long waited=dispatch_semaphore_wait(done,dispatch_time(DISPATCH_TIME_NOW,2*NSEC_PER_SEC)); + printf("AUDIO_INPUT_AUTH audio=%ld video=%ld request_granted=%d async=%d\n",audio,video,(int)granted,!waited && !synchronous); + fflush(stdout); + return audio!=3 || video!=2 || waited || granted!=1 || synchronous; +} + +static int device(AudioDeviceID* out) { + AudioObjectPropertyAddress p={kAudioHardwarePropertyDefaultInputDevice,kAudioObjectPropertyScopeGlobal,kAudioObjectPropertyElementMaster}; + AudioDeviceID id=kAudioObjectUnknown;UInt32 size=sizeof(id); + OSStatus status=AudioObjectGetPropertyData(kAudioObjectSystemObject,&p,0,nullptr,&size,&id); + if(status || id==kAudioObjectUnknown) {printf("AUDIO_INPUT_DEVICE status=%d\n",status);return 1;} + unsigned channels[2]={0,0}; + for(unsigned i=0;i<2;++i) { + AudioObjectPropertyAddress c={kAudioDevicePropertyStreamConfiguration, + i?kAudioObjectPropertyScopeOutput:kAudioObjectPropertyScopeInput,kAudioObjectPropertyElementMaster}; + unsigned char storage[256]={};size=sizeof(storage); + if(AudioObjectGetPropertyData(id,&c,0,nullptr,&size,storage)) return 2; + auto* list=(AudioBufferList*)storage; + for(unsigned b=0;bmNumberBuffers;++b) channels[i]+=list->mBuffers[b].mNumberChannels; + } + AudioObjectPropertyAddress s={kAudioDevicePropertyStreams,kAudioObjectPropertyScopeInput,kAudioObjectPropertyElementMaster}; + UInt32 streams=0; + status=AudioObjectGetPropertyDataSize(id,&s,0,nullptr,&streams); + AudioObjectPropertyAddress d={kAudioHardwarePropertyDevices,kAudioObjectPropertyScopeGlobal,kAudioObjectPropertyElementMaster}; + AudioDeviceID devices[16];size=sizeof(devices); + bool listed=false; + if(!AudioObjectGetPropertyData(kAudioObjectSystemObject,&d,0,nullptr,&size,devices)) + for(unsigned i=0;i Date: Sun, 18 Oct 2026 23:03:23 +0200 Subject: [PATCH 102/160] X11: release modifiers whose release the compositor swallowed GNOME's overview delivers the Super press to the game window, eats the release and hands the keyboard back without a FocusOut. The backend kept Command held, so every later key and click reached the game as a Command shortcut until a real focus change reconciled it (issue #74). Each key, button, motion and crossing event carries the server's modifier state; release any modifier key we track as down whose modifier is missing from it. Synthetic events from other clients are ignored. Co-Authored-By: Claude Opus 5.5 --- patches/cocotron.patch | 86 +++++++++++++++++++++++++++---------- tools/riot-modifier-check.m | 23 +++++++++- 2 files changed, 85 insertions(+), 24 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 72d30f8..ca2e81b 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2687,7 +2687,7 @@ index 18189404..12cde429 100644 +- (NSRect) frameForRootRect: (NSRect) rect; @end diff --git a/AppKit/X11.backend/X11Display.m b/AppKit/X11.backend/X11Display.m -index 5ae29bf4..47fb6552 100644 +index 5ae29bf4..a4a880b0 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3575,7 +3575,7 @@ index 5ae29bf4..47fb6552 100644 } - (NSEventModifierFlags) modifierFlagsForState: (unsigned int) state { -@@ -1010,17 +1625,96 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1010,17 +1625,136 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return result; } @@ -3619,6 +3619,28 @@ index 5ae29bf4..47fb6552 100644 + [self postModifierKey:key state:state window:delegate]; + } +} ++ ++/* A compositor can eat a modifier release without moving focus: GNOME's ++ * overview delivers Super's press, swallows its release and hands the ++ * keyboard back with no FocusOut, leaving Command held in the guest. Every ++ * input event carries the server's modifier state, so release any key we ++ * think is held whose modifier is missing there. `except` is the key of a ++ * modifier event, whose state predates its own transition. */ ++- (void) releaseModifierKeysMissingFromState:(unsigned int)state except:(unsigned int)except window:(id)delegate { ++ static const unsigned char keys[] = { 50, 62, 37, 105, 64, 108, 133, 134 }; ++ for (unsigned int i = 0; i < sizeof(keys); ++i) { ++ unsigned int key = keys[i]; ++ if (key == except || !_modifierKeysDown[key]) continue; ++ unsigned int held = rclolModifierMask(key); ++ if (key == 108) held |= Mod1Mask; /* Right Alt is Mod1 on layouts without AltGr. */ ++ if (state & held) continue; ++ _modifierKeysDown[key] = 0; ++ unsigned int now = state & ~(ShiftMask | ControlMask | Mod1Mask | Mod4Mask | Mod5Mask); ++ for (unsigned int j = 0; j < sizeof(keys); ++j) ++ if (_modifierKeysDown[keys[j]]) now |= rclolModifierMask(keys[j]); ++ [self postModifierKey:key state:now window:delegate]; ++ } ++} + - (void) postXEvent: (XEvent *) ev { id event = nil; @@ -3649,6 +3671,24 @@ index 5ae29bf4..47fb6552 100644 + /* Expected warp echoes are among the events queued when the warp was synced; motion ages them itself. */ + if (_relativeMouse.active && ev->type != MotionNotify) + X11RelativeMouseTick(&_relativeMouse); ++ ++ /* Events sent by other clients carry whatever state they chose. */ ++ switch (ev->xany.send_event ? 0 : ev->type) { ++ case KeyPress: case KeyRelease: ++ [self releaseModifierKeysMissingFromState:ev->xkey.state except:ev->xkey.keycode window:delegate]; ++ break; ++ case ButtonPress: case ButtonRelease: ++ [self releaseModifierKeysMissingFromState:ev->xbutton.state except:0 window:delegate]; ++ break; ++ case MotionNotify: ++ [self releaseModifierKeysMissingFromState:ev->xmotion.state except:0 window:delegate]; ++ break; ++ case EnterNotify: case LeaveNotify: ++ [self releaseModifierKeysMissingFromState:ev->xcrossing.state except:0 window:delegate]; ++ break; ++ default: ++ break; ++ } switch (ev->type) { case KeyPress: @@ -3672,7 +3712,7 @@ index 5ae29bf4..47fb6552 100644 NSEventModifierFlags modifierFlags = [self modifierFlagsForState: ev->xkey.state]; char buf[20] = {0}; KeySym keySym; -@@ -1030,7 +1724,11 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1030,7 +1764,11 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; if (ev->type == KeyPress) { @@ -3685,7 +3725,7 @@ index 5ae29bf4..47fb6552 100644 buf[strLen] = 0; } else { // Xutf8LookupString() may not be used with KeyRelease -@@ -1038,6 +1736,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1038,6 +1776,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { buf[strLen] = 0; } @@ -3704,7 +3744,7 @@ index 5ae29bf4..47fb6552 100644 id str = [[NSString alloc] initWithCString: buf encoding: NSUTF8StringEncoding]; NSPoint pos = -@@ -1077,6 +1787,32 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1077,6 +1827,32 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ButtonPress:; @@ -3737,7 +3777,7 @@ index 5ae29bf4..47fb6552 100644 NSTimeInterval now = [[NSDate date] timeIntervalSinceReferenceDate]; if (now - lastClickTimeStamp < [self doubleClickInterval]) { -@@ -1086,8 +1822,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1086,8 +1862,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } lastClickTimeStamp = now; @@ -3748,7 +3788,7 @@ index 5ae29bf4..47fb6552 100644 switch (ev->xbutton.button) { case Button1: -@@ -1096,10 +1832,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1096,10 +1872,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseDown; break; @@ -3759,7 +3799,7 @@ index 5ae29bf4..47fb6552 100644 default: type = NSOtherMouseDown; } -@@ -1113,14 +1845,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1113,14 +1885,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: 0.0]; @@ -3786,7 +3826,7 @@ index 5ae29bf4..47fb6552 100644 CGFloat deltaY = 0.0; switch (ev->xbutton.button) { -@@ -1130,14 +1870,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1130,14 +1910,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseUp; break; @@ -3801,7 +3841,7 @@ index 5ae29bf4..47fb6552 100644 default: type = NSOtherMouseUp; } -@@ -1151,7 +1883,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1151,7 +1923,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: deltaY]; @@ -3812,7 +3852,7 @@ index 5ae29bf4..47fb6552 100644 [self postEvent: event atStart: NO]; break; -@@ -1160,49 +1894,95 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1160,49 +1934,95 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { // ev->xmotion.x, ev->xmotion.y, ev->xmotion.x_root, // ev->xmotion.y_root); @@ -3934,7 +3974,7 @@ index 5ae29bf4..47fb6552 100644 mouseEventWithType: type location: pos modifierFlags: -@@ -1211,28 +1991,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +2031,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -3995,7 +4035,7 @@ index 5ae29bf4..47fb6552 100644 NSLog(@"FocusIn"); if ([delegate attachedSheet]) { [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; -@@ -1243,28 +2053,62 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +2093,62 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -4060,7 +4100,7 @@ index 5ae29bf4..47fb6552 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +2145,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +2185,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -4088,7 +4128,7 @@ index 5ae29bf4..47fb6552 100644 break; case MapRequest: -@@ -1317,10 +2173,21 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1317,10 +2213,21 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ConfigureNotify: @@ -4110,7 +4150,7 @@ index 5ae29bf4..47fb6552 100644 break; case ConfigureRequest: -@@ -1344,6 +2211,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2251,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -4119,7 +4159,7 @@ index 5ae29bf4..47fb6552 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1399,7 +2268,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1399,7 +2308,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; default: @@ -4130,7 +4170,7 @@ index 5ae29bf4..47fb6552 100644 [self _invalidateRRCache]; break; } -@@ -1433,6 +2304,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1433,6 +2344,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); @@ -4141,7 +4181,7 @@ index 5ae29bf4..47fb6552 100644 return 0; } -@@ -1476,54 +2351,279 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2391,279 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } @@ -4194,8 +4234,8 @@ index 5ae29bf4..47fb6552 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 { @@ -4382,8 +4422,8 @@ index 5ae29bf4..47fb6552 100644 + X11RelativeMouseEnd(&_relativeMouse); + _relativeWindow = None; + rclolPointerStamp = 0; - } - ++} ++ +/* CGAssociateMouseAndMouseCursorPosition(!doGrab). */ - (void) grabMouse: (BOOL) doGrab { + BOOL rclolRelativeBefore = _relativeMouse.active; diff --git a/tools/riot-modifier-check.m b/tools/riot-modifier-check.m index 5700c72..116a45d 100644 --- a/tools/riot-modifier-check.m +++ b/tools/riot-modifier-check.m @@ -48,8 +48,29 @@ int main(void) { XEvent map={0};map.type=KeymapNotify;[display postXEvent:&map];expectModifier(0x37,0); key(display,KeyPress,66,0);expectModifier(0x39,NSAlphaShiftKeyMask); key(display,KeyRelease,66,LockMask);expectModifier(0x39,NSAlphaShiftKeyMask); + // GNOME overview: Super press delivered, release swallowed, no focus change. + // The next event's server state lacks Mod4, so Command must be released first. + key(display,KeyPress,133,LockMask);expectModifier(0x37,NSAlphaShiftKeyMask|NSCommandKeyMask); + NSUInteger before=[events count]; + key(display,KeyPress,50,LockMask); + assert([events count]==before+2); + NSEvent *released=[events objectAtIndex:before]; + assert([released keyCode]==0x37 && [released modifierFlags]==NSAlphaShiftKeyMask); + expectModifier(0x38,NSAlphaShiftKeyMask|NSShiftKeyMask); + // A modifier that is still held stays held. + key(display,KeyPress,133,LockMask|ShiftMask);expectModifier(0x37,NSAlphaShiftKeyMask|NSShiftKeyMask|NSCommandKeyMask); + before=[events count]; + key(display,KeyRelease,50,LockMask|ShiftMask|Mod4Mask); + assert([events count]==before+1); + expectModifier(0x38,NSAlphaShiftKeyMask|NSCommandKeyMask); + // Right Alt reports Mod1 on layouts without AltGr; that still counts as held. + key(display,KeyPress,108,LockMask|Mod4Mask); + before=[events count]; + key(display,KeyRelease,133,LockMask|Mod4Mask|Mod1Mask); + assert([events count]==before+1); + expectModifier(0x37,NSAlphaShiftKeyMask|NSAlternateKeyMask|NSFunctionKeyMask); method_setImplementation(method,original); - puts("PASS Super=Command, Alt=Option, both Command keys, post-transition flags, focus-loss release, missed-release reconciliation, Caps Lock"); + puts("PASS Super=Command, Alt=Option, both Command keys, post-transition flags, focus-loss release, missed-release reconciliation, Caps Lock, swallowed Super release"); [events release]; } return 0; From 52b0fa905d871159e764f4ed24855dd2af173b01 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:08:48 +0200 Subject: [PATCH 103/160] riot-audio-input-check: detect a synchronous access callback by thread The handler read a flag the caller wrote without synchronization, and inferred "called inside the request" from whether the call had returned, which misfires when the handler runs on another thread first. Compare the handler's thread with the caller's instead and keep the shared results atomic. Co-Authored-By: Claude Opus 5.5 --- tools/riot-audio-input-check.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tools/riot-audio-input-check.cpp b/tools/riot-audio-input-check.cpp index c2ccf99..a2e62bc 100644 --- a/tools/riot-audio-input-check.cpp +++ b/tools/riot-audio-input-check.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include extern "C" void* const AVMediaTypeAudio; @@ -49,15 +50,16 @@ static int authorization() { SEL status=sel_registerName("authorizationStatusForMediaType:"); auto query=(long(*)(id,SEL,void*))objc_msgSend; long audio=query((id)device,status,AVMediaTypeAudio),video=query((id)device,status,AVMediaTypeVideo); - __block int granted=-1; - __block bool returned=false; - __block bool synchronous=false; + // macOS calls the handler on another queue, never on the requesting thread inside the call. + // Shared with the handler's thread, so atomic; read only after the handler signals. + static std::atomic granted{-1}; + static std::atomic synchronous{false}; + const pthread_t caller=pthread_self(); dispatch_semaphore_t done=dispatch_semaphore_create(0); auto request=(void(*)(id,SEL,void*,void(^)(BOOL)))objc_msgSend; request((id)device,sel_registerName("requestAccessForMediaType:completionHandler:"),AVMediaTypeAudio,^(BOOL value) { - synchronous=!returned;granted=value;dispatch_semaphore_signal(done); + synchronous=pthread_equal(pthread_self(),caller)!=0;granted=value;dispatch_semaphore_signal(done); }); - returned=true; long waited=dispatch_semaphore_wait(done,dispatch_time(DISPATCH_TIME_NOW,2*NSEC_PER_SEC)); printf("AUDIO_INPUT_AUTH audio=%ld video=%ld request_granted=%d async=%d\n",audio,video,(int)granted,!waited && !synchronous); fflush(stdout); From da356d9112e248ced21967b8737d765c80c8d9bc Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:13:07 +0200 Subject: [PATCH 104/160] X11: take modifier masks from the server's modifier map Right Alt (keycode 108) was hard-coded as Mod5/AltGr. On layouts where it is Mod1, the swallowed-release reconcile accepted Mod1 as held but rebuilt the state with Mod5, so the guest could see Option and Function together. Read the per-key masks from XGetModifierMapping (refreshed on MappingNotify) and use them everywhere; the old table is the fallback for unmapped keys. The probe now checks Right Alt against whichever mask the backend reports. Co-Authored-By: Claude Opus 5.5 --- patches/cocotron.patch | 93 ++++++++++++++++++++++++------------- tools/riot-modifier-check.m | 13 ++++-- 2 files changed, 71 insertions(+), 35 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index ca2e81b..22a1fa3 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2687,7 +2687,7 @@ index 18189404..12cde429 100644 +- (NSRect) frameForRootRect: (NSRect) rect; @end diff --git a/AppKit/X11.backend/X11Display.m b/AppKit/X11.backend/X11Display.m -index 5ae29bf4..a4a880b0 100644 +index 5ae29bf4..3a3c68f8 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3575,7 +3575,7 @@ index 5ae29bf4..a4a880b0 100644 } - (NSEventModifierFlags) modifierFlagsForState: (unsigned int) state { -@@ -1010,17 +1625,136 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1010,17 +1625,157 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return result; } @@ -3583,15 +3583,37 @@ index 5ae29bf4..a4a880b0 100644 + * carries the state AFTER it. Keep both sides separately so releasing one + * Shift/Command key does not clear the other, and reconcile missed releases + * when the compositor intercepts a shortcut or changes keyboard focus. */ ++static unsigned char rclolServerModifiers[256]; ++static BOOL rclolServerModifiersLoaded; ++ ++/* Which modifier each keycode sets depends on the layout: Right Alt is Mod5 ++ * (AltGr) on most European layouts and Mod1 (Alt) on US. Ask the server. */ ++static void rclolLoadServerModifiers(Display *display) { ++ if (rclolServerModifiersLoaded) return; ++ rclolServerModifiersLoaded = YES; ++ memset(rclolServerModifiers, 0, sizeof(rclolServerModifiers)); ++ XModifierKeymap *map = XGetModifierMapping(display); ++ if (!map) return; ++ for (int mod = 0; mod < 8; ++mod) ++ for (int i = 0; i < map->max_keypermod; ++i) { ++ KeyCode code = map->modifiermap[mod * map->max_keypermod + i]; ++ if (code) rclolServerModifiers[code] |= 1 << mod; ++ } ++ XFreeModifiermap(map); ++} ++ +static unsigned int rclolModifierMask(unsigned int key) { ++ unsigned int fallback; + switch (key) { -+ case 50: case 62: return ShiftMask; -+ case 37: case 105: return ControlMask; -+ case 64: return Mod1Mask; -+ case 108: return Mod5Mask; -+ case 133: case 134: return Mod4Mask; ++ case 50: case 62: fallback = ShiftMask; break; ++ case 37: case 105: fallback = ControlMask; break; ++ case 64: fallback = Mod1Mask; break; ++ case 108: fallback = Mod5Mask; break; ++ case 133: case 134: fallback = Mod4Mask; break; + default: return 0; + } ++ unsigned int server = rclolServerModifiers[key] & (Mod1Mask | Mod4Mask | Mod5Mask | ShiftMask | ControlMask); ++ return server ? server : fallback; +} + +- (void) postModifierKey:(unsigned int)key state:(unsigned int)state window:(id)delegate { @@ -3631,9 +3653,7 @@ index 5ae29bf4..a4a880b0 100644 + for (unsigned int i = 0; i < sizeof(keys); ++i) { + unsigned int key = keys[i]; + if (key == except || !_modifierKeysDown[key]) continue; -+ unsigned int held = rclolModifierMask(key); -+ if (key == 108) held |= Mod1Mask; /* Right Alt is Mod1 on layouts without AltGr. */ -+ if (state & held) continue; ++ if (state & rclolModifierMask(key)) continue; + _modifierKeysDown[key] = 0; + unsigned int now = state & ~(ShiftMask | ControlMask | Mod1Mask | Mod4Mask | Mod5Mask); + for (unsigned int j = 0; j < sizeof(keys); ++j) @@ -3648,6 +3668,7 @@ index 5ae29bf4..a4a880b0 100644 X11Window* window = (X11Window*) [self windowForID: ev->xany.window]; id delegate = [window delegate]; ++ rclolLoadServerModifiers(_display); + static int inputTrace = -1; + if (inputTrace < 0) inputTrace = getenv("RCLOL_INPUT_TRACE") != NULL; + if (inputTrace && (ev->type == KeyPress || ev->type == KeyRelease || ev->type == ButtonPress)) @@ -3712,7 +3733,7 @@ index 5ae29bf4..a4a880b0 100644 NSEventModifierFlags modifierFlags = [self modifierFlagsForState: ev->xkey.state]; char buf[20] = {0}; KeySym keySym; -@@ -1030,7 +1764,11 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1030,7 +1785,11 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; if (ev->type == KeyPress) { @@ -3725,7 +3746,7 @@ index 5ae29bf4..a4a880b0 100644 buf[strLen] = 0; } else { // Xutf8LookupString() may not be used with KeyRelease -@@ -1038,6 +1776,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1038,6 +1797,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { buf[strLen] = 0; } @@ -3744,7 +3765,7 @@ index 5ae29bf4..a4a880b0 100644 id str = [[NSString alloc] initWithCString: buf encoding: NSUTF8StringEncoding]; NSPoint pos = -@@ -1077,6 +1827,32 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1077,6 +1848,32 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ButtonPress:; @@ -3777,7 +3798,7 @@ index 5ae29bf4..a4a880b0 100644 NSTimeInterval now = [[NSDate date] timeIntervalSinceReferenceDate]; if (now - lastClickTimeStamp < [self doubleClickInterval]) { -@@ -1086,8 +1862,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1086,8 +1883,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } lastClickTimeStamp = now; @@ -3788,7 +3809,7 @@ index 5ae29bf4..a4a880b0 100644 switch (ev->xbutton.button) { case Button1: -@@ -1096,10 +1872,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1096,10 +1893,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseDown; break; @@ -3799,7 +3820,7 @@ index 5ae29bf4..a4a880b0 100644 default: type = NSOtherMouseDown; } -@@ -1113,14 +1885,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1113,14 +1906,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: 0.0]; @@ -3826,7 +3847,7 @@ index 5ae29bf4..a4a880b0 100644 CGFloat deltaY = 0.0; switch (ev->xbutton.button) { -@@ -1130,14 +1910,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1130,14 +1931,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseUp; break; @@ -3841,7 +3862,7 @@ index 5ae29bf4..a4a880b0 100644 default: type = NSOtherMouseUp; } -@@ -1151,7 +1923,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1151,7 +1944,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: deltaY]; @@ -3852,7 +3873,7 @@ index 5ae29bf4..a4a880b0 100644 [self postEvent: event atStart: NO]; break; -@@ -1160,49 +1934,95 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1160,49 +1955,95 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { // ev->xmotion.x, ev->xmotion.y, ev->xmotion.x_root, // ev->xmotion.y_root); @@ -3974,7 +3995,7 @@ index 5ae29bf4..a4a880b0 100644 mouseEventWithType: type location: pos modifierFlags: -@@ -1211,28 +2031,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +2052,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -4035,7 +4056,7 @@ index 5ae29bf4..a4a880b0 100644 NSLog(@"FocusIn"); if ([delegate attachedSheet]) { [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; -@@ -1243,28 +2093,62 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +2114,62 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -4100,7 +4121,7 @@ index 5ae29bf4..a4a880b0 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +2185,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +2206,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -4128,7 +4149,7 @@ index 5ae29bf4..a4a880b0 100644 break; case MapRequest: -@@ -1317,10 +2213,21 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1317,10 +2234,21 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ConfigureNotify: @@ -4150,7 +4171,7 @@ index 5ae29bf4..a4a880b0 100644 break; case ConfigureRequest: -@@ -1344,6 +2251,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2272,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -4159,7 +4180,17 @@ index 5ae29bf4..a4a880b0 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1399,7 +2308,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1392,6 +2322,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + + case MappingNotify: + NSLog(@"MappingNotify"); ++ XRefreshKeyboardMapping(&ev->xmapping); ++ if (ev->xmapping.request == MappingModifier) ++ rclolServerModifiersLoaded = NO; + break; + + case GenericEvent: +@@ -1399,7 +2332,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; default: @@ -4170,7 +4201,7 @@ index 5ae29bf4..a4a880b0 100644 [self _invalidateRRCache]; break; } -@@ -1433,6 +2344,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1433,6 +2368,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); @@ -4181,7 +4212,7 @@ index 5ae29bf4..a4a880b0 100644 return 0; } -@@ -1476,54 +2391,279 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2415,279 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } @@ -4234,8 +4265,8 @@ index 5ae29bf4..a4a880b0 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 { @@ -4422,8 +4453,8 @@ index 5ae29bf4..a4a880b0 100644 + X11RelativeMouseEnd(&_relativeMouse); + _relativeWindow = None; + rclolPointerStamp = 0; -+} -+ + } + +/* CGAssociateMouseAndMouseCursorPosition(!doGrab). */ - (void) grabMouse: (BOOL) doGrab { + BOOL rclolRelativeBefore = _relativeMouse.active; diff --git a/tools/riot-modifier-check.m b/tools/riot-modifier-check.m index 116a45d..f0cfc81 100644 --- a/tools/riot-modifier-check.m +++ b/tools/riot-modifier-check.m @@ -63,14 +63,19 @@ int main(void) { key(display,KeyRelease,50,LockMask|ShiftMask|Mod4Mask); assert([events count]==before+1); expectModifier(0x38,NSAlphaShiftKeyMask|NSCommandKeyMask); - // Right Alt reports Mod1 on layouts without AltGr; that still counts as held. + // Right Alt is Mod1 (Option) on US layouts and Mod5 (AltGr) elsewhere; the + // backend reads the server map, so take whichever it reported and check it + // is still held, and reported once, when Command goes up. key(display,KeyPress,108,LockMask|Mod4Mask); + NSUInteger rightAlt=[[events lastObject] modifierFlags]&~(NSUInteger)(NSAlphaShiftKeyMask|NSCommandKeyMask); + assert(rightAlt==NSAlternateKeyMask || rightAlt==NSFunctionKeyMask); + unsigned int rightAltMask=rightAlt==NSAlternateKeyMask ? Mod1Mask : Mod5Mask; before=[events count]; - key(display,KeyRelease,133,LockMask|Mod4Mask|Mod1Mask); + key(display,KeyRelease,133,LockMask|Mod4Mask|rightAltMask); assert([events count]==before+1); - expectModifier(0x37,NSAlphaShiftKeyMask|NSAlternateKeyMask|NSFunctionKeyMask); + expectModifier(0x37,NSAlphaShiftKeyMask|rightAlt); method_setImplementation(method,original); - puts("PASS Super=Command, Alt=Option, both Command keys, post-transition flags, focus-loss release, missed-release reconciliation, Caps Lock, swallowed Super release"); + puts("PASS Super=Command, Alt=Option, both Command keys, post-transition flags, focus-loss release, missed-release reconciliation, Caps Lock, swallowed Super release, layout-mapped Right Alt"); [events release]; } return 0; From 4ae311b14e39a9e6a4f325276fe177a463980b2f Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:16:18 +0200 Subject: [PATCH 105/160] close-league-client-in-game: don't block closes right after boot last_close started at 0.0 and the cooldown compares it with time.monotonic(), which counts from boot. For the first 30 s of uptime every close was refused as "in cooldown". That made the tests flaky on freshly booted CI runners (seen on PR #76). Use None for "never closed". Co-Authored-By: Claude Opus 5.5 --- tests/test_close_league_client_in_game.py | 14 +++++++++++++- tools/close-league-client-in-game.py | 8 +++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/test_close_league_client_in_game.py b/tests/test_close_league_client_in_game.py index aeddd38..c72db0f 100644 --- a/tests/test_close_league_client_in_game.py +++ b/tests/test_close_league_client_in_game.py @@ -91,6 +91,17 @@ class InGameCloseTests(unittest.TestCase): self.assertIn('closed the client UI for the running match (2 processes: 123,124)', self.output.getvalue()) + def test_match_soon_after_boot_still_closes_the_client_ui(self): + # time.monotonic() counts from boot; a fresh machine (or CI runner) is + # still inside the first cooldown window. + self.client_ui(123) + self.process(125, GAME) + with patch.object(in_game.time, 'monotonic', return_value=5.0): + result, calls = self.run_once() + self.assertEqual(result, 0) + self.assertEqual(sorted(calls), sorted([(123, in_game.signal.SIGTERM), + (123, in_game.signal.SIGKILL)])) + def test_marker_in_arguments_does_not_make_a_process_client_ui(self): self.client_ui(123) self.process(124, CRASH_HANDLER, args=CRASH_ARGUMENTS) @@ -209,7 +220,8 @@ class InGameCloseTests(unittest.TestCase): def test_close_decision_covers_limits_and_cooldown(self): state = {'game': True, 'ui': {'123': 1}} - self.assertEqual(in_game.close_decision(state, 0, 0.0, 100.0), 'close') + self.assertEqual(in_game.close_decision(state, 0, None, 100.0), 'close') + self.assertEqual(in_game.close_decision(state, 0, None, 5.0), 'close') self.assertIsNone(in_game.close_decision(state, 0, 90.0, 100.0)) self.assertEqual(in_game.close_decision(state, in_game.MAX_CLOSES, 0.0, 100.0), 'limit') self.assertIsNone(in_game.close_decision({'game': False, 'ui': {'123': 1}}, 0, 0.0, 100.0)) diff --git a/tools/close-league-client-in-game.py b/tools/close-league-client-in-game.py index 3a7b008..b44e13f 100755 --- a/tools/close-league-client-in-game.py +++ b/tools/close-league-client-in-game.py @@ -160,7 +160,9 @@ def close_decision(state, closes, last_close, now): return None if closes >= MAX_CLOSES: return 'limit' - if now - last_close < COOLDOWN_SECONDS: + # last_close is None until the first close: monotonic time counts from boot, + # so a 0.0 start would block closes for the first COOLDOWN_SECONDS of uptime. + if last_close is not None and now - last_close < COOLDOWN_SECONDS: return None return 'close' @@ -189,7 +191,7 @@ def supervise(proc_root=Path('/proc'), prefix=None, once=False, dry_run=False, o 'opt out with RCLOL_CLOSE_CLIENT_IN_GAME=0' % prefix, file=out, flush=True) in_game = False closes = 0 - last_close = 0.0 + last_close = None limit_reported = False seen = False idle_since = None @@ -203,7 +205,7 @@ def supervise(proc_root=Path('/proc'), prefix=None, once=False, dry_run=False, o if state['game'] and not in_game: in_game = True closes = 0 - last_close = 0.0 + last_close = None limit_reported = False print('close-league-client-in-game: a match started', file=out, flush=True) elif not state['game'] and in_game: From f8b875ddf1fcb146b611aef2f7558ecc325bd7c9 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:13:16 +0200 Subject: [PATCH 106/160] AUHAL: accept interleaved recording formats on element 1 Vivox sets interleaved Float32 stereo on output scope element 1 and AUBase's canonical-only rule rejected it with -10868, so voice chat never started recording (issue #75 trace: 20 attempts, all failing there). A real AUHAL converts any linear PCM; element 1 now accepts native-endian float32 or 8/16/32-bit integers in either layout, which PulseAudio records and RenderBus copies. Playback (element 0) keeps the canonical rule. The input check gains a Vivox-style run: interleaved stereo 44.1 kHz with the device set on element 1. Co-Authored-By: Claude Opus 5.5 --- patches/darling.patch | 62 +++++++++++++++++++++++--------- tools/riot-audio-input-check.cpp | 33 ++++++++++------- 2 files changed, 66 insertions(+), 29 deletions(-) diff --git a/patches/darling.patch b/patches/darling.patch index 5914a9f..abb04c6 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -2572,7 +2572,7 @@ index ff83d1e15..1e86f9e99 100644 bool m_suspended = true; }; diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp -index fe4d1eaa1..05088f87f 100644 +index fe4d1eaa1..ac8d76c0b 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp @@ -1,3 +1,4 @@ @@ -2647,16 +2647,41 @@ index fe4d1eaa1..05088f87f 100644 bool AUHAL::CanScheduleParameters() const { return true; -@@ -49,7 +93,7 @@ bool AUHAL::CanScheduleParameters() const +@@ -49,7 +93,32 @@ bool AUHAL::CanScheduleParameters() const bool AUHAL::StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) { - return !m_running && IsInitialized(); + return !m_running; ++} ++ ++// AUBase only accepts the canonical layout (Float32, non-interleaved). A real AUHAL ++// converts any linear PCM, and voice engines (Vivox) ask for interleaved Float32 on the ++// recording side, so element 1 accepts whatever PulseAudio records and RenderBus() copies: ++// native-endian float32 or 8/16/32-bit integers, either layout (24-bit is left out: ++// PulseAudio may read it as 4-byte samples while RenderBus() copies 3). ++// Element 0 (playback) keeps the canonical rule. ++bool AUHAL::ValidFormat(AudioUnitScope scope, AudioUnitElement element, const CAStreamBasicDescription& format) ++{ ++ if (element != kInputBus || scope == kAudioUnitScope_Global) ++ return AUBase::ValidFormat(scope, element, format); ++ ++ const UInt32 flags = format.mFormatFlags; ++ const UInt32 bits = format.mBitsPerChannel; ++ const bool isFloat = flags & kAudioFormatFlagIsFloat; ++ if (format.mFormatID != kAudioFormatLinearPCM || format.mFramesPerPacket != 1 ++ || format.mSampleRate <= 0 || format.mChannelsPerFrame < 1 || format.mChannelsPerFrame > 32 ++ || (flags & kAudioFormatFlagIsBigEndian) != (kAudioFormatFlagsNativeEndian & kAudioFormatFlagIsBigEndian)) ++ return false; ++ if (isFloat ? bits != 32 : (bits != 8 && bits != 16 && bits != 32)) ++ return false; ++ ++ const UInt32 frameBytes = (flags & kAudioFormatFlagIsNonInterleaved) ? bits / 8 : format.mChannelsPerFrame * bits / 8; ++ return format.mBytesPerFrame == frameBytes && format.mBytesPerPacket == frameBytes; } OSStatus AUHAL::Version() -@@ -57,45 +101,112 @@ OSStatus AUHAL::Version() +@@ -57,45 +126,112 @@ OSStatus AUHAL::Version() return 1; } @@ -2784,7 +2809,7 @@ index fe4d1eaa1..05088f87f 100644 } m_running = m_enableOutput || m_enableInput; -@@ -111,20 +222,49 @@ OSStatus AUHAL::Stop() +@@ -111,20 +247,49 @@ OSStatus AUHAL::Stop() { AudioDeviceStop(m_outputDevice, m_outputProcID); AudioDeviceDestroyIOProcID(m_outputDevice, m_outputProcID); @@ -2835,7 +2860,7 @@ index fe4d1eaa1..05088f87f 100644 case kAudioOutputUnitProperty_SetInputCallback: { ca_require(inDataSize == sizeof(AURenderCallbackStruct), InvalidPropertyValue); -@@ -161,7 +301,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -161,7 +326,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au ca_require(inDataSize == sizeof(AudioDeviceID), InvalidPropertyValue); const AudioDeviceID* dev = static_cast(inData); @@ -2851,7 +2876,7 @@ index fe4d1eaa1..05088f87f 100644 { m_outputDevice = *dev; PropertyChanged(inID, inScope, inElement); -@@ -176,20 +323,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -176,20 +348,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au return noErr; } } @@ -2882,7 +2907,7 @@ index fe4d1eaa1..05088f87f 100644 } case kAudioOutputUnitProperty_EnableIO: { -@@ -210,13 +365,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope +@@ -210,13 +390,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope return noErr; } } @@ -2907,7 +2932,7 @@ index fe4d1eaa1..05088f87f 100644 case kAudioOutputUnitProperty_SetInputCallback: { memcpy(outData, &m_outputAvailableCb, sizeof(m_outputAvailableCb)); -@@ -228,12 +393,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -228,12 +418,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au if (inElement == kOutputBus) { UInt32 value = m_enableOutput; @@ -2922,7 +2947,7 @@ index fe4d1eaa1..05088f87f 100644 } else return kAudioUnitErr_InvalidElement; -@@ -241,7 +406,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -241,7 +431,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au } case kAudioOutputUnitProperty_CurrentDevice: { @@ -2935,7 +2960,7 @@ index fe4d1eaa1..05088f87f 100644 { memcpy(outData, &m_outputDevice, sizeof(m_outputDevice)); } -@@ -277,9 +446,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, +@@ -277,9 +471,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, return This->doRecord(inNow, inInputData, inInputTime); } @@ -3025,7 +3050,7 @@ index fe4d1eaa1..05088f87f 100644 if (!HasInput(0)) { // std::cerr << "No connection\n"; -@@ -290,9 +539,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -290,9 +564,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp AudioUnitRenderActionFlags flags = kAudioUnitRenderAction_PreRender; const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Input, kOutputBus); @@ -3104,7 +3129,7 @@ index fe4d1eaa1..05088f87f 100644 // std::cout << "Pull result: " << result << std::endl; // std::cout << "Bytes: " << outOutputData->mBuffers[0].mDataByteSize << std::endl; // m_auhalData.write((char*) outOutputData->mBuffers[0].mData, outOutputData->mBuffers[0].mDataByteSize); -@@ -303,30 +618,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -303,30 +643,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp OSStatus AUHAL::doRecord(const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime) { @@ -3172,7 +3197,7 @@ index fe4d1eaa1..05088f87f 100644 return noErr; diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.h b/src/CoreAudio/CoreAudioComponent/AUHAL.h -index bc21bcbbb..3d6ae48f8 100644 +index bc21bcbbb..fad727962 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.h +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.h @@ -25,6 +25,7 @@ along with Darling. If not, see . @@ -3183,7 +3208,7 @@ index bc21bcbbb..3d6ae48f8 100644 class AUHAL : public AUBase { -@@ -32,6 +33,9 @@ public: +@@ -32,8 +33,12 @@ public: AUHAL(AudioComponentInstance inInstance, bool supportRecording = true); ~AUHAL(); @@ -3192,8 +3217,11 @@ index bc21bcbbb..3d6ae48f8 100644 + OSStatus SetAudioChannelLayout(AudioUnitScope scope,AudioUnitElement element,const AudioChannelLayout* layout) override; bool CanScheduleParameters() const override; bool StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) override; ++ bool ValidFormat(AudioUnitScope scope, AudioUnitElement element, const CAStreamBasicDescription& format) override; OSStatus Version() override; -@@ -43,7 +47,7 @@ public: + + OSStatus Start() override; +@@ -43,7 +48,7 @@ public: OSStatus GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope, AudioUnitElement inElement, UInt32& outDataSize, Boolean& outWritable) override; OSStatus GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, AudioUnitElement inElement, void* outData) override; @@ -3202,7 +3230,7 @@ index bc21bcbbb..3d6ae48f8 100644 static OSStatus ComponentEntryDispatch(ComponentParameters *params, AUHAL *This); private: -@@ -59,6 +63,7 @@ private: +@@ -59,6 +64,7 @@ private: void* inClientData); OSStatus doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutputData, const AudioTimeStamp* inOutputTime); @@ -3210,7 +3238,7 @@ index bc21bcbbb..3d6ae48f8 100644 OSStatus doRecord(const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime); protected: -@@ -69,13 +74,29 @@ protected: +@@ -69,13 +75,29 @@ protected: AudioDeviceID m_outputDevice, m_inputDevice; AudioDeviceIOProcID m_outputProcID = 0, m_inputProcID = 0; diff --git a/tools/riot-audio-input-check.cpp b/tools/riot-audio-input-check.cpp index a2e62bc..667abdf 100644 --- a/tools/riot-audio-input-check.cpp +++ b/tools/riot-audio-input-check.cpp @@ -20,7 +20,8 @@ static std::atomic callbacks{0}, renderErrors{0}, nonIncreasing{0}, ze static std::atomic totalFrames{0}; static double lastSample=-1; static bool planar; -static float samples[2][4096]; +static unsigned interleavedChannels=1; +static float samples[2][2*4096]; static OSStatus input(void*,AudioUnitRenderActionFlags* flags,const AudioTimeStamp* time,UInt32 bus,UInt32 frames,AudioBufferList*) { ++callbacks; @@ -31,15 +32,15 @@ static OSStatus input(void*,AudioUnitRenderActionFlags* flags,const AudioTimeSta auto* list=(AudioBufferList*)storage; list->mNumberBuffers=planar?2:1; for(unsigned b=0;bmNumberBuffers;++b) { - list->mBuffers[b].mNumberChannels=1; - list->mBuffers[b].mDataByteSize=frames*sizeof(float); + list->mBuffers[b].mNumberChannels=planar?1:interleavedChannels; + list->mBuffers[b].mDataByteSize=frames*sizeof(float)*list->mBuffers[b].mNumberChannels; list->mBuffers[b].mData=samples[b]; } AudioUnitRenderActionFlags renderFlags=0; OSStatus status=AudioUnitRender(unit,&renderFlags,time,bus,frames,list); if(status) ++renderErrors; else for(unsigned b=0;bmNumberBuffers;++b) - if(list->mBuffers[b].mDataByteSize!=frames*sizeof(float)) ++badBuffers; + if(list->mBuffers[b].mDataByteSize!=frames*sizeof(float)*list->mBuffers[b].mNumberChannels) ++badBuffers; totalFrames+=frames; return noErr; } @@ -95,19 +96,24 @@ static int device(AudioDeviceID* out) { return channels[0]!=2 || channels[1]!=0 || status || !streams || !listed; } -static int record(AudioDeviceID id,bool nonInterleaved,unsigned milliseconds) { +static int record(AudioDeviceID id,bool nonInterleaved,unsigned channels,unsigned milliseconds) { AudioComponentDescription description={kAudioUnitType_Output,kAudioUnitSubType_HALOutput,kAudioUnitManufacturer_Apple,0,0}; AudioComponent component=AudioComponentFindNext(nullptr,&description); if(!component || AudioComponentInstanceNew(component,&unit) || !unit) return 1; - planar=nonInterleaved;callbacks=0;renderErrors=0;nonIncreasing=0;zeroFrames=0;badBuffers=0;totalFrames=0;lastSample=-1; + planar=nonInterleaved;interleavedChannels=channels;callbacks=0;renderErrors=0;nonIncreasing=0;zeroFrames=0;badBuffers=0;totalFrames=0;lastSample=-1; UInt32 on=1,off=0; if(AudioUnitSetProperty(unit,kAudioOutputUnitProperty_EnableIO,kAudioUnitScope_Input,1,&on,sizeof(on))) return 2; if(AudioUnitSetProperty(unit,kAudioOutputUnitProperty_EnableIO,kAudioUnitScope_Output,0,&off,sizeof(off))) return 3; - if(AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&id,sizeof(id))) return 4; + // Vivox (the interleaved stereo run) sets the capture device on element 1. + const AudioUnitElement deviceElement=channels==2?1:0; + if(AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,deviceElement,&id,sizeof(id))) return 4; AudioDeviceID current=0;UInt32 size=sizeof(current); - if(AudioUnitGetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,¤t,&size) || current!=id) return 5; + if(AudioUnitGetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,deviceElement,¤t,&size) || current!=id) return 5; AudioStreamBasicDescription format=nonInterleaved ? AudioStreamBasicDescription{44100,kAudioFormatLinearPCM,kAudioFormatFlagIsFloat|kAudioFormatFlagIsPacked|kAudioFormatFlagIsNonInterleaved,4,1,4,2,32,0} + : channels==2 + // Vivox's recording format: interleaved Float32 stereo at the device rate. + ? AudioStreamBasicDescription{44100,kAudioFormatLinearPCM,kAudioFormatFlagIsFloat|kAudioFormatFlagIsPacked,8,1,8,2,32,0} : AudioStreamBasicDescription{48000,kAudioFormatLinearPCM,kAudioFormatFlagIsFloat|kAudioFormatFlagIsPacked,4,1,4,1,32,0}; OSStatus status=AudioUnitSetProperty(unit,kAudioUnitProperty_StreamFormat,kAudioUnitScope_Output,1,&format,sizeof(format)); if(status) {printf("AUDIO_INPUT_FORMAT status=%d\n",status);return 6;} @@ -126,8 +132,8 @@ static int record(AudioDeviceID id,bool nonInterleaved,unsigned milliseconds) { AudioUnitUninitialize(unit); AudioComponentInstanceDispose(unit); double seconds=double(totalFrames)/format.mSampleRate; - printf("AUDIO_INPUT_RECORD planar=%d rate=%.0f callbacks=%u frames=%llu seconds=%.3f render_errors=%u bad_buffers=%u " - "nonincreasing=%u zero_frames=%u stop_status=%d quiet_after_stop=%d\n",nonInterleaved,format.mSampleRate,count, + printf("AUDIO_INPUT_RECORD planar=%d channels=%u rate=%.0f callbacks=%u frames=%llu seconds=%.3f render_errors=%u bad_buffers=%u " + "nonincreasing=%u zero_frames=%u stop_status=%d quiet_after_stop=%d\n",nonInterleaved,(unsigned)format.mChannelsPerFrame,format.mSampleRate,count, (unsigned long long)totalFrames,seconds,(unsigned)renderErrors,(unsigned)badBuffers,(unsigned)nonIncreasing, (unsigned)zeroFrames,stopped,quiet); fflush(stdout); @@ -141,12 +147,15 @@ int main() { failures+=authorization()!=0; AudioDeviceID id=kAudioObjectUnknown; if(device(&id)) return 100+failures; - int result=record(id,false,1000); + int result=record(id,false,1,1000); if(result) printf("AUDIO_INPUT_RECORD failed step=%d\n",result); failures+=result!=0; - result=record(id,true,400); + result=record(id,true,2,400); if(result) printf("AUDIO_INPUT_RECORD failed step=%d planar=1\n",result); failures+=result!=0; + result=record(id,false,2,400); + if(result) printf("AUDIO_INPUT_RECORD failed step=%d interleaved_stereo=1\n",result); + failures+=result!=0; printf("AUDIO_INPUT_CHECK failures=%d\n",failures); return failures; } From fd8732d4bc0f5fee24e66fe0055e5e869f5ac3df Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:17:22 +0200 Subject: [PATCH 107/160] update.sh: replace the shared serial and UUID automatically Older setups shipped serial C02ZL0GVMD6N and UUID 6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B to every install. After installing, update.sh now runs configure-darling-platform.py --replace-known-defaults, which provisions the prefix only when its saved identity still has one of those values. Other custom values (imported serials/UUIDs, model, board, manufacturer, ROM, MLB) are kept; prefixes with no saved identity or only custom values are not touched. provision() also drops a standalone system-id or provisioning_UDID derived from the shared UUID instead of keeping it. Co-Authored-By: Claude Opus 5.5 --- docs/PLATFORM_IDENTITY.md | 10 ++++++-- scripts/update.sh | 9 +++++++ tests/test_platform_provisioning.py | 40 +++++++++++++++++++++++++++++ tools/configure-darling-platform.py | 30 +++++++++++++++++++--- 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md index 3d132ea..111afa5 100644 --- a/docs/PLATFORM_IDENTITY.md +++ b/docs/PLATFORM_IDENTITY.md @@ -90,7 +90,12 @@ python3 tools/configure-darling-platform.py --prefix /absolute/prefix --provisio Launch Darling normally afterwards. This requires the updated iokitd from this PR; the config tool alone cannot update an older running daemon. Provisioning -is never called automatically by installation or launch. +is never called automatically by installation or launch. The one exception is +`scripts/update.sh`: after installing, it runs +`configure-darling-platform.py --replace-known-defaults --write`, which +provisions the prefix only if its saved identity still contains the shared +serial or UUID below. Prefixes without saved identity files, or whose saved +values are all custom, are left byte-for-byte unchanged. Missing serials use 12 characters and missing MLBs use 17 characters, sampled with Python's `secrets` module from `0123456789ABCDEFGHJKLMNPQRSTUVWXYZ` (Base-34). @@ -106,7 +111,8 @@ example with your chosen unicast OUI). It only affects a missing ROM. An OUI does not make a generated identity Apple-issued. Provisioning replaces the known shared serial `C02ZL0GVMD6N` and UUID -`6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B`, along with the latter's derived fields. +`6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B`, along with the latter's derived fields +(including a standalone `system-id` or `provisioning_UDID` derived from it). It preserves other existing custom values, including imported serials and UUIDs that do not follow the generated formats. A standalone custom `system-id` is preserved by deriving its UUID instead of generating a different one. Model, diff --git a/scripts/update.sh b/scripts/update.sh index f8e997c..62d0c5c 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -7,6 +7,13 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" +# Older setups shipped one serial and UUID shared by every install. Give a prefix that still +# has them its own identity; any other saved values, and prefixes without them, are left alone. +replace_shared_identity() { + python3 build/runtime/tools/stop-darling-runtime.py + python3 tools/configure-darling-platform.py --replace-known-defaults --write || \ + echo 'Could not replace the shared platform identity; run tools/configure-darling-platform.py --provision --write' >&2 +} if [ -n "${DARLING_WORKSPACE:-}" ]; then python3 scripts/nix-idle.py # Initial setup and upgrades use the same supported user-owned install path. @@ -21,6 +28,7 @@ if [ -n "${DARLING_WORKSPACE:-}" ]; then cmake --install build/darling-src/build python3 scripts/nix-state.py stamp-base ./scripts/install-runtime.sh + replace_shared_identity echo 'Rootless update installed. In this shell use scripts/run.sh; otherwise exit this shell and start darling-lol.' exit 0 fi @@ -45,4 +53,5 @@ PY python3 build/runtime/tools/stop-darling-runtime.py sudo cmake --install build/darling-src/build ./scripts/install-runtime.sh +replace_shared_identity echo 'Update installed. Start Darling with: sh scripts/run.sh' diff --git a/tests/test_platform_provisioning.py b/tests/test_platform_provisioning.py index 48bd0c0..ce195b5 100644 --- a/tests/test_platform_provisioning.py +++ b/tests/test_platform_provisioning.py @@ -157,3 +157,43 @@ class ProvisioningChecks(unittest.TestCase): self.assertEqual(platform.provision(supplied)['IOPlatformUUID'], value['IOPlatformUUID']) with self.assertRaises(ValueError): platform.validate({'IOPlatformUUID': value['IOPlatformUUID'], 'provisioning_UDID': str(uuid.uuid4())}) + + def test_replace_known_defaults_only_touches_shared_identities(self): + replace = [sys.executable, str(SCRIPT), '--prefix', str(self.prefix), '--runtime-root', + str(self.runtime), '--replace-known-defaults', '--write'] + # A prefix without identity files is not created or provisioned. + result = subprocess.run(replace, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.prefix.exists()) + # A custom identity stays byte-for-byte unchanged. + custom = {'IOPlatformSerialNumber': 'MY-SERIAL', 'model': 'CustomModel'} + platform.save(self.prefix, custom) + canonical = platform.destination(self.prefix) + before = canonical.read_bytes() + result = subprocess.run(replace, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(canonical.read_bytes(), before) + self.assertFalse((self.prefix / 'private/etc/darling-smbios.conf').exists()) + # The shared values are replaced; custom fields survive and a rerun is stable. + platform.save(self.prefix, {**custom, 'IOPlatformSerialNumber': platform.KNOWN_SERIAL, + 'IOPlatformUUID': platform.KNOWN_UUID}) + result = subprocess.run(replace, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + value = platform.load(canonical) + self.assertNotEqual(value['IOPlatformSerialNumber'], platform.KNOWN_SERIAL) + self.assertNotEqual(value['IOPlatformUUID'], platform.KNOWN_UUID) + self.assertFalse(platform.has_known_defaults(value)) + self.assertEqual(value['model'], 'CustomModel') + self.assertNotIn('board-id', value) + self.assertNotIn('manufacturer', value) + self.assertEqual(platform.load(self.prefix / 'private/etc/darling-smbios.conf'), value) + after = canonical.read_bytes() + subprocess.run(replace, capture_output=True, text=True, check=True) + self.assertEqual(canonical.read_bytes(), after) + + def test_standalone_fields_derived_from_known_uuid_are_replaced(self): + known_id = uuid.UUID(platform.KNOWN_UUID).bytes_le + for supplied in [{'system-id': known_id}, {'provisioning_UDID': platform.KNOWN_UUID}]: + value = platform.provision(supplied) + self.assertNotEqual(value['IOPlatformUUID'], platform.KNOWN_UUID) + self.assertNotEqual(value['system-id'], known_id) diff --git a/tools/configure-darling-platform.py b/tools/configure-darling-platform.py index 7332dca..9b3408a 100644 --- a/tools/configure-darling-platform.py +++ b/tools/configure-darling-platform.py @@ -182,13 +182,23 @@ def save(prefix, properties): return atomic_write(destination(prefix), encoded) +def has_known_defaults(properties): + """True when an identity still carries the shared serial, UUID, or a field derived from that UUID.""" + return (properties.get('IOPlatformSerialNumber') == KNOWN_SERIAL + or str(properties.get('IOPlatformUUID', '')).upper() == KNOWN_UUID + or str(properties.get('provisioning_UDID', '')).upper() == KNOWN_UUID + or properties.get('system-id') == uuid.UUID(KNOWN_UUID).bytes_le) + + def provision(properties, rom_oui=None): """Fill missing identifiers, replacing only the two known shared defaults.""" result = validate(properties) if result.get('IOPlatformSerialNumber') == KNOWN_SERIAL: result.pop('IOPlatformSerialNumber') - if result.get('IOPlatformUUID', '').upper() == KNOWN_UUID: - result.pop('IOPlatformUUID') + if (result.get('IOPlatformUUID', '').upper() == KNOWN_UUID + or result.get('provisioning_UDID', '').upper() == KNOWN_UUID + or result.get('system-id') == uuid.UUID(KNOWN_UUID).bytes_le): + result.pop('IOPlatformUUID', None) result.pop('system-id', None) result.pop('provisioning_UDID', None) for key, length in [('IOPlatformSerialNumber', 12), ('mlb-serial-number', 17)]: @@ -274,8 +284,14 @@ def main(argv=None): parser.add_argument('--provision', action='store_true', help='Generate missing synthetic identifiers and provision prefix metadata') parser.add_argument('--runtime-root', type=Path, help='Runtime supplying SystemVersion.plist (default: saved SDK or /usr/local/libexec/darling)') parser.add_argument('--rom-oui', help='Explicit three-byte unicast OUI for a missing ROM; default is locally administered') + parser.add_argument('--replace-known-defaults', action='store_true', + help='Provision only if the prefix identity still has the shared serial or UUID; otherwise change nothing') parser.add_argument('--write', action='store_true', help='Write validated overrides to a stopped prefix (otherwise preview only)') args = parser.parse_args(argv) + if args.replace_known_defaults: + if args.source or args.set: + parser.error('--replace-known-defaults cannot be combined with --import or --set') + args.provision = True config = Path(__file__).resolve().parents[1] / 'build/config.json' saved = json.loads(config.read_text()) if config.exists() else {} prefix = args.prefix or Path(os.environ.get('DPREFIX') or saved.get('prefix', str(Path.home() / '.darling'))) @@ -288,11 +304,19 @@ def main(argv=None): rom_oui = bytes.fromhex(args.rom_oui.replace(':', '').replace('-', '')) if len(rom_oui) != 3 or rom_oui[0] & 1: raise ValueError('--rom-oui requires exactly three unicast bytes') + identity_files = [checked_path(prefix, 'private/etc/darling-smbios.conf'), destination(prefix)] + if args.replace_known_defaults and not any(path.exists() for path in identity_files): + print('No saved platform identity; nothing to replace.') + return 0 with prefix_lock(prefix) if args.write else nullcontext(): path = destination(prefix) properties = {} + if args.replace_known_defaults and not any( + path.exists() and has_known_defaults(load(path)) for path in identity_files): + print('Platform identity has no shared defaults; nothing changed.') + return 0 if args.provision: - legacy = checked_path(prefix, 'private/etc/darling-smbios.conf') + legacy = identity_files[0] if legacy.exists(): properties.update(load(legacy)) if path.exists(): From c068e32152bd6b83b7e81dcef515bff980720b37 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:22:07 +0200 Subject: [PATCH 108/160] install-runtime.sh: provision a platform identity for new prefixes When install-runtime.sh creates the prefix (first-time setup, regular or Nix), it now runs configure-darling-platform.py --provision --write once the runtime is stopped, so each new install gets its own synthetic serial, MLB, UUID and ROM. A prefix counts as new the same way Darling decides it: the directory did not exist before the first `darling shell`. Existing prefixes keep their identity on reinstall and update. Co-Authored-By: Claude Opus 5.5 --- docs/PLATFORM_IDENTITY.md | 17 ++++++++++------- scripts/install-runtime.sh | 9 +++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md index 111afa5..5f25849 100644 --- a/docs/PLATFORM_IDENTITY.md +++ b/docs/PLATFORM_IDENTITY.md @@ -1,9 +1,12 @@ # Explicit platform identity overrides -By default, Darling keeps the existing host-derived UUID and `DARLING-` serial. -No override file is generated by installation or launch. Import mode uses values supplied by the user. Explicit `--provision` mode can -generate synthetic identifiers; it does not issue authentic Apple identities -or change CPU, GPU, or security metadata. +First-time setup (`scripts/install-runtime.sh` creating a new prefix) runs +`--provision --write` once, so each new prefix gets its own synthetic identity. +A prefix without saved identity files keeps the host-derived UUID and +`DARLING-` serial; launching never generates an override. Import mode uses +values supplied by the user. `--provision` generates synthetic identifiers; it +does not issue authentic Apple identities or change CPU, GPU, or security +metadata. Preview an OpenCore `config.plist`, a flat GenSMBIOS JSON file, or an existing `darling-smbios.conf` without changing the selected prefix: @@ -89,9 +92,9 @@ python3 tools/configure-darling-platform.py --prefix /absolute/prefix --provisio ``` Launch Darling normally afterwards. This requires the updated iokitd from this -PR; the config tool alone cannot update an older running daemon. Provisioning -is never called automatically by installation or launch. The one exception is -`scripts/update.sh`: after installing, it runs +PR; the config tool alone cannot update an older running daemon. Besides +first-time setup above, provisioning runs automatically in one other case: +after installing, `scripts/update.sh` runs `configure-darling-platform.py --replace-known-defaults --write`, which provisions the prefix only if its saved identity still contains the shared serial or UUID below. Prefixes without saved identity files, or whose saved diff --git a/scripts/install-runtime.sh b/scripts/install-runtime.sh index 677b2ab..9628760 100755 --- a/scripts/install-runtime.sh +++ b/scripts/install-runtime.sh @@ -16,6 +16,9 @@ if [ -d build/venv/bin ]; then PATH="$repo/build/venv/bin:$PATH"; export PATH; f # 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 treats a missing prefix directory as new; only then is an identity generated below. +new_prefix= +[ -e "$prefix_dir" ] || new_prefix=1 darling shell /usr/bin/true # The prefix has no /private/tmp of its own, so the guest falls through to the root-owned # installed layer and every write there (the Riot installer's temp files) fails with EACCES. @@ -26,6 +29,12 @@ chmod 1777 "$prefix/private/tmp" python3 build/runtime/tools/ensure-darling-root-certificates.py || \ echo 'Darling system root store not installed; the launcher retries it' >&2 python3 build/runtime/tools/stop-darling-runtime.py +# Give a new prefix its own synthetic serial, MLB, UUID and ROM instead of the host-derived defaults. +# Existing prefixes keep their identity; update.sh only replaces the old shared serial and UUID. +if [ -n "$new_prefix" ]; then + python3 tools/configure-darling-platform.py --prefix "$prefix_dir" --provision --write || \ + echo 'Platform identity not provisioned; run tools/configure-darling-platform.py --provision --write' >&2 +fi sh build/runtime/tools/install-riot-presentation-runtime.sh sh build/runtime/tools/install-riot-gpu-runtime.sh # darling-lol perf: build and install optimized libc/libc++/libplatform. From d525aba5f6935d9c6fface558eb768cf6d86c8fe Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:25:23 +0200 Subject: [PATCH 109/160] AUHAL: only accept float or signed integer recording formats RenderBus pads missing frames with zeros, which is loud for unsigned PCM (silence is 0x80 for 8-bit, 0x8000 for 16-bit). Reject unsigned and 8-bit formats on element 1; voice engines use float or signed 16-bit. Co-Authored-By: Claude Opus 5.5 --- patches/darling.patch | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/patches/darling.patch b/patches/darling.patch index abb04c6..839e6c1 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -2572,7 +2572,7 @@ index ff83d1e15..1e86f9e99 100644 bool m_suspended = true; }; diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp -index fe4d1eaa1..ac8d76c0b 100644 +index fe4d1eaa1..1ee1da9c5 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp @@ -1,3 +1,4 @@ @@ -2647,7 +2647,7 @@ index fe4d1eaa1..ac8d76c0b 100644 bool AUHAL::CanScheduleParameters() const { return true; -@@ -49,7 +93,32 @@ bool AUHAL::CanScheduleParameters() const +@@ -49,7 +93,33 @@ bool AUHAL::CanScheduleParameters() const bool AUHAL::StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) { @@ -2658,8 +2658,9 @@ index fe4d1eaa1..ac8d76c0b 100644 +// AUBase only accepts the canonical layout (Float32, non-interleaved). A real AUHAL +// converts any linear PCM, and voice engines (Vivox) ask for interleaved Float32 on the +// recording side, so element 1 accepts whatever PulseAudio records and RenderBus() copies: -+// native-endian float32 or 8/16/32-bit integers, either layout (24-bit is left out: -+// PulseAudio may read it as 4-byte samples while RenderBus() copies 3). ++// native-endian float32 or signed 16/32-bit integers, either layout. RenderBus() pads with ++// zeros, which is only silence for float and signed samples, so unsigned (and 8-bit) formats ++// are left out; so is 24-bit (PulseAudio may read 4-byte samples while RenderBus() copies 3). +// Element 0 (playback) keeps the canonical rule. +bool AUHAL::ValidFormat(AudioUnitScope scope, AudioUnitElement element, const CAStreamBasicDescription& format) +{ @@ -2673,7 +2674,7 @@ index fe4d1eaa1..ac8d76c0b 100644 + || format.mSampleRate <= 0 || format.mChannelsPerFrame < 1 || format.mChannelsPerFrame > 32 + || (flags & kAudioFormatFlagIsBigEndian) != (kAudioFormatFlagsNativeEndian & kAudioFormatFlagIsBigEndian)) + return false; -+ if (isFloat ? bits != 32 : (bits != 8 && bits != 16 && bits != 32)) ++ if (isFloat ? bits != 32 : (!(flags & kAudioFormatFlagIsSignedInteger) || (bits != 16 && bits != 32))) + return false; + + const UInt32 frameBytes = (flags & kAudioFormatFlagIsNonInterleaved) ? bits / 8 : format.mChannelsPerFrame * bits / 8; @@ -2681,7 +2682,7 @@ index fe4d1eaa1..ac8d76c0b 100644 } OSStatus AUHAL::Version() -@@ -57,45 +126,112 @@ OSStatus AUHAL::Version() +@@ -57,45 +127,112 @@ OSStatus AUHAL::Version() return 1; } @@ -2809,7 +2810,7 @@ index fe4d1eaa1..ac8d76c0b 100644 } m_running = m_enableOutput || m_enableInput; -@@ -111,20 +247,49 @@ OSStatus AUHAL::Stop() +@@ -111,20 +248,49 @@ OSStatus AUHAL::Stop() { AudioDeviceStop(m_outputDevice, m_outputProcID); AudioDeviceDestroyIOProcID(m_outputDevice, m_outputProcID); @@ -2860,7 +2861,7 @@ index fe4d1eaa1..ac8d76c0b 100644 case kAudioOutputUnitProperty_SetInputCallback: { ca_require(inDataSize == sizeof(AURenderCallbackStruct), InvalidPropertyValue); -@@ -161,7 +326,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -161,7 +327,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au ca_require(inDataSize == sizeof(AudioDeviceID), InvalidPropertyValue); const AudioDeviceID* dev = static_cast(inData); @@ -2876,7 +2877,7 @@ index fe4d1eaa1..ac8d76c0b 100644 { m_outputDevice = *dev; PropertyChanged(inID, inScope, inElement); -@@ -176,20 +348,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -176,20 +349,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au return noErr; } } @@ -2907,7 +2908,7 @@ index fe4d1eaa1..ac8d76c0b 100644 } case kAudioOutputUnitProperty_EnableIO: { -@@ -210,13 +390,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope +@@ -210,13 +391,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope return noErr; } } @@ -2932,7 +2933,7 @@ index fe4d1eaa1..ac8d76c0b 100644 case kAudioOutputUnitProperty_SetInputCallback: { memcpy(outData, &m_outputAvailableCb, sizeof(m_outputAvailableCb)); -@@ -228,12 +418,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -228,12 +419,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au if (inElement == kOutputBus) { UInt32 value = m_enableOutput; @@ -2947,7 +2948,7 @@ index fe4d1eaa1..ac8d76c0b 100644 } else return kAudioUnitErr_InvalidElement; -@@ -241,7 +431,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -241,7 +432,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au } case kAudioOutputUnitProperty_CurrentDevice: { @@ -2960,7 +2961,7 @@ index fe4d1eaa1..ac8d76c0b 100644 { memcpy(outData, &m_outputDevice, sizeof(m_outputDevice)); } -@@ -277,9 +471,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, +@@ -277,9 +472,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, return This->doRecord(inNow, inInputData, inInputTime); } @@ -3050,7 +3051,7 @@ index fe4d1eaa1..ac8d76c0b 100644 if (!HasInput(0)) { // std::cerr << "No connection\n"; -@@ -290,9 +564,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -290,9 +565,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp AudioUnitRenderActionFlags flags = kAudioUnitRenderAction_PreRender; const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Input, kOutputBus); @@ -3129,7 +3130,7 @@ index fe4d1eaa1..ac8d76c0b 100644 // std::cout << "Pull result: " << result << std::endl; // std::cout << "Bytes: " << outOutputData->mBuffers[0].mDataByteSize << std::endl; // m_auhalData.write((char*) outOutputData->mBuffers[0].mData, outOutputData->mBuffers[0].mDataByteSize); -@@ -303,30 +643,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -303,30 +644,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp OSStatus AUHAL::doRecord(const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime) { From 74723556efaa821e08effc828bf7ac7c6bf6e328 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:41:10 +0200 Subject: [PATCH 110/160] AUHAL: accept Vivox's playback format and trace recorded levels The issue #75 trace shows recording now runs, but Vivox's playback unit (mic test loopback, other players' voices) sets interleaved Float32 stereo at 32 kHz on input scope element 0 and got -10868. Apply the relaxed rule to both client sides (input scope element 0, output scope element 1); the playback path already interleaves for the device and PulseAudio resamples. AudioUnitRender(bus 1) now traces frames and peak level (no samples) so a log shows whether the client pulls sound or silence. The input check also sets Vivox's playback format and expects unsigned 16-bit to be refused. Co-Authored-By: Claude Opus 5.5 --- patches/darling.patch | 68 +++++++++++++++++++++----------- tools/riot-audio-input-check.cpp | 18 +++++++++ 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/patches/darling.patch b/patches/darling.patch index 839e6c1..ae2cfd8 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -2572,7 +2572,7 @@ index ff83d1e15..1e86f9e99 100644 bool m_suspended = true; }; diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp -index fe4d1eaa1..1ee1da9c5 100644 +index fe4d1eaa1..769b06586 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp @@ -1,3 +1,4 @@ @@ -2647,7 +2647,7 @@ index fe4d1eaa1..1ee1da9c5 100644 bool AUHAL::CanScheduleParameters() const { return true; -@@ -49,7 +93,33 @@ bool AUHAL::CanScheduleParameters() const +@@ -49,7 +93,36 @@ bool AUHAL::CanScheduleParameters() const bool AUHAL::StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) { @@ -2656,15 +2656,18 @@ index fe4d1eaa1..1ee1da9c5 100644 +} + +// AUBase only accepts the canonical layout (Float32, non-interleaved). A real AUHAL -+// converts any linear PCM, and voice engines (Vivox) ask for interleaved Float32 on the -+// recording side, so element 1 accepts whatever PulseAudio records and RenderBus() copies: -+// native-endian float32 or signed 16/32-bit integers, either layout. RenderBus() pads with -+// zeros, which is only silence for float and signed samples, so unsigned (and 8-bit) formats -+// are left out; so is 24-bit (PulseAudio may read 4-byte samples while RenderBus() copies 3). -+// Element 0 (playback) keeps the canonical rule. ++// converts any linear PCM, and Vivox asks for interleaved Float32 on both client sides ++// (recording: output scope element 1; playback: input scope element 0, at its own rate). ++// Those accept what PulseAudio converts and our copies handle: native-endian float32 or ++// signed 16/32-bit integers, either layout. Missing frames are padded with zeros, which is ++// only silence for float and signed samples, so unsigned (and 8-bit) formats are left out; ++// so is 24-bit (PulseAudio may use 4-byte samples while we copy 3). Device sides keep the ++// canonical rule. +bool AUHAL::ValidFormat(AudioUnitScope scope, AudioUnitElement element, const CAStreamBasicDescription& format) +{ -+ if (element != kInputBus || scope == kAudioUnitScope_Global) ++ const bool clientSide = (scope == kAudioUnitScope_Output && element == kInputBus) ++ || (scope == kAudioUnitScope_Input && element == kOutputBus); ++ if (!clientSide) + return AUBase::ValidFormat(scope, element, format); + + const UInt32 flags = format.mFormatFlags; @@ -2682,7 +2685,7 @@ index fe4d1eaa1..1ee1da9c5 100644 } OSStatus AUHAL::Version() -@@ -57,45 +127,112 @@ OSStatus AUHAL::Version() +@@ -57,45 +130,129 @@ OSStatus AUHAL::Version() return 1; } @@ -2709,11 +2712,7 @@ index fe4d1eaa1..1ee1da9c5 100644 + std::lock_guard lk(m_dataAvailableMutex); + const UInt32 frames = std::min(inNumberFrames, m_bufferUsed / frameBytes); + const uint8_t* source = m_buffer.get(); - -- // TODO: Prepare for non-interleaved audio -- UInt32 howMuch = std::min(abl.mBuffers[0].mDataByteSize, m_bufferUsed); -- memcpy(abl.mBuffers[0].mData, m_buffer.get(), howMuch); -- abl.mBuffers[0].mDataByteSize = howMuch; ++ + if (planar) + { + for (UInt32 c = 0; c < abl.mNumberBuffers && c < desc.mChannelsPerFrame; c++) @@ -2731,6 +2730,27 @@ index fe4d1eaa1..1ee1da9c5 100644 + memcpy(destination, source, frames * frameBytes); + memset(destination + frames * frameBytes, 0, (inNumberFrames - frames) * frameBytes); + abl.mBuffers[0].mDataByteSize = inNumberFrames * frameBytes; ++ } + +- // TODO: Prepare for non-interleaved audio +- UInt32 howMuch = std::min(abl.mBuffers[0].mDataByteSize, m_bufferUsed); +- memcpy(abl.mBuffers[0].mData, m_buffer.get(), howMuch); +- abl.mBuffers[0].mDataByteSize = howMuch; ++ // Level only (no samples): shows whether the client pulls real sound or silence. ++ static std::atomic renders{0}; ++ const unsigned n = renders++; ++ if (rclolAudioTraceEnabled() && (n < 40 || n % 1000 == 0)) ++ { ++ float peak = 0; ++ const UInt32 count = frames * desc.mChannelsPerFrame; ++ if (desc.IsFloat() && sampleBytes == 4) ++ for (UInt32 i = 0; i < count; i++) ++ peak = std::max(peak, std::abs(reinterpret_cast(source)[i])); ++ else if (sampleBytes == 2) ++ for (UInt32 i = 0; i < count; i++) ++ peak = std::max(peak, std::abs(reinterpret_cast(source)[i] / 32768.0f)); ++ rclolAudioTrace("AUDIO_INPUT_RENDER n=%u frames=%u available=%u planar=%d peak=%.4f\n", ++ n, inNumberFrames, frames, (int) planar, peak); + } return noErr; @@ -2810,7 +2830,7 @@ index fe4d1eaa1..1ee1da9c5 100644 } m_running = m_enableOutput || m_enableInput; -@@ -111,20 +248,49 @@ OSStatus AUHAL::Stop() +@@ -111,20 +268,49 @@ OSStatus AUHAL::Stop() { AudioDeviceStop(m_outputDevice, m_outputProcID); AudioDeviceDestroyIOProcID(m_outputDevice, m_outputProcID); @@ -2861,7 +2881,7 @@ index fe4d1eaa1..1ee1da9c5 100644 case kAudioOutputUnitProperty_SetInputCallback: { ca_require(inDataSize == sizeof(AURenderCallbackStruct), InvalidPropertyValue); -@@ -161,7 +327,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -161,7 +347,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au ca_require(inDataSize == sizeof(AudioDeviceID), InvalidPropertyValue); const AudioDeviceID* dev = static_cast(inData); @@ -2877,7 +2897,7 @@ index fe4d1eaa1..1ee1da9c5 100644 { m_outputDevice = *dev; PropertyChanged(inID, inScope, inElement); -@@ -176,20 +349,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -176,20 +369,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au return noErr; } } @@ -2908,7 +2928,7 @@ index fe4d1eaa1..1ee1da9c5 100644 } case kAudioOutputUnitProperty_EnableIO: { -@@ -210,13 +391,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope +@@ -210,13 +411,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope return noErr; } } @@ -2933,7 +2953,7 @@ index fe4d1eaa1..1ee1da9c5 100644 case kAudioOutputUnitProperty_SetInputCallback: { memcpy(outData, &m_outputAvailableCb, sizeof(m_outputAvailableCb)); -@@ -228,12 +419,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -228,12 +439,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au if (inElement == kOutputBus) { UInt32 value = m_enableOutput; @@ -2948,7 +2968,7 @@ index fe4d1eaa1..1ee1da9c5 100644 } else return kAudioUnitErr_InvalidElement; -@@ -241,7 +432,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -241,7 +452,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au } case kAudioOutputUnitProperty_CurrentDevice: { @@ -2961,7 +2981,7 @@ index fe4d1eaa1..1ee1da9c5 100644 { memcpy(outData, &m_outputDevice, sizeof(m_outputDevice)); } -@@ -277,9 +472,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, +@@ -277,9 +492,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, return This->doRecord(inNow, inInputData, inInputTime); } @@ -3051,7 +3071,7 @@ index fe4d1eaa1..1ee1da9c5 100644 if (!HasInput(0)) { // std::cerr << "No connection\n"; -@@ -290,9 +565,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -290,9 +585,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp AudioUnitRenderActionFlags flags = kAudioUnitRenderAction_PreRender; const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Input, kOutputBus); @@ -3130,7 +3150,7 @@ index fe4d1eaa1..1ee1da9c5 100644 // std::cout << "Pull result: " << result << std::endl; // std::cout << "Bytes: " << outOutputData->mBuffers[0].mDataByteSize << std::endl; // m_auhalData.write((char*) outOutputData->mBuffers[0].mData, outOutputData->mBuffers[0].mDataByteSize); -@@ -303,30 +644,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -303,30 +664,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp OSStatus AUHAL::doRecord(const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime) { diff --git a/tools/riot-audio-input-check.cpp b/tools/riot-audio-input-check.cpp index 667abdf..5533694 100644 --- a/tools/riot-audio-input-check.cpp +++ b/tools/riot-audio-input-check.cpp @@ -142,6 +142,23 @@ static int record(AudioDeviceID id,bool nonInterleaved,unsigned channels,unsigne || seconds Date: Mon, 19 Oct 2026 00:58:45 +0200 Subject: [PATCH 111/160] tests: expect the shared-identity step at the end of rootless update #79 made update.sh stop Darling again after the overlays and run configure-darling-platform.py --replace-known-defaults --write. The rootless update test still expected the old call list and failed. Stub the identity tool, expect the stop + identity calls last, and check a failed install never reaches them. Co-Authored-By: Claude Opus 5.5 --- tests/test_nix_repairs.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index 1a6e8c4..df02a09 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -491,6 +491,9 @@ class SessionTests(unittest.TestCase): path.chmod(0o755) (scripts / 'nix-state.py').write_text('import os, sys\nwith open(os.environ["TEST_LOG"], "a") as f: f.write(sys.argv[1]+"\\n")\n') (tools / 'stop-darling-runtime.py').write_text('import os\nwith open(os.environ["TEST_LOG"], "a") as f: f.write("stop\\n")\n') + (root / 'tools').mkdir() + (root / 'tools/configure-darling-platform.py').write_text( + 'import os, sys\nwith open(os.environ["TEST_LOG"], "a") as f: f.write("identity " + " ".join(sys.argv[1:]) + "\\n")\n') for name in ['cmake', 'sudo', 'nix-store']: path = bin_dir / name path.write_text('#!/bin/sh\necho ' + name + ' >> "$TEST_LOG"\n' + @@ -502,12 +505,15 @@ class SessionTests(unittest.TestCase): result = run('sh', str(scripts / 'update.sh'), env=env) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(log.read_text().splitlines(), - ['nix-store', 'invalidate-base', 'build', 'stop', 'cmake', 'stamp-base', 'overlays']) + ['nix-store', 'invalidate-base', 'build', 'stop', 'cmake', 'stamp-base', 'overlays', + # The shared serial/UUID replacement writes the prefix, so Darling is stopped first. + 'stop', 'identity --replace-known-defaults --write']) log.write_text('') env['INSTALL_EXIT'] = '23' self.assertEqual(run('sh', str(scripts / 'update.sh'), env=env).returncode, 23) self.assertNotIn('stamp-base', log.read_text()) self.assertNotIn('overlays', log.read_text()) + self.assertNotIn('identity', log.read_text()) self.assertNotIn('sudo', log.read_text()) def test_rootless_update_never_uses_sudo_and_stamps_only_after_success(self): From 3142128575bd1c6824578f588f882cd35bb2dcbe Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:10:03 +0200 Subject: [PATCH 112/160] Fix Metal VSync propagation to window swap intervals --- docs/TESTING.md | 20 +++- docs/TROUBLESHOOTING.md | 15 +++ patches/cocotron.patch | 44 ++++++-- patches/darling.patch | 58 +++++------ tests/test_metal_vsync.py | 139 +++++++++++++++++++++++++ tools/riot-metal-presentation-check.mm | 6 ++ 6 files changed, 243 insertions(+), 39 deletions(-) create mode 100644 tests/test_metal_vsync.py diff --git a/docs/TESTING.md b/docs/TESTING.md index b1d7df4..7ab764c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -167,7 +167,7 @@ Useful probes (build tokenized Python helpers from `build/runtime/tools/`): - `riot-event-coalescing-check`: preserve key events and latest mouse motion. - `m327-cgl-resolver-check`: first calls through dynamically looked-up GL functions retain floating-point arguments; expected `first_sampler_linear=1 first_color_arguments=1 errors=0,0`. Build with `build-m58f327-opengl.py` and run with `run-riot-native-check.py m327-cgl-resolver-check`. - `m327-cgl-direct-check`: with `RCLOL_GPU_DIRECT_OTHER_WINDOW_TEST=1` and an owned 337x339 native target, interleave 338x339 fallback surfaces; expected `active_presenter_preserved=1 fallback_frames=357`. This needs the native presentation fixture; it is not a `run-riot-native-check.py` choice. -- `riot-metal-presentation-check`: Vulkan clear reaches the GL texture, including late window/context attachment. Set `RIOT_PRESENTATION_NESTED=1` to place the Metal layer beneath two ordinary layers and verify that its drawables are still presented and recycled past the three-drawable pool limit. +- `riot-metal-presentation-check`: Vulkan clear reaches the GL texture, including late window/context attachment. Each frame toggles `displaySyncEnabled` and checks the CGL window swap interval. Set `RIOT_PRESENTATION_NESTED=1` to place the Metal layer beneath two ordinary layers and verify that its drawables are still presented and recycled past the three-drawable pool limit. - `riot-ca-render-policy-check`: headless check of the actual compositor render policy. A nested Metal layer must still be prepared when a full-window IOSurface is present; surface-only trees should retain the local-render shortcut. Built by `build-riot-metal-presentation.py`; expected `PASS: mixed-tree Metal preparation survives IOSurface shortcut`. Creates no window or GPU work and does not publish its test surface. - `riot-metal-concurrency-check`: submission ordering, resource lifetime and concurrent recording. @@ -533,3 +533,21 @@ launch, window placement, click alignment and edge panning before rollout. After installing the tested X11 and CoreGraphics binaries locally, the user reported that everything was working (2026-10-16). Fullscreen policy was unchanged. + +## Metal VSync regression (#46) + +```sh +python3 -m unittest discover -s tests -p 'test_metal_vsync.py' +``` + +These host checks compile the patched layer-tree policy, flush body and CGL +swap methods against inert fixtures. They cover nested layers, VSync toggles, +shared-window requests, independent contexts, late attachment and failed +context/interval changes. They do not measure physical display refresh. + +For manual validation after updating with the clients closed, compare the +same scene with in-game VSync on and off. With VSync on, presentation should +follow the display/compositor cadence; with it off, the game's frame cap +should continue to work. Record monitor refresh, compositor, X11/Xwayland, +GPU/driver and window mode. The window presentation probe above checks the +API wiring; an in-game/compositor timing comparison remains necessary. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 3574291..b7d57fd 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -541,3 +541,18 @@ Recovery requires every matching client to be at least 60 seconds old and a complete Hyprland window record (`class`, `title`, `mapped`, `hidden`). A second process scan after window inspection must find the same client identities and no game. Missing information or shutdown failure leaves normal launch in charge. + +## VSync and frame caps (#46) + +The Metal layer's `displaySyncEnabled` property previously had no effect on +the window swap interval. A game that delegates pacing to VSync could run +uncapped when VSync was enabled. The compositor now applies that property to +the window before swapping, including nested Metal layers and setting changes. +If several Metal layers share a window, any layer requesting VSync enables it +for that window. Other windows retain independent swap settings. + +Exit the game and both clients, then run `./scripts/update.sh` to rebuild and +install the QuartzCore and OpenGL changes. Until updated, disable in-game +VSync to use the game's frame cap, as the issue reporter confirmed. Physical +scanout timing still depends on the driver and desktop compositor; the fix +has standalone regression coverage but needs an in-game timing retest. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 72d30f8..828ecc3 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -9418,13 +9418,14 @@ index 5dcac115..5de4f03f 100644 + @end diff --git a/QuartzCore/CALayerContext.m b/QuartzCore/CALayerContext.m -index 5dce8c5..3a7f937 100644 +index 5dce8c56..8e636724 100644 --- a/QuartzCore/CALayerContext.m +++ b/QuartzCore/CALayerContext.m -@@ -1,47 +1,77 @@ +@@ -1,47 +1,92 @@ #import +#import #import ++#import #import #import #import "CALayerInternal.h" @@ -9446,6 +9447,20 @@ index 5dce8c5..3a7f937 100644 + return NO; +} + ++/* One window has one swap interval. Any synchronized Metal layer requests ++ * VSync for that window; -1 leaves non-Metal windows on their existing policy. ++ * Re-evaluate at flush so toggles and nested/replaced layers take effect. */ ++static GLint metalSwapInterval(CALayer *layer) { ++ GLint interval = -1; ++ if ([[layer class] isSubclassOfClass: [CAMetalLayerInternal class]]) ++ interval = [(CAMetalLayer *)layer displaySyncEnabled] ? 1 : 0; ++ for (CALayer *child in layer.sublayers) { ++ GLint childInterval = metalSwapInterval(child); ++ if (childInterval > interval) interval = childInterval; ++ } ++ return interval; ++} ++ +/* The X11 transport presents Chromium's flattened full-window surface. + * Avoid uploading and compositing that same window again into a covered GL + * child. Restrict this to the explicitly configured native presentation path. */ @@ -9518,7 +9533,7 @@ index 5dce8c5..3a7f937 100644 [super dealloc]; } -@@ -51,12 +81,22 @@ +@@ -51,12 +96,22 @@ } - (void) setLayer: (CALayer *) layer { @@ -9541,7 +9556,7 @@ index 5dce8c5..3a7f937 100644 } - (void) setSubwindow: (CGSubWindow*) subwindow -@@ -68,16 +108,50 @@ +@@ -68,16 +123,50 @@ } _subwindow = [subwindow retain]; @@ -9593,7 +9608,7 @@ index 5dce8c5..3a7f937 100644 } - (void) assignTextureIdsToLayerTree: (CALayer *) layer { -@@ -93,8 +167,27 @@ +@@ -93,8 +182,27 @@ [self assignTextureIdsToLayerTree: child]; } @@ -9621,7 +9636,7 @@ index 5dce8c5..3a7f937 100644 glEnable(GL_DEPTH_TEST); glShadeModel(GL_SMOOTH); -@@ -118,27 +211,62 @@ +@@ -118,27 +226,62 @@ [_deleteTextureIds removeAllObjects]; @@ -9689,7 +9704,7 @@ index 5dce8c5..3a7f937 100644 } - (void) startTimerIfNeeded { -@@ -147,7 +275,7 @@ +@@ -147,7 +290,7 @@ target: self selector: @selector(timer:) userInfo: nil @@ -9698,14 +9713,25 @@ index 5dce8c5..3a7f937 100644 } - (void) deleteTextureId: (NSNumber *) textureId { -@@ -155,7 +283,9 @@ +@@ -155,7 +298,20 @@ } - (void) flush { - CGLFlushDrawable(_glContext); + if(skipLocalRender(_layer, _frame.size))return; -+ if (_glContext != NULL && _cglWindow != NULL) ++ if (_glContext != NULL && _cglWindow != NULL) { ++ CGLContextObj previous = CGLGetCurrentContext(); ++ // EGL applies the interval to the current draw surface, so attach this ++ // window before setting it (including after late window attachment). ++ if (CGLContextMakeCurrentAndAttachToWindow(_glContext, _cglWindow) != kCGLNoError) ++ return; ++ GLint interval = metalSwapInterval(_layer); ++ if (interval >= 0) ++ CGLSetParameter(_glContext, kCGLCPSwapInterval, &interval); + CGLFlushDrawable(_glContext); ++ if (previous != _glContext) ++ CGLSetCurrentContext(previous); ++ } } @end diff --git a/patches/darling.patch b/patches/darling.patch index 5194fda..5508e49 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -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..f25fd9aa5 100644 +index ffd7fe054..a40a634d3 100644 --- a/src/frameworks/OpenGL/OpenGL.c +++ b/src/frameworks/OpenGL/OpenGL.c @@ -2,11 +2,22 @@ @@ -6358,7 +6358,7 @@ index ffd7fe054..f25fd9aa5 100644 static EGLConfig config; static int num_config; -@@ -48,10 +124,18 @@ struct _CGLContextObj { +@@ -48,10 +124,19 @@ struct _CGLContextObj { GLuint retain_count; pthread_mutex_t lock; EGLContext egl_context; @@ -6368,6 +6368,7 @@ index ffd7fe054..f25fd9aa5 100644 // EGL has no function for getting the current swap interval, // so we need to save the last set value. The default is 1. int swap_interval; ++ int swap_interval_requested; + EGLSurface native_present_surface; + EGLContext native_present_context; + DarlingNativePresentTarget *native_present_target; @@ -6377,7 +6378,7 @@ index ffd7fe054..f25fd9aa5 100644 }; struct _CGLPixelFormatObj { -@@ -59,6 +143,24 @@ struct _CGLPixelFormatObj { +@@ -59,6 +144,24 @@ struct _CGLPixelFormatObj { CGLPixelFormatAttribute *attributes; }; @@ -6402,7 +6403,7 @@ index ffd7fe054..f25fd9aa5 100644 static inline int attribute_has_argument(CGLPixelFormatAttribute attr) { switch (attr) { case kCGLPFAAuxBuffers: -@@ -69,6 +171,9 @@ static inline int attribute_has_argument(CGLPixelFormatAttribute attr) { +@@ -69,6 +172,9 @@ static inline int attribute_has_argument(CGLPixelFormatAttribute attr) { case kCGLPFAAccumSize: case kCGLPFARendererID: case kCGLPFADisplayMask: @@ -6412,7 +6413,7 @@ index ffd7fe054..f25fd9aa5 100644 return 1; default: return 0; -@@ -78,6 +183,7 @@ static inline int attribute_has_argument(CGLPixelFormatAttribute attr) { +@@ -78,6 +184,7 @@ static inline int attribute_has_argument(CGLPixelFormatAttribute attr) { __attribute__((constructor)) static void _CGLInitialize(void) { @@ -6420,7 +6421,7 @@ index ffd7fe054..f25fd9aa5 100644 g_displays = CFDictionaryCreateMutable(NULL, 0, NULL, NULL); } -@@ -93,41 +199,71 @@ static int attributes_count(const CGLPixelFormatAttribute *attrs) { +@@ -93,41 +200,71 @@ static int attributes_count(const CGLPixelFormatAttribute *attrs) { CGLError CGLRegisterNativeDisplay(void *native_display) { @@ -6500,7 +6501,7 @@ index ffd7fe054..f25fd9aa5 100644 pthread_mutex_lock(&g_displaysMutex); CFDictionaryAddValue(g_displays, (const void*)(unsigned long) cid, rv); -@@ -139,6 +275,8 @@ static struct _CGLDisplay* getCGLDisplay(CGSConnectionID cid) +@@ -139,6 +276,8 @@ static struct _CGLDisplay* getCGLDisplay(CGSConnectionID cid) CGLError CGLSetSurface(CGLContextObj gl, CGSConnectionID cid, CGSWindowID wid, CGSSurfaceID sid) { @@ -6509,7 +6510,7 @@ index ffd7fe054..f25fd9aa5 100644 struct _CGLDisplay* disp = getCGLDisplay(cid); if (!disp) return kCGLBadConnection; -@@ -152,7 +290,7 @@ CGLError CGLSetSurface(CGLContextObj gl, CGSConnectionID cid, CGSWindowID wid, C +@@ -152,7 +291,7 @@ CGLError CGLSetSurface(CGLContextObj gl, CGSConnectionID cid, CGSWindowID wid, C if (!window) return kCGLBadWindow; @@ -6518,7 +6519,7 @@ index ffd7fe054..f25fd9aa5 100644 if (gl->egl_surface == EGL_NO_SURFACE) return kCGLBadState; return kCGLNoError; -@@ -161,7 +299,7 @@ CGLError CGLSetSurface(CGLContextObj gl, CGSConnectionID cid, CGSWindowID wid, C +@@ -161,7 +300,7 @@ CGLError CGLSetSurface(CGLContextObj gl, CGSConnectionID cid, CGSWindowID wid, C CGLWindowRef CGLGetWindow(void *native_window) { EGLNativeWindowType window = (EGLNativeWindowType) native_window; @@ -6527,7 +6528,7 @@ index ffd7fe054..f25fd9aa5 100644 if (surface == EGL_NO_SURFACE) { return NULL; -@@ -170,15 +308,53 @@ CGLWindowRef CGLGetWindow(void *native_window) { +@@ -170,15 +309,53 @@ CGLWindowRef CGLGetWindow(void *native_window) { return (CGLWindowRef) surface; } @@ -6583,7 +6584,7 @@ index ffd7fe054..f25fd9aa5 100644 } static pthread_key_t current_context_key; -@@ -198,18 +374,42 @@ CGLContextObj CGLGetCurrentContext(void) { +@@ -198,18 +375,42 @@ CGLContextObj CGLGetCurrentContext(void) { return pthread_getspecific(key); } @@ -6631,7 +6632,7 @@ index ffd7fe054..f25fd9aa5 100644 } CGLError CGLSetFullScreen(CGLContextObj ctx) { -@@ -223,11 +423,13 @@ CGLError CGLChoosePixelFormat( +@@ -223,11 +424,13 @@ CGLError CGLChoosePixelFormat( CGLPixelFormatObj *result, GLint *number_of_screens ) { @@ -6646,7 +6647,7 @@ index ffd7fe054..f25fd9aa5 100644 for (int i = 0; i < count; i++) { format->attributes[i] = attrs[i]; } -@@ -251,6 +453,10 @@ CGLError CGLDescribePixelFormat( +@@ -251,6 +454,10 @@ CGLError CGLDescribePixelFormat( CGLPixelFormatAttribute attr, GLint *value ) { @@ -6657,7 +6658,7 @@ index ffd7fe054..f25fd9aa5 100644 for (int i = 0; format->attributes[i] != 0; i++) { int has_arg = attribute_has_argument(format->attributes[i]); -@@ -303,37 +509,98 @@ GLuint CGLGetPixelFormatRetainCount(CGLPixelFormatObj pixelFormat) { +@@ -303,37 +510,99 @@ GLuint CGLGetPixelFormatRetainCount(CGLPixelFormatObj pixelFormat) { return pixelFormat->retain_count; } @@ -6719,6 +6720,7 @@ index ffd7fe054..f25fd9aa5 100644 + context->egl_config = contextConfig; context->egl_surface = NULL; context->swap_interval = 1; ++ context->swap_interval_requested = 0; + context->native_present_surface=EGL_NO_SURFACE; + context->native_present_context=EGL_NO_CONTEXT; + context->native_present_target=NULL; @@ -6759,7 +6761,7 @@ index ffd7fe054..f25fd9aa5 100644 return context; } -@@ -342,11 +609,17 @@ void CGLReleaseContext(CGLContextObj context) { +@@ -342,11 +611,17 @@ void CGLReleaseContext(CGLContextObj context) { return; } @@ -6780,7 +6782,7 @@ index ffd7fe054..f25fd9aa5 100644 if (CGLGetCurrentContext() == context) { CGLSetCurrentContext(NULL); -@@ -354,7 +627,11 @@ void CGLReleaseContext(CGLContextObj context) { +@@ -354,7 +629,11 @@ void CGLReleaseContext(CGLContextObj context) { pthread_mutex_destroy(&(context->lock)); @@ -6793,12 +6795,9 @@ index ffd7fe054..f25fd9aa5 100644 free(context); } -@@ -383,9 +660,330 @@ CGLError CGLUnlockContext(CGLContextObj context) { - return kCGLNoError; +@@ -384,8 +663,325 @@ CGLError CGLUnlockContext(CGLContextObj context) { } -+static int swapIntervalRequested; -+ CGLError CGLFlushDrawable(CGLContextObj context) { - eglSwapBuffers(display,context->egl_surface); - return kCGLNoError; @@ -6807,11 +6806,9 @@ index ffd7fe054..f25fd9aa5 100644 + /* darling-lol: EGL defaults to a swap interval of 1. When the window is covered or the compositor throttles it + * (Xwayland answers about once a second), every swap blocks that long and the game's frame rate collapses to ~1 fps. + * macOS apps get an unthrottled flush unless they ask for vsync through kCGLCPSwapInterval, which is handled in -+ * CGLSetParameter; apply "no vsync" once per context here, while it is current. */ -+ static CGLContextObj swapIntervalContext; -+ if (!swapIntervalRequested && context != swapIntervalContext && p_eglSwapInterval && CGLGetCurrentContext() == context) { ++ * CGLSetParameter; apply "no vsync" to unconfigured contexts while current. */ ++ if (!context->swap_interval_requested && p_eglSwapInterval && CGLGetCurrentContext() == context) { + p_eglSwapInterval(context->egl_display, 0); -+ swapIntervalContext = context; + } + return p_eglSwapBuffers(context->egl_display, context->egl_surface) ? kCGLNoError : kCGLBadState; +} @@ -7126,18 +7123,21 @@ index ffd7fe054..f25fd9aa5 100644 } CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, const GLint *value) { -@@ -394,8 +992,9 @@ CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, c - +@@ -395,9 +991,11 @@ CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, c if (parameter == kCGLCPSwapInterval) { -+ swapIntervalRequested = 1; GLint v = *value; - EGLBoolean success = eglSwapInterval(display, v); +- if (success) + EGLBoolean success = p_eglSwapInterval(display, v); - if (success) ++ if (success) { context->swap_interval = v; ++ context->swap_interval_requested = 1; ++ } return success ? kCGLNoError : kCGLBadValue; -@@ -417,14 +1016,19 @@ CGLError CGLGetParameter(CGLContextObj context, CGLContextParameter parameter, G + } + fprintf(stderr, "CGLSetParameter unimplemented for parameter %d\n", parameter); +@@ -417,14 +1015,19 @@ CGLError CGLGetParameter(CGLContextObj context, CGLContextParameter parameter, G return kCGLNoError; } diff --git a/tests/test_metal_vsync.py b/tests/test_metal_vsync.py new file mode 100644 index 0000000..49bba1d --- /dev/null +++ b/tests/test_metal_vsync.py @@ -0,0 +1,139 @@ +"""Exercise the patched presentation policy and CGL call order without a GPU.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +import test_runtime_compatibility as sources + + +@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required') +class MetalVSyncChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def test_explicit_vsync_is_scoped_to_each_cgl_context(self): + source = self.patched('darling', 'src/frameworks/OpenGL/OpenGL.c') + methods = '\n'.join(sources.function(source, name) for name in ('CGLFlushDrawable', 'CGLSetParameter')) + fixture = r''' +#include +#include +using GLint=int; +using CGLError=int; +using CGLContextParameter=int; +using EGLBoolean=int; +constexpr int kCGLNoError=0, kCGLBadContext=1, kCGLBadState=2, kCGLBadAddress=3, kCGLBadValue=4; +constexpr int kCGLCPSwapInterval=1; +struct Context { int egl_display=1, egl_surface=1, swap_interval=1, swap_interval_requested=0; }; +using CGLContextObj=Context*; +static Context* current; +static int display=1, applied=-1; +static bool fail=false; +static int swapInterval(int, int value) { if(fail) return 0; applied=value; return 1; } +static auto p_eglSwapInterval=swapInterval; +static int p_eglSwapBuffers(int,int) { return 1; } +static Context* CGLGetCurrentContext() { return current; } +''' + methods + r''' +int main() { + Context a, b; + current=&a; + int interval=1; + assert(CGLSetParameter(&a,kCGLCPSwapInterval,&interval)==0 && applied==1); + CGLFlushDrawable(&a); assert(applied==1); + current=&b; + CGLFlushDrawable(&b); assert(applied==0); // Another window retains its default. + current=&a; + interval=0; + assert(CGLSetParameter(&a,kCGLCPSwapInterval,&interval)==0 && applied==0); + interval=1; + assert(CGLSetParameter(&a,kCGLCPSwapInterval,&interval)==0 && applied==1); + current=&b; fail=true; + assert(CGLSetParameter(&b,kCGLCPSwapInterval,&interval)==kCGLBadValue); + assert(!b.swap_interval_requested); // Failed requests must not change policy. + fail=false; CGLFlushDrawable(&b); assert(applied==0); + assert(CGLFlushDrawable(nullptr)==kCGLBadContext); +} +''' + self.run_cpp(fixture) + + def test_nested_layers_toggles_windows_and_context_failures(self): + source = self.patched('cocotron', 'QuartzCore/CALayerContext.m') + policy = sources.function(source, 'metalSwapInterval') + # Translate only Objective-C message/iteration syntax; compile the actual + # policy and flush body against inert layer and CGL fixtures. + policy = policy.replace('[[layer class] isSubclassOfClass: [CAMetalLayerInternal class]]', 'layer->metal') + policy = policy.replace('[(CAMetalLayer *)layer displaySyncEnabled]', 'layer->sync') + policy = policy.replace('for (CALayer *child in layer.sublayers)', 'for (CALayer *child : layer->children)') + flush = source.split('- (void) flush {', 1)[1].split('\n}\n', 1)[0] + fixture = r''' +#include +#include +using GLint = int; +using CGLContextObj = int; +constexpr int kCGLNoError = 0, kCGLCPSwapInterval = 1; +constexpr int NULL_CONTEXT = 0; +struct CALayer { bool metal=false, sync=true; std::vector children; }; +struct Context { int interval=-2, swaps=0; } contexts[3]; +static int current=2, attached=0; +static bool fail=false, skip=false; +static int CGLGetCurrentContext() { return current; } +static int CGLSetCurrentContext(int context) { current=context; return 0; } +static int CGLContextMakeCurrentAndAttachToWindow(int context, int window) { + if(fail) return 1; + current=context; attached=window; return 0; +} +static int CGLSetParameter(int context, int parameter, const int* interval) { + assert(current==context && attached==context && parameter==kCGLCPSwapInterval); + contexts[context].interval=*interval; return 0; +} +static int CGLFlushDrawable(int context) { + assert(current==context && attached==context); + ++contexts[context].swaps; return 0; +} +static bool skipLocalRender(CALayer*, int) { return skip; } +''' + fixture += policy + fixture += '\nstruct Window { int _glContext=1, _cglWindow=1, _frame=0; CALayer* _layer; void flush() {' + fixture += flush.replace('NULL', 'NULL_CONTEXT').replace('_frame.size', '_frame') + '\n} };\n' + fixture += r''' +int main() { + CALayer root, container, metal, other; + metal.metal=other.metal=true; + root.children={&container}; container.children={&metal}; + assert(metalSwapInterval(&root)==1); + Window a{1,1,0,&root}; + a.flush(); assert(contexts[1].interval==1 && current==2); + metal.sync=false; + a.flush(); assert(contexts[1].interval==0 && current==2); + container.children.push_back(&other); + a.flush(); assert(contexts[1].interval==1); + other.sync=false; + a.flush(); assert(contexts[1].interval==0); + CALayer plain; + assert(metalSwapInterval(&plain)==-1); + Window b{2,2,0,&plain}; + b.flush(); assert(contexts[2].interval==-2); + other.sync=true; + a.flush(); assert(contexts[1].interval==1 && contexts[2].interval==-2); + a._cglWindow=0; + int swaps=contexts[1].swaps; + a.flush(); assert(contexts[1].swaps==swaps); + a._cglWindow=1; fail=true; + a.flush(); assert(contexts[1].swaps==swaps && current==2); + fail=false; skip=true; + a.flush(); assert(contexts[1].swaps==swaps); + skip=false; current=0; + a.flush(); assert(current==0); + current=1; a.flush(); assert(current==1); + root.children.clear(); + assert(metalSwapInterval(&root)==-1); +} +''' + self.run_cpp(fixture) + + def run_cpp(self, fixture): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path/'check.cpp').write_text(fixture) + result = subprocess.run(['c++', '-std=c++17', str(path/'check.cpp'), '-o', str(path/'check')], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + subprocess.run([str(path/'check')], check=True, timeout=10) diff --git a/tools/riot-metal-presentation-check.mm b/tools/riot-metal-presentation-check.mm index 5266d8d..cf1891b 100644 --- a/tools/riot-metal-presentation-check.mm +++ b/tools/riot-metal-presentation-check.mm @@ -35,6 +35,7 @@ int main() { id device = MTLCreateSystemDefaultDevice(); verifyPresentation(device != nil, "device"); CAMetalLayer* layer = [CAMetalLayer layer]; + verifyPresentation(layer.displaySyncEnabled == YES, "VSync enabled by default"); layer.device = device; layer.drawableSize = CGSizeMake(160,96); layer.frame = CGRectMake(0,0,160,96); @@ -66,6 +67,8 @@ int main() { verifyPresentation(flag != nullptr, "request second bounded capture"); fclose(flag); } + // Toggle the public property while the same layer/window stays live. + layer.displaySyncEnabled = (frame % 3) != 1; @autoreleasepool { id drawable = [layer nextDrawable]; verifyPresentation(drawable != nil, "drawable after late window/context attachment"); @@ -82,6 +85,9 @@ int main() { [commands commit]; [commands waitUntilCompleted]; pump(app); + GLint interval = -1; + CGLGetParameter([(CALayerContext*)[layer _context] glContext], kCGLCPSwapInterval, &interval); + verifyPresentation(interval == (layer.displaySyncEnabled ? 1 : 0), "Metal VSync reaches window swap interval"); } } CGLContextObj previous = CGLGetCurrentContext(); From cae74e9e0cac13e9fc51fe3349fe3af5578e1969 Mon Sep 17 00:00:00 2001 From: ekisu Date: Sun, 18 Oct 2026 21:06:22 -0300 Subject: [PATCH 113/160] Fix X11 decorations for client-drawn Cocoa windows Synchronize Motif decoration hints on style and titlebar-property changes, including native-window recreation and restoring ordinary WM decorations. Add a ten-case standalone Cocoa/X11 regression probe and document validation on KWin/Xwayland. --- docs/TESTING.md | 27 ++++++++ patches/cocotron.patch | 89 +++++++++++++++++---------- tools/build-riot-x11-runtime.py | 12 +++- tools/riot-window-decorations-check.m | 86 ++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 35 deletions(-) create mode 100644 tools/riot-window-decorations-check.m diff --git a/docs/TESTING.md b/docs/TESTING.md index b1d7df4..11a684d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -119,6 +119,33 @@ Expected: `CORETEXT_FONT_LIST pass=1` with nonzero family/face counts (306/1124 The font probe reproduces Chromium's installed-family enumeration, mandatory family matching and localized-name lookup with a null language output. It checks ownership of the single-match result and that a nonexistent mandatory family produces no substitute. The resize probe checks backing-layer bounds immediately after frame/bounds changes, including a scaled view and a child layer, without relying on input or coordinate conversion to refresh them. It also verifies that native parent size is current during child layout, and that adding an overlay sibling after the content view does not redirect subsequent content resizing into that overlay. The old sibling selection left content at 1600×900 while stretching the 30×20 overlay to 1280×720; the corrected layout resizes the content and preserves the overlay. Before the fixes, these checks report a stale 1280×720 layer and native parent after a 1600×900 resize. The `FRAME_SIZE_HOOK` check uses a subclass whose `setFrameSize:` publishes its viewport size. It checks growth and shrinkage through parent autoresizing, direct `setFrame:`, and direct `setFrameSize:`. Before component-setter dispatch was corrected only the final direct size setter reached the subclass (one callback); afterward all four updates do, with current geometry visible inside the callback. No probe launches or contacts Riot/League. The user confirmed that the language dropdown and League window resizing work after the final X11 correction (2026-10-15). The practice-tool crash remains unresolved. +### X11 window decorations + +`python3 tools/build-riot-x11-runtime.py` also builds +`build/riot-window-decorations-check`. Install and activate both the patched +AppKit and X11 backend before running it, with clients and the game closed: + +```sh +darling shell env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries \ + DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks \ + /Volumes/SystemRoot/path/to/darling-lol/build/riot-window-decorations-check +``` + +Expected: `WINDOW_DECORATIONS_CHECK cases=10 pass=1`. The independent Cocoa +probe reads `_MOTIF_WM_HINTS` on its own unmapped native windows. It covers a +normal titled window, hidden title alone, hidden plus transparent titlebar, +both reverse transitions, untitled masks with additional flags, switching to +borderless after creation, native-window recreation, and properties set before +the native window exists. It sends no input, maps no windows and does not +launch or contact Riot/League. Check the success marker, not only the launcher +exit status: some Darling versions do not propagate a guest assertion failure. + +Before the fix the hidden/transparent case fails. With the corrected overlays, +the ten cases passed on KDE Wayland/Xwayland. Passive inspection of the reopened +Riot Client also confirmed the no-decoration hint and identical compositor frame +and client dimensions (1536×864), instead of the previous 28-pixel titlebar. +This validates the Riot Client window, not every LeagueClientUx or game mode. + ### Presentation publisher handoff With clients closed, run `build/riot-client-resize-check --handoff` through Darling with the normal runtime overrides. This creates an owned window and two fixture frame slots. The live software owner first supplies a 1280×720 main frame, then switches to a 262×131 popup; another slot supplies the resized main frame without the software flag. The test samples its own window beyond the original frame extent and checks generation colors across growth and shrinkage. The old selector reports black at the 1600×900 sample; the correction passes all four sizes. Repeat with `RCLOL_XSHM=0` and with `DARLING_XSHM_LIBRARY` pointing to the normal transport to cover both paths. The fixtures remove their own frame slots on exit; they do not read client pixels or send input. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 72d30f8..186b72f 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -1946,7 +1946,7 @@ index d545120a..79760f47 100644 - (BOOL) inLiveResize { return _isInLiveResize; -@@ -1561,6 +1766,117 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1561,6 +1766,123 @@ static BOOL _allowsAutomaticWindowTabbing; return point; } @@ -1976,7 +1976,10 @@ index d545120a..79760f47 100644 +} + +- (void) setTitlebarAppearsTransparent: (BOOL) value { ++ if (_titlebarAppearsTransparent == value) ++ return; + _titlebarAppearsTransparent = value; ++ [_platformWindow setStyleMask: _styleMask]; +} + +- (NSInteger) titleVisibility { @@ -1984,7 +1987,10 @@ index d545120a..79760f47 100644 +} + +- (void) setTitleVisibility: (NSInteger) value { ++ if (_titleVisibility == value) ++ return; + _titleVisibility = value; ++ [_platformWindow setStyleMask: _styleMask]; +} + +- (BOOL) movableByWindowBackground { @@ -2064,7 +2070,7 @@ index d545120a..79760f47 100644 - (NSRect) frameRectForContentRect: (NSRect) contentRect { // hasMainMenu is an instance method so we can't just use the class method // frameRectForContentRect:styleMask: -@@ -1580,6 +1896,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1580,6 +1902,10 @@ static BOOL _allowsAutomaticWindowTabbing; } - (NSRect) contentRectForFrameRect: (NSRect) frameRect { @@ -2075,7 +2081,7 @@ index d545120a..79760f47 100644 NSRect result = [[NSDisplay currentDisplay] insetRect: frameRect forNativeWindowBorderWithStyle: [self styleMask]]; -@@ -2155,6 +2475,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2155,6 +2481,7 @@ static BOOL _allowsAutomaticWindowTabbing; - (void) orderWindow: (NSWindowOrderingMode) place relativeTo: (NSInteger) relativeTo { @@ -2083,7 +2089,7 @@ index d545120a..79760f47 100644 // The move notifications are sent under unknown conditions around // orderFront: in the Apple AppKit, we do them all the time here until it's // figured out. I suspect it is a side effect of off-screen windows being at -@@ -2208,6 +2529,8 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2208,6 +2535,8 @@ static BOOL _allowsAutomaticWindowTabbing; break; } @@ -2092,7 +2098,7 @@ index d545120a..79760f47 100644 [self postNotificationName: NSWindowDidMoveNotification]; } -@@ -2257,6 +2580,12 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2257,6 +2586,12 @@ static BOOL _allowsAutomaticWindowTabbing; } - (void) sendEvent: (NSEvent *) event { @@ -2105,7 +2111,7 @@ index d545120a..79760f47 100644 // Some events can cause our window to be destroyed // So make sure self lives at least through this current run loop... [[self retain] autorelease]; -@@ -2300,6 +2629,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2300,6 +2635,10 @@ static BOOL _allowsAutomaticWindowTabbing; case NSLeftMouseDown: { NSView *view = [_backgroundView hitTest: [event locationInWindow]]; @@ -2116,7 +2122,7 @@ index d545120a..79760f47 100644 if ([view acceptsFirstResponder]) { if ([view needsPanelToBecomeKey]) { -@@ -2307,6 +2640,27 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2307,6 +2646,27 @@ static BOOL _allowsAutomaticWindowTabbing; } } @@ -2144,7 +2150,7 @@ index d545120a..79760f47 100644 // Event goes to view, not first responder. [view mouseDown: event]; _mouseDownLocationInWindow = [event locationInWindow]; -@@ -2330,6 +2684,31 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2330,6 +2690,31 @@ static BOOL _allowsAutomaticWindowTabbing; _mouseDownLocationInWindow = NSMakePoint(NAN, NAN); break; @@ -2176,7 +2182,7 @@ index d545120a..79760f47 100644 case NSMouseMoved: { NSView *hit = [_backgroundView hitTest: [event locationInWindow]]; -@@ -2478,6 +2857,197 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2478,6 +2863,197 @@ static BOOL _allowsAutomaticWindowTabbing; _draggedTypes = nil; } @@ -2374,7 +2380,7 @@ index d545120a..79760f47 100644 - (void) dragImage: (NSImage *) image at: (NSPoint) location offset: (NSSize) offset -@@ -2486,6 +3056,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2486,6 +3062,10 @@ static BOOL _allowsAutomaticWindowTabbing; source: (id) source slideBack: (BOOL) slideBack { @@ -2385,7 +2391,7 @@ index d545120a..79760f47 100644 [[NSDraggingManager draggingManager] dragImage: image at: location offset: offset -@@ -2536,6 +3110,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2536,6 +3116,7 @@ static BOOL _allowsAutomaticWindowTabbing; } else { [[self platformWindow] hideWindow]; } @@ -2393,7 +2399,7 @@ index d545120a..79760f47 100644 } - (void) orderFront: (id) sender { -@@ -3064,6 +3639,13 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3064,6 +3645,13 @@ static BOOL _allowsAutomaticWindowTabbing; if (didSize) { // Don't redraw everything unless we really have to. [_backgroundView setFrameSize: _frame.size]; @@ -2407,7 +2413,7 @@ index d545120a..79760f47 100644 [_backgroundView setNeedsDisplay: YES]; [self resetCursorRects]; [self saveFrameUsingName: _autosaveFrameName]; -@@ -3260,8 +3842,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3260,8 +3848,7 @@ static BOOL _allowsAutomaticWindowTabbing; mouseWasInside && !mouseIsInside) { [exited addObject: area]; } @@ -2417,7 +2423,7 @@ index d545120a..79760f47 100644 [moved addObject: area]; } if (options & NSTrackingCursorUpdate && !mouseWasInside && -@@ -3459,7 +4040,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3459,7 +4046,7 @@ static BOOL _allowsAutomaticWindowTabbing; } - (CGSubWindow *) _createSubWindowWithFrame: (CGRect) frame { @@ -5191,7 +5197,7 @@ index f4925b66..730d4692 100644 _isModal = [delegate isSheet] || [NSApp modalWindow] == delegate; // FIXME: There should be no need for this. -@@ -254,7 +396,8 @@ static NSData *makeWindowIcon() { +@@ -254,19 +396,96 @@ static NSData *makeWindowIcon() { XNFocusWindow, _window, NULL); @@ -5201,7 +5207,10 @@ index f4925b66..730d4692 100644 [(X11Display *) [NSDisplay currentDisplay] setWindow: self forID: _window]; -@@ -265,8 +408,88 @@ static NSData *makeWindowIcon() { +- // FIXME: Move this call into setStyleMaskInternal below! +- if (_styleMask == NSBorderlessWindowMask) { +- [[self class] removeDecorationForWindow: _window onDisplay: _display]; +- } [self setStyleMaskInternal: _styleMask force: YES]; [self setWindowIcon]; @@ -5291,7 +5300,7 @@ index f4925b66..730d4692 100644 } - (void) dealloc { -@@ -279,24 +502,30 @@ static NSData *makeWindowIcon() { +@@ -279,24 +498,30 @@ static NSData *makeWindowIcon() { return _styleMask; } @@ -5338,7 +5347,7 @@ index f4925b66..730d4692 100644 if (!_mapped) { long states[2]; int states_cnt = 0; -@@ -304,7 +533,7 @@ static NSData *makeWindowIcon() { +@@ -304,7 +529,7 @@ static NSData *makeWindowIcon() { states[states_cnt++] = (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); } @@ -5347,7 +5356,7 @@ index f4925b66..730d4692 100644 states[states_cnt++] = (long) XInternAtom( _display, "_NET_WM_STATE_FULLSCREEN", False); } -@@ -312,14 +541,13 @@ static NSData *makeWindowIcon() { +@@ -312,14 +537,13 @@ static NSData *makeWindowIcon() { XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, 32, PropModeReplace, (const unsigned char *) states, states_cnt); @@ -5364,7 +5373,7 @@ index f4925b66..730d4692 100644 event.data.l[1] = (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); event.data.l[3] = 1; -@@ -327,6 +555,35 @@ static NSData *makeWindowIcon() { +@@ -327,6 +551,47 @@ static NSData *makeWindowIcon() { SubstructureNotifyMask | SubstructureRedirectMask, (XEvent *) &event); } @@ -5373,6 +5382,18 @@ index f4925b66..730d4692 100644 +} + +- (void) setStyleMaskInternal: (NSUInteger) mask force: (BOOL) force { ++ // Cocoa chrome may change after the native window already exists. A hidden, ++ // transparent titlebar is client-drawn chrome, even with the titled bit set. ++ // NSWindowTitleHidden is 1; this SDK exposes titleVisibility as NSInteger. ++ BOOL clientChrome = [_delegate titlebarAppearsTransparent] && ++ [_delegate titleVisibility] == 1; ++ if (!(mask & NSTitledWindowMask) || clientChrome) { ++ [[self class] removeDecorationForWindow: _window onDisplay: _display]; ++ } else { ++ // Return decoration ownership to the WM on the reverse transition. ++ XDeleteProperty(_display, _window, ++ XInternAtom(_display, "_MOTIF_WM_HINTS", False)); ++ } + if (force || (mask & NSWindowStyleMaskResizable) != + (_styleMask & NSWindowStyleMaskResizable)) { + XSizeHints *sh = XAllocSizeHints(); @@ -5400,7 +5421,7 @@ index f4925b66..730d4692 100644 } - (void) setStyleMask: (NSUInteger) mask { -@@ -338,7 +595,6 @@ static NSData *makeWindowIcon() { +@@ -338,7 +603,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -5408,7 +5429,7 @@ index f4925b66..730d4692 100644 struct { unsigned long flags; unsigned long functions; -@@ -356,8 +612,14 @@ static NSData *makeWindowIcon() { +@@ -356,8 +620,14 @@ static NSData *makeWindowIcon() { - (void) ensureMapped { if (!_mapped) { @@ -5423,7 +5444,7 @@ index f4925b66..730d4692 100644 } } -@@ -377,15 +639,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +647,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -5456,7 +5477,7 @@ index f4925b66..730d4692 100644 // TODO: background color } -@@ -393,6 +667,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +675,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. @@ -5485,7 +5506,7 @@ index f4925b66..730d4692 100644 [_context release]; _context = nil; -@@ -421,6 +717,392 @@ static NSData *makeWindowIcon() { +@@ -421,6 +725,392 @@ static NSData *makeWindowIcon() { } } @@ -5878,7 +5899,7 @@ index f4925b66..730d4692 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +1152,32 @@ static NSData *makeWindowIcon() { +@@ -470,14 +1160,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -5912,7 +5933,7 @@ index f4925b66..730d4692 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1188,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1196,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -5938,7 +5959,7 @@ index f4925b66..730d4692 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1262,21 @@ static NSData *makeWindowIcon() { +@@ -543,6 +1270,21 @@ static NSData *makeWindowIcon() { - (void) makeKey { [self ensureMapped]; XRaiseWindow(_display, _window); @@ -5960,7 +5981,7 @@ index f4925b66..730d4692 100644 } - (void) makeMain { -@@ -559,7 +1293,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1301,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -5990,7 +6011,7 @@ index f4925b66..730d4692 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -593,8 +1349,10 @@ static NSData *makeWindowIcon() { +@@ -593,8 +1357,10 @@ static NSData *makeWindowIcon() { if (_cglContext == NULL) { CGLError error; @@ -6003,7 +6024,7 @@ index f4925b66..730d4692 100644 NSLog(@"CGLCreateContext failed at %s %d with error %d", __FILE__, __LINE__, error); -@@ -615,6 +1373,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1381,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -6016,7 +6037,7 @@ index f4925b66..730d4692 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1416,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1424,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -6025,7 +6046,7 @@ index f4925b66..730d4692 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1430,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1438,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -6033,7 +6054,7 @@ index f4925b66..730d4692 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,19 +1475,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,19 +1483,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } diff --git a/tools/build-riot-x11-runtime.py b/tools/build-riot-x11-runtime.py index 3068865..3bef722 100644 --- a/tools/build-riot-x11-runtime.py +++ b/tools/build-riot-x11-runtime.py @@ -48,4 +48,14 @@ subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FL 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) + '-o', str(project / 'build/riot-fullscreen-geometry-check')], check=True) + +# Query the actual native hints after Cocoa title/style changes, without mapping windows. +probe = project / 'build/riot-window-decorations-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-window-decorations-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', 'AppKit', + '-framework', 'Foundation', '-framework', 'CoreGraphics', + '-o', str(project / 'build/riot-window-decorations-check')], check=True) diff --git a/tools/riot-window-decorations-check.m b/tools/riot-window-decorations-check.m new file mode 100644 index 0000000..0bff1f1 --- /dev/null +++ b/tools/riot-window-decorations-check.m @@ -0,0 +1,86 @@ +/* Standalone Cocoa/X11 regression: no client windows, hooks or input. */ +#import +#import +#include +#include +#include +#include + +@protocol DecorationProbeDisplay +- (void *)display; +@end + +@interface NSObject (DecorationProbe) +- (unsigned long)windowHandle; +- (void)recreateXWindowWithAlpha:(BOOL)alpha; +@end + +static BOOL undecorated(NSWindow *window) { + void *xlib = dlopen("/usr/lib/native/libX11.dylib", RTLD_LAZY); + assert(xlib); + Atom (*intern)(Display *, const char *, Bool) = dlsym(xlib, "XInternAtom"); + int (*sync)(Display *, Bool) = dlsym(xlib, "XSync"); + int (*get)(Display *, Window, Atom, long, long, Bool, Atom, + Atom *, int *, unsigned long *, unsigned long *, unsigned char **) = + dlsym(xlib, "XGetWindowProperty"); + int (*freeData)(void *) = dlsym(xlib, "XFree"); + assert(intern && sync && get && freeData); + id native = [window platformWindow]; + Display *display = (Display *)[(id)[NSDisplay currentDisplay] display]; + sync(display, False); + Atom type = None; + int format = 0; + unsigned long count = 0, remaining = 0; + unsigned char *data = NULL; + int status = get(display, [native windowHandle], intern(display, "_MOTIF_WM_HINTS", False), + 0, 5, False, AnyPropertyType, &type, &format, &count, &remaining, &data); + assert(status == Success); + BOOL result = format == 32 && count >= 3 && data && + (((unsigned long *)data)[0] & 2) && ((unsigned long *)data)[2] == 0; + if (data) freeData(data); + dlclose(xlib); + return result; +} + +static void assertDecoration(NSWindow *window, BOOL expected, const char *name) { + BOOL actual = undecorated(window); + printf("WINDOW_DECORATION case=%s undecorated=%d expected=%d\n", name, actual, expected); + assert(actual == expected); +} + +int main(void) { + setbuf(stdout, NULL); + @autoreleasepool { + [NSApplication sharedApplication]; + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(20, 20, 320, 180) + styleMask:NSTitledWindowMask backing:NSBackingStoreBuffered defer:NO]; + assertDecoration(window, NO, "ordinary titled window"); + [window setTitleVisibility:1]; + assertDecoration(window, NO, "hidden title alone preserves frame"); + [window setTitlebarAppearsTransparent:YES]; + assertDecoration(window, YES, "hidden and transparent title removes frame"); + [window setTitleVisibility:0]; + assertDecoration(window, NO, "visible title restores frame"); + [window setTitleVisibility:1]; + [window setTitlebarAppearsTransparent:NO]; + assertDecoration(window, NO, "opaque title restores frame"); + [window setStyleMask:NSTexturedBackgroundWindowMask]; + assertDecoration(window, YES, "untitled with extra flags"); + [window setStyleMask:NSTitledWindowMask]; + assertDecoration(window, NO, "titled style restored"); + [window setStyleMask:NSBorderlessWindowMask]; + assertDecoration(window, YES, "borderless after creation"); + [window setStyleMask:NSTitledWindowMask]; + [window setTitlebarAppearsTransparent:YES]; + [(id)[window platformWindow] recreateXWindowWithAlpha:NO]; + assertDecoration(window, YES, "recreated native window retains client chrome"); + NSWindow *deferred = [[NSWindow alloc] initWithContentRect:NSMakeRect(20, 20, 320, 180) + styleMask:NSTitledWindowMask backing:NSBackingStoreBuffered defer:YES]; + [deferred setTitleVisibility:1]; + [deferred setTitlebarAppearsTransparent:YES]; + assertDecoration(deferred, YES, "properties set before native creation"); + puts("WINDOW_DECORATIONS_CHECK cases=10 pass=1"); + // Windows stay unmapped: the probe never changes compositor focus. + } + return 0; +} From 956f1a156d81b9894fb38ec147697e27c43f88ad Mon Sep 17 00:00:00 2001 From: ekisu Date: Mon, 19 Oct 2026 01:15:03 -0300 Subject: [PATCH 114/160] Implement Cocoa local event monitors for native client dragging --- docs/TESTING.md | 32 ++++++++++ patches/cocotron.patch | 88 ++++++++++++++++++++++++--- tools/build-riot-x11-runtime.py | 9 +++ tools/riot-event-monitors-check.m | 98 +++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 tools/riot-event-monitors-check.m diff --git a/docs/TESTING.md b/docs/TESTING.md index 11a684d..7f8c1b9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -119,6 +119,38 @@ Expected: `CORETEXT_FONT_LIST pass=1` with nonzero family/face counts (306/1124 The font probe reproduces Chromium's installed-family enumeration, mandatory family matching and localized-name lookup with a null language output. It checks ownership of the single-match result and that a nonexistent mandatory family produces no substitute. The resize probe checks backing-layer bounds immediately after frame/bounds changes, including a scaled view and a child layer, without relying on input or coordinate conversion to refresh them. It also verifies that native parent size is current during child layout, and that adding an overlay sibling after the content view does not redirect subsequent content resizing into that overlay. The old sibling selection left content at 1600×900 while stretching the 30×20 overlay to 1280×720; the corrected layout resizes the content and preserves the overlay. Before the fixes, these checks report a stale 1280×720 layer and native parent after a 1600×900 resize. The `FRAME_SIZE_HOOK` check uses a subclass whose `setFrameSize:` publishes its viewport size. It checks growth and shrinkage through parent autoresizing, direct `setFrame:`, and direct `setFrameSize:`. Before component-setter dispatch was corrected only the final direct size setter reached the subclass (one callback); afterward all four updates do, with current geometry visible inside the callback. No probe launches or contacts Riot/League. The user confirmed that the language dropdown and League window resizing work after the final X11 correction (2026-10-15). The practice-tool crash remains unresolved. +### Local event monitors and client dragging + +`python3 tools/build-riot-x11-runtime.py` builds +`build/riot-event-monitors-check`. With patched AppKit installed and clients +closed, run: + +```sh +darling shell env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries \ + DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks \ + /Volumes/SystemRoot/path/to/darling-lol/build/riot-event-monitors-check +``` + +Expected: nine `PASS` lines and exit status 0. This independent probe tests +multiple monitors, mask filtering, removal, consumption, event replacement, +self-removal, and the in-flight snapshot when another monitor is removed. +Monitor order is unspecified and is deliberately not asserted. It dispatches +only synthetic events to its own unmapped window. +The same source runs against native macOS AppKit: + +```sh +clang -fblocks -framework AppKit tools/riot-event-monitors-check.m \ + -o /tmp/riot-event-monitors-check +/tmp/riot-event-monitors-check +``` + +Native reference: macOS 26.6.2. LeagueClientUx uses local +monitors for custom window movement; intercepting a fixed titlebar strip instead +would bypass its own drag policy and risk stealing its window-control clicks. +For a live regression check, drag its header, drag ordinary content (which must +not move the window), minimize and restore it, and check Riot Client header +dragging separately. No match is required. + ### X11 window decorations `python3 tools/build-riot-x11-runtime.py` also builds diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 186b72f..6934ffc 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -126,7 +126,7 @@ index d543e329..e320a3af 100644 { return [NSMethodSignature signatureWithObjCTypes: "v@:"]; diff --git a/AppKit/NSApplication.m b/AppKit/NSApplication.m -index 8e7508e2..6fc86b32 100644 +index 8e7508e2..5abc49b7 100644 --- a/AppKit/NSApplication.m +++ b/AppKit/NSApplication.m @@ -17,6 +17,9 @@ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER @@ -239,10 +239,14 @@ index 8e7508e2..6fc86b32 100644 do { pool = [NSAutoreleasePool new]; -@@ -716,6 +744,12 @@ NSApplication *NSApp = nil; +@@ -716,6 +744,16 @@ NSApplication *NSApp = nil; } - (void) sendEvent: (NSEvent *) event { ++ extern NSEvent *_NSApplyLocalEventMonitors(NSEvent *); ++ event = _NSApplyLocalEventMonitors(event); ++ if (event == nil) ++ return; + static int inputTrace = -1; + if (inputTrace < 0) inputTrace = getenv("RCLOL_INPUT_TRACE") != NULL; + if (inputTrace && ([event type] == NSKeyDown || [event type] == NSLeftMouseDown)) @@ -252,7 +256,7 @@ index 8e7508e2..6fc86b32 100644 if ([event type] == NSKeyDown) { unsigned modifierFlags = [event modifierFlags]; -@@ -742,6 +776,61 @@ NSApplication *NSApp = nil; +@@ -742,6 +780,61 @@ NSApplication *NSApp = nil; do { NSAutoreleasePool *pool = [NSAutoreleasePool new]; @@ -648,10 +652,49 @@ index cacd8999..9422d869 100644 NSInvalidAbstractInvocation(); return nil; diff --git a/AppKit/NSEvent.subproj/NSEvent.m b/AppKit/NSEvent.subproj/NSEvent.m -index d0bad986..d0b1b4aa 100644 +index d0bad986..fecfa9f8 100644 --- a/AppKit/NSEvent.subproj/NSEvent.m +++ b/AppKit/NSEvent.subproj/NSEvent.m -@@ -40,6 +40,10 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@@ -29,6 +29,38 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + #import + #import + #import ++#import ++ ++@interface _NSLocalEventMonitor : NSObject { ++@public ++ NSEventMask _mask; ++ NSEvent * (^_handler)(NSEvent *); ++} ++@end ++ ++@implementation _NSLocalEventMonitor ++- (void) dealloc { ++ [_handler release]; ++ [super dealloc]; ++} ++@end ++ ++static NSMutableArray *_NSLocalEventMonitors; ++ ++/* Snapshot the registrations: changes made by a handler affect subsequent ++ * dispatches, not the event already in flight. Handlers may also enter a nested ++ * event loop, which takes its own snapshot. */ ++NSEvent *_NSApplyLocalEventMonitors(NSEvent *event) { ++ NSArray *monitors = [[_NSLocalEventMonitors copy] autorelease]; ++ for (_NSLocalEventMonitor *monitor in [monitors reverseObjectEnumerator]) { ++ if (event == nil) ++ break; ++ NSUInteger type = [event type]; ++ if (type < sizeof(NSEventMask) * 8 && (monitor->_mask & ((NSEventMask)1 << type))) ++ event = monitor->_handler(event); ++ } ++ return event; ++} + + @implementation NSEvent + +@@ -40,6 +72,10 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ return [[NSDisplay currentDisplay] currentModifierFlags]; } @@ -662,6 +705,34 @@ index d0bad986..d0b1b4aa 100644 - (instancetype) initWithType: (NSEventType) type location: (NSPoint) location modifierFlags: (NSEventModifierFlags) modifierFlags +@@ -350,14 +386,23 @@ static NSTimer *_periodicTimer = nil; + } + + + (id) addLocalMonitorForEventsMatchingMask: (NSEventMask) mask +- handler: (NSEvent * (^)(NSEvent *event)) block ++ handler: (NSEvent * (^)(NSEvent *event)) block + { +- NSUnimplementedMethod(); +- return nil; ++ if (block == nil) ++ return nil; ++ if (_NSLocalEventMonitors == nil) ++ _NSLocalEventMonitors = [NSMutableArray new]; ++ _NSLocalEventMonitor *monitor = [[_NSLocalEventMonitor alloc] init]; ++ monitor->_mask = mask; ++ monitor->_handler = [block copy]; ++ [_NSLocalEventMonitors addObject: monitor]; ++ return [monitor autorelease]; + } + + + (void) removeMonitor: (id) eventMonitor { +- NSUnimplementedMethod(); ++ if ([_NSLocalEventMonitors indexOfObjectIdenticalTo: eventMonitor] != NSNotFound) { ++ [_NSLocalEventMonitors removeObjectIdenticalTo: eventMonitor]; ++ } + } + + @end diff --git a/AppKit/NSEvent.subproj/NSEvent_mouse.m b/AppKit/NSEvent.subproj/NSEvent_mouse.m index 31c65aac..f7d55ec0 100644 --- a/AppKit/NSEvent.subproj/NSEvent_mouse.m @@ -6184,7 +6255,7 @@ index 8860a087..a897e2ce 100644 - (NSArray *) orderedWindowNumbers; diff --git a/AppKit/include/AppKit/NSEvent.h b/AppKit/include/AppKit/NSEvent.h -index 0677f2e7..d16080f4 100644 +index 0677f2e7..b6daef18 100644 --- a/AppKit/include/AppKit/NSEvent.h +++ b/AppKit/include/AppKit/NSEvent.h @@ -83,6 +83,8 @@ typedef NS_ENUM(NSUInteger, NSEventType) { @@ -6196,11 +6267,14 @@ index 0677f2e7..d16080f4 100644 NSAppKitSystem = 100, -@@ -212,6 +214,7 @@ enum { NSApplicationActivated = 0, NSApplicationDeactivated = 1 }; +@@ -212,6 +214,10 @@ enum { NSApplicationActivated = 0, NSApplicationDeactivated = 1 }; + (NSPoint) mouseLocation; + (NSEventModifierFlags) modifierFlags; ++ (NSUInteger) pressedMouseButtons; +++ (id) addLocalMonitorForEventsMatchingMask: (NSEventMask) mask ++ handler: (NSEvent * (^)(NSEvent *event)) block; +++ (void) removeMonitor: (id) eventMonitor; - (instancetype) initWithType: (NSEventType) type location: (NSPoint) location diff --git a/tools/build-riot-x11-runtime.py b/tools/build-riot-x11-runtime.py index 3bef722..192d9f3 100644 --- a/tools/build-riot-x11-runtime.py +++ b/tools/build-riot-x11-runtime.py @@ -59,3 +59,12 @@ subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', '-framework', 'AppKit', '-framework', 'Foundation', '-framework', 'CoreGraphics', '-o', str(project / 'build/riot-window-decorations-check')], check=True) + +# Match native AppKit's local event monitor dispatch semantics. +probe = project / 'build/riot-event-monitors-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O3', '-march=native', '-mtune=generic', '-Wno-macro-redefined', '-c', + str(project / 'tools/riot-event-monitors-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', 'AppKit', + '-framework', 'Foundation', '-o', str(project / 'build/riot-event-monitors-check')], check=True) diff --git a/tools/riot-event-monitors-check.m b/tools/riot-event-monitors-check.m new file mode 100644 index 0000000..2b21195 --- /dev/null +++ b/tools/riot-event-monitors-check.m @@ -0,0 +1,98 @@ +/* Local event dispatch regression; also runs unchanged against native AppKit. + * No global input, client hooks, account access, or mapped windows. */ +#import + +static NSMutableString *calls; +static int failures; + +@interface MonitorTestWindow : NSWindow +@end +@implementation MonitorTestWindow +- (void) sendEvent: (NSEvent *) event { + [calls appendFormat: @"W%ld", (long)[event type]]; +} +@end + +static void expectCalls(NSString *name, NSString *expected) { + BOOL ok = [calls isEqual: expected]; + printf("%s %s got=%s expected=%s\n", ok ? "PASS" : "FAIL", + [name UTF8String], [calls UTF8String], [expected UTF8String]); + failures += !ok; + [calls setString: @""]; +} + +/* AppKit does not promise an order between independent registrations. Native + * ordering can change with block addresses, even on the same OS build. */ +static void expectBoth(NSString *name) { + NSString *expected = [calls isEqual: @"ABW1"] ? @"ABW1" : @"BAW1"; + expectCalls(name, expected); +} + +int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + calls = [NSMutableString new]; + NSWindow *window = [[MonitorTestWindow alloc] + initWithContentRect: NSMakeRect(100, 100, 300, 200) + styleMask: 0 backing: NSBackingStoreBuffered defer: NO]; + NSEvent *down = [NSEvent mouseEventWithType: NSLeftMouseDown + location: NSMakePoint(10, 10) modifierFlags: 0 timestamp: 0 + windowNumber: [window windowNumber] context: nil + eventNumber: 1 clickCount: 1 pressure: 1]; + NSEvent *up = [NSEvent mouseEventWithType: NSLeftMouseUp + location: NSMakePoint(10, 10) modifierFlags: 0 timestamp: 0 + windowNumber: [window windowNumber] context: nil + eventNumber: 2 clickCount: 1 pressure: 0]; + id a = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"A"]; return event; + }]; + id b = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"B"]; return event; + }]; + [NSApp sendEvent: down]; expectBoth(@"multiple-monitors"); + [NSApp sendEvent: up]; expectCalls(@"mask-filter", @"W2"); + [NSEvent removeMonitor: b]; + [NSApp sendEvent: down]; expectCalls(@"remove", @"AW1"); + [NSEvent removeMonitor: a]; + + b = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"B"]; return nil; + }]; + [NSApp sendEvent: down]; expectCalls(@"consume", @"B"); + [NSEvent removeMonitor: b]; + b = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"B"]; return up; + }]; + [NSApp sendEvent: down]; expectCalls(@"replace-event", @"BW2"); + [NSEvent removeMonitor: b]; + + __block id selfRemoving; + selfRemoving = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"S"]; + [NSEvent removeMonitor: selfRemoving]; return event; + }]; + [NSApp sendEvent: down]; expectCalls(@"self-remove", @"SW1"); + [NSApp sendEvent: down]; expectCalls(@"self-remove-next", @"W1"); + __block id pendingA, pendingB; + pendingA = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"A"]; + [NSEvent removeMonitor: pendingB]; return event; + }]; + pendingB = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"B"]; + [NSEvent removeMonitor: pendingA]; return event; + }]; + [NSApp sendEvent: down]; expectBoth(@"remove-pending-snapshot"); + [NSApp sendEvent: down]; expectCalls(@"remove-pending-next", @"W1"); + [window release]; + [calls release]; + } + return failures ? 1 : 0; +} From c451834cc2da4febb8783a46feae15138a9b28f5 Mon Sep 17 00:00:00 2001 From: ekisu Date: Mon, 19 Oct 2026 01:15:32 -0300 Subject: [PATCH 115/160] Recompile NSEvent in incremental AppKit runtime builds --- tools/build-riot-appkit-runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/build-riot-appkit-runtime.py b/tools/build-riot-appkit-runtime.py index c4a677d..6f64261 100644 --- a/tools/build-riot-appkit-runtime.py +++ b/tools/build-riot-appkit-runtime.py @@ -12,7 +12,7 @@ for line in (framework / 'CMakeFiles/AppKit.dir/flags.make').read_text().splitli name, value = line.split(' = ', 1) flags[name] = shlex.split(value) 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']: +for name in ['NSResponder', 'NSApplication', 'NSWindow', 'NSDisplay', 'NSView', 'NSThemeFrame', 'NSTextInput', 'NSBitmapImageRep', 'NSEvent.subproj/NSEvent', '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=generic', '-Wno-macro-redefined', '-c', From 99cd0f038dfe228916ced1375f7efc2229303d46 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:35:16 +0200 Subject: [PATCH 116/160] Present full-window Metal layers on the managed X11 surface for VRR --- docs/TESTING.md | 34 ++++ docs/TROUBLESHOOTING.md | 22 ++ patches/cocotron.patch | 249 ++++++++++++++++++++--- tests/test_x11_vrr.py | 164 +++++++++++++++ tools/build-riot-coregraphics-runtime.py | 13 +- tools/build-riot-x11-runtime.py | 2 +- tools/riot-metal-presentation-check.mm | 40 +++- upstream/manifest.json | 2 +- 8 files changed, 482 insertions(+), 44 deletions(-) create mode 100644 tests/test_x11_vrr.py diff --git a/docs/TESTING.md b/docs/TESTING.md index b1d7df4..86be75d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -533,3 +533,37 @@ launch, window placement, click alignment and edge panning before rollout. After installing the tested X11 and CoreGraphics binaries locally, the user reported that everything was working (2026-10-16). Fullscreen policy was unchanged. + +## VRR presentation regression (#46) + +```sh +python3 -m unittest discover -s tests -p 'test_x11_vrr.py' +``` + +The host fixtures exercise the patched eligibility, X11 property updates and +EGL surface ownership transitions. They cover hidden/titled/partial views, +multiple views, parent recreation, child fallback and current-context handling. + +Build `build-riot-coregraphics-runtime.py`, `build-riot-x11-runtime.py` and +`build-riot-metal-presentation.py` together. Stage all three updated frameworks +(CoreGraphics, AppKit's X11 backend, QuartzCore) when testing isolated binaries. +Run `riot-metal-presentation-check` with `RIOT_PRESENTATION_PARENT=1` to create +an owned borderless game-like window. It verifies the parent drawable is +borrowed. Add `RIOT_PRESENTATION_RESIZE=1` to verify fallback to a child at +half width and borrowing the parent again when restored. Add +`RIOT_PRESENTATION_NESTED=1` for nested layers. +The default probe still exercises the independent child-window path. + +For physical VRR validation, enable adaptive sync in the monitor and desktop, +then manually test fullscreen and borderless League with a frame cap inside +the monitor's VRR range (for example 157 FPS on a 165 Hz display), with VSync +off. Check the monitor's live refresh display as well as the game FPS counter. +Also test leaving fullscreen, restoring it, resizing, and alt-tab. Report GPU, +driver, compositor and session type. A stable FPS counter or the X11 property +alone does not prove that the monitor is using variable refresh. + +Local native validation on 2026-10-19 passed all three modes (child, parent +with resize, nested parent with resize), including Vulkan-to-GL pixel readback +and ten presentations per mode, using staged frameworks in a private prefix. +Physical VRR and in-game performance on the reporter's Mutter setup remain +unverified. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 3574291..033ca2d 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -541,3 +541,25 @@ Recovery requires every matching client to be at least 60 seconds old and a complete Hyprland window record (`class`, `title`, `mapped`, `hidden`). A second process scan after window inspection must find the same client identities and no game. Missing information or shutdown failure leaves normal launch in charge. + +## Variable refresh rate (VRR) + +Full-window borderless game layers now use the opaque managed X11 parent +surface for presentation. Previously they swapped an independent child while +the managed parent remained static. Using one surface lets the compositor +track the game's updates for VRR and avoids scanning out an empty parent. +Partial views, titled windows and other applications retain child drawables. +The child fallback retains the compositor-bypass prohibition that prevents +black screens. The fullscreen parent path advertises `_VARIABLE_REFRESH=1`; +leaving fullscreen clears that request. Desktop/driver policy still decides +whether to enable adaptive sync. + +With the game and both clients closed, rebuild with `./scripts/update.sh`; +CoreGraphics, QuartzCore and the X11 backend must be updated together. Enable +VRR in the desktop and monitor, and compare the monitor's live refresh reading +to a frame cap below its maximum refresh, initially with VSync off. VRR and +VSync are separate: this change does not implement an in-game frame limiter. +See [the VRR probe and manual checks](TESTING.md#vrr-presentation-regression-46). + +Standalone child, parent and nested-parent presentation probes passed locally; +monitor-level VRR and the reporter's in-game behavior still need confirmation. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 22a1fa3..167ddb9 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -4941,7 +4941,7 @@ index 0000000..31fa646 + +#endif diff --git a/AppKit/X11.backend/X11Window.h b/AppKit/X11.backend/X11Window.h -index bfe3839a..48cfac4c 100644 +index bfe3839a..bc592711 100644 --- a/AppKit/X11.backend/X11Window.h +++ b/AppKit/X11.backend/X11Window.h @@ -26,7 +26,7 @@ @@ -4953,7 +4953,7 @@ index bfe3839a..48cfac4c 100644 @interface X11Window : CGWindow { int _level; // TODO: care about this value -@@ -47,8 +47,32 @@ +@@ -47,8 +47,33 @@ BOOL _mapped; CGPoint _lastMotionPos; BOOL _isModal; @@ -4963,6 +4963,7 @@ index bfe3839a..48cfac4c 100644 + BOOL _hasAlpha; // created with the 32-bit ARGB visual + BOOL _wasMapped; // the visual is settled at the first map BOOL _hasShadow; ++ id _layerDrawableOwner; + NSTimer *_remoteFrameTimer; + Window _remoteFrameWindow; + GC _remoteFrameGC; @@ -4986,19 +4987,22 @@ index bfe3839a..48cfac4c 100644 @public XIC _xic; -@@ -60,9 +84,9 @@ +@@ -60,9 +85,12 @@ - (Visual *) visual; - (Drawable) drawable; - (NSPoint) transformPoint: (NSPoint) pos; -- (O2Rect) transformFrame: (O2Rect) frame; - (Window) windowHandle; ++- (void *) layerDrawableFor: (id) owner; ++- (void) acquireLayerDrawable: (id) owner; ++- (void) releaseLayerDrawable: (id) owner; +- (BOOL) handleRemoteFrameExpose: (Window) window; - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m -index f4925b66..730d4692 100644 +index f4925b66..55be7854 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -18,11 +18,14 @@ @@ -5362,7 +5366,7 @@ index f4925b66..730d4692 100644 } - (void) dealloc { -@@ -279,24 +502,30 @@ static NSData *makeWindowIcon() { +@@ -279,24 +502,59 @@ static NSData *makeWindowIcon() { return _styleMask; } @@ -5399,7 +5403,36 @@ index f4925b66..730d4692 100644 } + return NO; +} ++ ++/* Keep the game on one opaque managed surface. Mutter's VRR frame clock ++ * follows that surface's repaint events, not an independently swapped child. ++ * _VARIABLE_REFRESH also advertises eligibility to X11 drivers that use it. */ ++- (void) updateLayerPresentationHints: (BOOL) fullscreen { ++ if (!X11IsLeagueGameWindow(_display, _window, _delegate)) return; ++ unsigned long adaptive = fullscreen && _layerDrawableOwner != nil; ++ unsigned long bypass = fullscreen && !_layerDrawableOwner ? 2 : 0; ++ XChangeProperty(_display, _window, XInternAtom(_display, "_VARIABLE_REFRESH", False), ++ XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&adaptive, 1); ++ XChangeProperty(_display, _window, XInternAtom(_display, "_NET_WM_BYPASS_COMPOSITOR", False), ++ XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&bypass, 1); ++} ++ ++- (void *) layerDrawableFor: (id) owner { ++ return (!_layerDrawableOwner || _layerDrawableOwner == owner) && !_hasAlpha && X11IsLeagueGameWindow(_display, _window, _delegate) ? _cglWindow : NULL; ++} ++- (void) acquireLayerDrawable: (id) owner { ++ if (_layerDrawableOwner && _layerDrawableOwner != owner) return; ++ _layerDrawableOwner = owner; ++ [self updateLayerPresentationHints: _fullscreenRequested]; ++} ++ ++- (void) releaseLayerDrawable: (id) owner { ++ if (_layerDrawableOwner != owner) return; ++ _layerDrawableOwner = nil; ++ [self updateLayerPresentationHints: _fullscreenRequested]; ++} ++ +- (void) requestFullscreen: (BOOL) fullscreen force: (BOOL) force { + if (fullscreen) { + unsigned long bypass = 2; @@ -5409,7 +5442,7 @@ index f4925b66..730d4692 100644 if (!_mapped) { long states[2]; int states_cnt = 0; -@@ -304,7 +533,7 @@ static NSData *makeWindowIcon() { +@@ -304,7 +562,7 @@ static NSData *makeWindowIcon() { states[states_cnt++] = (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); } @@ -5418,7 +5451,7 @@ index f4925b66..730d4692 100644 states[states_cnt++] = (long) XInternAtom( _display, "_NET_WM_STATE_FULLSCREEN", False); } -@@ -312,14 +541,13 @@ static NSData *makeWindowIcon() { +@@ -312,14 +570,13 @@ static NSData *makeWindowIcon() { XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, 32, PropModeReplace, (const unsigned char *) states, states_cnt); @@ -5435,11 +5468,12 @@ index f4925b66..730d4692 100644 event.data.l[1] = (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); event.data.l[3] = 1; -@@ -327,6 +555,35 @@ static NSData *makeWindowIcon() { +@@ -327,6 +584,36 @@ static NSData *makeWindowIcon() { SubstructureNotifyMask | SubstructureRedirectMask, (XEvent *) &event); } + _fullscreenRequested = fullscreen; ++ [self updateLayerPresentationHints: fullscreen]; + XFlush(_display); +} + @@ -5471,7 +5505,7 @@ index f4925b66..730d4692 100644 } - (void) setStyleMask: (NSUInteger) mask { -@@ -338,7 +595,6 @@ static NSData *makeWindowIcon() { +@@ -338,7 +625,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -5479,7 +5513,7 @@ index f4925b66..730d4692 100644 struct { unsigned long flags; unsigned long functions; -@@ -356,8 +612,14 @@ static NSData *makeWindowIcon() { +@@ -356,8 +642,14 @@ static NSData *makeWindowIcon() { - (void) ensureMapped { if (!_mapped) { @@ -5494,7 +5528,7 @@ index f4925b66..730d4692 100644 } } -@@ -377,15 +639,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +669,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -5527,7 +5561,7 @@ index f4925b66..730d4692 100644 // TODO: background color } -@@ -393,6 +667,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +697,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. @@ -5556,7 +5590,7 @@ index f4925b66..730d4692 100644 [_context release]; _context = nil; -@@ -421,6 +717,392 @@ static NSData *makeWindowIcon() { +@@ -421,6 +747,392 @@ static NSData *makeWindowIcon() { } } @@ -5949,7 +5983,7 @@ index f4925b66..730d4692 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +1152,32 @@ static NSData *makeWindowIcon() { +@@ -470,14 +1182,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -5983,7 +6017,7 @@ index f4925b66..730d4692 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1188,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1218,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -6009,7 +6043,7 @@ index f4925b66..730d4692 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1262,21 @@ static NSData *makeWindowIcon() { +@@ -543,6 +1292,21 @@ static NSData *makeWindowIcon() { - (void) makeKey { [self ensureMapped]; XRaiseWindow(_display, _window); @@ -6031,7 +6065,7 @@ index f4925b66..730d4692 100644 } - (void) makeMain { -@@ -559,7 +1293,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1323,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -6061,7 +6095,7 @@ index f4925b66..730d4692 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -593,8 +1349,10 @@ static NSData *makeWindowIcon() { +@@ -593,8 +1379,10 @@ static NSData *makeWindowIcon() { if (_cglContext == NULL) { CGLError error; @@ -6074,10 +6108,12 @@ index f4925b66..730d4692 100644 NSLog(@"CGLCreateContext failed at %s %d with error %d", __FILE__, __LINE__, error); -@@ -615,6 +1373,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1403,14 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { ++ // QuartzCore owns presentation while a full-window game layer borrows it. ++ if (_layerDrawableOwner) return; + /* A remote compositor surface owns this drawable while it is present. + * Swapping the local Onyx surface would erase it until the next timer + * tick. Keep local presentation for windows without a usable remote frame. */ @@ -6087,7 +6123,7 @@ index f4925b66..730d4692 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1416,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1448,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -6096,7 +6132,7 @@ index f4925b66..730d4692 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1430,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1462,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -6104,7 +6140,7 @@ index f4925b66..730d4692 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,19 +1475,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,19 +1507,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } @@ -9489,7 +9525,7 @@ index 5dcac115..5de4f03f 100644 + @end diff --git a/QuartzCore/CALayerContext.m b/QuartzCore/CALayerContext.m -index 5dce8c5..3a7f937 100644 +index 5dce8c56..77b729c7 100644 --- a/QuartzCore/CALayerContext.m +++ b/QuartzCore/CALayerContext.m @@ -1,47 +1,77 @@ @@ -9582,8 +9618,9 @@ index 5dce8c5..3a7f937 100644 + [_deleteTextureIds release]; [_renderer release]; CGLReleaseContext(_glContext); +- CGLDestroyWindow(_cglWindow); + CGLReleasePixelFormat(_pixelFormat); - CGLDestroyWindow(_cglWindow); ++ if (_ownsCglWindow) CGLDestroyWindow(_cglWindow); [_subwindow release]; - [_layer release]; [super dealloc]; @@ -9612,12 +9649,18 @@ index 5dce8c5..3a7f937 100644 } - (void) setSubwindow: (CGSubWindow*) subwindow -@@ -68,16 +108,50 @@ +@@ -64,20 +104,55 @@ + CGSubWindow* oldSubwindow = _subwindow; + + if (_cglWindow) { +- CGLDestroyWindow(_cglWindow); ++ if (_ownsCglWindow) CGLDestroyWindow(_cglWindow); } _subwindow = [subwindow retain]; - _cglWindow = CGLGetWindow([_subwindow nativeWindow]); + _cglWindow = NULL; ++ _ownsCglWindow = NO; + if (_subwindow != nil) { + if (_glContext == NULL) { + CGLPixelFormatAttribute attributes[] = { 0 }; @@ -9636,7 +9679,7 @@ index 5dce8c5..3a7f937 100644 + } + } + if (_glContext != NULL) -+ _cglWindow = CGLGetWindow([_subwindow nativeWindow]); ++ [self updateDrawable]; + } [_subwindow show]; @@ -9664,7 +9707,7 @@ index 5dce8c5..3a7f937 100644 } - (void) assignTextureIdsToLayerTree: (CALayer *) layer { -@@ -93,8 +167,27 @@ +@@ -93,8 +168,43 @@ [self assignTextureIdsToLayerTree: child]; } @@ -9681,6 +9724,22 @@ index 5dce8c5..3a7f937 100644 + for (CALayer *child in layer.sublayers) + [self prepareMetalLayers: child]; +} ++ ++- (void) updateDrawable { ++ if (!_subwindow || !_glContext) return; ++ [_subwindow setAllowsParentDrawable: hasMetalLayer(_layer)]; ++ void *shared = [_subwindow sharedCGLWindow]; ++ if (shared && shared == _cglWindow && !_ownsCglWindow) return; ++ if (!shared && _cglWindow && _ownsCglWindow) return; ++ CGLContextObj previous = CGLGetCurrentContext(); ++ if (previous == _glContext) CGLSetCurrentContext(NULL); ++ if (_ownsCglWindow) CGLDestroyWindow(_cglWindow); ++ _cglWindow = shared ? shared : CGLGetWindow([_subwindow nativeWindow]); ++ _ownsCglWindow = !shared && _cglWindow != NULL; ++ // Update CGL's surface binding even when another context was current. ++ CGLContextMakeCurrentAndAttachToWindow(_glContext, _cglWindow); ++ if (previous != _glContext) CGLSetCurrentContext(previous); ++} + - (void) renderLayer: (CALayer *) layer { + CFTimeInterval renderLayerStart = CACurrentMediaTime(); @@ -9692,7 +9751,7 @@ index 5dce8c5..3a7f937 100644 glEnable(GL_DEPTH_TEST); glShadeModel(GL_SMOOTH); -@@ -118,27 +211,62 @@ +@@ -118,27 +228,63 @@ [_deleteTextureIds removeAllObjects]; @@ -9714,6 +9773,7 @@ index 5dce8c5..3a7f937 100644 } - (void) render { ++ [self updateDrawable]; + if (_cglWindow == NULL || _layer == nil) + return; + if(skipLocalRender(_layer, _frame.size))return; @@ -9760,7 +9820,7 @@ index 5dce8c5..3a7f937 100644 } - (void) startTimerIfNeeded { -@@ -147,7 +275,7 @@ +@@ -147,7 +293,7 @@ target: self selector: @selector(timer:) userInfo: nil @@ -9769,7 +9829,7 @@ index 5dce8c5..3a7f937 100644 } - (void) deleteTextureId: (NSNumber *) textureId { -@@ -155,7 +283,9 @@ +@@ -155,7 +301,9 @@ } - (void) flush { @@ -11102,10 +11162,14 @@ index 49b03483..5ebbf535 100644 - (void) display; - (void) displayIfNeeded; diff --git a/QuartzCore/include/QuartzCore/CALayerContext.h b/QuartzCore/include/QuartzCore/CALayerContext.h -index e4b0c342..d61c37ba 100644 +index e4b0c342..69b39250 100644 --- a/QuartzCore/include/QuartzCore/CALayerContext.h +++ b/QuartzCore/include/QuartzCore/CALayerContext.h -@@ -20,6 +20,7 @@ +@@ -17,9 +17,11 @@ + NSTimer *_timer; + CGSubWindow* _subwindow; + void* _cglWindow; ++ BOOL _ownsCglWindow; } @property(readonly) CGLContextObj glContext; @@ -11252,3 +11316,124 @@ index 53bacc46..3c6adfb7 100644 - (void) beginSheetForDirectory: (NSString *) path file: (NSString *) name modalForWindow: (NSWindow *) docWindow +diff --git a/CoreGraphics/CGSubWindow.m b/CoreGraphics/CGSubWindow.m +index 008ccd74..6692deb5 100644 +--- a/CoreGraphics/CGSubWindow.m ++++ b/CoreGraphics/CGSubWindow.m +@@ -8,6 +8,10 @@ + return NULL; + } + ++// Backends may lend an existing EGL surface for full-window layer rendering. ++- (void *) sharedCGLWindow { return NULL; } ++- (void) setAllowsParentDrawable: (BOOL) allowed {} ++ + - (void) show { + O2InvalidAbstractInvocation(); + } +diff --git a/CoreGraphics/include/CoreGraphics/CGSubWindow.h b/CoreGraphics/include/CoreGraphics/CGSubWindow.h +index de7a3496..79ee0d31 100644 +--- a/CoreGraphics/include/CoreGraphics/CGSubWindow.h ++++ b/CoreGraphics/include/CoreGraphics/CGSubWindow.h +@@ -6,6 +6,9 @@ + @interface CGSubWindow : NSObject + + - (void *) nativeWindow; ++// Borrowed surface; the subwindow/backend retains ownership. ++- (void *) sharedCGLWindow; ++- (void) setAllowsParentDrawable: (BOOL) allowed; + + - (void) show; + - (void) hide; +diff --git a/AppKit/X11.backend/X11SubWindow.m b/AppKit/X11.backend/X11SubWindow.m +index 738ab457..6596dab5 100644 +--- a/AppKit/X11.backend/X11SubWindow.m ++++ b/AppKit/X11.backend/X11SubWindow.m +@@ -1,6 +1,7 @@ + #import "X11SubWindow.h" + #import "X11Display.h" + #import "X11Window.h" ++#import + + @implementation X11SubWindow + +@@ -15,8 +16,9 @@ + } + + - (id) initWithParentWindow: (X11Window *) parent frame: (CGRect) frame { +- _parent = parent; ++ _parent = [parent retain]; + frame = [self convertFrame: frame]; ++ _drawableFrame = frame; + + _display = [(X11Display *) [NSDisplay currentDisplay] display]; + +@@ -31,7 +33,9 @@ + } + + - (void) dealloc { ++ if (_usingParentDrawable) [_parent releaseLayerDrawable: self]; + XDestroyWindow(_display, _window); ++ [_parent release]; + [super dealloc]; + } + +@@ -39,16 +43,41 @@ + return (void *) _window; + } + ++- (void) setAllowsParentDrawable: (BOOL) allowed { ++ _allowsParentDrawable = allowed; ++} ++ ++- (void *) sharedCGLWindow { ++ // Only a full-window borderless game view may draw into the managed parent. ++ // Other views keep independent child drawables and their own coordinates. ++ CGRect parentFrame = [_parent frame]; ++ BOOL coversParent = _allowsParentDrawable && _visible && !([_parent styleMask] & NSTitledWindowMask) && ++ CGRectEqualToRect(_drawableFrame, CGRectMake(0, 0, parentFrame.size.width, parentFrame.size.height)); ++ void *surface = coversParent ? [_parent layerDrawableFor: self] : NULL; ++ BOOL usingParent = surface != NULL; ++ if (usingParent != _usingParentDrawable) { ++ if (usingParent) [_parent acquireLayerDrawable: self]; ++ else [_parent releaseLayerDrawable: self]; ++ _usingParentDrawable = usingParent; ++ } ++ if (_visible && !usingParent) XMapWindow(_display, _window); ++ else XUnmapWindow(_display, _window); ++ return surface; ++} ++ + - (void) show { +- XMapWindow(_display, _window); ++ _visible = YES; ++ [self sharedCGLWindow]; + } + + - (void) hide { +- XUnmapWindow(_display, _window); ++ _visible = NO; ++ [self sharedCGLWindow]; + } + + - (void) setFrame: (CGRect) frame { + frame = [self convertFrame: frame]; ++ _drawableFrame = frame; + + XMoveResizeWindow(_display, _window, frame.origin.x, frame.origin.y, + frame.size.width, frame.size.height); +diff --git a/AppKit/X11.backend/X11SubWindow.h b/AppKit/X11.backend/X11SubWindow.h +index 65b63a3e..e5b913b1 100644 +--- a/AppKit/X11.backend/X11SubWindow.h ++++ b/AppKit/X11.backend/X11SubWindow.h +@@ -7,6 +7,10 @@ + X11Window *_parent; + Display *_display; + Window _window; ++ CGRect _drawableFrame; ++ BOOL _visible; ++ BOOL _allowsParentDrawable; ++ BOOL _usingParentDrawable; + } + + - initWithParentWindow: (X11Window *) parent frame: (CGRect) frame; diff --git a/tests/test_x11_vrr.py b/tests/test_x11_vrr.py new file mode 100644 index 0000000..267a995 --- /dev/null +++ b/tests/test_x11_vrr.py @@ -0,0 +1,164 @@ +"""Compile the patched VRR eligibility and drawable ownership methods with inert fixtures.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +import test_runtime_compatibility as sources + + +def body(source, signature): + return source.split(signature + ' {', 1)[1].split('\n}', 1)[0] + + +@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required') +class VrrChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def run_cpp(self, source): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path/'check.cpp').write_text('#include \n' + source) + result = subprocess.run(['c++', '-std=c++17', str(path/'check.cpp'), '-o', str(path/'check')], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + subprocess.run([str(path/'check')], check=True, timeout=10) + + def test_hints_only_allow_vrr_for_fullscreen_parent_presentation(self): + source = self.patched('cocotron', 'AppKit/X11.backend/X11Window.m') + method = body(source, '- (void) updateLayerPresentationHints: (BOOL) fullscreen') + getter = body(source, '- (void *) layerDrawableFor: (id) owner') + acquire = body(source, '- (void) acquireLayerDrawable: (id) owner').replace('[self updateLayerPresentationHints: _fullscreenRequested]', 'hints(_fullscreenRequested)') + release = body(source, '- (void) releaseLayerDrawable: (id) owner').replace('[self updateLayerPresentationHints: _fullscreenRequested]', 'hints(_fullscreenRequested)') + self.run_cpp(r''' +#include +#include +using BOOL=bool; +constexpr auto nil=nullptr; +constexpr int False=0, XA_CARDINAL=6, PropModeReplace=0; +static bool game=true; +static int adaptive=-1,bypass=-1,calls=0; +static bool X11IsLeagueGameWindow(int,int,int) { return game; } +static const char* XInternAtom(int,const char* name,int) { return name; } +static int XChangeProperty(int,int,const char* name,int type,int bits,int mode,unsigned char* data,int count) { + assert(type==XA_CARDINAL && bits==32 && mode==PropModeReplace && count==1); + int value=*(unsigned long*)data; ++calls; + if(!strcmp(name,"_VARIABLE_REFRESH")) adaptive=value; + else { assert(!strcmp(name,"_NET_WM_BYPASS_COMPOSITOR")); bypass=value; } + return 0; +} +struct Window { + int _display=1,_window=1,_delegate=1; + void* _layerDrawableOwner=nullptr; + void* _cglWindow=(void*)3; bool _hasAlpha=false,_fullscreenRequested=true; + void hints(BOOL fullscreen) { +''' + method + '\n }\n void* drawable(void* owner) {' + getter + '\n}\n void acquire(void* owner) {' + acquire + '\n}\n void release(void* owner) {' + release + r''' + } +}; +int main() { + Window w; + w.hints(true); assert(adaptive==0 && bypass==2); + w._layerDrawableOwner=&w; + w.hints(true); assert(adaptive==1 && bypass==0); + w.hints(false); assert(adaptive==0 && bypass==0); + w._layerDrawableOwner=nullptr; + w.hints(true); assert(adaptive==0 && bypass==2); + int before=calls; game=false; w.hints(true); assert(calls==before); + assert(!w.drawable(&w)); game=true; + int a,b; + assert(w.drawable(&a)==(void*)3); + w.acquire(&a); assert(w.drawable(&a)==(void*)3 && !w.drawable(&b)); + w.acquire(&b); assert(w._layerDrawableOwner==&a); + w.release(&b); assert(w._layerDrawableOwner==&a); + w.release(&a); assert(!w._layerDrawableOwner && w.drawable(&b)==(void*)3); + w._hasAlpha=true; assert(!w.drawable(&a)); +} +''') + + def test_subwindows_borrow_only_when_visible_borderless_and_covering_parent(self): + source = self.patched('cocotron', 'AppKit/X11.backend/X11SubWindow.m') + method = body(source, '- (void *) sharedCGLWindow') + replacements = {'[_parent frame]':'_parent->frame', '[_parent styleMask]':'_parent->style', + '[_parent layerDrawableFor: self]':'_parent->drawable(this)', + '[_parent acquireLayerDrawable: self]':'_parent->acquire(this)', + '[_parent releaseLayerDrawable: self]':'_parent->release(this)'} + for old,new in replacements.items(): + method=method.replace(old,new) + self.run_cpp(r''' +#include +using BOOL=bool; +struct CGRect { struct {double x,y;} origin; struct {double width,height;} size; }; +static CGRect CGRectMake(double x,double y,double w,double h) {return {{x,y},{w,h}};} +static bool CGRectEqualToRect(CGRect a,CGRect b) {return a.origin.x==b.origin.x && a.origin.y==b.origin.y && a.size.width==b.size.width && a.size.height==b.size.height;} +constexpr int NSTitledWindowMask=1; +static bool mapped; +static void XMapWindow(int,int) {mapped=true;} +static void XUnmapWindow(int,int) {mapped=false;} +struct Parent { + CGRect frame=CGRectMake(0,0,800,600); int style=0; bool eligible=true; + void* owner=nullptr; void* surface=this; + void* drawable(void* requester) {return eligible && (!owner || owner==requester) ? surface : nullptr;} + void acquire(void* requester) {assert(!owner); owner=requester;} + void release(void* requester) {assert(owner==requester); owner=nullptr;} +}; +struct Child { + Parent* _parent; CGRect _drawableFrame=CGRectMake(0,0,800,600); + BOOL _visible=true,_usingParentDrawable=false,_allowsParentDrawable=true; int _display=1,_window=2; + void* shared() { +''' + method + r''' + } +}; +int main() { + Parent p; Child a{&p}; Child b{&p}; + assert(a.shared()==p.surface && !mapped && p.owner==&a); + assert(a.shared()==p.surface); // Repeated lookup does not reacquire. + assert(b.shared()==nullptr && mapped); // A second view cannot share ownership. + a._visible=false; assert(!a.shared() && !mapped && !p.owner); + a._visible=true; assert(a.shared()==p.surface); + a._drawableFrame.size.width=400; + assert(!a.shared() && mapped && !p.owner); + a._drawableFrame=CGRectMake(0,0,800,600); p.style=NSTitledWindowMask; + assert(!a.shared() && mapped); + p.style=0; p.eligible=false; assert(!a.shared() && mapped); + p.eligible=true; a._allowsParentDrawable=false; assert(!a.shared() && mapped); + a._allowsParentDrawable=true; a._drawableFrame.origin.x=1; assert(!a.shared() && mapped); + a._drawableFrame.origin.x=0; assert(a.shared()==p.surface && !mapped); +} +''') + + def test_switching_surfaces_destroys_only_owned_child_drawables(self): + source = self.patched('cocotron', 'QuartzCore/CALayerContext.m') + method = body(source, '- (void) updateDrawable') + method=method.replace('[_subwindow setAllowsParentDrawable: hasMetalLayer(_layer)]', '_subwindow->allow(hasMetalLayer(_layer))') + method=method.replace('[_subwindow sharedCGLWindow]', '_subwindow->shared').replace('[_subwindow nativeWindow]', '_subwindow->native') + self.run_cpp(r''' +#include +using BOOL=bool; +using CGLContextObj=int; +static int current=0,destroyed=0,created=0; +static void* destroyedSurface; +static int CGLGetCurrentContext() {return current;} +static void CGLSetCurrentContext(int c) {current=c;} +static void CGLDestroyWindow(void* surface) {assert(surface==(void*)10); destroyedSurface=surface; ++destroyed;} +static void* CGLGetWindow(void*) {++created; return (void*)10;} +static void CGLContextMakeCurrentAndAttachToWindow(int c,void*) {current=c;} +static bool hasMetalLayer(int layer) {return layer!=0;} +struct Subwindow {void* shared=nullptr; void* native=(void*)2; void allow(bool enabled) {assert(enabled);} }; +struct Context { + Subwindow* _subwindow; int _layer=1; int _glContext=1; void* _cglWindow=nullptr; bool _ownsCglWindow=false; + void update() { +''' + method.replace('CGLSetCurrentContext(NULL)', 'CGLSetCurrentContext(0)') + r''' + } +}; +int main() { + Subwindow sub; Context c{&sub}; + c.update(); assert(created==1 && c._ownsCglWindow); // Ordinary clients still get a child. + c.update(); assert(created==1 && destroyed==0); + current=1; sub.shared=(void*)20; + c.update(); assert(destroyed==1 && destroyedSurface==(void*)10 && !c._ownsCglWindow && current==1); + c.update(); assert(destroyed==1); + sub.shared=(void*)30; c.update(); assert(destroyed==1 && c._cglWindow==(void*)30); + sub.shared=nullptr; c.update(); assert(created==2 && destroyed==1 && c._ownsCglWindow); + current=2; sub.shared=(void*)20; c.update(); assert(current==2 && destroyed==2); + c._subwindow=nullptr; c.update(); assert(destroyed==2); +} +''') diff --git a/tools/build-riot-coregraphics-runtime.py b/tools/build-riot-coregraphics-runtime.py index 8c5e72d..e00ff20 100644 --- a/tools/build-riot-coregraphics-runtime.py +++ b/tools/build-riot-coregraphics-runtime.py @@ -10,12 +10,13 @@ for line in (folder / 'CMakeFiles/CoreGraphics.dir/flags.make').read_text().spli if line.startswith('C_') and ' = ' in line: key, value = line.split(' = ', 1) flags[key] = shlex.split(value) -obj = root / 'build/riot-CGDirectDisplay.o' -subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], - '-O2', '-Wno-macro-redefined', '-c', - str(root / 'build/darling-src/src/external/cocotron/CoreGraphics/CGDirectDisplay.m'), - '-o', str(obj)], check=True) link = shlex.split((folder / 'CMakeFiles/CoreGraphics.dir/link.txt').read_text()) -link[link.index('CMakeFiles/CoreGraphics.dir/CGDirectDisplay.m.o')] = str(obj) +for name in ('CGDirectDisplay', 'CGSubWindow'): + obj = root / f'build/riot-{name}.o' + subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-Wno-macro-redefined', '-c', + str(root / f'build/darling-src/src/external/cocotron/CoreGraphics/{name}.m'), + '-o', str(obj)], check=True) + link[link.index(f'CMakeFiles/CoreGraphics.dir/{name}.m.o')] = str(obj) link[link.index('-o') + 1] = str(root / 'build/riot-CoreGraphics') subprocess.run(link, cwd=folder, check=True) diff --git a/tools/build-riot-x11-runtime.py b/tools/build-riot-x11-runtime.py index 3068865..eb8ef6a 100644 --- a/tools/build-riot-x11-runtime.py +++ b/tools/build-riot-x11-runtime.py @@ -14,7 +14,7 @@ for line in (framework / 'CMakeFiles/X11_backend.dir/flags.make').read_text().sp key, value = line.split(' = ', 1) flags[key] = shlex.split(value) link = shlex.split((framework / 'CMakeFiles/X11_backend.dir/link.txt').read_text()) -for name in ('X11Display', 'X11Window'): +for name in ('X11Display', 'X11Window', 'X11SubWindow'): obj = project / f'build/riot-{name}.o' 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) diff --git a/tools/riot-metal-presentation-check.mm b/tools/riot-metal-presentation-check.mm index 5266d8d..0bae3b1 100644 --- a/tools/riot-metal-presentation-check.mm +++ b/tools/riot-metal-presentation-check.mm @@ -8,6 +8,14 @@ #import #include #include +#include +extern "C" void _dispatch_main_queue_callback_4CF(void *); + +@interface PresentationCheckWindow : NSWindow +@end +@implementation PresentationCheckWindow +- (BOOL)canBecomeKeyWindow { return YES; } +@end static void verifyPresentation(bool value, const char* message) { if (!value) { fprintf(stderr, "FAIL: %s\n", message); exit(1); } @@ -18,7 +26,12 @@ static void pump(NSApplication* app) { NSEvent* event = [app nextEventMatchingMask:NSAnyEventMask untilDate:[NSDate distantPast] inMode:NSDefaultRunLoopMode dequeue:YES]; if (event) [app sendEvent:event]; - [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.005]]; + // A timed NSRunLoop wait can miss its deadline on Darling when the + // X connection is quiet. Parent presentation no longer supplies child + // Expose events to wake it; service sources without a blocking wait. + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.0, true); + _dispatch_main_queue_callback_4CF(nullptr); + usleep(1000); } } int main() { @@ -52,12 +65,17 @@ int main() { } else { view.layer = layer; } - NSWindow* window = [[NSWindow alloc] initWithContentRect:NSMakeRect(50,50,160,96) - styleMask:NSTitledWindowMask backing:NSBackingStoreBuffered defer:NO]; - window.title = @"Metal presentation check"; + const bool parentDrawable = getenv("RIOT_PRESENTATION_PARENT") != nullptr; + NSWindow* window = [[PresentationCheckWindow alloc] initWithContentRect:NSMakeRect(50,50,160,96) + styleMask:(parentDrawable ? NSBorderlessWindowMask : NSTitledWindowMask) backing:NSBackingStoreBuffered defer:parentDrawable]; + window.title = parentDrawable ? @"Presentation (TM) Client" : @"Metal presentation check"; window.contentView = view; [window makeKeyAndOrderFront:nil]; pump(app); + if (parentDrawable) { + void *borrowed = [[(CALayerContext*)[layer _context] subwindow] sharedCGLWindow]; + verifyPresentation(borrowed != nullptr, "full-window game layer borrows managed parent drawable"); + } id queue = [device newCommandQueue]; const unsigned frameCount = captureTest ? 130u : getenv("RIOT_PRESENTATION_STRESS") ? 120u : 10u; for (unsigned frame = 0; frame < frameCount; ++frame) { @@ -82,6 +100,20 @@ int main() { [commands commit]; [commands waitUntilCompleted]; pump(app); + if (parentDrawable) { + void *borrowed = [[(CALayerContext*)[layer _context] subwindow] sharedCGLWindow]; + verifyPresentation(borrowed != nullptr, "parent drawable remains borrowed"); + if (frame == 3 && getenv("RIOT_PRESENTATION_RESIZE")) { + CALayerContext* context = (CALayerContext*)[layer _context]; + CGRect original = [view frame]; + [context setFrame:CGRectMake(0,0,original.size.width / 2,original.size.height)]; + [context render]; [context flush]; + verifyPresentation([[context subwindow] sharedCGLWindow] == nullptr, "partial view falls back to child drawable"); + [context setFrame:original]; + [context render]; [context flush]; + verifyPresentation([[context subwindow] sharedCGLWindow] != nullptr, "restored view borrows parent drawable again"); + } + } } } CGLContextObj previous = CGLGetCurrentContext(); diff --git a/upstream/manifest.json b/upstream/manifest.json index 1dd1cac..9bb8346 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -57,7 +57,7 @@ "revision": "c8d38d16a9f613d300157bebbab2b9501bc0c274", "repository": "https://github.com/darlinghq/darling-cocotron.git", "patch": "patches/cocotron.patch", - "modified_files": 104, + "modified_files": 114, "new_files": [ "QuartzCore/include/QuartzCore/CATransformLayer.h", "AppKit/NSGeometryTrace.h" From 6a3f81cd3f13140318634b7cf7987bddcb98c58f Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:00:41 +0200 Subject: [PATCH 117/160] Keep hidden X11 windows unmapped Restack and makeKey no longer map a window AppKit considers hidden, and setIsVisible: NO always unmaps, so the client's splash stays hidden. Co-Authored-By: Claude Sonnet 5.5 --- patches/cocotron.patch | 87 +++++++++++++++++++++++++-------- tests/test_x11_hidden_window.py | 36 ++++++++++++++ 2 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 tests/test_x11_hidden_window.py diff --git a/patches/cocotron.patch b/patches/cocotron.patch index f506180..b8f2125 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -1690,7 +1690,7 @@ index 8ac2bbdc..a0fb4f37 100644 - (BOOL) performDragOperation: (id) sender { diff --git a/AppKit/NSWindow.m b/AppKit/NSWindow.m -index d545120a..79760f47 100644 +index d545120a..1920e594 100644 --- a/AppKit/NSWindow.m +++ b/AppKit/NSWindow.m @@ -27,6 +27,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ @@ -2462,15 +2462,28 @@ index d545120a..79760f47 100644 [[NSDraggingManager draggingManager] dragImage: image at: location offset: offset -@@ -2536,6 +3116,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2527,15 +3107,19 @@ static BOOL _allowsAutomaticWindowTabbing; + } + + - (void) setIsVisible: (BOOL) visible { +- if (_isVisible == visible) { ++ /* darling-lol: hiding always reaches the platform window, which may be mapped while _isVisible is NO. */ ++ if (_isVisible == visible && visible) { + return; + } ++ BOOL changed = _isVisible != visible; + _isVisible = visible; + if (visible) { + [[self platformWindow] showWindowWithoutActivation]; } else { [[self platformWindow] hideWindow]; } -+ [self postNotificationName: NSWindowDidChangeOcclusionStateNotification]; ++ if (changed) ++ [self postNotificationName: NSWindowDidChangeOcclusionStateNotification]; } - (void) orderFront: (id) sender { -@@ -3064,6 +3645,13 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3064,6 +3648,13 @@ static BOOL _allowsAutomaticWindowTabbing; if (didSize) { // Don't redraw everything unless we really have to. [_backgroundView setFrameSize: _frame.size]; @@ -2484,7 +2497,7 @@ index d545120a..79760f47 100644 [_backgroundView setNeedsDisplay: YES]; [self resetCursorRects]; [self saveFrameUsingName: _autosaveFrameName]; -@@ -3260,8 +3848,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3260,8 +3851,7 @@ static BOOL _allowsAutomaticWindowTabbing; mouseWasInside && !mouseIsInside) { [exited addObject: area]; } @@ -2494,7 +2507,7 @@ index d545120a..79760f47 100644 [moved addObject: area]; } if (options & NSTrackingCursorUpdate && !mouseWasInside && -@@ -3459,7 +4046,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3459,7 +4049,7 @@ static BOOL _allowsAutomaticWindowTabbing; } - (CGSubWindow *) _createSubWindowWithFrame: (CGRect) frame { @@ -5075,7 +5088,7 @@ index bfe3839a..48cfac4c 100644 - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m -index f4925b66..730d4692 100644 +index f4925b66..e458d96f 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -18,11 +18,14 @@ @@ -5571,7 +5584,7 @@ index f4925b66..730d4692 100644 struct { unsigned long flags; unsigned long functions; -@@ -356,8 +620,14 @@ static NSData *makeWindowIcon() { +@@ -356,11 +620,25 @@ static NSData *makeWindowIcon() { - (void) ensureMapped { if (!_mapped) { @@ -5586,7 +5599,18 @@ index f4925b66..730d4692 100644 } } -@@ -377,15 +647,27 @@ static NSData *makeWindowIcon() { ++/* darling-lol: only map for a window AppKit considers visible. NSWindow sets _isVisible before ++ * ordering, so ordered windows still map; a hidden one (like the client's splash) must not be ++ * revived by a later restack or makeKey. */ ++- (void) ensureMappedIfVisible { ++ if (_delegate == nil || [_delegate isVisible]) ++ [self ensureMapped]; ++} ++ + - (void) showWindowWithoutActivation { + [self ensureMapped]; + } +@@ -377,15 +655,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -5619,7 +5643,7 @@ index f4925b66..730d4692 100644 // TODO: background color } -@@ -393,6 +675,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +683,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. @@ -5648,7 +5672,7 @@ index f4925b66..730d4692 100644 [_context release]; _context = nil; -@@ -421,6 +725,392 @@ static NSData *makeWindowIcon() { +@@ -421,6 +733,392 @@ static NSData *makeWindowIcon() { } } @@ -6041,7 +6065,7 @@ index f4925b66..730d4692 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +1160,32 @@ static NSData *makeWindowIcon() { +@@ -470,14 +1168,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -6075,7 +6099,7 @@ index f4925b66..730d4692 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1196,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1204,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -6101,9 +6125,30 @@ index f4925b66..730d4692 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1270,21 @@ static NSData *makeWindowIcon() { +@@ -518,7 +1253,7 @@ static NSData *makeWindowIcon() { + + - (void) placeAboveWindow: (NSInteger) otherNumber { + X11Window *other = [X11Window windowWithWindowNumber: otherNumber]; +- [self ensureMapped]; ++ [self ensureMappedIfVisible]; + + if (!other) { + XRaiseWindow(_display, _window); +@@ -530,7 +1265,7 @@ static NSData *makeWindowIcon() { + + - (void) placeBelowWindow: (NSInteger) otherNumber { + X11Window *other = [X11Window windowWithWindowNumber: otherNumber]; +- [self ensureMapped]; ++ [self ensureMappedIfVisible]; + + if (!other) { + XLowerWindow(_display, _window); +@@ -541,8 +1276,23 @@ static NSData *makeWindowIcon() { + } + - (void) makeKey { - [self ensureMapped]; +- [self ensureMapped]; ++ [self ensureMappedIfVisible]; XRaiseWindow(_display, _window); + /* 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 @@ -6123,7 +6168,7 @@ index f4925b66..730d4692 100644 } - (void) makeMain { -@@ -559,7 +1301,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1309,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -6153,7 +6198,7 @@ index f4925b66..730d4692 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -593,8 +1357,10 @@ static NSData *makeWindowIcon() { +@@ -593,8 +1365,10 @@ static NSData *makeWindowIcon() { if (_cglContext == NULL) { CGLError error; @@ -6166,7 +6211,7 @@ index f4925b66..730d4692 100644 NSLog(@"CGLCreateContext failed at %s %d with error %d", __FILE__, __LINE__, error); -@@ -615,6 +1381,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1389,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -6179,7 +6224,7 @@ index f4925b66..730d4692 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1424,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1432,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -6188,7 +6233,7 @@ index f4925b66..730d4692 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1438,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1446,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -6196,7 +6241,7 @@ index f4925b66..730d4692 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,19 +1483,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,19 +1491,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } diff --git a/tests/test_x11_hidden_window.py b/tests/test_x11_hidden_window.py new file mode 100644 index 0000000..b0397c4 --- /dev/null +++ b/tests/test_x11_hidden_window.py @@ -0,0 +1,36 @@ +"""Check that the patched X11 backend cannot re-map a window AppKit has hidden.""" +import unittest +import test_runtime_compatibility as sources + + +def body(source, signature): + return source.split(signature + ' {', 1)[1].split('\n}', 1)[0] + + +class HiddenWindowChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def test_restack_and_make_key_map_only_visible_windows(self): + source = self.patched('cocotron', 'AppKit/X11.backend/X11Window.m') + self.assertIn('[_delegate isVisible]', body(source, '- (void) ensureMappedIfVisible')) + for signature in ('- (void) placeAboveWindow: (NSInteger) otherNumber', + '- (void) placeBelowWindow: (NSInteger) otherNumber', '- (void) makeKey'): + method = body(source, signature) + self.assertIn('[self ensureMappedIfVisible]', method, signature) + self.assertNotIn('[self ensureMapped]', method, signature) + + def test_ordering_marks_window_visible_before_placing_it(self): + source = self.patched('cocotron', 'AppKit/NSWindow.m') + ordering = source.split('relativeTo: (NSInteger) relativeTo\n{', 1)[1].split('\n}', 1)[0] + for call in ('placeAboveWindow', 'placeBelowWindow'): + self.assertLess(ordering.index('_isVisible = YES'), ordering.index(call)) + + def test_hiding_reaches_the_platform_window_when_already_not_visible(self): + source = self.patched('cocotron', 'AppKit/NSWindow.m') + method = body(source, '- (void) setIsVisible: (BOOL) visible') + self.assertIn('_isVisible == visible && visible', method) + self.assertIn('hideWindow', method) + + +if __name__ == '__main__': + unittest.main() From caec5f1dcea9327ee6bb6cdb791290c7c3623f3c Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:49:04 +0200 Subject: [PATCH 118/160] Make NSOpenPanel Open/Cancel always end the modal session Open with no row selected passed nil to -_setFilename:, and NSOpenPanel boxed it in an array literal, so the action threw before the modal session was stopped and the panel stayed up. Both buttons now go through one helper that stops the session only when this panel is the modal window and always orders the panel out; Open accepts the selection (filtered by canChooseFiles/canChooseDirectories and multiple-selection), or the shown directory for directory choosers. Escape cancels. Co-Authored-By: Claude Sonnet 5.5 --- patches/cocotron.patch | 124 +++++++++++++++++++++++++++++++- tests/test_nsopenpanel_close.py | 46 ++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 tests/test_nsopenpanel_close.py diff --git a/patches/cocotron.patch b/patches/cocotron.patch index b8f2125..85aa08f 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -11316,13 +11316,80 @@ index 00000000..19cd9ea9 + +@end diff --git a/AppKit/NSSavePanel.subproj/NSSavePanel.m b/AppKit/NSSavePanel.subproj/NSSavePanel.m -index 50c3d59e..5872e594 100644 +index 50c3d59e..be5a8463 100644 --- a/AppKit/NSSavePanel.subproj/NSSavePanel.m +++ b/AppKit/NSSavePanel.subproj/NSSavePanel.m -@@ -124,8 +124,48 @@ static NSSavePanel *_newPanel = nil; - [NSApp stopModalWithCode: NSCancelButton]; +@@ -113,19 +113,108 @@ + } } ++/* Paths of the rows selected in the file list. Safe with no selection. */ ++- (NSArray *) _selectedPaths { ++ NSMutableArray *paths = [NSMutableArray array]; ++ NSIndexSet *rows = [_outlineView selectedRowIndexes]; ++ for (NSUInteger row = [rows firstIndex]; row != NSNotFound; ++ row = [rows indexGreaterThanIndex: row]) { ++ id item = [_outlineView itemAtRow: row]; ++ if ([item isKindOfClass: [NSURL class]] && [[item path] length] > 0) ++ [paths addObject: [item path]]; ++ } ++ return paths; ++} ++ ++/* What the Open/Save button accepts: the selected rows, or with nothing ++ * selected the directory being shown. An empty result keeps the panel up. */ ++- (NSArray *) _acceptablePaths { ++ NSArray *selected = [self _selectedPaths]; ++ if ([selected count] > 0) ++ return selected; ++ if ([_directory length] > 0) ++ return @[ _directory ]; ++ return @[]; ++} ++ ++- (void) _setAcceptedPaths: (NSArray *) paths { ++ [self _setFilename: [paths firstObject]]; ++} ++ ++/* End the modal session this panel is running and take the panel off screen. ++ * The session is stopped only when this panel is the modal window, never ++ * another session that happens to be on top. The panel is hidden here as well ++ * so a dialog whose session cannot be stopped still goes away. */ ++- (void) _dismissWithCode: (NSModalResponse) code { ++ if ([NSApp modalWindow] == self) ++ [NSApp stopModalWithCode: code]; ++ [self orderOut: nil]; ++} ++ + - (IBAction) _selectFile: (id) sender { +- NSURL *url = [_outlineView itemAtRow: [_outlineView selectedRow]]; +- [self _setFilename: [url path]]; +- +- [NSApp stopModalWithCode: NSOKButton]; ++ NSArray *paths = [self _acceptablePaths]; ++ if ([paths count] == 0) ++ return; ++ @try { ++ [self _setAcceptedPaths: paths]; ++ } @catch (NSException *exception) { ++ NSLog(@"NSSavePanel: could not record the chosen file: %@", exception); ++ [self _dismissWithCode: NSCancelButton]; ++ return; ++ } ++ [self _dismissWithCode: NSOKButton]; + } + + - (IBAction) _cancel: (id) sender { +- [NSApp stopModalWithCode: NSCancelButton]; +-} +- ++ [self _dismissWithCode: NSCancelButton]; ++} ++ ++- (void) cancelOperation: (id) sender { ++ [self _cancel: sender]; ++} ++ +// Electron (Riot Client UI) opens its file dialogs through these and waits +// for the handler; run the panel modally and report the result. - (void) beginWithCompletionHandler: (void (^)(NSModalResponse result)) handler { @@ -11368,6 +11435,57 @@ index 50c3d59e..5872e594 100644 + return NO; } + - (NSInteger) runModalForDirectory: (NSString *) directory +diff --git a/AppKit/NSSavePanel.subproj/NSOpenPanel.m b/AppKit/NSSavePanel.subproj/NSOpenPanel.m +index dfd33637..9388b186 100644 +--- a/AppKit/NSSavePanel.subproj/NSOpenPanel.m ++++ b/AppKit/NSSavePanel.subproj/NSOpenPanel.m +@@ -23,6 +23,8 @@ + + @interface NSSavePanel (Private) + - (void) _setFilename: (NSString *) filename; ++- (NSArray *) _selectedPaths; ++- (void) _setAcceptedPaths: (NSArray *) paths; + @end + + @implementation NSOpenPanel +@@ -92,7 +94,36 @@ + + - (void) _setFilename: (NSString *) filename { + [super _setFilename: filename]; +- _filenames = [@[ filename ] retain]; ++ [self _setAcceptedPaths: [filename length] > 0 ? @[ filename ] : @[]]; ++} ++ ++- (void) _setAcceptedPaths: (NSArray *) paths { ++ if (!_allowsMultipleSelection && [paths count] > 1) ++ paths = @[ [paths firstObject] ]; ++ @synchronized(self) { ++ NSArray *copy = [paths copy]; ++ [_filenames release]; ++ _filenames = copy; ++ } ++ [super _setFilename: [paths firstObject]]; ++} ++ ++/* Open only accepts what the panel was configured to choose. With nothing ++ * selected, a directory chooser accepts the directory being shown. */ ++- (NSArray *) _acceptablePaths { ++ NSFileManager *manager = [NSFileManager defaultManager]; ++ NSMutableArray *result = [NSMutableArray array]; ++ for (NSString *path in [self _selectedPaths]) { ++ BOOL isDirectory = NO; ++ if (![manager fileExistsAtPath: path isDirectory: &isDirectory]) ++ continue; ++ if (isDirectory ? _canChooseDirectories : _canChooseFiles) ++ [result addObject: path]; ++ } ++ if ([result count] == 0 && [[self _selectedPaths] count] == 0 && ++ _canChooseDirectories && [[self directory] length] > 0) ++ [result addObject: [self directory]]; ++ return result; + } + - (NSInteger) runModalForDirectory: (NSString *) directory diff --git a/AppKit/include/AppKit/NSSavePanel.h b/AppKit/include/AppKit/NSSavePanel.h index 53bacc46..3c6adfb7 100644 diff --git a/tests/test_nsopenpanel_close.py b/tests/test_nsopenpanel_close.py new file mode 100644 index 0000000..68faed1 --- /dev/null +++ b/tests/test_nsopenpanel_close.py @@ -0,0 +1,46 @@ +"""Check that the patched Cocotron file panels always end their modal session and never throw on Open.""" +import unittest +import test_runtime_compatibility as sources + + +def body(source, signature): + return source.split(signature + ' {', 1)[1].split('\n}', 1)[0] + + +class FilePanelCloseChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def save_panel(self): + return self.patched('cocotron', 'AppKit/NSSavePanel.subproj/NSSavePanel.m') + + def open_panel(self): + return self.patched('cocotron', 'AppKit/NSSavePanel.subproj/NSOpenPanel.m') + + def test_both_buttons_dismiss_through_one_helper(self): + source = self.save_panel() + dismiss = body(source, '- (void) _dismissWithCode: (NSModalResponse) code') + self.assertIn('[NSApp modalWindow] == self', dismiss) + self.assertIn('stopModalWithCode: code', dismiss) + self.assertIn('orderOut', dismiss) + self.assertIn('_dismissWithCode: NSCancelButton', body(source, '- (IBAction) _cancel: (id) sender')) + self.assertIn('_dismissWithCode: NSOKButton', body(source, '- (IBAction) _selectFile: (id) sender')) + + def test_open_without_a_selection_cannot_throw(self): + # Nothing selected used to hand nil to -_setFilename:, and NSOpenPanel boxed it in @[ ... ]. + self.assertNotIn('@[ filename ]];', self.open_panel().replace('[filename length] > 0 ? @[ filename ] : @[]', '')) + select = body(self.save_panel(), '- (IBAction) _selectFile: (id) sender') + self.assertNotIn('itemAtRow: [_outlineView selectedRow]', select) + self.assertIn('@catch', select) + self.assertIn('NSCancelButton', select.split('@catch', 1)[1]) + + def test_open_panel_honours_what_it_may_choose(self): + accept = body(self.open_panel(), '- (NSArray *) _acceptablePaths') + self.assertIn('_canChooseDirectories', accept) + self.assertIn('_canChooseFiles', accept) + + def test_escape_cancels(self): + self.assertIn('[self _cancel: sender]', body(self.save_panel(), '- (void) cancelOperation: (id) sender')) + + +if __name__ == '__main__': + unittest.main() From 15b1c49d02597e9197fe9fa4338b0ceab71e2c22 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:19:42 +0200 Subject: [PATCH 119/160] Add standalone NSOpenPanel Open/Cancel probe for issue #85 Co-Authored-By: Claude Sonnet 5.5 --- docs/TESTING.md | 31 ++++++ tools/build-riot-openpanel-check.py | 24 ++++ tools/riot-openpanel-check.m | 166 ++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100755 tools/build-riot-openpanel-check.py create mode 100644 tools/riot-openpanel-check.m diff --git a/docs/TESTING.md b/docs/TESTING.md index 7f8c1b9..3249b96 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -178,6 +178,37 @@ Riot Client also confirmed the no-decoration hint and identical compositor frame and client dimensions (1536×864), instead of the previous 28-pixel titlebar. This validates the Riot Client window, not every LeagueClientUx or game mode. +### NSOpenPanel Open/Cancel (issue #85) + +`python3 tools/build-riot-openpanel-check.py` builds +`build/riot-openpanel-check`. Install the patched AppKit first, with clients and +the game closed. The probe opens its own directory chooser in a fresh `/tmp` +folder containing one subfolder and needs no Riot/League client: + +```sh +P=/Volumes/SystemRoot/path/to/darling-lol/build/riot-openpanel-check +E="env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks" +for m in cancel open-selected open-none; do darling shell $E $P --auto $m; done +darling shell $E $P --interactive +``` + +Each `--auto` mode waits 1.5 s, then clicks the panel's own Cancel or Open +button (selecting the subfolder first for `open-selected`), and checks that +`runModal` returned, the result code and URL, and that the panel is gone. A +15 s watchdog on a separate thread reports a hang. Expected, one line per mode: + +``` +OPENPANEL_CHECK mode=cancel result=PASS code=0 ... +OPENPANEL_CHECK mode=open-selected result=PASS code=1 url=/tmp/openpanel-check.XXXXXX/subfolder ... +OPENPANEL_CHECK mode=open-none result=PASS code=1 url=/tmp/openpanel-check.XXXXXX ... +``` + +A failure prints `result=FAIL ... problems=...` (`HANG` when `runModal` never +returned) and exits non-zero. `--interactive` shows the panel for manual clicks +and prints `OPENPANEL_CHECK mode=interactive result=RETURNED code=... url=...` +once it closes. As with the other probes, check the printed line rather than +only the launcher exit status. + ### Presentation publisher handoff With clients closed, run `build/riot-client-resize-check --handoff` through Darling with the normal runtime overrides. This creates an owned window and two fixture frame slots. The live software owner first supplies a 1280×720 main frame, then switches to a 262×131 popup; another slot supplies the resized main frame without the software flag. The test samples its own window beyond the original frame extent and checks generation colors across growth and shrinkage. The old selector reports black at the 1600×900 sample; the correction passes all four sizes. Repeat with `RCLOL_XSHM=0` and with `DARLING_XSHM_LIBRARY` pointing to the normal transport to cover both paths. The fixtures remove their own frame slots on exit; they do not read client pixels or send input. diff --git a/tools/build-riot-openpanel-check.py b/tools/build-riot-openpanel-check.py new file mode 100755 index 0000000..2bab44b --- /dev/null +++ b/tools/build-riot-openpanel-check.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Build the owned NSOpenPanel modal-session check (issue #85).""" +from pathlib import Path +import json +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +folder = root / 'build/darling-src/build/src/external/cocotron/AppKit' +config = root / 'build/config.json' +sdk = json.loads(config.read_text())['sdk'] if config.exists() else '/usr/local/libexec/darling' +flags = {} +for line in (folder / 'CMakeFiles/X11_backend.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +obj = root / 'build/riot-openpanel-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-fblocks', '-UNDEBUG', '-Wno-macro-redefined', '-c', str(root / 'tools/riot-openpanel-check.m'), + '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', sdk, str(obj), '-lSystem', '-lobjc', + '-framework', 'AppKit', '-framework', 'CoreFoundation', '-framework', 'Foundation', + '-o', str(root / 'build/riot-openpanel-check')], check=True) diff --git a/tools/riot-openpanel-check.m b/tools/riot-openpanel-check.m new file mode 100644 index 0000000..73b4cba --- /dev/null +++ b/tools/riot-openpanel-check.m @@ -0,0 +1,166 @@ +/* Standalone AppKit regression for issue #85: the panel's own Open/Cancel + * buttons must end runModal. Drives only its own NSOpenPanel; no Riot/League + * client is launched or contacted and no external input is generated. + * + * riot-openpanel-check --auto cancel|open-selected|open-none + * riot-openpanel-check --interactive + */ +#import +#include +#include +#include +#include +#include + +static NSOpenPanel *panel; +static NSString *tempDir, *subDir; +static const char *mode; +static BOOL interactive; +static BOOL panelVisibleAtTrigger; +static NSString *triggerNote = @""; +static const double kTriggerDelay = 1.5, kWatchdog = 15.0; + +static NSButton *findButton(NSView *view, SEL action) { + if ([view isKindOfClass:[NSButton class]] && [(NSButton *)view action] == action) + return (NSButton *)view; + for (NSView *sub in [view subviews]) { + NSButton *found = findButton(sub, action); + if (found) return found; + } + return nil; +} + +static NSOutlineView *findOutline(NSView *view) { + if ([view isKindOfClass:[NSOutlineView class]]) return (NSOutlineView *)view; + for (NSView *sub in [view subviews]) { + NSOutlineView *found = findOutline(sub); + if (found) return found; + } + return nil; +} + +static void press(SEL action, NSString *name) { + NSButton *button = findButton([panel contentView], action); + if (button) { + triggerNote = [NSString stringWithFormat:@"%@ via button", name]; + [button performClick:nil]; + } else { + triggerNote = [NSString stringWithFormat:@"%@ via direct action (button not found)", name]; + [panel performSelector:action withObject:nil]; + } +} + +static void selectSubfolder(void) { + NSOutlineView *outline = findOutline([panel contentView]); + if (!outline) { triggerNote = @"no outline view, subfolder row not found"; return; } + [outline reloadData]; + for (NSInteger row = 0; row < [outline numberOfRows]; row++) { + id item = [outline itemAtRow:row]; + NSString *path = [item isKindOfClass:[NSURL class]] ? [item path] : [item description]; + if ([path isEqualToString:subDir]) { + [outline selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; + return; + } + } + triggerNote = @"subfolder row not found"; +} + +@interface Trigger : NSObject +- (void)fire:(NSTimer *)timer; +@end +@implementation Trigger +- (void)fire:(NSTimer *)timer { + static BOOL done; + if (done) return; + done = YES; + panelVisibleAtTrigger = [panel isVisible]; + if (!strcmp(mode, "cancel")) { + press(@selector(_cancel:), @"cancel"); + } else { + if (!strcmp(mode, "open-selected")) selectSubfolder(); + press(@selector(_selectFile:), @"open"); + } +} +@end + +int main(int argc, char **argv) { + setbuf(stdout, NULL); + if (argc == 2 && !strcmp(argv[1], "--interactive")) { + interactive = YES; + mode = "interactive"; + } else if (argc == 3 && !strcmp(argv[1], "--auto") && + (!strcmp(argv[2], "cancel") || !strcmp(argv[2], "open-selected") || + !strcmp(argv[2], "open-none"))) { + mode = argv[2]; + } else { + fprintf(stderr, "usage: %s --auto cancel|open-selected|open-none | --interactive\n", argv[0]); + return 2; + } + @autoreleasepool { + [NSApplication sharedApplication]; + char templ[] = "/tmp/openpanel-check.XXXXXX"; + tempDir = [[NSString stringWithUTF8String:mkdtemp(templ)] retain]; + subDir = [[tempDir stringByAppendingPathComponent:@"subfolder"] retain]; + [[NSFileManager defaultManager] createDirectoryAtPath:subDir + withIntermediateDirectories:YES attributes:nil error:NULL]; + + panel = [NSOpenPanel openPanel]; + [panel setCanChooseDirectories:YES]; + [panel setCanChooseFiles:NO]; + [panel setAllowsMultipleSelection:NO]; + [panel setDirectory:tempDir]; + + if (!interactive) { + /* Independent of the run loop: a hung runModal still reports. */ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kWatchdog * NSEC_PER_SEC)), + dispatch_get_global_queue(0, 0), ^{ + printf("OPENPANEL_CHECK mode=%s result=FAIL problems=HANG " + "trigger=\"%s\" visible_at_trigger=%d\n", + mode, [triggerNote UTF8String], panelVisibleAtTrigger); + fflush(stdout); + _exit(3); + }); + Trigger *trigger = [[Trigger alloc] init]; + NSTimer *timer = [NSTimer timerWithTimeInterval:kTriggerDelay target:trigger + selector:@selector(fire:) userInfo:nil repeats:NO]; + [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode]; + [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSModalPanelRunLoopMode]; + } else { + printf("Interactive: pick the folder, or press Open/Cancel on the panel.\n"); + } + + NSInteger code = [panel runModal]; + NSString *path = [[panel URL] path]; + NSArray *urls = [panel URLs]; + BOOL visible = [panel isVisible]; + NSString *shown = path ? path : @"(none)"; + + if (interactive) { + printf("OPENPANEL_CHECK mode=interactive result=RETURNED code=%ld url=%s urls=%lu visible=%d\n", + (long)code, [shown UTF8String], (unsigned long)[urls count], visible); + return 0; + } + + NSMutableArray *problems = [NSMutableArray array]; + BOOL wantOpen = strcmp(mode, "cancel") != 0; + NSString *wantPath = !strcmp(mode, "open-selected") ? subDir : tempDir; + if (!panelVisibleAtTrigger) [problems addObject:@"panel_not_visible_at_trigger"]; + if (wantOpen) { + if (code != NSOKButton) [problems addObject:@"code_not_ok"]; + if (![path isEqualToString:wantPath]) [problems addObject:@"wrong_url"]; + if ([urls count] != 1) [problems addObject:@"wrong_url_count"]; + } else if (code != NSCancelButton) { + [problems addObject:@"code_not_cancel"]; + } + if (visible) [problems addObject:@"still_visible"]; + if ([triggerNote hasSuffix:@"not found"]) [problems addObject:triggerNote]; + BOOL pass = [problems count] == 0; + printf("OPENPANEL_CHECK mode=%s result=%s code=%ld url=%s expected_url=%s visible=%d trigger=\"%s\"%s%s\n", + mode, pass ? "PASS" : "FAIL", (long)code, [shown UTF8String], + wantOpen ? [wantPath UTF8String] : "-", visible, [triggerNote UTF8String], + pass ? "" : " problems=", + pass ? "" : [[problems componentsJoinedByString:@","] UTF8String]); + fflush(stdout); + return pass ? 0 : 1; + } +} From 092c4546993966d32c2e6a805662e25d99d41175 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:28:34 +0200 Subject: [PATCH 120/160] Fix openpanel probe: keep trigger note and panel alive across modal pools Co-Authored-By: Claude Sonnet 5.5 --- tools/riot-openpanel-check.m | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tools/riot-openpanel-check.m b/tools/riot-openpanel-check.m index 73b4cba..7143619 100644 --- a/tools/riot-openpanel-check.m +++ b/tools/riot-openpanel-check.m @@ -17,7 +17,10 @@ static NSString *tempDir, *subDir; static const char *mode; static BOOL interactive; static BOOL panelVisibleAtTrigger; -static NSString *triggerNote = @""; +/* Plain C storage: an autoreleased NSString kept here would be freed by the + * modal loop's pools before the result is reported (and the watchdog thread + * reads it too). */ +static char triggerNote[160] = ""; static const double kTriggerDelay = 1.5, kWatchdog = 15.0; static NSButton *findButton(NSView *view, SEL action) { @@ -39,20 +42,20 @@ static NSOutlineView *findOutline(NSView *view) { return nil; } -static void press(SEL action, NSString *name) { +static void press(SEL action, const char *name) { NSButton *button = findButton([panel contentView], action); if (button) { - triggerNote = [NSString stringWithFormat:@"%@ via button", name]; + snprintf(triggerNote, sizeof triggerNote, "%s via button", name); [button performClick:nil]; } else { - triggerNote = [NSString stringWithFormat:@"%@ via direct action (button not found)", name]; + snprintf(triggerNote, sizeof triggerNote, "%s via direct action (button not found)", name); [panel performSelector:action withObject:nil]; } } static void selectSubfolder(void) { NSOutlineView *outline = findOutline([panel contentView]); - if (!outline) { triggerNote = @"no outline view, subfolder row not found"; return; } + if (!outline) { snprintf(triggerNote, sizeof triggerNote, "no outline view, subfolder row not found"); return; } [outline reloadData]; for (NSInteger row = 0; row < [outline numberOfRows]; row++) { id item = [outline itemAtRow:row]; @@ -62,7 +65,7 @@ static void selectSubfolder(void) { return; } } - triggerNote = @"subfolder row not found"; + snprintf(triggerNote, sizeof triggerNote, "subfolder row not found"); } @interface Trigger : NSObject @@ -75,10 +78,10 @@ static void selectSubfolder(void) { done = YES; panelVisibleAtTrigger = [panel isVisible]; if (!strcmp(mode, "cancel")) { - press(@selector(_cancel:), @"cancel"); + press(@selector(_cancel:), "cancel"); } else { if (!strcmp(mode, "open-selected")) selectSubfolder(); - press(@selector(_selectFile:), @"open"); + press(@selector(_selectFile:), "open"); } } @end @@ -104,7 +107,7 @@ int main(int argc, char **argv) { [[NSFileManager defaultManager] createDirectoryAtPath:subDir withIntermediateDirectories:YES attributes:nil error:NULL]; - panel = [NSOpenPanel openPanel]; + panel = [[NSOpenPanel openPanel] retain]; [panel setCanChooseDirectories:YES]; [panel setCanChooseFiles:NO]; [panel setAllowsMultipleSelection:NO]; @@ -116,7 +119,7 @@ int main(int argc, char **argv) { dispatch_get_global_queue(0, 0), ^{ printf("OPENPANEL_CHECK mode=%s result=FAIL problems=HANG " "trigger=\"%s\" visible_at_trigger=%d\n", - mode, [triggerNote UTF8String], panelVisibleAtTrigger); + mode, triggerNote, panelVisibleAtTrigger); fflush(stdout); _exit(3); }); @@ -153,11 +156,12 @@ int main(int argc, char **argv) { [problems addObject:@"code_not_cancel"]; } if (visible) [problems addObject:@"still_visible"]; - if ([triggerNote hasSuffix:@"not found"]) [problems addObject:triggerNote]; + if (strstr(triggerNote, "not found") && !strstr(triggerNote, "button not found")) + [problems addObject:@"subfolder_row_not_found"]; BOOL pass = [problems count] == 0; printf("OPENPANEL_CHECK mode=%s result=%s code=%ld url=%s expected_url=%s visible=%d trigger=\"%s\"%s%s\n", mode, pass ? "PASS" : "FAIL", (long)code, [shown UTF8String], - wantOpen ? [wantPath UTF8String] : "-", visible, [triggerNote UTF8String], + wantOpen ? [wantPath UTF8String] : "-", visible, triggerNote, pass ? "" : " problems=", pass ? "" : [[problems componentsJoinedByString:@","] UTF8String]); fflush(stdout); From 9efaf4fb8e4bb99a0519375c47c8af3b77524d25 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:33:30 +0200 Subject: [PATCH 121/160] Openpanel probe: expand the tree to the subfolder before selecting, print selection diagnostics Co-Authored-By: Claude Sonnet 5.5 --- tools/riot-openpanel-check.m | 57 ++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/tools/riot-openpanel-check.m b/tools/riot-openpanel-check.m index 7143619..8aac17e 100644 --- a/tools/riot-openpanel-check.m +++ b/tools/riot-openpanel-check.m @@ -7,6 +7,7 @@ */ #import #include +#include #include #include #include @@ -53,19 +54,49 @@ static void press(SEL action, const char *name) { } } -static void selectSubfolder(void) { +static char selectNote[320] = ""; +static BOOL selectionOK = YES; + +static NSString *urlPath(id item) { + NSString *path = [item isKindOfClass:[NSURL class]] ? [item path] : nil; + while ([path length] > 1 && [path hasSuffix:@"/"]) + path = [path substringToIndex:[path length] - 1]; + return path; +} + +/* The panel's tree is rooted at "/" and is not navigated to the directory + * being shown, so expand each component of subDir from the root, then select + * its row. Records diagnostics in selectNote. */ +static BOOL selectSubfolder(void) { NSOutlineView *outline = findOutline([panel contentView]); - if (!outline) { snprintf(triggerNote, sizeof triggerNote, "no outline view, subfolder row not found"); return; } + if (!outline) { snprintf(selectNote, sizeof selectNote, "no_outline_view"); return NO; } [outline reloadData]; - for (NSInteger row = 0; row < [outline numberOfRows]; row++) { - id item = [outline itemAtRow:row]; - NSString *path = [item isKindOfClass:[NSURL class]] ? [item path] : [item description]; - if ([path isEqualToString:subDir]) { - [outline selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; - return; + NSString *prefix = @""; + NSInteger found = -1; + for (NSString *part in [subDir pathComponents]) { + if ([part isEqualToString:@"/"]) continue; + prefix = [prefix stringByAppendingFormat:@"/%@", part]; + found = -1; + for (NSInteger row = 0; row < [outline numberOfRows]; row++) { + if ([urlPath([outline itemAtRow:row]) isEqualToString:prefix]) { found = row; break; } } + if (found < 0) { + snprintf(selectNote, sizeof selectNote, "row_not_found at=%s rows=%ld", + [prefix UTF8String], (long)[outline numberOfRows]); + return NO; + } + if (![prefix isEqualToString:subDir]) [outline expandItem:[outline itemAtRow:found]]; } - snprintf(triggerNote, sizeof triggerNote, "subfolder row not found"); + [outline selectRowIndexes:[NSIndexSet indexSetWithIndex:found] byExtendingSelection:NO]; + NSIndexSet *sel = [outline selectedRowIndexes]; + NSInteger selRow = [sel firstIndex] == NSNotFound ? -1 : (NSInteger)[sel firstIndex]; + id item = selRow >= 0 ? [outline itemAtRow:selRow] : nil; + NSString *itemPath = urlPath(item); + snprintf(selectNote, sizeof selectNote, + "target_row=%ld selected_count=%lu selected_row=%ld item_class=%s item_path=%s", + (long)found, (unsigned long)[sel count], (long)selRow, + item ? class_getName([item class]) : "nil", itemPath ? [itemPath UTF8String] : "(nil)"); + return selRow == found; } @interface Trigger : NSObject @@ -80,7 +111,7 @@ static void selectSubfolder(void) { if (!strcmp(mode, "cancel")) { press(@selector(_cancel:), "cancel"); } else { - if (!strcmp(mode, "open-selected")) selectSubfolder(); + if (!strcmp(mode, "open-selected")) selectionOK = selectSubfolder(); press(@selector(_selectFile:), "open"); } } @@ -156,12 +187,12 @@ int main(int argc, char **argv) { [problems addObject:@"code_not_cancel"]; } if (visible) [problems addObject:@"still_visible"]; - if (strstr(triggerNote, "not found") && !strstr(triggerNote, "button not found")) - [problems addObject:@"subfolder_row_not_found"]; + if (!selectionOK) [problems addObject:@"selection_failed"]; BOOL pass = [problems count] == 0; - printf("OPENPANEL_CHECK mode=%s result=%s code=%ld url=%s expected_url=%s visible=%d trigger=\"%s\"%s%s\n", + printf("OPENPANEL_CHECK mode=%s result=%s code=%ld url=%s expected_url=%s visible=%d trigger=\"%s\"%s%s%s%s%s\n", mode, pass ? "PASS" : "FAIL", (long)code, [shown UTF8String], wantOpen ? [wantPath UTF8String] : "-", visible, triggerNote, + selectNote[0] ? " selection=\"" : "", selectNote, selectNote[0] ? "\"" : "", pass ? "" : " problems=", pass ? "" : [[problems componentsJoinedByString:@","] UTF8String]); fflush(stdout); From cf86cff688f9142306a126940b53c2f5e793b87c Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:00:24 +0200 Subject: [PATCH 122/160] Keep the merged X11 patch in canonical diff format --- patches/cocotron.patch | 552 ++++++++++++----------------------------- 1 file changed, 161 insertions(+), 391 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 231899a..cc6452b 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -5092,6 +5092,7 @@ index bfe3839a..bc592711 100644 - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m +index f4925b6..b565b91 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -18,11 +18,14 @@ @@ -5109,134 +5110,10 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m #import #import #import -@@ -38,130 +41,40 @@ +@@ -38,6 +41,110 @@ #import "X11Window.h" #import #import -- --@implementation X11Window -- --+ (Visual *) visual { -- static Visual *ret = NULL; -- -- if (!ret) { -- int visuals_matched, i; -- XVisualInfo match = {0}; -- Display *dpy = [(X11Display *) [NSDisplay currentDisplay] display]; -- -- XVisualInfo *info = XGetVisualInfo(dpy, 0, &match, &visuals_matched); -- -- for (i = 0; i < visuals_matched; i++) { -- if (info[i].depth == 32 && (info[i].red_mask == 0xff0000 && -- info[i].green_mask == 0x00ff00 && -- info[i].blue_mask == 0x0000ff)) { -- ret = info[i].visual; -- } -- } -- XFree(info); -- if (!ret) -- ret = DefaultVisual(dpy, DefaultScreen(dpy)); -- } -- -- return ret; --} -- --static NSData *makeWindowIcon() { -- static NSMutableData *res; -- -- if (res != nil) -- return res; -- -- // Figure out the path. -- NSBundle *bundle = [NSBundle mainBundle]; -- NSString *name = [bundle objectForInfoDictionaryKey: @"CFBundleIconFile"]; -- if (name == nil || [name length] == 0) -- return nil; -- NSString *path = [bundle pathForImageResource: name]; -- if (path == nil) { -- NSLog(@"Cannot find an icon file named %@", name); -- return nil; -- } -- NSURL *url = [NSURL fileURLWithPath: path]; -- -- O2ImageSource *imageSource = [O2ImageSource newImageSourceWithURL: url -- options: nil]; -- if (imageSource == nil) { -- NSLog(@"Cannot parse the icon"); -- return nil; -- } -- -- res = [[NSMutableData alloc] initWithCapacity: 32 * 32 * 4 + 2]; -- O2ColorSpace *colorSpace = O2ColorSpaceCreateDeviceRGB(); -- -- // Go over the images, turning them into the required format -- // and appending them to res. -- for (NSUInteger i = 0; i < [imageSource count]; i++) { -- -- O2Image *image = [imageSource createImageAtIndex: i options: nil]; -- NSUInteger width = O2ImageGetWidth(image); -- NSUInteger height = O2ImageGetHeight(image); -- -- // Render the image in ARGB. -- O2BitmapContext *context = (O2BitmapContext *) O2BitmapContextCreate( -- NULL, width, height, 8, width * 4, colorSpace, -- kO2ImageAlphaPremultipliedFirst | kO2BitmapByteOrder32Host); -- -- [context drawImage: image inRect: O2RectMake(0, 0, width, height)]; -- int *imageData = O2BitmapContextGetData(context); -- -- // Convert to the format accepted by Xlib. -- NSMutableData *data = -- [NSMutableData dataWithLength: width * height * sizeof(long)]; -- long *dataPtr = (long *) [data mutableBytes]; -- int count = width * height; -- for (int i = 0; i < count; i++) { -- dataPtr[i] = imageData[i]; -- } -- -- struct { -- long w, h; -- } size = {width, height}; -- [res appendBytes: &size length: sizeof(size)]; -- [res appendData: data]; -- -- [context release]; -- [image release]; -- } -- -- [colorSpace release]; -- [imageSource release]; -- -- return res; --} -- --- (void) setWindowIcon { -- -- NSData *data = makeWindowIcon(); -- -- if (data == nil) -- return; -- -- XChangeProperty( -- _display, _window, XInternAtom(_display, "_NET_WM_ICON", False), -- XInternAtom(_display, "CARDINAL", False), 32, PropModeReplace, -- [data bytes], [data length] / sizeof(long)); --} -- --- (instancetype) initWithDelegate: (NSWindow *) delegate { -- _delegate = delegate; -- _level = [delegate level]; -- _styleMask = [delegate styleMask]; -- _backingType = (CGSBackingStoreType)[delegate backingType]; -- _deviceDictionary = [NSMutableDictionary new]; -- -- X11Display* x11disp = (X11Display *) [NSDisplay currentDisplay]; -- _display = [x11disp display]; -- -- _frame = [self transformFrame: [delegate frame]]; -- BOOL isPanel = [delegate isKindOfClass: [NSPanel class]]; -- if (isPanel && _styleMask & NSDocModalWindowMask) -- _styleMask = NSBorderlessWindowMask; +#import +#include +#include @@ -5271,16 +5148,19 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + if (visualInfo != NULL) + return visualInfo; + - // TODO: get rid of these glX calls - GLint att[] = {GLX_RGBA, - GLX_DOUBLEBUFFER, -@@ -175,36 +88,252 @@ - 4, - None}; - -- int screen = DefaultScreen(_display); -- -- if ((_visualInfo = glXChooseVisual(_display, screen, att)) == NULL) { ++ // TODO: get rid of these glX calls ++ GLint att[] = {GLX_RGBA, ++ GLX_DOUBLEBUFFER, ++ GLX_RED_SIZE, ++ 4, ++ GLX_GREEN_SIZE, ++ 4, ++ GLX_BLUE_SIZE, ++ 4, ++ GLX_DEPTH_SIZE, ++ 4, ++ None}; ++ + int screen = DefaultScreen(display); + + // Rendering goes through EGL. NVIDIA's glXChooseVisual can return a GLX-only visual that every @@ -5295,16 +5175,8 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + visualInfo = NULL; + } + if (visualInfo == NULL && (visualInfo = glXChooseVisual(display, screen, att)) == NULL) { - NSLog(@"glXChooseVisual failed at %s %d", __FILE__, __LINE__); - } -- -- Colormap cmap = -- XCreateColormap(_display, RootWindow(_display, _visualInfo->screen), -- _visualInfo->visual, AllocNone); -- -- if (cmap < 0) { -- NSLog(@"XCreateColormap failed"); -- [self release]; ++ NSLog(@"glXChooseVisual failed at %s %d", __FILE__, __LINE__); ++ } + return visualInfo; +} + @@ -5346,145 +5218,56 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + } + return found; +} -+ -+@implementation X11Window -+ -++ (Visual *) visual { -+ static Visual *ret = NULL; -+ -+ if (!ret) { -+ int visuals_matched, i; -+ XVisualInfo match = {0}; -+ Display *dpy = [(X11Display *) [NSDisplay currentDisplay] display]; -+ -+ XVisualInfo *info = XGetVisualInfo(dpy, 0, &match, &visuals_matched); -+ -+ for (i = 0; i < visuals_matched; i++) { -+ if (info[i].depth == 32 && (info[i].red_mask == 0xff0000 && -+ info[i].green_mask == 0x00ff00 && -+ info[i].blue_mask == 0x0000ff)) { -+ ret = info[i].visual; -+ } -+ } -+ XFree(info); -+ if (!ret) -+ ret = DefaultVisual(dpy, DefaultScreen(dpy)); -+ } -+ -+ return ret; -+} -+ -+static NSData *makeWindowIcon() { -+ static NSMutableData *res; -+ -+ if (res != nil) -+ return res; -+ -+ // Figure out the path. -+ NSBundle *bundle = [NSBundle mainBundle]; -+ NSString *name = [bundle objectForInfoDictionaryKey: @"CFBundleIconFile"]; -+ if (name == nil || [name length] == 0) - return nil; -- } -+ NSString *path = [bundle pathForImageResource: name]; -+ if (path == nil) { -+ NSLog(@"Cannot find an icon file named %@", name); -+ return nil; -+ } -+ NSURL *url = [NSURL fileURLWithPath: path]; -+ -+ O2ImageSource *imageSource = [O2ImageSource newImageSourceWithURL: url -+ options: nil]; -+ if (imageSource == nil) { -+ NSLog(@"Cannot parse the icon"); -+ return nil; -+ } -+ -+ res = [[NSMutableData alloc] initWithCapacity: 32 * 32 * 4 + 2]; -+ O2ColorSpace *colorSpace = O2ColorSpaceCreateDeviceRGB(); -+ -+ // Go over the images, turning them into the required format -+ // and appending them to res. -+ for (NSUInteger i = 0; i < [imageSource count]; i++) { -+ -+ O2Image *image = [imageSource createImageAtIndex: i options: nil]; -+ NSUInteger width = O2ImageGetWidth(image); -+ NSUInteger height = O2ImageGetHeight(image); -+ -+ // Render the image in ARGB. -+ O2BitmapContext *context = (O2BitmapContext *) O2BitmapContextCreate( -+ NULL, width, height, 8, width * 4, colorSpace, -+ kO2ImageAlphaPremultipliedFirst | kO2BitmapByteOrder32Host); -+ -+ [context drawImage: image inRect: O2RectMake(0, 0, width, height)]; -+ int *imageData = O2BitmapContextGetData(context); -+ -+ // Convert to the format accepted by Xlib. -+ NSMutableData *data = -+ [NSMutableData dataWithLength: width * height * sizeof(long)]; -+ long *dataPtr = (long *) [data mutableBytes]; -+ int count = width * height; -+ for (int i = 0; i < count; i++) { -+ dataPtr[i] = imageData[i]; -+ } -+ -+ struct { -+ long w, h; -+ } size = {width, height}; -+ [res appendBytes: &size length: sizeof(size)]; -+ [res appendData: data]; -+ -+ [context release]; -+ [image release]; -+ } -+ -+ [colorSpace release]; -+ [imageSource release]; -+ -+ return res; -+} -+ -+- (void) setWindowIcon { -+ -+ NSData *data = makeWindowIcon(); -+ -+ if (data == nil) -+ return; -+ -+ XChangeProperty( -+ _display, _window, XInternAtom(_display, "_NET_WM_ICON", False), -+ XInternAtom(_display, "CARDINAL", False), 32, PropModeReplace, -+ [data bytes], [data length] / sizeof(long)); -+} -+ -+- (instancetype) initWithDelegate: (NSWindow *) delegate { -+ _delegate = delegate; -+ _level = [delegate level]; -+ _styleMask = [delegate styleMask]; -+ _backingType = (CGSBackingStoreType)[delegate backingType]; -+ _deviceDictionary = [NSMutableDictionary new]; -+ -+ X11Display* x11disp = (X11Display *) [NSDisplay currentDisplay]; -+ _display = [x11disp display]; -+ + + @implementation X11Window + +@@ -158,53 +265,75 @@ static NSData *makeWindowIcon() { + X11Display* x11disp = (X11Display *) [NSDisplay currentDisplay]; + _display = [x11disp display]; + +- _frame = [self transformFrame: [delegate frame]]; + _frame = [x11disp rootRectForFrame: [delegate frame]]; -+ BOOL isPanel = [delegate isKindOfClass: [NSPanel class]]; -+ if (isPanel && _styleMask & NSDocModalWindowMask) -+ _styleMask = NSBorderlessWindowMask; -+ + BOOL isPanel = [delegate isKindOfClass: [NSPanel class]]; + if (isPanel && _styleMask & NSDocModalWindowMask) + _styleMask = NSBorderlessWindowMask; +- // TODO: get rid of these glX calls +- GLint att[] = {GLX_RGBA, +- GLX_DOUBLEBUFFER, +- GLX_RED_SIZE, +- 4, +- GLX_GREEN_SIZE, +- 4, +- GLX_BLUE_SIZE, +- 4, +- GLX_DEPTH_SIZE, +- 4, +- None}; + +- int screen = DefaultScreen(_display); + _hasAlpha = [self wantsAlphaVisual]; + [self createXWindow]; -+ + +- if ((_visualInfo = glXChooseVisual(_display, screen, att)) == NULL) { +- NSLog(@"glXChooseVisual failed at %s %d", __FILE__, __LINE__); +- } + /* 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 (!X11IsLeagueGameWindow(_display, None, delegate)) + _remoteFrameTimer = [[NSTimer scheduledTimerWithTimeInterval: 1.0 / 60.0 + target: self selector: @selector(presentRemoteCAFrame:) + userInfo: nil repeats: YES] retain]; -+ + +- Colormap cmap = +- XCreateColormap(_display, RootWindow(_display, _visualInfo->screen), +- _visualInfo->visual, AllocNone); + return self; +} -+ + +- if (cmap < 0) { +- NSLog(@"XCreateColormap failed"); +- [self release]; +- return nil; +- } +- (void) createXWindow { + NSWindow *delegate = _delegate; + X11Display* x11disp = (X11Display *) [NSDisplay currentDisplay]; @@ -5551,7 +5334,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m [self syncDelegateProperties]; -@@ -217,7 +346,20 @@ +@@ -217,7 +346,20 @@ static NSData *makeWindowIcon() { .res_class = (char *) name}; XSetClassHint(_display, _window, &classHint); @@ -5573,7 +5356,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m _isModal = [delegate isSheet] || [NSApp modalWindow] == delegate; // FIXME: There should be no need for this. -@@ -254,19 +396,96 @@ +@@ -254,19 +396,96 @@ static NSData *makeWindowIcon() { XNFocusWindow, _window, NULL); @@ -5590,10 +5373,9 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m [self setStyleMaskInternal: _styleMask force: YES]; [self setWindowIcon]; -- -- return self; +} -+ + +- return self; +// Only when the background can show through (a pattern image may): League's game window is +// non-opaque on an opaque background, and a 32-bit parent costs Xwayland a copy of its GL child. +- (BOOL) isTranslucent { @@ -5677,10 +5459,26 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m } - (void) dealloc { -@@ -279,7 +498,106 @@ +@@ -279,24 +498,59 @@ static NSData *makeWindowIcon() { return _styleMask; } +-- (void) setStyleMaskInternal: (NSUInteger) mask force: (BOOL) force { +- if (force || (mask & NSWindowStyleMaskResizable) != +- (_styleMask & NSWindowStyleMaskResizable)) { +- XSizeHints *sh = XAllocSizeHints(); +- if (mask & NSWindowStyleMaskResizable) { +- // Make resizable +- sh->flags = 0; +- } else { +- // Make non-resizable +- sh->flags = PMinSize | PMaxSize; +- sh->min_width = sh->max_width = _frame.size.width; +- sh->min_height = sh->max_height = _frame.size.height; +- } +- +- XSetWMSizeHints(_display, _window, sh, XA_WM_NORMAL_HINTS); +- XFree(sh); +/* A borderless application covering a monitor needs EWMH fullscreen so the + * compositor places it above desktop panels. Compare full monitor bounds, + * never the work area, and leave unmanaged menus/popups alone. */ @@ -5695,7 +5493,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + if (!NSIsEmptyRect([screen frame]) && + NSEqualRects([display rootRectForFrame: [screen frame]], frame)) + return YES; -+ } + } + return NO; +} + @@ -5727,48 +5525,52 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + _layerDrawableOwner = nil; + [self updateLayerPresentationHints: _fullscreenRequested]; +} -+ + +- (void) requestFullscreen: (BOOL) fullscreen force: (BOOL) force { + if (fullscreen) { + unsigned long bypass = 2; + XChangeProperty(_display, _window, XInternAtom(_display,"_NET_WM_BYPASS_COMPOSITOR",False), + XA_CARDINAL,32,PropModeReplace,(unsigned char *)&bypass,1); + } -+ if (!_mapped) { -+ long states[2]; -+ int states_cnt = 0; -+ if (_isModal) { -+ states[states_cnt++] = -+ (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); -+ } + if (!_mapped) { + long states[2]; + int states_cnt = 0; +@@ -304,7 +558,7 @@ static NSData *makeWindowIcon() { + states[states_cnt++] = + (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); + } +- if (mask & NSWindowStyleMaskFullScreen) { + if (fullscreen) { -+ states[states_cnt++] = (long) XInternAtom( -+ _display, "_NET_WM_STATE_FULLSCREEN", False); -+ } -+ XChangeProperty(_display, _window, -+ XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, -+ 32, PropModeReplace, (const unsigned char *) states, -+ states_cnt); + states[states_cnt++] = (long) XInternAtom( + _display, "_NET_WM_STATE_FULLSCREEN", False); + } +@@ -312,14 +566,13 @@ static NSData *makeWindowIcon() { + XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, + 32, PropModeReplace, (const unsigned char *) states, + states_cnt); +- } else if (force || ((mask & NSWindowStyleMaskFullScreen) != +- (_styleMask & NSWindowStyleMaskFullScreen))) { + } else if (force || fullscreen != _fullscreenRequested) { -+ XClientMessageEvent event = {0}; -+ event.type = ClientMessage; -+ event.window = _window; -+ event.message_type = XInternAtom(_display, "_NET_WM_STATE", False); -+ event.format = 32; + XClientMessageEvent event = {0}; + event.type = ClientMessage; + event.window = _window; + event.message_type = XInternAtom(_display, "_NET_WM_STATE", False); + event.format = 32; +- event.data.l[0] = (mask & NSWindowStyleMaskFullScreen) ? 1 : 2; + event.data.l[0] = fullscreen ? 1 : 0; /* EWMH ADD / REMOVE, not TOGGLE */ -+ event.data.l[1] = -+ (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); -+ event.data.l[3] = 1; -+ XSendEvent(_display, DefaultRootWindow(_display), False, -+ SubstructureNotifyMask | SubstructureRedirectMask, -+ (XEvent *) &event); -+ } + event.data.l[1] = + (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); + event.data.l[3] = 1; +@@ -327,6 +580,48 @@ static NSData *makeWindowIcon() { + SubstructureNotifyMask | SubstructureRedirectMask, + (XEvent *) &event); + } + _fullscreenRequested = fullscreen; + [self updateLayerPresentationHints: fullscreen]; + XFlush(_display); +} + - - (void) setStyleMaskInternal: (NSUInteger) mask force: (BOOL) force { ++- (void) setStyleMaskInternal: (NSUInteger) mask force: (BOOL) force { + // Cocoa chrome may change after the native window already exists. A hidden, + // transparent titlebar is client-drawn chrome, even with the titled bit set. + // NSWindowTitleHidden is 1; this SDK exposes titleVisibility as NSInteger. @@ -5781,17 +5583,13 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + XDeleteProperty(_display, _window, + XInternAtom(_display, "_MOTIF_WM_HINTS", False)); + } - if (force || (mask & NSWindowStyleMaskResizable) != - (_styleMask & NSWindowStyleMaskResizable)) { - XSizeHints *sh = XAllocSizeHints(); -@@ -287,46 +605,23 @@ - // Make resizable - sh->flags = 0; - } else { -- // Make non-resizable -- sh->flags = PMinSize | PMaxSize; -- sh->min_width = sh->max_width = _frame.size.width; -- sh->min_height = sh->max_height = _frame.size.height; ++ if (force || (mask & NSWindowStyleMaskResizable) != ++ (_styleMask & NSWindowStyleMaskResizable)) { ++ XSizeHints *sh = XAllocSizeHints(); ++ if (mask & NSWindowStyleMaskResizable) { ++ // Make resizable ++ sh->flags = 0; ++ } else { + 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. @@ -5802,47 +5600,17 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + sh->min_width = sh->max_width = _frame.size.width; + sh->min_height = sh->max_height = _frame.size.height; + } - } - - XSetWMSizeHints(_display, _window, sh, XA_WM_NORMAL_HINTS); - XFree(sh); - } - -- if (!_mapped) { -- long states[2]; -- int states_cnt = 0; -- if (_isModal) { -- states[states_cnt++] = -- (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); -- } -- if (mask & NSWindowStyleMaskFullScreen) { -- states[states_cnt++] = (long) XInternAtom( -- _display, "_NET_WM_STATE_FULLSCREEN", False); -- } -- XChangeProperty(_display, _window, -- XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, -- 32, PropModeReplace, (const unsigned char *) states, -- states_cnt); -- } else if (force || ((mask & NSWindowStyleMaskFullScreen) != -- (_styleMask & NSWindowStyleMaskFullScreen))) { -- XClientMessageEvent event = {0}; -- event.type = ClientMessage; -- event.window = _window; -- event.message_type = XInternAtom(_display, "_NET_WM_STATE", False); -- event.format = 32; -- event.data.l[0] = (mask & NSWindowStyleMaskFullScreen) ? 1 : 2; -- event.data.l[1] = -- (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); -- event.data.l[3] = 1; -- XSendEvent(_display, DefaultRootWindow(_display), False, -- SubstructureNotifyMask | SubstructureRedirectMask, -- (XEvent *) &event); -- } ++ } ++ ++ XSetWMSizeHints(_display, _window, sh, XA_WM_NORMAL_HINTS); ++ XFree(sh); ++ } ++ + [self requestFullscreen:[self wantsFullscreenWithStyleMask:mask rootFrame:_frame] force:force]; } - (void) setStyleMask: (NSUInteger) mask { -@@ -338,7 +633,6 @@ +@@ -338,7 +633,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -5850,7 +5618,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m struct { unsigned long flags; unsigned long functions; -@@ -356,9 +650,23 @@ +@@ -356,11 +650,25 @@ static NSData *makeWindowIcon() { - (void) ensureMapped { if (!_mapped) { @@ -5861,21 +5629,22 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m XMapWindow(_display, _window); + _wasMapped = YES; _mapped = YES; -- } + _remoteFrameTargetRefresh = 0; -+ } -+} -+ + } + } + +/* darling-lol: only map for a window AppKit considers visible. NSWindow sets _isVisible before + * ordering, so ordered windows still map; a hidden one (like the client's splash) must not be + * revived by a later restack or makeKey. */ +- (void) ensureMappedIfVisible { + if (_delegate == nil || [_delegate isVisible]) + [self ensureMapped]; - } - ++} ++ - (void) showWindowWithoutActivation { -@@ -377,14 +685,26 @@ + [self ensureMapped]; + } +@@ -377,15 +685,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -5891,7 +5660,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + /* Motion also restores a cursor hidden while typing. App-level motion + * delivery is still filtered by acceptsMouseMovedEvents in X11Display. */ XSelectInput(_display, _window, mask); -+ + + /* Mouse-transparent windows must not steal crossing events from the + * view below them. Filtering dispatched events leaves X11 hit testing + * unchanged and can repeatedly hide/recreate tooltip windows. */ @@ -5904,10 +5673,11 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + 0, 0, None, ShapeSet); + XFlush(_display); + } - ++ // TODO: background color } -@@ -393,6 +713,28 @@ + +@@ -393,6 +713,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. @@ -5936,12 +5706,10 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m [_context release]; _context = nil; -@@ -419,6 +761,392 @@ - XDestroyWindow(_display, _window); - _window = 0; +@@ -421,6 +763,392 @@ static NSData *makeWindowIcon() { } -+} -+ + } + +- (void) setRemoteFrameTimerSlow: (BOOL) slow { + if (_remoteFrameTimerSlow == slow) return; + _remoteFrameTimerSlow = slow; @@ -6326,10 +6094,12 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + _remoteFrameValid = NO; + [self presentRemoteCAFrame: nil]; + return YES; - } - ++} ++ - (Window) windowHandle { -@@ -470,14 +1198,32 @@ + return _window; + } +@@ -470,14 +1198,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -6363,12 +6133,10 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m } - (void) setHasShadow: (BOOL) value { -@@ -486,6 +1232,25 @@ - - - (void) setLevel: (int) value { +@@ -488,6 +1234,25 @@ static NSData *makeWindowIcon() { _level = value; -+} -+ + } + +/* NSApplication's attention timer calls this for requestUserAttention:. The + * CGWindow base class raises an abstract-invocation exception, which aborted + * the whole LeagueClientUx process. Ask the window manager to mark the window @@ -6386,10 +6154,12 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + SubstructureNotifyMask | SubstructureRedirectMask, + (XEvent *) &event); + XFlush(_display); - } - ++} ++ - (void) setOpaque: (BOOL) value { -@@ -518,7 +1283,7 @@ + _isOpaque = value; + } +@@ -518,7 +1283,7 @@ static NSData *makeWindowIcon() { - (void) placeAboveWindow: (NSInteger) otherNumber { X11Window *other = [X11Window windowWithWindowNumber: otherNumber]; @@ -6398,7 +6168,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m if (!other) { XRaiseWindow(_display, _window); -@@ -530,7 +1295,7 @@ +@@ -530,7 +1295,7 @@ static NSData *makeWindowIcon() { - (void) placeBelowWindow: (NSInteger) otherNumber { X11Window *other = [X11Window windowWithWindowNumber: otherNumber]; @@ -6407,7 +6177,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m if (!other) { XLowerWindow(_display, _window); -@@ -541,8 +1306,23 @@ +@@ -541,8 +1306,23 @@ static NSData *makeWindowIcon() { } - (void) makeKey { @@ -6432,7 +6202,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m } - (void) makeMain { -@@ -559,7 +1339,29 @@ +@@ -559,7 +1339,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -6462,7 +6232,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -593,8 +1395,10 @@ +@@ -593,8 +1395,10 @@ static NSData *makeWindowIcon() { if (_cglContext == NULL) { CGLError error; @@ -6475,7 +6245,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m NSLog(@"CGLCreateContext failed at %s %d with error %d", __FILE__, __LINE__, error); -@@ -615,6 +1419,14 @@ +@@ -615,6 +1419,14 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -6490,7 +6260,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1464,7 @@ +@@ -652,7 +1464,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -6499,7 +6269,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1478,7 @@ +@@ -666,6 +1478,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -6507,7 +6277,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,19 +1523,13 @@ +@@ -710,19 +1523,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } From 306c6c5bf83a3364482e0498fdd979ea03a8c4c7 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:11:49 +0200 Subject: [PATCH 123/160] Use the host DNS resolver in Darling --- .gitattributes | 6 +- docs/TESTING.md | 13 +++ docs/TROUBLESHOOTING.md | 25 ++++++ patches/darling.patch | 15 +++- patches/xnu.patch | 20 +++++ tests/test_host_dns.py | 133 ++++++++++++++++++++++++++++ tests/test_runtime_compatibility.py | 2 +- upstream/manifest.json | 2 +- 8 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 tests/test_host_dns.py diff --git a/.gitattributes b/.gitattributes index 00c1a6e..0cd6baf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,3 @@ -# Unified-diff context uses a single space for an empty source line. -# Do not report that patch syntax as trailing source whitespace. -patches/*.patch whitespace=-blank-at-eol +# Unified-diff context adds a space before source tabs and empty lines. +# Do not report that patch syntax as source whitespace errors. +patches/*.patch whitespace=-blank-at-eol,-space-before-tab diff --git a/docs/TESTING.md b/docs/TESTING.md index 17a711a..ca9bd88 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -90,6 +90,19 @@ append-only output and tightening an existing public log to mode 0600. The runner test uses a fake `darling` executable to verify loopback fixtures and private-prefix-only shutdown/cleanup. +With the pinned Darling sources available, run the host resolver regression: + +```sh +python3 -m unittest discover -s tests -p 'test_host_dns.py' -v +``` + +It compiles the actual patched vchroot implementation in its Linux test mode +and reads temporary resolver files through absolute and relative host symlinks. +It checks atomic file replacement, symlink retargeting, guest DNS overrides and +guest absolute-link scope. A CMake fixture verifies that both install modes +replace an older Google resolver file with the host link. No Darling session or +DNS traffic is started. + ## CoreText font-list and AppKit resize regressions After preparing the source and configuring the runtime tools: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a655648..9b96bfe 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -192,6 +192,31 @@ observation, without waiting for a match: python3 tools/close-league-client-in-game.py --once --dry-run ``` +### Riot reports NO INTERNET FOUND while the host is online + +Older base runtimes ship Google DNS servers (`8.8.8.8` and `8.8.4.4`), which +some mobile and managed networks block. The base resolver now links to the +host's `/etc/resolv.conf` through `/Volumes/SystemRoot`, including when the host +file is a symlink into `/run` for systemd-resolved or NetworkManager. File and +symlink replacements remain visible without copying the resolver on launch. + +Exit Riot and League, then run `./scripts/update.sh` to rebuild and install both +the base link and the host symlink handling correction. Existing prefix DNS +overrides in `$DPREFIX/private/etc/resolv.conf` (normally +`~/.darling/private/etc/resolv.conf`) remain in effect; remove a previous manual +override with Darling stopped if you want to follow the host instead. A custom +prefix `/etc` directory can also shadow the base `/etc -> private/etc` link. + +Check resolution inside the guest with: + +```sh +darling shell curl https://clientconfig.rpg.riotgames.com/ +``` + +An HTTP response establishes that resolution succeeded even if that endpoint +rejects the request. This change follows the host resolver configuration; +already-running clients may cache DNS settings until restarted. + ### Downloads repeatedly fail with an IPv6 CONNECT_TO error The bundled downloader was observed retrying CDN chunks with: diff --git a/patches/darling.patch b/patches/darling.patch index a4d2178..ade6461 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -11230,10 +11230,21 @@ index 072a6e2..c804100 100755 echo "No instances running now" diff --git a/CMakeLists.txt b/CMakeLists.txt -index 8077b7e..0556080 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt -@@ -236,7 +236,12 @@ InstallSymlink(/Volumes/SystemRoot/usr/share/zoneinfo ${CMAKE_INSTALL_PREFIX}/li +@@ -204,8 +204,9 @@ + InstallSymlink(private/etc ${CMAKE_INSTALL_PREFIX}/libexec/darling/etc) + InstallSymlink(private/var ${CMAKE_INSTALL_PREFIX}/libexec/darling/var) + +-install(FILES etc/resolv.conf +- DESTINATION libexec/darling/private/etc) ++# Follow the host resolver, including local DNS proxies and network changes. ++# Prefix-local resolver overrides still take precedence over this base symlink. ++InstallSymlink(/Volumes/SystemRoot/etc/resolv.conf ${CMAKE_INSTALL_PREFIX}/libexec/darling/private/etc/resolv.conf) + InstallSymlink(/Volumes/SystemRoot/etc/machine-id ${CMAKE_INSTALL_PREFIX}/libexec/darling/private/etc/machine-id) + InstallSymlink(/Volumes/SystemRoot/etc/nsswitch.conf ${CMAKE_INSTALL_PREFIX}/libexec/darling/private/etc/nsswitch.conf) + +@@ -236,7 +237,12 @@ install(DIRECTORY DESTINATION libexec/darling/Library/Preferences) if(NOT DEBIAN_PACKAGING) diff --git a/patches/xnu.patch b/patches/xnu.patch index efbf7e3..a02f9cd 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -6180,3 +6180,23 @@ index 0000000..eb90f43 + return true; +} +#endif +diff --git a/darling/src/libsystem_kernel/emulation/src/linux_premigration/vchroot_userspace.c b/darling/src/libsystem_kernel/emulation/src/linux_premigration/vchroot_userspace.c +--- a/darling/src/libsystem_kernel/emulation/src/linux_premigration/vchroot_userspace.c ++++ b/darling/src/libsystem_kernel/emulation/src/linux_premigration/vchroot_userspace.c +@@ -421,12 +421,14 @@ + ctxt->follow = true; + ctxt->symlink_depth++; + +- // resolve absolute symlinks within the prefix ++ // Keep absolute host symlinks outside the prefix after crossing ++ // /Volumes/SystemRoot (e.g. /etc/resolv.conf -> /run/...). ++ // Resolve guest symlinks within the prefix + // + // however, DON'T resolve `/proc` symlinks within the prefix. + // anything under `/proc` is from Linux's `/proc` since we bind-mount it, so we can special-case them because we want those to be Linux-root relative. + // see https://github.com/darlinghq/darling/issues/1052 for the issue this works around. +- if (link[0] == '/' && !(strncmp(link, "/proc/", 6) == 0 || (rv == 5 && strncmp(link, "/proc", 5) == 0))) { ++ if (link[0] == '/' && ctxt->current_root_len > 0 && !(strncmp(link, "/proc/", 6) == 0 || (rv == 5 && strncmp(link, "/proc", 5) == 0))) { + ctxt->current_root = prefix_path; + ctxt->current_root_len = prefix_path_len; + } diff --git a/tests/test_host_dns.py b/tests/test_host_dns.py new file mode 100644 index 0000000..9a26d07 --- /dev/null +++ b/tests/test_host_dns.py @@ -0,0 +1,133 @@ +"""Exercise the base resolver install and actual vchroot code without DNS traffic.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +import test_runtime_compatibility as sources + + +@unittest.skipUnless(shutil.which('cc') and shutil.which('patch'), 'C compiler and patch required') +class HostDNSChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='host-dns-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.prefix = self.root / 'guest' + (self.prefix / 'private/etc').mkdir(parents=True) + (self.prefix / 'Volumes/SystemRoot').mkdir(parents=True) + (self.prefix / 'etc').symlink_to('private/etc') + self.resolver = self.prefix / 'private/etc/resolv.conf' + + def build_vchroot(self): + path = 'darling/src/libsystem_kernel/emulation/' + source = self.patched('xnu', path + 'src/linux_premigration/vchroot_userspace.c') + header = self.root / 'darling/emulation/linux_premigration/vchroot_expand.h' + header.parent.mkdir(parents=True) + header.write_text(self.patched('xnu', path + 'include/linux_premigration/vchroot_expand.h')) + # Use upstream's Linux TEST mode, replacing only its CLI and RPC setup. + fixture = r''' +#define _GNU_SOURCE +#define TEST +#include +#include +#include +#include +#define LC_C_LOCALE 0 +#define strncasecmp_l(a,b,n,l) strncasecmp(a,b,n) +#define __simple_printf printf +#define __simple_abort abort +static int dserver_rpc_vchroot_path(char *p, unsigned long n, uint64_t *len) { abort(); } +#define main upstream_main +''' + fixture += source + r''' +#undef main +int main(int argc, const char **argv) { + if (argc != 3) return 2; + int fd = open(argv[1], O_RDONLY | O_DIRECTORY); + if (fd < 0 || __darling_vchroot(fd)) return 3; + close(fd); + struct vchroot_expand_args args = {.dfd = -100, .flags = VCHROOT_FOLLOW}; + strcpy(args.path, argv[2]); + int result = vchroot_expand(&args); + if (result) return 4; + FILE *file = fopen(args.path, "r"); + if (!file) return 5; + char buf[4096]; + while (fgets(buf, sizeof(buf), file)) fputs(buf, stdout); + return fclose(file) != 0; +} +''' + file = self.root / 'check.c' + file.write_text(fixture) + self.binary = self.root / 'check' + result = subprocess.run(['cc', '-std=gnu11', '-I', str(self.root), str(file), + '-o', str(self.binary)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def read_guest(self, path='/etc/resolv.conf'): + result = subprocess.run([str(self.binary), str(self.prefix), path], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout + + def test_host_symlink_chains_updates_and_guest_override(self): + self.build_vchroot() + host = self.root / 'host' + host.mkdir() + data = host / 'stub-resolv.conf' + data.write_text('nameserver 127.0.0.53\nsearch tether.example\n') + self.resolver.symlink_to('/Volumes/SystemRoot' + str(data)) + self.assertEqual(self.read_guest(), data.read_text()) + self.resolver.unlink() + # An absolute host link followed by a relative one models resolved and + # NetworkManager layouts. Never write to the real host resolver. + (host / 'relative').symlink_to(data.name) + (host / 'resolv.conf').symlink_to(host / 'relative') + self.resolver.symlink_to('/Volumes/SystemRoot' + str(host / 'resolv.conf')) + self.assertEqual(self.read_guest(), data.read_text()) + # Network managers replace files atomically; a bind mount or snapshot + # would keep reading the old resolver inode. + replacement = host / 'next' + replacement.write_text('nameserver 192.0.2.53\n') + os.replace(replacement, data) + self.assertEqual(self.read_guest(), data.read_text()) + other = host / 'other' + other.write_text('nameserver 192.0.2.54\n') + (host / 'resolv.conf').unlink() + (host / 'resolv.conf').symlink_to(other) + self.assertEqual(self.read_guest(), other.read_text()) + self.resolver.unlink() + self.resolver.write_text('nameserver 127.0.0.1\n') + self.assertEqual(self.read_guest(), self.resolver.read_text()) + # Absolute links originating inside the guest must retain guest scope. + (self.prefix / 'guest-link').symlink_to('/private/etc/resolv.conf') + self.assertEqual(self.read_guest('/guest-link'), self.resolver.read_text()) + + @unittest.skipUnless(shutil.which('cmake'), 'CMake required') + def test_base_install_replaces_google_file_with_host_link(self): + cmake = self.patched('darling', 'CMakeLists.txt') + start = cmake.index('# Follow the host resolver') + end = cmake.index('\n', cmake.index('InstallSymlink(', start)) + macro = self.patched('darling', 'cmake/InstallSymlink.cmake') + project = self.root / 'project' + project.mkdir() + (project / 'CMakeLists.txt').write_text( + 'cmake_minimum_required(VERSION 3.16)\nproject(dns NONE)\n' + macro + '\n' + cmake[start:end] + '\n') + base = self.root / 'base' + target = base / 'libexec/darling/private/etc/resolv.conf' + target.parent.mkdir(parents=True) + for packaging in ('OFF', 'ON'): + target.unlink(missing_ok=True) + target.write_text('nameserver 8.8.8.8\nnameserver 8.8.4.4\n') + build = self.root / ('build-' + packaging) + subprocess.run(['cmake', '-S', str(project), '-B', str(build), + '-DCMAKE_INSTALL_PREFIX=' + str(base), + '-DBINARY_PACKAGING_MODE=' + packaging], check=True, capture_output=True) + subprocess.run(['cmake', '--install', str(build)], check=True, capture_output=True) + self.assertTrue(target.is_symlink()) + self.assertEqual(os.readlink(target), '/Volumes/SystemRoot/etc/resolv.conf') diff --git a/tests/test_runtime_compatibility.py b/tests/test_runtime_compatibility.py index 741917e..1539369 100644 --- a/tests/test_runtime_compatibility.py +++ b/tests/test_runtime_compatibility.py @@ -38,7 +38,7 @@ class CompatibilityChecks(unittest.TestCase): patch = patch_text.split(marker, 1)[1].split('\ndiff --git ', 1)[0] with tempfile.TemporaryDirectory() as folder: target = Path(folder) / path - target.parent.mkdir(parents=True) + target.parent.mkdir(parents=True, exist_ok=True) if original is not None: target.write_bytes(original) subprocess.run(['patch', '--silent', '-p1', '-d', folder], input=marker + patch + ("" if patch.endswith("\n") else "\n"), diff --git a/upstream/manifest.json b/upstream/manifest.json index 60fbbb8..331ce8f 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -274,7 +274,7 @@ "revision": "fa29287aa2f0115271e091f1031f53c9e024005d", "repository": "https://github.com/darlinghq/darling-xnu.git", "patch": "patches/xnu.patch", - "modified_files": 64, + "modified_files": 65, "new_files": [ "darling/src/libsystem_kernel/emulation/include/common/cpu_topology.h", "darling/src/libsystem_kernel/emulation/include/common/private_mutex.h", From 526029144310c2c675faa647e1cb1f58b0ee6fd4 Mon Sep 17 00:00:00 2001 From: Polar <343746874+PolarSeeker77@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:21:26 +0000 Subject: [PATCH 124/160] Document NVIDIA reports instead of "NVIDIA is not validated" --- README.md | 2 +- docs/TROUBLESHOOTING.md | 29 ++++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d997fb1..5be348c 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Game files, Riot installers, account databases, tokens, local prefixes, captures ## Requirements -- x86-64 Linux **5.14 or newer**, an X11 session or Xwayland, and a working hardware Vulkan/OpenGL driver. AMD RADV has been exercised; other drivers are not validated. Hybrid laptops usually render on the integrated GPU unless started with PRIME offload; NVIDIA is not validated. See [hybrid graphics](docs/TROUBLESHOOTING.md#hybrid-graphics-laptops-amdintel-igpu--nvidia). +- x86-64 Linux **5.14 or newer**, an X11 session or Xwayland, and a working hardware Vulkan/OpenGL driver. AMD RADV and the NVIDIA driver (proprietary and open kernel modules) have been exercised, see [NVIDIA](docs/TROUBLESHOOTING.md#nvidia); other drivers are not validated. Hybrid laptops usually render on the integrated GPU unless started with PRIME offload. See [hybrid graphics](docs/TROUBLESHOOTING.md#hybrid-graphics-laptops-amdintel-igpu--nvidia). - Git + Git LFS, Bash, curl, unzip, CMake, Make, Clang/LLD, LLVM development files, a C/C++ compiler, Python 3.12+ and the Darling build dependencies. The patch includes LLVM 22/23 branch-opcode compatibility; the new PC used LLVM/Clang 23. Node.js is needed only for the offline JavaScript checks. - X11/XCB/SHM development files for the current runtime helpers. - Enough free disk for Darling sources/build, Git LFS assets and the game: budget at least 50 GB. Building is substantially larger than this source-only repository. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a655648..9a08353 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -349,11 +349,25 @@ On Hyprland, also check that direct scanout is off (`hyprctl getoption render:di On the NVIDIA proprietary driver, check the submit worker's kernel time: a thread of the game process spending most of its time in the kernel (`/proc//task//stat` system time, or `perf trace -s -p `) points at driver calls. Each `vkQueueSubmit2` and each CPU-side `vkSignalSemaphore` is an RM control ioctl (command `0xc020462a`) that queues on a driver-wide lock. Before 2026-10-18 every command buffer signalled the event loop's wake-up semaphore, ~25 times per frame; with lazy wake-ups (default) and a 5 ms batch window (`/tmp/metal-submit-batch-us` = `5000`, `/tmp/metal-submit-batch-max` = `64`) an RTX 4090 ran at 206–552 FPS against 91–147 FPS with `/tmp/metal-no-lazy-wakeup`, in alternating 20 s phases of one match (the later phases with wake-ups on were capped at 240 FPS) (see [setup](SETUP.md#metal-and-indium-overlays)). The NVIDIA performance figures above were reported on an earlier runtime base; -they have not been remeasured after integration with current `main`. +they have not been remeasured after integration with current `main`. On `bcb3245` an RTX 5060 Laptop reported ~200–215 FPS in Practice Tool ([#53](https://github.com/lucannez64/darling-lol/issues/53)). + +## NVIDIA + +With the window visual and compositor draw buffer fixes (#22) and the RPC socket fix (#25), the game renders and runs on these NVIDIA GPUs: + +| GPU | Driver | Report | Result | +|---|---|---|---| +| GTX 1060 6GB | 580.178.04 | [below](#nvidia-580-pascal-and-older-black-game-window) | client and tutorial | +| RTX 2070 SUPER | 615.78, proprietary | [#55](https://github.com/lucannez64/darling-lol/issues/55) | tutorials, Co-op vs AI, Swiftplay and Draft games; no crashes | +| RTX 3060 | 615.71 | [below](#nvidia-resize-signals-cpu-readback-and-completion-latency) | custom matches through CPU readback | +| RTX 5060 Laptop | 615.71.09, open modules | [#53](https://github.com/lucannez64/darling-lol/issues/53) | Practice Tool at ~200–215 FPS on `bcb3245`; MUX set to discrete, PRIME variables set | +| RTX 5070 | 615.78.08, open modules | [#69](https://github.com/lucannez64/darling-lol/issues/69) | tutorial | + +If the game window stays black on NVIDIA, check `metal-device.log` as described in [Check which GPU rendered](#check-which-gpu-rendered). ## Hybrid graphics laptops (AMD/Intel iGPU + NVIDIA) -Metal renders with Vulkan, and each finished frame is handed to an OpenGL (EGL) context that presents it. The hand-off uses opaque file descriptors, which only work when Vulkan and OpenGL run on the same GPU. Indium takes the first Vulkan device the loader lists, and on a hybrid laptop both that device and the EGL context are normally the integrated GPU, so the discrete GPU stays idle. A report in [issue #3](https://github.com/lucannez64/darling-lol/issues/3) (Ryzen 9 5900HS, RTX 3060 Laptop) measured 60-70 FPS in ARAM on the integrated GPU; on the NVIDIA GPU the game showed a black screen. **Running on an NVIDIA GPU is not validated.** +Metal renders with Vulkan, and each finished frame is handed to an OpenGL (EGL) context that presents it. The hand-off uses opaque file descriptors, which only work when Vulkan and OpenGL run on the same GPU. Indium takes the first Vulkan device the loader lists, and on a hybrid laptop both that device and the EGL context are normally the integrated GPU, so the discrete GPU stays idle. A report in [issue #3](https://github.com/lucannez64/darling-lol/issues/3) (Ryzen 9 5900HS, RTX 3060 Laptop) measured 60-70 FPS in ARAM on the integrated GPU; on the NVIDIA GPU the game showed a black screen, since fixed (see [NVIDIA](#nvidia)). ### Run on the discrete GPU @@ -363,7 +377,12 @@ Start the launcher with PRIME render offload so that both Vulkan and OpenGL move prime-run sh scripts/run.sh ``` -`prime-run` sets `__NV_PRIME_RENDER_OFFLOAD=1`, `__VK_LAYER_NV_optimus=NVIDIA_only` and `__GLX_VENDOR_LIBRARY_NAME=nvidia`. `darling` copies the caller's environment into the guest, and guest processes inherit it; whether the Riot client passes all of it to the game has not been checked; the log below shows the result. Darling's OpenGL uses EGL, so if the log below still shows the integrated GPU as the presenting renderer, also set `__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json`. On two Mesa GPUs, `DRI_PRIME=1` does the same. +`prime-run` sets `__NV_PRIME_RENDER_OFFLOAD=1`, `__VK_LAYER_NV_optimus=NVIDIA_only` and `__GLX_VENDOR_LIBRARY_NAME=nvidia`. `darling` copies the caller's environment into the guest, and guest processes inherit it; whether the Riot client passes all of it to the game has not been checked; the log below shows the result. Darling's OpenGL uses EGL, so if the log below still shows the integrated GPU as the presenting renderer, also set `__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json`. On two Mesa GPUs, `DRI_PRIME=1` does the same. The RTX 5060 Laptop in [#53](https://github.com/lucannez64/darling-lol/issues/53) rendered with all four variables set: + +```sh +__NV_PRIME_RENDER_OFFLOAD=1 __VK_LAYER_NV_optimus=NVIDIA_only __GLX_VENDOR_LIBRARY_NAME=nvidia \ +__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json sh scripts/run.sh +``` The guest file `/tmp/metal-gpu` (a device index or part of its name, for example `nvidia`) moves only the Vulkan side. Use it for diagnosis: on its own it splits the two sides across GPUs, and frames cannot be presented. @@ -397,9 +416,9 @@ The first import of a drawable into the presenting OpenGL context, and the first [MetalDevice] pid=… drawable GL semaphore (first) import_error=0x0 wait_error=0x0 ok (checked for the first 600 imports) ``` -A non-zero error or `FAILED` means the frames cannot reach the window. Before 2026-10-16 the import did not mark the memory as dedicated, which the extension requires; Mesa ignores it, and it is the suspected cause of the NVIDIA black screen (not yet confirmed). An NVIDIA log of 2026-10-16 showed `glTiling=optimal` with `setup_error=0x501 storage_error=0x501 FAILED`: NVIDIA's GL rejects `GL_LINEAR_TILING_EXT`, so the linear Vulkan image could not be imported. Since then the internal image and the GL texture use optimal tiling when `GL_VENDOR` contains NVIDIA (`vkTiling=optimal glTiling=optimal ok` is the expected line); everything else keeps linear. If the game shows a black screen on NVIDIA, report it with this file. +A non-zero error or `FAILED` means the frames cannot reach the window. Before 2026-10-16 the import did not mark the memory as dedicated, which the extension requires; Mesa ignores it. It was suspected of causing the NVIDIA black screen; the causes found later were the window visual and the draw buffer (#22). An NVIDIA log of 2026-10-16 showed `glTiling=optimal` with `setup_error=0x501 storage_error=0x501 FAILED`: NVIDIA's GL rejects `GL_LINEAR_TILING_EXT`, so the linear Vulkan image could not be imported. Since then the internal image and the GL texture use optimal tiling when `GL_VENDOR` contains NVIDIA (`vkTiling=optimal glTiling=optimal ok` is the expected line); everything else keeps linear. If the game shows a black screen on NVIDIA, report it with this file. -An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Confirmation on the reporter's NVIDIA system is still needed. +An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Issue #3 has since been closed, and the game renders on NVIDIA (see [NVIDIA](#nvidia)). ### NVIDIA resize signals, CPU readback and completion latency From 857aabf8b70cbdd818c0ee28baa846b3ac4a3dc1 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:22:17 +0200 Subject: [PATCH 125/160] Bypass proxies when checking guest DNS --- docs/TROUBLESHOOTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 9b96bfe..9ecb58e 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -210,7 +210,7 @@ prefix `/etc` directory can also shadow the base `/etc -> private/etc` link. Check resolution inside the guest with: ```sh -darling shell curl https://clientconfig.rpg.riotgames.com/ +darling shell curl --noproxy '*' https://clientconfig.rpg.riotgames.com/ ``` An HTTP response establishes that resolution succeeded even if that endpoint From a68e3ab16920b7a6cece2e939d977ea42cdb91ea Mon Sep 17 00:00:00 2001 From: Benjamin <18471851+WolfBlack879@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:10:06 -0300 Subject: [PATCH 126/160] security: keep trustd running from boot The Riot Client UI intermittently stayed black for 30s to 2+ minutes (sometimes indefinitely). Electron 22 / Chrome 108 verifies the local Riot backend's TLS certificate through SecTrust, which calls trustd over XPC. trustd is launched on demand and has EnablePressuredExit, so it is often not running; under Darling the on-demand launch can take minutes, and every cert verification (serialized behind Chrome's global lock) waits for it. The UI's websocket handshake stalls after ServerHello until then. Start trustd at boot and keep it alive. Measured on one machine: before, time to "renderer socket connected to Foundation" ranged 0.8s-136s or never; after, 0.83s / 0.85s / 0.97s on three launches. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014cgmCWUB2zNH4uiMEioafC --- patches/security.patch | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/patches/security.patch b/patches/security.patch index d12af04..c975769 100644 --- a/patches/security.patch +++ b/patches/security.patch @@ -205,6 +205,21 @@ index 06c5d4aa..9f24ea91 100644 if (context.numTasks >= MAX_TASKS) { secnotice("http", "Too many fetch %@ requests for this cert", [self class]); return true; +diff --git a/trust/trustd/macOS/com.apple.trustd.plist b/trust/trustd/macOS/com.apple.trustd.plist +index b3780e3c..71e089a4 100644 +--- a/trust/trustd/macOS/com.apple.trustd.plist ++++ b/trust/trustd/macOS/com.apple.trustd.plist +@@ -2,7 +2,9 @@ + + + +- EnablePressuredExit ++ KeepAlive ++ ++ RunAtLoad + + EnableTransactions + diff --git a/trust/trustd/trustdFileHelper/main.m b/trust/trustd/trustdFileHelper/main.m index f6d166e2..fb7ae819 100644 --- a/trust/trustd/trustdFileHelper/main.m From cb1c9c2354b21c4861f03493360d1d2182ecc843 Mon Sep 17 00:00:00 2001 From: David Gheghea <71325082+DavidutzDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:45:23 +0200 Subject: [PATCH 127/160] Raise darlingserver's soft fd limit when the hard limit is fixed Under the rootless NixOS launcher darlingserver cannot raise its hard RLIMIT_NOFILE to nr_open, so it kept the soft limit it inherited. Every named memory entry holds a memfd in darlingserver, and its descriptors peak at about 1100 when the League lobby opens. With an inherited soft limit of 1024, memfd_create failed with EMFILE, mach_make_memory_entry_64 returned KERN_RESOURCE_SHORTAGE, and Chromium's CHECK in PlatformSharedMemoryRegion::Create killed the League UX and the Riot Client renderer after clicking Play. When raising the hard limit fails, raise the soft limit to the inherited hard limit instead. Log errno, size and fd limits when named-entry creation fails, and log the live memfd count every 1024 creations so a leak shows up in dserver.log. Co-Authored-By: Claude Opus 5.5 --- docs/TROUBLESHOOTING.md | 21 ++++++++ patches/darlingserver.patch | 98 ++++++++++++++++++++++++++++++++----- 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 54ec36b..15ffb7b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -251,6 +251,27 @@ reference before callback delivery. This establishes the cancellation fix; a lon Riot session still needs confirmation. With both clients and the game closed, run `./scripts/update.sh` to install it, then `sh scripts/run.sh`. +## League and Riot UIs crash after clicking Play + +The League UX processes and then the Riot Client renderer stop with `SIGTRAP` +a few seconds after the lobby opens. The renderer prints +`Check failed: kr == KERN_SUCCESS. mach_make_memory_entry_64: (os/kern) resource shortage (6)` +from `platform_shared_memory_region_mac.cc`. darlingserver backs every named +memory entry with a memfd, and its open descriptors peak at about 1100 when the +lobby opens. Under the rootless NixOS launcher darlingserver cannot raise its +hard `RLIMIT_NOFILE` to `nr_open`, so it used to keep the soft limit it +inherited. When that limit was 1024 (several Electron apps and desktop launchers +pass it on), `memfd_create` failed with `EMFILE`. + +darlingserver now raises its soft limit to the inherited hard limit when it +cannot raise the hard one. A failed named-entry creation also logs +`MEMDIAG … failed errno=… nofile=soft/hard` to `dserver.log`, and every 1024th +memfd logs `MEMDIAG descriptors live=…` so a leak would show as a growing `live` +count. With a 1024 soft limit the old server reproduced the crash and logged +`errno=24 nofile=1024/524288`. With the fix, the server reports a 524288 soft +limit under the same launch. To check a running server, read the `Max open files` +line of `/proc/$(pgrep -x darlingserver)/limits`. + ## UI crashes after a button opens an external link LaunchServices dereferenced a nullable error-output pointer in six paths. The patch checks the pointer itself and populates supplied outputs; the standalone regression covers missing handlers and null outputs. Electron opens LaunchServices through an absolute CoreServices subframework path, so the paired installer also replaces that binary in the stopped guest prefix. Updating only `RuntimeFrameworks/LaunchServices.framework` can leave the crash unfixed. diff --git a/patches/darlingserver.patch b/patches/darlingserver.patch index accf119..a2cc225 100644 --- a/patches/darlingserver.patch +++ b/patches/darlingserver.patch @@ -445,7 +445,7 @@ index 458e659..2e33f20 100644 // wait until it's read if (!dtape_semaphore_down_simple(kqchan->waiter_read_semaphore)) { diff --git a/duct-tape/src/memory.c b/duct-tape/src/memory.c -index 3114947..e2a8d5e 100644 +index 3114947..20cd162 100644 --- a/duct-tape/src/memory.c +++ b/duct-tape/src/memory.c @@ -11,6 +11,7 @@ @@ -456,7 +456,55 @@ index 3114947..e2a8d5e 100644 #include struct zone { -@@ -341,7 +342,14 @@ kern_return_t copyoutmap(vm_map_t map, void* fromdata, vm_map_address_t toaddr, +@@ -52,6 +53,26 @@ int ftruncate(int fd, off_t length); + + #define MFD_CLOEXEC 0x1 + ++/* darling-lol diagnostics: why shared-memory creation fails. */ ++int* __errno_location(void); ++struct dtape_diag_rlimit { unsigned long cur, max; }; ++int prlimit(int pid, int resource, const struct dtape_diag_rlimit* new_limit, struct dtape_diag_rlimit* old_limit); ++#define DTAPE_DIAG_RLIMIT_NOFILE 7 ++ ++static long dtape_diag_live_descriptors; ++static unsigned long dtape_diag_created_descriptors; ++ ++static void dtape_diag_log_shortage(const char* what, int fd, uint64_t size) { ++ int err = *__errno_location(); ++ struct dtape_diag_rlimit limit = {0, 0}; ++ prlimit(0, DTAPE_DIAG_RLIMIT_NOFILE, NULL, &limit); ++ dtape_log_error("MEMDIAG %s failed errno=%d size=%#llx fd=%d live=%ld created=%lu nofile=%lu/%lu", ++ what, err, (unsigned long long)size, fd, ++ __atomic_load_n(&dtape_diag_live_descriptors, __ATOMIC_RELAXED), ++ __atomic_load_n(&dtape_diag_created_descriptors, __ATOMIC_RELAXED), ++ limit.cur, limit.max); ++} ++ + void dtape_memory_init(void) { + + }; +@@ -107,6 +128,12 @@ static dtape_map_shared_descriptor_t* dtape_map_shared_descriptor_create(int mem + desc->memfd = memfd; + desc->size = size; + ++ long live = __atomic_add_fetch(&dtape_diag_live_descriptors, 1, __ATOMIC_RELAXED); ++ unsigned long created = __atomic_add_fetch(&dtape_diag_created_descriptors, 1, __ATOMIC_RELAXED); ++ if (created % 1024 == 0) { ++ dtape_log_error("MEMDIAG descriptors live=%ld created=%lu last_fd=%d", live, created, memfd); ++ } ++ + return desc; + }; + +@@ -121,6 +148,7 @@ static void dtape_map_shared_descriptor_release(dtape_map_shared_descriptor_t* d + + close(desc->memfd); + free(desc); ++ __atomic_sub_fetch(&dtape_diag_live_descriptors, 1, __ATOMIC_RELAXED); + }; + + static dtape_map_shared_entry_t* dtape_map_shared_entry_create(uint64_t address, uint64_t size, uint64_t page_offset, dtape_map_shared_descriptor_t* descriptor) { +@@ -341,7 +369,14 @@ kern_return_t copyoutmap(vm_map_t map, void* fromdata, vm_map_address_t toaddr, memmove((void*)toaddr, fromdata, length); return KERN_SUCCESS; } else { @@ -472,7 +520,7 @@ index 3114947..e2a8d5e 100644 } }; -@@ -526,20 +534,212 @@ kern_return_t mach_vm_allocate_kernel(vm_map_t map, mach_vm_offset_t* addr, mach +@@ -526,20 +561,218 @@ kern_return_t mach_vm_allocate_kernel(vm_map_t map, mach_vm_offset_t* addr, mach } }; @@ -570,8 +618,13 @@ index 3114947..e2a8d5e 100644 + dtape_map_shared_descriptor_t* desc = NULL; + if (permission & MAP_MEM_NAMED_CREATE) { + memfd = memfd_create("darling-mementry", MFD_CLOEXEC); -+ if (memfd < 0 || ftruncate(memfd, map_size) < 0) { -+ if (memfd >= 0) close(memfd); ++ if (memfd < 0) { ++ dtape_diag_log_shortage("named-create memfd_create", memfd, map_size); ++ return KERN_RESOURCE_SHORTAGE; ++ } ++ if (ftruncate(memfd, map_size) < 0) { ++ dtape_diag_log_shortage("named-create ftruncate", memfd, map_size); ++ close(memfd); + return KERN_RESOURCE_SHORTAGE; + } + } else { @@ -627,6 +680,7 @@ index 3114947..e2a8d5e 100644 + + desc = dtape_map_shared_descriptor_create(memfd, map_size); + if (desc == NULL) { ++ dtape_diag_log_shortage("named-entry descriptor malloc", memfd, map_size); + close(memfd); + return KERN_RESOURCE_SHORTAGE; + } @@ -689,7 +743,7 @@ index 3114947..e2a8d5e 100644 }; kern_return_t mach_memory_info(host_priv_t host, mach_zone_name_array_t* namesp, mach_msg_type_number_t* namesCntp, mach_zone_info_array_t* infop, mach_msg_type_number_t* infoCntp, mach_memory_info_array_t* memoryInfop, mach_msg_type_number_t* memoryInfoCntp) { -@@ -591,8 +791,115 @@ kern_return_t mach_vm_machine_attribute(vm_map_t map, mach_vm_address_t addr, ma +@@ -591,8 +824,115 @@ kern_return_t mach_vm_machine_attribute(vm_map_t map, mach_vm_address_t addr, ma }; kern_return_t mach_vm_map_external(vm_map_t target_map, mach_vm_offset_t* address, mach_vm_size_t initial_size, mach_vm_offset_t mask, int flags, ipc_port_t port, vm_object_offset_t offset, boolean_t copy, vm_prot_t cur_protection, vm_prot_t max_protection, vm_inherit_t inheritance) { @@ -807,7 +861,7 @@ index 3114947..e2a8d5e 100644 }; kern_return_t mach_vm_msync(vm_map_t map, mach_vm_offset_t address, mach_vm_size_t size, vm_sync_t sync_flags) { -@@ -620,15 +927,22 @@ kern_return_t mach_vm_msync(vm_map_t map, mach_vm_offset_t address, mach_vm_size +@@ -620,15 +960,22 @@ kern_return_t mach_vm_msync(vm_map_t map, mach_vm_offset_t address, mach_vm_size }; kern_return_t mach_vm_page_info(vm_map_t map, mach_vm_address_t address, vm_page_info_flavor_t flavor, vm_page_info_t info, mach_msg_type_number_t* count) { @@ -833,7 +887,7 @@ index 3114947..e2a8d5e 100644 }; kern_return_t mach_vm_protect(vm_map_t map, mach_vm_offset_t start, mach_vm_size_t size, boolean_t set_maximum, vm_prot_t new_protection) { -@@ -756,7 +1070,8 @@ region_info_out: +@@ -756,7 +1103,8 @@ region_info_out: return kr; }; default: @@ -843,7 +897,7 @@ index 3114947..e2a8d5e 100644 } }; -@@ -856,7 +1171,7 @@ static kern_return_t mach_vm_remap_external_shared(vm_map_t target_map, mach_vm_ +@@ -856,7 +1204,7 @@ static kern_return_t mach_vm_remap_external_shared(vm_map_t target_map, mach_vm_ src_existing_entry_count = dtape_map_find_shared_entries_locked(src_map, start_memaddr, map_size, src_existing_entries, sizeof(src_existing_entries) / sizeof(*src_existing_entries)); @@ -852,7 +906,7 @@ index 3114947..e2a8d5e 100644 // special case for what LLDB does with dyld info dtape_map_shared_descriptor_retain(src_existing_entries[0]->descriptor); descriptor = src_existing_entries[0]->descriptor; -@@ -1441,6 +1756,10 @@ mach_destroy_memory_entry( +@@ -1441,6 +1789,10 @@ mach_destroy_memory_entry( named_entry->ref_count -= 1; if (named_entry->ref_count == 0) { @@ -863,7 +917,7 @@ index 3114947..e2a8d5e 100644 if (named_entry->is_sub_map) { vm_map_deallocate(named_entry->backing.map); } else if (named_entry->is_copy) { -@@ -1456,6 +1775,9 @@ mach_destroy_memory_entry( +@@ -1456,6 +1808,9 @@ mach_destroy_memory_entry( named_entry_unlock(named_entry); named_entry_lock_destroy(named_entry); @@ -1802,7 +1856,7 @@ index cc10f1d..7df0056 100644 try { thread->processSignal(_body.bsd_signal_number, _body.linux_signal_number, _body.code, _body.signal_address, _body.thread_state, _body.float_state); diff --git a/src/darlingserver.cpp b/src/darlingserver.cpp -index f79f057..2bf9cfb 100644 +index f79f057..54c42aa 100644 --- a/src/darlingserver.cpp +++ b/src/darlingserver.cpp @@ -497,6 +497,45 @@ static void handle_sigusr1(int signum) { @@ -1882,7 +1936,25 @@ index f79f057..2bf9cfb 100644 if (argc < 6) { fprintf(stderr, "darlingserver is not meant to be started manually\n"); exit(1); -@@ -745,6 +808,13 @@ int main(int argc, char** argv) { +@@ -577,8 +640,15 @@ int main(int argc, char** argv) { + + // now set our increased rlimit + if (setrlimit(RLIMIT_NOFILE, &increased_limit) != 0) { +- fprintf(stderr, "Warning: failed to increase FD rlimit: %s\n", strerror(errno)); +- //exit(1); ++ // darling-lol: a rootless user namespace cannot raise the hard limit. ++ // Still raise the soft limit to it: every named memory entry holds a ++ // memfd here, and Chromium's UX restart pushes past a 1024 soft limit, ++ // failing memfd_create with EMFILE. ++ increased_limit.rlim_cur = increased_limit.rlim_max = default_limit.rlim_max; ++ if (setrlimit(RLIMIT_NOFILE, &increased_limit) != 0) { ++ fprintf(stderr, "Warning: failed to increase FD rlimit: %s\n", strerror(errno)); ++ //exit(1); ++ } + } + + #endif +@@ -745,6 +815,13 @@ int main(int argc, char** argv) { #endif // create the server From 3edddf250d836d032d144c3263d5774b526322e1 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Mon, 19 Oct 2026 22:35:19 -0600 Subject: [PATCH 128/160] darling-lol: use an original project mark instead of the game logo The bundled darling-lol.svg/png are the League of Legends logo, a Riot Games trademark, used as the project icon. The README already states that the project is not affiliated with or endorsed by Riot Games, so the icon should not be someone else's mark. Replace both files with an original D monogram. Same file names, so flake.nix and the desktop entry keep working. The mark is provisional and easy to swap for a final one. No functional changes. --- darling-lol.png | Bin 44526 -> 2526 bytes darling-lol.svg | 8 +++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/darling-lol.png b/darling-lol.png index 3f62d293f0406e3af0a86625e003db7207f38768..8369dfc2fceba02251510572909468b568a61ada 100644 GIT binary patch literal 2526 zcmV<42_g20P)}=6G=+cyCE+D96auvtssptn z)Y?i%oOY}oYXwJVoQ}1{jxU3C+HpE|+D_>>T3ggs>T9(QptexUi;Pg9IUvx|gx4eo zBzKpWyUX44IJ*dg3f$i9vzKRczt2qm+kJK?`@Ls(pZz_%03n1BLI@#*5JHLtE9A!f z<7ZyS)0su!m<@@k02%<)0;mGuMR6Gd&?GRv(#k*QxH>07t zb=3_9LQV6S6wd-|uIjdn+%9mwI(oYGUs?vBzP`S^Zz%AD1Xcmys@f(0MKIu*-tvKm z(B7VaE&=e5pEiwW_y&N@QqdmCYOfS>MJ#gg2!=ZVfnei&1{mk3(RSM+IqBhoE$@kh z--Zpgy6Qlv@skYfO*;6)qE>LZDNxsR%dqqSf#CEO2KF}qm3DT3*u@69z><7>0+#a9h*2-n!>TxsXLdczf%sDuuAcSOWL98>p%ZXC}gUCEb2VZNPN%>SR zKWxhZs0lVLqv2gwXbFTGZ&4lq3Hfl|yF?0#OZjc9%UbFxYU&z4B02Wh+AC5}T=LmH z-L3nxE@!1N!R6gb_KFmY;KI@|vlM{y#T>*{BvvR1fKkRpl{~IAAyC)!VcP*PYxsyO zF^-$Timde+C0SdMn1*36;qT8XZBJ$d&L*>J?Q((cBBp;gG&We1i+ZIQFM5q5>?A? zH2;#VM-h8vGX{6InmvV!_*IMpl%mL$&6te3wGSZpt%Wb{EWl;d~!h7vlxCI43F;-rN(QEECZx`Kw0^oqsDH9M_X=~1A!B7Ak68QX5ci zVw?i1@4F4UqX0Ol;$zL`gCw0QjJBXUwXf&aXUE1yI_gXKgJ7;C~pWfykrlkZS9IN)ujd;2fpbO@wYJ09PX= zVlQuoD&7y(LpKzFEC#pjQ+6#-GETqODN_I}3>KL)f)pqTje%|`02UXou)q@fc5>7%D0`{^#gHB-FnzJI^cSwo4vhoK|t0WNp&zjr@Mr%r%M18?tzj*4b#m=&Jj zDAaxTOUe&^=4=$n@KNZ90$^Yal}lSt`@mQC| zp{t?^!1?Ln74QUW;hlVqIlNoGV5TF#_)87$Xw`ORa8U$6^_NF-E-U}Fy0UU7&|QFo z2HtuH$?ylz83n+>a`NcU{si4o02~tg)kX}R=!Whn01hx-K@RvQ2-P&p7@>l;m=*s^y9rzwiotzG@V+L^6i;FWiNAylYqjFl^TV#xK9v55B=y7Qju5zk%U0YymL3 zeR5ty!vg3W0R1MkjW809U1lX!1fUNNFfM?|dF|~x;E=e20I(<=U|jtg7eK{sFcHPp zH=cB=`Iv$LNJs8bj9VY$>i2NlUy({W(#dEomFz=X+h+5B(AVeK(D70G13?UJkXQ@{ z7`Hxue(fPAnkE34lx_BV00BJ<+DTTD@qM-fAWAY@vTus$ND91}ZJDeifrjd_jmN>D z--~X^)pSI=4m2nk2$CEdtSm<~BAKOQW+?zX!`9p0AyQBr>=)LSv!FK;-U~3tMk|O~ z4A|Dw-MU}tvj741n614c1tSDoYjs&m7l9X+tqwNs1TZhZ-6Dm+Ah-8+9lXJbr-2e& ztOA%j%3X^R%RsQN=CXz-mxYskXHJ(_)funNoS*Z)5ywf$r=r~lUv#t{px2-JCM5Rc zx*lStir)08u(u#9o57xSl9CXXMAC}|OjRga%O2N`C(n1|}V6_4;4gFL;F14?^ZyV@K%^eTG{|_MXMUZF)lhcd|LjM^|ssf-1p^dLnc0jVjVAyMvam6Cx o_Z;3fA%qY@2qAt<8 literal 44526 zcmZ^Kbxa&g^eyhPxXZ#Kg+&&3U1V`9R@|+{wYa;xyOrV;EAH-ETw1ia6n)?Cz2qhD zk2lGkGr5y9H<@HIlY7rZsj0|eV~}IO!NFm}Vc`E50Oh|vrM4^bKcF?2*HDIo z^I`nY9|{Ne^gr`QaB%LNaBwFkaBxDIaBw6}xgF{v|4pEnDat|r!~fbp>CS-v0NqJm z&-Fhv`QLVFFE#Vu0M!kqEQ5N20l>n5KU8}`_|L`QCZp?SVd`cnWbR`5AHZ>Oa`CZ2 zIN2b4S`dg3504OpiTTbagZR z?DYR|xP&18Ef4uWL0q?gw*Svwz@U;^Uh8LF?mrEeS{`1hT*vOa5)MF2U*u`qq(6l1 zX_rbg(1jJ3&88DacM?VuMo6{GJ9~Ntf29Quo`XhOFD&<8%`XU~<~O$fbhTb@Fo}!N z$S&(YpYcBAn#T+N&Xk7UQNE$9)3~BC@V^sKUBzFrUHI%&DD8NvtuOcpypbv?rSs*T z3kcsa=F*h%+s4R6T-$TPu%oQ6`}NMKn{i|m&l-}#7~bjz zi6NFP6`L1q=9-V3Bj5diXJ9w^34m8oQ!tgS5-!3qyjEX5|QQ-p6@chI}l!M?*rD6g;5{MZwL`8vw0Gd!7?po za|6*7ZOLO15B#@AoDU{uCB{GY3>A9wH;Q8DP{aqp+MCGlsP_@JoG9<1;F6~;Py z@BG?exW5OYUHGd>f=?gng_|A$2bNp23zBu^aWtp)zR0mlbeLCUltz;L)Kh)nr^>m5 za|vxyoWIS5^f=2}K zmuAnaP$>yuGPdGcG8tx^6C7aFHLqiQ)n$5H06(AO_ntV7AYv5xH1%Z5-Tdl`LXL zZGtv3+I>HRo;lc=9W73@C3*`Lt@pCiIun@q*~{yaU2AGd+E?zDsbe7`eZYb&r@?})!fhUX0yuc~PDJnU@%Bm;>(mXE!4#K#bf-4h{PM}cZNj{nqjK*4 zT3*3*n$rFs7hD+eWwkr9tzENVfE9>L;fHDvF~%}u^PIxc!Dtd4k3ue*9I=UM>YYGhYyZGiYRpN*Pp2e_6dErd zlh+kTmtQvC=x=Oyfv!~NlM6C`$3T~>%pRCxz%~d1-5|BBe5h_G01ucRhfnvLH5CmK zfJ_I~$0Ib&Kw(>M0sCQu(5{Dt(fTOn$$sTMVg^eTyEdqyZ4Uq>9~U|TapabII9Blh zpw(OeD#1n-ug2;dp9|*q!Qj`d8363r{xX)bsK+e-V%RE6Tvi(Wz=2P#(nb6xgIrH~ zvCti8Pj1fgo;ucghQJm0e#-O)rwxMhJ+;Uqe$N0^HO5}QFNuW&Tk|~mbd#vxW9eFom^%dkF7yhUbr}xEca&m%^~cst{nQE^fGdcX|1{o!8oP!+blA*!|-6L&n6^?&&WbTPl7iGLc^OSH6m& zk)*A3sbuWDScVR^ht9gp;_mqjl4)H7n33E;(f=}o&jzOo7rd1(JlHebGsjaOJ1v@( z*tTROJs9yWEW|`DHa;Eq_chGWtA>TXB796O{m2MX?avKPWcs4gUlcnf2D*g&q$fjC zOnLf%lfI>jiN0Y&Wv1IU7uO~ru#0Phe8wq_Nv+M9%czi9n8JgyKhrlfa4(HuHu(DO z*n&s!qScp0m~+v7*B{3O`$AtdJ*DBNx!}k0(M?99b7k-Q!x}N0o3IS6A9wl3f*zLx zS0t-?4%l93q(wKCaOgglfr31E@bR(#m}39JCCIS0y$6VD+aT7z*A#@2gkUXgpN>eR zWr|^Y5XdzFy*|5Ca~lLuzQ%{XaQ;k;$eL7c`Qxr=HTpCpryJpsyR~FnoWuUdjV$52 z@h8V7V2RiEYv5hwrKt#&=-+RTH-KU7$~>LiRX9`#I`f;hoGVz~ zUy0vx7MJ|hS2bj7&LpOXua?Gfww3($6vc3{5V3-Hi_5KdmGmX%@q{LxhyvbmInJFV zkxfue92EhH;y{p!rX`RhbLvdoDSxXMsz;fxnEc_^JEM^dz94nW0*(4+!?+&8_Ve6V z^`eX=q9esZ9C{%iX9^(3%wgmtG}RVNkb@rhDTS)CHN)jp^DXyzCCG4sLWZAu z{_+*B>;*SrTj`@*1y;4N&2Jp@s)=jSBw4UeCu%~>1jAcqEK`qv132?QKU|6PNb$9^ zSw76aq@HfOY&e86CnkKV zjFg#-+-RDdnB@`{!sLwIP__Z{cz*|6OEbx}?~H!T$`!{_gQ?%S35h5e>+L;@y8NP3!1=TgONvM+ymXGppuT2Gk3AE{WmQz1MFw*9qPFl5BtZ}t!A)HjLf zMDol}nYGG07qk?sGF_WKI<0YhabM^MNAKV8U0ajpyG?|%mGReDE)N-704ZIjM3QzE%Do`=03k*k6mt5C-IYaF|lADpAk=2PRM}$>98oC6lM{)rV`-LVm=2l1b*M zJd43O)~|MX9?M{Zmpl&+%neo zx~DQ#u%Fq5a>>nF%S(D#mQxCt#*pm$6J#YTaMlSV6piSjx}zk~6H%|@>eUonUZ8(T zhCELAQgYN=wD63s$$UaNbCmM9Va#erBHiA}r_`E^dJBCoInkUB>tlafuMJ74Qhv_b zdKzlJXSe6E=G zxNX_jCz_|9gfCe<3$Vt<@D16Rx1etaPGl7_ng{0EyIK#TIM%FriGCR@A@vvp=rB!i zfD#ny_!%BZjaV zgiPimnO~77!kHux_*aTT4_Z(XO*@wbC2f2nKf~$#Na4GX_C*L^6ssusY*Rb@m<{^A#C=2y>uBUA zGHXklf-6wCBcSvxKDlr6f~MIwpv{dpL7CMp8R79vy$N{^Q z#iQOeA2F`Ui(~x~yy=yG@Wzpv@x?bpSm)qB_33TTt$l`vAO9jG9ov>v<@4yx#E>3|9DeLJ4jLgS1z%#8=WpHVy(#wVtoqw{vyHqh-tS|Y6 z?mT;`fzfOhc5~?z6>JpRM;bsNf{^_7g)mBfg|7lPQir2F)=cMJ9vYg?F|dP|#xV#4 zu^2`uB9&1^!fTtd;ja#2h*~(wAEq?#&MyXV*_%Kn{d1;{Gq43E@qMVGE=@c;3vH;# zah@jHzf#^Wf1K;?+`Rl^CDjyf?4)!${Li!S-dau&p;&P;YmT5--JmK(!q|+e8~%$q zrWj)Qf+F!_xW|vzjLTo|%qU2`F5fIt8@7H#gyc(B{FL*XSX!B6Wow{#*i1a$WVTmYO=DLvu#D&{ct@U!n zR@eXVedHJc`GmYTCD80v6d{&Yz2V&b3U9C(&rEGY zXbD0}cqqGvgf?To3onCDx`QZ%Ul@VEOJ|^YAty@JC*?(sNJt`cACegHVg zp-ihHuPcxOq773WGa)$Lj<(vfcwRPRMql^ww4}2oBY4g`3;$M^qm5G$@_Hzf@`oh1 zI6;s?#!*+V-RhvskmQhZ;{9@NX?P}J>)qQeLr?Ppw=VVAr^&bIBFnu0)I#_!g}&=F zH)a*`bNr0rJEj5a*9FAb!Wha?<@FCbL9Y>jHCuEVEH8f?u+01Y8^VuK!GN1SZcWF( z$}~v5U$?#M`7bHp{vwxmiGID|_y5tgF5Yny^}+hfAn(QJJDHIw%-*iauZ^XP4_ACRs$#8XY#hV2{h^rj$CXqP>15z*jMBirJF;e>w_%@l@lA`5Nx>i? znn-!sha%=60~sV{Pk#Noi3_)9af(KZI{vf{TCX3@({FD3Z`DxUP!|d@ShW=kk&AwP zEr>(8G5>}-Jjrcvx>zZ;{1N06XyJ=sSUNgvJu8!2zDnk{yVEW_A6BT6i;uS_A{Cg> z@bl?k&tjgXR>X>`QgH}`xnEL%Iebhvut+Mj4}ga!XYorGEE_Mh;17H4b$8q6j#gu! z+d>;&CkzRujE9HbOTxu)oJeZ92mx`3X8?4%b4#HTU~)-lDABUtIZqpbbl{&?6q1Dc zAH2@K)n&h6czA>~v@v}LGlgjaRf{rQlo@?d1MWg(!^3rb6@8Z@}x0Z>FIy>y+CW)UiZjg!5b{jFTgry(c z!m(qux_e;aw)&GyCBmM-BeK?RA65qZQ7Ps&hhb-Tx{#OqPn zf_-*pTlg#H(w4kkDPSu+&(*kGuBuLjsTm;F(Hvz;o!@{2^+w7$+R>b(tTph?MXL5;l@JvcKxDgp<@+qL-WM5a#ygn+63BIpod6=$0-4hQ1do#Ka-E?3D5E zMFNqP>N(EOWKtxuHY4n}){cxnGmSLJiiK(5K)k@kR0tt!M6SbqkwKO76MszpAFuHj z&W<<;^UXkv?NB*KM=&Y-5_4rig&gYMKH#XX%9P;5Zm~YHt>(>u+Ll=_ji>|p^-lxh zyQVx3AN40QfR>6~WauyQ2geH8K)ZAm3>z=7pIh)dGBBpz?vI8!#N(f5>X>Pb_Ed=t zX$fXVbpTD1O_7>+hFEBRzj-RmR>uKb>N>f(!ek*~0yk zLJm5fh7Ue94tlrbSmK_PZ*u9Ow-lIC5Tz0@Dv#wysLX-$id0S{O%vqKEG7Q&8$k}- z8B9PIlvm*-zazk=I|vi5Fm;j_M8z-Cou;{}0<3ZDB#yY|4>GC_8Ij~5Sl)p6K1P2* z$%zbZVds+?`Uej&aPVX%!*3q+LrGy!FbtQYXrBxKKr-*o+*b$5HhGMu=e`iP8c8Yq zDExN8ra^do5s(EAIZS-Rv|-o_^u^_GNCbwV$L=W(6RMbD-Pp2W%SP^}F>ia~=XzA@mDQFgeAo+yThasPzNqzeUxn z2}Z-g&&So0wWfAnii=yRWi*@5$e#7rl1{d&J2u-B;THe7I+h$rIkLprQ!b0)`O=<6 zohKPw@_{Q8Iy=Rk+?U!~E}N@wYV1pMKI7$2eB`C~t&Fb&MBZ)>W0-OLH~m%@%H{YNk>wi30r)XNEt zmE!z;2&BTq#;?pH)k}r%sUf~k=Mzz;>OZ>aWP|m68DYt*(-S;NMGSA8)Za@rdL(Vk z)jXK+c_3}**-l5)ZoF)SWCjA6l9QzsP9FqCFkW+#8D(aLgOKpP%$VTcrkoxpky16^ zl}6c%XLTomn9bT%=fgrA{Sw$6q}Vh~*tOe&zOGbUU<-j(v0?@Ub!nJDhMte<0t^!F z_+5dC7JV3k)+VM@nU~=KT-wZV*^YlV)_=K;HIu6;qlgrz%jf5dV}Is0>=m=Z&cbwq z#D0{(-LnxSY_>SJQ$fwD?mTkzPBuPqO%+UD`WM;dt<;DFo?obMs5(9kB|SS*s3r|Q zQf4zDI2MKqRK}NwBm0o!f(S9}MtaJzolRTUrwyrIX zjyP#@o0TS{9LSgq-a+i{-GQ!fr?8CesC9futjSNP+tgjYY2(UH7LDhjXa0pJs2_E9 zF$blbwB;GYZMY-AYHBF{W;?Ii5MmG0prej0$qG9Nwrxvwm%XJ$hAuCvV6@c~bIi|} z$c{Pc^)hQ`D3219tlgS61p2#z$%Hb^-jlU4z(2k)O@kGtTLhFA*qIcQxjbsJuwZ0l zpTY=3B8OUmDiq(N9_mLS0^VfNVKDz#xvbg)L#?9W3Tk5gG-x$SG(JRl$ zq+ZWIfPp^Uk)+3gq644NFK&+cpTfGi>?+hJFy2$6w^^J<5}4>Ez5xQ<`^Y! z#4#A=(91<60sWCEZVaIFhJ=|RISbFCCBdLLCQz)gL8V$N@>h;Jus02aM+8lvoqZ;w zFq$aq6La~zI^exN?nA8v5pE(-vkJgcOG&AOcm82Y4)@#91_ALOHcH#lpPuk{NS;R+ z!cJPlS8PF>UzYt&=?_uqNfk3f2PIjR)EvBen3aucjlC=m<#p1W<9zj|9NLXsTmV|_f*_j4 zGf>P{q)A*^VvLfB-lohXuTNLIXgifmnwzMcGngd_he;NTplb#|7ECcoUp14AE>;|7 zv>wucZw+s>_$|+(xv~qXdF=1{h#6Qr4Pn8?OtWg1;X^nnLv53;1wNKc<$4HAZHLvK z7fh_?!3V5pI&y2RNJEJKDx#;MW%f%!Biw|EZ@5gBU0s@aF1;-RfSUYEvqVAAuRDE= z!21t9A+r}h-D2~vugI9vIxYa>!YgxtMyWZjnVQ2NX3Ef-S4_f~Qg;G&zND^1O1gmq zHW%nR(NrNiuJE()m`4Bi&nmLd%)i-t@gg|82oxfT0+5X-26GkBj_5z*7AuI`o{~!` z3{Jj5QzOU7NHN+0AC?~yo~I&bF4Uh7g)6>{ zGMV&TtyWO@7Pto81%+XY{c{cZt<$TD_`3&wYKP`WlwlEEeLULMt^V8J?Qi*@&P?_l zj~}}YvgQ|XbnP;~rOuZg=|HKfavYj87x$n3vnZ~ZOtL@AqNyfcdYAe zsaURP^|lPE+6hc3gSV0>Nm3;wWbA@=eb`*`&sRfqLCP}Iv~m)?AE5_Hl@tYN{4WgA z$bIF^yo1}6MX@@aI5`;quHOWZ+%ni12g_NV*{Ru8_J~5suO4}!TF(0g1ZqVeeqaDC zvp3iaV+GiGt4s5&EBD5nIu*zYRK8+HKd$eCY5PkK!2RGViR&!YS%wp-X&+PzD0WpF}}m+m{> z!2~1o0nb`mo}Pp*9U*`8fLH6?(eTr^ckaIdyk1sevS?kBDk;`Mo41mpn1hVHi037G z;nIutpDhjHmLHuGc0`G12RMfTcsuw^pkBIRDT(?hewD`-$%^GTyC)BN@Z*uJq7eCT z3p|bQYlhOZ<+HktJ>|Qy4c0Vy+#i}kw4$wu2x&=GkZLq-b{SRk5f9Z60amiIJ3!Nx zk&^c4rFnYJ=M)Dcxp-Mx0cv4D9RPQU&K*PBM zPTKU0nigO|%5PziM)aaJVrETlIdxNj%q!-r7vU=c!&4jPz}y6#y+m6-A$McGrv7(JM(jOSB-W+Tl2E;>=ADGvL=z8-t0^Tv`t2C50wun#BJ zv4@#?7s_3H~*;l<#LFhBk={j zU`5?9d-~N8?DPJ5BnYG{t#y%$lV$I6j#U5LHU_2|Ps z;N?B)?Ir64Tw*7d8Ye)>l&qTt;mzyaPIhtf*`}2cthiP?P!j5FGd@;v#wC?9b0y5ZBuT_@rK_h7S+aTbG7VZ` zhhoTNJLyeIePI}*Rd7pWl;+?bOQxOS$%YX$)HXwc+SpA#YXhg`^_kV@_%Dy+VG&@rR$^GUW9DI&q`Fe+};>8kx(>l4bf%BFZl1VHB zn<0A8xVfmI$&!rp#d5dC$g3<(=BCJoEGq|$wk(n{{F2pMp6|``Yqtz+%}oLioPu;6 znj9%`3inBE(8Z0W`pYXWzHpYH7BXT6x(77MYny9=RKW)I}j$KN{pL>#24LXMhaYHdk?JFA6T=vlidiI{ZAs}=CuEP6C1LB zSx17hUeC|G!@K^`Zi9p4jw~jT&tRTV51L)%Hpezq6Rv7dV5RKPp5;GwlwpMlK2LYP ze~L8`^KigKxgnaW$~}0@{_ViDpEZ%VIOBX|BLCtXD$^_slOl#J>Z2yGhv#He+Q4K- zi9;V_Qwcxb$&Y=9CPUY7DS$Klpex-{CO~pUFO;h}DIuSJO6}=RU*jOM7z4H*8OEPP zCnI#m2!ng$mOwobmxKlfKjacKH??_?YwF2w72M;A4J+n34Y0=Af6(=J?h_ z4@>5M{Znbyphja6udTaH-DmCA=xk=)U0Q%w<(d`IDCvP@iPB*8MFgTp&{`FtJ%5|} z#R`pHS4}vcuNsWDQW%yveT2pZy{-5Gl#!nkivjT}K!C8U#yi>%3R;_=VCsXa{*lT- z%q^mL$Vw*`g@HM_7+OxrJs`S4(9AHsN@}J;7N5ejH&RXUjfHfy1FI@3rt_f(_G_p4 zLyef*Jm`YQd(5xq$v2YPs8bLR&Y06^8r9##zf3sTPttxnc(a!>`5Z?MtbX@3W4GNg z?yB|uA@iZyhK7g)*8{{9&&AMN5GhjI#1x<(}HqvOhIh&3Y)aiLil#0s|p z$}pXeaXTNCh%N`InMB@Tz*B!-7Fy)w$x&7}nE#FGixvMu94MhOJX9htI$H6`5rr~( zjz&JElu%SMIMD1-$G{-?(g;ggpl^aX1a#~tA8FI~OeV&zO~jIV$wEbVRJbx(62lX( zVkL(2Bcm{oQ`{FmWX^ku@PpaLN0Hc23$Jf4SPYH6R4-vOXlgeCb=(n%o1gCJ%OULU zLara0^scNQu}oj*YY%R@YsN{q4EzWCO?YMJ24asW!ZjHuh_Qz02ZEY4N`%)7h!LKW z6J`~%j2^6;HHv5ah+^=E$&`Ze(^jb^vN(T0q>!UtNzv*zI#8>lx)kG(PHJIE#3Bz? zBC@j_Kx%skn@F_({Z8%rNSjNeN~Iy)>4*nw-HBh1cJ`stOL>Qn%b%#rr^xNn~#bM@6T)x^g@oC zeLC;{g)=(o?0eP(#UZ=UD}y+%b%y6d&6!rAD0nG|mS2}af6qn`u*M!&kU*o1G_>(D zicc<#_z-695G&vCgRNrZ0-&;7B7=$3I6ZvxF!f-6(OOp|T2hTWa*zd?g|Yo|-XS2I5Ooj~=Xi=~Vxc!i-P<*AI>qwyZ%2 zGuP3skciLKEQhl4+H(?xC-To|Bo*~85b~%7c=1t(SNDh;CJ%Y-N0CC;-|;pFVR8X` z+_uBNt;CiR5}jDZ=q3Awlm2-%YdiKbCGXyg@$B!fKAh-ixd)|K@>o{ipL0diS>CuR z+-lAZRN>=2S{FWgZN-C*N^@&sQzlo%{PM1qtdaynl0)VyL3KZ@k;=W^^Hc25Wl&Zv4DtSr2g-AepEF0{m`LS~lO z^PbOGP<=dS%L90yBZjxzzit8zpE?8?JWT(&_$9cWZ5D?RmdmV+t4mZ~aG?O^wSt}T`xP6;ad72}s;FxK9uf`I3|HZiz_4t&G<>4;0gL=ti00yIz zDdnc}#<vS&XV-Zv$m>rJ9L=DB`PHH=&t399#$qZJx;9SY9-mbp6%-rJI9J$uU_l%xucw z|I(?~jFgnc2PbV@@Z$fNsqvyxm9tD^RU-(at_J39gdj>a_nJj{AN#fU8qpzsF~sLq z`R-hnEJ{r@CGtvt#=S$iH1vOJ9$QkqNbwWn^-JZ!_=hnqLF^hnKf9;Sgi|PagUc)G zNB4PKl8SKaY+1hF?p5#?$B9UudAOuTPO_JxK^g`)2WKbuwQPW9s0d^*TK)w&Cd)OF zC1f{f2%5ac4S!Lzazqbm{EJZ!jf%P7m&LeA>)50(YlDK)zen}ve9F4?LL?ouh2zOY z1{k(la;d3C6L;vX#&vvXQ^-u8+!1KkVfb(ffJ;k^9z+pYh&MV74y7FKp zAG4nta4$23i=%M2P`{OOAh2*9YKZZGKhmH6nL#->_tp9?Dv8&z34_nvoMZvF(Wg32 z%;bDh{dSK_?1rghl(YO$`pL#r%aw^CoU^zrDiGtm{k%SMF8OB|&&RQ0BU)LB<=P&8 z)Af_AT#J&9wEmZDw7!(i20Vo`Me-0A&S+^F{z0aoIdT}+{804ew#{kz71m+NsKo%V!!3A*Mp86SNli#UT!a^aJ`FNXX-urG_nc#c}x z@anZI5|aEs0m8DLCuEpQvyZndXK3a_QO$==PFB*on6YaWFG!b#Q}Hu%Q=LEaFLmHF zf132sK-tLYpgH^Izho6_>PpFqBt5$Z#;Twd@Fh14E)*G0 zAOrW5S=9{#w&scd_+nLD#x*7BmS*J!gph^@|DBP|E%XfU^5Ok6T7jWVIrTA+t*2OF z^K=mo`M)cF2a+|X_{h0dh)90Z=uW7O5f}i7#>U>68y+`S)eYKOZVQMRIZ+e*(9wr0BrR(oI zdJ`B#Z^q03@ph5WgpgPsSA;{w=_De<7Tm>xsk#T~M3m}35FrVBQ6>>PYs_Hlg<+v5r=8=4@1@?;4R{e}#HZcq#i>q= zsGETF^Ob3w!#}Fddf)D7WEmJy1E4On3G38j7?X`lK^ z6GN}d8%#)Dhk!%ujWRg~m>&>YWL5WNg?mdlwfRm6_s&gPf8P^WT$wKuTAaJqRxmKs zpN`kOm)PK8CP4iCF9KI(Gt;tUlrFz$C|!Tz8L3dc%|x070y@f}e+~E8d^Uku^K7zk z;+N|AT)%7#h)a}JD!KEsq5~cHhVqUwx&RLt`DI~LV`Clw$6=0$*P0?xITsRRa^0GIxFdsbQuO+tT$?T29CZy6@9g(IEDqsW9zOM0J zBm1{8mcg2-F)k#X>0(&|6*pu<9@DlMF^$YY;YIo2l;Y5^f)|F5zP>kLsbrBHJVc=q zW3w^RJQ~r66ALR&asaWeBOO+eT)c#Cj+vXVLR@b^APb+z9dCC!($|lkrm_B25y+?t zIn-l(fog`yq+LdKR$#$dSfkpOUVnrkc1I1+Hw?x3d8$^iqUNpXkYg=++$VdE~7YX{%4!;c)2aDeF_X3`?Z9eDzZOQM>fJfW? zo!?u-+9tHD=S8b%;W{vLJpPN>Xqn-B1x%^wP2!N`IDSp3F;K!_f1-+*JaUAVOC%PZ z3QN}V8i*k+S1FD#gn*w83LbpQMAwwf4{0Z zT<1%O(0>w*+P?{kQpxH;r1``lr0576>*u;4&LQJskhA7j^HD%YokBY-u8vE9!^cdjfuwyT}p!bm`-u^#HLV$yVi(4A;JzG=F?QSY@d z5Ala&Z_HD_Q<7HQ{1dfNd?Eiz88P}}PT$T;{kf|ZhiHq7nQ#23QFyuC z!F!l$77nE*B0$Fs95J;tHxLNBZZW|1YFGyzH0Rj_0P<-$|~#!2G1Z+;JdS|?v# zg70|^N^@J|nS8JIeSiPRbFZyvkkV?Gi{Lx=Xeilr8BV!g>HQT49cctii`=#ha3EZ% z{UhvlwlanopTZE;=iJH|6t-U;X}T{UK0MUt0k&&fz5m2~$DM2gyFkNhEhL40fk#Sn zl+z(EFm!%d^ECf}asq+I3B%*{hlWqwz~s#>xkz_LhbiJMb`FN^z_@%6texau_&pFz zZAKu?3_&XLQzuOUkJ&q#v=sie93>W+UvWMs)NycG#1b1_p5T(#mKY!Td=Ow4L@&%8 zue(~ry&48wmQ60gUD*!Xgpi(cfI+bJ&QE`e2d^K-k$V49s^DPGeXPV|y4ER?$krFY zD4^sO#g-;uA&A<=FJJz~#MQtqClS-&PswsyAqPyQ^+KU3uxMtq{O=+1)IyAAgR_M^ zFR*0FY;Ma=1q&k3D|187Hvs)w5|MQRGs*r5|1SNYK*M6+RN`%ngQ=SQ{SM|QZ#~+l z{;_A-dHUvl=!xlb?dIt=sG=!Q(Zca}V5t+jB*$;Pa6~7>RyO_u3kf)^*~8-3H+H^7 z^%o2;)xQMU7I)WQB%1QXi}rF4T+jrX_vR=-of+6h0@ZSRXf+zPPt zDH*;4C74zPfkd94>nM=nLpgvR6#HEfiV;OUOef3bixvUk7m6o+MXNLAE|@)Gpp)IH zQO>3w6pZBoqCa?InJKRm)Qz*dG%KBL2Tk1?{u~#<+Jx@XiUGq$P3UCl%erm_*KOxz znzA|ZSGR!sZm zbJaZ~C2_>HGS2lOWXvHjogawH>3wA}YWEf`^XI)x4tkwI0(|jC@-ek#D><8`qCT0} zb~+7sUmHCVEzQ~gBSPt0@wJmEHnPExp7G2i_&MB@9wYSP z*m_eC0x!v1PexupjMLn@cuA0{zRhK``dId7Nz?P#JMQ_v!tSqGkcWcn z^Y`r05PZO3NBzI&U*CezU(UB*&MTwKVw!82J6!$^vTiTYDoGQ9S>aH&tO*9-ed(mc zWTNQfP4EWd#p9#{zco1@4K@DvLE)kO6Tnn@515=b&U-@=tl>8(=s zU(NRXl>r{fBi2W#jeAM1!Tgun(1<&Axe`>Qg~ifr&{9Xl*UW_y_yLBJQH;f;BSyL! z_%UXHvk~4rI;8#)nXTpSG)kjbv(j0-fJ+P0Cs*tPx1|W=x~1oudgi7Y2lEXuohVll z=S}JcajfV2Jc*!z4PVb|-a_CN#eW!nDCe<;C64!A@hG)*phRmyzlCVlj0Z2MaSI;- zG=LKcHYNPU?|=|O9?+#C>S@rmM>)G#IL+m#_rve~%a7ooW6nezp(YTP*N(Se@sz#N zs@%y)sL00|nhFZdI)+C<_`)amO?3V9r<)_sP-gP7li{HwCT6^9dWvl(Y#;oIy0Pm# zVw{(+|DxVo8=56513cEuzE9luZ@-lXJ*?`kO5_UMy#x`XL~4?RyYC;%xKAW04eyw2 zpf+4KOq%Q7nhK+?h~Dmb5}2AMdlJz1s%#tcv;Vdmv;}UtP<_|~J*iYAT($YFKnegA zH!Dig_1-zH&a>lSzXGg(QaDA(Gd-XDr7+9pJ1OO&y*n^EcV>@`Xa@rAUQAH%3L8Mg zL}o5Oc?gn+=8b%__j}4hlbwvqWTmpFs#Db)t=L4kuy7lpv2>rP!#Km2&4j}}8i#sJ z>LcyyOfsddc?e=if?AI(8E-0&e?h(GMz~}vcEW_8M?z;5S=w~UC!~M&61@a{K1R)z z6`6`~@g1OXZz_{Dhbi?nR=>lcXx9drq{Hve)}v6(6`qG>eR60yF=L-j-SU3VK@8gU z;hI5^&YY=8bOL>R5!9+UO4TZuETpn5_f?6Fj(RsQ0&dFh-v{-X5LXE#OOvHB0s;#0GRUy*ZHo8g>5uHt>a8hWZ%|L-74zI9 zTwJlG0#^qB(=;dWif91{jIekCfOscM@F}i0Ay3gr&`5Us!W+7DS^#uQ)86ny9TYq^RCFcs&CjUO$WNw(zMd zH;^N;Kji8TxnrQIDv^Quzj8ds@U{-_z7n{Hsv4(B%`~Zpca-c|8MI@tkO*?&>1G z>@z}Rs|LPO!?31Q3(QuNu(fb%(hD%mM z?|G4QACo~bz?#2sgAH*|ed z^(XR?ZWj!n(8acAb-t!@iK%$O`}u~Q;+j$8IbU!wLQ))B3=`(Xn-nZxFIz@aew7M) zd`pWaW|<4?;3q*ap7;#v6JZfQ#JcoIsLeRfu&GoeR{M2!l5m-z|E0~K!@b2u;QO_~ zZKlzv0C6K9MPruOmmgf#V{g6|%UnIb@u{ZS=7AU2AOCI*Qp(6a(0uB~pQM^AD30ny z64k`z>2yUBjOWkySBY|;OrZG%4M&Z;SL7>^SdR^l9d+OFsWAP!N! zZ~j1duQQkV)a*#0@9X&#(8;UYT{F?BN8O`%`YBBM^bY4t8XlPZ92hyvQ^edYSd3@* zBnXrGsb*UgZk#o2!;%_ZG4XI@t?td_mqp!;^TmbGg zu9ZpSI}#)-0DbpiRX}Pnx<(ra`CkBJK%2j3>BYj5*>vL&cP7z`kZ?YxAB(uV3l$u! z_}SEeAo~VYDwJCvqN*KKYTcA%1l8am`dB~|J;(wK#@~fc$&;-7K>wnj(q4Q>yE&Ov ztZRZ6eP5oFJMDYSuxKF!Uq+#61$~LxG=X)GJgmc^w4r~ob?9=5MX>cvnJr%|H;_&8 z5QAk^Hs)^gLpJ9a7PC5Q$qm$Gv^dgFr+h50$q#r(wlJUaJ$>|p^XFU>cDoO(_mj4O z*7hf@b$cS1QS5QsAGIdyy(GE8>Xny>WCQbC?IhI4%X)T{q(-e^bI zV2k+;x}ZV7wXUzk|65qvZZU8@aC#A0?_y8_LTo}j#-S)d^a)Bt6oxcYp;X&~G4#+~ z+XC4r4Kk`7HGkNtEu=WvojZU9}lRtjf$_4ee(Qiaj67s*Ge%*~TY{ zTYNIZx$$yJ@$|XdM#H1luYZ}?W%yX$w5K?+aK6>d zG(>)6-NSqwUB|kFJQnR{0yTt1W+xH|RT+ha5;5RFQ)~?KyzkI|<63L~kK^y3&J3Jd zENy&Lmd@t9ycgTj#>~HUM)B>2>O2G`F!$GM2-<4_0mQNhK3=pZN`ZWE0EQmAqDiD# ztc&GNMzDx)VmDBxgHrlMEGk63_t45hwEHM5y6Ek@o-T^|P2qi=O+c)F5M_qT_QhVN zVN=oHFqa4?F^x_jmi>l2f|K2pGJ?3xREhANt*PKqi3gM^(4YMYB?9e=)+bLty*ip6 zA{GT#(n%4kn5Khy+TC+Cv-v<)Pj08P8I8K{m?P!S+++Q5=-Bj2)uRLE-jd%behBF5 za=B~`&4j{ z^&2Mgk##4}ae|y~9^+#9KyH&+>|~xKh~Bjmwk-#So2Zy+VJp943gh)fdplOA#3@$& zh|o3rpdf?H#Q-{~5%1s6Edcm3V6l9zK^uvcwUJa&8%9c5QBuW~_}AdSeoXuA@#XUO z|7-79`fuKEI4P|KLx+cfwGi6m{ZKro=s@DO0hajS7D35&~+N z#H~P?xI2;64|IL<%(XH^7;NVSb|%XRr^H-_v$=huej>^% zvIdW_zMK|z%Wy_veubhRAG13qBdyfjAK{uEs*K zWZQaU|7F{jm2p9Wjs8F@-77mbb9-Bczl+03W4TxQh_MPJKXxL&Z7?@F~R?BKTUh=V4>hlQ^JmrpsPnI`&)vW2HQ zS9g;=b(_I^rgo^!;TG#yIYqx04Xpg<40NEa_nQM`hvho~nYmmn_A@?wi!Pa6K%5Xf> zPctq_*@zYdTW)E%ksewvZpr(NsU0@XxH}uyT@7_{*+d~L@xK$_=Dw?AVCy$v%t|ly z*>81+Z{4Bm_am;TU)w*bA3*AD7+hT02qi^Ieg-ttEIXoDMA0S)HSEgg^<%~COl*=w z>C16wmmegq#9=rem9C>qf?;e;ksA4MUjoX^ubcwZbWWiiA&|Dd?JyYJ_|q6%0(k(7 z1Qm)n#HqzRV#E;JCxIamKbkl`R(fY#Dr7R7$_Dr{D7%Y(gu`07Q{_G;U}evgE96Rj zwX#qu@)+kw@7bryrr~_f z6s!~xP;n2a-%j3z0TQVvuz;5MHn>E5W{#mvOPu08&mQz~d%42zC^5CViPZE2=ISJ# zmn|4?CUFFcC)1fl3o$vJ9uin`GX)%k*@W1z#L6FiXX1Yw?KI$@u&l$-O8oD_vd5*t z-~Edf`!*Oveuv=T*uMu7BxE#@lO~yoV68mJ!vVq)1W=enyN=DrOxz>13Kei#)vqMW|5I&wBrak0E8>K|l&eXAzRa;$|YU z)YyZN6i#dhLL8FLX81(JY79w}Jea1g%Y@m@VUw=5v{^Z!opi!$FMV z#^@Y=C{vguPsj`$-L~4wQ9Q)eUN6Ei`hN7@ce@pA=5ho-U9K1B;9*g*_8>s;Vh`1C zrwld`Zf*_!WvIi0E46?pK32C++ z6eVZ%q9i0^ZbyMk>^zi^!KpWi5Zbbz5|SX>bPNH*(B>Z#$VM!v{YL(*^Yq>D7$SNY zqJ2Pw9X*Sog^uu1EE&wq;K60P%%+=lSl$LOhblHTdry1}eym=3M^bSdBAnbJQUoM2 zmtaw0u$e$WFN0%GNR|ZfN3NuYdEvTpGF280VJvAHb=-&Z2mchoc0uEVd2mg{s%F9W);80P-D7o zh(jOqbW;+{WnQ_30-e~sUr@m?DEbvXLm4l76JcYykE6tCN<4)UlVtpP1dBm29}sfR zIv7Hl6ZIgmT>45JToO%3;LwcUax)I2>8y>xmWhaW6ov|my>)>I<2^t`lDxMWA>-(k zinM%0*MHU^bXsbGQCjEBK;kEwTS@{zg#$lg{j?UCDzBw(z)DO1IF-ohtA zx4$ZQh!P`Ak08LNx#4Tze!?!Kyq@5|94yMWD>Q>h;9wqc3`m8iw9r0X|vM)^mi^7@fKV zCBVsjgA$O+766}Y{3$@l+V^4zNGCSLpd`}wVPhlNBe6(PZQ2rxfS&Tb7~%wFAHk!i z{4#pFFlIGsL`3dab=4~Q zG(HhlW(~ln*7RFa)G+26`pL6czSM3?%r1|UTd6T6_7RH5?CRN!ra~yYGlCik7SlwC z(L;z4A#B=M`I{PplU!!-23%yl1~Y{J=}@o4|7#fFgaI4Dvauoq$ud`Gz|&wLf5Ep{ z$`{c&s%}V(-H%Uzof<+!5i7T0Bd6i6ln59V-h)F~HaEY=BClVXZR>dtio-ArqF9|t z8Yoq7AcckR9Y#nymUAf)O{8)=5D_C=xq}EH7wn7@kYNuZAZ1qw35dsTBp||0O|6&A z%brV>IOXh91Vot9?+J-hEq;g+;pQ&_ENu54Y%*9@k|dijRz78hZq1QER(4}6u=PE@ zpa5LP$E1MG_yOO@^-QLVw6$}+(3fl1UzE70UQs6VTOcHsTayAcs@7KY(yw#%8#?Hg z?F);UNs%vOKVtztGp!qFC!nTtNup$#)mZWGuLlO}b&by7t1*~M_INt5uj~HKK(E9~ zERAKWSeKW1zI0I5ibBg90a-*C(@aE&>#R$FM7yL!1%L)hI5*n#U7j$>7ozga{-2T?vTD z=)rhIB&|-$__(nA%pAODQ{qA`p~(YDAR#7ZV`3iT4rWxX$m0GlZNO zMLYYmj;zl+9HZv|!}Sw6t8N3BO9wEUnb^Sh>13ANhsOe%tq>0n+jQ(GPg(uULppkbR$Cpi(@a5@4ma z!NbCipMVf2=B`4LG?BF%o(v=8c4p`%e8~=Y)N~%$vL?z@(AOAF2k;+!NS@g&rk|>{ z5k(@@nLsXyvC>bVgt(bIDdCf`{z#q@mY1WGKK&-~DYLXJN4me&ula^_|nYQObPa)o%_iqjv&-KS+meVg|}&{oGDFsB)xAaQ^gJ!YaT_M6nP@leuaRD zAa*7tgj#A0eF$OteY&vF%u5tdNR6m(k`sHO_*l*kBd86&&`E%F<__C98a{*M0g-PB}Vr1B62eF&?1KQ@by+9e2i z@HZlk5V)gp0M9&$05$g=6s4M|+X!muhlq&q(_0V`;HUmVh))n7LqwIDvo&QV(`OE% zM-S8E<#Tp5Q_K;-OnpjE2O}GDj9v!zI_7uLi=Z9)XV#Ky^f6{rvV5IEAB&tex+yb1 zekw(3%t<~>6_44eAyo01m9nT3Fe7muB|^T5twJBFU)!hAjWF99ga=eOzv1!sRwI|) zW%ck-y|XbmQ?5#+2oHG=>{WM7D25yUscVB;k($09~GH3y3rmDFFcaH%Aw zV`K1Q?bt+!)kzGC3?1YcPFwUJG&7v0;4Lz=lJX{!BulI|h&U;n+UvwfBDIqxMw}>k zfEW&*o=5^i(f*1I&>Jt)M8KldOj@WiFZ~dM$TKr}60I!c%Xl+cKGC<_0};JOZ?w92 zN6*&xWgp2nv#o1^Bh4SA1#B}+9%KthzhF%bw}z@0?qzN?uS=jaxZm1}l&r}@=M(J! zin009j!TgE1RH~ujFTiHWxDG|h{P3CfUJ9~77)I&U3nVA44H+8kdplyNg(RM8n}9!H6Q>Hq$dIR=-8iyt z!^NZIT!5bI!3}tH&}#pjkPe3N1&T$^ z9|bgFS02K`Cn#T!gP{_;gA@^Y>s^|u((SwlC>_p4q^RoR#1@n&Fe`Ni6&AB7@hV0A zhUxL&(8-;=;G9FJo~##GZG0SU9c^V!bRHl2a?58BU0E5r@H4QTM@^?54%birD81$h z^7tCp=H4p4VBN}#xYVqZnNC@|oNHKzfDU&GgMmtH5LGNRc6&X`uPIU^)H&|DRB>c| z=eLx_WtO!wMg5BTas)m;RcUT)0UmB>0FMJ;8r6XZ3QK&O@@pkl;(r?YH!Vp^i-Cx- z7;JpNCP9U|Av$VYSAmEC$LeeZ85MA_skv8>z$YKumKM$$eU@rN1*!rS zxr-`_>m;cVquY9g5-y!)4ka9V%`gH&Ja$A-472N=O1xf|y82P0^9KY#hLu=}e=NQg z1WOBpxbl3CYKIr-n~|d@5;bbi1!(Q>MdN#5gMCtUs{_M>5g+!Sla4_;dSu)1G;L zJ8$ESqYnG0Kfd?Ejko8P^Jnk;z~u4HxiXD0yz0zkA|>+9YkWzFZ@)r_Md-Xh4VTF2 z(h4!uo+N`YsM&}z7Lk1xC453_9z~J_W&tJQ_`E}aN5p-N4qn_SVo_^!|M12T48#DW z|Ni)`#7g`RVOftrWylgg0nj;~LG@!0lt2R0YI^;I|EgUwO0{53VscXkZQDg zy>^26y|@_E`HT=3ac%J}rP$chMz=?!dj={wtVgHPP1jhOe zWzt}EP{1M~$CJk=Yo^iVhqcxy};gH6G-|(tA5_#!R|L1nB+rBP*GQO*vD;e&Ioh>(UC@;AWax@FbyHBw?5OWfwh|y%vCm>B| zy-A52#(blBY?4%EB)$yc3i>dtBg5#YKpQ(zB1Mi2A-&in5h5Z23`&UKIESq@r0~id ze=G6Nz;|7M{_}z}%cnV8QWi~q+|UH9dd)(sK#Br^Q=x^$1hF?6qBHR0A?-}3;@yi& ziYkv_%bFOIpu$2ty_+Iq^{b!@rG<<)7#|1Qd!7nP-n&yP1ZXf9A4uw66iMOA`v@^o zW_w&Rq|8Ue7>r}}Fj5YuVt2DOpUH8~FS*jZLaVd0*~;0|{=nJP`@{Yxj2j05->s>E0wPftN)JpJL} zgbbtE|2{RE$$2|zi7Z+Fc?zUR2WwFyPAa^GkTkYg4GS0LFw(@RnH#W4(qcW&5L!sv z?QF>^1opY|8?z&4IWzea=b91juh`e#)ctjMj4drPm8)Z&d?YusYU}|X<_%VL_u?6* zFv{6WKamVA_F@*WrKGGYD3T#=ZlOw+A)Jrkkmd%>6VT3EvJpxf<0zewH-jxG%rFY*-{By9Z=irLHOP8&VytMXUc)1=6ATJ>Vh}jUq$Jj{>KE|Dd z;9k7}!f?dDh8{+e^7p4eJ8m$R zDoM#khZB+_sS9w(V$+00E0JuCGLotpN16?^+ggp$atKY%A(ZHqYn`)c62ma(csS3 zaLDK|D)=<5sJiGL2{?_4hp{hObz3xs;H zSCIz@5sX<64;y1$Pl*_|Yvb(Cn8RM<@oLrWARB2HQlA|Qh+Cy<~GF+ZWkSORMnzHB68?G#nc zq|08Dd2+hWb{^-i(#}L@H(iA@WOBHv&1O>MZF?_1=Xu0gi?uk57W-*N$u11HPN9kK zF~s@>aoUN@j}bChW+x)zI8+EQ#CQ-NCC)4OSj5N^)SV;=zdhw|{I_%_`0sjQuf$6H zyJ7jszqGb6ln@Jenkq5GJU|~=v8;o1E<<&mvylvwRY^M^(4k+`>Ie?4#PvR^6tLu6 z`VlhPN)HaLVTbmi3<@qIPehx)8zoc*2fw0*&>EKTu*gKa5MdM7eNkfA{D2B6jQP3d zX(2K*WFto6SPS`xHF4~BxEV{hV5Yt}g5FVhLb;2LmjF7axYn{qSzLIUMNQQGW zBdzgz0f(`=^*9-t8Ew9gOOl$o6@x=0H9!#gMMHr7lry3 zE7$&&_;4NZL~f=?3S%Ci%3y434|*`f?b-SkDRRzPxUvnAy$?1E zkZ?4ji)hk8CmS8$HQlx?os>E46n1F|a9gZW!#zfnrSO^y(*oZtt z1sCK-x=Da_8()*fu|MExT1eUF>1iaf?LC;tVj1b=Ih(1}qKP(^@$ZfA9tPG?*I5N<#>YlDhcam_sZk|~Yi&=7R+82j%8aJPzLgqzQr2t?i^*CW zlaOs?ba)0SI#@-wBTb%Nqce$NXbR56VlW!a0J4F_QioAxWM@CkUPv6nB@t@^qcAwZdj5TfK8=lwndD*tyq zum9G+S7Igp{UCg|W>JIyX##AF>_-s?OOo^u$Cc~pAR#I991C%^WR9bk2(gYv>6M}K zFd62tzU(sD=iG$wknUagfPh6ttFkeuOi-8s;y zE3p#)j`;RqP``7%^a3WhjrX)P0Nr{m5TmFcBP8k8p$IOv9swAHM}Y{HUlZaH@c=bS zgwjlbE+V;_E~cZViv_$%U^Zu=-b-NirBhBNu#UI6MW5mWovp9)8WY%`C-{t+Wci9Y zbdsQhUJ4irRPZR^5fV~stme^@scafyKC*-wzO4nna)n=s|9i3YuK&BdTz4QcjpzWI z#ek4tDuThGfMBDH0}{k}i3o=r69`dUz9zun(Th)#J}i9llxZQrqldPJ9^FiYP$7w8 z4J5I!ffN=N zN*uwUO9aF6-MSJh@$ZPgRfTwY)UZ$tlW+-ed59D>Vmg6l%2?6OwBuk$qp?xkXcPes zNxhl`DGGWHO$=k2tVMzxbL0kW%Ba~Np-kDl1C;5P+kq+tc?Ae4$sZBGV}A@G5r4&| zNW|O3m`|X$k=Bo>=&Q8J#uRlFtI(@Gx&@K$POpYUtU*89*}6rvm=~fZHj`ocUNnw~ z@p4VH4FRjssi*QeBPnT;h-N$;gCR-CSvc4zZPW$8niwopni>M2qahH|4IgUz?{@D> zti-=31`e*54qClTF5f(nV~MTU^V3cnyAix*voO_mN_ ziv%rvWlkr}VDjd55(L!DnK%@{?17;N$9xay#Wlwu6tQJDgepe%ZNw~AaLNCF?VV+y z97Wdmf7LdA{l;C81VV6kSRl9scXwFa-4|HM;_eXKEx1e2kc5PUxLm*E(^BXCFg=kQ zvc3!Zy!n5~x!paJsXkTbRMoLUIF=|`0(qPi^C-$oG{~LOETUTW_L<(7^V=^$cfkX6s8?m7_@qqhH}ORyc20L2AF@v-$2Sma6QlS!bd z4lEilNcvmh5{KYWe7dn97>&kZw#)_^eWstN+jurfr}-haL9q2LlvEfTtzRM2y|2pXBxTSQFiBRT7q}kM80BHuo~+y zfnS1Ji=CwxU;3xZ>Fmr*YYcm-D@X@ls@>>?)~m4r6EXDlKqIC+j73tJav~8N`pI7i za4>i;Oz^1=%OpE!0u(jjwZ*dMzOS$itib;QmN!NPXhxw?ayT|6OgWMW1)N|r6j|ba z7K0K=?+OG5&uhTPr4$^6M=f*JR9qAjcpG0Om}IPl5G7-cMM($s)?OH7DOlG55otCa zL}RFa#@1*8Vtj~Dz~*ceZCElHhgsNqKUKx|Sixy*C@*0KK5H=s6+Fhy(hnVcpmyO! zTz`}yxAC4V^zY(jrXc?Nd@HR~`!VKFpw7RXc8XL5Ym%i%Ttk685&03995&a6G>u0d zlL5HoFj+ag?uAc@a-}rAsN@n$g!mcJ9wmjS`Y_(W*b&rrgpN*c+NAPDPn2$Ss*8!z z2d$nXA}vJC6R=2O+qWTfP;GZ3po64+83FCYto~@)iJ0%=(n3USN0DY?GM6IriON;v znS~vkO@>K0!7=hC@36XewOWlPb%M7OU&}@`dY8&*`G#)p_Lkbx_<+6KE4h$e)p5r5 ztV^T%(7T_0oXJgIk|gUe+>Zjb>K*J%37=kaCwV$Z$Yde}MAaxVl!&MyWaz+A&17i8 zVQ2EvfsKP^AqG}x6x1R0L&5jEbj%8@z<&~56+q?s*Nz?Ust79IR0xCc5PaF2JT6+F zNhfJse>0kxN7~zy1u~Jq&C^LMouxBKs8wZ#yh~5I^ON}wz3D@gPiY`YPwQN4YB24C zfDDb!v1k_3-CmP`g{szy;j(~U#!YxEAg;b5pqT{Y@t8-IJ`q=DQyVNK&$IOLr_shI ztm(~?ZFxiO@2FxZ^=V?W%X$35sNp>ht!j${nQ@vBy-hed?G!#+ueo{aw>bc z&r;3HYNWq47E|dPICwNuqZ`SSB_aK>DG=u!GUSPJiezZVp}#tT1voTPqyw8QLHSt2 zaOP)`dyp`0U}%7|>WDjSD00+0qwtxhdf1zxSwMGd z79|!^ZSF;ZCX%X=0!^gkZ89`br$1vM3+NX-!enN%j(3^7Am6HEyazc#p5YhQlfLq- zaZKrUeM_*iak9IwW!xXN)t>HGGR57LtWn^rU}JTwyDhW1i5I+2>5u~%;a!YN6OKQd zfcZGVL`rnxNKXo6h;b2lGDM{pdD@A}ZWN@22uI^G4Tt_=tiW)}OQ=Yw0L+lF@sO>5 z`%JeLSb={X%bRf2GJh!+CjnK`fT3K)LUSMrA0&>8hZk&2J9*MxGt;F_7P=eD$GpVM z5=>$u-79gSz) zNO5lA#@Ji#G{gS5_ZahjZ<_KgOAYec)c&d;mwPD&u|J!8o06h81N{rIuxRkVL@1H; zM-Y%D=0AW>Cy}6Und+DE=^!HeqUj_eMIeVGm!R-5m>#mTI%H|T@VZCH?l>#30{=ZM zuM$vtk-(b-7=VqB1w`<$pzQb84-mn{3SPj%L-~CP@bTPTXvd?o_#sotF{kJ;o(`rL z&t)1}TD*7YBnx^|0y?NNdScL`8tqdsX;wWWhhWf3_sC8tI%#lTK$D}!TpN!PRqAYt zcqH{e@&qLP*Xcx4@4Z~HG$%@I7|$B0luc_(0zC*wsB^7!6R4Du*% zn{w$7<48>MME%pSq(C&7hs^>N(~1Zs&X)}_urav?K_ToKvUk<+x+Y|NWH}wO0xR(E z#gb({Dg|m5&-7t{4^>3aq2QM@|1JE`Qs9D&B}xHHd$@Q6{>#*1;*_o;u2N7ul#;wk zHoreD^rW>g3zr@kat9GoIJ%lR0V(q-qI6Odak0@<$4)>KP#t?3MS+xa6hc7CTm>JC zq`ZntjASs2B1w||@f4{h>5ZaD192}+fnLPCJ!oM7)&8mS3{%xk{{G(D{!8|UzI}G} zd7oe1T{1?p&-U@y(_lOwdr7_F??{pvYM?(5gHK8NUtuzxm^TWC7NXub95O`R?TJtz z>J21HiI{sKFNHX4(6!@X1AkZu@u!HBRU>VNBTu>$|c zv1A1bS?XFX;gs&c3TOT(B`hFCo+wa9nmEvnPLldGdXb?j7)d`os=XwGh*4d-nSt~t zkw2LRHiF!qIE@&NsB$+ZpdWF!15>Sx=^aBg>r(L6W3bwpUwngJYHyhtoS(Tn zHzMf<&$jkzd$c|YhLKNvX*>YQL$(;6*fW^J0@+O3{o5&U2PHp(!Fce7;V=)&U56-X zZ1*7Il!%l%h~pD+Hz6qjQEyw7A|URKqndz3uou+?#N}Na1XG?5t*3*_Q2}bZatEkT z5vcBAcdS?etiXQ?OXv3qOXsAPR{%2>DS#LyY@n)Kw2S((0#H5ZAf?w*eaKMb*D(l> zI(KJQB}HR#A4W1lbt}BDhO0xU&G%%Wx`T-d<=KE>Vrd~G)U%BgH<|$`K&~~_&DpcEkjCAvo>qc?6xz4&*|&GN?$&q zL9IbGGf7&VL`V~fzJ!U79a{+l6D!()B8Fps3B-u0M}Y*%;3k9^sZt|gk}QtH!6#bW zgd?@6I;1d}45hFl~_mLpLa0^u9 z3R8r)xjUZ5Os`ND|keWU}yPSeqjs7 zP^}uMle<)d`DY@u(cpZCNftZ0JD@QmAE7WYon{OyEOSix6yYF`K^;-|dMs*)7kXn8 zBc5+1hKW-!s7E8ECx|kXW^YxsKZE4;;9ag#135=$3L}aWqW6L9Aa|&p!D5h_Lkt*V zj$jVf&PsB%K26HIkVS67^q)4=|Dh_$#*{kpoqS-Yl5XH>jAe}e_-f4V7cjEdTvaEt( zUPP3^YD;@nZ2C})JcOo!#>mM8B#A~h$0v&Ad{b6D&KZCW<|CLyL7v7Tf#F%isKG2Y zlb{hNe+xp2NN!UcqB!}okx&)ar4fUI_Z&kR!IU7cK2kr+Uiw&_({qhl9%Ua6;Pvv+ zhok8L8n~Jo;BziwqRb&CGl}Zc&@>?4{unr@;yMT>R&GyhAd=aLfPgCVP82?|{8vQr z=`8L;Js#SN;o_6j?^BNg`GN?FB$LaQRkdN5EPQ7e4wMhOVtMJqD^$S>{2D6bq7qTs zwFYIyy}Y!ae{pTUt`U9~E(-L8WC5cz4ik-4I9;0XiDdR8j!Uv*ARr_5>18GsG~%|T^kFRXfT}`z~nS66fyN41`y%CvaeO06S`oY3A6ez>A9{7VA=D3S#)P{p?U=bDB$VxVRimTHnr09;Mcfg|_()|!p7}*#~#V`sz zaEM}fC(u9&f-4x#0hFWp?q5RC1WO1j87z7w%m1Czd@BvXb zhm1cn?4prjw{$M*pRl{6<+mPNEAT&n%EZxi$-m^xUs>=kHvTPH?pp)Mv$%lr`%*xl zki$bT)3Yez3@hP91Kx2ha__^LS9* zkn|Q9RAXevW6%dHKLZOJ#37Ck=KA-*i5E_#e5kjUjYkMEhgYH zkr{FUB~08Phf4|HT@6JEmA?@OurfE}p^4k`iQ>yd^BXdx)oPjdDbmWS`3{68`Nh2j zk2dvmun+So;VYX3Xlkh?gOKGETm+Y$(12Eh!%q3UOZS;e(8aj}D@=teP)21WM7q$f zaKleJO9lP_OVqavFoGB!0k|W{5kPJx?Re1nB~27C+g@NESt6}pF$Yb&V`XMgB%a%s zU+{_Q2W375yoiG@^U;yxRh6noV)wGPv|-dffX7c5RjW|IL=p#*$H8|t#zqqup8yV` zzeW)w;x^!rppd(s2vwBQ7LKaN>%0et-blxr*wmrY2@K*`g`H3sSnmEnkyNm~OyMWi zlS7!MUXp$I3sv0BM6g)E6s7=3Md&iX<`l@{27kas;ko5!D!)1r1S@?r1vD{pZA?7c zjh$$sLndYVGM_nW$J`;zVk)1M7BG_x&jjblo2*2qxgjT0!e<_H37El&WDq*lR(J?* z*@hXOm$qcb+DlROt45vQ1G;9Jb*+Y6d5`Myd#JwQYo@~Y3i*s1ey*~V-6W(9;pdMC zf9s_1`j4R->xAE$0lztZt?W#h3gBQY3h^bRm2U(XvK;{~rk5h1gi$&dO#ziVi7XkU zvx>R2q1$$2A`6h#lbDVmnX$~HmY{TyWbw)Cv6#4IBR8rZbkY{-OMhu4oxF=$S}4>V zg++-{^$Ga+xbZ{C6Hu}b$3)|)eF@Nb{swqxtWpONAeUPWADevo7c?onj*&nkx^oMl zJ4R+NOscT*PPr(r`vE>_>|iKa+K96mb&O=;V)j@};|pdj!^Qp-QFy@%xEKVbKbEy+ zE?+C@ijU!i*p+uXp8V1c2@?+#3 zV+dn$=e?n(MlQXm$yj9HsvbJ=6YpnTo=+E&}f7UmN;mwJfO=}W5ZKy ze{1&q2l2NKma9B7!Wq7jgX)Aw(mG+lkA;Wn?~0~~?f!s82D{Lm0v(Xq0ig-6V`VyI z2Km+zOk*;gEq|6t7})uCifSWr@~v|4@FMTimw-%k6swV^t$HRsFlep$hVEo&t-qKA zc{*!PMw2IPmK6SRGY zq8CQzcoYppa^CEuGW zis@=Q#_2g+|a&Xw_cf^UqW^uigHqq2_*2absZ6k*xtSvP)Xn`ozvM8_{Q;nKaWjwa%`?ZauO zH&*9S0dn0Z-$$U%`*`K>T?OWHX{_3_~apG0tH)K1Sk1`cWjP?L`8i)Q|x3 zbk;ZGk|SNSFGVt>;{kcv$y%#Xpi@eIH8}upHf`iptndc2F_6q4exMt%j{Y)^4!XCE z;RPb(+cx4=24i;?naGCJR`#3bcLNkTJr4turqXSAlw^W&FF!DYJH5wc9Ic{Ui}$hR3hx-^b0rxgp>|Un zs@C98d6Xh!;KP=AzjRd0j{ZxIKQG9CeHxGwC*ZlT~;hewRmg!g0ozKV*5)@M~6 zUT+oBJ|Q*3!TBK|1Tq>y@Z~lH!PDivy{}IOAPCk$q0s(OA$_#`{{H>tYRlfyWw)%? z4HFa7T91GLlP?yn0F1Ekim}LG`g`Hke+GJyJ8$Xttei+;qA~QD z?2@BWq;ax)5TOk_vpy~hkMi+{hh*d6M|P7F&ruB1 z?F_|Wwp>F7MMjRm20R|d1cV>Mc5D#R#;|M4f})7C*mL`P7}yo#WJSYnU2d1ED1uGO z1tjDkG(N7Zi^eDC?~M?U^-e(0WJ{+4Ci!Bya#E@A9i}n~avx#K&)|%}#>TWB#vy{O zF2cdZ@q3_9I94Q70MC^bfMhW+2;};*7|6-LHKYDXEN}Bt5&A)8!B?M!bbR>SDO*DH znb;^y|56-WEH8mWfL(eXhXQtCWgI%OG8^I0jOsWEm`l(x7>DVU(x+iFf!6$NqKucR z?hqQ7!k0Qnlp>ya6)U4;y8Rq|@zs#n_cT%@NY+tHfq2zJWyg2b!x$8C6Pr_@MBofY zC?M)qO60)1kPK-kG}1~5oxP8_Wbra{(x>nz>QsGt7de=DG-c4o6QeCRR3D+v zr6&Jp{}{C$2bGGP$2RoUcUkkfhDO=I*iVL%=IpKvv}Cy4&Qr_q`&LBeF(B(`^gRT)GzU*2lqi+<0DPI1yB8DGn)VQEEb7$`M2XN#e@~n^ZZMQ2 zNeq84s;MSc+6k8uRk=27ENUX7@bQV5x0V%vy1%?~kW|?iC}HI#srilh_0OVf0N9en zJ3;7G9zSYir~n$n`}JYPo#J`jgA zR`OjSf*HTDTt~y1g`$AXXf#D)u8T)OCGx9NBugp%XL964cRoWW2C8!e&1hw(_hkxm zNM>j7CbP-r<6;w|weXO;k@8f3_w2bxMGiC$^1tZV(LPq~Dw_{0WcLhKS7RcEq~s{M zJ>=UC6~2mTI|H-J^#~ zPkAwj`=@%-{g;Ci8D>liMnPmN^DSo=^%Qq-7LNz3VW{4$8k|9rE$Oa5!oeXaH)CRA z%esI<$%A1#T0>g4UWzJ*UZVVP5G?qw4#Q>F8pUu(P7KHA#Bkv4i~yQ(XarZ^N`Oa) z-#|c~8Se11tJ8k~g-2d%ER=k!HBq8!{nACG&@|*HQ%60kW-lg7cg##w^}?qnxizgg z^o$=V1qw7q4#mSGW}Xw zP?giDYY;rNmGXUtD; z!~%Yi*}-eXXyBJ5LK zQB-6jG+Aup6Ep=(|3d;wL<$q|DU#@Xu6(#>>)DjZs9r5c;UUmA1&v1l>w^^l04%LZ zL_t*d&Vx}TadO|$&M(-dl_iEp&TZ)!Y;MYa{#o0%wvF`GlZQ(`s}~v0!ykN8J>Gm+ z4(M}oX(TW6R#ygFJk4oLUxxADQ{Yp2@n=f%6G^E?VdLqoh^UpBC2NsjN2ck6c#LU` zQ?~?Ld8g^mgSSr`cTu2x>3!O z`a|MGsny3~VG@za2nvH&!**;GcI2RNI1CE!SKv({QpFdoQsMRPhOD_oNx8bLb zLgSIsqwvTu(;I+GJ5BDB1d?HvcP@rnlg`ovqB!zV?htBl)Gm>E_y}t!x}nqn?2Z&k zn&=+?oNm&|kl21|I7Rx{I}i|%G_C=_P^Y0#1o9;1W2Ao$#i#Qhdb{5qi+%J<(@Ml& zG^BaJL{w0Y5mAwFYfw!<5npO?X~FeOvIyDYNNM92I`SnJ(nM4CFy_h^r1Qrzj~AF* zypU$bGv9xXcBaXKU=wm=SqLOp1rmQ|Wmcylb{#1;psM;a8dNt@J$jH}8|=oLFv+2+ zA3~Fc_=N;`81|V2?2q zP#lc>ZuDU)R_S9R%)toO+i#mUS1%o|=YIXjrn@^kfJ|f#=fPnU7c>P^dGyUMYHN&V z`9oI*Tk2%qV)bPhKTAA*WF@&3UwzL&9z>_&Gsd2-fu z>rYJw^X}jI*3OYvFo8Omuj(NGPT|z-)xkw-9FMAK@Di8sj(RpYSM{KV^@1Bn5ur}{ zU|+nMbQ0~CZLOLMyZ}$(c{VoIHzdIZkBO1;{+l8j4H_PWD8|z|XU>loYpopmN z5CR-rN@OVNi^;H%>E3p9GM{Pgml(9tTI@rNTD<&aI8^bK{U_>aWSh)PqQo&{FVT$# zdc|&_s75j@0s%fltpx}|wQ&@JV6tC%=I1m3T-hcRpEv&Qcl~`}=@o$LvIP*V@bCHM zlJhDZQZIbo6OTN;K9LOb$@}XvU&hl~D5)ghF)RCve8y}tnIrj#=V;A8$`{-$Kl+pD zkg3cz_a~+5`6V`*o~((LJda+iL!xRB^=v?@b`w&JBvro!NxG}5?lXyDQ&s;Y1|C+` zSqKHp$SeW`OASX;Boq5vOfC8Za=^ zE*q-`bRoiMrr>#7sT||+gCoBmwaEnMZO$&es2=82;DK_T&98NGW8)T3DdrJf*5Pl_ zYK-CFWf(tSrD}}-8frKzvyJ|qI%3qwXiO~PdM+9Rn`#2Upl8^QaE}*O z3KI4T?>8;={I?pGWt)UTsV~cdZ}z~%z%f6>#v)9ZQrtQpJH7Ku_l$3={** zjZi4+jXwflhVD{N%5@fsoQfa>i)t_aub^vee}(GMD^v(+1`7vUO~fLC#g7;$Os3&c zMC&wZYAATu6J-c3`P*4Q9f|bnw8&hV(+hZ&5p?Ef$rExK-|1mgF_z}oS87u{>eTA= zW;ng7U#6OkX-r;1J?m0kzbOf|vPv|LAi^qCH}=CONwj7g6ef1;1~eMeybFbor9THW z(b9ZCQJwvO0F$2W!*H?a(|j*E4Ei)>$s#m1=gSJAZ8MSt*y+<)Pg)5IZR%0AGM)bB z<{|PBO?u}{#@v&UAQ$ryoj`NhwOr8V9VEa!k3aF+k}-f~SX9$%QGDHEv;q-q-UKpO zyh+)iavG4r7J!~Od~Dt1JYy)(`vjNj*`EY4W(8;AV3XovOiVJm9ARj(cG=I$ zF{^jLq#I`9QxqvodlfWMY&ji;jqO!Y!XTQThDn0zw2P*Oo^3~xr-4;lzMz>5Bb(FA zQgv!*OFcj}BB0bFYwq|N{~Qu|RyS+#V4d@u9#{-FA)!;CfL zRN7d`4>FVhk1TuQBWSvZ4!!k4np^TG=$Z@g-#tjA5@M@<#p$?nIYQCsfsKLVB#B{> zjBP*?haRcVR26ZC*KAES2Cgom@zvh;leE#nPlYpNE;*U!4rLB=CRRg=Uv`w=C=RO64Bm{h6HLj}-S_C7EijmBjZ0GgTq z1%CZIIUfrn9J_@IpgQb>JO(DVe1?ThocD;4pkBEoNYb4TiIbq4>_$Y{)CFf?h)+^( z#;1j-aUSpk-KCe>fZf^FpCT`@1-;x4)g9^_b(21e^BBU!=&lUr9)zBBS6gC7J|f9r z)<|AYlbe)e$17031xB#oY+d#ItV@Awg}&K!hay zTW8Th73(zzGKEI9d(&xrN;7A-+{CMDW7ciIoisW6WyjM84_$hQUi4#XP-E{aS1VKO z(j0XW+ko1ix$F%@%AwBkJU!HQAv-r;#Mz46NB;GLI83atQw%C6s4PiMOe`uf+ZDm> zD!|LD&;Hv(y8K;b~L7e{1z5Ib=)jGze>POYWMsf(Nas>2%;b2m*@5RBOq{m@k zqD#Z@FtD>yiXaqBMfqjRVzVK!P-V*%6VR_8op}-#29C2D4wgzL{zMd!mg?R(2wD%u zK!`gUAD3?V7swMZpmYWuU7+0VWH?T0Lj}-3T>9JPrN7L< zAwms3i4#}7WLHvDv4DfAqMGL5Dr%^v)g3`CHS~72k?Ay$a}QK&GlXQi#M){ns+@Uh zEPrCObvH+;(^<=3O&*t<)v@L`YIkmxzt|&qn{W6!wyw;S`OHs@rIQSu$v7I9eEeW+ zJhG8Zh!T+DTVe#{y}NJ#H-7>F3OoHBiU{%c!)T?N`qq_DsyD-0#xRo+9NhdEx3Q!8 zp>3D z5LlPBS*W(hCcssxu=>in02ma)->#)>>vg*POhxd!`h)*lgR%UtTIw2L&+>pY#+6Tx zu}{RrARfzLV5^$c>VQZmJ@5&LMxMsSqPqAoMGP9;PbeUy-CHT(k@f#To&u#HMTQbu z9>Yar%Z=rglzNgh7CBXogGr!vMWL{Gq1+BNBy5)xwzDGenX*8M`}e%=?*^gt# zaUPa)tN^%@-l{-(MeYMd7D@m#2FEVZ`4#!`tS!}9qbKgYtl*CwJ9g~edjrc)I4J`3 zJY(+p6Az$VKYi()dnJ?aSndK8pYT|C24E1H9R-ucb=k|O1eb-(T|`-mR@{HfEB^Q& z9*|vcsB&>r!zh=NumdY){6KkAwQc$L)cI%x#Tk?jTW=4!@@=JIU0Ph>*PQ<J$QitTlMxfC&0ln*F;l5{L6s=iKH-z z5|4a_Ne}rZay}VMb)j>YG_fji^$0sNl3$$h)G&~Yy(tOmDcEyVlmsRFC|QVuYaRfY zc&v+sf#;u#gTgOdsiJ`2`7kNKYkdh518KTc)es?Q`dKE^NTj7dHtRC5{YidOf8yx$ zXEN0|z}zYKTK2i>!@Er=^c*sJ%26Nf8GKWGF!p)XDWE>qf7I8(?F&x+?AiB8?>gw& z$1mKAfCGTD0Qib^y72Kh*y+l{ULE(qvK;=GZ2g*3dDivRN9@P0PDu54Xo3E}+rD21*f zq<-}?8=wKD_QeNGa|A^cjx%1e7>I1F5*R3BEMTc>XO^UKD0;gwhqx5HQ&701f|ZG* zDfmB9i-oJN!obDT2jUVCuo)m|nS_Y~^#%qC2}6!$k@2<=(u%-$b)Wq_tRc$jEmn zS}1sNZ1Uv2Q*bDf?Qn_Wk!i}3AfPzkBZ7^w;4TadV)O2ymICSKgGf+Kq~km0a|rw8 zTz#2&uK8|p@|qWZddh2ULCu0g`(*}v1$tM*Fh2l#(nI!!VkcA z6KUFsF@SPiz$o(o6NC8#fG{N-*h9k5)t^H;E2I_we>OBR{@vb+e;Qr)|4VaX&9X<@ z7>x%QmQ4<@q%Qp2&(ey4VYDMySkBLM5+UNBNs)xA3tldlVGG_Qg)OeWjz*GrdKD6Q z1ae(?{Pb7=H1~$>xgg|IRoDydi}+H$t_03>O*{VIQ2=GdyKkrfLOZq!L&W9T^10Ib z0~}10KZO`3X6Z>%SV&P(Lqsy_ca1g+k9ifjA9xHXWvV$~W|DYLSQ3MNIE_3WJqN6|R=Ajl6Mu zFrqcyE!%h(=rxT;%ymHKs9n{Uz&pg%81N3U2SigaZOp3A)h@Q&RlIqX{;uT8M8e=l709a)MlYt*aMXIq}J@4s)&UR*zZNvsmC0Qyn_ z4C5LUU|AV_K&i{IF)*y35lpOLKZ-UCUj8Gv~1Uwc#uW-K7(Ul&tmUxT*4V1^mz%tK`%|ZqIRrvlVW1^5?C=QCi zJ&ibqc!eZY7?jiyYOpEzwbT=%)7pdvs$`bgQ#F#rux2tC2PbkXEu^W6AH#cmt{UA} zsCY=uD0wbFj6j%Co_K}qPA7nmG)FuN|V^ShxAiJ zjRo8xFEWBZg7l_4G#q7I?Yn83?qCNg1ZY~FZuL4-^f1r+& zt7LoNk9@~lz@T#1sgW4+1Q_oH9|R`>(|DFk>4k-XPdx0%k(3i(v-p6)sPcgf>=e=g z<-M-jC`@x59k!$x4v?}N-j&l(9DkV48OW zCR&~*Kq0ab9tK)$Ol(9}C4olpanNYFJ9OE_LgU@G;jdOoja8O<6~XTsGvBrK%e(%4 zL#b=Zg0DU+zoE*7Z!if1h4v4|LE)BeA`U90#!!utwAV&0CV8udI-+Eav8s+FIn{#( zBKSrM3yrciCrt~{NE?$GFB4(~X3;^?TAPJ@jvZ;CnQu|%(-fG54jw`?nV@(WieK)p?e;7CgdGUYBn=Uq|sWx%{>2 z0&FIhO#11g<$muA<52Gq?>^ZybwcO(rrzKVkKUfx8+eB;WKG}_7D^o$YXz@(S3(bG zc6G~Ei#XP`mgeFK^p-{2!=P~jjxOpY_vm^=c2ys%Er1d+l>&yyWcd&x zEpCt85Gaj}e387G&C~{bA`dZ?YC4(6o>;h)XhhH`bs{ccsT3syLwzD1p!f@|C}4P4 z$?f6r=X-?hIAzH;b$R)J(_sA7yJdk_j4cmTW!f*eOD z-;*`&q#!@x2AAORHf|}2&m7$J4fxEaq(4BjfP%M?c+8=_d7P?dGP4%$i6L+C*}}Ey z&XMxxrW!^|i6>jnRnO?1^=H|)RW>+RPb%HYY4S8%2BYO#(=*1i34fN0Rmd!Kpti6>n^QNCg{^`N#QstyJt8i<|+AN+BL{X%0s{8jJR{iC0HYT3KXRRCMQ z_VUM%-nLADPGt|dXvUewu<0wE2<&Y1H@^j%=_&JpLu4KYf_;v=NAW-?^@%=}x=#Kq zAIc8w#!;-wck-sXR8`T&e04rHE+y4WfW}gNDPmx%-sG_`RC~EjyLuWQ1H}=cB6v4! zhrOtcOP~F+WCF=Qg9<|{-iNN)Xt9uP6@GH7aG+Mce?{1aE6VLzbbpUU8>R}%i}|g? ztF0FWQvF}W@~ZbPekLf+Tk3Na8;tNvIsU?$Q4T{h%SPis)erzl*_Z-`s`jrXgDD-Q z3#1bvQanS|lSjMtBw2vtuZouMN$Fj2)Ie256A9v2yh2H4N;-HIo6l(~zJ4`g6IIqq+=@iDIpAxI`9-%U`qF|KnH`FcH#wf915y1bSrmr*1$hjj#g9pY8knC? z2Oc`#SLV@%lV4K}r9gG*QVeF%;O#<#Jj@^byXcMve+o$+RqN`LXktF!r)S}_Kqds| z(?lnkTr;!rnPWae23Sx!2mxkw{E-wc-!p2vg6Am>hQuG-?O zb8aX`XBnu*%4T|=I-OqBt98^bl$ar}s&i?;!#D0EN|Az5wgA}b zeLOU#aRx31hFS>^8^!hE9{IWO)!LS5*}roO&~@cg;g|V0zp<<$T6_TYuXWSn$V2J! z|EXX3T3DG~#U8U(Sd__f!LnM<0zlb_0+n)h)&z;C^PG8vg{03-#*m#=!Q*3C2a>on)OF zT^XRuAv*scma716zx@1z#~!T_GuiAT?jk0MLobUh{JFfcPu+L_4E zoK}Z2o&kj@gXA04;9kJGOapfmIXs!{ZOknC5f5&|WgRsv7|ttdBe_a{D;F}7xBU`N z(_Nl*NAV*gc&~U1MKzk!3*D)fF+5cmp$4)A+qjSGugr&x8S-T3SA)m&TmZErzn?N~ z8&Fs2t@#Y}>S{87fde+)wEoUhFrK~evGaFZ86EBCZko-3F*3N`n*(g3Ep-sEiF=QE z1^9k^_1MV2%F(}y+HbN4@Gv7;$zF+hSfuQ$X~v+tHG()$Yh6l!M$AodP#EUk z7-%BK<$%Uk>x5SJKZineXE-R9E_V6v6Pe0z`}^Ice{E?if93A~T`~XXuvqxjGFz(3 zA)cnZh>PF^=aNH{@II##p}u$oE%@}y|Ai)U49nb(PZJ~4vq>;njm%s`QQl)j{t{BW z${_a;25=kudwI16-+E0AMkS#dNKb9A;b=>PxC_(!x_`yh5jav$uIM0N#eN>B}vH9DTj zdypSrGwxrh4R48D@~aQtd$M$S?pokNR_16ZM>GMq@dMX^f08=Xyp|6y1lHWr*w(8&8s-Xpg;yONqXb zw~f=kg-P5Phr+-y_Mz<64zms9tgyMMr3TIaAG_%PZFCjt zieOi$4h265sUH?N_rvfeAHilCE<#MMC67&rGI@cUilzG2M8L{@x&Z@dgRMk$&BoV`m_aKv_RNjU_kq&S)^oi=W3VO!t~o(;J)gWCb* z$B=zZdMg+`di3b0?)&Gvq3e=%(L7{Y!72XFAa|;*7!K6q(g~_gUg1mdelkxqX0nF0 zwx4j5YPVG`o;+&Y{^r#WPnw*2doyE; z0gDW4C!Plswb45eBC0;JB|;$0_6`W3t&h;yM9lXvuvILq{ZthvH68(R`3jeSn&37(O4O8I zBT6d`g>UJ>RC;HRAjKyP%d|5>USfmHwQ5bCWW&N{oW;xZ@Sjut z6zWP_)02SN#jBYvx3Okv5B-L^M@}pC$Zl#p;O_0t*=bt)pkdGH*M90A9oDofuX%?0 zFqi~#9AC&Iydy(o7d-^r&)L_#1&ZTUH+M3)dvL6GI_RR>N^b&YTAncX2Ky8lXfy#g z)4_?0xYl(A`fp2$Tkg_UeJ_^20qBWVmjm?(X;5*o$zLLl&igEmj1T3KFu!jBf%3 zOdKGMO$^9j5~G~;5J%zRlaLosl&JBa#HL7%`ye$epr)`2efXMgxd&N8#xgW}gxZMczmTgi{o8)RE`xIO}Oiv;ows5!C^Gr>4X zX3Ip7joD1QpiZHi(F=G;%??YQrv3@e{=-;)Z1`o2V*xNv9^_tNnmoe8!0~)Q5zPJM zW$mz&s$p|^!02X-B&j!c)=)!;==EY9YNo6ux2JC57CBYA*X+t}@+4Q*9Li88%D2@Q zQ%eW=)Mi8xqVYdq<4_&h9FsV;_O}=$sI|7nC?^%6a6+%U6`JG}2FHYd)v)9|zhYPY zdxse=_bV&7N^wjw%JS6H}q47XHHg6$RF4{`z^ckh*~9k8&z~*!+D!FxKy{O+cm`A_FEIwx z9C=Y)0O|z3W(e>lE2BXkRyVOL@Rs^SJqSFgE@2ZOhx04*de<5M-!`@@D}ZJGLwyb- z)ns6OI@t;E%1bfXiA_`q>hS zSX3swu*6%e5d8lo{CcsKxn=hry>TfQw#cKx6@Wns$e^gw*WjZ_1@93@Q|;YKEk&wI z2U159$--J{W4@rOuq7YJISkFcVf2%+>bTs~YNW)}55*HH(vxVQ#b8f4SniaOs);}h z-ee~xYMaN^Vcf4-;My6s)dJ=pp}}RKchcC(QQIAoAVk&zrRmiBgd*8CeKoR zs$Dj8@uVp+!G@A_$#xzZ`*h2v3x7zjeBKPeO~{4 zr=HU-&h87Z=rg0EAFO`ZoLBp;4(~s)%Apf)WvITL&GhjUN;jM5dr30I>Bg(x7)ts= zb*X5A*e^lCX+}F2=t4k{y6ot*Cvmc5wnh$)Xt2GY{CunR(mFU$arcPQu{pfNm12!DMeDKZqpE| za2tAHQbnP9JSIsB@gycDzVlT%&&|A!@*aPiC9(`FkYAbUE5pcN^qDf2SA%c`etZ17 z`Yx~c9>hRnNDoYWZ1pV`LPU1K#wYImjE#^iW>J(#6?Vj+NV1>-pLk(&GzzCQpln$v z-O3j7A@!w;%`w6IYJK;v2dYng=T}$)#R{nYCfibjh$%4#l~>H!Nf58iy2HK3OKqqaUfRw5ubo${&759Qib6}$dfEIpwPsM?~^Bv zDt^jr29R^-(xm#);g7J7a>p8v`Zw=d|JdiN90%4Lz8DKYjnHSP3&Gqa_)gsdoJ<-Y z_=3%u0`fTB_y9OZ?S}>ia4)@q4S`|*G8g_+xvYN^%QFI6L5g>cDv+Dh!KwuM40WVR zf`7l7VjKkKopOL~8WSNB6yg=F~lk0xLcDVL$tITRisH2ie;LAfH-*)g>^8{IK?CAqy}+k z%D1dfi+?b;a6Vu7H?Fzs%N>Ky)7RVQcxiJq80p|W6$Q1PT*O$QCzBSLlm+my34kII zJRnDe65wJkT5taAT=>l#*2`M~So(W)gSrZINw!uO0Doi%?}NUO9gJJS_>mpOR%28g zPwp_z<8IRG4clN1$@6{eAihz(=$RTzCnZ$%O$a55wcDU@DONYAI$UW<&XR@rsy@0a zB?3%q5+yX2Dmy}!-RmzNGQX}2mZ#8GD1zS~mKOY_vo{~bM5FWsY&^_hFJhFiyp`}N z5h)#nqC~88FfK0f;=@3RSm|jLg5$14Cv^ngZhWRTl_vjG8O>&DPkqt2YYxv%n8DXh zq4b3*KwlnU3~)EAkpp?j*g+lw2J6q11&rh~27}t27#o1xBhS+ac1(2XZtksZ&6UI!vttY{X}>E5~sL1Nqo^os}st%|i9TWnSbR zxf7p-vHfVkry;R2?f66!ZCC0t-wS|Cy1dncz{ElGnqzitG76|4D78|H8AtS)9w(z>(a@ zhQP&~!glb>#G&)shA-oHvbxa!o!W|j1It$ckErf!4l;>2y@3fN=m*?JZ$<$dkY*x- zsK=vI{YAym}vpg&W-@FG4Auy+6B50!-13{D#iuazs zHDgxbKZd_&4c8p1g=a!Vuxr^Xpyy)}VCt*N3PHBQ#K+VlFwvN@4GO`~XW?UmyhaCg zl*HvRj^=w=Q=P))Y7<}sc~A8RM)EnmfsN&DH4xNi^j;zax6;-~7y5su64U - League of Legends - + + darling-lol + + + From 9651602cbafff1a3f00b783c909e2814852bdbf3 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Mon, 19 Oct 2026 22:47:59 -0600 Subject: [PATCH 129/160] README, NOTICE: add trademark notice and remove the gameplay clip Consolidate the non-affiliation note into a dedicated Trademarks section, replace the embedded gameplay video with a pointer to the status document, and reword the takedown note as a continuity statement. Describe the compatibility tools as referring to public API names and constants. --- NOTICE.md | 8 +++++++- README.md | 13 ++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/NOTICE.md b/NOTICE.md index 0332431..f9cedba 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -4,4 +4,10 @@ The manifest points to public upstream source repositories. This repository includes source differences and authored integration/research tools, not a copy of the upstream runtime, Apple's SDK binaries or Riot's client/game assets. Build artifacts and local installs are excluded. -Some compatibility tools contain short instruction signatures or API names used to recognize the user's installed client. The client itself is not redistributed. Installation downloads from Riot's distribution service directly. +Some compatibility tools refer to public API names and constants of the user's installed client. The client itself is not redistributed. Installation downloads from Riot's distribution service directly. + +## Trademarks + +League of Legends and Riot Games are trademarks or registered trademarks of Riot Games, Inc. +This project is an unofficial compatibility effort and is not affiliated with, endorsed by, or +sponsored by Riot Games. diff --git a/README.md b/README.md index 5be348c..5f34130 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,14 @@ > > Even with all of that, you will probably still get permabanned. > -> **3. Takedown.** Riot may well shut this project down. If that happens, I will publish a fork of Darling containing only the changes made here (none of them breach anything), and release the small script as a gist or similar. +> **3. Continuity.** If this repository is discontinued, the patches remain available under their upstream licenses, and the original integration tooling remains available under GPL-3.0. > > **4. To Riot.** If you are reading this, it means a lot of people want to play your game on Linux and are disappointed that they currently can't. As with macOS for your other games (TFT, Valorant), it feels like Riot doesn't care about a part of its players who have been waiting the longest. If you want to fix this, make LoL Next compatible with Linux, either natively or through a layer like Proton. Yes, that includes your kernel anticheat: Epic Games did it with Easy Anti-Cheat. Follow the trend. This is the year of Linux, and people shouldn't be locked out of a platform or forced to dual boot for a single game. > Since Riot have heard of the repo if you want to contact me you can at darling-lolriot@proton.me (especially if you want to do an official or supported version of league of legends on linux) If someone else wants to contact me know that i'm open to jobs, part-time remote only as a employee and full time remote as a freelancer for a mission -https://github.com/user-attachments/assets/140df3e0-3790-46b6-bab5-578139b84a7c +A short manual ARAM run and the current compatibility status are documented in [docs/STATUS.md](docs/STATUS.md). Experimental source patches and tools for running the **macOS League of Legends client on x86-64 Linux through Darling**. @@ -140,6 +140,13 @@ docs/ setup, testing, status and licensing notes build/ local sources, outputs and rendered tools (ignored) ``` -Darling/upstream components retain their existing licenses; see [third-party notes](NOTICE.md). Original integration tooling is distributed under GPL-3.0; patches retain the licensing of their target files. This project is not affiliated with Riot Games. +Darling/upstream components retain their existing licenses; see [third-party notes](NOTICE.md). Original integration tooling is distributed under GPL-3.0; patches retain the licensing of their target files. + +## Trademarks + +League of Legends and Riot Games are trademarks or registered trademarks of Riot Games, Inc. +This project is an unofficial compatibility effort and is not affiliated with, endorsed by, or +sponsored by Riot Games. No game client, game assets, or account data are distributed in this +repository. From 24962cc0560e3838037ffa5c8a2be2dca77fbe1c Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Mon, 19 Oct 2026 22:47:59 -0600 Subject: [PATCH 130/160] flake.nix: mark the desktop entry and package as unofficial Keep the executable names and StartupWMClass; only the user-facing name, comment and descriptions change. --- flake.nix | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flake.nix b/flake.nix index cb1b11f..8d418d8 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Darling League of Legends - experimental macOS LoL runner on Linux via Darling"; + description = "Unofficial Darling League of Legends - experimental macOS LoL runner on Linux via Darling"; inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; @@ -229,12 +229,12 @@ cat << 'DESKTOP_EOF' > $out/share/applications/darling-lol.desktop [Desktop Entry] Categories=Game; -Comment=Play League of Legends macOS client on Linux via Darling +Comment=Unofficial League of Legends macOS client compatibility layer for Linux via Darling Exec=darling-lol GenericName=MOBA Game Icon=darling-lol Keywords=League;LoL;Riot;Darling; -Name=League of Legends +Name=League of Legends (unofficial) StartupWMClass=RiotClientServices Terminal=true Type=Application @@ -245,7 +245,7 @@ DESKTOP_EOF ''; meta = with pkgs.lib; { - description = "Tooling and patches to run macOS League of Legends on Linux with Darling"; + description = "Unofficial tooling and patches to run macOS League of Legends on Linux with Darling"; homepage = "https://github.com/lucannez64/darling-lol"; license = licenses.gpl3Only; platforms = [ "x86_64-linux" ]; From 3309f079c70f3af43721c7e6389b7d60d204e1be Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Mon, 19 Oct 2026 22:47:59 -0600 Subject: [PATCH 131/160] patches, tools, docs: use neutral integrity-check wording and state the compatibility scope The patch comments and the Mach-O strings filter named an anti-cheat product. Use neutral wording; the patches still differ only in comments. The platform-identity document now says the overrides are for compatibility, not a bypass of anti-cheat, telemetry, licensing or platform controls. --- docs/PLATFORM_IDENTITY.md | 3 ++- patches/darling.patch | 4 ++-- patches/xnu.patch | 2 +- tools/analyze-macho.sh | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md index 5f25849..f8be827 100644 --- a/docs/PLATFORM_IDENTITY.md +++ b/docs/PLATFORM_IDENTITY.md @@ -6,7 +6,8 @@ A prefix without saved identity files keeps the host-derived UUID and `DARLING-` serial; launching never generates an override. Import mode uses values supplied by the user. `--provision` generates synthetic identifiers; it does not issue authentic Apple identities or change CPU, GPU, or security -metadata. +metadata. The overrides are for application compatibility; they are not intended +to bypass anti-cheat, telemetry, licensing, or platform security controls. Preview an OpenCore `config.plist`, a flat GenSMBIOS JSON file, or an existing `darling-smbios.conf` without changing the selected prefix: diff --git a/patches/darling.patch b/patches/darling.patch index ade6461..e0d6b94 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -3326,8 +3326,8 @@ index 8b92b6c50..18a57e359 100644 + * (kAudioUnitErr_InvalidProperty) made it throw its audio graph away and build a new one in a loop, so it never played + * anything. Accepting it harmless: the unit plays on the default device anyway. + * -+ * Only the game process gets this. With the property accepted everywhere, the League client's Vanguard module went to its -+ * error state (MvgState 5, status -101) on every launch (2 of 2, 2026-10-11) and the client could not start a match; ++ * Only the game process gets this. With the property accepted everywhere, the League client's integrity module went to its ++ * error state (state 5, status -101) on every launch (2 of 2, 2026-10-11) and the client could not start a match; + * with the old behaviour it connected again (state 0). */ +static bool callerIsLeagueGame() +{ diff --git a/patches/xnu.patch b/patches/xnu.patch index a02f9cd..2c8f6e2 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -5051,7 +5051,7 @@ index b998456..c6281e6 100644 + * (/tmp/rclol-local-semaphores, the former opt-in switch, is now a no-op). While it was on by default in every + * process (2026-10-10 22:22-23:50) LeagueClient died ~85 s after start in 3 of 4 checked launches + * ("mach_msg_overwrite failed -9", then dserver_rpc_interrupt_enter -9: its RPC descriptor had become invalid) and -+ * the user saw Vanguard errors; that mechanism is not understood, so the clients keep the server path. With the ++ * the client logged integrity-check errors; that mechanism is not understood, so the clients keep the server path. With the + * lifetime fixes above the game ran two full matches with it (2026-10-14: the main thread's server waits fell from + * 1.36 to 0.10 ms per frame, 39 to 15 blocks per frame). /tmp/rclol-local-semaphores-all turns it on for every + * process (probe tests). Decided once per process and logged diff --git a/tools/analyze-macho.sh b/tools/analyze-macho.sh index 8f8dd1d..d9a8ed1 100755 --- a/tools/analyze-macho.sh +++ b/tools/analyze-macho.sh @@ -10,5 +10,5 @@ OBJDUMP="$(command -v llvm-objdump || true)" file "$BIN" | tee "$OUT/file.txt" [ -n "$OTOOL" ] && { "$OTOOL" -hv "$BIN" > "$OUT/headers.txt" 2>&1; "$OTOOL" -L "$BIN" > "$OUT/linked-libs.txt" 2>&1; echo "wrote headers + linked-libs"; } || echo "no otool/llvm-otool found" [ -n "$OBJDUMP" ] && "$OBJDUMP" --macho --dylibs-used "$BIN" > "$OUT/dylibs.txt" 2>&1 || true -strings -a "$BIN" | grep -iE "Metal|MTL|Vanguard|vgc|CEF|Chromium|RiotClient|NSApplication|LSOpen|CFBundle" | sort -u | head -100 | tee "$OUT/strings-of-interest.txt" +strings -a "$BIN" | grep -iE "Metal|MTL|CEF|Chromium|RiotClient|NSApplication|LSOpen|CFBundle" | sort -u | head -100 | tee "$OUT/strings-of-interest.txt" echo "done -> $OUT/" From 4157d1ed3b874378e3f4e5028d6b1a6e6c53b7ce Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Mon, 19 Oct 2026 23:06:47 -0600 Subject: [PATCH 132/160] docs: add scope and terminology guidance Add docs/COMPLIANCE.md with the project's compatibility scope: no DRM/anti-cheat circumvention, no third-party game assets, and neutral terminology for issues and PRs. Link it from README and NOTICE. --- NOTICE.md | 2 ++ README.md | 3 +++ docs/COMPLIANCE.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 docs/COMPLIANCE.md diff --git a/NOTICE.md b/NOTICE.md index f9cedba..759c3aa 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -11,3 +11,5 @@ Some compatibility tools refer to public API names and constants of the user's i League of Legends and Riot Games are trademarks or registered trademarks of Riot Games, Inc. This project is an unofficial compatibility effort and is not affiliated with, endorsed by, or sponsored by Riot Games. + +See [compliance and terminology guidance](docs/COMPLIANCE.md) for scope and review expectations. diff --git a/README.md b/README.md index 5f34130..9176f73 100644 --- a/README.md +++ b/README.md @@ -149,4 +149,7 @@ This project is an unofficial compatibility effort and is not affiliated with, e sponsored by Riot Games. No game client, game assets, or account data are distributed in this repository. +See [compliance and terminology guidance](docs/COMPLIANCE.md) for the project's scope and +review expectations. + diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md new file mode 100644 index 0000000..7fe57b1 --- /dev/null +++ b/docs/COMPLIANCE.md @@ -0,0 +1,49 @@ +# Scope and compliance guidance + +This project is an unofficial compatibility layer for running the macOS League of Legends +client on Linux through Darling. It is not affiliated with, endorsed by, or sponsored by Riot +Games, Inc. + +## Scope + +- The project is about compatibility: making the client run, render and behave correctly on + Linux. +- It does not aim to circumvent DRM, anti-cheat, licensing, telemetry, or other platform + security controls, and it does not distribute a game client, game assets, or account data. +- Do not describe or present the project as a tool for evading detection, preventing bans, + bypassing DRM, or defeating anti-cheat. Do not promise that an account will not be banned. + +## Terminology + +Prefer accurate compatibility language: + +| Prefer | Avoid | +|---|---| +| compatibility issue, client check, platform identity | detection vector, stealth, evasion, bypass | +| hardware/platform reporting | spoofing, telemetry leak | +| runtime/launcher fix | anti-cheat bypass | + +Technical facts can be described precisely without framing the work as evasion. If a change +touches a client check or a platform identity, say what it does and why it is needed for +compatibility. + +## Assets and third-party material + +- Do not add or link third-party game assets: logos, icons, fonts, screenshots, gameplay + audio/video, client files, game data, or account data. +- Project icons and artwork must be original. Forks must use their own name and icon; see the + trademark notice in `README.md` and `NOTICE.md`. +- Do not commit local prefixes, logs, tokens, captures, or compiled client/runtime binaries. + +## Pull requests and issues + +- Describe the compatibility problem and the fix. Avoid "stealth", "spoof", "evade", + "undetectable", or "unbannable" framing. +- Maintainers may ask for wording changes before merging. This is about keeping the project's + scope accurate, not about hiding technical details. +- Contributions that add third-party assets or present the project as a DRM/anti-cheat + circumvention tool are out of scope for this repository. + +## Note + +This document describes project scope and review expectations. It is not legal advice. From ef36caf35c5cc148aecaa70b18072cfc5d21dac2 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:52:23 +0200 Subject: [PATCH 133/160] Fix Mach-O analysis with no matching strings --- tools/analyze-macho.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/analyze-macho.sh b/tools/analyze-macho.sh index d9a8ed1..22932fd 100755 --- a/tools/analyze-macho.sh +++ b/tools/analyze-macho.sh @@ -10,5 +10,5 @@ OBJDUMP="$(command -v llvm-objdump || true)" file "$BIN" | tee "$OUT/file.txt" [ -n "$OTOOL" ] && { "$OTOOL" -hv "$BIN" > "$OUT/headers.txt" 2>&1; "$OTOOL" -L "$BIN" > "$OUT/linked-libs.txt" 2>&1; echo "wrote headers + linked-libs"; } || echo "no otool/llvm-otool found" [ -n "$OBJDUMP" ] && "$OBJDUMP" --macho --dylibs-used "$BIN" > "$OUT/dylibs.txt" 2>&1 || true -strings -a "$BIN" | grep -iE "Metal|MTL|CEF|Chromium|RiotClient|NSApplication|LSOpen|CFBundle" | sort -u | head -100 | tee "$OUT/strings-of-interest.txt" +strings -a "$BIN" | { grep -iE "Metal|MTL|CEF|Chromium|RiotClient|NSApplication|LSOpen|CFBundle" || test "$?" -eq 1; } | sort -u | head -100 | tee "$OUT/strings-of-interest.txt" echo "done -> $OUT/" From c00dd569c062d692b3f6d7273bcca1aa87a613d1 Mon Sep 17 00:00:00 2001 From: Kalah <58551020+lucannez64@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:14:32 +0200 Subject: [PATCH 134/160] Fix Nix FHS text fonts and fontconfig aliases (#113) --- .github/workflows/source-check.yml | 6 +++ docs/NIX.md | 21 +++++++++ flake.nix | 73 ++++++++++++++++++++++++------ 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 20552db..c068e01 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -3,6 +3,12 @@ on: [push, pull_request] permissions: contents: read jobs: + nix-fonts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@3c6342b75005b9cc7c7d4069c3e9179247c89012 # v31 + - run: nix build .#checks.x86_64-linux.fonts --no-link -L validate: runs-on: ubuntu-latest steps: diff --git a/docs/NIX.md b/docs/NIX.md index fe6e9e4..c831f34 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -207,6 +207,27 @@ If you start Darling manually in this shell, run `darling shutdown` there before exiting. A separately launched shell cannot reconnect to an existing Darling namespace; `darling-lol --stop` can still stop it. +### Text fonts + +The FHS environment bundles Liberation and DejaVu text fonts and uses its own +fontconfig configuration and standard aliases. It does not depend on fonts in +the host's `/usr/share/fonts`, which the FHS environment replaces. Fonts in your +user font directories remain available. This also prevents an icon-only user +font inventory from becoming the only fallback for ordinary text. + +To check font availability without starting Darling or Riot: + +```sh +nix run .#default -- fc-match sans-serif +nix run .#default -- fc-match Arial +``` + +These should select DejaVu Sans and Liberation Sans respectively. The standalone +`nix build .#checks.x86_64-linux.fonts` check verifies generic and common Windows +font aliases with both an empty home directory and icon-only user fonts. This +checks font availability; CAPTCHA text rendering still needs manual verification +in Riot's UI. + ## Files and operational limits - Workspace: `${XDG_DATA_HOME:-$HOME/.local/share}/darling-lol`. diff --git a/flake.nix b/flake.nix index 8d418d8..de2c1af 100644 --- a/flake.nix +++ b/flake.nix @@ -12,6 +12,14 @@ pkgs = nixpkgs.legacyPackages.${system}; llvmPackages = pkgs.llvmPackages_21; + # Host /etc/fonts survives buildFHSEnv, but host /usr/share/fonts does + # not. Use store paths for both text fonts and the standard alias rules. + darlingFonts = [ pkgs.liberation_ttf pkgs.dejavu_fonts ]; + darlingFontsConf = pkgs.makeFontsConf { + fontDirectories = darlingFonts; + includes = [ "${pkgs.fontconfig.out}/etc/fonts/conf.d" ]; + }; + # Python environment with dependencies required by tooling pythonEnv = pkgs.python3.withPackages ( ps: with ps; [ @@ -155,6 +163,8 @@ export DPREFIX="''${DPREFIX:-$HOME/.darling}" export LC_ALL=C.UTF-8 export LANG=C.UTF-8 + export FONTCONFIG_FILE=${darlingFontsConf} + export FONTCONFIG_PATH=${pkgs.fontconfig.out}/etc/fonts export DARLING_RUNNER_DIR=${darlingRunner}/bin export PATH="$DARLING_RUNNER_DIR:/usr/local/bin:''${PATH:-}" export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/pkgconfig:/usr/lib64/pkgconfig:/usr/share/pkgconfig:''${PKG_CONFIG_PATH:-}" @@ -253,20 +263,55 @@ DESKTOP_EOF }; in { - checks.${system}.packaging = pkgs.runCommand "darling-lol-packaging-tests" { - nativeBuildInputs = [ pythonEnv pkgs.bash pkgs.git pkgs.coreutils pkgs.util-linux pkgs.gawk ]; - } '' - cp -R ${darlingLol}/share/darling-lol source - chmod -R u+w source - cd source - export HOME="$TMPDIR/home" PYTHONDONTWRITEBYTECODE=1 - mkdir -p "$HOME" - # Tests use private stub binaries/repositories, never Darling itself. - python3 -m unittest discover -s tests -v - python3 scripts/validate.py - grep -q '^Terminal=true$' ${darlingLol}/share/applications/darling-lol.desktop - touch "$out" - ''; + checks.${system} = { + fonts = pkgs.runCommand "darling-lol-font-tests" { + nativeBuildInputs = [ pkgs.fontconfig ]; + } '' + export HOME="$TMPDIR/home" XDG_CONFIG_HOME="$TMPDIR/config" + export XDG_DATA_HOME="$TMPDIR/data" XDG_CACHE_HOME="$TMPDIR/cache" + export FONTCONFIG_FILE=${darlingFontsConf} + export FONTCONFIG_PATH=${pkgs.fontconfig.out}/etc/fonts + mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" + # Check real font selection with no host or user text fonts available. + check_font() { + actual=$(fc-match --format '%{family}' "$1") + if [ "$actual" != "$2" ]; then + echo "Font match for $1: expected $2, got $actual" >&2 + exit 1 + fi + test -r "$(fc-match --format '%{file}' "$1")" + } + check_aliases() { + check_font sans-serif 'DejaVu Sans' + check_font serif 'DejaVu Serif' + check_font monospace 'DejaVu Sans Mono' + check_font Arial 'Liberation Sans' + check_font 'Times New Roman' 'Liberation Serif' + check_font 'Courier New' 'Liberation Mono' + } + check_aliases + # Reproduce an icon-only user inventory without host text fonts. + cp -R ${pkgs.font-awesome}/share/fonts "$XDG_DATA_HOME/fonts" + fc-list --format '%{file}\n' | grep -F "$XDG_DATA_HOME/fonts/" + check_aliases + touch "$out" + ''; + + packaging = pkgs.runCommand "darling-lol-packaging-tests" { + nativeBuildInputs = [ pythonEnv pkgs.bash pkgs.git pkgs.coreutils pkgs.util-linux pkgs.gawk ]; + } '' + cp -R ${darlingLol}/share/darling-lol source + chmod -R u+w source + cd source + export HOME="$TMPDIR/home" PYTHONDONTWRITEBYTECODE=1 + mkdir -p "$HOME" + # Tests use private stub binaries/repositories, never Darling itself. + python3 -m unittest discover -s tests -v + python3 scripts/validate.py + grep -q '^Terminal=true$' ${darlingLol}/share/applications/darling-lol.desktop + touch "$out" + ''; + }; packages.${system} = { darling-lol = darlingLol; From bbfa4c61b07995cb931c1278248005b24ea6ff38 Mon Sep 17 00:00:00 2001 From: nb <40745505+nicolai455@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:24:10 +0200 Subject: [PATCH 135/160] fix(appkit): add NSSavePanel tag-name compatibility (#112) Co-authored-by: Kalah <58551020+lucannez64@users.noreply.github.com> --- docs/TESTING.md | 25 ++++ patches/cocotron.patch | 14 +- tests/test_save_panel_tags.py | 169 +++++++++++++++++++++++ tools/build-riot-save-panel-tag-check.py | 24 ++++ tools/riot-save-panel-tag-check.m | 74 ++++++++++ 5 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 tests/test_save_panel_tags.py create mode 100755 tools/build-riot-save-panel-tag-check.py create mode 100644 tools/riot-save-panel-tag-check.m diff --git a/docs/TESTING.md b/docs/TESTING.md index ca9bd88..63cdd2a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -222,6 +222,31 @@ and prints `OPENPANEL_CHECK mode=interactive result=RETURNED code=... url=...` once it closes. As with the other probes, check the printed line rather than only the launcher exit status. +### NSSavePanel Finder tags (`setTagNames:` / `tagNames`) + +`python3 tools/build-riot-save-panel-tag-check.py` builds +`build/riot-save-panel-tag-check`. Install the patched AppKit first, with clients +and the game closed. The probe checks selector availability, inheritance, and +safe no-op semantics without launching any client or creating window fixtures: + +```sh +P=/Volumes/SystemRoot/path/to/darling-lol/build/riot-save-panel-tag-check +E="env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks" +darling shell $E $P +``` + +Expected output: + +``` +SAVE_PANEL_TAG class=NSSavePanel selectors=1 inherited=1 values=1 pass=1 +SAVE_PANEL_TAG class=NSOpenPanel selectors=1 inherited=1 values=1 pass=1 +SAVE_PANEL_TAG_CHECK pass=1 +``` + +Cocotron does not persist Finder tag metadata or provide a tag UI. `setTagNames:` +safely ignores input and `tagNames` returns an empty `NSArray` to prevent +unrecognized-selector crashes from callers (such as Electron file dialogs). + ### Presentation publisher handoff With clients closed, run `build/riot-client-resize-check --handoff` through Darling with the normal runtime overrides. This creates an owned window and two fixture frame slots. The live software owner first supplies a 1280×720 main frame, then switches to a 262×131 popup; another slot supplies the resized main frame without the software flag. The test samples its own window beyond the original frame extent and checks generation colors across growth and shrinkage. The old selector reports black at the 1600×900 sample; the correction passes all four sizes. Repeat with `RCLOL_XSHM=0` and with `DARLING_XSHM_LIBRARY` pointing to the normal transport to cover both paths. The fixtures remove their own frame slots on exit; they do not read client pixels or send input. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index cc6452b..ce31983 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -11408,7 +11408,7 @@ diff --git a/AppKit/NSSavePanel.subproj/NSSavePanel.m b/AppKit/NSSavePanel.subpr index 50c3d59e..be5a8463 100644 --- a/AppKit/NSSavePanel.subproj/NSSavePanel.m +++ b/AppKit/NSSavePanel.subproj/NSSavePanel.m -@@ -113,19 +113,108 @@ +@@ -113,19 +113,116 @@ } } @@ -11510,6 +11510,14 @@ index 50c3d59e..be5a8463 100644 + return NO; +} + ++- (void) setTagNames: (NSArray *) names { ++ (void)names; ++} ++ ++- (NSArray *) tagNames { ++ return [NSArray array]; ++} ++ +- (void) setExtensionHidden: (BOOL) flag { +} + @@ -11580,7 +11588,7 @@ diff --git a/AppKit/include/AppKit/NSSavePanel.h b/AppKit/include/AppKit/NSSaveP index 53bacc46..3c6adfb7 100644 --- a/AppKit/include/AppKit/NSSavePanel.h +++ b/AppKit/include/AppKit/NSSavePanel.h -@@ -88,6 +88,18 @@ enum { +@@ -88,6 +88,20 @@ enum { - (void) setPrompt: (NSString *) message; - (NSString *) prompt; @@ -11588,6 +11596,8 @@ index 53bacc46..3c6adfb7 100644 +- (NSString *) nameFieldLabel; +- (void) setShowsTagField: (BOOL) flag; +- (BOOL) showsTagField; ++- (void) setTagNames: (NSArray *) names; ++- (NSArray *) tagNames; +- (void) setExtensionHidden: (BOOL) flag; +- (BOOL) isExtensionHidden; +- (void) setCanSelectHiddenExtension: (BOOL) flag; diff --git a/tests/test_save_panel_tags.py b/tests/test_save_panel_tags.py new file mode 100644 index 0000000..3b16347 --- /dev/null +++ b/tests/test_save_panel_tags.py @@ -0,0 +1,169 @@ +"""Offline contracts for NSSavePanel's unsupported Finder-tag compatibility.""" +import json +from pathlib import Path +import re +import runpy +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +IMPLEMENTATION = 'AppKit/NSSavePanel.subproj/NSSavePanel.m' +HEADER = 'AppKit/include/AppKit/NSSavePanel.h' +PROBE_SOURCE = 'tools/riot-save-panel-tag-check.m' +BUILD_HELPER = 'tools/build-riot-save-panel-tag-check.py' + + +def added_source(path): + marker = f'diff --git a/{path} b/{path}\n' + section = (ROOT / 'patches/cocotron.patch').read_text().split(marker, 1)[1] + section = section.split('\ndiff --git ', 1)[0] + return '\n'.join(line[1:] for line in section.splitlines() + if line.startswith('+') and not line.startswith('+++')) + + +def extract_patch(path): + marker = f'diff --git a/{path} b/{path}\n' + patch_text = (ROOT / 'patches/cocotron.patch').read_text() + if marker not in patch_text: + raise AssertionError(f'Missing patch section for {path}') + section = patch_text.split(marker, 1)[1].split('\ndiff --git ', 1)[0] + return marker + section + '\n' + + +def method(source, signature): + match = re.search(re.escape(signature) + r'\s*\{([^}]+)\}', source) + if match is None: + raise AssertionError(f'Missing implementation: {signature}') + return match.group(1) + + +class SavePanelTagTests(unittest.TestCase): + def test_tag_names_setter_accepts_unsupported_metadata_without_storing_it(self): + body = method(added_source(IMPLEMENTATION), '- (void) setTagNames: (NSArray *) names') + self.assertIn('(void)names;', body) + self.assertNotIn('retain', body) + self.assertNotIn('copy', body) + + def test_tag_names_getter_returns_an_empty_array_not_nil(self): + body = method(added_source(IMPLEMENTATION), '- (NSArray *) tagNames') + self.assertIn('return [NSArray array];', body) + + def test_public_header_declares_both_tag_name_methods(self): + header = added_source(HEADER) + self.assertIn('- (void) setTagNames: (NSArray *) names;', header) + self.assertIn('- (NSArray *) tagNames;', header) + + def test_existing_tag_field_and_modal_fixes_are_retained(self): + source = added_source(IMPLEMENTATION) + self.assertIn('- (void) setShowsTagField: (BOOL) flag', source) + self.assertIn('- (BOOL) showsTagField', source) + self.assertIn('- (void) _dismissWithCode: (NSModalResponse) code', source) + self.assertNotIn('setShowsTags:', source) + + def test_patch_applies_cleanly_to_pinned_upstream_cocotron(self): + if not shutil.which('patch'): + self.skipTest('patch executable required') + manifest = json.loads((ROOT / 'upstream/manifest.json').read_text()) + component = next(c for c in manifest['components'] if c['patch'] == 'patches/cocotron.patch') + repo = ROOT / 'build/darling-src' / component['path'] + if not (repo / '.git').exists(): + self.skipTest('prepared Darling sources required') + + revision = component['revision'] + for relpath in [IMPLEMENTATION, HEADER]: + filename = Path(relpath).name + original = subprocess.check_output(['git', '-C', str(repo), 'show', f'{revision}:{relpath}']) + with tempfile.TemporaryDirectory() as td: + work_file = Path(td) / filename + work_file.write_bytes(original) + patch_file = Path(td) / f'{filename}.patch' + patch_file.write_text(extract_patch(relpath)) + res = subprocess.run( + ['patch', '--dry-run', '--fuzz=0', str(work_file), str(patch_file)], + capture_output=True, + text=True, + ) + self.assertEqual(res.returncode, 0, f'Patch failed to apply to {relpath}: {res.stderr}\n{res.stdout}') + + def test_extracted_method_bodies_compile_with_clang(self): + clang = shutil.which('clang') + if not clang: + self.skipTest('clang not available') + impl = added_source(IMPLEMENTATION) + setter_body = method(impl, '- (void) setTagNames: (NSArray *) names') + getter_body = method(impl, '- (NSArray *) tagNames') + snippet = f''' +@interface NSObject ++ (instancetype)alloc; +- (instancetype)init; +@end + +@interface NSArray : NSObject ++ (instancetype)array; +@end + +@interface NSSavePanel : NSObject +- (void) setTagNames: (NSArray *) names; +- (NSArray *) tagNames; +@end + +@implementation NSSavePanel +- (void) setTagNames: (NSArray *) names {{{setter_body}}} +- (NSArray *) tagNames {{{getter_body}}} +@end +''' + with tempfile.NamedTemporaryFile(suffix='.m', mode='w') as f: + f.write(snippet) + f.flush() + res = subprocess.run( + [clang, '-Qunused-arguments', '-x', 'objective-c', '-fsyntax-only', '-Werror', f.name], + capture_output=True, + text=True, + ) + self.assertEqual(res.returncode, 0, f'Clang syntax check failed: {res.stderr}') + + def test_builder_compiles_and_links_probe_without_embedding_shim(self): + source = ROOT / BUILD_HELPER + self.assertTrue(source.exists(), f'Missing build helper: {BUILD_HELPER}') + with tempfile.TemporaryDirectory() as temporary: + project = Path(temporary) + driver = project / 'tools' / source.name + driver.parent.mkdir() + driver.write_text(source.read_text()) + framework = project / 'build/darling-src/build/src/external/cocotron/AppKit' + cmake = framework / 'CMakeFiles/X11_backend.dir' + cmake.mkdir(parents=True) + (cmake / 'flags.make').write_text('C_DEFINES = -DTEST\nC_INCLUDES = -Itest\nC_FLAGS = -O0\n') + calls = [] + with patch('subprocess.run', side_effect=lambda argv, **kw: calls.append((argv, kw))): + runpy.run_path(str(driver), run_name='__main__') + probe_name = 'riot-save-panel-tag-check' + compiles = [argv for argv, _ in calls if str(project / f'tools/{probe_name}.m') in argv] + self.assertEqual(len(compiles), 1, 'probe must be compiled') + self.assertEqual(compiles[0][compiles[0].index('-o') + 1], str(project / f'build/{probe_name}.o')) + links = [argv for argv, _ in calls if str(project / f'build/{probe_name}') in argv] + self.assertEqual(len(links), 1, 'probe must be linked') + self.assertIn(str(project / f'build/{probe_name}.o'), links[0]) + self.assertIn('AppKit', links[0]) + for argv, _ in calls: + for arg in argv: + self.assertNotIn('compat', arg) + + def test_probe_declares_only_and_implements_no_shim(self): + source = (ROOT / PROBE_SOURCE).read_text() + self.assertIn('@interface NSSavePanel (RiotTagProbeDeclarations)', source) + self.assertIn('- (void)setTagNames:(NSArray *)names;', source) + self.assertIn('- (NSArray *)tagNames;', source) + self.assertIn('- (void)setShowsTagField:(BOOL)value;', source) + self.assertIn('- (BOOL)showsTagField;', source) + self.assertNotIn('@implementation NSSavePanel', source, 'probe must not contain compatibility shim') + self.assertIn('checkPanel([NSSavePanel class])', source) + self.assertIn('checkPanel([NSOpenPanel class])', source) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/build-riot-save-panel-tag-check.py b/tools/build-riot-save-panel-tag-check.py new file mode 100755 index 0000000..cc1c4d8 --- /dev/null +++ b/tools/build-riot-save-panel-tag-check.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Build the standalone NSSavePanel Finder-tag compatibility check.""" +from pathlib import Path +import json +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +folder = root / 'build/darling-src/build/src/external/cocotron/AppKit' +config = root / 'build/config.json' +sdk = json.loads(config.read_text())['sdk'] if config.exists() else '/usr/local/libexec/darling' +flags = {} +for line in (folder / 'CMakeFiles/X11_backend.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +obj = root / 'build/riot-save-panel-tag-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-fblocks', '-UNDEBUG', '-Wno-macro-redefined', '-c', str(root / 'tools/riot-save-panel-tag-check.m'), + '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', sdk, str(obj), '-lSystem', '-lobjc', + '-framework', 'AppKit', '-framework', 'CoreFoundation', '-framework', 'Foundation', + '-o', str(root / 'build/riot-save-panel-tag-check')], check=True) diff --git a/tools/riot-save-panel-tag-check.m b/tools/riot-save-panel-tag-check.m new file mode 100644 index 0000000..ba313db --- /dev/null +++ b/tools/riot-save-panel-tag-check.m @@ -0,0 +1,74 @@ +#import +#import +#import +#import +#import +#include + +/* Declarations only: implementations must come from the loaded AppKit, not + * from linking a compatibility category directly into this probe. */ +@interface NSSavePanel (RiotTagProbeDeclarations) +- (void)setShowsTagField:(BOOL)value; +- (BOOL)showsTagField; +- (void)setTagNames:(NSArray *)names; +- (NSArray *)tagNames; +@end + +static BOOL emptyTags(NSSavePanel *panel) { + NSArray *tags = [panel tagNames]; + return tags != nil && [tags isKindOfClass:[NSArray class]] && [tags count] == 0; +} + +static BOOL checkPanel(Class panelClass) { + SEL selectors[] = {@selector(setShowsTagField:), @selector(showsTagField), + @selector(setTagNames:), @selector(tagNames)}; + BOOL available = YES, inherited = YES, values = NO; + /* This stateless probe must work without panel initialization. Bare runtime + * allocation avoids NSWindow/nib/display setup; it does NOT test the chooser. + * Dispose without running NSWindow's deallocator on an uninitialized panel. */ + NSSavePanel *panel = (NSSavePanel *)class_createInstance(panelClass, 0); + for (unsigned i = 0; i < sizeof(selectors) / sizeof(selectors[0]); ++i) { + Method base = class_getInstanceMethod([NSSavePanel class], selectors[i]); + Method method = class_getInstanceMethod(panelClass, selectors[i]); + available &= panel != nil && method != NULL && [panel respondsToSelector:selectors[i]]; + inherited &= base != NULL && method != NULL && + method_getImplementation(base) == method_getImplementation(method); + } + if (panelClass == [NSOpenPanel class]) + inherited &= class_getSuperclass(panelClass) == [NSSavePanel class]; + if (available) { + @try { + values = ![panel showsTagField] && emptyTags(panel); + [panel setShowsTagField:YES]; + values &= ![panel showsTagField]; + [panel setShowsTagField:NO]; + values &= ![panel showsTagField]; + NSMutableArray *names = [NSMutableArray arrayWithObject:@"probe-tag"]; + [panel setTagNames:names]; + [names addObject:@"second-tag"]; + values &= emptyTags(panel); + [panel setTagNames:[NSArray array]]; + values &= emptyTags(panel); + [panel setTagNames:nil]; + values &= emptyTags(panel); + } @catch (NSException *exception) { + fprintf(stderr, "SAVE_PANEL_TAG exception=%s\n", [[exception name] UTF8String]); + values = NO; + } + } + if (panel != nil) + object_dispose(panel); + BOOL pass = available && inherited && values; + printf("SAVE_PANEL_TAG class=%s selectors=%d inherited=%d values=%d pass=%d\n", + class_getName(panelClass), available, inherited, values, pass); + return pass; +} + +int main(void) { + @autoreleasepool { + BOOL save = checkPanel([NSSavePanel class]); + BOOL open = checkPanel([NSOpenPanel class]); + printf("SAVE_PANEL_TAG_CHECK pass=%d\n", save && open); + return save && open ? 0 : 1; + } +} From ec85cfb96bc67f3914cfb94e2cd8c4ad87057b48 Mon Sep 17 00:00:00 2001 From: nb <40745505+nicolai455@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:02:29 +0200 Subject: [PATCH 136/160] fix: preserve ambiguous guest processes during prefix cleanup (#111) * fix: preserve ambiguous guest processes during prefix cleanup * fix: warn only for matching runtime cleanup candidates --------- Co-authored-by: lucannez64 <58551020+lucannez64@users.noreply.github.com> --- docs/TROUBLESHOOTING.md | 2 +- tests/test_runtime_cleanup_scope.py | 151 ++++++++++++++++++++ tools/ensure-darling-runtime.py | 57 ++++---- tools/riot-runtime-cleanup-offline-check.py | 7 +- 4 files changed, 187 insertions(+), 30 deletions(-) create mode 100644 tests/test_runtime_cleanup_scope.py diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 15ffb7b..13a2f7b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -376,7 +376,7 @@ It prints each thread-local access that calls its thunk without the descriptor i ## 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. +`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. Automatic cleanup by process name is disabled because guest names and UIDs do not prove prefix ownership (killing by name risks terminating an active launchd from an unrelated prefix or session). If setup is blocked, shut down the original Darling session from its own namespace (`darling shutdown`), or inspect the leftover PID under `/proc/` before terminating it manually. ## Lower fight FPS than Windows diff --git a/tests/test_runtime_cleanup_scope.py b/tests/test_runtime_cleanup_scope.py new file mode 100644 index 0000000..afc76f8 --- /dev/null +++ b/tests/test_runtime_cleanup_scope.py @@ -0,0 +1,151 @@ +"""Cleanup scope regressions: fake proc/prefix trees and mocked signals only.""" +import contextlib +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import MagicMock, Mock, patch + + +SCRIPT = Path(__file__).resolve().parents[1] / 'tools/ensure-darling-runtime.py' + + +class RuntimeCleanupScopeTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix='cleanup-scope-') + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + self.prefix = self.root / 'selected prefix' + self.other = self.root / 'unrelated prefix' + self.shm = self.root / 'shm' + for folder in (self.prefix / 'private/tmp', self.other / 'private/tmp', self.shm): + folder.mkdir(parents=True) + # Guard signals before import; no real process may ever be signalled. + self.kill = self.enterContext(patch('os.kill')) + spec = importlib.util.spec_from_file_location('cleanup_scope', SCRIPT) + assert spec is not None and spec.loader is not None + self.cleanup = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.cleanup) + self.stderr = self.enterContext(contextlib.redirect_stderr(io.StringIO())) + self.enterContext(contextlib.redirect_stdout(io.StringIO())) + self.enterContext(patch.dict(os.environ, {'DPREFIX': str(self.prefix)})) + self.enterContext(patch.object(self.cleanup, 'Path', side_effect=self.fake_path)) + # Its default Path('/proc') is bound at definition time, before patching Path. + running = self.cleanup.any_server_running + self.enterContext(patch.object(self.cleanup, 'any_server_running', + side_effect=lambda proc_root=None: running( + self.proc if proc_root is None else proc_root))) + + def fake_path(self, value): + # Never inspect host /proc or remove host shared-memory files. + return {'/proc': self.proc, '/dev/shm': self.shm}.get(str(value), Path(value)) + + def process(self, pid, *, comm='launchd', argv=('/sbin/launchd',), state='S'): + folder = self.proc / str(pid) + folder.mkdir() + (folder / 'comm').write_text(comm + '\n') + (folder / 'cmdline').write_bytes(b'\0'.join(arg.encode() for arg in argv) + b'\0') + (folder / 'status').write_text(f'State:\t{state} (fixture)\n') + return folder + + def test_direct_cleanup_entry_points_preserve_ambiguous_processes(self): + self.process(101) + self.process(102, comm='chrome_crashpad', argv=( + '/Applications/Riot Client.app/Contents/Frameworks/chrome_crashpad_handler',)) + self.cleanup.stop_orphaned_launchd(self.proc) + self.cleanup.stop_riot_crashpad(self.proc) + self.kill.assert_not_called() + self.assertIn('prefix ownership', self.stderr.getvalue()) + self.assertIn('launchd candidate PIDs 101', self.stderr.getvalue()) + self.assertIn('Riot crashpad candidate PIDs 102', self.stderr.getvalue()) + + def test_clean_start_is_silent(self): + self.cleanup.main() + self.assertEqual(self.stderr.getvalue(), '') + self.kill.assert_not_called() + + def test_unrelated_processes_are_silent(self): + self.process(110, comm='chrome_crashpad', argv=( + '/opt/google/chrome/chrome_crashpad_handler', '--annotation=riot')) + self.process(111, comm='unrelated', argv=('/usr/bin/tool', '/sbin/launchd')) + self.process(112, comm='unrelated', argv=('/usr/bin/tool', + '/Applications/Riot Client.app/chrome_crashpad_handler')) + self.cleanup.main() + self.assertEqual(self.stderr.getvalue(), '') + self.kill.assert_not_called() + + def test_foreign_owned_candidates_are_silent(self): + self.process(113) + self.process(114, comm='chrome_crashpad', argv=( + '/Applications/Riot Client.app/chrome_crashpad_handler',)) + with patch.object(Path, 'stat', return_value=Mock(st_uid=os.getuid() + 1)): + self.cleanup.stop_orphaned_launchd(self.proc) + self.cleanup.stop_riot_crashpad(self.proc) + self.assertEqual(self.stderr.getvalue(), '') + self.kill.assert_not_called() + + def test_main_preserves_processes_from_another_prefix_without_a_server(self): + self.process(101) + self.process(102, comm='chrome_crashpad', argv=( + str(self.other / 'Applications/Riot Client.app/crashpad_handler'),)) + selected = self.prefix / 'private/tmp/.com.riotgames.selected' + selected.write_text('stale') + unrelated = self.other / 'private/tmp/.com.riotgames.other' + unrelated.write_text('preserve') + shared = self.shm / 'darling-buffer-fixture' + shared.write_text('stale') + self.cleanup.main() + self.kill.assert_not_called() + self.assertFalse(selected.exists()) + self.assertFalse(shared.exists()) + self.assertEqual(unrelated.read_text(), 'preserve') + + def test_any_live_server_preserves_locks_and_shared_buffers(self): + self.process(103, comm='darlingserver', argv=('darlingserver',)) + lock = self.prefix / 'private/tmp/.com.riotgames.lock' + lock.write_text('active') + shared = self.shm / 'darling-buffer-fixture' + shared.write_text('active') + self.cleanup.main() + self.kill.assert_not_called() + self.assertTrue(lock.exists()) + self.assertTrue(shared.exists()) + + def test_unreadable_process_list_fails_closed(self): + hidden = MagicMock() + hidden.name = '104' + (hidden / 'comm').read_text.side_effect = PermissionError('hidden') + proc = Mock() + proc.iterdir.return_value = [hidden] + self.assertTrue(self.cleanup.any_server_running(proc)) + self.kill.assert_not_called() + + def test_dead_marker_is_recovered_only_in_the_selected_prefix(self): + self.process(105, state='Z') + marker = self.prefix / '.init.pid' + marker.write_text('105') + other = self.other / '.init.pid' + other.write_text('105') + self.cleanup.main() + self.assertFalse(marker.exists()) + recovered = list(self.prefix.glob('.init.pid.stale-105-*')) + self.assertEqual(len(recovered), 1) + self.assertEqual(recovered[0].read_text(), '105') + self.assertEqual(other.read_text(), '105') + self.kill.assert_not_called() + + def test_live_marker_is_not_replaced(self): + self.process(106) + marker = self.prefix / '.init.pid' + marker.write_text('106') + self.cleanup.main() + self.assertEqual(marker.read_text(), '106') + self.kill.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/ensure-darling-runtime.py b/tools/ensure-darling-runtime.py index 5e131b4..391c13b 100644 --- a/tools/ensure-darling-runtime.py +++ b/tools/ensure-darling-runtime.py @@ -2,7 +2,7 @@ """Clear a dead Darling server's stale PID marker, and stale shared buffers once no server runs.""" import os from pathlib import Path -import signal +import sys import time def recover_marker(prefix): marker = prefix / '.init.pid' @@ -38,39 +38,46 @@ def any_server_running(proc_root=Path('/proc')): pass return False -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.""" +def _matching_pids(kind, proc_root): + """Find same-user candidates by guest executable; this does not establish ownership.""" 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': + if proc.stat().st_uid != uid: continue - os.kill(int(proc.name), signal.SIGKILL) - print(f'Stopped orphaned Darling launchd PID {proc.name}', flush=True) + argv = (proc / 'cmdline').read_bytes() + if kind == 'launchd': + matches = argv.rstrip(b'\0') == b'/sbin/launchd' + else: + executable = argv.split(b'\0', 1)[0].decode(errors='replace') + matches = (proc / 'comm').read_text().startswith('chrome_crashpad') and any( + bundle in executable for bundle in ( + '/Riot Client.app/', '/League of Legends.app/', + '/League of Legends (TM) Client.app/')) + if matches: + yield proc.name except (OSError, ValueError): - pass + continue + + +def stop_orphaned_launchd(proc_root=Path('/proc')): + """Warn about launchd candidates without assuming prefix ownership or signalling them.""" + pids = list(_matching_pids('launchd', proc_root)) + if pids: + print(f'Preserving launchd candidate PIDs {", ".join(pids)}: prefix ownership ' + 'cannot be proved. If setup is blocked, shut down the original Darling ' + 'session or inspect the leftover processes manually.', file=sys.stderr, flush=True) + 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 + """Warn about Riot crashpad candidates without assuming prefix ownership or signalling them.""" + pids = list(_matching_pids('crashpad', proc_root)) + if pids: + print(f'Preserving Riot crashpad candidate PIDs {", ".join(pids)}: prefix ownership ' + 'cannot be proved. Use the original session to shut down leftover helpers.', + file=sys.stderr, flush=True) def remove_owned(paths): uid = os.getuid() diff --git a/tools/riot-runtime-cleanup-offline-check.py b/tools/riot-runtime-cleanup-offline-check.py index f9bc9fa..5d5eac4 100644 --- a/tools/riot-runtime-cleanup-offline-check.py +++ b/tools/riot-runtime-cleanup-offline-check.py @@ -25,10 +25,9 @@ with tempfile.TemporaryDirectory(prefix='darling-cleanup-check-') as folder: 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() + kill.assert_not_called() cleanup.stop_orphaned_launchd(root) - assert kill.call_args_list == [((103, cleanup.signal.SIGKILL),)] + kill.assert_not_called() assert not cleanup.any_server_running(root) process(105, 'darlingserver', ['darlingserver', '--prefix=another-prefix']) assert cleanup.any_server_running(root) @@ -58,4 +57,4 @@ with tempfile.TemporaryDirectory(prefix='darling-cleanup-check-') as folder: 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') +print('RUNTIME_CLEANUP_OFFLINE pass=1 browser_preserved=1 other_prefix_preserved=1 foreign_owner_preserved=1 hidden_process_preserved=1 orphan_preserved=1') From 6a96869db860991fdf83cffddcc9ec0564ce7a7b Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Tue, 20 Oct 2026 13:31:02 +0200 Subject: [PATCH 137/160] fix(nix): harden fontconfig compatibility across host setups --- docs/NIX.md | 6 +++ flake.nix | 34 +++++++++++--- tests/test_nix_fonts.py | 95 +++++++++++++++++++++++++++++++++++++++ tests/test_nix_repairs.py | 2 +- 4 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 tests/test_nix_fonts.py diff --git a/docs/NIX.md b/docs/NIX.md index c831f34..05910c8 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -215,6 +215,12 @@ the host's `/usr/share/fonts`, which the FHS environment replaces. Fonts in your user font directories remain available. This also prevents an icon-only user font inventory from becoming the only fallback for ordinary text. +Inherited `FONTCONFIG_PATH` and `FONTCONFIG_SYSROOT` overrides are cleared inside +the FHS environment so foreign include paths or an invalid sysroot cannot hide +the packaged fonts. Custom XDG font/configuration directories and legacy +`~/.fonts` remain supported, including paths with spaces. The host configuration +and user font files are not modified. + To check font availability without starting Darling or Riot: ```sh diff --git a/flake.nix b/flake.nix index de2c1af..f071941 100644 --- a/flake.nix +++ b/flake.nix @@ -17,7 +17,14 @@ darlingFonts = [ pkgs.liberation_ttf pkgs.dejavu_fonts ]; darlingFontsConf = pkgs.makeFontsConf { fontDirectories = darlingFonts; - includes = [ "${pkgs.fontconfig.out}/etc/fonts/conf.d" ]; + includes = [ + "${pkgs.fontconfig.out}/etc/fonts/conf.d" + (pkgs.writeText "darling-user-fonts.conf" '' + + ~/.fonts + + '') + ]; }; # Python environment with dependencies required by tooling @@ -163,8 +170,8 @@ export DPREFIX="''${DPREFIX:-$HOME/.darling}" export LC_ALL=C.UTF-8 export LANG=C.UTF-8 + unset FONTCONFIG_PATH FONTCONFIG_SYSROOT export FONTCONFIG_FILE=${darlingFontsConf} - export FONTCONFIG_PATH=${pkgs.fontconfig.out}/etc/fonts export DARLING_RUNNER_DIR=${darlingRunner}/bin export PATH="$DARLING_RUNNER_DIR:/usr/local/bin:''${PATH:-}" export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/pkgconfig:/usr/lib64/pkgconfig:/usr/share/pkgconfig:''${PKG_CONFIG_PATH:-}" @@ -267,10 +274,10 @@ DESKTOP_EOF fonts = pkgs.runCommand "darling-lol-font-tests" { nativeBuildInputs = [ pkgs.fontconfig ]; } '' - export HOME="$TMPDIR/home" XDG_CONFIG_HOME="$TMPDIR/config" - export XDG_DATA_HOME="$TMPDIR/data" XDG_CACHE_HOME="$TMPDIR/cache" + export HOME="$TMPDIR/home with spaces" XDG_CONFIG_HOME="$TMPDIR/config with spaces" + export XDG_DATA_HOME="$TMPDIR/data with spaces" XDG_CACHE_HOME="$TMPDIR/cache with spaces" + unset FONTCONFIG_PATH FONTCONFIG_SYSROOT export FONTCONFIG_FILE=${darlingFontsConf} - export FONTCONFIG_PATH=${pkgs.fontconfig.out}/etc/fonts mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" # Check real font selection with no host or user text fonts available. check_font() { @@ -294,6 +301,23 @@ DESKTOP_EOF cp -R ${pkgs.font-awesome}/share/fonts "$XDG_DATA_HOME/fonts" fc-list --format '%{file}\n' | grep -F "$XDG_DATA_HOME/fonts/" check_aliases + + # Retain custom XDG and legacy fonts as well as user alias rules. + chmod u+w "$XDG_DATA_HOME/fonts" + mkdir -p "$HOME/.fonts" "$XDG_CONFIG_HOME/fontconfig" + cp ${pkgs.liberation_ttf}/share/fonts/truetype/LiberationSans-Regular.ttf "$XDG_DATA_HOME/fonts/user-text.ttf" + cp ${pkgs.liberation_ttf}/share/fonts/truetype/LiberationSerif-Regular.ttf "$HOME/.fonts/legacy-text.ttf" + fc-list --format '%{file}\n' | grep -Fx "$XDG_DATA_HOME/fonts/user-text.ttf" + fc-list --format '%{file}\n' | grep -Fx "$HOME/.fonts/legacy-text.ttf" + cat > "$XDG_CONFIG_HOME/fontconfig/fonts.conf" <<'USERCONF_EOF' + + + DarlingCompatibilityAlias + Liberation Serif + + + USERCONF_EOF + check_font DarlingCompatibilityAlias 'Liberation Serif' touch "$out" ''; diff --git a/tests/test_nix_fonts.py b/tests/test_nix_fonts.py new file mode 100644 index 0000000..0f084f1 --- /dev/null +++ b/tests/test_nix_fonts.py @@ -0,0 +1,95 @@ +import json +import os +import re +import subprocess +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +def extract_fhs_profile(flake_text: str) -> str: + match = re.search(r"profile\s*=\s*''(.*?)'';", flake_text, re.DOTALL) + if not match: + raise ValueError("Could not find profile in flake.nix") + raw_profile = match.group(1) + # Nix multi-line strings: (?~/.fonts", flake_text) + + # FHS profile unsets foreign/sysroot fontconfig vars before exporting package FONTCONFIG_FILE + self.assertIn("unset FONTCONFIG_PATH FONTCONFIG_SYSROOT", flake_text) + self.assertIn("export FONTCONFIG_FILE=${darlingFontsConf}", flake_text) + + unset_pos = flake_text.index("unset FONTCONFIG_PATH FONTCONFIG_SYSROOT") + export_pos = flake_text.index("export FONTCONFIG_FILE=${darlingFontsConf}") + self.assertLess(unset_pos, export_pos, "unset must happen before export FONTCONFIG_FILE") + + def test_fhs_profile_hostile_environment_sanitization(self): + flake_text = (REPO_ROOT / "flake.nix").read_text(encoding="utf-8") + profile_script = extract_fhs_profile(flake_text) + + hostile_envs = [ + { + "FONTCONFIG_SYSROOT": "/not/a/real/host/sysroot", + "FONTCONFIG_PATH": "/foreign/host/etc/fonts", + }, + { + "FONTCONFIG_SYSROOT": "", + "FONTCONFIG_PATH": "/foreign/host/etc/fonts", + }, + ] + + for hostile_font_env in hostile_envs: + with self.subTest(hostile_font_env=hostile_font_env): + test_env = dict(hostile_font_env) + test_env.update({ + "HOME": "/tmp/mock-home", + "XDG_DATA_HOME": "/tmp/mock-xdg data with spaces", + "XDG_CONFIG_HOME": "/tmp/mock-xdg config with spaces", + "UNRELATED_USER_VAR": "preserve_this_value", + "PATH": os.environ.get("PATH", "/bin:/usr/bin"), + }) + + dump_cmd = ( + f"{profile_script}\n" + f'"{sys.executable}" -c \'import os, json; print(json.dumps(dict(os.environ)))\'' + ) + + proc = subprocess.run( + ["bash", "-c", dump_cmd], + env=test_env, + capture_output=True, + text=True, + check=True, + ) + + resulting_env = json.loads(proc.stdout) + + self.assertNotIn("FONTCONFIG_SYSROOT", resulting_env) + self.assertNotIn("FONTCONFIG_PATH", resulting_env) + self.assertTrue( + resulting_env.get("FONTCONFIG_FILE", "").startswith("/nix/store/"), + f"Unexpected FONTCONFIG_FILE: {resulting_env.get('FONTCONFIG_FILE')}", + ) + self.assertEqual(resulting_env.get("XDG_DATA_HOME"), "/tmp/mock-xdg data with spaces") + self.assertEqual(resulting_env.get("XDG_CONFIG_HOME"), "/tmp/mock-xdg config with spaces") + self.assertEqual(resulting_env.get("UNRELATED_USER_VAR"), "preserve_this_value") + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index df02a09..6db36e9 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -453,7 +453,7 @@ class SessionTests(unittest.TestCase): self.assertIn('terminal', result.stderr.lower()) self.assertFalse((root / 'build').exists()) self.assertIn('Terminal=true', (ROOT / 'flake.nix').read_text()) - self.assertIn('checks.${system}.packaging', (ROOT / 'flake.nix').read_text()) + self.assertIn('checks.${system}', (ROOT / 'flake.nix').read_text()) def test_rootless_update_refuses_live_marker_without_build_or_stop(self): with tempfile.TemporaryDirectory(prefix='nix-update-idle-') as directory: From 699b96ffc8f34287b58a543512ee803f58244c7a Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Tue, 20 Oct 2026 13:43:22 +0200 Subject: [PATCH 138/160] docs(tests): document fontconfig regression helpers --- tests/test_nix_fonts.py | 5 +++++ tests/test_nix_repairs.py | 1 + 2 files changed, 6 insertions(+) diff --git a/tests/test_nix_fonts.py b/tests/test_nix_fonts.py index 0f084f1..ac0e5ae 100644 --- a/tests/test_nix_fonts.py +++ b/tests/test_nix_fonts.py @@ -9,6 +9,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent def extract_fhs_profile(flake_text: str) -> str: + """Extract the FHS shell profile, substituting inert paths for Nix values.""" match = re.search(r"profile\s*=\s*''(.*?)'';", flake_text, re.DOTALL) if not match: raise ValueError("Could not find profile in flake.nix") @@ -19,7 +20,10 @@ def extract_fhs_profile(flake_text: str) -> str: return profile_script class NixFontsConfigTest(unittest.TestCase): + """Check packaged font configuration and isolation from host overrides.""" + def test_flake_font_configuration(self): + """Require bundled text fonts, user font support, and ordered sanitization.""" flake_text = (REPO_ROOT / "flake.nix").read_text(encoding="utf-8") # Flake must bundle both font families in darlingFontsConf @@ -41,6 +45,7 @@ class NixFontsConfigTest(unittest.TestCase): self.assertLess(unset_pos, export_pos, "unset must happen before export FONTCONFIG_FILE") def test_fhs_profile_hostile_environment_sanitization(self): + """Clear hostile fontconfig overrides while preserving user XDG settings.""" flake_text = (REPO_ROOT / "flake.nix").read_text(encoding="utf-8") profile_script = extract_fhs_profile(flake_text) diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index 6db36e9..09ebb58 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -440,6 +440,7 @@ class SessionTests(unittest.TestCase): self.assertFalse((root / 'other-prefix').exists()) def test_first_run_eof_is_actionable_and_desktop_requests_terminal(self): + """Report missing terminal input without setup and package a terminal launcher.""" with tempfile.TemporaryDirectory(prefix='nix-session-test-') as directory: root = Path(directory) (root / 'scripts').mkdir() From bdbf1e9a37da44d9c52abc3b99b4062ff9e99d1c Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:57:05 +0200 Subject: [PATCH 139/160] test(nix): run font compatibility regressions in CI --- .github/workflows/source-check.yml | 2 ++ docs/NIX.md | 7 ++++--- flake.nix | 2 ++ tests/test_nix_fonts.py | 6 +++--- tests/test_nix_repairs.py | 3 ++- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index c068e01..3e91493 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -21,6 +21,8 @@ jobs: - run: python tools/riot-launcher-offline-check.py - run: python tools/riot-runtime-cleanup-offline-check.py - run: python tools/riot-cdn-dns-offline-check.py + - run: python -m unittest discover -s tests -p 'test_nix_fonts.py' + - run: python -m unittest discover -s tests -p 'test_nix_repairs.py' - run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' - run: python -m unittest discover -s tests -p 'test_desktop_run.py' - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' diff --git a/docs/NIX.md b/docs/NIX.md index 05910c8..2cde500 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -215,9 +215,10 @@ the host's `/usr/share/fonts`, which the FHS environment replaces. Fonts in your user font directories remain available. This also prevents an icon-only user font inventory from becoming the only fallback for ordinary text. -Inherited `FONTCONFIG_PATH` and `FONTCONFIG_SYSROOT` overrides are cleared inside -the FHS environment so foreign include paths or an invalid sysroot cannot hide -the packaged fonts. Custom XDG font/configuration directories and legacy +The FHS environment selects the packaged fontconfig configuration and clears +inherited `FONTCONFIG_PATH` and `FONTCONFIG_SYSROOT` overrides. Clearing the +sysroot prevents it from redirecting the packaged configuration and font paths. +Custom XDG font/configuration directories and legacy `~/.fonts` remain supported, including paths with spaces. The host configuration and user font files are not modified. diff --git a/flake.nix b/flake.nix index f071941..ef8a7b5 100644 --- a/flake.nix +++ b/flake.nix @@ -19,6 +19,8 @@ fontDirectories = darlingFonts; includes = [ "${pkgs.fontconfig.out}/etc/fonts/conf.d" + # makeFontsConf adds XDG font directories, but neither it nor + # conf.d/50-user.conf adds the legacy ~/.fonts directory. (pkgs.writeText "darling-user-fonts.conf" '' ~/.fonts diff --git a/tests/test_nix_fonts.py b/tests/test_nix_fonts.py index ac0e5ae..4562268 100644 --- a/tests/test_nix_fonts.py +++ b/tests/test_nix_fonts.py @@ -88,9 +88,9 @@ class NixFontsConfigTest(unittest.TestCase): self.assertNotIn("FONTCONFIG_SYSROOT", resulting_env) self.assertNotIn("FONTCONFIG_PATH", resulting_env) - self.assertTrue( - resulting_env.get("FONTCONFIG_FILE", "").startswith("/nix/store/"), - f"Unexpected FONTCONFIG_FILE: {resulting_env.get('FONTCONFIG_FILE')}", + self.assertEqual( + resulting_env.get("FONTCONFIG_FILE"), + "/nix/store/mock-darlingFontsConf", ) self.assertEqual(resulting_env.get("XDG_DATA_HOME"), "/tmp/mock-xdg data with spaces") self.assertEqual(resulting_env.get("XDG_CONFIG_HOME"), "/tmp/mock-xdg config with spaces") diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index 09ebb58..49e232f 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -454,7 +454,8 @@ class SessionTests(unittest.TestCase): self.assertIn('terminal', result.stderr.lower()) self.assertFalse((root / 'build').exists()) self.assertIn('Terminal=true', (ROOT / 'flake.nix').read_text()) - self.assertIn('checks.${system}', (ROOT / 'flake.nix').read_text()) + self.assertIn('packaging = pkgs.runCommand "darling-lol-packaging-tests"', + (ROOT / 'flake.nix').read_text()) def test_rootless_update_refuses_live_marker_without_build_or_stop(self): with tempfile.TemporaryDirectory(prefix='nix-update-idle-') as directory: From 97c78c2679718cc1b35eff39c8ab537f686828df Mon Sep 17 00:00:00 2001 From: ekisu Date: Mon, 19 Oct 2026 19:19:55 -0300 Subject: [PATCH 140/160] perf(indium): cache buffer address keys in CPU memory --- patches/metal--deps--indium.patch | 49 ++++++- tests/fixtures/indium-address-shadow.cpp | 175 +++++++++++++++++++++++ tests/test_indium_address_shadow.py | 50 +++++++ 3 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/indium-address-shadow.cpp create mode 100644 tests/test_indium_address_shadow.py diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index f3c149f..f6c73e5 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -289,7 +289,7 @@ index b7ffcce..5b1e84c 100644 }; }; diff --git a/private-include/indium/command-encoder.private.hpp b/private-include/indium/command-encoder.private.hpp -index c3b1b93..3dab2ce 100644 +index c3b1b93..6b8783d 100644 --- a/private-include/indium/command-encoder.private.hpp +++ b/private-include/indium/command-encoder.private.hpp @@ -1,6 +1,7 @@ @@ -300,7 +300,7 @@ index c3b1b93..3dab2ce 100644 #include #include #include -@@ -17,117 +18,295 @@ +@@ -17,117 +18,330 @@ #include @@ -529,6 +529,40 @@ index c3b1b93..3dab2ce 100644 + struct AddressPool { + std::shared_ptr chunk; + size_t used = 0; ++ ++ // Two shader stages are live in the render encoder. Cache their small ++ // immutable tables in CPU memory, not in the GPU-mapped scratch chunk. ++ // This is only a read cache: misses retain the original byte comparison. ++ struct Shadow { ++ bool valid = false; ++ uint32_t offset; ++ VkDeviceSize range; ++ size_t count; ++ std::array addresses; ++ }; ++ std::array shadows; ++ size_t nextShadow = 0; ++ ++ bool sameAddresses(uint32_t offset, VkDeviceSize range, const uint64_t* addresses, size_t count) const { ++ for (const auto& shadow : shadows) { ++ // A table's last eight bytes can be padding. If a new layout ++ // reads that padding, only the mapped bytes define the old key. ++ if (shadow.valid && shadow.offset == offset && shadow.range == range && count <= shadow.count) ++ return !memcmp(shadow.addresses.data(), addresses, count * sizeof(uint64_t)); ++ } ++ return !memcmp(static_cast(chunk->contents()) + offset, addresses, count * sizeof(uint64_t)); ++ } ++ ++ void rememberAddresses(uint32_t offset, VkDeviceSize range, const uint64_t* addresses, size_t count) { ++ if (count > 64) return; ++ auto& shadow = shadows[nextShadow]; ++ nextShadow = (nextShadow + 1) % shadows.size(); ++ shadow.offset = offset; ++ shadow.range = range; ++ shadow.count = count; ++ memcpy(shadow.addresses.data(), addresses, count * sizeof(uint64_t)); ++ shadow.valid = true; ++ } + }; - if (functionResources.buffers.size() > 0) { @@ -583,17 +617,18 @@ index c3b1b93..3dab2ce 100644 + if (bindingReuseEnabled() && previous && addressPool.chunk && previous->range == tableSize && + previous->offset <= chunkSize && tableSize <= chunkSize - previous->offset && + previous->buffer == static_cast(addressPool.chunk.get())->buffer() && -+ !memcmp(static_cast(addressPool.chunk->contents()) + previous->offset, -+ addresses.data(), addresses.size() * sizeof(uint64_t))) ++ addressPool.sameAddresses(previous->offset, previous->range, addresses.data(), addresses.size())) + return *previous; + size_t tableOffset = (addressPool.used + alignment - 1) & ~(alignment - 1); + if (!addressPool.chunk || tableOffset + tableSize > chunkSize) { + addressPool.chunk = privateDevice->acquireScratchChunk(); ++ for (auto& shadow : addressPool.shadows) shadow.valid = false; + // we need to keep the chunk alive until the operation is completed + keepAliveBuffers.push_back(addressPool.chunk); + tableOffset = 0; + } + memcpy(static_cast(addressPool.chunk->contents()) + tableOffset, addresses.data(), addresses.size() * sizeof(uint64_t)); ++ if (previous) addressPool.rememberAddresses(static_cast(tableOffset), tableSize, addresses.data(), addresses.size()); + addressPool.used = tableOffset + tableSize; + // plain pointer: a shared_ptr cast here was an atomic reference count round trip per stage per draw + auto* privateAddressBuffer = static_cast(addressPool.chunk.get()); @@ -654,7 +689,7 @@ index c3b1b93..3dab2ce 100644 descSet.descriptorCount = 1; descSet.pBufferInfo = &info; } -@@ -136,17 +315,27 @@ namespace Indium { +@@ -136,17 +350,27 @@ namespace Indium { auto& bindingInfo = funcInfo.bindings[j]; if (bindingInfo.type == Iridium::BindingType::Texture) { @@ -691,7 +726,7 @@ index c3b1b93..3dab2ce 100644 auto& descSet = writeDescSet.emplace_back(); descSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; descSet.dstSet = descriptorSets[i]; -@@ -156,19 +345,21 @@ namespace Indium { +@@ -156,19 +380,21 @@ namespace Indium { descSet.descriptorCount = 1; descSet.pImageInfo = &info; } else if (bindingInfo.type == Iridium::BindingType::Sampler) { @@ -724,7 +759,7 @@ index c3b1b93..3dab2ce 100644 info.sampler = privateSampler->sampler(); auto& descSet = writeDescSet.emplace_back(); -@@ -188,10 +379,4 @@ namespace Indium { +@@ -188,10 +414,4 @@ namespace Indium { return descriptorSets; }; diff --git a/tests/fixtures/indium-address-shadow.cpp b/tests/fixtures/indium-address-shadow.cpp new file mode 100644 index 0000000..4d10ebd --- /dev/null +++ b/tests/fixtures/indium-address-shadow.cpp @@ -0,0 +1,175 @@ +// CPU-only buffer/Vulkan shims; the scratch, pool and table helper are extracted +// from the authoritative component patch, not copied into this fixture. +#include +#include +#include +#include +#include +#include +#include +#include +#include +static size_t allocationCalls = 0; +void* operator new(size_t size) { + ++allocationCalls; + if (auto* p = std::malloc(size ? size : 1)) return p; + throw std::bad_alloc(); +} +void operator delete(void* p) noexcept { std::free(p); } +void operator delete(void* p, size_t) noexcept { std::free(p); } +using VkBuffer = uint64_t; +using VkDeviceSize = uint64_t; +constexpr VkBuffer VK_NULL_HANDLE = 0; +namespace Iridium { enum class BindingType { Buffer, Texture, Sampler }; } +static bool enabled = true; +bool bindingReuseEnabled() { return enabled; } +void logUnboundResource(const char*, size_t, size_t, size_t) {} +struct Buffer { virtual ~Buffer() = default; virtual void* contents() = 0; }; +struct PrivateBuffer : Buffer { + uint64_t id, address; + size_t reads = 0; + std::vector bytes; + PrivateBuffer(uint64_t id, uint64_t address) : id(id), address(address), bytes(256*1024, 0xa5) {} + void* contents() override { ++reads; return bytes.data(); } + VkBuffer buffer() const { return id; } + uint64_t gpuAddress() const { return address; } +}; +struct PrivateDevice { + size_t chunks = 0; + uint64_t fallback = 0xf00d; + uint64_t fallbackBufferAddress() { return fallback; } + std::shared_ptr acquireScratchChunk() { ++chunks; return std::make_shared(chunks, chunks << 20); } +}; +struct FunctionResources { std::vector, size_t>> buffers; }; +struct Resolved { PrivateBuffer* value = nullptr; }; +struct ResolvedFunctionResources { std::vector buffers; }; +struct Binding { Iridium::BindingType type; size_t index; }; +struct FunctionInfo { std::vector bindings; }; + +/* ACTUAL_PATCHED_HELPER */ + +struct Check { + AddressPool pool; + std::shared_ptr device = std::make_shared(); + std::vector> keep; + std::array tables{}; + Check() { keep.reserve(8); } + PrivateBuffer* mapped() { return static_cast(pool.chunk.get()); } + void run(size_t stage, const FunctionResources& resources, const FunctionInfo& info, + bool reuse, const ResolvedFunctionResources* resolved = nullptr, bool previous = true) { + auto old = tables[stage]; + auto used = pool.used, chunks = device->chunks, calls = allocationCalls; + auto table = makeBufferAddressTable(resources, info, resolved, device, keep, pool, + previous ? &tables[stage] : nullptr); + // Only the existing DescriptorScratch overflow and new scratch chunk + // may allocate (make_shared control/object plus the chunk byte vector). + assert(allocationCalls - calls == (info.bindings.size() > 64 ? 1u : 0u) + + (device->chunks - chunks) * 2); + assert((table.buffer == old.buffer && table.offset == old.offset && pool.used == used) == reuse); + assert(table.range == ((std::count_if(info.bindings.begin(), info.bindings.end(), + [](const Binding& b) { return b.type == Iridium::BindingType::Buffer; }) * 8 + 15) & ~size_t(15))); + assert(keep.size() == device->chunks); + size_t index = 0; + for (const auto& binding : info.bindings) { + if (binding.type != Iridium::BindingType::Buffer) continue; + PrivateBuffer* buffer = nullptr; + if (resolved && binding.index < resolved->buffers.size()) buffer = resolved->buffers[binding.index].value; + else if (binding.index < resources.buffers.size()) buffer = dynamic_cast(resources.buffers[binding.index].first.get()); + uint64_t expected = buffer ? buffer->gpuAddress() + resources.buffers[binding.index].second : device->fallback; + uint64_t actual; + memcpy(&actual, mapped()->bytes.data() + table.offset + index++ * 8, 8); + assert(actual == expected); + } + tables[stage] = table; + } +}; +FunctionInfo info(size_t count, size_t start = 0) { + FunctionInfo result; + for (size_t i = 0; i < count; ++i) result.bindings.push_back({Iridium::BindingType::Buffer, start+i}); + return result; +} +int main() { + // Two 64-key entries: +1080 bytes per pool on the tested 64-bit ABI. + static_assert(sizeof(AddressPool) == sizeof(std::shared_ptr) + sizeof(size_t) + 1080); + FunctionResources r; + auto resource = std::make_shared(100, 0x1000); + for (size_t i = 0; i < 150; ++i) r.buffers.push_back({resource, i*16}); + Check p; + auto f = info(3); + p.run(0, r, f, false); + auto accesses = p.mapped()->reads; + p.run(0, r, f, true); assert(p.mapped()->reads == accesses); + r.buffers[100].second++; p.run(0, r, f, true); + auto equivalent = std::make_shared(101, 0x0ff0); + r.buffers[0] = {equivalent, 16}; p.run(0, r, f, true); + r.buffers[1].second++; p.run(0, r, f, false); + auto mixed = f; + mixed.bindings.push_back({Iridium::BindingType::Texture, 999}); + mixed.bindings.push_back({Iridium::BindingType::Sampler, 999}); + p.run(0, r, mixed, true); + p.run(1, r, info(4, 20), false); + accesses = p.mapped()->reads; + p.run(0, r, f, true); p.run(1, r, info(4, 20), true); + assert(p.mapped()->reads == accesses); // both interleaved stages hit CPU keys + for (size_t i = 0; i < 5; ++i) { + r.buffers[20].second++; p.run(1, r, info(4, 20), false); + } + accesses = p.mapped()->reads; + p.run(0, r, f, true); + assert(p.mapped()->reads == accesses + 1); // eviction retains mapped fallback + + Check padding; + padding.run(0, r, info(1), false); + r.buffers[1] = {resource, uint64_t(0xa5a5a5a5a5a5a5a5ULL - resource->gpuAddress())}; + accesses = padding.mapped()->reads; + padding.run(0, r, info(2), true); // odd -> even reads real padding + assert(padding.mapped()->reads == accesses + 1); + r.buffers[1].second++; padding.run(0, r, info(2), false); + accesses = padding.mapped()->reads; + padding.run(0, r, info(1), true); // even -> odd needs only cached prefix + assert(padding.mapped()->reads == accesses); + padding.run(0, r, info(3), false); + + Check large; + for (size_t count : {size_t(64), size_t(65), size_t(150), size_t(32767), size_t(32768)}) { + large.run(0, r, info(count), false); + large.run(0, r, info(count), true); + } + r.buffers[64].second++; large.run(0, r, info(65), false); + large.run(1, r, info(8), false, nullptr, false); + large.run(1, r, info(8), true); + + Check missing; + FunctionResources empty; + missing.run(0, empty, info(2, 999), false); + missing.run(0, empty, info(2, 999), true); + missing.device->fallback++; missing.run(0, empty, info(2, 999), false); + ResolvedFunctionResources resolved; + resolved.buffers.resize(r.buffers.size()); resolved.buffers[0].value = resource.get(); + missing.run(1, r, info(1), false, &resolved); + missing.run(1, r, info(1), true, &resolved); + resolved.buffers[0].value = nullptr; + missing.run(1, r, info(1), false, &resolved); + + Check roll; + roll.run(0, r, info(2), false); roll.run(1, r, info(2, 3), false); + auto oldChunk = roll.pool.chunk; + roll.pool.used = 256*1024; + r.buffers[0].second++; roll.run(0, r, info(2), false); + assert(roll.pool.chunk != oldChunk && roll.keep.size() == 2); + // Old offset 256 must not supply a stale key for untouched new-chunk bytes. + roll.tables[1] = {roll.tables[0].buffer, 256, 16}; + roll.run(1, r, info(2, 3), false); roll.run(1, r, info(2, 3), true); + enabled = false; roll.run(1, r, info(2, 3), false); enabled = true; + + Check guard; + guard.run(0, r, info(0), false); guard.run(0, r, info(0), true); + auto id = guard.tables[0].buffer; + guard.tables[0] = {id, 256*1024, 0}; guard.run(0, r, info(0), true); + guard.tables[0] = {id, 256*1024+1, 0}; guard.run(0, r, info(0), false); + accesses = guard.mapped()->reads; + guard.tables[0] = {id, 256*1024-8, 16}; guard.run(0, r, info(1), false); + assert(guard.mapped()->reads == accesses + 1); // write only; no invalid read + guard.tables[0].buffer += 100; guard.run(0, r, info(1), false); + guard.tables[0].range = 32; guard.run(0, r, info(1), false); +} diff --git a/tests/test_indium_address_shadow.py b/tests/test_indium_address_shadow.py new file mode 100644 index 0000000..1a8e886 --- /dev/null +++ b/tests/test_indium_address_shadow.py @@ -0,0 +1,50 @@ +"""Compile the shipped address-table helper against an inert CPU-only fixture.""" +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +HEADER = 'private-include/indium/command-encoder.private.hpp' + + +def address_helper(): + patch = (ROOT / 'patches/metal--deps--indium.patch').read_text() + marker = f'diff --git a/{HEADER} b/{HEADER}\n' + section = patch.split(marker, 1)[1].split('\ndiff --git ', 1)[0] + # Extract each postimage hunk separately: never silently join a source gap. + hunks = re.split(r'(?m)^@@ [^\n]*\n', section)[1:] + postimages = [''.join(line[1:] for line in hunk.splitlines(keepends=True) + if line.startswith((' ', '+'))) for hunk in hunks] + + def region(start, end): + matches = [text[text.index(start):text.index(end, text.index(start))] + for text in postimages if start in text and end in text] + if len(matches) != 1: + raise AssertionError('expected one complete patched region: ' + start) + return matches[0] + + return (region(' template class DescriptorScratch', + ' // Per-encoder binding tables') + + region('\tstruct AddressPool {', '\n\ttemplate')) + + +@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required') +class AddressShadowChecks(unittest.TestCase): + def test_interleaving_fallback_padding_rollover_large_keys_and_allocations(self): + fixture = (ROOT / 'tests/fixtures/indium-address-shadow.cpp').read_text() + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) + source = path / 'check.cpp' + source.write_text(fixture.replace('/* ACTUAL_PATCHED_HELPER */', address_helper())) + build = subprocess.run(['c++', '-std=c++17', '-O1', str(source), '-o', str(path/'check')], + capture_output=True, text=True) + self.assertEqual(build.returncode, 0, build.stderr) + result = subprocess.run([str(path/'check')], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == '__main__': + unittest.main() From 634803a30a28c53c8e4d02045acfcecbf26c8a27 Mon Sep 17 00:00:00 2001 From: ekisu Date: Tue, 20 Oct 2026 00:34:25 -0300 Subject: [PATCH 141/160] test(indium): run accepted address shadow regression in CI --- .github/workflows/source-check.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 3e91493..d8c96b5 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -16,6 +16,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' + - name: Require C++ regression compiler + run: | + command -v c++ + c++ --version - run: python scripts/validate.py - run: python tools/riot-input-offline-check.py - run: python tools/riot-launcher-offline-check.py @@ -28,5 +32,6 @@ jobs: - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' - run: python -m unittest discover -s tests -p 'test_system_profiler.py' - run: python -m unittest discover -s tests -p 'test_metal_device_info.py' + - run: python -m unittest discover -s tests -p 'test_indium_address_shadow.py' -v - run: python -m unittest discover -s tests -p 'test_exec_guest.py' - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' From 6dec8e176e076c10dcc2fe8dfb220268504f8535 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Tue, 20 Oct 2026 23:49:39 -0600 Subject: [PATCH 142/160] launcher: use a neutral Darling Launcher name and icon Use a project-owned identity for the visible desktop entry while keeping the existing binary, package and window-matching identifiers working. The helper now copies the project's original icon instead of extracting one from the client, and the old game-named entry and icons it created are removed. Extend the packaging check to assert the neutral fields. --- flake.nix | 42 ++++--- scripts/create-desktop-entry.sh | 2 +- tools/desktop-entry.py | 121 +++++++++++++++++++ tools/riot-desktop-entry.py | 202 -------------------------------- 4 files changed, 146 insertions(+), 221 deletions(-) create mode 100755 tools/desktop-entry.py delete mode 100755 tools/riot-desktop-entry.py diff --git a/flake.nix b/flake.nix index ef8a7b5..cc37e30 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Unofficial Darling League of Legends - experimental macOS LoL runner on Linux via Darling"; + description = "Unofficial Darling launcher for the macOS game client on Linux"; inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; @@ -136,7 +136,7 @@ pythonEnv ]; - # FHS environment for building and running Darling and League of Legends. + # FHS environment for building and running Darling and the macOS game client. # Darling requires root/multitasking mount & namespace semantics, /usr/local layout, # and dynamically compiled overlays. An FHS environment provides the standard standard # filesystem hierarchies (/usr/include, /usr/lib, /usr/bin) and native dynamic linker. @@ -223,10 +223,10 @@ mkdir -p $out/bin $out/share/darling-lol $out/share/icons/hicolor/scalable/apps $out/share/icons/hicolor/128x128/apps $out/share/pixmaps $out/share/applications cp -r * $out/share/darling-lol/ - cp darling-lol.svg $out/share/icons/hicolor/scalable/apps/darling-lol.svg - cp darling-lol.png $out/share/icons/hicolor/128x128/apps/darling-lol.png - cp darling-lol.png $out/share/pixmaps/darling-lol.png - cp darling-lol.svg $out/share/pixmaps/darling-lol.svg + cp darling-lol.svg $out/share/icons/hicolor/scalable/apps/darling-launcher.svg + cp darling-lol.png $out/share/icons/hicolor/128x128/apps/darling-launcher.png + cp darling-lol.png $out/share/pixmaps/darling-launcher.png + cp darling-lol.svg $out/share/pixmaps/darling-launcher.svg # Install configured launcher substitute ./darling-lol-launcher.sh $out/bin/darling-lol \ @@ -235,6 +235,7 @@ --replace-fail "@UNSHARE_BIN@" "${pkgs.util-linux}/bin/unshare" chmod +x $out/bin/darling-lol wrapProgram $out/bin/darling-lol --prefix PATH : ${pkgs.lib.makeBinPath [ pythonEnv pkgs.coreutils pkgs.util-linux pkgs.gawk ]} + ln -s darling-lol $out/bin/darling-launcher makeWrapper $out/bin/darling-lol $out/bin/darling-lol-installer \ --add-flags "--installer" @@ -245,15 +246,15 @@ makeWrapper $out/bin/darling-lol $out/bin/darling-lol-build \ --add-flags "./scripts/build.sh" - cat << 'DESKTOP_EOF' > $out/share/applications/darling-lol.desktop + cat << 'DESKTOP_EOF' > $out/share/applications/darling-launcher.desktop [Desktop Entry] Categories=Game; -Comment=Unofficial League of Legends macOS client compatibility layer for Linux via Darling -Exec=darling-lol -GenericName=MOBA Game -Icon=darling-lol -Keywords=League;LoL;Riot;Darling; -Name=League of Legends (unofficial) +Comment=Unofficial launcher for the macOS game client via Darling +Exec=darling-launcher +GenericName=Game +Icon=darling-launcher +Keywords=Darling;Launcher;Compatibility; +Name=Darling Launcher StartupWMClass=RiotClientServices Terminal=true Type=Application @@ -264,7 +265,7 @@ DESKTOP_EOF ''; meta = with pkgs.lib; { - description = "Unofficial tooling and patches to run macOS League of Legends on Linux with Darling"; + description = "Unofficial tooling and patches to run the macOS game client on Linux with Darling"; homepage = "https://github.com/lucannez64/darling-lol"; license = licenses.gpl3Only; platforms = [ "x86_64-linux" ]; @@ -334,7 +335,12 @@ DESKTOP_EOF # Tests use private stub binaries/repositories, never Darling itself. python3 -m unittest discover -s tests -v python3 scripts/validate.py - grep -q '^Terminal=true$' ${darlingLol}/share/applications/darling-lol.desktop + grep -q '^Terminal=true$' ${darlingLol}/share/applications/darling-launcher.desktop + grep -q '^Name=Darling Launcher$' ${darlingLol}/share/applications/darling-launcher.desktop + grep -q '^Icon=darling-launcher$' ${darlingLol}/share/applications/darling-launcher.desktop + grep -q '^Keywords=Darling;Launcher;Compatibility;$' ${darlingLol}/share/applications/darling-launcher.desktop + ! grep -qiE '^(Name|Comment|GenericName|Keywords)=.*(League|LoL|Riot|Vanguard|anti-cheat)' ${darlingLol}/share/applications/darling-launcher.desktop + test -x ${darlingLol}/bin/darling-launcher touch "$out" ''; }; @@ -349,7 +355,7 @@ DESKTOP_EOF default = { type = "app"; program = "${darlingLol}/bin/darling-lol"; - meta.description = "Launch League of Legends via Darling"; + meta.description = "Launch the macOS game client via Darling"; }; shell = { type = "app"; @@ -359,7 +365,7 @@ DESKTOP_EOF installer = { type = "app"; program = "${darlingLol}/bin/darling-lol-installer"; - meta.description = "Run the Darling and League of Legends rootless installer"; + meta.description = "Run the Darling rootless installer"; }; build = { type = "app"; @@ -371,7 +377,7 @@ DESKTOP_EOF devShells.${system}.default = pkgs.mkShell { packages = buildDeps ++ [ darlingLolFhs ]; shellHook = '' - echo "Darling LoL build & development environment loaded." + echo "Darling build & development environment loaded." echo "Tip: Run 'darling-lol-fhs' to enter the FHS build sandbox if standard paths (/usr/include, /usr/lib) are needed." ''; }; diff --git a/scripts/create-desktop-entry.sh b/scripts/create-desktop-entry.sh index 81b8c22..dbdb7a2 100755 --- a/scripts/create-desktop-entry.sh +++ b/scripts/create-desktop-entry.sh @@ -4,4 +4,4 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" python3 scripts/configure.py >/dev/null -python3 build/runtime/tools/riot-desktop-entry.py +python3 build/runtime/tools/desktop-entry.py diff --git a/tools/desktop-entry.py b/tools/desktop-entry.py new file mode 100755 index 0000000..0aa6c76 --- /dev/null +++ b/tools/desktop-entry.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Create and install the GNOME / FreeDesktop desktop entry and launcher icon.""" +import os +import shutil +import subprocess +from pathlib import Path + +PROJECT_ROOT = Path("@PROJECT_ROOT@") +HOST_HOME = Path("@HOST_HOME@") + + +def remove_legacy_entry(path: Path) -> None: + """Remove a previous launcher entry only when this project created it.""" + try: + if path.exists() and "desktop-run.sh" in path.read_text(errors="ignore"): + path.unlink() + except OSError: + pass + + +def main(): + icons_base = HOST_HOME / ".local/share/icons" + apps_base = HOST_HOME / ".local/share/applications" + + scalable_dir = icons_base / "hicolor/scalable/apps" + png_dir = icons_base / "hicolor/128x128/apps" + scalable_dir.mkdir(parents=True, exist_ok=True) + png_dir.mkdir(parents=True, exist_ok=True) + apps_base.mkdir(parents=True, exist_ok=True) + + source_svg = PROJECT_ROOT / "darling-lol.svg" + source_png = PROJECT_ROOT / "darling-lol.png" + if not source_svg.is_file() or not source_png.is_file(): + raise SystemExit("launcher icon is missing: expected darling-lol.svg and darling-lol.png") + + # Install the project's original icon under a neutral name. Do not extract + # or install an icon from the game/client installation. + svg_target = scalable_dir / "darling-launcher.svg" + png_target = png_dir / "darling-launcher.png" + shutil.copyfile(source_svg, svg_target) + shutil.copyfile(source_png, png_target) + shutil.copyfile(source_svg, icons_base / "darling-launcher.svg") + shutil.copyfile(source_png, icons_base / "darling-launcher.png") + + # Remove artifacts created by older versions of this helper. + remove_legacy_entry(apps_base / "league-of-legends.desktop") + for legacy_icon in ( + icons_base / "league-of-legends.svg", + icons_base / "league-of-legends.png", + scalable_dir / "league-of-legends.svg", + icons_base / "hicolor/512x512/apps/league-of-legends.png", + ): + try: + if legacy_icon.exists(): + legacy_icon.unlink() + except OSError: + pass + + # Create the desktop entry. Keep StartupWMClass: it is the client's real + # window class and is required for the compositor to match the window. + run_script = PROJECT_ROOT / "scripts/desktop-run.sh" + exec_path = str(run_script).replace("%", "%%") + exec_cmd = f'"{exec_path}"' if " " in exec_path else exec_path + desktop_content = f"""[Desktop Entry] +Version=1.0 +Type=Application +Name=Darling Launcher +GenericName=Game +Comment=Unofficial launcher for the macOS game client via Darling +Exec={exec_cmd} +Path={PROJECT_ROOT} +Icon=darling-launcher +Terminal=false +Categories=Game;ActionGame;StrategyGame; +Keywords=darling;launcher;compatibility; +StartupWMClass=Riot Client +StartupNotify=false +""" + + desktop_file = apps_base / "darling-launcher.desktop" + desktop_file.write_text(desktop_content) + os.chmod(desktop_file, 0o755) + print(f"Created application launcher: {desktop_file}") + + target_desktop_dirs = [] + try: + res = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True, check=False) + if res.returncode == 0 and res.stdout.strip(): + d = Path(res.stdout.strip()) + if d.is_dir() and d not in target_desktop_dirs: + target_desktop_dirs.append(d) + except Exception: + pass + for cand in [HOST_HOME / "Desktop"]: + if cand.is_dir() and cand not in target_desktop_dirs: + target_desktop_dirs.append(cand) + + for ddir in target_desktop_dirs: + remove_legacy_entry(ddir / "league-of-legends.desktop") + desktop_shortcut = ddir / "darling-launcher.desktop" + desktop_shortcut.write_text(desktop_content) + os.chmod(desktop_shortcut, 0o755) + gio = shutil.which("gio") + if gio: + subprocess.run([gio, "set", str(desktop_shortcut), "metadata::trusted", "true"], check=False) + print(f"Created desktop shortcut: {desktop_shortcut}") + + # Refresh icon and desktop databases + update_desktop = shutil.which("update-desktop-database") + if update_desktop: + subprocess.run([update_desktop, str(apps_base)], check=False) + + update_icons = shutil.which("gtk-update-icon-cache") + if update_icons: + subprocess.run([update_icons, "-f", "-t", str(icons_base / "hicolor")], check=False) + + print("GNOME application launcher and icon installed successfully.") + + +if __name__ == "__main__": + main() diff --git a/tools/riot-desktop-entry.py b/tools/riot-desktop-entry.py deleted file mode 100755 index 049e94d..0000000 --- a/tools/riot-desktop-entry.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python3 -"""Create and install the GNOME / FreeDesktop desktop entry and launcher icon.""" -import os -import shutil -import subprocess -import sys -from pathlib import Path - -PROJECT_ROOT = Path("@PROJECT_ROOT@") -HOST_HOME = Path("@HOST_HOME@") -DPREFIX = Path("@DPREFIX@") - -SVG_ICON_CONTENT = """ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -""" - - -def extract_best_png_from_icns(icns_path: Path) -> bytes | None: - """Extract the highest-resolution embedded PNG from an Apple ICNS file.""" - try: - data = icns_path.read_bytes() - if not data.startswith(b"icns"): - return None - offset = 8 - best_png = None - best_len = 0 - while offset + 8 <= len(data): - size = int.from_bytes(data[offset + 4 : offset + 8], "big") - if size < 8 or offset + size > len(data): - break - chunk = data[offset + 8 : offset + size] - if chunk.startswith(b"\x89PNG\r\n\x1a\n") and len(chunk) > best_len: - best_png = chunk - best_len = len(chunk) - offset += size - return best_png - except Exception: - return None - - -def find_candidate_icns() -> Path | None: - """Find any available Riot or League .icns icon files.""" - candidates = [ - *list((PROJECT_ROOT / "build/installer").glob("*League of Legends*.app/Contents/Resources/*.icns")), - *list(DPREFIX.glob("Users/Shared/Riot Games/Riot Client.app/Contents/Resources/*.icns")), - *list(DPREFIX.glob("Applications/Riot Client.app/Contents/Resources/*.icns")), - *list(DPREFIX.glob("Applications/League of Legends.app/Contents/Resources/*.icns")), - ] - for c in candidates: - if c.is_file() and c.stat().st_size > 1024: - return c - return None - - -def main(): - icons_base = HOST_HOME / ".local/share/icons" - apps_base = HOST_HOME / ".local/share/applications" - desktop_dir = HOST_HOME / "Desktop" - - scalable_dir = icons_base / "hicolor/scalable/apps" - png_dir = icons_base / "hicolor/512x512/apps" - scalable_dir.mkdir(parents=True, exist_ok=True) - png_dir.mkdir(parents=True, exist_ok=True) - apps_base.mkdir(parents=True, exist_ok=True) - - # 1. Install scalable SVG icon - svg_target = scalable_dir / "league-of-legends.svg" - svg_target.write_text(SVG_ICON_CONTENT) - shutil.copyfile(svg_target, icons_base / "league-of-legends.svg") - - # 2. Extract or generate PNG icon - png_target = png_dir / "league-of-legends.png" - icns_file = find_candidate_icns() - extracted = False - if icns_file: - png_data = extract_best_png_from_icns(icns_file) - if png_data: - png_target.write_bytes(png_data) - shutil.copyfile(png_target, icons_base / "league-of-legends.png") - extracted = True - print(f"Extracted official icon from {icns_file.name} to {png_target}") - - if not extracted and not png_target.exists(): - # Try to rasterize SVG via rsvg-convert or magick - rsvg = shutil.which("rsvg-convert") - magick = shutil.which("magick") or shutil.which("convert") - if rsvg: - subprocess.run([rsvg, "-w", "512", "-h", "512", str(svg_target), "-o", str(png_target)], check=False) - if png_target.exists(): - shutil.copyfile(png_target, icons_base / "league-of-legends.png") - elif magick: - subprocess.run([magick, "-background", "none", "-resize", "512x512", str(svg_target), str(png_target)], check=False) - if png_target.exists(): - shutil.copyfile(png_target, icons_base / "league-of-legends.png") - - # 3. Create .desktop file - run_script = PROJECT_ROOT / "scripts/desktop-run.sh" - exec_path = str(run_script).replace("%", "%%") - exec_cmd = f'"{exec_path}"' if " " in exec_path else exec_path - desktop_content = f"""[Desktop Entry] -Version=1.0 -Type=Application -Name=League of Legends -GenericName=MOBA Game -Comment=Play League of Legends on Linux with darling-lol -Exec={exec_cmd} -Path={PROJECT_ROOT} -Icon=league-of-legends -Terminal=false -Categories=Game;ActionGame;StrategyGame; -Keywords=riot;league;lol;darling;games; -StartupWMClass=Riot Client -StartupNotify=false -""" - - desktop_file = apps_base / "league-of-legends.desktop" - desktop_file.write_text(desktop_content) - os.chmod(desktop_file, 0o755) - print(f"Created application launcher: {desktop_file}") - - target_desktop_dirs = [] - try: - res = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True, check=False) - if res.returncode == 0 and res.stdout.strip(): - d = Path(res.stdout.strip()) - if d.is_dir() and d not in target_desktop_dirs: - target_desktop_dirs.append(d) - except Exception: - pass - for cand in [HOST_HOME / "Desktop"]: - if cand.is_dir() and cand not in target_desktop_dirs: - target_desktop_dirs.append(cand) - - for ddir in target_desktop_dirs: - desktop_shortcut = ddir / "league-of-legends.desktop" - desktop_shortcut.write_text(desktop_content) - os.chmod(desktop_shortcut, 0o755) - gio = shutil.which("gio") - if gio: - subprocess.run([gio, "set", str(desktop_shortcut), "metadata::trusted", "true"], check=False) - print(f"Created desktop shortcut: {desktop_shortcut}") - - # 4. Refresh icon and desktop databases - update_desktop = shutil.which("update-desktop-database") - if update_desktop: - subprocess.run([update_desktop, str(apps_base)], check=False) - - update_icons = shutil.which("gtk-update-icon-cache") - if update_icons: - subprocess.run([update_icons, "-f", "-t", str(icons_base / "hicolor")], check=False) - - print("GNOME application launcher and icon installed successfully.") - - -if __name__ == "__main__": - main() From 4d0affe1fc49c0bd0d5bd6d18569009ab611c486 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:23:47 +0200 Subject: [PATCH 143/160] fix(launcher): only remove launcher artifacts this project created Match the legacy entry against this checkout's desktop-run.sh instead of any desktop-run.sh, and delete the legacy icon files only after that entry or the old artwork proves ownership. Quote and escape Exec arguments as the Desktop Entry specification requires, so a checkout path with reserved characters no longer produces an invalid entry, and escape the Path value. Use StartupWMClass=RiotClientServices in both entries: Darling derives WM_CLASS from -[NSProcessInfo processName], the basename of argv[0], and the launcher starts .../RiotClientServices. "Riot Client" is only the bundle display name, so the checkout entry never matched the window. Both entries now share Name/GenericName/Comment/Icon/Keywords/Categories and a test keeps them in sync; Terminal stays different because the checkout entry logs to build/desktop-launch.log while the packaged entry runs the session in a terminal. Also drop the stray top-level icon copies, handle dangling legacy symlinks, name the icon for assistive technology, document the alias and menu entry, and cover the helper with disposable-HOME tests. --- .github/workflows/source-check.yml | 1 + darling-lol.svg | 4 +- docs/NIX.md | 3 + docs/SETUP.md | 13 ++- docs/TESTING.md | 6 +- flake.nix | 4 + tests/test_desktop_entry.py | 157 ++++++++++++++++++++++++++++ tools/desktop-entry.py | 162 ++++++++++++++++++++--------- 8 files changed, 295 insertions(+), 55 deletions(-) create mode 100644 tests/test_desktop_entry.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 3e91493..a866b9d 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -25,6 +25,7 @@ jobs: - run: python -m unittest discover -s tests -p 'test_nix_repairs.py' - run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' - run: python -m unittest discover -s tests -p 'test_desktop_run.py' + - run: python -m unittest discover -s tests -p 'test_desktop_entry.py' - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' - run: python -m unittest discover -s tests -p 'test_system_profiler.py' - run: python -m unittest discover -s tests -p 'test_metal_device_info.py' diff --git a/darling-lol.svg b/darling-lol.svg index 7ec69b9..1916ca4 100644 --- a/darling-lol.svg +++ b/darling-lol.svg @@ -1,5 +1,5 @@ - - darling-lol + + Darling Launcher diff --git a/docs/NIX.md b/docs/NIX.md index 2cde500..0323f61 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -91,6 +91,9 @@ darling-lol --installer darling-lol-shell ``` +The application menu entry is `Darling Launcher`, and `darling-launcher` is an +alias for `darling-lol` for scripts that prefer the neutral name. + If the desktop entry does not appear, ensure your desktop session includes `~/.nix-profile/share` in `XDG_DATA_DIRS`, then log out and back in. Non-NixOS systems may need this desktop-session integration after installing Nix. diff --git a/docs/SETUP.md b/docs/SETUP.md index 4d85c6b..0c0d39b 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -61,10 +61,15 @@ The normal launcher starts neither an Electron inspector nor client API/UI helpe ## Application-menu diagnostics and recovery options -`scripts/create-desktop-entry.sh` creates an application-menu entry that runs -`scripts/desktop-run.sh`, preserving the normal launcher behavior and saving -stdout/stderr in `build/desktop-launch.log` with mode 0600. Logs can contain -private session details; do not attach raw launcher logs to a public issue. +`scripts/create-desktop-entry.sh` creates a `Darling Launcher` application-menu +entry that runs `scripts/desktop-run.sh`, preserving the normal launcher behavior +and saving stdout/stderr in `build/desktop-launch.log` with mode 0600. Logs can +contain private session details; do not attach raw launcher logs to a public +issue. The helper also removes the `league-of-legends.*` entry and icons written +by older versions, but only when it can prove this project created them. The +entry keeps `StartupWMClass=RiotClientServices`: Darling derives `WM_CLASS` from +the basename of `argv[0]`, so that is the window class of the client the entry +starts, while `Riot Client` is only its bundle display name. Region/locale remain the existing `RCLOL_REGION`/`RCLOL_LOCALE` options. `RCLOL_CDN_IPV4_ONLY` defaults to `1` in the Riot launcher and can be set to `0` diff --git a/docs/TESTING.md b/docs/TESTING.md index 63cdd2a..1e80d10 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -78,6 +78,7 @@ Host-only checks, which do not start Darling: python3 tools/riot-cdn-dns-offline-check.py python3 -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' python3 -m unittest discover -s tests -p 'test_desktop_run.py' +python3 -m unittest discover -s tests -p 'test_desktop_entry.py' python3 -m unittest discover -s tests -p 'test_urlsession_runner.py' ``` @@ -87,7 +88,10 @@ insensitivity, A queries, non-CDN names and misleading domain suffixes without sending a DNS query. Recovery tests use fake `/proc` entries and mock every compositor/shutdown command. The desktop test checks exit/argument forwarding, append-only output and tightening an existing public log to mode 0600. The -runner test uses a fake `darling` executable to verify loopback fixtures and +desktop-entry test renders the helper against a disposable `HOME` and checks +the neutral fields, Exec quoting, icon paths, that only launcher artifacts +this project created are removed, and that the checkout and packaged entries +share their identity fields. The runner test uses a fake `darling` executable to verify loopback fixtures and private-prefix-only shutdown/cleanup. With the pinned Darling sources available, run the host resolver regression: diff --git a/flake.nix b/flake.nix index cc37e30..322e2e9 100644 --- a/flake.nix +++ b/flake.nix @@ -339,6 +339,10 @@ DESKTOP_EOF grep -q '^Name=Darling Launcher$' ${darlingLol}/share/applications/darling-launcher.desktop grep -q '^Icon=darling-launcher$' ${darlingLol}/share/applications/darling-launcher.desktop grep -q '^Keywords=Darling;Launcher;Compatibility;$' ${darlingLol}/share/applications/darling-launcher.desktop + grep -q '^Categories=Game;$' ${darlingLol}/share/applications/darling-launcher.desktop + # Darling derives WM_CLASS from argv[0], so the window class is the + # client executable name; this is an interoperability identifier. + grep -q '^StartupWMClass=RiotClientServices$' ${darlingLol}/share/applications/darling-launcher.desktop ! grep -qiE '^(Name|Comment|GenericName|Keywords)=.*(League|LoL|Riot|Vanguard|anti-cheat)' ${darlingLol}/share/applications/darling-launcher.desktop test -x ${darlingLol}/bin/darling-launcher touch "$out" diff --git a/tests/test_desktop_entry.py b/tests/test_desktop_entry.py new file mode 100644 index 0000000..4806f3e --- /dev/null +++ b/tests/test_desktop_entry.py @@ -0,0 +1,157 @@ +"""Verify the desktop-entry helper against a disposable HOME.""" +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / 'tools/desktop-entry.py' +FLAKE = ROOT / 'flake.nix' +OLD_SVG = '' +IDENTITY_KEYS = ('Name', 'GenericName', 'Comment', 'Icon', 'Keywords', 'Categories', 'StartupWMClass') + + +def parse_entry(text): + entry = {} + for line in text.splitlines(): + if '=' in line and not line.startswith(('#', '[')): + key, value = line.split('=', 1) + entry[key] = value + return entry + + +class DesktopEntryTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='desktop entry ', dir=os.environ.get('TMPDIR')) + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.home = self.root / 'home' + self.home.mkdir() + + def project(self, name='project'): + project = self.root / name + project.mkdir(parents=True) + shutil.copy2(ROOT / 'darling-lol.svg', project / 'darling-lol.svg') + shutil.copy2(ROOT / 'darling-lol.png', project / 'darling-lol.png') + return project + + def install(self, project): + script = project / 'desktop-entry.py' + script.write_text(HELPER.read_text() + .replace('@PROJECT_ROOT@', str(project)) + .replace('@HOST_HOME@', str(self.home))) + return subprocess.run([sys.executable, str(script)], capture_output=True, text=True, + env=dict(os.environ, HOME=str(self.home)), check=False) + + def entry(self): + return self.home / '.local/share/applications/darling-launcher.desktop' + + def icons(self): + base = self.home / '.local/share/icons' + return base / 'hicolor/scalable/apps/darling-launcher.svg', base / 'hicolor/128x128/apps/darling-launcher.png' + + def test_installs_neutral_entry_and_icons(self): + project = self.project() + result = self.install(project) + self.assertEqual(result.returncode, 0, result.stderr) + text = self.entry().read_text() + self.assertIn('Name=Darling Launcher\n', text) + self.assertIn('Icon=darling-launcher\n', text) + self.assertIn(f'Path={project}\n', text) + for field in ('Name', 'Comment', 'GenericName', 'Keywords'): + line = next(line for line in text.splitlines() if line.startswith(field + '=')) + for brand in ('League', 'LoL', 'Riot', 'Vanguard', 'anti-cheat'): + self.assertNotIn(brand.lower(), line.lower()) + svg, png = self.icons() + self.assertTrue(svg.is_file()) + self.assertTrue(png.is_file()) + self.assertEqual(png.read_bytes(), (project / 'darling-lol.png').read_bytes()) + + def test_preserves_entries_and_icons_it_did_not_create(self): + project = self.project() + other = self.root / 'elsewhere' + (self.home / '.local/share/applications').mkdir(parents=True) + legacy_entry = self.home / '.local/share/applications/league-of-legends.desktop' + legacy_entry.write_text(f'[Desktop Entry]\nExec={other}/scripts/desktop-run.sh\n') + (self.home / 'Desktop').mkdir() + desktop_entry = self.home / 'Desktop/league-of-legends.desktop' + desktop_entry.write_text(f'[Desktop Entry]\nExec={other}/scripts/desktop-run.sh\n') + icons = self.home / '.local/share/icons' + icons.mkdir(parents=True) + (icons / 'league-of-legends.png').write_text('user icon') + result = self.install(project) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(legacy_entry.is_file()) + self.assertTrue(desktop_entry.is_file()) + self.assertEqual((icons / 'league-of-legends.png').read_text(), 'user icon') + self.assertTrue(self.entry().is_file()) + + def test_removes_legacy_artifacts_it_created(self): + project = self.project() + applications = self.home / '.local/share/applications' + applications.mkdir(parents=True) + owned = f'[Desktop Entry]\nExec="{project}/scripts/desktop-run.sh"\n' + legacy_entry = applications / 'league-of-legends.desktop' + legacy_entry.write_text(owned) + (self.home / 'Desktop').mkdir() + desktop_link = self.home / 'Desktop/league-of-legends.desktop' + desktop_link.symlink_to(project / 'scripts/desktop-run.sh') + icons = self.home / '.local/share/icons' + scalable = icons / 'hicolor/scalable/apps' + legacy_png_dir = icons / 'hicolor/512x512/apps' + scalable.mkdir(parents=True) + legacy_png_dir.mkdir(parents=True) + legacy_svg = scalable / 'league-of-legends.svg' + legacy_svg.write_text(OLD_SVG) + legacy_png = legacy_png_dir / 'league-of-legends.png' + legacy_png.write_bytes(b'old client icon') + result = self.install(project) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(legacy_entry.exists()) + self.assertFalse(desktop_link.is_symlink()) + self.assertFalse(legacy_svg.exists()) + self.assertFalse(legacy_png.exists()) + self.assertTrue(self.entry().is_file()) + + def test_quotes_exec_arguments_with_reserved_characters(self): + project = self.project('proj $dollar#hash\'quote') + result = self.install(project) + self.assertEqual(result.returncode, 0, result.stderr) + text = self.entry().read_text() + exec_line = next(line for line in text.splitlines() if line.startswith('Exec=')) + self.assertTrue(exec_line.startswith('Exec="'), exec_line) + self.assertTrue(exec_line.endswith('"'), exec_line) + self.assertIn('\\$dollar', exec_line) + if shutil.which('desktop-file-validate'): + check = subprocess.run(['desktop-file-validate', str(self.entry())], + capture_output=True, text=True, check=False) + self.assertEqual(check.returncode, 0, check.stdout + check.stderr) + + def test_identity_fields_match_the_packaged_entry(self): + project = self.project() + result = self.install(project) + self.assertEqual(result.returncode, 0, result.stderr) + rendered = parse_entry(self.entry().read_text()) + packaged_here = re.search( + r"cat << 'DESKTOP_EOF' > \$out/share/applications/darling-launcher\.desktop\n(.*?)\n\s*DESKTOP_EOF", + FLAKE.read_text(), re.S) + self.assertIsNotNone(packaged_here, 'packaged desktop entry not found in flake.nix') + packaged = parse_entry(packaged_here.group(1)) + for key in IDENTITY_KEYS: + self.assertIn(key, packaged) + self.assertEqual(rendered.get(key), packaged[key], key) + + def test_missing_icon_fails(self): + project = self.project() + (project / 'darling-lol.png').unlink() + result = self.install(project) + self.assertNotEqual(result.returncode, 0) + self.assertIn('launcher icon is missing', result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/desktop-entry.py b/tools/desktop-entry.py index 0aa6c76..e77d1a0 100755 --- a/tools/desktop-entry.py +++ b/tools/desktop-entry.py @@ -8,14 +8,84 @@ from pathlib import Path PROJECT_ROOT = Path("@PROJECT_ROOT@") HOST_HOME = Path("@HOST_HOME@") +# Marks a launcher entry created by this project. Previous versions pointed +# Exec at this checkout's desktop-run.sh, so an entry without that marker +# belongs to someone else and is left alone. +LEGACY_RUN_SCRIPT = "scripts/desktop-run.sh" -def remove_legacy_entry(path: Path) -> None: - """Remove a previous launcher entry only when this project created it.""" +# Distinctive fragments of the artwork the previous helper wrote. +LEGACY_SVG_MARKERS = ('id="goldGrad"', 'M172 108 L242 108') + +# Characters that force Exec quoting, per the Desktop Entry specification. +EXEC_RESERVED = set(' \t\n"\'\\><~|&;$*?#()`') + + +def escape_string(value: str) -> str: + """Escape a value of Desktop Entry type ``string``.""" + return (value.replace("\\", "\\\\").replace("\t", "\\t") + .replace("\n", "\\n").replace("\r", "\\r")) + + +def quote_exec_argument(value: str) -> str: + """Escape and quote one Exec argument as the specification requires.""" + escaped = value.replace("%", "%%") + if not any(char in EXEC_RESERVED for char in escaped): + return escaped + escaped = "".join("\\" + char if char in '"`$\\' else char for char in escaped) + return f'"{escaped}"' + + +def legacy_entry_owned(path: Path) -> bool: + """Return True when a launcher entry was created by this project.""" + marker = str(PROJECT_ROOT / LEGACY_RUN_SCRIPT) try: - if path.exists() and "desktop-run.sh" in path.read_text(errors="ignore"): - path.unlink() + text = path.read_text(errors="ignore") except OSError: + # A dangling symlink still counts when it points at this checkout. + try: + text = os.readlink(path) + except OSError: + return False + return marker in text or marker.replace("%", "%%") in text + + +def remove_legacy_entry(path: Path) -> bool: + """Remove a previous launcher entry only when this project created it.""" + if not (path.is_symlink() or path.exists()): + return False + if not legacy_entry_owned(path): + return False + try: + path.unlink() + except OSError: + return False + return True + + +def legacy_svg_owned(path: Path) -> bool: + """Return True when an icon is the artwork the previous helper wrote.""" + try: + text = path.read_text(errors="ignore") + except OSError: + return False + return all(marker in text for marker in LEGACY_SVG_MARKERS) + + +def shortcut_directories() -> list[Path]: + """Return the desktop directories that hold launcher shortcuts.""" + directories: list[Path] = [] + try: + result = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True, check=False) + if result.returncode == 0 and result.stdout.strip(): + path = Path(result.stdout.strip()) + if path.is_dir(): + directories.append(path) + except Exception: pass + for candidate in [HOST_HOME / "Desktop"]: + if candidate.is_dir() and candidate not in directories: + directories.append(candidate) + return directories def main(): @@ -35,32 +105,42 @@ def main(): # Install the project's original icon under a neutral name. Do not extract # or install an icon from the game/client installation. - svg_target = scalable_dir / "darling-launcher.svg" - png_target = png_dir / "darling-launcher.png" - shutil.copyfile(source_svg, svg_target) - shutil.copyfile(source_png, png_target) - shutil.copyfile(source_svg, icons_base / "darling-launcher.svg") - shutil.copyfile(source_png, icons_base / "darling-launcher.png") + shutil.copyfile(source_svg, scalable_dir / "darling-launcher.svg") + shutil.copyfile(source_png, png_dir / "darling-launcher.png") - # Remove artifacts created by older versions of this helper. - remove_legacy_entry(apps_base / "league-of-legends.desktop") - for legacy_icon in ( - icons_base / "league-of-legends.svg", - icons_base / "league-of-legends.png", - scalable_dir / "league-of-legends.svg", - icons_base / "hicolor/512x512/apps/league-of-legends.png", - ): - try: - if legacy_icon.exists(): - legacy_icon.unlink() - except OSError: - pass + # Remove artifacts created by older versions of this helper. Only files + # proven to be ours are deleted: an entry that pointed at this checkout's + # desktop-run.sh, and the SVG with the old artwork. The old PNG was + # extracted from the client and cannot be verified, so it is removed only + # when one of those proofs succeeded. + owned = remove_legacy_entry(apps_base / "league-of-legends.desktop") + desktop_dirs = shortcut_directories() + for directory in desktop_dirs: + owned = remove_legacy_entry(directory / "league-of-legends.desktop") or owned + legacy_svgs = (scalable_dir / "league-of-legends.svg", icons_base / "league-of-legends.svg") + if any(legacy_svg_owned(path) for path in legacy_svgs): + owned = True - # Create the desktop entry. Keep StartupWMClass: it is the client's real - # window class and is required for the compositor to match the window. - run_script = PROJECT_ROOT / "scripts/desktop-run.sh" - exec_path = str(run_script).replace("%", "%%") - exec_cmd = f'"{exec_path}"' if " " in exec_path else exec_path + if owned: + for legacy_icon in ( + *legacy_svgs, + icons_base / "league-of-legends.png", + icons_base / "hicolor/512x512/apps/league-of-legends.png", + ): + try: + legacy_icon.unlink(missing_ok=True) + except OSError: + pass + + # Create the desktop entry. Keep StartupWMClass: Darling's AppKit sets + # WM_CLASS from -[NSProcessInfo processName], which is the basename of + # argv[0], and the launcher starts .../RiotClientServices. It is an + # interoperability identifier, not a user-visible name. + # Terminal stays false here because this entry runs desktop-run.sh, which + # saves session output in build/desktop-launch.log; the packaged entry runs + # the session directly and therefore needs a terminal. + exec_cmd = quote_exec_argument(str(PROJECT_ROOT / LEGACY_RUN_SCRIPT)) + project_path = escape_string(str(PROJECT_ROOT)) desktop_content = f"""[Desktop Entry] Version=1.0 Type=Application @@ -68,12 +148,12 @@ Name=Darling Launcher GenericName=Game Comment=Unofficial launcher for the macOS game client via Darling Exec={exec_cmd} -Path={PROJECT_ROOT} +Path={project_path} Icon=darling-launcher Terminal=false -Categories=Game;ActionGame;StrategyGame; -Keywords=darling;launcher;compatibility; -StartupWMClass=Riot Client +Categories=Game; +Keywords=Darling;Launcher;Compatibility; +StartupWMClass=RiotClientServices StartupNotify=false """ @@ -82,22 +162,8 @@ StartupNotify=false os.chmod(desktop_file, 0o755) print(f"Created application launcher: {desktop_file}") - target_desktop_dirs = [] - try: - res = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True, check=False) - if res.returncode == 0 and res.stdout.strip(): - d = Path(res.stdout.strip()) - if d.is_dir() and d not in target_desktop_dirs: - target_desktop_dirs.append(d) - except Exception: - pass - for cand in [HOST_HOME / "Desktop"]: - if cand.is_dir() and cand not in target_desktop_dirs: - target_desktop_dirs.append(cand) - - for ddir in target_desktop_dirs: - remove_legacy_entry(ddir / "league-of-legends.desktop") - desktop_shortcut = ddir / "darling-launcher.desktop" + for directory in desktop_dirs: + desktop_shortcut = directory / "darling-launcher.desktop" desktop_shortcut.write_text(desktop_content) os.chmod(desktop_shortcut, 0o755) gio = shutil.which("gio") From b93f70ab633e193514538a551877c2320735285e Mon Sep 17 00:00:00 2001 From: Kalah <58551020+lucannez64@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:01:57 +0200 Subject: [PATCH 144/160] Add donations section to README Added a donations section with a link to Ko-Fi. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 9176f73..b6144f7 100644 --- a/README.md +++ b/README.md @@ -152,4 +152,7 @@ repository. See [compliance and terminology guidance](docs/COMPLIANCE.md) for the project's scope and review expectations. +## Donations +If you want to help me live or just pay me a coffee +you can at [Ko-Fi](https://ko-fi.com/lucannez64) From f9d7f1a8ab8c91e72bb250b318e086eb3de95e2c Mon Sep 17 00:00:00 2001 From: lowell Date: Thu, 22 Oct 2026 08:01:47 +0300 Subject: [PATCH 145/160] Clamp the profile VRAM and report its GPU in system_profiler --- docs/PLATFORM_IDENTITY.md | 7 +++++++ tools/darling-system_profiler.sh | 24 ++++++++++++++++++------ tools/run-riot-client.sh | 3 +++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md index f8be827..54f11a7 100644 --- a/docs/PLATFORM_IDENTITY.md +++ b/docs/PLATFORM_IDENTITY.md @@ -156,3 +156,10 @@ keeps no identity of its own, so it reports the same values as IOKit, including override saved by this tool. A field the guest does not provide is left out. `python3 -m unittest tests.test_system_profiler` checks the output with stub commands. + +`SPDisplaysDataType` reads the host DRM cards. With `DARLING_METAL_MAC_GPU_NAME` +set (the launcher default), an AMD or NVIDIA host card is reported as the +`MacBookPro16,1` discrete GPU: `AMD Radeon Pro 5500M`, vendor `0x1002`, device +`0x7340`, bus PCIe and VRAM from `DARLING_METAL_VRAM_MB` (4096 by default). This +keeps the display report consistent with `MTLDevice.name`; without the variable +the host card is reported as-is. diff --git a/tools/darling-system_profiler.sh b/tools/darling-system_profiler.sh index 540b101..f2c1f02 100644 --- a/tools/darling-system_profiler.sh +++ b/tools/darling-system_profiler.sh @@ -35,7 +35,11 @@ pci_name() { # vendor device (hex without 0x) -> "Vendor Model" done } -displays() { # one line per GPU: vendor|device|vram MB or empty|name +displays() { # one line per GPU: vendor|device|vram MB or empty|name|bus + # Same switch as the Metal device name: a Mac profile presents the MacBookPro16,1 + # discrete GPU instead of the host card, so system_profiler and Metal agree. + mac_spoof=0 + case "${DARLING_METAL_MAC_GPU_NAME:-}" in '') ;; 0) ;; *) mac_spoof=1;; esac for card in "$host"/sys/class/drm/card[0-9] "$host"/sys/class/drm/card[0-9][0-9]; do [ -r "$card/device/vendor" ] || continue vendor=$(sed 's/^0x//' "$card/device/vendor"); device=$(sed 's/^0x//' "$card/device/device") @@ -44,7 +48,14 @@ displays() { # one line per GPU: vendor|device|vram MB or empty|name vram=$(awk '{ printf "%d", $1 / 1048576 }' "$card/device/mem_info_vram_total") fi name=$(pci_name "$vendor" "$device"); [ -n "$name" ] || name="Display" - echo "$vendor|$device|$vram|$name" + bus=builtin + if [ "$mac_spoof" = 1 ]; then + case "$vendor" in + 1002|10de) vendor=1002; device=7340; name='AMD Radeon Pro 5500M' + vram=${DARLING_METAL_VRAM_MB:-4096}; bus=pcie;; + esac + fi + echo "$vendor|$device|$vram|$name|$bus" done } @@ -144,11 +155,11 @@ if [ "$xml" = 1 ]; then SPHardwareDataType) hardware | overview_xml hardware_overview;; SPSoftwareDataType) software | overview_xml os_overview;; SPDisplaysDataType) - displays | while IFS='|' read -r vendor device vram name; do + displays | while IFS='|' read -r vendor device vram name bus; do name=$(printf '%s' "$name" | escape) echo ' ' echo " _name$name" - echo ' spdisplays_busspdisplays_builtin' + echo " spdisplays_busspdisplays_$bus" echo " spdisplays_device-id0x$device" echo " spdisplays_vendor${name%% *} (0x$vendor)" [ -z "$vram" ] || echo " spdisplays_vram$vram MB" @@ -190,8 +201,9 @@ for type in $types; do SPSoftwareDataType) software | overview_text Software 'System Software Overview';; SPDisplaysDataType) printf 'Graphics/Displays:\n\n' - displays | while IFS='|' read -r vendor device vram name; do - printf ' %s:\n\n Chipset Model: %s\n Type: GPU\n Bus: Built-In\n' "$name" "$name" + displays | while IFS='|' read -r vendor device vram name bus; do + [ "$bus" = pcie ] && bus_text=PCIe || bus_text='Built-In' + printf ' %s:\n\n Chipset Model: %s\n Type: GPU\n Bus: %s\n' "$name" "$name" "$bus_text" [ -z "$vram" ] || printf ' VRAM (Total): %s MB\n' "$vram" printf ' Vendor: %s (0x%s)\n Device ID: 0x%s\n\n' "${name%% *}" "$vendor" "$device" done;; diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 767294b..45a23e4 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -38,6 +38,9 @@ unset RCLOL_UX_ARG2 RCLOL_UX_ARG2_SLOT # Explicit 0 or an empty value selects the CPU fallback for diagnostics. # DARLING_METAL_MAC_GPU_NAME reports the MacBookPro16,1 GPU of the host vendor (AMD or Intel) as # MTLDevice.name, matching the model IOKit reports; memory figures stay the host's. +# VRAM clamp: honor an explicit DARLING_METAL_VRAM_MB, otherwise pin 4096 MB so the +# 5500M identity does not leak the host card's heap size. +export DARLING_METAL_VRAM_MB="${DARLING_METAL_VRAM_MB:-4096}" for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC RCLOL_GPU_DIRECT_VSYNC DARLING_CA_PRESENT_AT_COMMIT DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER RCLOL_FORWARD_MOUSE_MOVED RCLOL_MOUSE_CONFINEMENT DARLING_METAL_MAC_GPU_NAME; do eval 'value=${'"$option"'-1}' case "$value" in 0|'') unset "$option";; *) export "$option=$value";; esac From dd794377a9b07f50e6d6c352a2524687bb1d7b41 Mon Sep 17 00:00:00 2001 From: lowell Date: Thu, 22 Oct 2026 08:09:49 +0300 Subject: [PATCH 146/160] Write the Metal device log only on request --- docs/SETUP.md | 2 +- docs/TROUBLESHOOTING.md | 2 +- patches/cocotron.patch | 10 +++++++--- patches/metal--deps--indium.patch | 7 +++++-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 0c0d39b..56e370c 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -164,7 +164,7 @@ The hand-off experiments (`metal-present-dispatch-wait`, `metal-deferred-blit` a | `/tmp/metal-skip-cpu-devices` | software Vulkan devices (lavapipe) are skipped while a hardware device exists | | `/tmp/metal-gpu` | diagnostic: selects the Vulkan device used for Metal by index or case-insensitive name substring instead of the loader's first device. The OpenGL context that presents does not follow it; see [hybrid laptops](TROUBLESHOOTING.md#hybrid-graphics-laptops-amdintel-igpu--nvidia). `METAL_INDIUM_GPU` is the environment equivalent; the file wins | | `/tmp/metal-all-devices` | a `VkDevice` is created for every supported Vulkan device, as before 2026-10-16. Without it only the selected device gets one; on a machine with lavapipe installed this also drops lavapipe's idle rasterizer threads | -| `/tmp/metal-no-drawable-vram` | drawables of a layer with `framebufferOnly` off (League sets it off) go back to host-visible system RAM, as before 2026-10-16. Without the file they are allocated in GPU memory like `framebufferOnly` drawables; nothing maps drawable memory from the CPU. On the RX 6600 this gave about 20 FPS more in a practice-tool fight and the same FPS when idle, not yet measured in an ARAM. `/tmp/metal-device.log` shows `drawable-vram=on` and `storage=vram` | +| `/tmp/metal-no-drawable-vram` | drawables of a layer with `framebufferOnly` off (League sets it off) go back to host-visible system RAM, as before 2026-10-16. Without the file they are allocated in GPU memory like `framebufferOnly` drawables; nothing maps drawable memory from the CPU. On the RX 6600 this gave about 20 FPS more in a practice-tool fight and the same FPS when idle, not yet measured in an ARAM. `/tmp/metal-device.log` (with `DARLING_METAL_DEVICE_LOG=1`) shows `drawable-vram=on` and `storage=vram` | | `/tmp/metal-no-lazy-wakeup` | every completion registration signals the event loop's wake-up semaphore from the CPU again, as before 2026-10-18. The handshake is per device. Without the file a registration signals only while the poller has announced a blocking wait (a spinning poller re-reads the registrations on its next pass, and its spin ends when one arrives). On the NVIDIA proprietary driver each `vkSignalSemaphore` is an RM control ioctl that waits on a driver-wide lock, about 0.3 ms each and ~25 per League frame, and it was the largest kernel cost of the submit worker. Re-read every 256 registrations; the wait log counts `wakeupSignalled` and `wakeupSkipped` | | `/tmp/metal-submit-batch-us` | the submit batch window in microseconds (500–100000, default 500); re-read every 256 checks, so it can be changed during a match. A presenting commit and every CPU wait still flush the batch at once. On the NVIDIA proprietary driver every `vkQueueSubmit2` is an RM control ioctl that waits on a driver-wide lock (about 0.4 ms each in a League match); with the default window a frame took about 8.5 submits, with `5000` about 2.5 (RTX 4090: 89 → 113 FPS, same match) | | `/tmp/metal-submit-batch-max` | command buffers per batch (1–256, default 16), re-read like the window; `64` lets one batch hold a whole frame | diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 13a2f7b..20703e6 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -434,7 +434,7 @@ The guest file `/tmp/metal-gpu` (a device index or part of its name, for example ### Check which GPU rendered -Each process that uses Metal appends to guest `/tmp/metal-device.log` (host `~/.darling/private/tmp/metal-device.log`): every Vulkan device the loader reported, the selected one and why, and the renderer of the presenting OpenGL context with both device UUIDs: +With `DARLING_METAL_DEVICE_LOG=1` in the launcher environment, each process that uses Metal appends to guest `/tmp/metal-device.log` (host `~/.darling/private/tmp/metal-device.log`): every Vulkan device the loader reported, the selected one and why, and the renderer of the presenting OpenGL context with both device UUIDs. The log is off by default because the guest can read it and it names the host GPU: ``` [MetalDevice] pid=… physical name="NVIDIA GeForce RTX 3060 Laptop GPU" type=discrete vendorID=0x10de … supported=yes diff --git a/patches/cocotron.patch b/patches/cocotron.patch index ce31983..2c04817 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -10019,12 +10019,13 @@ diff --git a/QuartzCore/CAMetalDrawable.mm b/QuartzCore/CAMetalDrawable.mm index d6f86b31..687f05d5 100644 --- a/QuartzCore/CAMetalDrawable.mm +++ b/QuartzCore/CAMetalDrawable.mm -@@ -31,6 +31,13 @@ +@@ -31,6 +31,14 @@ #include #include +#include +#include ++#include +#include +#include +#include @@ -10033,7 +10034,7 @@ index d6f86b31..687f05d5 100644 namespace DynamicVK = Indium::DynamicVK; static void reportGLErrors(void) { -@@ -50,8 +57,111 @@ static void reportGLErrors(void) { +@@ -50,8 +57,114 @@ namespace Indium::DynamicVK { static DynamicFunction vkGetSemaphoreFdKHR("vkGetSemaphoreFdKHR"); static DynamicFunction vkGetMemoryFdKHR("vkGetMemoryFdKHR"); @@ -10057,8 +10058,11 @@ index d6f86b31..687f05d5 100644 + } +} + -+// Same line format as Indium's device log. ++// Same line format as Indium's device log; off unless DARLING_METAL_DEVICE_LOG is ++// set, so the guest-readable file does not name the host GPU by default. +static void appendDeviceLog(const std::string& line) { ++ const char* enabled = getenv("DARLING_METAL_DEVICE_LOG"); ++ if (!enabled || !*enabled || (enabled[0] == '0' && !enabled[1])) return; + if (FILE* log = fopen("/tmp/metal-device.log", "a")) { + fprintf(log, "[MetalDevice] pid=%d %s\n", getpid(), line.c_str()); + fclose(log); diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index f3c149f..c6d11ab 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -2884,7 +2884,7 @@ index 322e186..f712c7a 100644 #include #include #include -@@ -12,39 +17,207 @@ +@@ -12,39 +17,210 @@ #include @@ -2911,8 +2911,11 @@ index 322e186..f712c7a 100644 + // device list is built, plus one when the presenting OpenGL context first + // imports a Vulkan drawable. It exists so a user can tell us which GPU + // actually rendered a match, which the game's own logs and FPS numbers do not -+ // show. ++ // show. Off by default: the guest can read this file, so enabling it names the ++ // host GPU to anything running in the prefix. Set DARLING_METAL_DEVICE_LOG=1. + void appendDeviceLog(const std::string& line) { ++ const char* enabled = getenv("DARLING_METAL_DEVICE_LOG"); ++ if (!enabled || !*enabled || (enabled[0] == '0' && !enabled[1])) return; + if (FILE* log = fopen("/tmp/metal-device.log", "a")) { + fprintf(log, "[MetalDevice] pid=%d %s\n", getpid(), line.c_str()); + fclose(log); From 2d0b6cf35191d88e55b44f97ae3bf4abd581b438 Mon Sep 17 00:00:00 2001 From: lowell Date: Thu, 22 Oct 2026 13:06:30 +0300 Subject: [PATCH 147/160] Drop host session names from the optional guest environment filter --- tools/exec-guest.py | 14 ++++++++++++++ tools/run-riot-client.sh | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/tools/exec-guest.py b/tools/exec-guest.py index 331f754..71a55dd 100755 --- a/tools/exec-guest.py +++ b/tools/exec-guest.py @@ -16,6 +16,12 @@ SESSION_PREFIXES = ( 'XDG_SESSION_', 'XDG_SEAT', 'XDG_VTNR', 'XDG_CURRENT_DESKTOP', 'XDG_MENU_PREFIX', 'GNOME_', 'KDE_', 'GDM', 'SSH_', 'SYSTEMD_', 'MEMORY_PRESSURE_', 'QT_', 'GTK_', 'GDK_', 'GIO_', 'GSETTINGS_', 'GJS_', 'FLATPAK_', 'SNAP_', 'KONSOLE_', 'TERM_PROGRAM', 'BASH_FUNC_', + # Host shell, prompt and developer-tool metadata. None of it exists on a Mac and + # several entries name Linux paths, so the guest must not inherit them. + 'STARSHIP_', 'ILLOGICAL_IMPULSE_', 'INIR_', 'PNPM_', 'YARN_', 'npm_config_', 'PIP_', + 'CARGO_', 'GIT_', 'LESS_TERMCAP_', '_P9K_', 'VSSCRIPT_', 'ORCA_', 'CLAUDE', 'TF_', + 'CUDA_', 'COMPOSER_', 'DEBIAN_', 'FZF_', 'BAT_', 'MYSQL_', 'AWS_', 'LESS', + 'CLOUDSDK_', 'GH_', 'GLAB_', 'HOMEBREW_', 'ELECTRON_', 'DELTA_', 'HIST', 'GPG_', ) SESSION_NAMES = { 'WAYLAND_DISPLAY', 'DESKTOP_SESSION', 'DESKTOP_STARTUP_ID', 'SESSION_MANAGER', @@ -23,6 +29,9 @@ SESSION_NAMES = { 'LISTEN_FDS', 'LISTEN_PID', 'LISTEN_FDNAMES', 'COLORTERM', 'VTE_VERSION', 'WINDOWID', 'WINDOWPATH', 'CHROME_DESKTOP', 'NO_AT_BRIDGE', 'MOTD_SHOWN', 'SNAP', 'APPIMAGE', 'APPDIR', 'OWD', 'VIRTUAL_ENV', 'DEBUGINFOD_URLS', 'LS_COLORS', 'COLORFGBG', 'OLDPWD', 'SHLVL', + 'AGENT', 'NO_COLOR', 'TERM', 'VISUAL', 'PROMPT_COMMAND', 'HISTFILE', 'HISTSIZE', + 'SAVEHIST', 'HISTORY_IGNORE', 'PYTHONUNBUFFERED', 'LINES', 'COLUMNS', '_', + 'CI', 'EDITOR', 'PAGER', 'GIT_PAGER', 'SYSTEMD_PAGER', 'MANPAGER', } GUEST_SHELL = '/bin/zsh' @@ -40,6 +49,11 @@ def filter_host_environment(environ): # The host login shell (for example /bin/fish) does not exist in the guest. if 'SHELL' not in keep: environ['SHELL'] = GUEST_SHELL + # A host PATH names Linux directories; the guest always has the Mac layout. + if 'PATH' not in keep: + environ['PATH'] = '/usr/bin:/bin:/usr/sbin:/sbin' + environ.pop('RCLOL_KEEP_HOST_ENV', None) + environ.pop('RCLOL_FILTER_HOST_ENV', None) return removed diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 45a23e4..332d675 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -46,6 +46,11 @@ for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC RCLOL_GPU_DIRECT_V case "$value" in 0|'') unset "$option";; *) export "$option=$value";; esac done prefix=$prefix_dir +# Resolve the loader before the environment filter replaces PATH with the Mac layout. +darling_bin=$(command -v darling) || { + echo 'darling loader not found in PATH' >&2 + exit 1 +} # RIOT_GUEST_HOME is host-side; DYLD paths must be relative to the guest root. case "$guest_home" in "$prefix"/*) guest_path=/${guest_home#"$prefix"/} ;; @@ -136,6 +141,6 @@ exec python3 "$runner_dir/exec-guest.py" env -u ELECTRON_RUN_AS_NODE -u DYLD_INS DYLD_LIBRARY_PATH="${DYLD_LIBRARY_PATH:-$guest_path/RuntimeLibraries}" \ DYLD_FRAMEWORK_PATH="${DYLD_FRAMEWORK_PATH:-$guest_path/RuntimeFrameworks}" \ DARLING_XSHM_LIBRARY="${DARLING_XSHM_LIBRARY:-$guest_path/libDarlingXShm.dylib}" \ - darling "$A" --region="${RCLOL_REGION:-EUW}" --locale="${RCLOL_LOCALE:-en_GB}" \ + "$darling_bin" "$A" --region="${RCLOL_REGION:-EUW}" --locale="${RCLOL_LOCALE:-en_GB}" \ "$@" \ --log-dir="$guest_path/Library/Logs/Riot Games/Riot Client" From 7ff91e81a474f30bdf43589d52a0eb1123ec8ad3 Mon Sep 17 00:00:00 2001 From: lowell Date: Thu, 22 Oct 2026 17:48:01 +0300 Subject: [PATCH 148/160] Align the Intel display identity and validate the VRAM override --- docs/PLATFORM_IDENTITY.md | 8 +++++--- tools/darling-system_profiler.sh | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md index 54f11a7..9714821 100644 --- a/docs/PLATFORM_IDENTITY.md +++ b/docs/PLATFORM_IDENTITY.md @@ -160,6 +160,8 @@ commands. `SPDisplaysDataType` reads the host DRM cards. With `DARLING_METAL_MAC_GPU_NAME` set (the launcher default), an AMD or NVIDIA host card is reported as the `MacBookPro16,1` discrete GPU: `AMD Radeon Pro 5500M`, vendor `0x1002`, device -`0x7340`, bus PCIe and VRAM from `DARLING_METAL_VRAM_MB` (4096 by default). This -keeps the display report consistent with `MTLDevice.name`; without the variable -the host card is reported as-is. +`0x7340`, bus PCIe and VRAM from `DARLING_METAL_VRAM_MB` (4096 by default; a +value that is not a positive decimal keeps the default). An Intel card is +reported as `Intel UHD Graphics 630` on bus Built-In with shared memory, as +`MTLDevice.name` does. This keeps the display report consistent with Metal; +without the variable the host card is reported as-is. diff --git a/tools/darling-system_profiler.sh b/tools/darling-system_profiler.sh index f2c1f02..401f68e 100644 --- a/tools/darling-system_profiler.sh +++ b/tools/darling-system_profiler.sh @@ -52,7 +52,12 @@ displays() { # one line per GPU: vendor|device|vram MB or empty|name|bus if [ "$mac_spoof" = 1 ]; then case "$vendor" in 1002|10de) vendor=1002; device=7340; name='AMD Radeon Pro 5500M' - vram=${DARLING_METAL_VRAM_MB:-4096}; bus=pcie;; + vram=${DARLING_METAL_VRAM_MB:-4096} + case "$vram" in ''|*[!0-9]*) vram=4096;; esac + [ "$vram" -eq 0 ] 2>/dev/null && vram=4096 + bus=pcie;; + 8086) vendor=8086; device=3e9b; name='Intel UHD Graphics 630' + vram=; bus=builtin;; esac fi echo "$vendor|$device|$vram|$name|$bus" From 913e81887e42e6500a1a6e799b23cef6ddd10e33 Mon Sep 17 00:00:00 2001 From: lowell Date: Thu, 22 Oct 2026 19:21:00 +0300 Subject: [PATCH 149/160] Fall back before an oversized VRAM override reaches the shell test --- tools/darling-system_profiler.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/darling-system_profiler.sh b/tools/darling-system_profiler.sh index 401f68e..7719431 100644 --- a/tools/darling-system_profiler.sh +++ b/tools/darling-system_profiler.sh @@ -54,7 +54,11 @@ displays() { # one line per GPU: vendor|device|vram MB or empty|name|bus 1002|10de) vendor=1002; device=7340; name='AMD Radeon Pro 5500M' vram=${DARLING_METAL_VRAM_MB:-4096} case "$vram" in ''|*[!0-9]*) vram=4096;; esac - [ "$vram" -eq 0 ] 2>/dev/null && vram=4096 + # Keep the numeric test overflow-safe: a long digit string must + # fall back before the shell compares it. + if [ "${#vram}" -gt 7 ] || [ "$vram" -eq 0 ] || [ "$vram" -gt 1048576 ]; then + vram=4096 + fi bus=pcie;; 8086) vendor=8086; device=3e9b; name='Intel UHD Graphics 630' vram=; bus=builtin;; From 899576cbbcca0d7bfeebfb5a9401ef06f8fc188d Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 12:15:35 -0600 Subject: [PATCH 150/160] feat(launcher): hide the League client UI while a match runs Add an opt-in alternative to closing LeagueClientUx: hide the managed LeagueClientUx and Riot Client X11 windows while the game process runs and show them again when it exits. The client stays open; the game window is never touched. Detection is attributed to the launcher prefix through __mldr_sockpath and fails closed when proc, ownership or window state is unreadable. The helper requires X11/Xwayland, restores the window on end, signal or error, and is mutually exclusive with RCLOL_CLOSE_CLIENT_IN_GAME=1. Document the flag, compliance scope and limits. A local 30-second test on an i5-3470/RX 550 reduced summed client/CEF CPU from 120.7% to 93.4%; this is a CPU-bound-host measurement, not a universal FPS claim. No client binaries, CEF, assets or anti-cheat are modified. --- .github/workflows/source-check.yml | 1 + README.md | 8 + docs/SETUP.md | 8 + docs/TROUBLESHOOTING.md | 49 ++++ tests/test_hide_league_client_in_game.py | 165 +++++++++++++ tools/hide-league-client-in-game.py | 295 +++++++++++++++++++++++ tools/run-riot-client.sh | 11 + 7 files changed, 537 insertions(+) create mode 100644 tests/test_hide_league_client_in_game.py create mode 100755 tools/hide-league-client-in-game.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index a866b9d..afc8de7 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -31,3 +31,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_metal_device_info.py' - run: python -m unittest discover -s tests -p 'test_exec_guest.py' - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' + - run: python -m unittest discover -s tests -p 'test_hide_league_client_in_game.py' diff --git a/README.md b/README.md index b6144f7..97774de 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,14 @@ First installation and future Riot updates can still encounter compatibility gap For another region, set `RCLOL_REGION` and `RCLOL_LOCALE` before launch. For example `RCLOL_REGION=NA RCLOL_LOCALE=en_US ./scripts/run.sh`. Linux display selection follows `$DISPLAY`; the legacy launcher fallback is `:1`. +If you want to keep the client open during matches, opt in to hiding its window instead of closing it: + +```sh +RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +The helper detects the match, hides the `LeagueClientUx`/`Riot Client` X11 windows, and restores them when the game ends. It does not close the client, patch CEF or touch the game window. It is off by default, requires X11/Xwayland, and `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence. See [troubleshooting](docs/TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) for the measured CPU effect and caveats. + ## Testing Start with standalone regression checks, then play a match manually. See [testing](docs/TESTING.md). Do not attach a debugger, query client APIs, inject diagnostic hooks or automate client/game input. This project ships no client-side automation, inspection or profiling helpers. diff --git a/docs/SETUP.md b/docs/SETUP.md index 0c0d39b..a83e8b7 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -78,6 +78,14 @@ opts in to Hyprland-only recovery of a default-prefix Riot session with no visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection) for the observed failures, recovery guards and NSURLSession limitations. +`RCLOL_HIDE_CLIENT_IN_GAME=1` opts in to hiding the managed `LeagueClientUx` +and `Riot Client` X11 windows while a match runs, then showing them when the +game process ends. It keeps the client open and is mutually exclusive with +`RCLOL_CLOSE_CLIENT_IN_GAME=1` (close takes precedence). It is off by default, +requires X11/Xwayland, and does not patch client binaries, CEF or the game. See +[the troubleshooting measurement](TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) +for the measured CPU effect and limitations. + `RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before the guest starts: `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `XDG_CURRENT_DESKTOP`, GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit (`QT_*`, `GTK_*`, `GDK_*`), diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 13a2f7b..2125d45 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -192,6 +192,55 @@ observation, without waiting for a match: python3 tools/close-league-client-in-game.py --once --dry-run ``` +### Keep the client open but hide it during a match + +`LeagueClientUx` can consume a noticeable amount of CPU while the client stays +open during a match. An opt-in alternative to closing it hides the managed +client window instead: + +```sh +RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +The launcher detects when a match starts, unmaps the `LeagueClientUx` and +`Riot Client` X11 windows, and maps them again when the game process ends. The +client is not closed, the League backend and Riot client keep running, and the +game window is never hidden. `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence +when both flags are set. + +The helper is off by default and window-management only: it does not patch +client binaries, CEF, anti-cheat or DRM. It attributes the game to this prefix +through the loader's `__mldr_sockpath`, and fails closed when ownership, proc +state, environment or window information cannot be read. It requires an X11 or +Xwayland desktop; on a native Wayland window manager without Xwayland it cannot +find the windows. + +On a CPU-bound host the effect appears after CEF has been hidden for a few +seconds. In a local 30-second test on an i5-3470 with a Radeon RX 550, hiding +`LeagueClientUx` reduced the summed CPU of the matched client/CEF processes from +120.7% to 93.4% (about 23%, or 27 percentage points, of one core); showing the +window returned it to 119.9%. This is not a universal FPS or percentage claim: +a game that is GPU-bound, a client with no active UI work, or a different +desktop may show less benefit. The hidden window can also need a moment to +repaint after it is shown. + +`RCLOL_CLOSE_CLIENT_IN_GAME=1` remains the more aggressive option when in-client +chat during the match is not needed. Configure one of the two, not both: + +```sh +# Keep the client open, hide its windows while playing +RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh + +# Or close the client UI while playing +RCLOL_CLOSE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +Diagnose the match detector without changing any window: + +```sh +python3 tools/hide-league-client-in-game.py --once +``` + ### Riot reports NO INTERNET FOUND while the host is online Older base runtimes ship Google DNS servers (`8.8.8.8` and `8.8.4.4`), which diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py new file mode 100644 index 0000000..22740fe --- /dev/null +++ b/tests/test_hide_league_client_in_game.py @@ -0,0 +1,165 @@ +"""Exercise the in-match client window hide with fake /proc entries and X11.""" +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + 'hide_in_game', ROOT / 'tools/hide-league-client-in-game.py') +hide_in_game = importlib.util.module_from_spec(spec) +spec.loader.exec_module(hide_in_game) + +GAME = '/Applications/League of Legends.app/Contents/LoL/LeagueofLegends' +MAIN = '/Applications/League of Legends.app/Contents/LoL/LeagueClientUx.app/Contents/MacOS/LeagueClientUx' +RIOT = '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices' + + +class FakeX11: + def __init__(self): + self.unmapped = [] + self.shown = [] + + def unmap(self, window): + self.unmapped.append(window) + + def show(self, window): + self.shown.append(window) + + +class HideClientInGameTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + self.prefix = self.root / '.darling' + self.env_patch = patch.dict(os.environ, {'DPREFIX': str(self.prefix)}) + self.env_patch.start() + self.addCleanup(self.env_patch.stop) + + def environment(self, prefix): + return b'%s=%s\0' % ( + hide_in_game.SOCK_VARIABLE, + os.fsencode(prefix / hide_in_game.SOCK_NAME), + ) + + def process(self, pid, executable=GAME, prefix='own', environ=None, args=('--flag',)): + folder = self.proc / str(pid) + folder.mkdir() + (folder / 'cmdline').write_bytes( + executable.encode() + b'\0' + + b''.join(arg.encode() + b'\0' for arg in args)) + if environ is None: + environ = self.environment(self.prefix if prefix == 'own' else prefix) + (folder / 'environ').write_bytes(environ) + (folder / 'stat').write_text(f'{pid} (game) S') + return folder + + def test_game_running_for_own_prefix(self): + self.process(123) + self.assertTrue(hide_in_game.game_running(self.prefix, self.proc)) + + def test_no_game_is_false(self): + self.process(123, executable=MAIN) + self.assertFalse(hide_in_game.game_running(self.prefix, self.proc)) + + def test_game_of_another_prefix_is_false(self): + self.process(123, prefix=self.root / 'other') + self.assertFalse(hide_in_game.game_running(self.prefix, self.proc)) + + def test_game_without_socket_is_false(self): + self.process(123, environ=b'') + self.assertFalse(hide_in_game.game_running(self.prefix, self.proc)) + + def test_unreadable_environment_is_unknown(self): + folder = self.process(123) + original = Path.read_bytes + + def read(path): + if path.name == 'environ': + raise PermissionError() + return original(path) + + with patch.object(Path, 'read_bytes', read): + self.assertIsNone(hide_in_game.game_running(self.prefix, self.proc)) + + def test_target_windows_ignores_game_class(self): + with patch.object(hide_in_game, 'xprop_root', + return_value=['0x10', '0x20', '0x30']), \ + patch.object(hide_in_game, 'window_class', + side_effect=[ + 'wm_class(string) = "leagueclientux"', + 'wm_class(string) = "leagueoflegends"', + 'wm_class(string) = "riot client"', + ]): + self.assertEqual(hide_in_game.target_windows(), [0x10, 0x30]) + + def test_hides_then_shows_on_match_end(self): + states = iter([True, True, False]) + x11 = FakeX11() + output = io.StringIO() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda: [0xabc], + state_source=lambda _prefix: next(states), + sleep=lambda _seconds: None, + out=output, + ) + watcher.run(max_steps=3) + self.assertEqual(x11.unmapped, [0xabc]) + self.assertEqual(x11.shown, [0xabc]) + self.assertIn('hidden 0xabc', output.getvalue()) + self.assertIn('the match ended', output.getvalue()) + self.assertIn('shown 0xabc', output.getvalue()) + + def test_unreadable_state_restores_the_window(self): + states = iter([True, None]) + x11 = FakeX11() + output = io.StringIO() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda: [0xdef], + state_source=lambda _prefix: next(states), + sleep=lambda _seconds: None, + out=output, + ) + watcher.run(max_steps=2) + self.assertEqual(x11.unmapped, [0xdef]) + self.assertEqual(x11.shown, [0xdef]) + self.assertIn('unreadable /proc state', output.getvalue()) + + def test_no_game_does_not_touch_windows(self): + x11 = FakeX11() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda: [0xabc], + state_source=lambda _prefix: False, + sleep=lambda _seconds: None, + out=io.StringIO(), + ) + watcher.run(max_steps=1) + self.assertEqual(x11.unmapped, []) + self.assertEqual(x11.shown, []) + + def test_main_is_disabled_by_default(self): + with patch.dict(os.environ, { + 'RCLOL_HIDE_CLIENT_IN_GAME': '', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '', + }): + self.assertEqual(hide_in_game.main([]), 0) + + def test_close_mode_takes_precedence(self): + with patch.dict(os.environ, { + 'RCLOL_HIDE_CLIENT_IN_GAME': '1', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '1', + }): + self.assertEqual(hide_in_game.main([]), 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py new file mode 100755 index 0000000..2762c46 --- /dev/null +++ b/tools/hide-league-client-in-game.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +"""Opt-in: hide the League client UI while a match is running. + +`RCLOL_HIDE_CLIENT_IN_GAME=1` is an alternative to closing the client UI with +`RCLOL_CLOSE_CLIENT_IN_GAME=1`. It hides the managed X11 windows for +`LeagueClientUx` and `Riot Client`, and shows them again when the game process +exits. The Riot client, League backend, CEF helpers and game keep running. + +This is launcher/window management only. It does not patch client binaries, +CEF, anti-cheat or DRM, and it never hides the game window. It is off by +default, fails closed, and is intended for X11/Xwayland desktops. + +The game is attributed to this prefix through the loader's +`__mldr_sockpath`, because guest processes do not carry `DPREFIX`. If proc, +ownership or window information cannot be read, no window is touched. + +Diagnose one observation with: + + python3 tools/hide-league-client-in-game.py --once + +Enable it for a launcher run with: + + RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +""" + +import argparse +import ctypes +import os +import re +import signal +import subprocess +import sys +import time +from pathlib import Path + +GAME_NAME = 'LeagueofLegends' +SOCK_VARIABLE = b'__mldr_sockpath' +SOCK_NAME = '.darlingserver.sock' +POLL_SECONDS = 2.0 +TARGET_CLASSES = ('leagueclientux', 'riot client') + + +def target_prefix(): + """The prefix this launcher owns, or None when it cannot be determined.""" + value = os.environ.get('DPREFIX', str(Path.home() / '.darling')) + prefix = Path(value).expanduser() + return prefix if prefix.is_absolute() else None + + +def process_state(proc): + try: + return (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0] + except (OSError, IndexError): + return None + + +def command_names(proc): + try: + raw = (proc / 'cmdline').read_bytes() + except OSError: + return [] + return [ + os.path.basename(part.decode(errors='replace')) + for part in raw.split(b'\0') + if part + ] + + +def game_running(prefix, proc_root=Path('/proc')): + """True/False for a game of this prefix; None when /proc is unreliable. + + The game executable is matched by its exact file name. The process must + carry the loader socket of this prefix, so a game from another install is + never considered. Any unreadable candidate makes the result None (fail + closed). + """ + owner = (prefix / SOCK_NAME).parent.resolve() + try: + entries = list(proc_root.iterdir()) + except OSError: + return None + for proc in entries: + if not proc.name.isdigit() or process_state(proc) in ('Z', None): + continue + try: + if proc.stat().st_uid != os.getuid(): + continue + if GAME_NAME not in command_names(proc): + continue + environment = dict( + item.split(b'=', 1) + for item in (proc / 'environ').read_bytes().split(b'\0') + if b'=' in item + ) + socket = environment.get(SOCK_VARIABLE) + if socket is None: + continue + if Path(os.fsdecode(socket)).parent.resolve() != owner: + continue + return True + except FileNotFoundError: + if proc.exists(): + return None + continue + except (OSError, ValueError): + return None + return False + + +def xprop_root(): + try: + listing = subprocess.check_output( + ['xprop', '-root', '_NET_CLIENT_LIST'], text=True, timeout=5 + ) + except (OSError, subprocess.SubprocessError): + return [] + return re.findall(r'0x[0-9a-fA-F]+', listing) + + +def window_class(window): + try: + prop = subprocess.check_output( + ['xprop', '-id', window, 'WM_CLASS'], text=True, timeout=5 + ) + except (OSError, subprocess.SubprocessError): + return '' + return prop.lower() + + +def target_windows(): + """Managed client windows only; the game window is never selected.""" + found = [] + for window in xprop_root(): + classes = window_class(window) + if any(name in classes for name in TARGET_CLASSES): + found.append(int(window, 16)) + return sorted(set(found)) + + +class X11: + def __init__(self): + self.lib = ctypes.CDLL('libX11.so.6') + self.lib.XOpenDisplay.restype = ctypes.c_void_p + self.lib.XOpenDisplay.argtypes = [ctypes.c_char_p] + self.lib.XUnmapWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + self.lib.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + self.lib.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] + self.lib.XCloseDisplay.argtypes = [ctypes.c_void_p] + error_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) + self._error_handler = error_type(lambda _display, _event: 0) + self.lib.XSetErrorHandler.argtypes = [error_type] + self.lib.XSetErrorHandler.restype = ctypes.c_void_p + self.lib.XSetErrorHandler(self._error_handler) + self.display = self.lib.XOpenDisplay(None) + if not self.display: + raise RuntimeError('cannot open DISPLAY') + + def unmap(self, window): + self.lib.XUnmapWindow(self.display, window) + self.lib.XSync(self.display, 0) + + def show(self, window): + self.lib.XMapRaised(self.display, window) + self.lib.XSync(self.display, 0) + + def close(self): + self.lib.XCloseDisplay(self.display) + + +class Watcher: + """Hide while a match is running, restore on end, uncertainty or exit.""" + + def __init__(self, prefix, x11, window_source=target_windows, + state_source=game_running, sleep=time.sleep, out=sys.stdout): + self.prefix = prefix + self.x11 = x11 + self.window_source = window_source + self.state_source = state_source + self.sleep = sleep + self.out = out + self.hidden = set() + self.reported_no_window = False + + def _show_all(self, reason): + if not self.hidden: + return + print(f'hide-league-client-in-game: {reason}; showing the client', file=self.out, flush=True) + for window in sorted(self.hidden): + try: + self.x11.show(window) + print(f'hide-league-client-in-game: shown 0x{window:x}', file=self.out, flush=True) + except Exception as error: + print(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}', + file=sys.stderr, flush=True) + self.hidden.clear() + + def _hide_new(self): + windows = self.window_source() + if not windows and not self.hidden and not self.reported_no_window: + print('hide-league-client-in-game: no managed client window; not hiding', + file=self.out, flush=True) + self.reported_no_window = True + for window in windows: + if window in self.hidden: + continue + try: + self.x11.unmap(window) + self.hidden.add(window) + print(f'hide-league-client-in-game: hidden 0x{window:x}', file=self.out, flush=True) + except Exception as error: + print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', + file=sys.stderr, flush=True) + + def run(self, max_steps=None): + steps = 0 + while True: + state = self.state_source(self.prefix) + if state is None: + self._show_all('unreadable /proc state') + elif state: + if not self.hidden and self.reported_no_window: + self.reported_no_window = False + self._hide_new() + else: + self._show_all('the match ended') + self.reported_no_window = False + steps += 1 + if max_steps is not None and steps >= max_steps: + return 0 + self.sleep(POLL_SECONDS) + + def restore(self): + self._show_all('exiting') + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--detach', action='store_true', + help='leave the launcher session so a terminal hang-up cannot end it') + parser.add_argument('--once', action='store_true', + help='one game observation and exit (no window changes)') + args = parser.parse_args(argv) + + if os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': + print('hide-league-client-in-game: ignored; RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence', + file=sys.stderr, flush=True) + return 0 + if os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': + print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1', + flush=True) + return 0 + prefix = target_prefix() + if prefix is None: + print('hide-league-client-in-game: unusable DPREFIX: no action', + file=sys.stderr, flush=True) + return 1 + if args.once: + state = game_running(prefix) + print(f'hide-league-client-in-game: match={"unreadable" if state is None else "running" if state else "absent"}', + flush=True) + return 0 + if args.detach: + try: + os.setsid() + except OSError: + pass + try: + x11 = X11() + except Exception as error: + print(f'hide-league-client-in-game: cannot open X11: {error}', + file=sys.stderr, flush=True) + return 1 + + watcher = Watcher(prefix, x11) + + def stop(_signum, _frame): + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + print('hide-league-client-in-game: watching %s for a running match; ' + 'opt out with RCLOL_HIDE_CLIENT_IN_GAME=0' % prefix, flush=True) + try: + watcher.run() + except KeyboardInterrupt: + pass + finally: + watcher.restore() + x11.close() + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 767294b..88dc083 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -19,6 +19,17 @@ fi if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then python3 "$runner_dir/close-league-client-in-game.py" --detach & fi +# Opt-in alternative for users who keep the client open during a match: hide +# the managed client windows instead of closing the UI. It never touches the +# League backend, the Riot client or the game window; it restores the window +# when the game process ends. Close and hide are mutually exclusive. +if [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then + if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then + echo 'RCLOL_HIDE_CLIENT_IN_GAME ignored: RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence' >&2 + else + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + fi +fi # The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN # AAAA lookups are suppressed; set this to 0 to retain the upstream resolver. export RCLOL_CDN_IPV4_ONLY="${RCLOL_CDN_IPV4_ONLY:-1}" From 521527eb21e1571c6844c3fa319fdbb7c4a7118b Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 12:32:03 -0600 Subject: [PATCH 151/160] fix(launcher): address hide-client review findings - associate target windows with the selected prefix through the guest PID, X11 _NET_WM_PID, NSpid and the loader socket; fail closed when ownership is unknown; - let --once run without either opt-in gate, matching the documented diagnostic; - route SIGHUP through the restore handler; - cache non-empty WM_CLASS results per window and prune stale entries; - cover unreadable /proc, host PID mapping and prefix ownership in tests. --- tests/test_hide_league_client_in_game.py | 67 ++++++++++++- tools/hide-league-client-in-game.py | 120 ++++++++++++++++++++--- 2 files changed, 167 insertions(+), 20 deletions(-) diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index 22740fe..87f4e05 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -88,6 +88,43 @@ class HideClientInGameTests(unittest.TestCase): with patch.object(Path, 'read_bytes', read): self.assertIsNone(hide_in_game.game_running(self.prefix, self.proc)) + def test_unreadable_process_directory_is_unknown(self): + class UnreadableProc: + def iterdir(self): + raise OSError() + + self.assertIsNone(hide_in_game.game_running(self.prefix, UnreadableProc())) + + def test_host_socket_for_guest_uses_nspid(self): + host = self.proc / '999' + host.mkdir() + (host / 'status').write_text('Name:\tCrBrowserMain\nNSpid:\t999\t5877\n') + (host / 'environ').write_bytes(self.environment(self.prefix)) + self.assertEqual( + hide_in_game.host_socket_for_guest(5877, self.proc), + os.fsencode(self.prefix / hide_in_game.SOCK_NAME), + ) + + def test_window_belongs_to_prefix(self): + socket = os.fsencode(self.prefix / hide_in_game.SOCK_NAME) + with patch.object(hide_in_game, 'window_pid', return_value=5877), \ + patch.object(hide_in_game, 'host_socket_for_guest', return_value=socket): + self.assertTrue(hide_in_game.window_belongs_to_prefix('0x10', self.prefix)) + self.assertFalse(hide_in_game.window_belongs_to_prefix( + '0x10', self.root / 'other')) + + def test_once_runs_without_opt_in(self): + output = io.StringIO() + with patch.dict(os.environ, { + 'RCLOL_HIDE_CLIENT_IN_GAME': '', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '', + }), patch.object(hide_in_game, 'target_prefix', + return_value=self.prefix), \ + patch.object(hide_in_game, 'game_running', return_value=True), \ + patch.object(hide_in_game.sys, 'stdout', output): + self.assertEqual(hide_in_game.main(['--once']), 0) + self.assertIn('match=running', output.getvalue()) + def test_target_windows_ignores_game_class(self): with patch.object(hide_in_game, 'xprop_root', return_value=['0x10', '0x20', '0x30']), \ @@ -96,8 +133,28 @@ class HideClientInGameTests(unittest.TestCase): 'wm_class(string) = "leagueclientux"', 'wm_class(string) = "leagueoflegends"', 'wm_class(string) = "riot client"', - ]): - self.assertEqual(hide_in_game.target_windows(), [0x10, 0x30]) + ]), \ + patch.object(hide_in_game, 'window_belongs_to_prefix', return_value=True): + self.assertEqual(hide_in_game.target_windows(self.prefix), [0x10, 0x30]) + + def test_target_windows_requires_this_prefix(self): + with patch.object(hide_in_game, 'xprop_root', + return_value=['0x10', '0x20']), \ + patch.object(hide_in_game, 'window_class', + return_value='wm_class(string) = "leagueclientux"'), \ + patch.object(hide_in_game, 'window_belongs_to_prefix', + side_effect=[True, False]): + self.assertEqual(hide_in_game.target_windows(self.prefix), [0x10]) + + def test_target_windows_caches_non_empty_classes(self): + cache = {} + with patch.object(hide_in_game, 'xprop_root', return_value=['0x10']), \ + patch.object(hide_in_game, 'window_class', + return_value='wm_class(string) = "leagueclientux"') as classes, \ + patch.object(hide_in_game, 'window_belongs_to_prefix', return_value=True): + self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10]) + self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10]) + self.assertEqual(classes.call_count, 1) def test_hides_then_shows_on_match_end(self): states = iter([True, True, False]) @@ -105,7 +162,7 @@ class HideClientInGameTests(unittest.TestCase): output = io.StringIO() watcher = hide_in_game.Watcher( self.prefix, x11, - window_source=lambda: [0xabc], + window_source=lambda _prefix, _cache: [0xabc], state_source=lambda _prefix: next(states), sleep=lambda _seconds: None, out=output, @@ -123,7 +180,7 @@ class HideClientInGameTests(unittest.TestCase): output = io.StringIO() watcher = hide_in_game.Watcher( self.prefix, x11, - window_source=lambda: [0xdef], + window_source=lambda _prefix, _cache: [0xdef], state_source=lambda _prefix: next(states), sleep=lambda _seconds: None, out=output, @@ -137,7 +194,7 @@ class HideClientInGameTests(unittest.TestCase): x11 = FakeX11() watcher = hide_in_game.Watcher( self.prefix, x11, - window_source=lambda: [0xabc], + window_source=lambda _prefix, _cache: [0xabc], state_source=lambda _prefix: False, sleep=lambda _seconds: None, out=io.StringIO(), diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 2762c46..91264d8 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -117,23 +117,112 @@ def xprop_root(): return re.findall(r'0x[0-9a-fA-F]+', listing) -def window_class(window): +def xprop_value(window, prop): try: - prop = subprocess.check_output( - ['xprop', '-id', window, 'WM_CLASS'], text=True, timeout=5 + return subprocess.check_output( + ['xprop', '-id', window, prop], text=True, timeout=5 ) except (OSError, subprocess.SubprocessError): return '' - return prop.lower() -def target_windows(): - """Managed client windows only; the game window is never selected.""" +def window_class(window): + return xprop_value(window, 'WM_CLASS').lower() + + +def window_pid(window): + match = re.search(r'=\s*(\d+)', xprop_value(window, '_NET_WM_PID')) + return int(match.group(1)) if match else None + + +def _socket_from_process(proc): + try: + environment = dict( + item.split(b'=', 1) + for item in (proc / 'environ').read_bytes().split(b'\0') + if b'=' in item + ) + except (OSError, ValueError): + return None + return environment.get(SOCK_VARIABLE) + + +def host_socket_for_guest(guest_pid, proc_root=Path('/proc')): + """Find the host process for a guest X11 PID and return its loader socket. + + Darling's X11 window stores the guest PID in _NET_WM_PID, while the + process visible in /proc is the host loader. NSpid carries both IDs. The + direct /proc/ case is checked too for a native process. + """ + direct = proc_root / str(guest_pid) + if direct.exists(): + socket = _socket_from_process(direct) + if socket is not None: + return socket + sockets = set() + try: + entries = list(proc_root.iterdir()) + except OSError: + return None + for proc in entries: + if not proc.name.isdigit(): + continue + try: + status = (proc / 'status').read_text() + except OSError: + continue + for line in status.splitlines(): + if not line.startswith('NSpid:'): + continue + if str(guest_pid) not in line.split()[1:]: + continue + socket = _socket_from_process(proc) + if socket is not None: + sockets.add(socket) + return sockets.pop() if len(sockets) == 1 else None + + +def window_belongs_to_prefix(window, prefix): + pid = window_pid(window) + if pid is None: + return False + socket = host_socket_for_guest(pid) + if socket is None: + return False + try: + return Path(os.fsdecode(socket)).parent.resolve() == (prefix / SOCK_NAME).parent.resolve() + except OSError: + return False + + +def target_windows(prefix, class_cache=None): + """Managed client windows for this prefix; the game window is never selected. + + WM_CLASS is cached only when non-empty. The cache is pruned when a window + leaves _NET_CLIENT_LIST; empty results are retried because a class can be + set after the window appears. + """ + current = [] + for raw in xprop_root(): + try: + current.append((int(raw, 16), raw)) + except ValueError: + continue + current_ids = {window for window, _raw in current} + if class_cache is not None: + for stale in set(class_cache) - current_ids: + del class_cache[stale] found = [] - for window in xprop_root(): - classes = window_class(window) - if any(name in classes for name in TARGET_CLASSES): - found.append(int(window, 16)) + for window, raw in current: + classes = class_cache.get(window) if class_cache is not None else None + if not classes: + classes = window_class(raw) + if classes and class_cache is not None: + class_cache[window] = classes + if not classes or not any(name in classes for name in TARGET_CLASSES): + continue + if window_belongs_to_prefix(raw, prefix): + found.append(window) return sorted(set(found)) @@ -179,6 +268,7 @@ class Watcher: self.sleep = sleep self.out = out self.hidden = set() + self.class_cache = {} self.reported_no_window = False def _show_all(self, reason): @@ -195,7 +285,7 @@ class Watcher: self.hidden.clear() def _hide_new(self): - windows = self.window_source() + windows = self.window_source(self.prefix, self.class_cache) if not windows and not self.hidden and not self.reported_no_window: print('hide-league-client-in-game: no managed client window; not hiding', file=self.out, flush=True) @@ -242,11 +332,11 @@ def main(argv=None): help='one game observation and exit (no window changes)') args = parser.parse_args(argv) - if os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': + if not args.once and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': print('hide-league-client-in-game: ignored; RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence', file=sys.stderr, flush=True) return 0 - if os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': + if not args.once and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1', flush=True) return 0 @@ -277,8 +367,8 @@ def main(argv=None): def stop(_signum, _frame): raise KeyboardInterrupt - signal.signal(signal.SIGTERM, stop) - signal.signal(signal.SIGINT, stop) + for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + signal.signal(signum, stop) print('hide-league-client-in-game: watching %s for a running match; ' 'opt out with RCLOL_HIDE_CLIENT_IN_GAME=0' % prefix, flush=True) try: From 8de6bceeae0c8115a3343d7f7acdc545f5bd656f Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 14:56:03 -0600 Subject: [PATCH 152/160] feat(launcher): select client-in-game mode from preferences Add RCLOL_CLIENT_IN_GAME_TRICKS=1 as the practical launcher switch. It reads lol-user-experience from LCULocalPreferences.yaml: - potatoModeEnabled false or unreadable: no action; - closeLeagueClientDuringGame true: close the client UI; - closeLeagueClientDuringGame false: hide the client windows. The existing close and hide switches remain low-level options when the automatic switch is not set. The hide helper accepts the master switch, and the resolver plus preference cases are covered by tests and documentation. The local .desktop entry was validated with the master switch; the installed preferences select hide on this machine. --- .github/workflows/source-check.yml | 1 + README.md | 14 +++-- docs/SETUP.md | 17 +++--- docs/TROUBLESHOOTING.md | 56 +++++++++---------- tests/test_client_in_game_tricks.py | 69 ++++++++++++++++++++++++ tests/test_hide_league_client_in_game.py | 20 ++++++- tools/client-in-game-tricks.py | 57 ++++++++++++++++++++ tools/hide-league-client-in-game.py | 19 ++++--- tools/run-riot-client.sh | 39 ++++++++------ 9 files changed, 231 insertions(+), 61 deletions(-) create mode 100644 tests/test_client_in_game_tricks.py create mode 100755 tools/client-in-game-tricks.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index afc8de7..780121a 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -32,3 +32,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_exec_guest.py' - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' - run: python -m unittest discover -s tests -p 'test_hide_league_client_in_game.py' + - run: python -m unittest discover -s tests -p 'test_client_in_game_tricks.py' diff --git a/README.md b/README.md index 97774de..51a3cf4 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,21 @@ First installation and future Riot updates can still encounter compatibility gap For another region, set `RCLOL_REGION` and `RCLOL_LOCALE` before launch. For example `RCLOL_REGION=NA RCLOL_LOCALE=en_US ./scripts/run.sh`. Linux display selection follows `$DISPLAY`; the legacy launcher fallback is `:1`. -If you want to keep the client open during matches, opt in to hiding its window instead of closing it: +For automatic client handling during a match, opt in with one switch: ```sh -RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh ``` -The helper detects the match, hides the `LeagueClientUx`/`Riot Client` X11 windows, and restores them when the game ends. It does not close the client, patch CEF or touch the game window. It is off by default, requires X11/Xwayland, and `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence. See [troubleshooting](docs/TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) for the measured CPU effect and caveats. +It reads `lol-user-experience` from the installed League client's +`LCULocalPreferences.yaml`. When `potatoModeEnabled` is true, it closes the +client UI if `closeLeagueClientDuringGame` is true; otherwise it hides the +`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game +ends. With client optimizations disabled or unreadable preferences it does +nothing. The `RCLOL_CLOSE_CLIENT_IN_GAME` and `RCLOL_HIDE_CLIENT_IN_GAME` +low-level switches remain available when the automatic switch is not set. See +[troubleshooting](docs/TROUBLESHOOTING.md#automatic-client-handling-during-a-match) +for the measured effect and caveats. ## Testing diff --git a/docs/SETUP.md b/docs/SETUP.md index a83e8b7..2679892 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -78,12 +78,17 @@ opts in to Hyprland-only recovery of a default-prefix Riot session with no visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection) for the observed failures, recovery guards and NSURLSession limitations. -`RCLOL_HIDE_CLIENT_IN_GAME=1` opts in to hiding the managed `LeagueClientUx` -and `Riot Client` X11 windows while a match runs, then showing them when the -game process ends. It keeps the client open and is mutually exclusive with -`RCLOL_CLOSE_CLIENT_IN_GAME=1` (close takes precedence). It is off by default, -requires X11/Xwayland, and does not patch client binaries, CEF or the game. See -[the troubleshooting measurement](TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) +`RCLOL_CLIENT_IN_GAME_TRICKS=1` opts in to automatic client handling during a +match. It reads `lol-user-experience` from the installed client's +`LCULocalPreferences.yaml`: with `potatoModeEnabled: true`, it closes the client +UI when `closeLeagueClientDuringGame: true`, otherwise it hides the managed +`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game +ends. With client optimizations disabled or preferences unreadable it does +nothing. The low-level `RCLOL_CLOSE_CLIENT_IN_GAME=1` and +`RCLOL_HIDE_CLIENT_IN_GAME=1` switches remain available when the automatic +switch is not set. The feature is off by default, requires X11/Xwayland, and +does not patch client binaries, CEF or the game. See +[the troubleshooting measurement](TROUBLESHOOTING.md#automatic-client-handling-during-a-match) for the measured CPU effect and limitations. `RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 2125d45..0c37ba5 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -192,30 +192,39 @@ observation, without waiting for a match: python3 tools/close-league-client-in-game.py --once --dry-run ``` -### Keep the client open but hide it during a match +### Automatic client handling during a match -`LeagueClientUx` can consume a noticeable amount of CPU while the client stays -open during a match. An opt-in alternative to closing it hides the managed -client window instead: +A single opt-in switch reads the client's own preferences and chooses the mode: ```sh -RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh ``` -The launcher detects when a match starts, unmaps the `LeagueClientUx` and -`Riot Client` X11 windows, and maps them again when the game process ends. The -client is not closed, the League backend and Riot client keep running, and the -game window is never hidden. `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence -when both flags are set. +It reads `lol-user-experience` from the installed client's +`LCULocalPreferences.yaml`: -The helper is off by default and window-management only: it does not patch -client binaries, CEF, anti-cheat or DRM. It attributes the game to this prefix -through the loader's `__mldr_sockpath`, and fails closed when ownership, proc -state, environment or window information cannot be read. It requires an X11 or -Xwayland desktop; on a native Wayland window manager without Xwayland it cannot -find the windows. +| Client preference | Launcher action | +|---|---| +| `potatoModeEnabled: false` or unreadable | no client-in-game helper | +| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: true` | close the client UI | +| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: false` | hide the client windows | -On a CPU-bound host the effect appears after CEF has been hidden for a few +The hide path unmaps the `LeagueClientUx` and `Riot Client` X11 windows and maps +them again when the game process ends. The client is not closed, the League +backend and Riot client keep running, and the game window is never hidden. The +close path uses the UI-close helper described above. + +The low-level `RCLOL_HIDE_CLIENT_IN_GAME=1` and +`RCLOL_CLOSE_CLIENT_IN_GAME=1` switches remain available when the automatic +switch is not set. All modes are off by default. The helpers are +window-management only: they do not patch client binaries, CEF, anti-cheat or +DRM. They attribute the game to this prefix through the loader's +`__mldr_sockpath`, and fail closed when ownership, proc state, environment or +window information cannot be read. The hide helper requires an X11 or Xwayland +desktop; on a native Wayland window manager without Xwayland it cannot find the +windows. + +On a CPU-bound host the hide effect appears after CEF has been hidden for a few seconds. In a local 30-second test on an i5-3470 with a Radeon RX 550, hiding `LeagueClientUx` reduced the summed CPU of the matched client/CEF processes from 120.7% to 93.4% (about 23%, or 27 percentage points, of one core); showing the @@ -224,16 +233,9 @@ a game that is GPU-bound, a client with no active UI work, or a different desktop may show less benefit. The hidden window can also need a moment to repaint after it is shown. -`RCLOL_CLOSE_CLIENT_IN_GAME=1` remains the more aggressive option when in-client -chat during the match is not needed. Configure one of the two, not both: - -```sh -# Keep the client open, hide its windows while playing -RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh - -# Or close the client UI while playing -RCLOL_CLOSE_CLIENT_IN_GAME=1 ./scripts/run.sh -``` +With client optimizations enabled and close-during-game enabled, the close +helper remains the more aggressive option when in-client chat during the match +is not needed. Diagnose the match detector without changing any window: diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py new file mode 100644 index 0000000..6477571 --- /dev/null +++ b/tests/test_client_in_game_tricks.py @@ -0,0 +1,69 @@ +"""Select the automatic client-in-game mode from LCULocalPreferences.yaml.""" +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + 'client_in_game_tricks', ROOT / 'tools/client-in-game-tricks.py') +tricks = importlib.util.module_from_spec(spec) +spec.loader.exec_module(tricks) + + +class ClientInGameTricksTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name) / 'LCULocalPreferences.yaml' + + def write(self, optimization=None, close=None): + lines = ['lol-user-experience:', ' data:'] + if optimization is not None: + lines.append(f' potatoModeEnabled: {optimization}') + if close is not None: + lines.append(f' closeLeagueClientDuringGame: {close}') + self.path.write_text('\n'.join(lines) + '\n') + + def test_close_when_both_preferences_are_true(self): + self.write(True, True) + self.assertEqual(tricks.mode(self.path), 'close') + + def test_hide_when_optimizations_are_on_and_close_is_false(self): + self.write(True, False) + self.assertEqual(tricks.mode(self.path), 'hide') + + def test_hide_when_close_preference_is_missing(self): + self.write(True) + self.assertEqual(tricks.mode(self.path), 'hide') + + def test_none_when_optimizations_are_off(self): + self.write(False, True) + self.assertEqual(tricks.mode(self.path), 'none') + + def test_none_when_optimizations_are_missing_or_file_is_unreadable(self): + self.write(None, True) + self.assertEqual(tricks.mode(self.path), 'none') + self.assertEqual(tricks.mode(self.path.with_name('missing.yaml')), 'none') + + def test_last_duplicate_value_wins_and_quotes_are_accepted(self): + self.path.write_text( + 'potatoModeEnabled: false\n' + 'closeLeagueClientDuringGame: false\n' + 'potatoModeEnabled: "true"\n' + 'closeLeagueClientDuringGame: "true"\n') + self.assertEqual(tricks.mode(self.path), 'close') + + def test_main_prints_mode(self): + self.write(True, False) + output = io.StringIO() + with patch('sys.stdout', output): + self.assertEqual(tricks.main([str(self.path)]), 0) + self.assertEqual(output.getvalue().strip(), 'hide') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index 87f4e05..95be689 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -5,7 +5,7 @@ import os from pathlib import Path import tempfile import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch ROOT = Path(__file__).resolve().parents[1] spec = importlib.util.spec_from_file_location( @@ -217,6 +217,24 @@ class HideClientInGameTests(unittest.TestCase): }): self.assertEqual(hide_in_game.main([]), 0) + def test_master_tricks_switch_enables_hide(self): + class FakeX11: + def close(self): + pass + + watcher = Mock() + with patch.dict(os.environ, { + 'RCLOL_CLIENT_IN_GAME_TRICKS': '1', + 'RCLOL_HIDE_CLIENT_IN_GAME': '', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '', + }), patch.object(hide_in_game, 'target_prefix', + return_value=self.prefix), \ + patch.object(hide_in_game, 'X11', return_value=FakeX11()), \ + patch.object(hide_in_game, 'Watcher', return_value=watcher): + self.assertEqual(hide_in_game.main([]), 0) + watcher.run.assert_called_once_with() + watcher.restore.assert_called_once_with() + if __name__ == '__main__': unittest.main() diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py new file mode 100755 index 0000000..bdc8d6b --- /dev/null +++ b/tools/client-in-game-tricks.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Resolve the client-in-game mode from League's local preferences. + +The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads +`lol-user-experience` from LCULocalPreferences.yaml: + +- potatoModeEnabled must be true (client optimizations enabled); +- closeLeagueClientDuringGame true -> close the client UI while a match runs; +- closeLeagueClientDuringGame false -> hide the client windows instead; +- missing/unreadable preferences -> no action (none). + +This helper only selects a mode. It does not change preferences or files. +""" + +import re +import sys +from pathlib import Path + +CLOSE_KEY = 'closeLeagueClientDuringGame' +OPTIMIZATION_KEY = 'potatoModeEnabled' +TRUE_VALUES = {'true', 'yes', '1'} +FALSE_VALUES = {'false', 'no', '0'} + + +def read_bool(path, key): + """Return True/False for the last value, or None when absent/unreadable.""" + try: + lines = Path(path).read_text().splitlines() + except (OSError, UnicodeError): + return None + value = None + for line in lines: + match = re.match(r'^\s*' + re.escape(key) + r':\s*(\S+)', line) + if match: + value = match.group(1).strip('"\'').lower() + if value in TRUE_VALUES: + return True + if value in FALSE_VALUES: + return False + return None + + +def mode(path): + if read_bool(path, OPTIMIZATION_KEY) is not True: + return 'none' + return 'close' if read_bool(path, CLOSE_KEY) is True else 'hide' + + +def main(argv=None): + argv = sys.argv[1:] if argv is None else argv + path = argv[0] if argv else '' + print(mode(path)) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 91264d8..68ad0b9 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 """Opt-in: hide the League client UI while a match is running. -`RCLOL_HIDE_CLIENT_IN_GAME=1` is an alternative to closing the client UI with -`RCLOL_CLOSE_CLIENT_IN_GAME=1`. It hides the managed X11 windows for -`LeagueClientUx` and `Riot Client`, and shows them again when the game process -exits. The Riot client, League backend, CEF helpers and game keep running. +The launcher's `RCLOL_CLIENT_IN_GAME_TRICKS=1` mode selects this helper when +the client's own close-during-game preference is false. The low-level +`RCLOL_HIDE_CLIENT_IN_GAME=1` switch remains available for direct launches. It +hides the managed X11 windows for `LeagueClientUx` and `Riot Client`, and shows +them again when the game process exits. The Riot client, League backend, CEF +helpers and game keep running. This is launcher/window management only. It does not patch client binaries, CEF, anti-cheat or DRM, and it never hides the game window. It is off by @@ -332,13 +334,14 @@ def main(argv=None): help='one game observation and exit (no window changes)') args = parser.parse_args(argv) - if not args.once and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': + tricks = os.environ.get('RCLOL_CLIENT_IN_GAME_TRICKS') == '1' + if not args.once and not tricks and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': print('hide-league-client-in-game: ignored; RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence', file=sys.stderr, flush=True) return 0 - if not args.once and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': - print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1', - flush=True) + if not args.once and not tricks and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': + print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1 or ' + 'RCLOL_CLIENT_IN_GAME_TRICKS=1', flush=True) return 0 prefix = target_prefix() if prefix is None: diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 88dc083..7d76a6d 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -12,23 +12,30 @@ runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then python3 "$runner_dir/recover-hidden-riot-client.py" fi -# Opt-in: close the League client UI while a match runs. It keeps the CPU for the game -# and leaves the client the backend starts when the match ends as the only CEF instance; -# a second instance on the same profile never paints its window, which is the black -# window seen with Riot's own "Close client during game" option. Off by default. -if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then +# Opt-in automatic client-in-game handling. This single switch reads the +# client's own lol-user-experience preferences: close the UI when the user +# selected "close client during game", otherwise hide the client windows. +# It does nothing unless potatoModeEnabled (client optimizations) is true. +if [ "${RCLOL_CLIENT_IN_GAME_TRICKS:-0}" = 1 ]; then + pref="$prefix_dir/Applications/League of Legends.app/Contents/LoL/Config/LCULocalPreferences.yaml" + mode=$(python3 "$runner_dir/client-in-game-tricks.py" "$pref" 2>/dev/null || echo none) + case "$mode" in + close) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=true -> close helper' >&2 + python3 "$runner_dir/close-league-client-in-game.py" --detach & + ;; + hide) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=false -> hide helper' >&2 + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + ;; + *) + echo 'client-in-game-tricks: no action (client optimizations disabled or preferences unreadable)' >&2 + ;; + esac +elif [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then python3 "$runner_dir/close-league-client-in-game.py" --detach & -fi -# Opt-in alternative for users who keep the client open during a match: hide -# the managed client windows instead of closing the UI. It never touches the -# League backend, the Riot client or the game window; it restores the window -# when the game process ends. Close and hide are mutually exclusive. -if [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then - if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then - echo 'RCLOL_HIDE_CLIENT_IN_GAME ignored: RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence' >&2 - else - python3 "$runner_dir/hide-league-client-in-game.py" --detach & - fi +elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then + python3 "$runner_dir/hide-league-client-in-game.py" --detach & fi # The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN # AAAA lookups are suppressed; set this to 0 to retain the upstream resolver. From 9bcd41e12f4c4e073ab638a501c61bbbf192e0b3 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 15:07:47 -0600 Subject: [PATCH 153/160] fix(launcher): fail closed on automatic mode edge cases - restrict preference reads to the lol-user-experience section; - distinguish a failed close-preference read from a missing key; - reject xprop's 'not found' text as a WM_CLASS value; - track a window before unmap so an interrupting signal still restores it; - report the tricks-mode opt-out in the hide helper's startup message; - cover the new cases with preference, window and interruption tests. --- tests/test_client_in_game_tricks.py | 26 +++++++++++-- tests/test_hide_league_client_in_game.py | 43 +++++++++++++++++++++ tools/client-in-game-tricks.py | 49 +++++++++++++++++------- tools/hide-league-client-in-game.py | 10 +++-- 4 files changed, 107 insertions(+), 21 deletions(-) diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py index 6477571..4b19d28 100644 --- a/tests/test_client_in_game_tricks.py +++ b/tests/test_client_in_game_tricks.py @@ -51,12 +51,30 @@ class ClientInGameTricksTests(unittest.TestCase): def test_last_duplicate_value_wins_and_quotes_are_accepted(self): self.path.write_text( - 'potatoModeEnabled: false\n' - 'closeLeagueClientDuringGame: false\n' - 'potatoModeEnabled: "true"\n' - 'closeLeagueClientDuringGame: "true"\n') + 'lol-user-experience:\n' + ' data:\n' + ' potatoModeEnabled: false\n' + ' closeLeagueClientDuringGame: false\n' + ' potatoModeEnabled: "true"\n' + ' closeLeagueClientDuringGame: "true"\n') self.assertEqual(tricks.mode(self.path), 'close') + def test_keys_outside_the_named_section_are_ignored(self): + self.path.write_text( + 'lol-user-experience:\n' + ' data:\n' + ' potatoModeEnabled: true\n' + 'other-section:\n' + ' potatoModeEnabled: true\n' + ' closeLeagueClientDuringGame: true\n') + self.assertEqual(tricks.mode(self.path), 'hide') + + def test_second_read_failure_fails_closed(self): + self.write(True, False) + text = self.path.read_text() + with patch.object(Path, 'read_text', side_effect=[text, OSError()]): + self.assertEqual(tricks.mode(self.path), 'none') + def test_main_prints_mode(self): self.write(True, False) output = io.StringIO() diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index 95be689..946bbad 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -156,6 +156,49 @@ class HideClientInGameTests(unittest.TestCase): self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10]) self.assertEqual(classes.call_count, 1) + def test_missing_wm_class_is_not_cached_as_a_class(self): + with patch.object(hide_in_game, 'xprop_value', + return_value='WM_CLASS: not found.'): + self.assertEqual(hide_in_game.window_class('0x10'), '') + + def test_unmap_failure_removes_the_window_from_hidden(self): + class FailingX11(FakeX11): + def unmap(self, window): + raise RuntimeError('unmap failed') + + watcher = hide_in_game.Watcher( + self.prefix, FailingX11(), + window_source=lambda _prefix, _cache: [0xabc], + state_source=lambda _prefix: True, + sleep=lambda _seconds: None, + out=io.StringIO(), + ) + watcher._hide_new() + self.assertEqual(watcher.hidden, set()) + + def test_interrupted_unmap_keeps_the_window_tracked(self): + shown = [] + + class InterruptingX11: + def unmap(self, window): + raise KeyboardInterrupt + + def show(self, window): + shown.append(window) + + watcher = hide_in_game.Watcher( + self.prefix, InterruptingX11(), + window_source=lambda _prefix, _cache: [0xabc], + state_source=lambda _prefix: True, + sleep=lambda _seconds: None, + out=io.StringIO(), + ) + with self.assertRaises(KeyboardInterrupt): + watcher._hide_new() + self.assertEqual(watcher.hidden, {0xabc}) + watcher.restore() + self.assertEqual(shown, [0xabc]) + def test_hides_then_shows_on_match_end(self): states = iter([True, True, False]) x11 = FakeX11() diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py index bdc8d6b..adf28c6 100755 --- a/tools/client-in-game-tricks.py +++ b/tools/client-in-game-tricks.py @@ -7,32 +7,45 @@ The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads - potatoModeEnabled must be true (client optimizations enabled); - closeLeagueClientDuringGame true -> close the client UI while a match runs; - closeLeagueClientDuringGame false -> hide the client windows instead; -- missing/unreadable preferences -> no action (none). +- missing preferences or an unreadable file -> no action (none). -This helper only selects a mode. It does not change preferences or files. +Only the named `lol-user-experience` section is considered, so identical keys +in another section cannot select a helper. This helper only selects a mode; it +does not change preferences or files. """ -import re import sys from pathlib import Path +SECTION = 'lol-user-experience' CLOSE_KEY = 'closeLeagueClientDuringGame' OPTIMIZATION_KEY = 'potatoModeEnabled' TRUE_VALUES = {'true', 'yes', '1'} FALSE_VALUES = {'false', 'no', '0'} -def read_bool(path, key): - """Return True/False for the last value, or None when absent/unreadable.""" - try: - lines = Path(path).read_text().splitlines() - except (OSError, UnicodeError): - return None +def read_bool(path, key, section=SECTION): + """Return True/False for the last key in section, or None when absent. + + OSError/UnicodeError is raised for an unreadable file so callers can fail + closed instead of treating a read race as a missing preference. + """ + lines = Path(path).read_text().splitlines() + in_section = False value = None for line in lines: - match = re.match(r'^\s*' + re.escape(key) + r':\s*(\S+)', line) - if match: - value = match.group(1).strip('"\'').lower() + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + if not line.startswith((' ', '\t')): + if stripped == section + ':': + in_section = True + elif in_section: + break + continue + if not in_section or not stripped.startswith(key + ':'): + continue + value = stripped.split(':', 1)[1].strip().strip('"\'').lower() if value in TRUE_VALUES: return True if value in FALSE_VALUES: @@ -41,9 +54,17 @@ def read_bool(path, key): def mode(path): - if read_bool(path, OPTIMIZATION_KEY) is not True: + try: + optimization = read_bool(path, OPTIMIZATION_KEY) + except (OSError, UnicodeError): return 'none' - return 'close' if read_bool(path, CLOSE_KEY) is True else 'hide' + if optimization is not True: + return 'none' + try: + close = read_bool(path, CLOSE_KEY) + except (OSError, UnicodeError): + return 'none' + return 'close' if close is True else 'hide' def main(argv=None): diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 68ad0b9..bf6f212 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -129,7 +129,8 @@ def xprop_value(window, prop): def window_class(window): - return xprop_value(window, 'WM_CLASS').lower() + value = xprop_value(window, 'WM_CLASS').lower() + return value if '=' in value else '' def window_pid(window): @@ -295,11 +296,12 @@ class Watcher: for window in windows: if window in self.hidden: continue + self.hidden.add(window) try: self.x11.unmap(window) - self.hidden.add(window) print(f'hide-league-client-in-game: hidden 0x{window:x}', file=self.out, flush=True) except Exception as error: + self.hidden.discard(window) print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', file=sys.stderr, flush=True) @@ -372,8 +374,10 @@ def main(argv=None): for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): signal.signal(signum, stop) + optout = ('RCLOL_CLIENT_IN_GAME_TRICKS=0' if tricks + else 'RCLOL_HIDE_CLIENT_IN_GAME=0') print('hide-league-client-in-game: watching %s for a running match; ' - 'opt out with RCLOL_HIDE_CLIENT_IN_GAME=0' % prefix, flush=True) + 'opt out with %s' % (prefix, optout), flush=True) try: watcher.run() except KeyboardInterrupt: From 4ef09cb92a2ef9ba7d9ad2ae0514d5faaeaf6a39 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 15:27:33 -0600 Subject: [PATCH 154/160] fix(launcher): recover a stale client tree without restarting Darling Darlingserver normally stays alive after the Riot Client or game is closed. If the client window disappears but Riot/League/CEF processes remain, restarting via run.sh reuses a half-dead runtime and can fail to open a window. Add an opt-in RCLOL_RECOVER_STALE_CLIENT=1 preflight. When a darlingserver is alive, no game is running, no managed client window is visible and stale client processes belong to this prefix, stop only that client/CEF tree. Preserve darlingserver, launchd/mldr, sockets and .init.pid, then continue with the normal product launch. The helper fails closed when X11 is unavailable, never touches a running game or a visible client window, and does not patch client binaries, CEF or assets. It saves the Darling bootstrap, but not Riot Client/CEF startup; a full runtime restart remains the fallback if no window appears. --- .github/workflows/source-check.yml | 1 + README.md | 8 ++ docs/SETUP.md | 8 ++ docs/TROUBLESHOOTING.md | 9 ++ tests/test_recover_stale_client.py | 110 +++++++++++++++ tools/recover-stale-client.py | 212 +++++++++++++++++++++++++++++ tools/run-riot-client.sh | 6 + 7 files changed, 354 insertions(+) create mode 100644 tests/test_recover_stale_client.py create mode 100755 tools/recover-stale-client.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index a866b9d..df99278 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -31,3 +31,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_metal_device_info.py' - run: python -m unittest discover -s tests -p 'test_exec_guest.py' - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' + - run: python -m unittest discover -s tests -p 'test_recover_stale_client.py' diff --git a/README.md b/README.md index b6144f7..111ae26 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,14 @@ First installation and future Riot updates can still encounter compatibility gap For another region, set `RCLOL_REGION` and `RCLOL_LOCALE` before launch. For example `RCLOL_REGION=NA RCLOL_LOCALE=en_US ./scripts/run.sh`. Linux display selection follows `$DISPLAY`; the legacy launcher fallback is `:1`. +If the game or Riot client was closed but `darlingserver` is still running, opt in to partial recovery before the next launch: + +```sh +RCLOL_RECOVER_STALE_CLIENT=1 ./scripts/run.sh +``` + +It stops only a windowless Riot/League/CEF tree, preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`, and then starts the product normally. It does nothing while a game or managed client window is present and fails closed when it cannot inspect X11. See [troubleshooting](docs/TROUBLESHOOTING.md#client-remains-running-without-a-window). + ## Testing Start with standalone regression checks, then play a match manually. See [testing](docs/TESTING.md). Do not attach a debugger, query client APIs, inject diagnostic hooks or automate client/game input. This project ships no client-side automation, inspection or profiling helpers. diff --git a/docs/SETUP.md b/docs/SETUP.md index 0c0d39b..e3cafa8 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -78,6 +78,14 @@ opts in to Hyprland-only recovery of a default-prefix Riot session with no visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection) for the observed failures, recovery guards and NSURLSession limitations. +`RCLOL_RECOVER_STALE_CLIENT=1` opts in to partial runtime recovery before +launch. If `darlingserver` is alive, no game is running, no managed client +window is visible and stale Riot/League/CEF processes remain, the launcher stops +only that client tree and reuses the runtime. It preserves `darlingserver`, +launchd/mldr, sockets and `.init.pid`; if X11/Xwayland cannot be inspected it +does nothing. See +[troubleshooting](TROUBLESHOOTING.md#client-remains-running-without-a-window). + `RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before the guest starts: `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `XDG_CURRENT_DESKTOP`, GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit (`QT_*`, `GTK_*`, `GDK_*`), diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 13a2f7b..64d4289 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -149,6 +149,15 @@ ownership or window information cannot be read. A hidden background client or download is restarted when recovery is enabled. Other desktop environments are not supported by this helper; recovery is off by default. +For a live Darling runtime with no visible client window but stale Riot/League/ +CEF processes, `RCLOL_RECOVER_STALE_CLIENT=1` performs a partial recovery before +the next launch. It keeps `darlingserver`, launchd/mldr, sockets and `.init.pid`, +stops only the stale client process tree, and then lets the normal launcher start +the product again. It does nothing while a game process is running or when a +managed client window is visible. With X11/Xwayland unavailable it fails closed. +This saves the Darling bootstrap but not the Riot Client/CEF startup itself. If +no window appears afterward, use a full runtime shutdown/restart. + ### Client window stays black after a match The League client keeps its Chromium/CEF UI (`LeagueClientUx` and its helpers) diff --git a/tests/test_recover_stale_client.py b/tests/test_recover_stale_client.py new file mode 100644 index 0000000..743907f --- /dev/null +++ b/tests/test_recover_stale_client.py @@ -0,0 +1,110 @@ +"""Test the opt-in partial client-tree recovery with fake /proc entries.""" +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + 'recover_stale_client', ROOT / 'tools/recover-stale-client.py') +recover_stale_client = importlib.util.module_from_spec(spec) +spec.loader.exec_module(recover_stale_client) + +GAME = '/Applications/League of Legends.app/Contents/LoL/LeagueofLegends' +CLIENT = '/Users/Shared/Riot Games/Riot Client.app/Contents/MacOS/RiotClientServices' + + +class RecoverStaleClientTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + self.prefix = self.root / '.darling' + + def process(self, pid, comm, argv, env=None, executable=None): + folder = self.proc / str(pid) + folder.mkdir() + (folder / 'comm').write_text(comm + '\n') + (folder / 'cmdline').write_bytes( + (argv[0] if executable is None else executable).encode() + b'\0' + + b''.join(arg.encode() + b'\0' for arg in argv[1:])) + (folder / 'stat').write_text(f'{pid} ({comm}) S') + if env is not None: + (folder / 'environ').write_bytes(env) + return folder + + def client_env(self, prefix=None): + prefix = self.prefix if prefix is None else prefix + return b'%s=%s\0' % ( + recover_stale_client.SOCK_VARIABLE, + os.fsencode(prefix / recover_stale_client.SOCK_NAME), + ) + + def run_recover(self, window=False): + killed = [] + + def kill(pid, signum): + killed.append((pid, signum)) + + out = io.StringIO() + with patch.object(recover_stale_client, 'visible_client_window', + return_value=window): + result = recover_stale_client.recover( + self.prefix, proc_root=self.proc, window_check=lambda: window, + kill=kill, sleep=lambda _seconds: None, out=out) + return result, killed, out.getvalue() + + def test_no_darlingserver_does_nothing(self): + self.process(10, 'mldr', ['/sbin/launchd']) + result, killed, output = self.run_recover() + self.assertEqual((result, killed), (0, [])) + self.assertIn('no darlingserver', output) + + def test_game_is_never_touched(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', [GAME]) + result, killed, output = self.run_recover() + self.assertEqual((result, killed), (0, [])) + self.assertIn('game running', output) + + def test_visible_window_does_nothing(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', [CLIENT], self.client_env()) + result, killed, output = self.run_recover(window=True) + self.assertEqual((result, killed), (0, [])) + self.assertIn('client window present', output) + + def test_stale_client_is_killed_but_runtime_is_preserved(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', ['/sbin/launchd']) + self.process(12, 'mldr', [CLIENT], self.client_env()) + result, killed, output = self.run_recover() + self.assertEqual(result, 0) + self.assertEqual([pid for pid, _sig in killed], [12, 12]) + self.assertIn('stale client tree', output) + + def test_other_prefix_and_game_are_preserved(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', [CLIENT], self.client_env(self.root / 'other')) + self.process(12, 'mldr', [GAME], self.client_env()) + result, killed, output = self.run_recover() + self.assertEqual((result, killed), (0, [])) + self.assertIn('game running', output) + + def test_runtime_process_names_are_not_killed(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', ['/sbin/launchd']) + self.process(12, 'mldr', [CLIENT], self.client_env()) + result, killed, _output = self.run_recover() + self.assertEqual(result, 0) + self.assertNotIn(10, [pid for pid, _sig in killed]) + self.assertNotIn(11, [pid for pid, _sig in killed]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/recover-stale-client.py b/tools/recover-stale-client.py new file mode 100755 index 0000000..3c29805 --- /dev/null +++ b/tools/recover-stale-client.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Partially recover a live Darling runtime with a stale client tree. + +This is opt-in through RCLOL_RECOVER_STALE_CLIENT=1. It only acts when all +of these are true: + +- a darlingserver is running; +- no League game process is running; +- no managed Riot/League client window is visible; +- one or more Riot/League client, CEF or crash-handler processes are still + present for this prefix. + +It terminates only those client processes with SIGTERM/SIGKILL. The +darlingserver, launchd/mldr services, sockets and .init.pid stay intact so the +next launcher run can reuse the runtime. It never patches files or the game. +""" + +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + +GAME_NAME = 'LeagueofLegends' +WINDOW_CLASSES = ('leagueclientux', 'riot client') +CLIENT_MARKERS = ( + '/Applications/League of Legends.app', + '/Applications/Riot Client.app', + '/Users/Shared/Riot Games/Riot Client.app', + 'RiotClientServices', + 'Riot Client', + 'LeagueClient', + 'LeagueClientUx', + 'LeagueCrashHandler', +) +RUNTIME_NAMES = ('darlingserver', 'darling') +SOCK_VARIABLE = b'__mldr_sockpath' +SOCK_NAME = '.darlingserver.sock' + + +def target_prefix(): + value = os.environ.get('DPREFIX', str(Path.home() / '.darling')) + prefix = Path(value).expanduser() + return prefix if prefix.is_absolute() else None + + +def command_names(proc): + try: + raw = (proc / 'cmdline').read_bytes() + except OSError: + return [] + return [ + os.path.basename(part.decode(errors='replace')) + for part in raw.split(b'\0') + if part + ] + + +def command_text(proc): + try: + raw = (proc / 'cmdline').read_bytes() + except OSError: + return '' + return b' '.join(part for part in raw.split(b'\0') if part).decode( + errors='replace') + + +def process_state(proc): + try: + return (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0] + except (OSError, IndexError): + return None + + +def server_running(proc_root=Path('/proc')): + try: + entries = list(proc_root.iterdir()) + except OSError: + return False + for proc in entries: + if not proc.name.isdigit(): + continue + try: + if (proc / 'comm').read_text().strip() == 'darlingserver': + return True + except OSError: + pass + return False + + +def game_running(proc_root=Path('/proc')): + for proc in proc_root.iterdir() if proc_root.exists() else (): + if not proc.name.isdigit(): + continue + names = command_names(proc) + if GAME_NAME in names: + return True + return False + + +def visible_client_window(): + """Return True when a managed client window is present. + + A missing xprop or display is treated as 'cannot prove there is no + window' so we do not kill a possibly working client. + """ + try: + listing = subprocess.check_output( + ['xprop', '-root', '_NET_CLIENT_LIST'], text=True, timeout=5 + ) + except (OSError, subprocess.SubprocessError): + return True + for raw in listing.split(): + if not raw.startswith('0x'): + continue + try: + classes = subprocess.check_output( + ['xprop', '-id', raw, 'WM_CLASS'], text=True, timeout=5 + ).lower() + except (OSError, subprocess.SubprocessError): + return True + if any(name in classes for name in WINDOW_CLASSES): + return True + return False + + +def client_pids(prefix, proc_root=Path('/proc')): + """Client/CEF PIDs belonging to prefix, excluding Darling and the game.""" + owner = (prefix / SOCK_NAME).parent.resolve() + found = [] + try: + entries = list(proc_root.iterdir()) + except OSError: + return [] + for proc in entries: + if not proc.name.isdigit() or process_state(proc) in ('Z', None): + continue + try: + if proc.stat().st_uid != os.getuid(): + continue + names = command_names(proc) + if GAME_NAME in names or any(name in RUNTIME_NAMES for name in names): + continue + text = command_text(proc) + if not any(marker in text for marker in CLIENT_MARKERS): + continue + try: + env = dict( + item.split(b'=', 1) + for item in (proc / 'environ').read_bytes().split(b'\0') + if b'=' in item + ) + except OSError: + continue + socket = env.get(SOCK_VARIABLE) + if socket is None: + continue + if Path(os.fsdecode(socket)).parent.resolve() != owner: + continue + found.append(int(proc.name)) + except (OSError, ValueError): + continue + return sorted(found) + + +def recover(prefix, proc_root=Path('/proc'), window_check=visible_client_window, + kill=os.kill, sleep=time.sleep, out=sys.stdout): + if not server_running(proc_root): + print('recover-stale-client: no darlingserver; normal startup', file=out, flush=True) + return 0 + if game_running(proc_root): + print('recover-stale-client: game running; no action', file=out, flush=True) + return 0 + if window_check(): + print('recover-stale-client: client window present; no action', file=out, flush=True) + return 0 + pids = client_pids(prefix, proc_root) + if not pids: + print('recover-stale-client: no stale client processes; no action', file=out, flush=True) + return 0 + print('recover-stale-client: stale client tree without a window; ' + 'terminating %d process(es)' % len(pids), file=out, flush=True) + for pid in pids: + try: + kill(pid, signal.SIGTERM) + except OSError: + pass + sleep(4) + for pid in pids: + try: + kill(pid, signal.SIGKILL) + except OSError: + pass + return 0 + + +def main(): + if os.environ.get('RCLOL_RECOVER_STALE_CLIENT') != '1': + print('recover-stale-client: disabled; set RCLOL_RECOVER_STALE_CLIENT=1', + flush=True) + return 0 + prefix = target_prefix() + if prefix is None: + print('recover-stale-client: unusable DPREFIX; no action', + file=sys.stderr, flush=True) + return 1 + return recover(prefix) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 767294b..085434e 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -9,6 +9,12 @@ if [ "${RCLOL_CORE_DUMPS:-0}" != 1 ]; then fi runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) . "$runner_dir/runtime-paths.sh" +# Opt-in partial recovery: if Darling is alive but a previous client tree is +# windowless, stop only that client/CEF tree and reuse the runtime. Never +# touches the game, darlingserver or the runtime sockets. +if [ "${RCLOL_RECOVER_STALE_CLIENT:-0}" = 1 ]; then + python3 "$runner_dir/recover-stale-client.py" +fi if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then python3 "$runner_dir/recover-hidden-riot-client.py" fi From 9172bb6c3d54fe5847e457bfcffc5b135f56a53b Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 15:30:45 -0600 Subject: [PATCH 155/160] fix(launcher): handle YAML comments and log failures - strip inline YAML comments from preference values, including quoted values; - keep a successfully unmapped window tracked when the success log raises BrokenPipeError, while still discarding a window whose unmap failed; - cover both cases with tests. --- tests/test_client_in_game_tricks.py | 8 ++++++++ tests/test_hide_league_client_in_game.py | 18 ++++++++++++++++++ tools/client-in-game-tricks.py | 9 ++++++++- tools/hide-league-client-in-game.py | 8 +++++++- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py index 4b19d28..53a0db3 100644 --- a/tests/test_client_in_game_tricks.py +++ b/tests/test_client_in_game_tricks.py @@ -69,6 +69,14 @@ class ClientInGameTricksTests(unittest.TestCase): ' closeLeagueClientDuringGame: true\n') self.assertEqual(tricks.mode(self.path), 'hide') + def test_inline_yaml_comments_are_ignored(self): + self.path.write_text( + 'lol-user-experience:\n' + ' data:\n' + ' potatoModeEnabled: true # client optimizations\n' + ' closeLeagueClientDuringGame: "true" # close during matches\n') + self.assertEqual(tricks.mode(self.path), 'close') + def test_second_read_failure_fails_closed(self): self.write(True, False) text = self.path.read_text() diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index 946bbad..f03e8c1 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -199,6 +199,24 @@ class HideClientInGameTests(unittest.TestCase): watcher.restore() self.assertEqual(shown, [0xabc]) + def test_log_failure_does_not_discard_an_unmapped_window(self): + class BrokenOutput: + def write(self, _text): + raise BrokenPipeError + + def flush(self): + pass + + watcher = hide_in_game.Watcher( + self.prefix, FakeX11(), + window_source=lambda _prefix, _cache: [0xabc], + state_source=lambda _prefix: True, + sleep=lambda _seconds: None, + out=BrokenOutput(), + ) + watcher._hide_new() + self.assertEqual(watcher.hidden, {0xabc}) + def test_hides_then_shows_on_match_end(self): states = iter([True, True, False]) x11 = FakeX11() diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py index adf28c6..1b7a314 100755 --- a/tools/client-in-game-tricks.py +++ b/tools/client-in-game-tricks.py @@ -45,7 +45,14 @@ def read_bool(path, key, section=SECTION): continue if not in_section or not stripped.startswith(key + ':'): continue - value = stripped.split(':', 1)[1].strip().strip('"\'').lower() + value = stripped.split(':', 1)[1].strip() + if value[:1] in ('"', "'"): + quote = value[0] + end = value.find(quote, 1) + value = value[1:end] if end != -1 else value[1:] + else: + value = value.split('#', 1)[0].strip() + value = value.lower() if value in TRUE_VALUES: return True if value in FALSE_VALUES: diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index bf6f212..22b9ca2 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -299,11 +299,17 @@ class Watcher: self.hidden.add(window) try: self.x11.unmap(window) - print(f'hide-league-client-in-game: hidden 0x{window:x}', file=self.out, flush=True) except Exception as error: self.hidden.discard(window) print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', file=sys.stderr, flush=True) + continue + try: + print(f'hide-league-client-in-game: hidden 0x{window:x}', + file=self.out, flush=True) + except Exception: + # Logging must not lose a successfully unmapped window. + pass def run(self, max_steps=None): steps = 0 From c12ab8455952a78719b55c42c76170602c3e3162 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 15:50:38 -0600 Subject: [PATCH 156/160] fix(launcher): remove orphaned client-in-game helpers A launcher can exit while its long-lived close/hide helper remains. The next run then starts another watcher, so several copies watch the same prefix. Add an opt-in RCLOL_CLEAN_STALE_HELPERS=1 preflight that removes only helper processes of this prefix reparented to init/systemd. Helpers of a live launcher, other prefixes, the game, Riot/League/CEF, darlingserver and the runtime are preserved. Unreadable or relative process data leaves a process alone. Validated across repeated launcher starts: the cleanup removed four, then one, then one orphaned watcher; a subsequent dry run reported no orphaned helpers. Zombie reaping after partial recovery is tracked separately in #124. --- .github/workflows/source-check.yml | 1 + README.md | 12 ++ docs/SETUP.md | 9 ++ docs/TROUBLESHOOTING.md | 24 ++++ tests/test_clean_stale_helpers.py | 110 +++++++++++++++++ tools/clean-stale-helpers.py | 183 +++++++++++++++++++++++++++++ tools/run-riot-client.sh | 7 ++ 7 files changed, 346 insertions(+) create mode 100644 tests/test_clean_stale_helpers.py create mode 100755 tools/clean-stale-helpers.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index df99278..9594dc3 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -32,3 +32,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_exec_guest.py' - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' - run: python -m unittest discover -s tests -p 'test_recover_stale_client.py' + - run: python -m unittest discover -s tests -p 'test_clean_stale_helpers.py' diff --git a/README.md b/README.md index 111ae26..2e7ebf0 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,18 @@ RCLOL_RECOVER_STALE_CLIENT=1 ./scripts/run.sh It stops only a windowless Riot/League/CEF tree, preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`, and then starts the product normally. It does nothing while a game or managed client window is present and fails closed when it cannot inspect X11. See [troubleshooting](docs/TROUBLESHOOTING.md#client-remains-running-without-a-window). +If repeated launches leave several `close-league-client-in-game.py` watchers, +opt in to removing the orphaned ones before the new helper starts: + +```sh +RCLOL_CLEAN_STALE_HELPERS=1 ./scripts/run.sh +``` + +It only removes helpers of this prefix whose parent is `init`/`systemd` (the +launcher that started them has exited). A helper of a live launcher, another +prefix, the game, Riot/League/CEF, `darlingserver` and the runtime are never +touched. See [troubleshooting](docs/TROUBLESHOOTING.md#multiple-client-in-game-helpers). + ## Testing Start with standalone regression checks, then play a match manually. See [testing](docs/TESTING.md). Do not attach a debugger, query client APIs, inject diagnostic hooks or automate client/game input. This project ships no client-side automation, inspection or profiling helpers. diff --git a/docs/SETUP.md b/docs/SETUP.md index e3cafa8..c8178a6 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -86,6 +86,15 @@ launchd/mldr, sockets and `.init.pid`; if X11/Xwayland cannot be inspected it does nothing. See [troubleshooting](TROUBLESHOOTING.md#client-remains-running-without-a-window). +`RCLOL_CLEAN_STALE_HELPERS=1` runs a preflight before the match helper starts. +It removes `close-league-client-in-game.py` and +`hide-league-client-in-game.py` processes of this prefix whose parent is +`init`/`systemd`, which means the launcher that started them has exited. A +helper of a live launcher, another prefix, the game, Riot/League/CEF and +`darlingserver` are preserved. It fails closed when a command line, +environment, stat entry or parent cannot be read. See +[troubleshooting](TROUBLESHOOTING.md#multiple-client-in-game-helpers). + `RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before the guest starts: `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `XDG_CURRENT_DESKTOP`, GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit (`QT_*`, `GTK_*`, `GDK_*`), diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 64d4289..ee79ad9 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -158,6 +158,30 @@ managed client window is visible. With X11/Xwayland unavailable it fails closed. This saves the Darling bootstrap but not the Riot Client/CEF startup itself. If no window appears afterward, use a full runtime shutdown/restart. +### Multiple client-in-game helpers + +The close/hide helper is a long-lived watcher. If an earlier launcher exits +while it remains, the next launch can start another copy; list them with: + +```sh +pgrep -af 'close-league-client-in-game.py|hide-league-client-in-game.py' +``` + +To remove the orphaned copies before the next helper starts: + +```sh +RCLOL_CLEAN_STALE_HELPERS=1 ./scripts/run.sh +``` + +Only helpers of the current prefix whose parent is `init`/`systemd` are +terminated. Helpers whose launcher is still alive, other prefixes, the game, +Riot/League/CEF, `darlingserver` and the runtime are never touched. A dry run +is available from the configured tools directory: + +```sh +RCLOL_CLEAN_STALE_HELPERS=1 python3 build/runtime/tools/clean-stale-helpers.py --dry-run +``` + ### Client window stays black after a match The League client keeps its Chromium/CEF UI (`LeagueClientUx` and its helpers) diff --git a/tests/test_clean_stale_helpers.py b/tests/test_clean_stale_helpers.py new file mode 100644 index 0000000..52c0294 --- /dev/null +++ b/tests/test_clean_stale_helpers.py @@ -0,0 +1,110 @@ +"""Exercise orphan-helper cleanup with fake /proc entries and mocked signals.""" +import importlib.util +import io +import os +from pathlib import Path +import signal +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + 'clean_stale_helpers', ROOT / 'tools/clean-stale-helpers.py') +clean_stale_helpers = importlib.util.module_from_spec(spec) +spec.loader.exec_module(clean_stale_helpers) + +HELPER = '/repo/tools/close-league-client-in-game.py' +HIDE_HELPER = '/repo/tools/hide-league-client-in-game.py' + + +class CleanStaleHelpersTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + self.prefix = self.root / '.darling' + self.other = self.root / 'other-prefix' + + def parent(self, pid, comm): + folder = self.proc / str(pid) + folder.mkdir(exist_ok=True) + (folder / 'comm').write_text(comm + '\n') + + def process(self, pid, script=HELPER, parent_pid=500, parent_comm='systemd', + prefix='own', argv0='/usr/bin/python3', state='S', + environment=True, arguments=('--detach',)): + folder = self.proc / str(pid) + folder.mkdir() + argv = [argv0, script, *arguments] + (folder / 'cmdline').write_bytes( + b'\0'.join(arg.encode() for arg in argv) + b'\0') + if parent_comm is not None: + self.parent(parent_pid, parent_comm) + fields = [state, str(parent_pid)] + ['0'] * 17 + ['100'] + (folder / 'stat').write_text(f'{pid} (python3) ' + ' '.join(fields) + '\n') + if environment: + prefix = self.prefix if prefix == 'own' else prefix + (folder / 'environ').write_bytes( + b'DPREFIX=' + os.fsencode(prefix) + b'\0') + else: + (folder / 'environ').write_bytes(b'UNRELATED=1\0') + return folder + + def run_clean(self, dry_run=False): + killed = [] + + def kill(pid, signum): + killed.append((pid, signum)) + + out = io.StringIO() + result = clean_stale_helpers.clean( + self.prefix, proc_root=self.proc, kill=kill, + sleep=lambda _seconds: None, out=out, dry_run=dry_run) + return result, killed, out.getvalue() + + def test_removes_only_orphaned_helpers_of_this_prefix(self): + self.process(100, HELPER) + self.process(101, HIDE_HELPER) + self.process(200, HELPER, parent_pid=600, parent_comm='darling') + self.process(201, HELPER, prefix=self.other) + self.process(202, HELPER, argv0='/usr/bin/vim') + self.process(203, HELPER, state='Z') + self.process(204, HELPER, environment=False) + self.process(205, HELPER, parent_pid=700, parent_comm=None) + result, killed, output = self.run_clean() + self.assertEqual(result, 0) + self.assertEqual(sorted(killed), sorted([ + (100, signal.SIGTERM), (100, signal.SIGKILL), + (101, signal.SIGTERM), (101, signal.SIGKILL), + ])) + self.assertIn('removing 2 orphaned helper(s): 100,101', output) + + def test_dry_run_reports_without_signalling(self): + self.process(100) + result, killed, output = self.run_clean(dry_run=True) + self.assertEqual((result, killed), (0, [])) + self.assertIn('would remove 1 orphaned helper(s): 100', output) + + def test_no_orphans_is_a_noop(self): + self.process(200, parent_pid=600, parent_comm='darling') + result, killed, output = self.run_clean() + self.assertEqual((result, killed), (0, [])) + self.assertIn('no orphaned helpers', output) + + def test_relative_helper_prefix_is_preserved(self): + self.process(100, prefix='relative-prefix') + result, killed, output = self.run_clean() + self.assertEqual((result, killed), (0, [])) + self.assertIn('no orphaned helpers', output) + + def test_launcher_runs_cleanup_before_starting_a_helper(self): + source = (ROOT / 'tools/run-riot-client.sh').read_text() + self.assertIn('RCLOL_CLEAN_STALE_HELPERS', source) + self.assertLess(source.index('clean-stale-helpers.py'), + source.index('close-league-client-in-game.py')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/clean-stale-helpers.py b/tools/clean-stale-helpers.py new file mode 100755 index 0000000..a01d49a --- /dev/null +++ b/tools/clean-stale-helpers.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Remove orphaned client-in-game helpers from an earlier launcher run. + +The close/hide helpers are long-lived watch processes. A launcher can end +while they remain, and a later run starts another helper, so several copies +watch the same prefix. This opt-in preflight removes only helpers that belong +to this prefix and whose parent is init/systemd: the launcher that started +them has already exited and reparented them. It never signals a helper whose +parent is still alive, the current launcher, darlingserver, the game, +Riot/League/CEF, or a process of another prefix. + +It fails closed: an unreadable command line, environment, stat entry or +parent leaves that process alone. Enable it with +RCLOL_CLEAN_STALE_HELPERS=1; the launcher runs it before starting its own +helper. +""" + +import argparse +import os +import signal +import sys +import time +from pathlib import Path + +HELPER_NAMES = ('close-league-client-in-game.py', + 'hide-league-client-in-game.py') +REAPER_NAMES = ('init', 'systemd') +TERM_GRACE = 3.0 + + +def target_prefix(): + """The prefix this launcher owns, or None when it cannot be determined.""" + value = os.environ.get('DPREFIX', str(Path.home() / '.darling')) + prefix = Path(value).expanduser() + return prefix if prefix.is_absolute() else None + + +def process_info(proc): + """Return (state, ppid, start_ticks), or None when stat is unreadable.""" + try: + fields = (proc / 'stat').read_text().rsplit(')', 1)[1].split() + if len(fields) < 20: + return None + return fields[0], int(fields[1]), int(fields[19]) + except (OSError, ValueError, IndexError): + return None + + +def parent_is_reaper(ppid, proc_root): + """True when the parent is PID 1 or a systemd/init user manager.""" + if ppid == 1: + return True + try: + comm = (proc_root / str(ppid) / 'comm').read_text().strip() + except OSError: + return False + return comm in REAPER_NAMES + + +def helper_identity(proc, prefix, proc_root): + """Return the start ticks of an orphaned helper of prefix, or None. + + The script name must be an exact argument, and the executable must be a + Python interpreter. This avoids matching an editor or a log viewer that + happens to have the helper name in its arguments. DPREFIX is inherited by + the helper from run-riot-client.sh; a missing or different value belongs + to another install and is preserved. + """ + try: + if proc.stat().st_uid != os.getuid(): + return None + raw = (proc / 'cmdline').read_bytes() + argv = [part.decode(errors='replace') for part in raw.split(b'\0') if part] + if len(argv) < 2 or not os.path.basename(argv[0]).startswith(('python', 'pypy')): + return None + if not any(os.path.basename(arg) in HELPER_NAMES for arg in argv[1:]): + return None + environment = dict( + item.split(b'=', 1) + for item in (proc / 'environ').read_bytes().split(b'\0') + if b'=' in item + ) + raw_prefix = environment.get(b'DPREFIX') + if raw_prefix is None: + return None + helper_prefix = Path(os.fsdecode(raw_prefix)).expanduser() + if not helper_prefix.is_absolute(): + return None + helper_prefix = helper_prefix.resolve() + if helper_prefix != prefix: + return None + + info = process_info(proc) + if info is None or info[0] == 'Z': + return None + if not parent_is_reaper(info[1], proc_root): + return None + return info[2] + except FileNotFoundError: + return None + except (OSError, RuntimeError, ValueError): + return None + + +def clean(prefix, proc_root=Path('/proc'), kill=os.kill, sleep=time.sleep, + out=sys.stdout, dry_run=False): + """Terminate orphaned helpers for prefix; return 0 on every normal path.""" + if prefix is None or not prefix.is_absolute(): + print('clean-stale-helpers: unusable DPREFIX: no action', file=out, flush=True) + return 1 + try: + prefix = prefix.expanduser().resolve() + except (OSError, RuntimeError): + print('clean-stale-helpers: unusable DPREFIX: no action', file=out, flush=True) + return 1 + try: + entries = list(proc_root.iterdir()) + except OSError: + print('clean-stale-helpers: unreadable /proc: no action', file=out, flush=True) + return 0 + + helpers = {} + for proc in entries: + if not proc.name.isdigit() or int(proc.name) == os.getpid(): + continue + start_ticks = helper_identity(proc, prefix, proc_root) + if start_ticks is not None: + helpers[int(proc.name)] = start_ticks + if not helpers: + print('clean-stale-helpers: no orphaned helpers; nothing to do', + file=out, flush=True) + return 0 + + pids = sorted(helpers) + action = 'would remove' if dry_run else 'removing' + print('clean-stale-helpers: %s %d orphaned helper(s): %s' + % (action, len(pids), ','.join(str(pid) for pid in pids)), + file=out, flush=True) + if dry_run: + return 0 + + for pid, start_ticks in helpers.items(): + proc = proc_root / str(pid) + if helper_identity(proc, prefix, proc_root) != start_ticks: + continue + try: + kill(pid, signal.SIGTERM) + except OSError: + continue + sleep(TERM_GRACE) + for pid, start_ticks in helpers.items(): + proc = proc_root / str(pid) + if helper_identity(proc, prefix, proc_root) != start_ticks: + continue + try: + kill(pid, signal.SIGKILL) + except OSError: + pass + return 0 + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--prefix', type=Path, + help='prefix whose helpers should be checked') + parser.add_argument('--dry-run', action='store_true', + help='report what would be removed without signalling') + args = parser.parse_args() + if os.environ.get('RCLOL_CLEAN_STALE_HELPERS') != '1': + print('clean-stale-helpers: disabled; set RCLOL_CLEAN_STALE_HELPERS=1', + flush=True) + return 0 + prefix = args.prefix or target_prefix() + if prefix is None or not prefix.is_absolute(): + print('clean-stale-helpers: unusable DPREFIX: no action', + file=sys.stderr, flush=True) + return 1 + return clean(prefix, dry_run=args.dry_run) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 085434e..34e7e06 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -18,6 +18,13 @@ fi if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then python3 "$runner_dir/recover-hidden-riot-client.py" fi +# Opt-in cleanup of helpers left by an earlier launcher. This runs before the +# current helper is started. It removes only helpers of this prefix whose +# parent is init/systemd, so a helper of a live launcher is not touched. +if [ "${RCLOL_CLEAN_STALE_HELPERS:-0}" = 1 ]; then + python3 "$runner_dir/clean-stale-helpers.py" --prefix "$prefix_dir" || + echo 'clean-stale-helpers: cleanup failed; continuing' >&2 +fi # Opt-in: close the League client UI while a match runs. It keeps the CPU for the game # and leaves the client the backend starts when the match ends as the only CEF instance; # a second instance on the same profile never paints its window, which is the black From 604073448cf43ea64b634a9317c56fa2f50a75ed Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Fri, 23 Oct 2026 06:08:45 -0600 Subject: [PATCH 157/160] fix(launcher): address in-game helper review - restore hidden windows even when the launcher output pipe is closed; - exit the hide watcher after ten idle minutes and start it only after the installed-client check; - reject missing, malformed or invalid close-during-game preferences instead of silently selecting hide; - document the preference contract and idle behavior. --- README.md | 12 ++--- docs/SETUP.md | 11 ++--- docs/TROUBLESHOOTING.md | 5 ++- tests/test_client_in_game_tricks.py | 8 +++- tests/test_hide_league_client_in_game.py | 45 +++++++++++++++++++ tools/client-in-game-tricks.py | 57 +++++++++++++++++------- tools/hide-league-client-in-game.py | 57 +++++++++++++++++------- tools/run-riot-client.sh | 52 ++++++++++----------- 8 files changed, 178 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 51a3cf4..689f53f 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,13 @@ RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh It reads `lol-user-experience` from the installed League client's `LCULocalPreferences.yaml`. When `potatoModeEnabled` is true, it closes the -client UI if `closeLeagueClientDuringGame` is true; otherwise it hides the -`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game -ends. With client optimizations disabled or unreadable preferences it does -nothing. The `RCLOL_CLOSE_CLIENT_IN_GAME` and `RCLOL_HIDE_CLIENT_IN_GAME` -low-level switches remain available when the automatic switch is not set. See +client UI if `closeLeagueClientDuringGame` is true, and hides the +`LeagueClientUx`/`Riot Client` X11 windows when that value is false. Missing or +invalid preference values, disabled client optimizations and unreadable files +select no action. The `RCLOL_CLOSE_CLIENT_IN_GAME` and +`RCLOL_HIDE_CLIENT_IN_GAME` low-level switches remain available when the +automatic switch is not set. The helper exits after ten minutes without a +client or match once it has seen one. See [troubleshooting](docs/TROUBLESHOOTING.md#automatic-client-handling-during-a-match) for the measured effect and caveats. diff --git a/docs/SETUP.md b/docs/SETUP.md index 2679892..548a02f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -81,13 +81,14 @@ for the observed failures, recovery guards and NSURLSession limitations. `RCLOL_CLIENT_IN_GAME_TRICKS=1` opts in to automatic client handling during a match. It reads `lol-user-experience` from the installed client's `LCULocalPreferences.yaml`: with `potatoModeEnabled: true`, it closes the client -UI when `closeLeagueClientDuringGame: true`, otherwise it hides the managed -`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game -ends. With client optimizations disabled or preferences unreadable it does -nothing. The low-level `RCLOL_CLOSE_CLIENT_IN_GAME=1` and +UI when `closeLeagueClientDuringGame: true`, and hides the managed +`LeagueClientUx`/`Riot Client` X11 windows when that value is false. Missing or +invalid preference values, disabled client optimizations and unreadable files +select no action. The low-level `RCLOL_CLOSE_CLIENT_IN_GAME=1` and `RCLOL_HIDE_CLIENT_IN_GAME=1` switches remain available when the automatic switch is not set. The feature is off by default, requires X11/Xwayland, and -does not patch client binaries, CEF or the game. See +does not patch client binaries, CEF or the game. The hide helper exits after ten +minutes without a client or match once it has seen one. See [the troubleshooting measurement](TROUBLESHOOTING.md#automatic-client-handling-during-a-match) for the measured CPU effect and limitations. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 0c37ba5..7869d27 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -206,13 +206,16 @@ It reads `lol-user-experience` from the installed client's | Client preference | Launcher action | |---|---| | `potatoModeEnabled: false` or unreadable | no client-in-game helper | +| `potatoModeEnabled: true`, close preference missing or invalid | no action | | `potatoModeEnabled: true`, `closeLeagueClientDuringGame: true` | close the client UI | | `potatoModeEnabled: true`, `closeLeagueClientDuringGame: false` | hide the client windows | The hide path unmaps the `LeagueClientUx` and `Riot Client` X11 windows and maps them again when the game process ends. The client is not closed, the League backend and Riot client keep running, and the game window is never hidden. The -close path uses the UI-close helper described above. +close path uses the UI-close helper described above. The hide helper exits after +ten minutes without a client or match once it has seen one; a missing or invalid +close preference selects no helper. The low-level `RCLOL_HIDE_CLIENT_IN_GAME=1` and `RCLOL_CLOSE_CLIENT_IN_GAME=1` switches remain available when the automatic diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py index 53a0db3..752435b 100644 --- a/tests/test_client_in_game_tricks.py +++ b/tests/test_client_in_game_tricks.py @@ -36,9 +36,12 @@ class ClientInGameTricksTests(unittest.TestCase): self.write(True, False) self.assertEqual(tricks.mode(self.path), 'hide') - def test_hide_when_close_preference_is_missing(self): + def test_none_when_close_preference_is_missing_or_invalid(self): self.write(True) - self.assertEqual(tricks.mode(self.path), 'hide') + self.assertEqual(tricks.mode(self.path), 'none') + for value in ('maybe', '"true', 'true#note', '"true"junk'): + self.write(True, value) + self.assertEqual(tricks.mode(self.path), 'none', value) def test_none_when_optimizations_are_off(self): self.write(False, True) @@ -64,6 +67,7 @@ class ClientInGameTricksTests(unittest.TestCase): 'lol-user-experience:\n' ' data:\n' ' potatoModeEnabled: true\n' + ' closeLeagueClientDuringGame: false\n' 'other-section:\n' ' potatoModeEnabled: true\n' ' closeLeagueClientDuringGame: true\n') diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index f03e8c1..867f2f3 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -217,6 +217,46 @@ class HideClientInGameTests(unittest.TestCase): watcher._hide_new() self.assertEqual(watcher.hidden, {0xabc}) + def test_restore_log_failure_still_shows_the_window(self): + class BrokenOutput: + def write(self, _text): + raise BrokenPipeError + + def flush(self): + pass + + x11 = FakeX11() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda _prefix, _cache: [0xabc], + state_source=lambda _prefix: True, + sleep=lambda _seconds: None, + out=BrokenOutput(), + ) + watcher.hidden = {0xabc} + watcher._show_all('test') + self.assertEqual(x11.shown, [0xabc]) + self.assertEqual(watcher.hidden, set()) + + def test_idle_timeout_leaves_after_client_disappears(self): + states = iter([True, False, False]) + windows = iter([[0xabc], [], []]) + times = iter([0.0, 10.0, 700.0]) + x11 = FakeX11() + output = io.StringIO() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda _prefix, _cache: next(windows), + state_source=lambda _prefix: next(states), + sleep=lambda _seconds: None, + out=output, + clock=lambda: next(times), + ) + self.assertEqual(watcher.run(max_steps=3), 0) + self.assertEqual(x11.unmapped, [0xabc]) + self.assertEqual(x11.shown, [0xabc]) + self.assertIn('no client or match', output.getvalue()) + def test_hides_then_shows_on_match_end(self): states = iter([True, True, False]) x11 = FakeX11() @@ -278,6 +318,11 @@ class HideClientInGameTests(unittest.TestCase): }): self.assertEqual(hide_in_game.main([]), 0) + def test_helper_starts_after_the_installed_client_check(self): + source = (ROOT / 'tools/run-riot-client.sh').read_text() + self.assertLess(source.index('Riot Client is not installed in'), + source.index('RCLOL_CLIENT_IN_GAME_TRICKS')) + def test_master_tricks_switch_enables_hide(self): class FakeX11: def close(self): diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py index 1b7a314..2d4330c 100755 --- a/tools/client-in-game-tricks.py +++ b/tools/client-in-game-tricks.py @@ -7,7 +7,8 @@ The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads - potatoModeEnabled must be true (client optimizations enabled); - closeLeagueClientDuringGame true -> close the client UI while a match runs; - closeLeagueClientDuringGame false -> hide the client windows instead; -- missing preferences or an unreadable file -> no action (none). +- missing or invalid closeLeagueClientDuringGame or potatoModeEnabled -> no action + (none). Only the named `lol-user-experience` section is considered, so identical keys in another section cannot select a helper. This helper only selects a mode; it @@ -24,8 +25,39 @@ TRUE_VALUES = {'true', 'yes', '1'} FALSE_VALUES = {'false', 'no', '0'} +def parse_bool(value): + """Return True/False/None for one YAML scalar, ignoring a trailing comment.""" + value = value.strip() + if not value: + return None + if value[0] in ('"', "'"): + quote = value[0] + end = value.find(quote, 1) + if end == -1: + return None + scalar = value[1:end] + remainder = value[end + 1:] + if remainder and not remainder[0].isspace(): + return None + remainder = remainder.strip() + if remainder and not remainder.startswith('#'): + return None + else: + scalar = value + for index, char in enumerate(scalar): + if char == '#' and (index == 0 or scalar[index - 1].isspace()): + scalar = scalar[:index].rstrip() + break + scalar = scalar.lower() + if scalar in TRUE_VALUES: + return True + if scalar in FALSE_VALUES: + return False + return None + + def read_bool(path, key, section=SECTION): - """Return True/False for the last key in section, or None when absent. + """Return True/False for the last key in section, or None when invalid. OSError/UnicodeError is raised for an unreadable file so callers can fail closed instead of treating a read race as a missing preference. @@ -45,19 +77,8 @@ def read_bool(path, key, section=SECTION): continue if not in_section or not stripped.startswith(key + ':'): continue - value = stripped.split(':', 1)[1].strip() - if value[:1] in ('"', "'"): - quote = value[0] - end = value.find(quote, 1) - value = value[1:end] if end != -1 else value[1:] - else: - value = value.split('#', 1)[0].strip() - value = value.lower() - if value in TRUE_VALUES: - return True - if value in FALSE_VALUES: - return False - return None + value = parse_bool(stripped.split(':', 1)[1]) + return value def mode(path): @@ -71,7 +92,11 @@ def mode(path): close = read_bool(path, CLOSE_KEY) except (OSError, UnicodeError): return 'none' - return 'close' if close is True else 'hide' + if close is True: + return 'close' + if close is False: + return 'hide' + return 'none' def main(argv=None): diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 22b9ca2..f9135af 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -39,6 +39,7 @@ GAME_NAME = 'LeagueofLegends' SOCK_VARIABLE = b'__mldr_sockpath' SOCK_NAME = '.darlingserver.sock' POLL_SECONDS = 2.0 +IDLE_SECONDS = 600 TARGET_CLASSES = ('leagueclientux', 'riot client') @@ -263,35 +264,46 @@ class Watcher: """Hide while a match is running, restore on end, uncertainty or exit.""" def __init__(self, prefix, x11, window_source=target_windows, - state_source=game_running, sleep=time.sleep, out=sys.stdout): + state_source=game_running, sleep=time.sleep, out=sys.stdout, + clock=time.monotonic): self.prefix = prefix self.x11 = x11 self.window_source = window_source self.state_source = state_source self.sleep = sleep self.out = out + self.clock = clock self.hidden = set() self.class_cache = {} self.reported_no_window = False + self.seen = False + self.idle_since = None + + def _log(self, message, stream=None): + try: + print(message, file=self.out if stream is None else stream, flush=True) + except Exception: + # A closed launcher pipe must not prevent window restoration. + pass def _show_all(self, reason): if not self.hidden: return - print(f'hide-league-client-in-game: {reason}; showing the client', file=self.out, flush=True) + self._log(f'hide-league-client-in-game: {reason}; showing the client') for window in sorted(self.hidden): try: self.x11.show(window) - print(f'hide-league-client-in-game: shown 0x{window:x}', file=self.out, flush=True) except Exception as error: - print(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}', - file=sys.stderr, flush=True) + self._log(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}', + sys.stderr) + continue + self._log(f'hide-league-client-in-game: shown 0x{window:x}') self.hidden.clear() def _hide_new(self): windows = self.window_source(self.prefix, self.class_cache) if not windows and not self.hidden and not self.reported_no_window: - print('hide-league-client-in-game: no managed client window; not hiding', - file=self.out, flush=True) + self._log('hide-league-client-in-game: no managed client window; not hiding') self.reported_no_window = True for window in windows: if window in self.hidden: @@ -301,29 +313,44 @@ class Watcher: self.x11.unmap(window) except Exception as error: self.hidden.discard(window) - print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', - file=sys.stderr, flush=True) + self._log(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', + sys.stderr) continue - try: - print(f'hide-league-client-in-game: hidden 0x{window:x}', - file=self.out, flush=True) - except Exception: - # Logging must not lose a successfully unmapped window. - pass + self._log(f'hide-league-client-in-game: hidden 0x{window:x}') + + def _windows_present(self): + try: + return bool(self.window_source(self.prefix, self.class_cache)) + except Exception: + # Uncertainty must not turn into an automatic exit. + return True def run(self, max_steps=None): steps = 0 while True: + now = self.clock() state = self.state_source(self.prefix) if state is None: self._show_all('unreadable /proc state') elif state: + self.seen = True + self.idle_since = None if not self.hidden and self.reported_no_window: self.reported_no_window = False self._hide_new() else: self._show_all('the match ended') self.reported_no_window = False + if self._windows_present(): + self.seen = True + self.idle_since = None + elif self.seen: + if self.idle_since is None: + self.idle_since = now + if now - self.idle_since >= IDLE_SECONDS: + self._log('hide-league-client-in-game: no client or match for ' + f'{IDLE_SECONDS}s: leaving') + return 0 steps += 1 if max_steps is not None and steps >= max_steps: return 0 diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 7d76a6d..611c810 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -12,31 +12,6 @@ runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then python3 "$runner_dir/recover-hidden-riot-client.py" fi -# Opt-in automatic client-in-game handling. This single switch reads the -# client's own lol-user-experience preferences: close the UI when the user -# selected "close client during game", otherwise hide the client windows. -# It does nothing unless potatoModeEnabled (client optimizations) is true. -if [ "${RCLOL_CLIENT_IN_GAME_TRICKS:-0}" = 1 ]; then - pref="$prefix_dir/Applications/League of Legends.app/Contents/LoL/Config/LCULocalPreferences.yaml" - mode=$(python3 "$runner_dir/client-in-game-tricks.py" "$pref" 2>/dev/null || echo none) - case "$mode" in - close) - echo 'client-in-game-tricks: closeLeagueClientDuringGame=true -> close helper' >&2 - python3 "$runner_dir/close-league-client-in-game.py" --detach & - ;; - hide) - echo 'client-in-game-tricks: closeLeagueClientDuringGame=false -> hide helper' >&2 - python3 "$runner_dir/hide-league-client-in-game.py" --detach & - ;; - *) - echo 'client-in-game-tricks: no action (client optimizations disabled or preferences unreadable)' >&2 - ;; - esac -elif [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then - python3 "$runner_dir/close-league-client-in-game.py" --detach & -elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then - python3 "$runner_dir/hide-league-client-in-game.py" --detach & -fi # The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN # AAAA lookups are suppressed; set this to 0 to retain the upstream resolver. export RCLOL_CDN_IPV4_ONLY="${RCLOL_CDN_IPV4_ONLY:-1}" @@ -84,6 +59,33 @@ if [ -z "${RIOT_LAUNCHER_HOST:-}" ] && [ ! -e "$client" ]; then ln -s "../Users/Shared/Riot Games/Riot Client.app" "$client" fi fi +# Opt-in automatic client-in-game handling. Start the watcher only after the +# installed-client check above: a failed installer launch must not leave a +# helper behind. The preference selector returns no action for missing or +# invalid close-during-game values. +if [ -z "${RIOT_LAUNCHER_HOST:-}" ]; then + if [ "${RCLOL_CLIENT_IN_GAME_TRICKS:-0}" = 1 ]; then + pref="$prefix_dir/Applications/League of Legends.app/Contents/LoL/Config/LCULocalPreferences.yaml" + mode=$(python3 "$runner_dir/client-in-game-tricks.py" "$pref" 2>/dev/null || echo none) + case "$mode" in + close) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=true -> close helper' >&2 + python3 "$runner_dir/close-league-client-in-game.py" --detach & + ;; + hide) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=false -> hide helper' >&2 + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + ;; + *) + echo 'client-in-game-tricks: no action (client optimizations disabled or preferences missing/invalid)' >&2 + ;; + esac + elif [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then + python3 "$runner_dir/close-league-client-in-game.py" --detach & + elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + fi +fi A=${RIOT_LAUNCHER_HOST:-"$client/Contents/MacOS/RiotClientServices"} # External XIM (for example Fcitx) consumes keys before Darling forwards them. # Use Xlib's local input method; allow explicit testing of another modifier. From 308fb3f3cec5bed73e142527a3646cb40e6ee867 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Fri, 23 Oct 2026 06:08:45 -0600 Subject: [PATCH 158/160] fix(launcher): harden stale-client recovery - fail closed when X11 omits _NET_CLIENT_LIST or WM_CLASS, and recognize the RiotClientServices window class; - require stale client processes to be at least 60 seconds old; - recheck process identity before SIGTERM and SIGKILL and recheck the window before signalling; - document the age and window-detection guards. --- README.md | 7 +- docs/SETUP.md | 9 +-- docs/TROUBLESHOOTING.md | 16 +++-- tests/test_recover_stale_client.py | 57 ++++++++++++++- tools/recover-stale-client.py | 110 ++++++++++++++++++----------- 5 files changed, 144 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 2e7ebf0..f9154eb 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,12 @@ If the game or Riot client was closed but `darlingserver` is still running, opt RCLOL_RECOVER_STALE_CLIENT=1 ./scripts/run.sh ``` -It stops only a windowless Riot/League/CEF tree, preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`, and then starts the product normally. It does nothing while a game or managed client window is present and fails closed when it cannot inspect X11. See [troubleshooting](docs/TROUBLESHOOTING.md#client-remains-running-without-a-window). +It stops only a windowless Riot/League/CEF tree at least 60 seconds old, +preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`, and then +starts the product normally. It recognizes `LeagueClientUx`, `Riot Client` and +`RiotClientServices` windows, does nothing while a game or managed client window +is present, and fails closed when X11 properties cannot be read. See +[troubleshooting](docs/TROUBLESHOOTING.md#client-remains-running-without-a-window). If repeated launches leave several `close-league-client-in-game.py` watchers, opt in to removing the orphaned ones before the new helper starts: diff --git a/docs/SETUP.md b/docs/SETUP.md index c8178a6..cdcdbc8 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -80,10 +80,11 @@ for the observed failures, recovery guards and NSURLSession limitations. `RCLOL_RECOVER_STALE_CLIENT=1` opts in to partial runtime recovery before launch. If `darlingserver` is alive, no game is running, no managed client -window is visible and stale Riot/League/CEF processes remain, the launcher stops -only that client tree and reuses the runtime. It preserves `darlingserver`, -launchd/mldr, sockets and `.init.pid`; if X11/Xwayland cannot be inspected it -does nothing. See +window is visible and stale Riot/League/CEF processes at least 60 seconds old +remain, the launcher stops only that client tree and reuses the runtime. It +recognizes `LeagueClientUx`, `Riot Client` and `RiotClientServices` windows and +preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`; if X11/Xwayland +properties cannot be inspected it does nothing. See [troubleshooting](TROUBLESHOOTING.md#client-remains-running-without-a-window). `RCLOL_CLEAN_STALE_HELPERS=1` runs a preflight before the match helper starts. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index ee79ad9..571dce4 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -150,13 +150,15 @@ download is restarted when recovery is enabled. Other desktop environments are not supported by this helper; recovery is off by default. For a live Darling runtime with no visible client window but stale Riot/League/ -CEF processes, `RCLOL_RECOVER_STALE_CLIENT=1` performs a partial recovery before -the next launch. It keeps `darlingserver`, launchd/mldr, sockets and `.init.pid`, -stops only the stale client process tree, and then lets the normal launcher start -the product again. It does nothing while a game process is running or when a -managed client window is visible. With X11/Xwayland unavailable it fails closed. -This saves the Darling bootstrap but not the Riot Client/CEF startup itself. If -no window appears afterward, use a full runtime shutdown/restart. +CEF processes at least 60 seconds old, `RCLOL_RECOVER_STALE_CLIENT=1` performs a +partial recovery before the next launch. It keeps `darlingserver`, launchd/mldr, +sockets and `.init.pid`, stops only the stale client process tree, and then lets +the normal launcher start the product again. It recognizes `LeagueClientUx`, +`Riot Client` and `RiotClientServices` windows, does nothing while a game process +is running or when a managed client window is visible, and fails closed when +X11/Xwayland properties are missing or unreadable. This saves the Darling +bootstrap but not the Riot Client/CEF startup itself. If no window appears +afterward, use a full runtime shutdown/restart. ### Multiple client-in-game helpers diff --git a/tests/test_recover_stale_client.py b/tests/test_recover_stale_client.py index 743907f..14099a0 100644 --- a/tests/test_recover_stale_client.py +++ b/tests/test_recover_stale_client.py @@ -24,16 +24,19 @@ class RecoverStaleClientTests(unittest.TestCase): self.root = Path(self.temp.name) self.proc = self.root / 'proc' self.proc.mkdir() + (self.proc / 'uptime').write_text('1000 0\n') self.prefix = self.root / '.darling' - def process(self, pid, comm, argv, env=None, executable=None): + def process(self, pid, comm, argv, env=None, executable=None, age=120): folder = self.proc / str(pid) folder.mkdir() (folder / 'comm').write_text(comm + '\n') (folder / 'cmdline').write_bytes( (argv[0] if executable is None else executable).encode() + b'\0' + b''.join(arg.encode() + b'\0' for arg in argv[1:])) - (folder / 'stat').write_text(f'{pid} ({comm}) S') + ticks = os.sysconf('SC_CLK_TCK') + fields = ['S'] + ['0'] * 18 + [str(int((1000 - age) * ticks))] + (folder / 'stat').write_text(f'{pid} ({comm}) ' + ' '.join(fields) + '\n') if env is not None: (folder / 'environ').write_bytes(env) return folder @@ -45,11 +48,13 @@ class RecoverStaleClientTests(unittest.TestCase): os.fsencode(prefix / recover_stale_client.SOCK_NAME), ) - def run_recover(self, window=False): + def run_recover(self, window=False, on_kill=None): killed = [] def kill(pid, signum): killed.append((pid, signum)) + if on_kill is not None: + on_kill(pid, signum) out = io.StringIO() with patch.object(recover_stale_client, 'visible_client_window', @@ -105,6 +110,52 @@ class RecoverStaleClientTests(unittest.TestCase): self.assertNotIn(10, [pid for pid, _sig in killed]) self.assertNotIn(11, [pid for pid, _sig in killed]) + def test_pid_identity_is_rechecked_before_sigkill(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(12, 'mldr', [CLIENT], self.client_env()) + + def reuse_pid(pid, signum): + if signum == recover_stale_client.signal.SIGTERM: + (self.proc / str(pid) / 'cmdline').write_bytes(b'/usr/bin/editor\0') + + result, killed, _output = self.run_recover(on_kill=reuse_pid) + self.assertEqual(result, 0) + self.assertEqual(killed, [(12, recover_stale_client.signal.SIGTERM)]) + + def test_young_client_is_preserved(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(12, 'mldr', [CLIENT], self.client_env(), + age=recover_stale_client.MIN_AGE_SECONDS - 1) + result, killed, output = self.run_recover() + self.assertEqual((result, killed), (0, [])) + self.assertIn('no stale client processes', output) + + def test_missing_net_client_list_is_inconclusive(self): + with patch.object(recover_stale_client.subprocess, 'check_output', + return_value='_NET_CLIENT_LIST: not found.\n'): + self.assertTrue(recover_stale_client.visible_client_window()) + + def test_empty_client_list_is_no_window(self): + with patch.object(recover_stale_client.subprocess, 'check_output', + return_value='_NET_CLIENT_LIST(WINDOW): window id #\n'): + self.assertFalse(recover_stale_client.visible_client_window()) + + def test_missing_wm_class_is_inconclusive(self): + with patch.object(recover_stale_client.subprocess, 'check_output', + side_effect=[ + '_NET_CLIENT_LIST(WINDOW): window id # 0x10\n', + 'WM_CLASS: not found.\n', + ]): + self.assertTrue(recover_stale_client.visible_client_window()) + + def test_riot_client_shell_is_a_visible_window(self): + with patch.object(recover_stale_client.subprocess, 'check_output', + side_effect=[ + '_NET_CLIENT_LIST(WINDOW): window id # 0x10\n', + 'WM_CLASS(STRING) = "RiotClientServices", "RiotClientServices"\n', + ]): + self.assertTrue(recover_stale_client.visible_client_window()) + if __name__ == '__main__': unittest.main() diff --git a/tools/recover-stale-client.py b/tools/recover-stale-client.py index 3c29805..66466ce 100755 --- a/tools/recover-stale-client.py +++ b/tools/recover-stale-client.py @@ -7,15 +7,17 @@ of these are true: - a darlingserver is running; - no League game process is running; - no managed Riot/League client window is visible; -- one or more Riot/League client, CEF or crash-handler processes are still - present for this prefix. +- one or more Riot/League client, CEF or crash-handler processes at least 60 + seconds old are still present for this prefix. It terminates only those client processes with SIGTERM/SIGKILL. The darlingserver, launchd/mldr services, sockets and .init.pid stay intact so the next launcher run can reuse the runtime. It never patches files or the game. """ +import math import os +import re import signal import subprocess import sys @@ -23,7 +25,8 @@ import time from pathlib import Path GAME_NAME = 'LeagueofLegends' -WINDOW_CLASSES = ('leagueclientux', 'riot client') +WINDOW_CLASSES = ('leagueclientux', 'riot client', 'riotclientservices') +MIN_AGE_SECONDS = 60 CLIENT_MARKERS = ( '/Applications/League of Legends.app', '/Applications/Riot Client.app', @@ -102,8 +105,9 @@ def game_running(proc_root=Path('/proc')): def visible_client_window(): """Return True when a managed client window is present. - A missing xprop or display is treated as 'cannot prove there is no - window' so we do not kill a possibly working client. + A missing xprop/property or display is treated as 'cannot prove there is no + window' so we do not kill a possibly working client. xprop exits zero and + prints "not found" for a property the window manager did not set. """ try: listing = subprocess.check_output( @@ -111,56 +115,71 @@ def visible_client_window(): ) except (OSError, subprocess.SubprocessError): return True - for raw in listing.split(): - if not raw.startswith('0x'): - continue + lowered = listing.lower() + if '_net_client_list' not in lowered or 'not found' in lowered: + return True + for raw in re.findall(r'0x[0-9a-fA-F]+', listing): try: classes = subprocess.check_output( ['xprop', '-id', raw, 'WM_CLASS'], text=True, timeout=5 ).lower() except (OSError, subprocess.SubprocessError): return True + if 'not found' in classes or '=' not in classes: + return True if any(name in classes for name in WINDOW_CLASSES): return True return False +def client_identity(prefix, pid, proc_root=Path('/proc')): + """Start ticks of a live stale client for prefix, or None when not eligible.""" + proc = proc_root / str(pid) + if process_state(proc) in ('Z', None): + return None + owner = (prefix / SOCK_NAME).parent.resolve() + try: + if proc.stat().st_uid != os.getuid(): + return None + names = command_names(proc) + if GAME_NAME in names or any(name in RUNTIME_NAMES for name in names): + return None + text = command_text(proc) + if not any(marker in text for marker in CLIENT_MARKERS): + return None + environment = dict( + item.split(b'=', 1) + for item in (proc / 'environ').read_bytes().split(b'\0') + if b'=' in item + ) + socket = environment.get(SOCK_VARIABLE) + if socket is None: + return None + if Path(os.fsdecode(socket)).parent.resolve() != owner: + return None + uptime = float((proc_root / 'uptime').read_text().split()[0]) + ticks = os.sysconf('SC_CLK_TCK') + if not math.isfinite(uptime) or uptime < 0 or ticks <= 0: + return None + # Field 22 follows a comm field that may contain spaces/parentheses. + start_ticks = int((proc / 'stat').read_text().rsplit(')', 1)[1].split()[19]) + if start_ticks < 0 or uptime - start_ticks / ticks < MIN_AGE_SECONDS: + return None + return start_ticks + except (OSError, ValueError, IndexError): + return None + + def client_pids(prefix, proc_root=Path('/proc')): """Client/CEF PIDs belonging to prefix, excluding Darling and the game.""" - owner = (prefix / SOCK_NAME).parent.resolve() found = [] try: entries = list(proc_root.iterdir()) except OSError: return [] for proc in entries: - if not proc.name.isdigit() or process_state(proc) in ('Z', None): - continue - try: - if proc.stat().st_uid != os.getuid(): - continue - names = command_names(proc) - if GAME_NAME in names or any(name in RUNTIME_NAMES for name in names): - continue - text = command_text(proc) - if not any(marker in text for marker in CLIENT_MARKERS): - continue - try: - env = dict( - item.split(b'=', 1) - for item in (proc / 'environ').read_bytes().split(b'\0') - if b'=' in item - ) - except OSError: - continue - socket = env.get(SOCK_VARIABLE) - if socket is None: - continue - if Path(os.fsdecode(socket)).parent.resolve() != owner: - continue + if proc.name.isdigit() and client_identity(prefix, proc.name, proc_root) is not None: found.append(int(proc.name)) - except (OSError, ValueError): - continue return sorted(found) @@ -175,19 +194,30 @@ def recover(prefix, proc_root=Path('/proc'), window_check=visible_client_window, if window_check(): print('recover-stale-client: client window present; no action', file=out, flush=True) return 0 - pids = client_pids(prefix, proc_root) - if not pids: + identities = {} + for pid in client_pids(prefix, proc_root): + start_ticks = client_identity(prefix, pid, proc_root) + if start_ticks is not None: + identities[pid] = start_ticks + if not identities: print('recover-stale-client: no stale client processes; no action', file=out, flush=True) return 0 + if window_check(): + print('recover-stale-client: client window appeared; no action', file=out, flush=True) + return 0 print('recover-stale-client: stale client tree without a window; ' - 'terminating %d process(es)' % len(pids), file=out, flush=True) - for pid in pids: + 'terminating %d process(es)' % len(identities), file=out, flush=True) + for pid, start_ticks in identities.items(): + if client_identity(prefix, pid, proc_root) != start_ticks: + continue try: kill(pid, signal.SIGTERM) except OSError: pass sleep(4) - for pid in pids: + for pid, start_ticks in identities.items(): + if client_identity(prefix, pid, proc_root) != start_ticks: + continue try: kill(pid, signal.SIGKILL) except OSError: From a53dd00ff940e90b9a7f794c7b82cde4e03543e8 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Fri, 23 Oct 2026 11:24:24 -0600 Subject: [PATCH 159/160] docs: document Vulkan ICD selection for multiple GPUs --- docs/TROUBLESHOOTING.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index f4a5488..dd6a956 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -500,6 +500,41 @@ With the window visual and compositor draw buffer fixes (#22) and the RPC socket If the game window stays black on NVIDIA, check `metal-device.log` as described in [Check which GPU rendered](#check-which-gpu-rendered). +## Vulkan device or ICD selection + +The Metal wrapper renders with Vulkan and hands each frame to an OpenGL (EGL) +context. If the machine has more than one Vulkan ICD, or a software Vulkan +device such as lavapipe, the loader can enumerate a device that does not match +the presenting GPU. The client may then stop with a Metal device or version +error even though the runtime built correctly ([#127](https://github.com/lucannez64/darling-lol/issues/127)). + +Check what the loader reports and what Metal selected: + +```sh +ls -l /usr/share/vulkan/icd.d/ +vulkaninfo --summary +grep -E 'physical name|selected name|presenting GL' ~/.darling/private/tmp/metal-device.log +``` + +If the selected device is wrong, force the ICD for the GPU that should present: + +```sh +VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/radeon_icd.x86_64.json sh scripts/run.sh +``` + +The filename is distribution and driver specific (`radeon_icd.x86_64.json`, +`nvidia_icd.json` and `intel_icd.x86_64.json` are common). Choose the JSON for +the GPU you want, and set this per launch; do not install it globally in a shell +profile. `VK_ICD_FILENAMES` changes the Vulkan side only. If the log reports +`MISMATCH`, the presenting OpenGL context is on another GPU; use the PRIME or +EGL variables in [hybrid graphics](#hybrid-graphics-laptops-amdintel-igpu--nvidia) +as well. If `physical name` shows `llvmpipe` or another CPU device, select the +hardware ICD or enable [`/tmp/metal-skip-cpu-devices`](SETUP.md#metal-and-indium-overlays). + +This workaround selects a device; it does not make an unsupported Metal feature +available. Include the `physical`, `selected` and `presenting` lines from +`metal-device.log` when reporting the failure. + ## Hybrid graphics laptops (AMD/Intel iGPU + NVIDIA) Metal renders with Vulkan, and each finished frame is handed to an OpenGL (EGL) context that presents it. The hand-off uses opaque file descriptors, which only work when Vulkan and OpenGL run on the same GPU. Indium takes the first Vulkan device the loader lists, and on a hybrid laptop both that device and the EGL context are normally the integrated GPU, so the discrete GPU stays idle. A report in [issue #3](https://github.com/lucannez64/darling-lol/issues/3) (Ryzen 9 5900HS, RTX 3060 Laptop) measured 60-70 FPS in ARAM on the integrated GPU; on the NVIDIA GPU the game showed a black screen, since fixed (see [NVIDIA](#nvidia)). From 30a178b348caa572a7e51e40384ec0b4387fa1b0 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:29:21 +0100 Subject: [PATCH 160/160] docs: scope GPU diagnostics to one logged reproduction Signed-off-by: lucannez64 <58551020+lucannez64@users.noreply.github.com> --- docs/TROUBLESHOOTING.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 337820b..85200cc 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -513,13 +513,27 @@ Check what the loader reports and what Metal selected: ```sh ls -l /usr/share/vulkan/icd.d/ vulkaninfo --summary -grep -E 'physical name|selected name|presenting GL' ~/.darling/private/tmp/metal-device.log ``` -If the selected device is wrong, force the ICD for the GPU that should present: +The device log is opt-in and append-only. With the game and both clients closed, +clear the old log and enable logging for one reproduction: ```sh -VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/radeon_icd.x86_64.json sh scripts/run.sh +rm -f "${DPREFIX:-$HOME/.darling}/private/tmp/metal-device.log" +DARLING_METAL_DEVICE_LOG=1 sh scripts/run.sh +``` + +After reproducing, inspect the `physical`, `selected` and `presenting` lines +for the same process ID: + +```sh +grep -E 'physical name|selected name|presenting GL' "${DPREFIX:-$HOME/.darling}/private/tmp/metal-device.log" +``` + +If the selected device is wrong, restrict Vulkan to the intended driver's ICD: + +```sh +DARLING_METAL_DEVICE_LOG=1 VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/radeon_icd.x86_64.json sh scripts/run.sh ``` The filename is distribution and driver specific (`radeon_icd.x86_64.json`, @@ -531,8 +545,9 @@ EGL variables in [hybrid graphics](#hybrid-graphics-laptops-amdintel-igpu--nvidi as well. If `physical name` shows `llvmpipe` or another CPU device, select the hardware ICD or enable [`/tmp/metal-skip-cpu-devices`](SETUP.md#metal-and-indium-overlays). -This workaround selects a device; it does not make an unsupported Metal feature -available. Include the `physical`, `selected` and `presenting` lines from +This workaround selects an ICD/driver, which can expose several physical GPUs. +It does not guarantee a particular GPU or a match with the OpenGL presenting +GPU, and it does not make unsupported Metal features available. Include the `physical`, `selected` and `presenting` lines from `metal-device.log` when reporting the failure. ## Hybrid graphics laptops (AMD/Intel iGPU + NVIDIA)