diff --git CMakeLists.txt CMakeLists.txt
index e45b53e20..de168d656 100644
--- CMakeLists.txt
+++ CMakeLists.txt
@@ -1109,6 +1109,8 @@ if(SDL_LIBC)
     check_symbol_exists(poll "poll.h" HAVE_POLL)
     check_symbol_exists(memfd_create "sys/mman.h" HAVE_MEMFD_CREATE)
     check_symbol_exists(posix_fallocate "fcntl.h" HAVE_POSIX_FALLOCATE)
+    check_symbol_exists(mkostemp "stdlib.h" HAVE_MKOSTEMP)
+    check_symbol_exists(pipe2 "unistd.h" HAVE_PIPE2)
 
     check_library_exists(m pow "" HAVE_LIBM)
     if(HAVE_LIBM)
@@ -2273,6 +2275,16 @@ elseif(APPLE)
       set(HAVE_OPENGLES TRUE)
     endif()
 
+    # A Wayland compositor running on macOS (owl and the like) can be talked to
+    # like any other, so the driver is available here as well. It is off by
+    # default; the Cocoa driver is what gets picked either way unless
+    # SDL_VIDEODRIVER=wayland says otherwise.
+    if(MACOS AND SDL_WAYLAND)
+      # Need to check for EGL first because Wayland depends on it.
+      CheckEGL()
+      CheckWayland()
+    endif()
+
     if(SDL_VULKAN OR SDL_METAL OR SDL_RENDER_METAL)
       check_objc_source_compiles("
         #include <AvailabilityMacros.h>
diff --git cmake/sdlchecks.cmake cmake/sdlchecks.cmake
index ea4c0ff43..83a523657 100644
--- cmake/sdlchecks.cmake
+++ cmake/sdlchecks.cmake
@@ -43,6 +43,8 @@ macro(FindLibraryAndSONAME _LIB)
 
     message(STATUS "dynamic lib${_LIB} -> ${_LIB_REGEXD}")
     set(${_LNAME}_LIB_SONAME ${_LIB_REGEXD})
+    # For runtime linkers that won't find the library from its name alone.
+    set(${_LNAME}_LIB_SONAME_PATH "${_LIB_DIRECTORY}/${_LIB_REGEXD}")
   endif()
 endmacro()
 
@@ -716,10 +718,21 @@ macro(CheckWayland)
       FindLibraryAndSONAME(wayland-cursor LIBDIRS ${PKG_WAYLAND_LIBRARY_DIRS})
       FindLibraryAndSONAME(xkbcommon LIBDIRS ${PKG_WAYLAND_LIBRARY_DIRS})
       if(SDL_WAYLAND_SHARED AND WAYLAND_CLIENT_LIB AND WAYLAND_EGL_LIB AND WAYLAND_CURSOR_LIB AND XKBCOMMON_LIB AND HAVE_SDL_LOADSO)
-        set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC "\"${WAYLAND_CLIENT_LIB_SONAME}\"")
-        set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC_EGL "\"${WAYLAND_EGL_LIB_SONAME}\"")
-        set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC_CURSOR "\"${WAYLAND_CURSOR_LIB_SONAME}\"")
-        set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC_XKBCOMMON "\"${XKBCOMMON_LIB_SONAME}\"")
+        if(APPLE)
+          # Full paths, not file names: dyld only looks a bare name up in
+          # DYLD_LIBRARY_PATH and a handful of system directories, which won't
+          # be where a package manager put these. dlopen() still consults
+          # DYLD_LIBRARY_PATH by file name first, so this stays overridable.
+          set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC "\"${WAYLAND_CLIENT_LIB_SONAME_PATH}\"")
+          set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC_EGL "\"${WAYLAND_EGL_LIB_SONAME_PATH}\"")
+          set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC_CURSOR "\"${WAYLAND_CURSOR_LIB_SONAME_PATH}\"")
+          set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC_XKBCOMMON "\"${XKBCOMMON_LIB_SONAME_PATH}\"")
+        else()
+          set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC "\"${WAYLAND_CLIENT_LIB_SONAME}\"")
+          set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC_EGL "\"${WAYLAND_EGL_LIB_SONAME}\"")
+          set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC_CURSOR "\"${WAYLAND_CURSOR_LIB_SONAME}\"")
+          set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC_XKBCOMMON "\"${XKBCOMMON_LIB_SONAME}\"")
+        endif()
         set(HAVE_WAYLAND_SHARED TRUE)
       else()
         list(APPEND EXTRA_LIBS ${PKG_WAYLAND_LIBRARIES})
@@ -738,7 +751,11 @@ macro(CheckWayland)
             FindLibraryAndSONAME(decor-0 LIBDIRS ${PKG_LIBDECOR_LIBRARY_DIRS})
             if(SDL_WAYLAND_LIBDECOR_SHARED AND DECOR_0_LIB AND HAVE_SDL_LOADSO)
                 set(HAVE_WAYLAND_LIBDECOR_SHARED TRUE)
-                set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC_LIBDECOR "\"${DECOR_0_LIB_SONAME}\"")
+                if(APPLE)
+                  set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC_LIBDECOR "\"${DECOR_0_LIB_SONAME_PATH}\"")
+                else()
+                  set(SDL_VIDEO_DRIVER_WAYLAND_DYNAMIC_LIBDECOR "\"${DECOR_0_LIB_SONAME}\"")
+                endif()
             else()
               list(APPEND EXTRA_LIBS ${PKG_LIBDECOR_LIBRARIES})
             endif()
@@ -869,6 +886,16 @@ macro(CheckEGL)
         int main (int argc, char** argv) { return 0; }" HAVE_OPENGL_EGL)
     if(HAVE_OPENGL_EGL)
       set(SDL_VIDEO_OPENGL_EGL 1)
+      if(APPLE)
+        # dyld won't find a libEGL that lives outside of its own small set of
+        # search directories by file name, so record where this one is.
+        # DYLD_LIBRARY_PATH is still consulted by file name first, which is how
+        # a compositor's own EGL (owl-egl and the like) gets picked up instead.
+        FindLibraryAndSONAME("EGL" LIBDIRS ${EGL_LIBRARY_DIRS})
+        if(EGL_LIB_SONAME_PATH)
+          set(SDL_VIDEO_OPENGL_EGL_LIBRARY "\"${EGL_LIB_SONAME_PATH}\"")
+        endif()
+      endif()
     endif()
   endif()
 endmacro()
diff --git configure configure
index c6c94e1c0..3ee039082 100755
--- configure
+++ configure
@@ -18460,7 +18460,7 @@ fi
 
 base_libdir=`echo \${libdir} | sed 's/.*\/\(.*\)/\1/; q'`
 
-find_lib()
+find_lib_path()
 {
     gcc_bin_path=`$CC -print-search-dirs 2>/dev/null | $FGREP programs: | sed 's/[^=]*=\(.*\)/\1/' | sed 's/:/ /g'`
     gcc_lib_path=`$CC -print-search-dirs 2>/dev/null | $FGREP libraries: | sed 's/[^=]*=\(.*\)/\1/' | sed 's/:/ /g'`
@@ -18473,12 +18473,20 @@ find_lib()
     for path in $env_lib_path $gcc_bin_path $gcc_lib_path $host_lib_path; do
         lib=`ls -- $path/$1 2>/dev/null | sed 's,.*/,,' | "$SORT" -V -r | $AWK 'BEGIN{FS="."}{ print NF, $0 }' | "$SORT" -n -s | sed 's,[0-9]* ,,' | head -1`
         if test x$lib != x; then
-            echo $lib
+            echo `echo $path | sed 's,/*$,,'`/$lib
             return
         fi
     done
 }
 
+find_lib()
+{
+    lib=`find_lib_path "$@"`
+    if test x$lib != x; then
+        echo $lib | sed 's,.*/,,'
+    fi
+}
+
 { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for an ANSI C-conforming const" >&5
 printf %s "checking for an ANSI C-conforming const... " >&6; }
 if test ${ac_cv_c_const+y}
@@ -19815,6 +19823,18 @@ if test "x$ac_cv_func_posix_fallocate" = xyes
 then :
   printf "%s\n" "#define HAVE_POSIX_FALLOCATE 1" >>confdefs.h
 
+fi
+ac_fn_c_check_func "$LINENO" "mkostemp" "ac_cv_func_mkostemp"
+if test "x$ac_cv_func_mkostemp" = xyes
+then :
+  printf "%s\n" "#define HAVE_MKOSTEMP 1" >>confdefs.h
+
+fi
+ac_fn_c_check_func "$LINENO" "pipe2" "ac_cv_func_pipe2"
+if test "x$ac_cv_func_pipe2" = xyes
+then :
+  printf "%s\n" "#define HAVE_PIPE2 1" >>confdefs.h
+
 fi
 ac_fn_c_check_func "$LINENO" "_Exit" "ac_cv_func__Exit"
 if test "x$ac_cv_func__Exit" = xyes
@@ -23322,6 +23342,12 @@ fi
 
 
             case "$host" in
+                *-*-darwin*)
+                    wayland_client_lib=`find_lib_path "libwayland-client.[0-9]*.dylib" "$WAYLAND_LIBS"`
+                    wayland_egl_lib=`find_lib_path "libwayland-egl.[0-9]*.dylib" "$WAYLAND_LIBS"`
+                    wayland_cursor_lib=`find_lib_path "libwayland-cursor.[0-9]*.dylib" "$WAYLAND_LIBS"`
+                    xkbcommon_lib=`find_lib_path "libxkbcommon.[0-9]*.dylib" "$WAYLAND_LIBS"`
+                    ;;
                 *)
                     wayland_client_lib=`find_lib "libwayland-client.so.*" "$WAYLAND_LIBS" | sed 's/.*\/\(.*\)/\1/; q'`
                     wayland_egl_lib=`find_lib "libwayland-egl.so.*" "$WAYLAND_LIBS" | sed 's/.*\/\(.*\)/\1/; q'`
@@ -23468,7 +23494,14 @@ else $as_nop
 fi
 
 
-                    decor_lib=`find_lib "libdecor-0.so.*" "$DECOR_LIBS" | sed 's/.*\/\(.*\)/\1/; q'`
+                    case "$host" in
+                        *-*-darwin*)
+                            decor_lib=`find_lib_path "libdecor-0.[0-9]*.dylib" "$DECOR_LIBS"`
+                            ;;
+                        *)
+                            decor_lib=`find_lib "libdecor-0.so.*" "$DECOR_LIBS" | sed 's/.*\/\(.*\)/\1/; q'`
+                            ;;
+                    esac
 
                     if test x$enable_wayland_shared != xyes; then
                        enable_libdecor_shared=no
@@ -26090,6 +26123,21 @@ printf "%s\n" "$video_opengl_egl" >&6; }
 
 printf "%s\n" "#define SDL_VIDEO_OPENGL_EGL 1" >>confdefs.h
 
+
+            case "$host" in
+                *-*-darwin*)
+                    egl_lib=`find_lib_path "libEGL.[0-9]*.dylib"`
+                    if test x$egl_lib = x; then
+                        egl_lib=`find_lib_path "libEGL.dylib"`
+                    fi
+                    if test x$egl_lib != x; then
+                        echo "-- dynamic libEGL -> $egl_lib"
+
+printf "%s\n" "#define SDL_VIDEO_OPENGL_EGL_LIBRARY \"$egl_lib\"" >>confdefs.h
+
+                    fi
+                    ;;
+            esac
         fi
     fi
 }
@@ -29432,6 +29480,9 @@ printf "%s\n" "#define SDL_VIDEO_RENDER_OGL_ES2 1" >>confdefs.h
         CheckX11
         CheckMacGL
         CheckMacGLES
+        # Need to check for EGL first because Wayland depends on it.
+        CheckEGL
+        CheckWayland
         CheckGLX
         CheckOpenGL
         CheckVulkan
diff --git configure.ac configure.ac
index ba2f8acde..6b08c60ee 100644
--- configure.ac
+++ configure.ac
@@ -179,7 +179,9 @@ dnl set this to use on systems that use lib64 instead of lib
 base_libdir=`echo \${libdir} | sed 's/.*\/\(.*\)/\1/; q'`
 
 dnl Function to find a library in the compiler search path
-find_lib()
+dnl Reports the full path of the library, for platforms whose runtime linker
+dnl won't find it from the file name alone.
+find_lib_path()
 {
     gcc_bin_path=[`$CC -print-search-dirs 2>/dev/null | $FGREP programs: | sed 's/[^=]*=\(.*\)/\1/' | sed 's/:/ /g'`]
     gcc_lib_path=[`$CC -print-search-dirs 2>/dev/null | $FGREP libraries: | sed 's/[^=]*=\(.*\)/\1/' | sed 's/:/ /g'`]
@@ -192,12 +194,20 @@ find_lib()
     for path in $env_lib_path $gcc_bin_path $gcc_lib_path $host_lib_path; do
         lib=[`ls -- $path/$1 2>/dev/null | sed 's,.*/,,' | "$SORT" -V -r | $AWK 'BEGIN{FS="."}{ print NF, $0 }' | "$SORT" -n -s | sed 's,[0-9]* ,,' | head -1`]
         if test x$lib != x; then
-            echo $lib
+            echo [`echo $path | sed 's,/*$,,'`]/$lib
             return
         fi
     done
 }
 
+find_lib()
+{
+    lib=[`find_lib_path "$@"`]
+    if test x$lib != x; then
+        echo $lib | sed 's,.*/,,'
+    fi
+}
+
 dnl Check for compiler characteristics
 AC_C_CONST
 AC_C_INLINE
@@ -359,7 +369,7 @@ dnl Checks for library functions.
         AC_DEFINE(HAVE_MPROTECT, 1, [ ])
         ],[]),
     )
-    AC_CHECK_FUNCS(malloc calloc realloc free getenv setenv putenv unsetenv bsearch qsort abs bcopy memset memcmp memcpy memmove wcslen wcslcpy wcslcat _wcsdup wcsdup wcsstr wcscmp wcsncmp wcscasecmp _wcsicmp wcsncasecmp _wcsnicmp strlen strlcpy strlcat _strrev _strupr _strlwr index rindex strchr strrchr strstr strtok_r itoa _ltoa _uitoa _ultoa strtod strtol strtoul _i64toa _ui64toa strtoll strtoull atoi atof strcmp strncmp _stricmp strcasecmp _strnicmp strncasecmp strcasestr vsscanf vsnprintf fopen64 fseeko fseeko64 sigaction sigtimedwait setjmp nanosleep sysconf sysctlbyname getauxval elf_aux_info poll memfd_create posix_fallocate _Exit)
+    AC_CHECK_FUNCS(malloc calloc realloc free getenv setenv putenv unsetenv bsearch qsort abs bcopy memset memcmp memcpy memmove wcslen wcslcpy wcslcat _wcsdup wcsdup wcsstr wcscmp wcsncmp wcscasecmp _wcsicmp wcsncasecmp _wcsnicmp strlen strlcpy strlcat _strrev _strupr _strlwr index rindex strchr strrchr strstr strtok_r itoa _ltoa _uitoa _ultoa strtod strtol strtoul _i64toa _ui64toa strtoll strtoull atoi atof strcmp strncmp _stricmp strcasecmp _strnicmp strncasecmp strcasestr vsscanf vsnprintf fopen64 fseeko fseeko64 sigaction sigtimedwait setjmp nanosleep sysconf sysctlbyname getauxval elf_aux_info poll memfd_create posix_fallocate mkostemp pipe2 _Exit)
 
     AC_CHECK_LIB(m, pow, [LIBS="$LIBS -lm"; EXTRA_LDFLAGS="$EXTRA_LDFLAGS -lm"])
     AC_CHECK_FUNCS(acos acosf asin asinf atan atanf atan2 atan2f ceil ceilf copysign copysignf cos cosf exp expf fabs fabsf floor floorf trunc truncf fmod fmodf log logf log10 log10f lround lroundf pow powf round roundf scalbn scalbnf sin sinf sqrt sqrtf tan tanf)
@@ -1731,8 +1741,19 @@ CheckWayland()
 [AS_HELP_STRING([--enable-wayland-shared], [dynamically load Wayland support [default=maybe]])],
                           , enable_wayland_shared=maybe)
 
-dnl FIXME: Do BSD and OS X need special cases?
+dnl FIXME: Do BSDs need a special case?
             case "$host" in
+                *-*-darwin*)
+                    dnl Full paths, not file names: dyld only looks a bare name up
+                    dnl in DYLD_LIBRARY_PATH and a handful of system directories,
+                    dnl which won't be where a package manager put these. dlopen()
+                    dnl still consults DYLD_LIBRARY_PATH by file name first, so an
+                    dnl absolute path here stays overridable.
+                    wayland_client_lib=[`find_lib_path "libwayland-client.[0-9]*.dylib" "$WAYLAND_LIBS"`]
+                    wayland_egl_lib=[`find_lib_path "libwayland-egl.[0-9]*.dylib" "$WAYLAND_LIBS"`]
+                    wayland_cursor_lib=[`find_lib_path "libwayland-cursor.[0-9]*.dylib" "$WAYLAND_LIBS"`]
+                    xkbcommon_lib=[`find_lib_path "libxkbcommon.[0-9]*.dylib" "$WAYLAND_LIBS"`]
+                    ;;
                 *)
                     wayland_client_lib=[`find_lib "libwayland-client.so.*" "$WAYLAND_LIBS" | sed 's/.*\/\(.*\)/\1/; q'`]
                     wayland_egl_lib=[`find_lib "libwayland-egl.so.*" "$WAYLAND_LIBS" | sed 's/.*\/\(.*\)/\1/; q'`]
@@ -1787,7 +1808,14 @@ dnl See if libdecor is available
                     AC_ARG_ENABLE(libdecor-shared,
 [AS_HELP_STRING([--enable-libdecor-shared], [dynamically load libdecor [default=yes]])],, enable_libdecor_shared=yes)
 
-                    decor_lib=[`find_lib "libdecor-0.so.*" "$DECOR_LIBS" | sed 's/.*\/\(.*\)/\1/; q'`]
+                    case "$host" in
+                        *-*-darwin*)
+                            decor_lib=[`find_lib_path "libdecor-0.[0-9]*.dylib" "$DECOR_LIBS"`]
+                            ;;
+                        *)
+                            decor_lib=[`find_lib "libdecor-0.so.*" "$DECOR_LIBS" | sed 's/.*\/\(.*\)/\1/; q'`]
+                            ;;
+                    esac
 
                     if test x$enable_wayland_shared != xyes; then
                        enable_libdecor_shared=no
@@ -2558,6 +2586,24 @@ CheckEGL()
         AC_MSG_RESULT($video_opengl_egl)
         if test x$video_opengl_egl = xyes; then
             AC_DEFINE(SDL_VIDEO_OPENGL_EGL, 1, [ ])
+
+            case "$host" in
+                *-*-darwin*)
+                    dnl dyld won't find a libEGL that lives outside of its own
+                    dnl small set of search directories by file name, so record
+                    dnl where this one is. DYLD_LIBRARY_PATH is still consulted
+                    dnl by file name first, which is how a compositor's own EGL
+                    dnl (owl-egl and the like) gets picked up instead.
+                    egl_lib=[`find_lib_path "libEGL.[0-9]*.dylib"`]
+                    if test x$egl_lib = x; then
+                        egl_lib=[`find_lib_path "libEGL.dylib"`]
+                    fi
+                    if test x$egl_lib != x; then
+                        echo "-- dynamic libEGL -> $egl_lib"
+                        AC_DEFINE_UNQUOTED(SDL_VIDEO_OPENGL_EGL_LIBRARY, "$egl_lib", [ ])
+                    fi
+                    ;;
+            esac
         fi
     fi
 }
@@ -4391,6 +4437,9 @@ dnl BeOS support removed after SDL 2.0.1. Haiku still works.  --ryan.
         CheckX11
         CheckMacGL
         CheckMacGLES
+        # Need to check for EGL first because Wayland depends on it.
+        CheckEGL
+        CheckWayland
         CheckGLX
         CheckOpenGL
         CheckVulkan
diff --git docs/README-macos.md docs/README-macos.md
index 8e0a4a998..ac1425a68 100644
--- docs/README-macos.md
+++ docs/README-macos.md
@@ -55,6 +55,10 @@ of SDL 2.24.0.
 To use the library once it's built, you essential have two possibilities:
 use the traditional autoconf/automake/make method, or use Xcode.
 
+Besides the Cocoa video driver, SDL can also be built with the Wayland one, for
+talking to a Wayland compositor hosted on Mac OS X. That is off by default and
+is described in [README-wayland-macos.md](README-wayland-macos.md).
+
 
 # Caveats for using SDL with Mac OS X
 
diff --git docs/README-wayland-macos.md docs/README-wayland-macos.md
new file mode 100644
index 000000000..65014464d
--- /dev/null
+++ docs/README-wayland-macos.md
@@ -0,0 +1,118 @@
+# Wayland on Mac OS X
+
+SDL's Wayland video driver is not Linux-specific: it talks to a compositor over
+a Unix socket, and there are Wayland compositors that run on Mac OS X and put
+Wayland clients into the native Quartz environment, in much the same way XQuartz
+does for X11. [Owl](https://github.com/owl-compositor/owl) is one such
+compositor.
+
+The Cocoa driver remains the default on this platform, and nothing about a
+normal macOS build changes. The Wayland driver is opt-in at build time, and
+opt-in again at run time.
+
+## What you need
+
+Wayland itself has to be built for Mac OS X first; the ports live under the
+[owl-compositor](https://github.com/owl-compositor) organization, and depend on
+[epoll-shim](https://github.com/jiixyj/epoll-shim) for the parts of libwayland
+that expect Linux event loop APIs.
+
+At build time SDL looks for, through `pkg-config`:
+
+- `wayland-client` (1.18 or newer), `wayland-egl`, `wayland-cursor`
+- `wayland-scanner`, to generate the protocol glue
+- `xkbcommon` (0.5.0 or newer)
+- `egl` — for the headers only; the library is loaded at run time, if at all
+
+Mesa provides the EGL headers. A compositor may also ship its own EGL, in which
+case that is what you want loaded at run time (see below).
+
+## Building
+
+With CMake, the driver is off by default here and is turned on explicitly:
+
+```bash
+mkdir build
+cd build
+cmake -DCMAKE_BUILD_TYPE=Release -DSDL_WAYLAND=ON ..
+make
+sudo make install
+```
+
+With configure, it is detected the same way as on any other Unix, so it is
+enabled whenever the dependencies above are installed, and can be turned off
+with `--disable-video-wayland`:
+
+```bash
+./autogen.sh          # configure.ac knows about Wayland on Darwin
+mkdir build
+cd build
+../configure
+make
+sudo make install
+```
+
+The libraries are loaded with `dlopen()` rather than linked against, unless
+`--disable-wayland-shared` (or `-DSDL_WAYLAND_SHARED=OFF`) says otherwise. Since
+dyld only resolves a bare file name out of `DYLD_LIBRARY_PATH` and a handful of
+system directories — never a package manager's prefix — the full paths of the
+libraries found at build time are what gets baked in. `DYLD_LIBRARY_PATH` still
+takes precedence over them at run time, by file name, so an override works the
+way it usually does on this platform.
+
+## Running
+
+The compositor has to be running, and `WAYLAND_DISPLAY` (and normally
+`XDG_RUNTIME_DIR`) has to point at it — the same environment any other Wayland
+client needs. Then ask for the driver by name:
+
+```bash
+SDL_VIDEODRIVER=wayland ./mygame
+```
+
+Without that, SDL picks the Cocoa driver, which is the intended behavior: a
+stale `WAYLAND_DISPLAY` in the environment should never quietly move a native
+Mac application onto a compositor.
+
+## How windows get drawn
+
+There are two paths, and which one is used depends on what is available:
+
+- **EGL.** `SDL_WINDOW_OPENGL` windows, and the accelerated `opengl` /
+  `opengles2` renderers, go through EGL. Mesa's EGL works if it was built with
+  Wayland platform support; a compositor's own EGL (owl-egl, which puts client
+  rendering onto CGL and IOSurface) is the faster path where it exists.
+- **Shared memory.** `SDL_GetWindowSurface()` and the `software` renderer are
+  backed by `wl_shm` buffers, with no EGL involved at all. This is what runs on
+  a machine where EGL is missing or not worth using.
+
+By default SDL prefers to back `SDL_GetWindowSurface()` with a GPU texture,
+falling back to the shared memory path when that fails. To skip the attempt and
+use `wl_shm` directly:
+
+```bash
+SDL_FRAMEBUFFER_ACCELERATION=0 SDL_VIDEODRIVER=wayland ./myapp
+```
+
+Which EGL gets loaded is decided at run time in this order:
+
+1. `SDL_VIDEO_EGL_DRIVER`, if set — a file name or a full path.
+2. `DYLD_LIBRARY_PATH`, matched against the file name of the EGL recorded at
+   build time. This is how a compositor's EGL is normally selected:
+   `DYLD_LIBRARY_PATH=/opt/local/lib/owl-egl ./mygame`.
+3. The EGL that was found at build time.
+
+## Limitations
+
+- **Vulkan does not work on this driver.** MoltenVK implements the Metal
+  surface extension, not `VK_KHR_wayland_surface`. Vulkan applications want the
+  Cocoa driver.
+- **Window decorations come from the compositor.** SDL uses `libdecor` for
+  client-side decorations, which is generally not built for Mac OS X, so a
+  compositor that doesn't implement `xdg-decoration` (or draw native frames of
+  its own) will leave windows undecorated.
+- **Message boxes** shell out to `zenity`, and fail without it. This affects
+  `SDL_ShowMessageBox()` when the Wayland driver is active.
+- **Input methods** are the compositor's business here: SDL's own IME support is
+  a Linux-only build option, so dead keys, emoji and IME composition work only
+  as far as the compositor implements `text-input-v3`.
diff --git include/SDL_config.h.cmake include/SDL_config.h.cmake
index 64b8413c8..6509fc919 100644
--- include/SDL_config.h.cmake
+++ include/SDL_config.h.cmake
@@ -192,6 +192,8 @@
 #cmakedefine HAVE_FSEEKO64 1
 #cmakedefine HAVE_MEMFD_CREATE 1
 #cmakedefine HAVE_POSIX_FALLOCATE 1
+#cmakedefine HAVE_MKOSTEMP 1
+#cmakedefine HAVE_PIPE2 1
 #cmakedefine HAVE_SIGACTION 1
 #cmakedefine HAVE_SIGTIMEDWAIT 1
 #cmakedefine HAVE_SA_SIGACTION 1
@@ -478,6 +480,7 @@
 #cmakedefine SDL_VIDEO_OPENGL_GLX @SDL_VIDEO_OPENGL_GLX@
 #cmakedefine SDL_VIDEO_OPENGL_WGL @SDL_VIDEO_OPENGL_WGL@
 #cmakedefine SDL_VIDEO_OPENGL_EGL @SDL_VIDEO_OPENGL_EGL@
+#cmakedefine SDL_VIDEO_OPENGL_EGL_LIBRARY @SDL_VIDEO_OPENGL_EGL_LIBRARY@
 #cmakedefine SDL_VIDEO_OPENGL_OSMESA @SDL_VIDEO_OPENGL_OSMESA@
 #cmakedefine SDL_VIDEO_OPENGL_OSMESA_DYNAMIC @SDL_VIDEO_OPENGL_OSMESA_DYNAMIC@
 
diff --git include/SDL_config.h.in include/SDL_config.h.in
index 35281d158..c97cd7747 100644
--- include/SDL_config.h.in
+++ include/SDL_config.h.in
@@ -214,6 +214,8 @@
 #undef HAVE_POLL
 #undef HAVE_MEMFD_CREATE
 #undef HAVE_POSIX_FALLOCATE
+#undef HAVE_MKOSTEMP
+#undef HAVE_PIPE2
 #undef HAVE__EXIT
 
 #else
@@ -433,6 +435,7 @@
 #undef SDL_VIDEO_OPENGL_BGL
 #undef SDL_VIDEO_OPENGL_CGL
 #undef SDL_VIDEO_OPENGL_EGL
+#undef SDL_VIDEO_OPENGL_EGL_LIBRARY
 #undef SDL_VIDEO_OPENGL_GLX
 #undef SDL_VIDEO_OPENGL_WGL
 #undef SDL_VIDEO_OPENGL_OSMESA
diff --git src/render/opengl/SDL_render_gl.c src/render/opengl/SDL_render_gl.c
index ea3fe1074..3cba319cb 100644
--- src/render/opengl/SDL_render_gl.c
+++ src/render/opengl/SDL_render_gl.c
@@ -101,6 +101,14 @@ typedef struct
     SDL_bool GL_EXT_framebuffer_object_supported;
     GL_FBOList *framebuffers;
 
+#ifdef __MACOSX__
+    /* A macOS build may run against a non-Apple GL, e.g. Mesa via the
+       Wayland video driver, which lacks the Apple extensions. */
+    SDL_bool GL_APPLE_ycbcr_422_supported;
+    SDL_bool GL_APPLE_texture_range_supported;
+    SDL_bool GL_APPLE_client_storage_supported;
+#endif
+
     /* OpenGL functions */
 #define SDL_PROC(ret, func, params) ret (APIENTRY *func) params;
 #include "SDL_glfuncs.h"
@@ -434,6 +442,9 @@ convert_format(GL_RenderData *renderdata, Uint32 pixel_format,
         break;
 #ifdef __MACOSX__
     case SDL_PIXELFORMAT_UYVY:
+        if (!renderdata->GL_APPLE_ycbcr_422_supported) {
+            return SDL_FALSE;
+        }
         *internalFormat = GL_RGB8;
         *format = GL_YCBCR_422_APPLE;
         *type = GL_UNSIGNED_SHORT_8_8_APPLE;
@@ -557,14 +568,17 @@ static int GL_CreateTexture(SDL_Renderer *renderer, SDL_Texture *texture)
 #ifndef STORAGE_SHARED_APPLE
 #define STORAGE_SHARED_APPLE 0x85BF
 #endif
-    if (texture->access == SDL_TEXTUREACCESS_STREAMING) {
-        renderdata->glTexParameteri(textype, GL_TEXTURE_STORAGE_HINT_APPLE,
-                                    GL_STORAGE_SHARED_APPLE);
-    } else {
-        renderdata->glTexParameteri(textype, GL_TEXTURE_STORAGE_HINT_APPLE,
-                                    GL_STORAGE_CACHED_APPLE);
+    if (renderdata->GL_APPLE_texture_range_supported) {
+        if (texture->access == SDL_TEXTUREACCESS_STREAMING) {
+            renderdata->glTexParameteri(textype, GL_TEXTURE_STORAGE_HINT_APPLE,
+                                        GL_STORAGE_SHARED_APPLE);
+        } else {
+            renderdata->glTexParameteri(textype, GL_TEXTURE_STORAGE_HINT_APPLE,
+                                        GL_STORAGE_CACHED_APPLE);
+        }
     }
-    if (texture->access == SDL_TEXTUREACCESS_STREAMING && texture->format == SDL_PIXELFORMAT_ARGB8888 && (texture->w % 8) == 0) {
+    if (renderdata->GL_APPLE_client_storage_supported &&
+        texture->access == SDL_TEXTUREACCESS_STREAMING && texture->format == SDL_PIXELFORMAT_ARGB8888 && (texture->w % 8) == 0) {
         renderdata->glPixelStorei(GL_UNPACK_CLIENT_STORAGE_APPLE, GL_TRUE);
         renderdata->glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
         renderdata->glPixelStorei(GL_UNPACK_ROW_LENGTH,
@@ -1889,7 +1903,12 @@ static int GL_CreateRenderer(SDL_Renderer *renderer, SDL_Window *window, Uint32
     }
 #endif
 #ifdef __MACOSX__
-    renderer->info.texture_formats[renderer->info.num_texture_formats++] = SDL_PIXELFORMAT_UYVY;
+    data->GL_APPLE_ycbcr_422_supported = SDL_GL_ExtensionSupported("GL_APPLE_ycbcr_422");
+    data->GL_APPLE_texture_range_supported = SDL_GL_ExtensionSupported("GL_APPLE_texture_range");
+    data->GL_APPLE_client_storage_supported = SDL_GL_ExtensionSupported("GL_APPLE_client_storage");
+    if (data->GL_APPLE_ycbcr_422_supported) {
+        renderer->info.texture_formats[renderer->info.num_texture_formats++] = SDL_PIXELFORMAT_UYVY;
+    }
 #endif
 
     renderer->rect_index_order[0] = 0;
diff --git src/video/SDL_egl.c src/video/SDL_egl.c
index 50f7082f4..553ed2776 100644
--- src/video/SDL_egl.c
+++ src/video/SDL_egl.c
@@ -80,8 +80,12 @@
 #define DEFAULT_OGL_ES_PVR "libGLES_CM.dll"
 #define DEFAULT_OGL_ES     "libGLESv1_CM.dll"
 
-#elif defined(SDL_VIDEO_DRIVER_COCOA)
-/* EGL AND OpenGL ES support via ANGLE */
+#elif defined(SDL_VIDEO_DRIVER_COCOA) || defined(__MACOSX__)
+/* EGL AND OpenGL ES support via ANGLE, or EGL over the system OpenGL (Mesa,
+ * owl-egl) for the Wayland driver. Desktop GL entry points always come out of
+ * the system framework, the same one the Cocoa driver loads.
+ */
+#define DEFAULT_OGL        "/System/Library/Frameworks/OpenGL.framework/Libraries/libGL.dylib"
 #define DEFAULT_EGL        "libEGL.dylib"
 #define DEFAULT_OGL_ES2    "libGLESv2.dylib"
 #define DEFAULT_OGL_ES_PVR "libGLES_CM.dylib"   //???
@@ -105,6 +109,15 @@
 #define DEFAULT_OGL_ES     "libGLESv1_CM.so.1"
 #endif /* SDL_VIDEO_DRIVER_RPI */
 
+/* The build system can pin the EGL implementation to load, which is what it
+ * does where the dynamic linker won't find it by leaf name alone (a Mac OS X
+ * package prefix, say). SDL_VIDEO_EGL_DRIVER still overrides this at runtime.
+ */
+#ifdef SDL_VIDEO_OPENGL_EGL_LIBRARY
+#undef DEFAULT_EGL
+#define DEFAULT_EGL SDL_VIDEO_OPENGL_EGL_LIBRARY
+#endif
+
 #if defined(SDL_VIDEO_OPENGL) && !defined(SDL_VIDEO_VITA_PVR_OGL)
 #include "SDL_opengl.h"
 #endif
@@ -246,11 +259,16 @@ void *SDL_EGL_GetProcAddress(_THIS, const char *proc)
 {
     void *retval = NULL;
     if (_this->egl_data) {
-        const Uint32 eglver = (((Uint32)_this->egl_data->egl_version_major) << 16) | ((Uint32)_this->egl_data->egl_version_minor);
-        const SDL_bool is_egl_15_or_later = eglver >= ((((Uint32)1) << 16) | 5);
+        /* EGL 1.5, or EGL_KHR_get_all_proc_addresses, can use eglGetProcAddress() for
+         * any symbol. 1.4 and earlier can't use it for core entry points.
+         *
+         * Where it is available it also has to be tried first: an EGL layered on top
+         * of another GL API (owl-egl over CGL on Mac OS X, for one) hands out wrapped
+         * core entry points here that the GL library underneath doesn't know about.
+         */
+        const SDL_bool get_all_proc_addresses = _this->egl_data->egl_get_all_proc_addresses;
 
-        /* EGL 1.5 can use eglGetProcAddress() for any symbol. 1.4 and earlier can't use it for core entry points. */
-        if (!retval && is_egl_15_or_later && _this->egl_data->eglGetProcAddress) {
+        if (!retval && get_all_proc_addresses && _this->egl_data->eglGetProcAddress) {
             retval = _this->egl_data->eglGetProcAddress(proc);
         }
 
@@ -261,8 +279,8 @@ void *SDL_EGL_GetProcAddress(_THIS, const char *proc)
         }
 #endif
 
-        /* Try eglGetProcAddress if we're on <= 1.4 and still searching... */
-        if (!retval && !is_egl_15_or_later && _this->egl_data->eglGetProcAddress) {
+        /* Try eglGetProcAddress if we couldn't use it up front and are still searching... */
+        if (!retval && !get_all_proc_addresses && _this->egl_data->eglGetProcAddress) {
             retval = _this->egl_data->eglGetProcAddress(proc);
         }
     }
@@ -491,6 +509,9 @@ static void SDL_EGL_GetVersion(_THIS)
             if (SDL_sscanf(egl_version, "%d.%d", &major, &minor) == 2) {
                 _this->egl_data->egl_version_major = major;
                 _this->egl_data->egl_version_minor = minor;
+                if (major > 1 || (major == 1 && minor >= 5)) {
+                    _this->egl_data->egl_get_all_proc_addresses = SDL_TRUE;
+                }
             } else {
                 SDL_LogWarn(SDL_LOG_CATEGORY_VIDEO, "Could not parse EGL version string: %s", egl_version);
             }
@@ -498,6 +519,20 @@ static void SDL_EGL_GetVersion(_THIS)
     }
 }
 
+/* EGL 1.4 and earlier only promise eglGetProcAddress() for extension entry
+ * points, unless EGL_KHR_get_all_proc_addresses says otherwise. Needs an
+ * initialized display, so it can't be folded into SDL_EGL_GetVersion().
+ */
+static void SDL_EGL_CheckGetAllProcAddresses(_THIS)
+{
+    if (!_this->egl_data->egl_get_all_proc_addresses) {
+        if (SDL_EGL_HasExtension(_this, SDL_EGL_DISPLAY_EXTENSION, "EGL_KHR_get_all_proc_addresses") ||
+            SDL_EGL_HasExtension(_this, SDL_EGL_CLIENT_EXTENSION, "EGL_KHR_client_get_all_proc_addresses")) {
+            _this->egl_data->egl_get_all_proc_addresses = SDL_TRUE;
+        }
+    }
+}
+
 int SDL_EGL_LoadLibrary(_THIS, const char *egl_path, NativeDisplayType native_display, EGLenum platform)
 {
     int library_load_retcode = SDL_EGL_LoadLibraryOnly(_this, egl_path);
@@ -553,6 +588,7 @@ int SDL_EGL_LoadLibrary(_THIS, const char *egl_path, NativeDisplayType native_di
 
     /* Get the EGL version with a valid egl_display, for EGL <= 1.4 */
     SDL_EGL_GetVersion(_this);
+    SDL_EGL_CheckGetAllProcAddresses(_this);
 
     _this->egl_data->is_offscreen = SDL_FALSE;
 
@@ -639,6 +675,7 @@ int SDL_EGL_InitializeOffscreen(_THIS, int device)
 
     /* Get the EGL version with a valid egl_display, for EGL <= 1.4 */
     SDL_EGL_GetVersion(_this);
+    SDL_EGL_CheckGetAllProcAddresses(_this);
 
     _this->egl_data->is_offscreen = SDL_TRUE;
 
diff --git src/video/SDL_egl_c.h src/video/SDL_egl_c.h
index f6052617e..a1ddf839d 100644
--- src/video/SDL_egl_c.h
+++ src/video/SDL_egl_c.h
@@ -39,6 +39,7 @@ typedef struct SDL_EGL_VideoData
     int egl_swapinterval;
     int egl_surfacetype;
     int egl_version_major, egl_version_minor;
+    SDL_bool egl_get_all_proc_addresses; /* eglGetProcAddress() is good for core entry points too */
     EGLint egl_required_visual_id;
     SDL_bool is_offscreen;  /* whether EGL display was offscreen */
     EGLenum apitype;  /* EGL_OPENGL_ES_API, EGL_OPENGL_API, etc */
diff --git src/video/SDL_video.c src/video/SDL_video.c
index 2f6c631d1..3928ed8fb 100644
--- src/video/SDL_video.c
+++ src/video/SDL_video.c
@@ -2698,6 +2698,11 @@ static SDL_bool ShouldAttemptTextureFramebuffer(void)
             attempt_texture_framebuffer = SDL_FALSE;
         }
 #endif
+#if defined(__MACOSX__) /* The only GL under the Wayland driver on macOS is software Mesa; the wl_shm framebuffer is faster. */
+        if (_this->CreateWindowFramebuffer && (SDL_strcmp(_this->name, "wayland") == 0)) {
+            attempt_texture_framebuffer = SDL_FALSE;
+        }
+#endif
 #if defined(__EMSCRIPTEN__)
         attempt_texture_framebuffer = SDL_FALSE;
 #endif
diff --git src/video/wayland/SDL_waylanddatamanager.c src/video/wayland/SDL_waylanddatamanager.c
index cece2427e..f018a0811 100644
--- src/video/wayland/SDL_waylanddatamanager.c
+++ src/video/wayland/SDL_waylanddatamanager.c
@@ -82,6 +82,35 @@ static int sigtimedwait(const sigset_t *set, siginfo_t *info, const struct times
 }
 #endif
 
+/* pipe2() is a Linux/BSD extension; on systems without it (macOS, older
+ * platforms) the flags have to be applied to a plain pipe() afterwards.
+ */
+static int CreateDataPipe(int pipefd[2])
+{
+#ifdef HAVE_PIPE2
+    return pipe2(pipefd, O_CLOEXEC | O_NONBLOCK);
+#else
+    int i;
+
+    if (pipe(pipefd) == -1) {
+        return -1;
+    }
+
+    for (i = 0; i < 2; ++i) {
+        const int flags = fcntl(pipefd[i], F_GETFL, 0);
+        if (flags == -1 ||
+            fcntl(pipefd[i], F_SETFL, flags | O_NONBLOCK) == -1 ||
+            fcntl(pipefd[i], F_SETFD, FD_CLOEXEC) == -1) {
+            close(pipefd[0]);
+            close(pipefd[1]);
+            return -1;
+        }
+    }
+
+    return 0;
+#endif
+}
+
 static ssize_t write_pipe(int fd, const void *buffer, size_t total_length, size_t *pos)
 {
     int ready = 0;
@@ -471,7 +500,7 @@ void *Wayland_data_offer_receive(SDL_WaylandDataOffer *offer,
     data_device = offer->data_device;
     if (!data_device) {
         SDL_SetError("Data device not initialized");
-    } else if (pipe2(pipefd, O_CLOEXEC | O_NONBLOCK) == -1) {
+    } else if (CreateDataPipe(pipefd) == -1) {
         SDL_SetError("Could not read pipe");
     } else {
         wl_data_offer_receive(offer->offer, mime_type, pipefd[1]);
@@ -505,7 +534,7 @@ void *Wayland_primary_selection_offer_receive(SDL_WaylandPrimarySelectionOffer *
     primary_selection_device = offer->primary_selection_device;
     if (!primary_selection_device) {
         SDL_SetError("Primary selection device not initialized");
-    } else if (pipe2(pipefd, O_CLOEXEC | O_NONBLOCK) == -1) {
+    } else if (CreateDataPipe(pipefd) == -1) {
         SDL_SetError("Could not read pipe");
     } else {
         zwp_primary_selection_offer_v1_receive(offer->offer, mime_type, pipefd[1]);
diff --git src/video/wayland/SDL_waylandframebuffer.c src/video/wayland/SDL_waylandframebuffer.c
new file mode 100644
index 000000000..f30cefbfa
--- /dev/null
+++ src/video/wayland/SDL_waylandframebuffer.c
@@ -0,0 +1,315 @@
+/*
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2025 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
+
+#include "../../SDL_internal.h"
+
+#ifdef SDL_VIDEO_DRIVER_WAYLAND
+
+#include "SDL_waylandframebuffer.h"
+#include "SDL_waylandvideo.h"
+#include "SDL_waylandwindow.h"
+#include "SDL_waylandshmbuffer.h"
+
+/* Software rendering support, for windows that aren't backed by EGL: what the
+ * application draws into is a plain shadow buffer, whose damaged parts get
+ * copied into a wl_shm buffer and committed to the surface.
+ *
+ * The shadow is what makes the pixel pointer SDL hands out stay put for the
+ * lifetime of the window surface, which it has to: a wl_shm buffer can't be
+ * written to while the compositor is still reading it.
+ */
+#define SHM_FRAMEBUFFER_COUNT 2
+
+struct Wayland_SHMFrameBuffer
+{
+    struct Wayland_SHMBuffer buffers[SHM_FRAMEBUFFER_COUNT];
+
+    /* Whether a buffer holds something older than the last frame, and so needs
+     * a full copy rather than just the damaged rectangles.
+     */
+    SDL_bool stale[SHM_FRAMEBUFFER_COUNT];
+    int last; /* the buffer that was committed last time around */
+
+    void *shadow;
+    int width;
+    int height;
+    int pitch;
+
+    SDL_bool committed;
+};
+
+static void DestroyFrameBuffer(struct Wayland_SHMFrameBuffer *framebuffer)
+{
+    int i;
+
+    for (i = 0; i < SHM_FRAMEBUFFER_COUNT; ++i) {
+        Wayland_ReleaseSHMBuffer(&framebuffer->buffers[i]);
+    }
+
+    SDL_free(framebuffer->shadow);
+    SDL_free(framebuffer);
+}
+
+/* Picks a buffer the compositor isn't reading from, preferring the one that was
+ * used last time: its contents are the previous frame, so an update that only
+ * damages part of the window only has to copy that part.
+ */
+static int AcquireBuffer(struct Wayland_SHMFrameBuffer *framebuffer)
+{
+    int i;
+
+    if (!framebuffer->buffers[framebuffer->last].busy) {
+        return framebuffer->last;
+    }
+
+    for (i = 0; i < SHM_FRAMEBUFFER_COUNT; ++i) {
+        if (!framebuffer->buffers[i].busy) {
+            return i;
+        }
+    }
+
+    return -1;
+}
+
+/* wl_surface.damage_buffer takes buffer coordinates, which is what the update
+ * rectangles are in. Compositors older than version 4 only have
+ * wl_surface.damage, in surface coordinates, where scaling and viewports would
+ * have to be undone first; repainting the whole surface is both correct and
+ * simpler there.
+ */
+static SDL_bool CanDamageInBufferCoordinates(SDL_WindowData *data)
+{
+    return wl_compositor_get_version(data->waylandData->compositor) >= 4 ? SDL_TRUE : SDL_FALSE;
+}
+
+static void DamageWholeSurface(SDL_WindowData *data, const SDL_Rect *buffer_rect)
+{
+    if (CanDamageInBufferCoordinates(data)) {
+        wl_surface_damage_buffer(data->surface, 0, 0, buffer_rect->w, buffer_rect->h);
+    } else {
+        wl_surface_damage(data->surface, 0, 0, data->window_width, data->window_height);
+    }
+}
+
+int Wayland_CreateWindowFramebuffer(_THIS, SDL_Window *window, Uint32 *format, void **pixels, int *pitch)
+{
+    SDL_VideoData *viddata = _this->driverdata;
+    SDL_WindowData *data = window->driverdata;
+    struct Wayland_SHMFrameBuffer *framebuffer;
+    int w, h, i;
+
+    if (!data) {
+        return SDL_SetError("Window not initialized");
+    }
+    if (!viddata->shm) {
+        return SDL_SetError("Wayland compositor has no wl_shm support");
+    }
+
+    /* The video core recreates a window surface without destroying the old one. */
+    Wayland_DestroyWindowFramebuffer(_this, window);
+
+    SDL_GetWindowSizeInPixels(window, &w, &h);
+    if (w <= 0 || h <= 0) {
+        return SDL_SetError("Invalid window framebuffer size");
+    }
+
+    framebuffer = (struct Wayland_SHMFrameBuffer *)SDL_calloc(1, sizeof(*framebuffer));
+    if (!framebuffer) {
+        return SDL_OutOfMemory();
+    }
+
+    framebuffer->width = w;
+    framebuffer->height = h;
+    framebuffer->pitch = w * 4;
+
+    framebuffer->shadow = SDL_calloc(1, (size_t)framebuffer->pitch * h);
+    if (!framebuffer->shadow) {
+        DestroyFrameBuffer(framebuffer);
+        return SDL_OutOfMemory();
+    }
+
+    for (i = 0; i < SHM_FRAMEBUFFER_COUNT; ++i) {
+        /* XRGB8888, not ARGB8888: what the application leaves in the alpha
+         * channel is anyone's guess, and Wayland alpha is premultiplied, so an
+         * ARGB buffer would render a window that was never told about alpha
+         * as transparent.
+         */
+        if (Wayland_AllocSHMBufferFormat(w, h, WL_SHM_FORMAT_XRGB8888, &framebuffer->buffers[i]) < 0) {
+            DestroyFrameBuffer(framebuffer);
+            return -1; /* SDL_SetError was called by the allocation */
+        }
+        framebuffer->stale[i] = SDL_TRUE;
+    }
+
+    data->shm_framebuffer = framebuffer;
+
+    *format = SDL_PIXELFORMAT_RGB888;
+    *pixels = framebuffer->shadow;
+    *pitch = framebuffer->pitch;
+
+    return 0;
+}
+
+int Wayland_UpdateWindowFramebuffer(_THIS, SDL_Window *window, const SDL_Rect *rects, int numrects)
+{
+    SDL_VideoData *viddata = _this->driverdata;
+    SDL_WindowData *data = window->driverdata;
+    struct Wayland_SHMFrameBuffer *framebuffer = data ? data->shm_framebuffer : NULL;
+    struct Wayland_SHMBuffer *buffer;
+    SDL_Rect surface_rect;
+    SDL_bool full_copy;
+    int index;
+    int i;
+
+    if (!framebuffer) {
+        return SDL_SetError("Window has no framebuffer");
+    }
+
+    /* Nothing can be committed to a surface that hasn't been given a role and
+     * configured yet; showing the window is what does that.
+     */
+    if ((window->flags & SDL_WINDOW_HIDDEN) || data->shell_surface_type == WAYLAND_SURFACE_UNKNOWN) {
+        return 0;
+    }
+
+    /* A configure event can change the backbuffer size before the video core
+     * gets around to recreating the framebuffer. Committing a buffer of the old
+     * size is a protocol error on some compositors, so sit this frame out and
+     * wait for the new framebuffer.
+     */
+    if (framebuffer->width != data->drawable_width || framebuffer->height != data->drawable_height) {
+        return 0;
+    }
+
+    index = AcquireBuffer(framebuffer);
+    if (index < 0) {
+        /* Every buffer is still held by the compositor. Dropping the frame is
+         * better than drawing into a buffer that is being read: the shadow
+         * still has the contents, so the next update repaints in full.
+         */
+        for (i = 0; i < SHM_FRAMEBUFFER_COUNT; ++i) {
+            framebuffer->stale[i] = SDL_TRUE;
+        }
+        return 0;
+    }
+
+    buffer = &framebuffer->buffers[index];
+    full_copy = framebuffer->stale[index] || !framebuffer->committed;
+
+    surface_rect.x = 0;
+    surface_rect.y = 0;
+    surface_rect.w = framebuffer->width;
+    surface_rect.h = framebuffer->height;
+
+    if (full_copy) {
+        SDL_memcpy(buffer->shm_data, framebuffer->shadow, (size_t)framebuffer->pitch * framebuffer->height);
+    } else {
+        for (i = 0; i < numrects; ++i) {
+            SDL_Rect rect;
+            const Uint8 *src;
+            Uint8 *dst;
+            int row;
+
+            if (!SDL_IntersectRect(&rects[i], &surface_rect, &rect)) {
+                continue;
+            }
+
+            src = (const Uint8 *)framebuffer->shadow + (rect.y * framebuffer->pitch) + (rect.x * 4);
+            dst = (Uint8 *)buffer->shm_data + (rect.y * framebuffer->pitch) + (rect.x * 4);
+
+            for (row = 0; row < rect.h; ++row) {
+                SDL_memcpy(dst, src, (size_t)rect.w * 4);
+                src += framebuffer->pitch;
+                dst += framebuffer->pitch;
+            }
+        }
+    }
+
+    wl_surface_attach(data->surface, buffer->wl_buffer, 0, 0);
+
+    if (!framebuffer->committed || !CanDamageInBufferCoordinates(data)) {
+        DamageWholeSurface(data, &surface_rect);
+    } else {
+        for (i = 0; i < numrects; ++i) {
+            SDL_Rect rect;
+
+            if (!SDL_IntersectRect(&rects[i], &surface_rect, &rect)) {
+                continue;
+            }
+
+            wl_surface_damage_buffer(data->surface, rect.x, rect.y, rect.w, rect.h);
+        }
+    }
+
+    wl_surface_commit(data->surface);
+    WAYLAND_wl_display_flush(viddata->display);
+
+    buffer->busy = SDL_TRUE;
+
+    /* This buffer now matches the shadow; every other one lags behind it. */
+    for (i = 0; i < SHM_FRAMEBUFFER_COUNT; ++i) {
+        framebuffer->stale[i] = (i != index) ? SDL_TRUE : SDL_FALSE;
+    }
+    framebuffer->last = index;
+    framebuffer->committed = SDL_TRUE;
+
+    return 0;
+}
+
+void Wayland_DetachWindowFramebuffer(_THIS, SDL_Window *window)
+{
+    SDL_WindowData *data = window->driverdata;
+    struct Wayland_SHMFrameBuffer *framebuffer = data ? data->shm_framebuffer : NULL;
+    int i;
+
+    if (framebuffer) {
+        /* The surface has just had its buffers dropped, which is what a NULL
+         * attach does. Nothing of ours is on screen or being read from any
+         * more, whether the release events have arrived yet or not, and the
+         * next frame has to be a full one.
+         */
+        for (i = 0; i < SHM_FRAMEBUFFER_COUNT; ++i) {
+            framebuffer->buffers[i].busy = SDL_FALSE;
+            framebuffer->stale[i] = SDL_TRUE;
+        }
+        framebuffer->committed = SDL_FALSE;
+    }
+}
+
+void Wayland_DestroyWindowFramebuffer(_THIS, SDL_Window *window)
+{
+    SDL_WindowData *data = window->driverdata;
+
+    if (data && data->shm_framebuffer) {
+        /* The buffers are just destroyed, not detached from the surface first:
+         * committing a NULL buffer would unmap the window, and this is called
+         * to replace the framebuffer on resize as much as to tear it down.
+         * A compositor that is still holding one of them is left with the last
+         * frame on screen until something else is committed.
+         */
+        DestroyFrameBuffer(data->shm_framebuffer);
+        data->shm_framebuffer = NULL;
+    }
+}
+
+#endif /* SDL_VIDEO_DRIVER_WAYLAND */
+
+/* vi: set ts=4 sw=4 expandtab: */
diff --git src/video/wayland/SDL_waylandframebuffer.h src/video/wayland/SDL_waylandframebuffer.h
new file mode 100644
index 000000000..d8f72471d
--- /dev/null
+++ src/video/wayland/SDL_waylandframebuffer.h
@@ -0,0 +1,40 @@
+/*
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2025 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
+
+#include "../../SDL_internal.h"
+
+#ifndef SDL_waylandframebuffer_h_
+#define SDL_waylandframebuffer_h_
+
+#include "../SDL_sysvideo.h"
+
+struct Wayland_SHMFrameBuffer;
+
+extern int Wayland_CreateWindowFramebuffer(_THIS, SDL_Window *window, Uint32 *format, void **pixels, int *pitch);
+extern int Wayland_UpdateWindowFramebuffer(_THIS, SDL_Window *window, const SDL_Rect *rects, int numrects);
+extern void Wayland_DestroyWindowFramebuffer(_THIS, SDL_Window *window);
+
+/* Called where the window's surface has its buffers detached, so that the
+ * framebuffer doesn't go on waiting for release events that aren't coming.
+ */
+extern void Wayland_DetachWindowFramebuffer(_THIS, SDL_Window *window);
+
+#endif /* SDL_waylandframebuffer_h_ */
diff --git src/video/wayland/SDL_waylandshmbuffer.c src/video/wayland/SDL_waylandshmbuffer.c
index a64c7c350..110f1837d 100644
--- src/video/wayland/SDL_waylandshmbuffer.c
+++ src/video/wayland/SDL_waylandshmbuffer.c
@@ -81,6 +81,19 @@ static int CreateTempFD(off_t size)
         char tmp_path[PATH_MAX];
 
         xdg_path = SDL_getenv("XDG_RUNTIME_DIR");
+#ifdef __MACOSX__
+        /* XDG_RUNTIME_DIR isn't a given on macOS; the compositor can also be
+         * reached through an absolute WAYLAND_DISPLAY or through WAYLAND_SOCKET,
+         * neither of which requires it. Any directory we can hand a file
+         * descriptor out of will do here.
+         */
+        if (!xdg_path) {
+            xdg_path = SDL_getenv("TMPDIR");
+        }
+        if (!xdg_path) {
+            xdg_path = "/tmp";
+        }
+#endif
         if (!xdg_path) {
             return -1;
         }
@@ -88,7 +101,14 @@ static int CreateTempFD(off_t size)
         SDL_strlcpy(tmp_path, xdg_path, PATH_MAX);
         SDL_strlcat(tmp_path, template, PATH_MAX);
 
+#ifdef HAVE_MKOSTEMP
         fd = mkostemp(tmp_path, O_CLOEXEC);
+#else
+        fd = mkstemp(tmp_path);
+        if (fd >= 0) {
+            fcntl(fd, F_SETFD, FD_CLOEXEC);
+        }
+#endif
         if (fd < 0) {
             return -1;
         }
@@ -107,7 +127,11 @@ static int CreateTempFD(off_t size)
 
 static void buffer_handle_release(void *data, struct wl_buffer *wl_buffer)
 {
-    /* NOP */
+    struct Wayland_SHMBuffer *shmBuffer = (struct Wayland_SHMBuffer *)data;
+
+    if (shmBuffer) {
+        shmBuffer->busy = SDL_FALSE;
+    }
 }
 
 static struct wl_buffer_listener buffer_listener = {
@@ -115,13 +139,18 @@ static struct wl_buffer_listener buffer_listener = {
 };
 
 int Wayland_AllocSHMBuffer(int width, int height, struct Wayland_SHMBuffer *shmBuffer)
+{
+    return Wayland_AllocSHMBufferFormat(width, height, WL_SHM_FORMAT_ARGB8888, shmBuffer);
+}
+
+int Wayland_AllocSHMBufferFormat(int width, int height, Uint32 shm_format, struct Wayland_SHMBuffer *shmBuffer)
 {
     SDL_VideoDevice *vd = SDL_GetVideoDevice();
     SDL_VideoData *data = vd->driverdata;
     struct wl_shm_pool *shm_pool;
     int shm_fd;
     int stride;
-    const Uint32 SHM_FMT = WL_SHM_FORMAT_ARGB8888;
+    const Uint32 SHM_FMT = shm_format;
 
     if (!shmBuffer) {
         return SDL_InvalidParamError("shmBuffer");
@@ -129,6 +158,7 @@ int Wayland_AllocSHMBuffer(int width, int height, struct Wayland_SHMBuffer *shmB
 
     stride = width * 4;
     shmBuffer->shm_data_size = stride * height;
+    shmBuffer->busy = SDL_FALSE;
 
     shm_fd = CreateTempFD(shmBuffer->shm_data_size);
     if (shm_fd < 0) {
@@ -166,6 +196,7 @@ void Wayland_ReleaseSHMBuffer(struct Wayland_SHMBuffer *shmBuffer)
             shmBuffer->shm_data = NULL;
         }
         shmBuffer->shm_data_size = 0;
+        shmBuffer->busy = SDL_FALSE;
     }
 }
 
diff --git src/video/wayland/SDL_waylandshmbuffer.h src/video/wayland/SDL_waylandshmbuffer.h
index fb6649a08..91c0f7233 100644
--- src/video/wayland/SDL_waylandshmbuffer.h
+++ src/video/wayland/SDL_waylandshmbuffer.h
@@ -25,10 +25,19 @@ struct Wayland_SHMBuffer
     struct wl_buffer *wl_buffer;
     void *shm_data;
     int shm_data_size;
+
+    /* Attached to a surface and not handed back by the compositor yet. Only
+     * meaningful for buffers that are committed more than once; whoever
+     * commits the buffer is the one that has to set it.
+     */
+    SDL_bool busy;
 };
 
 /* Allocates an SHM buffer with the format WL_SHM_FORMAT_ARGB8888 */
 extern int Wayland_AllocSHMBuffer(int width, int height, struct Wayland_SHMBuffer *shmBuffer);
+
+/* Allocates an SHM buffer with an explicit wl_shm format */
+extern int Wayland_AllocSHMBufferFormat(int width, int height, Uint32 shm_format, struct Wayland_SHMBuffer *shmBuffer);
 extern void Wayland_ReleaseSHMBuffer(struct Wayland_SHMBuffer *shmBuffer);
 
 #endif
\ No newline at end of file
diff --git src/video/wayland/SDL_waylandvideo.c src/video/wayland/SDL_waylandvideo.c
index 0549520d6..fd7e8e45a 100644
--- src/video/wayland/SDL_waylandvideo.c
+++ src/video/wayland/SDL_waylandvideo.c
@@ -31,6 +31,7 @@
 #include "SDL_waylandvideo.h"
 #include "SDL_waylandevents_c.h"
 #include "SDL_waylandwindow.h"
+#include "SDL_waylandframebuffer.h"
 #include "SDL_waylandopengles.h"
 #include "SDL_waylandmouse.h"
 #include "SDL_waylandkeyboard.h"
@@ -255,6 +256,10 @@ static SDL_VideoDevice *Wayland_CreateDevice(void)
     device->GL_DeleteContext = Wayland_GLES_DeleteContext;
 #endif
 
+    device->CreateWindowFramebuffer = Wayland_CreateWindowFramebuffer;
+    device->UpdateWindowFramebuffer = Wayland_UpdateWindowFramebuffer;
+    device->DestroyWindowFramebuffer = Wayland_DestroyWindowFramebuffer;
+
     device->CreateSDLWindow = Wayland_CreateWindow;
     device->ShowWindow = Wayland_ShowWindow;
     device->HideWindow = Wayland_HideWindow;
diff --git src/video/wayland/SDL_waylandvideo.h src/video/wayland/SDL_waylandvideo.h
index 13e9b35ee..71d2e79f7 100644
--- src/video/wayland/SDL_waylandvideo.h
+++ src/video/wayland/SDL_waylandvideo.h
@@ -25,7 +25,9 @@
 #ifndef SDL_waylandvideo_h_
 #define SDL_waylandvideo_h_
 
+#ifdef SDL_VIDEO_OPENGL_EGL
 #include <EGL/egl.h>
+#endif
 #include "wayland-util.h"
 
 #include "../SDL_sysvideo.h"
@@ -83,9 +85,11 @@ typedef struct
     struct wp_viewporter *viewporter;
     struct wp_fractional_scale_manager_v1 *fractional_scale_manager;
 
+#ifdef SDL_VIDEO_OPENGL_EGL
     EGLDisplay edpy;
     EGLContext context;
     EGLConfig econf;
+#endif
 
     struct xkb_context *xkb_context;
     struct SDL_WaylandInput *input;
diff --git src/video/wayland/SDL_waylandwindow.c src/video/wayland/SDL_waylandwindow.c
index d208c1ab2..1186a888e 100644
--- src/video/wayland/SDL_waylandwindow.c
+++ src/video/wayland/SDL_waylandwindow.c
@@ -32,6 +32,7 @@
 #include "SDL_waylandvideo.h"
 #include "SDL_waylandtouch.h"
 #include "SDL_waylandshmbuffer.h"
+#include "SDL_waylandframebuffer.h"
 #include "SDL_hints.h"
 #include "../../SDL_hints_c.h"
 #include "SDL_events.h"
@@ -1288,6 +1289,7 @@ void Wayland_ShowWindow(_THIS, SDL_Window *window)
      */
     wl_surface_attach(data->surface, NULL, 0, 0);
     wl_surface_commit(data->surface);
+    Wayland_DetachWindowFramebuffer(_this, window);
 
     /* Create the shell surface and map the toplevel/popup */
 #ifdef HAVE_LIBDECOR_H
@@ -1512,6 +1514,7 @@ void Wayland_HideWindow(_THIS, SDL_Window *window)
     /* Be sure to detach after this is done, otherwise ShowWindow crashes! */
     wl_surface_attach(wind->surface, NULL, 0, 0);
     wl_surface_commit(wind->surface);
+    Wayland_DetachWindowFramebuffer(_this, window);
 
 #ifdef HAVE_LIBDECOR_H
     if (wind->shell_surface_type == WAYLAND_SURFACE_LIBDECOR) {
@@ -2302,6 +2305,11 @@ void Wayland_DestroyWindow(_THIS, SDL_Window *window)
             WAYLAND_wl_egl_window_destroy(wind->egl_window);
         }
 
+        /* The video core normally tears the framebuffer down before getting
+         * here; this is for the paths where it doesn't.
+         */
+        Wayland_DestroyWindowFramebuffer(_this, window);
+
         if (wind->idle_inhibitor) {
             zwp_idle_inhibitor_v1_destroy(wind->idle_inhibitor);
         }
diff --git src/video/wayland/SDL_waylandwindow.h src/video/wayland/SDL_waylandwindow.h
index 618fd21cf..41b6fffec 100644
--- src/video/wayland/SDL_waylandwindow.h
+++ src/video/wayland/SDL_waylandwindow.h
@@ -32,6 +32,7 @@
 #include "SDL_waylandshmbuffer.h"
 
 struct SDL_WaylandInput;
+struct Wayland_SHMFrameBuffer;
 
 typedef struct
 {
@@ -94,6 +95,9 @@ typedef struct
 
     struct Wayland_SHMBuffer icon;
 
+    /* Software rendering support; see SDL_waylandframebuffer.c */
+    struct Wayland_SHMFrameBuffer *shm_framebuffer;
+
     /* floating dimensions for restoring from maximized and fullscreen */
     int floating_width, floating_height;
 
