detached e4fec9b

fix(drm): clear inherited CRTC colour state on modeset and close

Another DRM master can leave a CRTC GAMMA_LUT (or DEGAMMA_LUT/CTM) set.
The kernel keeps it across masters, and it applied on top of the PQ
output, distorting SDR content scanned out through the plane colour
pipeline on HDR outputs. forceBypass now writes 0 to the three CRTC
colour properties with the planes' Bypass pipeline.
commit
e4fec9bc3dc2227e3c0a3da3bd2820fb1e66391f
author
brice · · 2026-10-12 12:39 UTC
parent
0e0d396
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +56 -0
M docs/config.md +1 -0
@@ -199,6 +199,7 @@ Hardware-decoded video can use NV12 (8-bit) or P010 (10-bit) DMA-BUF subsurfaces
199199 - Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged.
200200 - Opaque SDR (sRGB) buffers also skip composition on HDR outputs when the KMS plane has a colour pipeline (`DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE`): sRGB curve, `sdr-brightness`, BT.709 to BT.2020, PQ, the same maths as composition. This applies to a fullscreen window (direct scanout) and to a lone window on an overlay plane. The compositor looks for that chain on each plane, whatever the driver; amdgpu on recent kernels has it. Without it, with alpha, with YUV or extended-linear content, SDR windows are composed, and the log gives the reason (`hdr_sdr_content`, `yuv`). When the driver refuses a pipeline (tested once per plane, buffer format and cursor state), the window is composed with reason `color_refused`.
201201 - Composed frames and every modeset leave the planes' pipeline on Bypass.
202+- Every modeset and exit clear the CRTC's `DEGAMMA_LUT`, `CTM` and `GAMMA_LUT`, which this compositor never uses: a gamma ramp left by another DRM master would otherwise distort every frame.
202203 - A VT switch resets the pipeline first: before the seat is disabled, each output that applies a pipeline commits Bypass (at most 100 ms are spent waiting for it, then the switch goes on and a warning is logged). The pipeline survives a master change, and a client without `DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE` cannot clear it. A crash or `kill -9` cannot do this reset: the next DRM master inherits the pipeline, with its colours distorted, until a client that sets the cap commits a modeset (or this compositor starts again).
203204 - Windowed and otherwise composed HDR content keeps its full range in a linear fp16 image before PQ output; the display tone-maps it according to its EDID metadata. SDR surfaces are blended in linear light on HDR outputs, so antialiased edges may look slightly different than sRGB-space blending. Dim veils (`floating.dim`, `stash.dim`) darken SDR white as much as on an SDR output; brighter HDR highlights stay a little brighter.
204205 - Screenshots and capture use opaque 8-bit sRGB. HDR capture applies GPU tone mapping and reduces out-of-sRGB gamut: values below 90% of SDR reference white stay unchanged, while brighter values are compressed to preserve highlight differences (SDR reference white maps to about 245/255 when capturing an HDR output, versus 255/255 on an SDR output). This capture-only transform does not change HDR display output.
@@ -199,6 +199,7 @@ Hardware-decoded video can use NV12 (8-bit) or P010 (10-bit) DMA-BUF subsurfaces
199 - Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged.199 - Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged.
200 - Opaque SDR (sRGB) buffers also skip composition on HDR outputs when the KMS plane has a colour pipeline (`DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE`): sRGB curve, `sdr-brightness`, BT.709 to BT.2020, PQ, the same maths as composition. This applies to a fullscreen window (direct scanout) and to a lone window on an overlay plane. The compositor looks for that chain on each plane, whatever the driver; amdgpu on recent kernels has it. Without it, with alpha, with YUV or extended-linear content, SDR windows are composed, and the log gives the reason (`hdr_sdr_content`, `yuv`). When the driver refuses a pipeline (tested once per plane, buffer format and cursor state), the window is composed with reason `color_refused`.200 - Opaque SDR (sRGB) buffers also skip composition on HDR outputs when the KMS plane has a colour pipeline (`DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE`): sRGB curve, `sdr-brightness`, BT.709 to BT.2020, PQ, the same maths as composition. This applies to a fullscreen window (direct scanout) and to a lone window on an overlay plane. The compositor looks for that chain on each plane, whatever the driver; amdgpu on recent kernels has it. Without it, with alpha, with YUV or extended-linear content, SDR windows are composed, and the log gives the reason (`hdr_sdr_content`, `yuv`). When the driver refuses a pipeline (tested once per plane, buffer format and cursor state), the window is composed with reason `color_refused`.
201 - Composed frames and every modeset leave the planes' pipeline on Bypass.201 - Composed frames and every modeset leave the planes' pipeline on Bypass.
202+- Every modeset and exit clear the CRTC's `DEGAMMA_LUT`, `CTM` and `GAMMA_LUT`, which this compositor never uses: a gamma ramp left by another DRM master would otherwise distort every frame.
202 - A VT switch resets the pipeline first: before the seat is disabled, each output that applies a pipeline commits Bypass (at most 100 ms are spent waiting for it, then the switch goes on and a warning is logged). The pipeline survives a master change, and a client without `DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE` cannot clear it. A crash or `kill -9` cannot do this reset: the next DRM master inherits the pipeline, with its colours distorted, until a client that sets the cap commits a modeset (or this compositor starts again).203 - A VT switch resets the pipeline first: before the seat is disabled, each output that applies a pipeline commits Bypass (at most 100 ms are spent waiting for it, then the switch goes on and a warning is logged). The pipeline survives a master change, and a client without `DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE` cannot clear it. A crash or `kill -9` cannot do this reset: the next DRM master inherits the pipeline, with its colours distorted, until a client that sets the cap commits a modeset (or this compositor starts again).
203 - Windowed and otherwise composed HDR content keeps its full range in a linear fp16 image before PQ output; the display tone-maps it according to its EDID metadata. SDR surfaces are blended in linear light on HDR outputs, so antialiased edges may look slightly different than sRGB-space blending. Dim veils (`floating.dim`, `stash.dim`) darken SDR white as much as on an SDR output; brighter HDR highlights stay a little brighter.204 - Windowed and otherwise composed HDR content keeps its full range in a linear fp16 image before PQ output; the display tone-maps it according to its EDID metadata. SDR surfaces are blended in linear light on HDR outputs, so antialiased edges may look slightly different than sRGB-space blending. Dim veils (`floating.dim`, `stash.dim`) darken SDR white as much as on an SDR output; brighter HDR highlights stay a little brighter.
204 - Screenshots and capture use opaque 8-bit sRGB. HDR capture applies GPU tone mapping and reduces out-of-sRGB gamut: values below 90% of SDR reference white stay unchanged, while brighter values are compressed to preserve highlight differences (SDR reference white maps to about 245/255 when capturing an HDR output, versus 255/255 on an SDR output). This capture-only transform does not change HDR display output.205 - Screenshots and capture use opaque 8-bit sRGB. HDR capture applies GPU tone mapping and reduces out-of-sRGB gamut: values below 90% of SDR reference white stay unchanged, while brighter values are compressed to preserve highlight differences (SDR reference white maps to about 245/255 when capturing an HDR output, versus 255/255 on an SDR output). This capture-only transform does not change HDR display output.
M internal/adapters/drm/colorop.go +6 -0
@@ -90,9 +90,15 @@ func (o *Output) primaryColorProps(req *atomicReq, mode colorMode) {
9090 // forceBypass sets Bypass on the primary and overlay planes whether or not
9191 // the applied state is known to differ: for requests that must leave a
9292 // known state (modeset, close).
93+// The CRTC's DEGAMMA_LUT, CTM and GAMMA_LUT are cleared with them: this
94+// compositor never sets them, and a ramp left by another DRM master would
95+// apply on top of the PQ output.
9396 func (o *Output) forceBypass(req *atomicReq) {
9497 o.primary.colorProps(req, colorBypass, 0, 0)
9598 o.overlay.colorProps(req, colorBypass, 0, 0)
99+ for _, name := range [...]string{"DEGAMMA_LUT", "CTM", "GAMMA_LUT"} {
100+ req.set(o.crtc, o.crtcProps[name], 0)
101+ }
96102 }
97103
98104 // overlayColorProps puts mode on the overlay plane if it applies another one.
@@ -90,9 +90,15 @@ func (o *Output) primaryColorProps(req *atomicReq, mode colorMode) {
90 // forceBypass sets Bypass on the primary and overlay planes whether or not90 // forceBypass sets Bypass on the primary and overlay planes whether or not
91 // the applied state is known to differ: for requests that must leave a91 // the applied state is known to differ: for requests that must leave a
92 // known state (modeset, close).92 // known state (modeset, close).
93+// The CRTC's DEGAMMA_LUT, CTM and GAMMA_LUT are cleared with them: this
94+// compositor never sets them, and a ramp left by another DRM master would
95+// apply on top of the PQ output.
93 func (o *Output) forceBypass(req *atomicReq) {96 func (o *Output) forceBypass(req *atomicReq) {
94 o.primary.colorProps(req, colorBypass, 0, 0)97 o.primary.colorProps(req, colorBypass, 0, 0)
95 o.overlay.colorProps(req, colorBypass, 0, 0)98 o.overlay.colorProps(req, colorBypass, 0, 0)
99+ for _, name := range [...]string{"DEGAMMA_LUT", "CTM", "GAMMA_LUT"} {
100+ req.set(o.crtc, o.crtcProps[name], 0)
101+ }
96 }102 }
97 103
98 // overlayColorProps puts mode on the overlay plane if it applies another one.104 // overlayColorProps puts mode on the overlay plane if it applies another one.
M internal/adapters/drm/colorop_test.go +49 -0
@@ -2,6 +2,7 @@ package drm
22
33 import (
44 "encoding/binary"
5+ "maps"
56 "math"
67 "os"
78 "regexp"
@@ -526,6 +527,54 @@ func TestColorBypassOnComposedProtectedOffCloseAndModeset(t *testing.T) {
526527 })
527528 }
528529
530+// A modeset and Close clear the CRTC's DEGAMMA_LUT, CTM and GAMMA_LUT,
531+// which another DRM master may have left: a ramp kept there distorts every
532+// frame, the PQ ones included.
533+func TestCRTCColorClearedOnModesetAndClose(t *testing.T) {
534+ const pCTM, pGamma = 63, 64
535+ cleared := func(t *testing.T, c commitRec) {
536+ t.Helper()
537+ for _, p := range []uint32{pDegamma, pCTM, pGamma} {
538+ if v, ok := c.req.value(tCrtc, p); !ok || v != 0 {
539+ t.Errorf("CRTC property %d = %d %v, want 0", p, v, ok)
540+ }
541+ }
542+ }
543+ setup := func(t *testing.T) (*Output, *mockkms, *[]commitRec) {
544+ o, k, commits := colorOutput(t, false, nil)
545+ o.crtcProps = maps.Clone(o.crtcProps)
546+ o.crtcProps["DEGAMMA_LUT"], o.crtcProps["CTM"], o.crtcProps["GAMMA_LUT"] = pDegamma, pCTM, pGamma
547+ return o, k, commits
548+ }
549+ t.Run("modeset", func(t *testing.T) {
550+ o, k, commits := setup(t)
551+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
552+ if err := o.modeset(); err != nil {
553+ t.Fatal(err)
554+ }
555+ cleared(t, (*commits)[0])
556+ })
557+ t.Run("close", func(t *testing.T) {
558+ o, k, commits := setup(t)
559+ k.EXPECT().rmFB(mock.Anything).Return(nil)
560+ k.EXPECT().destroyBlob(uint32(61)).Return(nil).Once()
561+ o.Close()
562+ cleared(t, (*commits)[0])
563+ })
564+ t.Run("frame", func(t *testing.T) {
565+ // A frame commit leaves them alone: they stay as the modeset left them.
566+ o, _, commits := setup(t)
567+ if ok, err := o.commitScanoutRect(80, sdrContent(o), pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
568+ t.Fatalf("ok=%v err=%v", ok, err)
569+ }
570+ for _, p := range []uint32{pDegamma, pCTM, pGamma} {
571+ if _, ok := (*commits)[0].req.value(tCrtc, p); ok {
572+ t.Errorf("frame commit writes CRTC property %d", p)
573+ }
574+ }
575+ })
576+}
577+
529578 // The overlay's pipeline is driven with the primary on Bypass; a frame
530579 // without the overlay turns it back to Bypass once.
531580 func TestColorOverlayTransitions(t *testing.T) {
@@ -2,6 +2,7 @@ package drm
2 2
3 import (3 import (
4 "encoding/binary"4 "encoding/binary"
5+ "maps"
5 "math"6 "math"
6 "os"7 "os"
7 "regexp"8 "regexp"
@@ -526,6 +527,54 @@ func TestColorBypassOnComposedProtectedOffCloseAndModeset(t *testing.T) {
526 })527 })
527 }528 }
528 529
530+// A modeset and Close clear the CRTC's DEGAMMA_LUT, CTM and GAMMA_LUT,
531+// which another DRM master may have left: a ramp kept there distorts every
532+// frame, the PQ ones included.
533+func TestCRTCColorClearedOnModesetAndClose(t *testing.T) {
534+ const pCTM, pGamma = 63, 64
535+ cleared := func(t *testing.T, c commitRec) {
536+ t.Helper()
537+ for _, p := range []uint32{pDegamma, pCTM, pGamma} {
538+ if v, ok := c.req.value(tCrtc, p); !ok || v != 0 {
539+ t.Errorf("CRTC property %d = %d %v, want 0", p, v, ok)
540+ }
541+ }
542+ }
543+ setup := func(t *testing.T) (*Output, *mockkms, *[]commitRec) {
544+ o, k, commits := colorOutput(t, false, nil)
545+ o.crtcProps = maps.Clone(o.crtcProps)
546+ o.crtcProps["DEGAMMA_LUT"], o.crtcProps["CTM"], o.crtcProps["GAMMA_LUT"] = pDegamma, pCTM, pGamma
547+ return o, k, commits
548+ }
549+ t.Run("modeset", func(t *testing.T) {
550+ o, k, commits := setup(t)
551+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
552+ if err := o.modeset(); err != nil {
553+ t.Fatal(err)
554+ }
555+ cleared(t, (*commits)[0])
556+ })
557+ t.Run("close", func(t *testing.T) {
558+ o, k, commits := setup(t)
559+ k.EXPECT().rmFB(mock.Anything).Return(nil)
560+ k.EXPECT().destroyBlob(uint32(61)).Return(nil).Once()
561+ o.Close()
562+ cleared(t, (*commits)[0])
563+ })
564+ t.Run("frame", func(t *testing.T) {
565+ // A frame commit leaves them alone: they stay as the modeset left them.
566+ o, _, commits := setup(t)
567+ if ok, err := o.commitScanoutRect(80, sdrContent(o), pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
568+ t.Fatalf("ok=%v err=%v", ok, err)
569+ }
570+ for _, p := range []uint32{pDegamma, pCTM, pGamma} {
571+ if _, ok := (*commits)[0].req.value(tCrtc, p); ok {
572+ t.Errorf("frame commit writes CRTC property %d", p)
573+ }
574+ }
575+ })
576+}
577+
529 // The overlay's pipeline is driven with the primary on Bypass; a frame578 // The overlay's pipeline is driven with the primary on Bypass; a frame
530 // without the overlay turns it back to Bypass once.579 // without the overlay turns it back to Bypass once.
531 func TestColorOverlayTransitions(t *testing.T) {580 func TestColorOverlayTransitions(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close