diff --git a/docs/TESTING.md b/docs/TESTING.md index 3249b96..17a711a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -623,3 +623,76 @@ 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. + +## 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. + +## Combined VSync and VRR testing + +The `test/vsync-vrr` branch combines PRs #81 and #88, resolving their shared +compositor-patch and presentation-probe changes. The probe toggles +`displaySyncEnabled` and checks the CGL interval in child, parent and nested +parent modes; the parent modes also support resize fallback/restoration. +All three native modes passed locally in a private prefix, together with ten +host regression checks and validation of all 31 component patches. + +After closing the game and both clients: + +```sh +git fetch origin +git switch --track origin/test/vsync-vrr +./scripts/update.sh +``` + +Test VSync with desktop VRR disabled, then test VRR with VSync off and a cap +inside the monitor's adaptive range. Finally test both enabled. Report game +FPS and the monitor's live refresh reading separately. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 3574291..a655648 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -541,3 +541,40 @@ 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. + +## 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 85aa08f..cc6452b 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -5031,7 +5031,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 @@ @@ -5043,7 +5043,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; @@ -5053,6 +5053,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; @@ -5076,19 +5077,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..e458d96f 100644 +index f4925b6..b565b91 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -18,11 +18,14 @@ @@ -5455,7 +5459,7 @@ index f4925b66..e458d96f 100644 } - (void) dealloc { -@@ -279,24 +498,30 @@ static NSData *makeWindowIcon() { +@@ -279,24 +498,59 @@ static NSData *makeWindowIcon() { return _styleMask; } @@ -5492,6 +5496,35 @@ index f4925b66..e458d96f 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) { @@ -5502,7 +5535,7 @@ index f4925b66..e458d96f 100644 if (!_mapped) { long states[2]; int states_cnt = 0; -@@ -304,7 +529,7 @@ static NSData *makeWindowIcon() { +@@ -304,7 +558,7 @@ static NSData *makeWindowIcon() { states[states_cnt++] = (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); } @@ -5511,7 +5544,7 @@ index f4925b66..e458d96f 100644 states[states_cnt++] = (long) XInternAtom( _display, "_NET_WM_STATE_FULLSCREEN", False); } -@@ -312,14 +537,13 @@ static NSData *makeWindowIcon() { +@@ -312,14 +566,13 @@ static NSData *makeWindowIcon() { XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, 32, PropModeReplace, (const unsigned char *) states, states_cnt); @@ -5528,11 +5561,12 @@ index f4925b66..e458d96f 100644 event.data.l[1] = (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); event.data.l[3] = 1; -@@ -327,6 +551,47 @@ static NSData *makeWindowIcon() { +@@ -327,6 +580,48 @@ static NSData *makeWindowIcon() { SubstructureNotifyMask | SubstructureRedirectMask, (XEvent *) &event); } + _fullscreenRequested = fullscreen; ++ [self updateLayerPresentationHints: fullscreen]; + XFlush(_display); +} + @@ -5576,7 +5610,7 @@ index f4925b66..e458d96f 100644 } - (void) setStyleMask: (NSUInteger) mask { -@@ -338,7 +603,6 @@ static NSData *makeWindowIcon() { +@@ -338,7 +633,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -5584,7 +5618,7 @@ index f4925b66..e458d96f 100644 struct { unsigned long flags; unsigned long functions; -@@ -356,11 +620,25 @@ static NSData *makeWindowIcon() { +@@ -356,11 +650,25 @@ static NSData *makeWindowIcon() { - (void) ensureMapped { if (!_mapped) { @@ -5610,7 +5644,7 @@ index f4925b66..e458d96f 100644 - (void) showWindowWithoutActivation { [self ensureMapped]; } -@@ -377,15 +655,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +685,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -5643,7 +5677,7 @@ index f4925b66..e458d96f 100644 // TODO: background color } -@@ -393,6 +683,28 @@ static NSData *makeWindowIcon() { +@@ -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. @@ -5672,7 +5706,7 @@ index f4925b66..e458d96f 100644 [_context release]; _context = nil; -@@ -421,6 +733,392 @@ static NSData *makeWindowIcon() { +@@ -421,6 +763,392 @@ static NSData *makeWindowIcon() { } } @@ -6065,7 +6099,7 @@ index f4925b66..e458d96f 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +1168,32 @@ static NSData *makeWindowIcon() { +@@ -470,14 +1198,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -6099,7 +6133,7 @@ index f4925b66..e458d96f 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1204,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1234,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -6125,7 +6159,7 @@ index f4925b66..e458d96f 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -518,7 +1253,7 @@ static NSData *makeWindowIcon() { +@@ -518,7 +1283,7 @@ static NSData *makeWindowIcon() { - (void) placeAboveWindow: (NSInteger) otherNumber { X11Window *other = [X11Window windowWithWindowNumber: otherNumber]; @@ -6134,7 +6168,7 @@ index f4925b66..e458d96f 100644 if (!other) { XRaiseWindow(_display, _window); -@@ -530,7 +1265,7 @@ static NSData *makeWindowIcon() { +@@ -530,7 +1295,7 @@ static NSData *makeWindowIcon() { - (void) placeBelowWindow: (NSInteger) otherNumber { X11Window *other = [X11Window windowWithWindowNumber: otherNumber]; @@ -6143,7 +6177,7 @@ index f4925b66..e458d96f 100644 if (!other) { XLowerWindow(_display, _window); -@@ -541,8 +1276,23 @@ static NSData *makeWindowIcon() { +@@ -541,8 +1306,23 @@ static NSData *makeWindowIcon() { } - (void) makeKey { @@ -6168,7 +6202,7 @@ index f4925b66..e458d96f 100644 } - (void) makeMain { -@@ -559,7 +1309,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1339,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -6198,7 +6232,7 @@ index f4925b66..e458d96f 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -593,8 +1365,10 @@ static NSData *makeWindowIcon() { +@@ -593,8 +1395,10 @@ static NSData *makeWindowIcon() { if (_cglContext == NULL) { CGLError error; @@ -6211,10 +6245,12 @@ index f4925b66..e458d96f 100644 NSLog(@"CGLCreateContext failed at %s %d with error %d", __FILE__, __LINE__, error); -@@ -615,6 +1389,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1419,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. */ @@ -6224,7 +6260,7 @@ index f4925b66..e458d96f 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1432,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1464,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -6233,7 +6269,7 @@ index f4925b66..e458d96f 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1446,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1478,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -6241,7 +6277,7 @@ index f4925b66..e458d96f 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,19 +1491,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,19 +1523,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } @@ -9629,13 +9665,13 @@ index 5dcac115..5de4f03f 100644 + @end diff --git a/QuartzCore/CALayerContext.m b/QuartzCore/CALayerContext.m -index 5dce8c5..3a7f937 100644 --- a/QuartzCore/CALayerContext.m +++ b/QuartzCore/CALayerContext.m -@@ -1,47 +1,77 @@ +@@ -1,47 +1,89 @@ #import +#import #import ++#import #import #import #import "CALayerInternal.h" @@ -9657,6 +9693,17 @@ index 5dce8c5..3a7f937 100644 + return NO; +} + ++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. */ @@ -9700,10 +9747,11 @@ index 5dce8c5..3a7f937 100644 - kCGLNoError) - NSLog(@"CGLCreateContext failed with %d in %s %d", error, __FILE__, - __LINE__); +- + self = [super init]; + if (self == nil) + return nil; - ++ + _deleteTextureIds = [[NSMutableArray alloc] init]; _frame = rect; - @@ -9722,14 +9770,15 @@ 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]; } -@@ -51,12 +81,22 @@ +@@ -51,12 +93,22 @@ } - (void) setLayer: (CALayer *) layer { @@ -9752,12 +9801,18 @@ index 5dce8c5..3a7f937 100644 } - (void) setSubwindow: (CGSubWindow*) subwindow -@@ -68,16 +108,50 @@ +@@ -64,20 +116,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 }; @@ -9776,7 +9831,7 @@ index 5dce8c5..3a7f937 100644 + } + } + if (_glContext != NULL) -+ _cglWindow = CGLGetWindow([_subwindow nativeWindow]); ++ [self updateDrawable]; + } [_subwindow show]; @@ -9804,7 +9859,7 @@ index 5dce8c5..3a7f937 100644 } - (void) assignTextureIdsToLayerTree: (CALayer *) layer { -@@ -93,8 +167,27 @@ +@@ -93,8 +180,43 @@ [self assignTextureIdsToLayerTree: child]; } @@ -9821,6 +9876,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(); @@ -9832,28 +9903,31 @@ index 5dce8c5..3a7f937 100644 glEnable(GL_DEPTH_TEST); glShadeModel(GL_SMOOTH); -@@ -118,27 +211,62 @@ +@@ -118,27 +240,63 @@ [_deleteTextureIds removeAllObjects]; + CFTimeInterval assignStart = CACurrentMediaTime(); + darlingQCTiming("renderLayerSetup", assignStart - renderLayerStart); [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; - [mtl prepareRender]; - } +- ++ darlingQCTiming("assignTextureIds", CACurrentMediaTime() - assignStart); ++ + [self prepareMetalLayers: layer]; - ++ + CFTimeInterval rendererStart = CACurrentMediaTime(); [_renderer render]; + darlingQCTiming("rendererRender", CACurrentMediaTime() - rendererStart); } - (void) render { ++ [self updateDrawable]; + if (_cglWindow == NULL || _layer == nil) + return; + if(skipLocalRender(_layer, _frame.size))return; @@ -9900,7 +9974,7 @@ index 5dce8c5..3a7f937 100644 } - (void) startTimerIfNeeded { -@@ -147,7 +275,7 @@ +@@ -147,7 +305,7 @@ target: self selector: @selector(timer:) userInfo: nil @@ -9909,14 +9983,25 @@ index 5dce8c5..3a7f937 100644 } - (void) deleteTextureId: (NSNumber *) textureId { -@@ -155,7 +283,9 @@ +@@ -155,7 +313,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 @@ -11242,10 +11327,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; @@ -11510,3 +11599,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/patches/darling.patch b/patches/darling.patch index ae2cfd8..a4d2178 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -6765,7 +6765,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 @@ @@ -6866,7 +6866,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; @@ -6876,6 +6876,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; @@ -6885,7 +6886,7 @@ index ffd7fe054..f25fd9aa5 100644 }; struct _CGLPixelFormatObj { -@@ -59,6 +143,24 @@ struct _CGLPixelFormatObj { +@@ -59,6 +144,24 @@ struct _CGLPixelFormatObj { CGLPixelFormatAttribute *attributes; }; @@ -6910,7 +6911,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: @@ -6920,7 +6921,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) { @@ -6928,7 +6929,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) { @@ -7008,7 +7009,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) { @@ -7017,7 +7018,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; @@ -7026,7 +7027,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; @@ -7035,7 +7036,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; } @@ -7091,7 +7092,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); } @@ -7139,7 +7140,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 ) { @@ -7154,7 +7155,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 ) { @@ -7165,7 +7166,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; } @@ -7227,6 +7228,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; @@ -7267,7 +7269,7 @@ index ffd7fe054..f25fd9aa5 100644 return context; } -@@ -342,11 +609,17 @@ void CGLReleaseContext(CGLContextObj context) { +@@ -342,11 +611,17 @@ void CGLReleaseContext(CGLContextObj context) { return; } @@ -7288,7 +7290,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)); @@ -7301,12 +7303,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; @@ -7315,11 +7314,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; +} @@ -7634,18 +7631,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/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 192d9f3..9c3d173 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..65f281f 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() { @@ -35,6 +48,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); @@ -52,12 +66,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) { @@ -66,6 +85,7 @@ int main() { verifyPresentation(flag != nullptr, "request second bounded capture"); fclose(flag); } + layer.displaySyncEnabled = (frame % 3) != 1; @autoreleasepool { id drawable = [layer nextDrawable]; verifyPresentation(drawable != nil, "drawable after late window/context attachment"); @@ -82,6 +102,23 @@ 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"); + 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..60fbbb8 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": 115, "new_files": [ "QuartzCore/include/QuartzCore/CATransformLayer.h", "AppKit/NSGeometryTrace.h"