From bbf3cf00316d162dc19a65c4ef302de3d70592f1 Mon Sep 17 00:00:00 2001 From: T-Gander Date: Wed, 19 Aug 2026 00:11:34 -0600 Subject: [PATCH 1/5] fix(linux/kms): count CRTCs not planes when assigning monitor index kms_display_names() and display_t::init() each independently counted every active, non-cursor plane as one "monitor" when building/matching the display index. A CRTC with more than one simultaneously-active plane (e.g. gamescope's base + overlay layers) was counted twice by both passes, throwing their indices out of sync with each other and producing duplicate/misnumbered display names. Track counted CRTCs per card in both passes so a CRTC contributes to the index exactly once, regardless of how many active planes it has. --- src/platform/linux/kmsgrab.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/platform/linux/kmsgrab.cpp b/src/platform/linux/kmsgrab.cpp index fdf10a92fc5..05c972a1690 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -932,6 +933,7 @@ namespace platf { continue; } + std::set counted_crtcs; auto end = std::end(card); for (auto plane = std::begin(card); plane != end; ++plane) { // Skip unused planes @@ -943,8 +945,15 @@ namespace platf { continue; } + // A CRTC can have more than one simultaneously-active plane (e.g. gamescope's + // base + overlay layers). Count each CRTC once so this matches kms_display_names(). + if (counted_crtcs.count(plane->crtc_id)) { + continue; + } + if (monitor != monitor_index) { ++monitor; + counted_crtcs.insert(plane->crtc_id); continue; } @@ -2075,6 +2084,7 @@ namespace platf { } auto crtc_to_monitor = kms::map_crtc_to_monitor(card.monitors(conn_type_count)); + std::set counted_crtcs; auto end = std::end(card); for (auto plane = std::begin(card); plane != end; ++plane) { @@ -2087,6 +2097,12 @@ namespace platf { continue; } + // A CRTC can have more than one simultaneously-active plane (e.g. gamescope's + // base + overlay layers). Count each CRTC once, not once per active plane. + if (counted_crtcs.count(plane->crtc_id)) { + continue; + } + auto fb = card.fb(plane.get()); if (!fb) { BOOST_LOG(error) << "Couldn't get drm fb for plane ["sv << plane->fb_id << "]: "sv << strerror(errno); @@ -2126,6 +2142,7 @@ namespace platf { kms::print(plane.get(), fb.get(), crtc.get()); display_names.emplace_back(std::format("{}-{}", drmModeGetConnectorTypeName(it->second.type), it->second.index)); count++; + counted_crtcs.insert(plane->crtc_id); } cds.emplace_back(kms::card_descriptor_t { From 4ee223fb733ba6102ebd4a6f6a6bb79fc8b46990 Mon Sep 17 00:00:00 2001 From: T-Gander Date: Wed, 19 Aug 2026 13:53:01 -0600 Subject: [PATCH 2/5] Added better handing for graphics bind failures --- src/platform/linux/cuda.cpp | 25 ++++++++++++++++++++----- src/platform/linux/graphics.cpp | 13 +++++++++++++ src/platform/linux/kmsgrab.cpp | 17 ++++++++++++++++- src/platform/linux/vaapi.cpp | 25 ++++++++++++++++++++----- 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/platform/linux/cuda.cpp b/src/platform/linux/cuda.cpp index 6623c9e95a0..23534a741f2 100644 --- a/src/platform/linux/cuda.cpp +++ b/src/platform/linux/cuda.cpp @@ -538,15 +538,28 @@ namespace cuda { } else if (descriptor.sequence > sequence) { sequence = descriptor.sequence; - rgb = egl::rgb_t {}; - auto rgb_opt = egl::import_source(display.get(), descriptor.sd); if (!rgb_opt) { - return -1; - } + // The plane's current format/modifier can't be imported (e.g. a game switched to a + // 10bpc swapchain in exclusive fullscreen that this driver won't bind to a GL texture). + // Encode a blank frame instead of dropping the client, and only log once per failure + // streak to avoid flooding the log every frame. + if (!import_failed_last_frame) { + BOOST_LOG(warning) << "Skipping frame(s): plane format (fourcc: "sv << util::hex(descriptor.sd.fourcc).to_string_view() + << ") failed to import; will resume automatically if the format changes back"sv; + import_failed_last_frame = true; + } - rgb = std::move(*rgb_opt); + rgb = egl::create_blank(img); + } else { + if (import_failed_last_frame) { + BOOST_LOG(info) << "Resumed capture after plane format import failure"sv; + import_failed_last_frame = false; + } + + rgb = std::move(*rgb_opt); + } } auto fmt_desc = av_pix_fmt_desc_get(sw_format); @@ -640,6 +653,8 @@ namespace cuda { int offset_y; ///< Vertical offset in physical pixels. bool is_yuv444; ///< Whether the CUDA converter outputs YUV 4:4:4. + + bool import_failed_last_frame = false; ///< Whether the previous frame's plane import failed, to avoid log spam. }; /** diff --git a/src/platform/linux/graphics.cpp b/src/platform/linux/graphics.cpp index 1ef27c7166e..0ae0e94e780 100644 --- a/src/platform/linux/graphics.cpp +++ b/src/platform/linux/graphics.cpp @@ -655,10 +655,23 @@ namespace egl { gl::ctx.BindTexture(GL_TEXTURE_2D, rgb->tex[0]); if (!gl::egl_image_target_texture_2d()) { BOOST_LOG(error) << "glEGLImageTargetTexture2DOES is not available; cannot import RGB DMA-BUF"sv; + gl::ctx.BindTexture(GL_TEXTURE_2D, 0); return std::nullopt; } gl::egl_image_target_texture_2d()(GL_TEXTURE_2D, rgb->xrgb8); + // Some drivers accept an EGLImage of a given DRM format/modifier from eglCreateImage() + // but then reject binding it to a GL texture, e.g. Mesa/RADV rejecting a 10bpc format + // like DRM_FORMAT_XBGR2101010. When that happens, the texture is left with stale or + // incomplete contents, so this must be treated as an import failure rather than + // silently streaming whatever ends up in the texture. + if (auto err = gl::ctx.GetError(); err != GL_NO_ERROR) { + BOOST_LOG(error) << "Failed to bind EGLImage (DRM fourcc: "sv << util::hex(xrgb.fourcc).to_string_view() + << ") to GL texture: "sv << util::hex(err).to_string_view(); + gl::ctx.BindTexture(GL_TEXTURE_2D, 0); + return std::nullopt; + } + gl::ctx.BindTexture(GL_TEXTURE_2D, 0); gl_drain_errors; diff --git a/src/platform/linux/kmsgrab.cpp b/src/platform/linux/kmsgrab.cpp index 05c972a1690..56b12666c65 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -1668,7 +1668,21 @@ namespace platf { auto rgb_opt = egl::import_source(display.get(), sd); if (!rgb_opt) { - return capture_e::error; + // The plane's current format/modifier can't be imported (e.g. a game switched to a + // 10bpc swapchain in exclusive fullscreen that this driver won't bind to a GL texture). + // Skip this frame rather than tearing down the whole capture thread for every client, + // and only log once per failure streak to avoid flooding the log every frame. + if (!import_failed_last_frame) { + BOOST_LOG(warning) << "Skipping frame(s): plane format (fourcc: "sv << util::hex(sd.fourcc).to_string_view() + << ") failed to import; will resume automatically if the format changes back"sv; + import_failed_last_frame = true; + } + return capture_e::timeout; + } + + if (import_failed_last_frame) { + BOOST_LOG(info) << "Resumed capture after plane format import failure"sv; + import_failed_last_frame = false; } auto &rgb = *rgb_opt; @@ -1726,6 +1740,7 @@ namespace platf { gbm::gbm_t gbm; ///< GBM device used for buffer allocation. egl::display_t display; ///< EGL display created from the GBM device. egl::ctx_t ctx; ///< EGL context used to copy KMS frames into RAM. + bool import_failed_last_frame = false; ///< Whether the previous frame's plane import failed, to avoid log spam. }; /** diff --git a/src/platform/linux/vaapi.cpp b/src/platform/linux/vaapi.cpp index a9190a7bb55..4cb30378691 100644 --- a/src/platform/linux/vaapi.cpp +++ b/src/platform/linux/vaapi.cpp @@ -558,15 +558,28 @@ namespace va { } else if (descriptor.sequence > sequence) { sequence = descriptor.sequence; - rgb = egl::rgb_t {}; - auto rgb_opt = egl::import_source(display.get(), descriptor.sd); if (!rgb_opt) { - return -1; + // The plane's current format/modifier can't be imported (e.g. a game switched to a + // 10bpc swapchain in exclusive fullscreen that this driver won't bind to a GL texture). + // Encode a blank frame instead of dropping the client, and only log once per failure + // streak to avoid flooding the log every frame. + if (!import_failed_last_frame) { + BOOST_LOG(warning) << "Skipping frame(s): plane format (fourcc: "sv << util::hex(descriptor.sd.fourcc).to_string_view() + << ") failed to import; will resume automatically if the format changes back"sv; + import_failed_last_frame = true; + } + + rgb = egl::create_blank(img); + } else { + if (import_failed_last_frame) { + BOOST_LOG(info) << "Resumed capture after plane format import failure"sv; + import_failed_last_frame = false; + } + + rgb = std::move(*rgb_opt); } - - rgb = std::move(*rgb_opt); } sws.load_vram(descriptor, offset_x, offset_y, rgb->tex[0], false); @@ -603,6 +616,8 @@ namespace va { int offset_x; ///< Horizontal offset in physical pixels. int offset_y; ///< Vertical offset in physical pixels. + + bool import_failed_last_frame = false; ///< Whether the previous frame's plane import failed, to avoid log spam. }; /** From 220e4f39164587fbddada6c37ea15c8ddf837f34 Mon Sep 17 00:00:00 2001 From: T-Gander Date: Wed, 19 Aug 2026 14:15:57 -0600 Subject: [PATCH 3/5] Quiet remaining per-frame import-failure log flood, add diagnostic dump import_source() still logged at error level on every failed frame even after the round-1 fallback started rate-limiting the caller-side warnings, since the function has no memory of prior calls. Downgrade those internal logs to debug (callers already surface a rate-limited, actionable warning) and add a debug-level dump of the exact surface descriptor (fourcc, modifier, per-plane fd/offset/pitch) on failure, to compare against the real buffer's attribs when the import genuinely fails. Also check eglGetError() after a successful eglCreateImage(), not just after a null return. Co-Authored-By: Claude Sonnet 5 --- src/platform/linux/graphics.cpp | 37 +++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/platform/linux/graphics.cpp b/src/platform/linux/graphics.cpp index 0ae0e94e780..da42c49d6c9 100644 --- a/src/platform/linux/graphics.cpp +++ b/src/platform/linux/graphics.cpp @@ -630,6 +630,25 @@ namespace egl { return attribs; } + /** + * @brief Log the surface descriptor an import attempt was built from, for diagnosing + * driver-specific import failures (e.g. a particular DRM format/modifier/pitch combo). + * Only meant to be called on the (rate-limited, caller-side) failure path, hence debug level. + */ + static void log_surface_descriptor(const surface_descriptor_t &xrgb) { + BOOST_LOG(debug) << "Surface descriptor: "sv << xrgb.width << 'x' << xrgb.height + << " fourcc="sv << util::hex(xrgb.fourcc).to_string_view() + << " modifier="sv << util::hex(xrgb.modifier).to_string_view(); + for (auto x = 0; x < 4; ++x) { + if (xrgb.fds[x] < 0) { + continue; + } + BOOST_LOG(debug) << " plane["sv << x << "]: fd="sv << xrgb.fds[x] + << " offset="sv << xrgb.offsets[x] + << " pitch="sv << xrgb.pitches[x]; + } + } + /** * @brief Import the source frame texture for EGL/OpenGL conversion. * @@ -647,11 +666,20 @@ namespace egl { }; if (!rgb->xrgb8) { - BOOST_LOG(error) << "Couldn't import RGB Image: "sv << util::hex(eglGetError()).to_string_view(); + BOOST_LOG(debug) << "Couldn't import RGB Image: "sv << util::hex(eglGetError()).to_string_view(); + log_surface_descriptor(xrgb); return std::nullopt; } + // Some drivers can return a non-null EGLImage from eglCreateImage() while still leaving + // an error queued (validation deferred until first use). Catch that here too rather than + // only checking eglGetError() on the outright-null-image path above. + if (auto egl_err = eglGetError(); egl_err != EGL_SUCCESS) { + BOOST_LOG(debug) << "eglCreateImage() left a pending EGL error despite returning an image: "sv << util::hex(egl_err).to_string_view(); + log_surface_descriptor(xrgb); + } + gl::ctx.BindTexture(GL_TEXTURE_2D, rgb->tex[0]); if (!gl::egl_image_target_texture_2d()) { BOOST_LOG(error) << "glEGLImageTargetTexture2DOES is not available; cannot import RGB DMA-BUF"sv; @@ -664,10 +692,11 @@ namespace egl { // but then reject binding it to a GL texture, e.g. Mesa/RADV rejecting a 10bpc format // like DRM_FORMAT_XBGR2101010. When that happens, the texture is left with stale or // incomplete contents, so this must be treated as an import failure rather than - // silently streaming whatever ends up in the texture. + // silently streaming whatever ends up in the texture. Logged at debug level: callers + // already surface a rate-limited, user-facing warning instead of flooding at error level. if (auto err = gl::ctx.GetError(); err != GL_NO_ERROR) { - BOOST_LOG(error) << "Failed to bind EGLImage (DRM fourcc: "sv << util::hex(xrgb.fourcc).to_string_view() - << ") to GL texture: "sv << util::hex(err).to_string_view(); + BOOST_LOG(debug) << "Failed to bind EGLImage to GL texture: "sv << util::hex(err).to_string_view(); + log_surface_descriptor(xrgb); gl::ctx.BindTexture(GL_TEXTURE_2D, 0); return std::nullopt; } From 14a6f2a9b58a23457e404a053299be3627109692 Mon Sep 17 00:00:00 2001 From: T-Gander Date: Wed, 19 Aug 2026 17:23:39 -0600 Subject: [PATCH 4/5] fix(linux/kms): clamp texture readback to actual size, not CRTC viewport display_ram_t::snapshot() read GetTextureSubImage() using the CRTC's output viewport dimensions, not the imported plane texture's actual size. These differ when the display controller hardware-scales a plane at scanout (e.g. a game's native-resolution exclusive-fullscreen swapchain stretched to fill a higher-resolution output) -- kmsgrab imports the plane's raw pre-scale buffer via DMA-BUF, bypassing the CRTC scaler entirely, so the texture is the smaller native size while the destination buffer is sized for the full output. Reading past the texture's real bounds triggered GL_INVALID_VALUE every frame, which manifested as a black screen for the whole scaled-plane session via the existing import-failure fallback (the "Failed to bind EGLImage" log line was a stray error from this call, not the import itself). Clamps the read to the texture's real dimensions and uses GL_PACK_ROW_LENGTH so a smaller read still lands correctly in the destination buffer's top-left corner instead of shearing across rows. This does not reproduce the hardware scaling itself -- the rest of the frame is left as whatever the buffer previously contained -- but it resolves the crash/black-screen and captures the correct content at its native resolution. Co-Authored-By: Claude Sonnet 5 --- src/platform/linux/kmsgrab.cpp | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/platform/linux/kmsgrab.cpp b/src/platform/linux/kmsgrab.cpp index 56b12666c65..e51d44cbfc4 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -1700,7 +1700,27 @@ namespace platf { return platf::capture_e::interrupted; } - gl::ctx.GetTextureSubImage(rgb->tex[0], 0, img_offset_x, img_offset_y, 0, width, height, 1, GL_BGRA, GL_UNSIGNED_BYTE, img_out->height * img_out->row_pitch, img_out->data); + // The plane's backing texture can be smaller than the configured capture + // resolution when the display controller hardware-scales it up at scanout time + // (e.g. a game's native-resolution exclusive-fullscreen swapchain stretched to + // fill the output). Reading past the texture's real bounds triggers + // GL_INVALID_VALUE, so clamp the read to what's actually there. GL_PACK_ROW_LENGTH + // keeps the destination stride matching the full-size image buffer so the read + // lands correctly in its top-left corner instead of shearing across rows. + // Note this does not reproduce the hardware scaling itself: the rest of the frame + // is left as whatever the buffer previously contained. + int read_width = std::max(0, std::min(width, w - img_offset_x)); + int read_height = std::max(0, std::min(height, h - img_offset_y)); + bool clamped = read_width != width || read_height != height; + if (clamped) { + gl::ctx.PixelStorei(GL_PACK_ROW_LENGTH, width); + } + + gl::ctx.GetTextureSubImage(rgb->tex[0], 0, img_offset_x, img_offset_y, 0, read_width, read_height, 1, GL_BGRA, GL_UNSIGNED_BYTE, img_out->height * img_out->row_pitch, img_out->data); + + if (clamped) { + gl::ctx.PixelStorei(GL_PACK_ROW_LENGTH, 0); + } img_out->frame_timestamp = frame_timestamp; From 3d2b7f2770600cc730669347bee9aaff3f345519 Mon Sep 17 00:00:00 2001 From: T-Gander Date: Wed, 19 Aug 2026 17:32:16 -0600 Subject: [PATCH 5/5] feat(linux/kms): scale captured plane to output size when hardware-scaled Replaces the clamp-and-crop fix from 14a6f2a9 with the real fix: when the display controller is hardware-scaling a plane at scanout (its CRTC destination rect differs from the plane's native buffer size -- e.g. a game's native-resolution exclusive-fullscreen swapchain stretched to fill a higher-resolution output), kmsgrab's raw DMA-BUF import bypasses that scaler entirely and only ever sees the pre-scale buffer. Reproduce the same upscale with a linear-filtered glBlitFramebuffer into a scratch texture sized to the configured capture resolution, before the existing GetTextureSubImage readback, so the captured frame matches what the display actually shows instead of being cropped to the plane's native corner. The scratch texture/FBOs are lazily created on first use and reused across frames; the common case (plane already fills the output, no scaling needed) is unaffected and takes the same path as before. Untested against real hardware in this session -- built by cross-referencing this project's own glad config (gl:compatibility=4.6, confirming BlitFramebuffer/GL_READ_FRAMEBUFFER/GL_DRAW_FRAMEBUFFER are generated) and the existing FBO helper patterns already used elsewhere in this file, but needs a live test to confirm the blit against an EGLImage-backed source texture behaves as expected on this driver. Co-Authored-By: Claude Sonnet 5 --- src/platform/linux/kmsgrab.cpp | 68 ++++++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/src/platform/linux/kmsgrab.cpp b/src/platform/linux/kmsgrab.cpp index e51d44cbfc4..c65269f7a0d 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -1703,25 +1703,54 @@ namespace platf { // The plane's backing texture can be smaller than the configured capture // resolution when the display controller hardware-scales it up at scanout time // (e.g. a game's native-resolution exclusive-fullscreen swapchain stretched to - // fill the output). Reading past the texture's real bounds triggers - // GL_INVALID_VALUE, so clamp the read to what's actually there. GL_PACK_ROW_LENGTH - // keeps the destination stride matching the full-size image buffer so the read - // lands correctly in its top-left corner instead of shearing across rows. - // Note this does not reproduce the hardware scaling itself: the rest of the frame - // is left as whatever the buffer previously contained. - int read_width = std::max(0, std::min(width, w - img_offset_x)); - int read_height = std::max(0, std::min(height, h - img_offset_y)); - bool clamped = read_width != width || read_height != height; - if (clamped) { - gl::ctx.PixelStorei(GL_PACK_ROW_LENGTH, width); - } + // fill the output) -- kmsgrab imports the plane's raw pre-scale buffer via + // DMA-BUF, bypassing that hardware scaler entirely. Reproduce the same upscale + // with a linear-filtered GL blit into a full-resolution scratch texture before + // reading it back, so the captured frame matches what the display actually shows + // instead of being cropped to the plane's native corner. + GLuint read_tex = rgb->tex[0]; + int read_offset_x = img_offset_x; + int read_offset_y = img_offset_y; + + if (w != width || h != height) { + if (!scale_tex.size()) { + scale_tex = gl::tex_t::make(1); + gl::ctx.BindTexture(GL_TEXTURE_2D, scale_tex[0]); + gl::ctx.TexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width, height); + gl::ctx.BindTexture(GL_TEXTURE_2D, 0); + + scale_dst_fb = gl::frame_buf_t::make(1); + scale_dst_fb.bind(&scale_tex[0], &scale_tex[0] + 1); + + scale_src_fb = gl::frame_buf_t::make(1); + } + + gl::ctx.BindFramebuffer(GL_READ_FRAMEBUFFER, scale_src_fb[0]); + gl::ctx.FramebufferTexture(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, rgb->tex[0], 0); + gl::ctx.ReadBuffer(GL_COLOR_ATTACHMENT0); + + gl::ctx.BindFramebuffer(GL_DRAW_FRAMEBUFFER, scale_dst_fb[0]); + GLenum draw_buf = GL_COLOR_ATTACHMENT0; + gl::ctx.DrawBuffers(1, &draw_buf); + +#ifndef NDEBUG + auto status = gl::ctx.CheckFramebufferStatus(GL_READ_FRAMEBUFFER); + if (status != GL_FRAMEBUFFER_COMPLETE) { + BOOST_LOG(error) << "Scale blit: source CheckFramebufferStatus() --> [0x"sv << util::hex(status).to_string_view() << ']'; + } +#endif + + gl::ctx.BlitFramebuffer(0, 0, w, h, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_LINEAR); - gl::ctx.GetTextureSubImage(rgb->tex[0], 0, img_offset_x, img_offset_y, 0, read_width, read_height, 1, GL_BGRA, GL_UNSIGNED_BYTE, img_out->height * img_out->row_pitch, img_out->data); + gl::ctx.BindFramebuffer(GL_FRAMEBUFFER, 0); - if (clamped) { - gl::ctx.PixelStorei(GL_PACK_ROW_LENGTH, 0); + read_tex = scale_tex[0]; + read_offset_x = 0; + read_offset_y = 0; } + gl::ctx.GetTextureSubImage(read_tex, 0, read_offset_x, read_offset_y, 0, width, height, 1, GL_BGRA, GL_UNSIGNED_BYTE, img_out->height * img_out->row_pitch, img_out->data); + img_out->frame_timestamp = frame_timestamp; if (cursor && captured_cursor.visible) { @@ -1761,6 +1790,15 @@ namespace platf { egl::display_t display; ///< EGL display created from the GBM device. egl::ctx_t ctx; ///< EGL context used to copy KMS frames into RAM. bool import_failed_last_frame = false; ///< Whether the previous frame's plane import failed, to avoid log spam. + + // Lazily created the first time a captured plane's native size differs from the + // configured capture resolution (i.e. the display controller is hardware-scaling + // it). scale_tex is sized to the full capture resolution; scale_src_fb/scale_dst_fb + // wrap the per-frame imported texture and scale_tex respectively so BlitFramebuffer + // can scale between them. + gl::tex_t scale_tex; ///< Scratch texture holding the upscaled frame, when needed. + gl::frame_buf_t scale_src_fb; ///< FBO used to bind the per-frame imported texture as the blit source. + gl::frame_buf_t scale_dst_fb; ///< FBO wrapping scale_tex as the blit destination. }; /**