Skip to content

fix(linux/kms): correct multi-plane monitor indexing and hardware-scaled capture - #5532

Draft
T-Gander wants to merge 5 commits into
LizardByte:masterfrom
T-Gander:fix/kmsgrab-multi-plane-monitor
Draft

fix(linux/kms): correct multi-plane monitor indexing and hardware-scaled capture#5532
T-Gander wants to merge 5 commits into
LizardByte:masterfrom
T-Gander:fix/kmsgrab-multi-plane-monitor

Conversation

@T-Gander

@T-Gander T-Gander commented Aug 20, 2026

Copy link
Copy Markdown

Description

Human Overview of changes:
This bug fix allows my steam lxc running Sunshine + Gamescope with a dummy display port connector to successfully render and stream. I'll be happy to run this custom patched version for just myself, however I thought I would post this fix so for if anyone else in the community is looking for it or wants to clean it up, they can or they can use it too. I've spent a month of troubleshooting other avenues before ending up looking at Sunshines code and ending up with this solution.
This PRs code changes are entirely AI generated, but has been tested on my hardware and is working for me (RX 9060 XT). YMMV.

This PR comprises of two bug fixes.

  1. Deduplication of planes when each plane contains the same CRTC ID (Gamescopes -drm mode surfaces this issue. As it produces two planes throwing off index selection for available monitors. Possibly one plane for the overlay and one for the screen in gamescopes case? I didn't look too deeply into it.)
  2. When a games render resolution in gamescope is lower than the interfaces resolution, you are presented with a corrupted image. 2 fixes were applied; have a black fallback for display instead of corruption, correct scaling when the src is lower res than what the connector reports.

AI Overview of changes:
Fixes several bugs in the Linux KMS capture backend (kmsgrab) related to multi-plane monitors and hardware-scaled scanout.

  • Monitor index mismatch: kms_display_names() and display_t::init() each counted every active, non-cursor plane as a separate monitor. 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 and producing duplicate/misnumbered display names. Both passes now count active CRTCs instead, so a CRTC contributes to the index exactly once regardless of how many planes are active on it.
  • Black screen on hardware-scaled planes: when the display controller hardware-scales 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 and only ever sees the pre-scale buffer. The texture readback was sized to the CRTC's output viewport rather than the actual imported texture, which triggered GL_INVALID_VALUE every frame and manifested as a black screen for the whole session. The capture path now reproduces the hardware upscale itself via a linear-filtered glBlitFramebuffer into a scratch texture sized to the configured capture resolution before readback, so the captured frame matches what the display actually shows. The scratch texture/FBOs are lazily created and reused; the common unscaled case is unaffected.

Tested against real hardware with a hardware-scaled plane at scanout (native-resolution exclusive-fullscreen swapchain stretched to a higher-resolution output).

Screenshot

N/A — not a UI change.

Issues Fixed or Closed

None referenced.

Roadmap Issues

None.

Type of Change

  • feat: New feature (non-breaking change which adds functionality)
  • fix: Bug fix (non-breaking change which fixes an issue)
  • docs: Documentation only changes
  • style: Changes that do not affect the meaning of the code (white-space, formatting, missing semicolons, etc.)
  • refactor: Code change that neither fixes a bug nor adds a feature
  • perf: Code change that improves performance
  • test: Adding missing tests or correcting existing tests
  • build: Changes that affect the build system or external dependencies
  • ci: Changes to CI configuration files and scripts
  • chore: Other changes that don't modify src or test files
  • revert: Reverts a previous commit
  • BREAKING CHANGE: Introduces a breaking change (can be combined with any type above)

Checklist

  • Code follows the style guidelines of this project
  • Code has been self-reviewed
  • Code has been commented, particularly in hard-to-understand areas
  • Code docstring/documentation-blocks for new or existing methods/components have been added or updated
  • Unit tests have been added or updated for any new or modified functionality

AI Usage

See our AI usage policy.

  • None: No AI tools were used in creating this PR
  • Light: AI provided minor assistance (formatting, simple suggestions)
  • Moderate: AI helped with code generation or debugging specific parts
  • Heavy: AI generated most or all of the code changes

T-Gander and others added 5 commits August 19, 2026 00:11
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.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…aled

Replaces the clamp-and-crop fix from 14a6f2a 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 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
3 New issues
18.7% Duplication on New Code (required ≤ 2%)
3 New Code Smells (required ≤ 0)
2 Duplicated Blocks on New Code (required ≤ 0)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@T-Gander
T-Gander marked this pull request as draft August 20, 2026 07:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant