detached ad3c741

feat(drm): scan out SDR buffers on HDR outputs through the plane colour pipeline (#124)

* perf(drm): reuse atomic request buffers on the frame commit path

kmsDevice.commit builds its ioctl arrays in scratch owned by the request
instead of allocating three slices per call. Each Output reuses one
request for frame commits and separate ones for the paths that hold two
requests at once (state commits, TEST_ONLY probes). overlayFrame reuses an
Output-owned window slice, and ReadEvents parses flips into one slice.

* feat(drm): scan out SDR buffers on HDR outputs through the plane colour pipeline

Set DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE before any plane read, match each
plane's pipeline (sRGB EOTF, multiplier, 3x4 matrix, PQ inverse EOTF, all
other ops bypassable) and drive it for opaque sRGB buffers on HDR10 outputs
instead of composing them, on the primary and overlay planes. Colour state is
tracked per plane, written only on change (never async), reset to Bypass on
modesets, close, power off and protection. TEST_ONLY refusals are cached per
plane, format and cursor state.

* fix(drm): compose a frame whose plane colour pipeline KMS refuses

A frame commit refused with the pipeline that passes TEST_ONLY with Bypass
caches the refusal for the plane, format and cursor state and is composed
instead of failing the output.

* test(drm): cover plane colour pipelines, refusal caching and amdgpu rules

Matcher, S31.32, CTM against hdr.frag, planeColor table, commit transitions,
Bypass resets, refusal caching, zero-allocation steady state (perf-check) and
amdgpu rules for COLOR_PIPELINE with DEGAMMA_LUT and non-FB_ID async props.
Docs describe the HDR scanout and overlay behaviour.

* fix(drm): reset the plane colour pipeline before a seat disable, review fixes

A VT switch acks the disable at once and revokes the master, so the planes
kept COLOR_PIPELINE for the next master, which cannot clear it without the
client cap. The seat now runs a callback before the ack; the app asks each
running output, on its own goroutine, to commit Bypass (100 ms bound, warn
and go on).

Also: a pipeline-caused EINVAL no longer disables the hardware cursor (the
pipeline test runs before the cursor test), overlayFrame returns early on
no overlay, probeAsync no longer forgets the colour state, comments on YUV
with the colour pipeline cap, test cleanups, and the pure colour pipeline
code moves to colorop_match.go.

* fix(drm): blame the colour pipeline only when the commit differs in colour alone

The Bypass TEST_ONLY after a refused frame dropped the connector content
hint, so a refusal caused by the hint was cached as the pipeline's. The test
now carries the hint and differs from the commit in colour alone. Note on
the seat-disable Bypass commit possibly waiting for an in-flight flip.

* refactor(drm): decide the overlay colour once and drop single-use helpers

overlayCandidate returns the plane colour mode it already computed, so overlayFrame no longer calls planeColor a second time and colorFits goes away. setupColor drops both pipelines through one loop instead of pipelineOf, and the one-line contentRefused wrapper is inlined. The seat-disable test asserts the late warning, and a test that only checked an ioctl on an invalid fd is removed.
commit
ad3c74134bdce61af35bf005fffe9e4c1602f07b
author
bnema · · 2026-10-03 22:17 UTC
committer
GitHub · · 2026-10-03 22:17 UTC
parent
fc55404
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
35 files · +2838 -149
M .mockery.yml +12 -0
@@ -31,6 +31,13 @@ packages:
3131 dir: internal/adapters/drm
3232 pkgname: drm
3333 filename: kms_mock_test.go
34+ github.com/bnema/neferwl/internal/adapters/seat:
35+ interfaces:
36+ disabler:
37+ config:
38+ dir: internal/adapters/seat
39+ pkgname: seat
40+ filename: disabler_mock_test.go
3441 github.com/bnema/neferwl/internal/adapters/libinput:
3542 interfaces:
3643 deviceConfig:
@@ -79,6 +86,11 @@ packages:
7986 dir: internal/app
8087 pkgname: app
8188 filename: cursor_mock_test.go
89+ seatDisablePreparer:
90+ config:
91+ dir: internal/app
92+ pkgname: app
93+ filename: seatdisable_mock_test.go
8294 github.com/bnema/neferwl/internal/adapters/statefile:
8395 interfaces:
8496 procTree:
@@ -31,6 +31,13 @@ packages:
31 dir: internal/adapters/drm31 dir: internal/adapters/drm
32 pkgname: drm32 pkgname: drm
33 filename: kms_mock_test.go33 filename: kms_mock_test.go
34+ github.com/bnema/neferwl/internal/adapters/seat:
35+ interfaces:
36+ disabler:
37+ config:
38+ dir: internal/adapters/seat
39+ pkgname: seat
40+ filename: disabler_mock_test.go
34 github.com/bnema/neferwl/internal/adapters/libinput:41 github.com/bnema/neferwl/internal/adapters/libinput:
35 interfaces:42 interfaces:
36 deviceConfig:43 deviceConfig:
@@ -79,6 +86,11 @@ packages:
79 dir: internal/app86 dir: internal/app
80 pkgname: app87 pkgname: app
81 filename: cursor_mock_test.go88 filename: cursor_mock_test.go
89+ seatDisablePreparer:
90+ config:
91+ dir: internal/app
92+ pkgname: app
93+ filename: seatdisable_mock_test.go
82 github.com/bnema/neferwl/internal/adapters/statefile:94 github.com/bnema/neferwl/internal/adapters/statefile:
83 interfaces:95 interfaces:
84 procTree:96 procTree:
M Makefile +1 -1
@@ -54,7 +54,7 @@ arch:
5454 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
5555 # Fast, mandatory guards for tiled content publications and SHM copies.
5656 perf-check:
57- CGO_ENABLED=0 go test ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
57+ CGO_ENABLED=0 go test ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
5858 check: vet test arch fakes-check perf-check
5959
6060 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
@@ -54,7 +54,7 @@ arch:
54 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...54 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
55 # Fast, mandatory guards for tiled content publications and SHM copies.55 # Fast, mandatory guards for tiled content publications and SHM copies.
56 perf-check:56 perf-check:
57- CGO_ENABLED=0 go test ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=157+ CGO_ENABLED=0 go test ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
58 check: vet test arch fakes-check perf-check58 check: vet test arch fakes-check perf-check
59 59
60 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules60 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
M README.md +1 -1
@@ -55,7 +55,7 @@ The terminal command comes from `terminal`, then `$TERMINAL`, then `foot`. `term
5555
5656 - `zwp_linux_dmabuf_v1` v6: client buffers are sampled in place. Vulkan composes into exported scanout images and the frame fence goes to KMS as `IN_FENCE_FD`. No CPU copy, no CPU wait.
5757 - `wp_single_pixel_buffer_v1`: solid backgrounds and letterbox bars are drawn as fills, without client buffers.
58-- Direct scanout of a fullscreen window's buffer; dmabuf feedback gives it a scanout-ready format first. A lone opaque window can use an overlay plane.
58+- Direct scanout of a fullscreen window's buffer; dmabuf feedback gives it a scanout-ready format first. A lone opaque window can use an overlay plane. On HDR outputs, SDR windows also bypass composition when the plane has a colour pipeline.
5959 - `wp_tearing_control_v1` and VRR while a fullscreen window covers the output.
6060 - `wp_linux_drm_syncobj_v1`: client fences go to KMS. NVIDIA drivers need it.
6161 - `wp_presentation`, `wp_fifo_v1`, `wp_commit_timing_v1` for frame pacing.
@@ -55,7 +55,7 @@ The terminal command comes from `terminal`, then `$TERMINAL`, then `foot`. `term
55 55
56 - `zwp_linux_dmabuf_v1` v6: client buffers are sampled in place. Vulkan composes into exported scanout images and the frame fence goes to KMS as `IN_FENCE_FD`. No CPU copy, no CPU wait.56 - `zwp_linux_dmabuf_v1` v6: client buffers are sampled in place. Vulkan composes into exported scanout images and the frame fence goes to KMS as `IN_FENCE_FD`. No CPU copy, no CPU wait.
57 - `wp_single_pixel_buffer_v1`: solid backgrounds and letterbox bars are drawn as fills, without client buffers.57 - `wp_single_pixel_buffer_v1`: solid backgrounds and letterbox bars are drawn as fills, without client buffers.
58-- Direct scanout of a fullscreen window's buffer; dmabuf feedback gives it a scanout-ready format first. A lone opaque window can use an overlay plane.58+- Direct scanout of a fullscreen window's buffer; dmabuf feedback gives it a scanout-ready format first. A lone opaque window can use an overlay plane. On HDR outputs, SDR windows also bypass composition when the plane has a colour pipeline.
59 - `wp_tearing_control_v1` and VRR while a fullscreen window covers the output.59 - `wp_tearing_control_v1` and VRR while a fullscreen window covers the output.
60 - `wp_linux_drm_syncobj_v1`: client fences go to KMS. NVIDIA drivers need it.60 - `wp_linux_drm_syncobj_v1`: client fences go to KMS. NVIDIA drivers need it.
61 - `wp_presentation`, `wp_fifo_v1`, `wp_commit_timing_v1` for frame pacing.61 - `wp_presentation`, `wp_fifo_v1`, `wp_commit_timing_v1` for frame pacing.
M docs/config.md +3 -0
@@ -156,6 +156,9 @@ HDR-aware Wayland clients, including games through Vulkan WSI, browsers, and vid
156156 Hardware-decoded video can use NV12 (8-bit) or P010 (10-bit) DMA-BUF subsurfaces with BT.601/709/2020 coefficients, full or limited range, and chroma-location type 0. The Vulkan device must support sampling the format/modifier with per-plane views; planes in one DMA-BUF use one memory import, while separate plane buffers additionally require Vulkan DISJOINT support. Unsupported formats are not offered. Confirm hardware decoding and the advertised DMA-BUF formats when testing an app.
157157
158158 - Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged.
159+- 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`.
160+- Composed frames and every modeset leave the planes' pipeline on Bypass.
161+- 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).
159162 - 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.
160163 - 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.
161164 - The display gets the metadata from its own EDID, not the client's MaxCLL/MaxFALL.
@@ -156,6 +156,9 @@ HDR-aware Wayland clients, including games through Vulkan WSI, browsers, and vid
156 Hardware-decoded video can use NV12 (8-bit) or P010 (10-bit) DMA-BUF subsurfaces with BT.601/709/2020 coefficients, full or limited range, and chroma-location type 0. The Vulkan device must support sampling the format/modifier with per-plane views; planes in one DMA-BUF use one memory import, while separate plane buffers additionally require Vulkan DISJOINT support. Unsupported formats are not offered. Confirm hardware decoding and the advertised DMA-BUF formats when testing an app.156 Hardware-decoded video can use NV12 (8-bit) or P010 (10-bit) DMA-BUF subsurfaces with BT.601/709/2020 coefficients, full or limited range, and chroma-location type 0. The Vulkan device must support sampling the format/modifier with per-plane views; planes in one DMA-BUF use one memory import, while separate plane buffers additionally require Vulkan DISJOINT support. Unsupported formats are not offered. Confirm hardware decoding and the advertised DMA-BUF formats when testing an app.
157 157
158 - Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged.158 - Fullscreen HDR buffers (10-bit, PQ) go straight to the display unchanged.
159+- 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`.
160+- Composed frames and every modeset leave the planes' pipeline on Bypass.
161+- 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).
159 - 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.162 - 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.
160 - 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.163 - 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.
161 - The display gets the metadata from its own EDID, not the client's MaxCLL/MaxFALL.164 - The display gets the metadata from its own EDID, not the client's MaxCLL/MaxFALL.
M docs/performance.md +1 -1
@@ -17,7 +17,7 @@ With the capability, the dynamic loader ignores `LD_PRELOAD` and `LD_LIBRARY_PAT
1717
1818 ## Guards
1919
20-`make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, frame lifecycle transitions, scanout/overlay frame decision, pacer bookkeeping, output message routing, surface update recycling and input-region traversal. Run it with `make check`.
20+`make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, frame lifecycle transitions, scanout/overlay frame decision, DRM frame commit and plane colour pipeline decision, pacer bookkeeping, output message routing, surface update recycling and input-region traversal. Run it with `make check`.
2121
2222 ## Measuring
2323
@@ -17,7 +17,7 @@ With the capability, the dynamic loader ignores `LD_PRELOAD` and `LD_LIBRARY_PAT
17 17
18 ## Guards18 ## Guards
19 19
20-`make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, frame lifecycle transitions, scanout/overlay frame decision, pacer bookkeeping, output message routing, surface update recycling and input-region traversal. Run it with `make check`.20+`make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, frame lifecycle transitions, scanout/overlay frame decision, DRM frame commit and plane colour pipeline decision, pacer bookkeeping, output message routing, surface update recycling and input-region traversal. Run it with `make check`.
21 21
22 ## Measuring22 ## Measuring
23 23
M internal/adapters/drm/amdgpu_rules_test.go +131 -2
@@ -89,7 +89,12 @@ func (m *amdgpuModel) attach(k *mockkms, strict ...uint32) {
8989 // - the active primary plane covers the whole mode (no partial src/dst);
9090 // - HDR_OUTPUT_METADATA needs the BT.2020 Colorspace;
9191 // - MODE_ID must name a live blob, and a change of MODE_ID, ACTIVE or the
92-// connector CRTC_ID is a modeset that needs the ALLOW_MODESET flag.
92+// connector CRTC_ID is a modeset that needs the ALLOW_MODESET flag;
93+// - a plane COLOR_PIPELINE cannot be selected together with the CRTC
94+// DEGAMMA_LUT (amdgpu_dm_plane.c);
95+// - an async commit changes nothing but a plane's FB_ID and IN_FENCE_FD
96+// (drm_atomic_set_property: a changed CRTC, connector, colorop or other
97+// plane property is EINVAL; writing the current value is allowed).
9398 //
9499 // amdgpuRule has no blob model: any MODE_ID is live.
95100 func amdgpuRule(t *testing.T) func(*atomicReq, uint32) error {
@@ -105,6 +110,14 @@ func amdgpuRuleWith(t *testing.T, live func(uint32) bool) func(*atomicReq, uint3
105110 for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
106111 state[[2]uint32{p, pFB}], state[[2]uint32{p, pCrtcID}] = 70, tCrtc
107112 }
113+ // The lit desktop's primary plane covers the whole mode: a commit that
114+ // leaves its geometry alone (an async flip) keeps it.
115+ for _, c := range [...]struct {
116+ prop uint32
117+ val uint64
118+ }{{12, 200 << 16}, {13, 100 << 16}, {16, 200}, {17, 100}} {
119+ state[[2]uint32{tPrimary, c.prop}] = c.val
120+ }
108121 return func(req *atomicReq, flags uint32) error {
109122 next := make(map[[2]uint32]uint64, len(state))
110123 for k, v := range state {
@@ -116,6 +129,23 @@ func amdgpuRuleWith(t *testing.T, live func(uint32) bool) func(*atomicReq, uint3
116129 }
117130 }
118131 get := func(obj, prop uint32) uint64 { return next[[2]uint32{obj, prop}] }
132+ if flags&flipAsyncFlag != 0 {
133+ for i, obj := range req.objs {
134+ for _, p := range req.props[i] {
135+ isPlane := obj == tPrimary || obj == tCursor || obj == tOverlay || obj == 53
136+ if p.val != state[[2]uint32{obj, p.prop}] && !(isPlane && (p.prop == pFB || p.prop == pFence)) {
137+ t.Logf("amdgpu: async commit changes object %d property %d", obj, p.prop)
138+ return unix.EINVAL
139+ }
140+ }
141+ }
142+ }
143+ for _, p := range []uint32{tPrimary, tOverlay} {
144+ if get(tCrtc, pDegamma) != 0 && (get(p, pColorPipe) != 0 || get(p, pColorPipeOverlay) != 0) {
145+ t.Logf("amdgpu: COLOR_PIPELINE on plane %d with a CRTC DEGAMMA_LUT", p)
146+ return unix.EINVAL
147+ }
148+ }
119149 if mode := get(tCrtc, pMode); mode != 0 && !live(uint32(mode)) {
120150 t.Logf("amdgpu: MODE_ID %d names an unknown or destroyed blob", mode)
121151 return unix.EINVAL
@@ -194,10 +224,13 @@ func TestAmdgpuRuleRejectsWhatHardwareRejected(t *testing.T) {
194224 },
195225 "cursor with CRTC but no FB": func(r *atomicReq) { r.set(tCursor, pFB, 0) },
196226 "partial primary": func(r *atomicReq) { r.set(tPrimary, 16, 100) },
197- "unknown MODE_ID": func(r *atomicReq) { r.set(tCrtc, pMode, 123) },
198227 "HDR metadata without colorspace": func(r *atomicReq) {
199228 r.set(tConn, pHDRMeta, 55)
200229 },
230+ "COLOR_PIPELINE with DEGAMMA_LUT": func(r *atomicReq) {
231+ r.set(tCrtc, pDegamma, 77)
232+ r.set(tPrimary, pColorPipe, opBase)
233+ },
201234 } {
202235 t.Run(name, func(t *testing.T) {
203236 req := &atomicReq{}
@@ -277,6 +310,16 @@ func TestAmdgpuRuleRejectsModesetWithoutAllowModes(t *testing.T) {
277310 }
278311 }
279312
313+// The state a lit desktop starts from is valid: the rule only refuses an
314+// unknown MODE_ID once it models blobs.
315+func TestAmdgpuRuleRejectsUnknownModeBlob(t *testing.T) {
316+ req := &atomicReq{}
317+ req.set(tCrtc, pMode, 123)
318+ if err := newAmdgpuModel(t).rule(req, atomicAllowModes); err == nil {
319+ t.Fatal("rule accepted an unknown MODE_ID")
320+ }
321+}
322+
280323 func TestAmdgpuRuleRejectsDestroyedModeBlob(t *testing.T) {
281324 m := newAmdgpuModel(t)
282325 req := &atomicReq{}
@@ -411,3 +454,89 @@ func TestRunProtectedActivationRetriesWithoutStoppingOrEarlyProof(t *testing.T)
411454 }
412455 }
413456 }
457+
458+// Async commits may change FB_ID and IN_FENCE_FD only; writing a value a
459+// property already has is allowed.
460+func TestAmdgpuRuleAsyncChangesOnlyFB(t *testing.T) {
461+ for _, tc := range []struct {
462+ name string
463+ build func(*atomicReq)
464+ ok bool
465+ }{
466+ {"FB_ID and fence", func(r *atomicReq) { r.set(tPrimary, pFB, 71); r.set(tPrimary, pFence, 5) }, true},
467+ {"the current value of another property", func(r *atomicReq) { r.set(tPrimary, pFB, 71); r.set(tPrimary, pCrtcID, tCrtc) }, true},
468+ {"COLOR_PIPELINE changes", func(r *atomicReq) { r.set(tPrimary, pFB, 71); r.set(tPrimary, pColorPipe, opBase) }, false},
469+ {"colorop BYPASS changes", func(r *atomicReq) { r.set(tPrimary, pFB, 71); r.set(opBase, bypassProp(opBase), 1) }, false},
470+ {"VRR changes", func(r *atomicReq) { r.set(tPrimary, pFB, 71); r.set(tCrtc, pVRR, 1) }, false},
471+ {"cursor moves", func(r *atomicReq) { r.set(tPrimary, pFB, 71); r.set(tCursor, 14, 9) }, false},
472+ } {
473+ t.Run(tc.name, func(t *testing.T) {
474+ req := &atomicReq{}
475+ tc.build(req)
476+ err := amdgpuRule(t)(req, flipAsyncFlag)
477+ if (err == nil) != tc.ok {
478+ t.Fatalf("async rule error %v, want ok=%v", err, tc.ok)
479+ }
480+ })
481+ }
482+}
483+
484+// The colour pipeline sequence the output drives on an HDR output meets the
485+// amdgpu rules: modeset on Bypass, SDR scanout selecting the pipeline in a
486+// synchronous commit, async flips of it carrying FB_ID alone, a composed
487+// frame back on Bypass, and Close.
488+func TestColorPipelineSequenceAcceptedByAmdgpuRules(t *testing.T) {
489+ m := newAmdgpuModel(t)
490+ o, k, commits := colorOutput(t, true, m.rule)
491+ m.attach(k)
492+ k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil).Maybe()
493+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
494+ enableTestHDR(o)
495+ o.hdr.blob = 55
496+ m.live[55], m.live[61] = true, true
497+ o.modeBlob = 99
498+ if err := o.modeset(); err != nil {
499+ t.Fatalf("modeset: %v", err)
500+ }
501+ c := sdrContent(o)
502+ c.Async = true
503+ o.shown = 9
504+ flip := func(mode colorMode) {
505+ t.Helper()
506+ o.frame.endPending()
507+ o.vrrOn = true // keep VRR out of the way: it is its own commit field
508+ if ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), mode); !ok || err != nil {
509+ t.Fatalf("scanout %d: ok=%v err=%v", mode, ok, err)
510+ }
511+ }
512+ flip(colorSDRToPQ)
513+ flip(colorSDRToPQ)
514+ flip(colorSDRToPQ)
515+ if n := len(*commits); (*commits)[n-1].flags&flipAsyncFlag == 0 {
516+ t.Fatal("steady-state flip with the pipeline was not async")
517+ }
518+ flip(colorBypass)
519+ o.frame.endPending()
520+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
521+ t.Fatalf("composed: %v", err)
522+ }
523+ o.Close()
524+}
525+
526+// The pipeline is never selected next to a CRTC DEGAMMA_LUT: the modeset
527+// state this compositor commits has none.
528+func TestColorPipelineNeverSetsDegamma(t *testing.T) {
529+ m := newAmdgpuModel(t)
530+ o, k, commits := colorOutput(t, false, m.rule)
531+ m.attach(k)
532+ k.EXPECT().objProps(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
533+ c := sdrContent(o)
534+ if ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
535+ t.Fatalf("ok=%v err=%v", ok, err)
536+ }
537+ for _, rec := range *commits {
538+ if _, ok := rec.req.value(tCrtc, pDegamma); ok {
539+ t.Fatal("commit touches DEGAMMA_LUT")
540+ }
541+ }
542+}
@@ -89,7 +89,12 @@ func (m *amdgpuModel) attach(k *mockkms, strict ...uint32) {
89 // - the active primary plane covers the whole mode (no partial src/dst);89 // - the active primary plane covers the whole mode (no partial src/dst);
90 // - HDR_OUTPUT_METADATA needs the BT.2020 Colorspace;90 // - HDR_OUTPUT_METADATA needs the BT.2020 Colorspace;
91 // - MODE_ID must name a live blob, and a change of MODE_ID, ACTIVE or the91 // - MODE_ID must name a live blob, and a change of MODE_ID, ACTIVE or the
92-// connector CRTC_ID is a modeset that needs the ALLOW_MODESET flag.92+// connector CRTC_ID is a modeset that needs the ALLOW_MODESET flag;
93+// - a plane COLOR_PIPELINE cannot be selected together with the CRTC
94+// DEGAMMA_LUT (amdgpu_dm_plane.c);
95+// - an async commit changes nothing but a plane's FB_ID and IN_FENCE_FD
96+// (drm_atomic_set_property: a changed CRTC, connector, colorop or other
97+// plane property is EINVAL; writing the current value is allowed).
93 //98 //
94 // amdgpuRule has no blob model: any MODE_ID is live.99 // amdgpuRule has no blob model: any MODE_ID is live.
95 func amdgpuRule(t *testing.T) func(*atomicReq, uint32) error {100 func amdgpuRule(t *testing.T) func(*atomicReq, uint32) error {
@@ -105,6 +110,14 @@ func amdgpuRuleWith(t *testing.T, live func(uint32) bool) func(*atomicReq, uint3
105 for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {110 for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
106 state[[2]uint32{p, pFB}], state[[2]uint32{p, pCrtcID}] = 70, tCrtc111 state[[2]uint32{p, pFB}], state[[2]uint32{p, pCrtcID}] = 70, tCrtc
107 }112 }
113+ // The lit desktop's primary plane covers the whole mode: a commit that
114+ // leaves its geometry alone (an async flip) keeps it.
115+ for _, c := range [...]struct {
116+ prop uint32
117+ val uint64
118+ }{{12, 200 << 16}, {13, 100 << 16}, {16, 200}, {17, 100}} {
119+ state[[2]uint32{tPrimary, c.prop}] = c.val
120+ }
108 return func(req *atomicReq, flags uint32) error {121 return func(req *atomicReq, flags uint32) error {
109 next := make(map[[2]uint32]uint64, len(state))122 next := make(map[[2]uint32]uint64, len(state))
110 for k, v := range state {123 for k, v := range state {
@@ -116,6 +129,23 @@ func amdgpuRuleWith(t *testing.T, live func(uint32) bool) func(*atomicReq, uint3
116 }129 }
117 }130 }
118 get := func(obj, prop uint32) uint64 { return next[[2]uint32{obj, prop}] }131 get := func(obj, prop uint32) uint64 { return next[[2]uint32{obj, prop}] }
132+ if flags&flipAsyncFlag != 0 {
133+ for i, obj := range req.objs {
134+ for _, p := range req.props[i] {
135+ isPlane := obj == tPrimary || obj == tCursor || obj == tOverlay || obj == 53
136+ if p.val != state[[2]uint32{obj, p.prop}] && !(isPlane && (p.prop == pFB || p.prop == pFence)) {
137+ t.Logf("amdgpu: async commit changes object %d property %d", obj, p.prop)
138+ return unix.EINVAL
139+ }
140+ }
141+ }
142+ }
143+ for _, p := range []uint32{tPrimary, tOverlay} {
144+ if get(tCrtc, pDegamma) != 0 && (get(p, pColorPipe) != 0 || get(p, pColorPipeOverlay) != 0) {
145+ t.Logf("amdgpu: COLOR_PIPELINE on plane %d with a CRTC DEGAMMA_LUT", p)
146+ return unix.EINVAL
147+ }
148+ }
119 if mode := get(tCrtc, pMode); mode != 0 && !live(uint32(mode)) {149 if mode := get(tCrtc, pMode); mode != 0 && !live(uint32(mode)) {
120 t.Logf("amdgpu: MODE_ID %d names an unknown or destroyed blob", mode)150 t.Logf("amdgpu: MODE_ID %d names an unknown or destroyed blob", mode)
121 return unix.EINVAL151 return unix.EINVAL
@@ -194,10 +224,13 @@ func TestAmdgpuRuleRejectsWhatHardwareRejected(t *testing.T) {
194 },224 },
195 "cursor with CRTC but no FB": func(r *atomicReq) { r.set(tCursor, pFB, 0) },225 "cursor with CRTC but no FB": func(r *atomicReq) { r.set(tCursor, pFB, 0) },
196 "partial primary": func(r *atomicReq) { r.set(tPrimary, 16, 100) },226 "partial primary": func(r *atomicReq) { r.set(tPrimary, 16, 100) },
197- "unknown MODE_ID": func(r *atomicReq) { r.set(tCrtc, pMode, 123) },
198 "HDR metadata without colorspace": func(r *atomicReq) {227 "HDR metadata without colorspace": func(r *atomicReq) {
199 r.set(tConn, pHDRMeta, 55)228 r.set(tConn, pHDRMeta, 55)
200 },229 },
230+ "COLOR_PIPELINE with DEGAMMA_LUT": func(r *atomicReq) {
231+ r.set(tCrtc, pDegamma, 77)
232+ r.set(tPrimary, pColorPipe, opBase)
233+ },
201 } {234 } {
202 t.Run(name, func(t *testing.T) {235 t.Run(name, func(t *testing.T) {
203 req := &atomicReq{}236 req := &atomicReq{}
@@ -277,6 +310,16 @@ func TestAmdgpuRuleRejectsModesetWithoutAllowModes(t *testing.T) {
277 }310 }
278 }311 }
279 312
313+// The state a lit desktop starts from is valid: the rule only refuses an
314+// unknown MODE_ID once it models blobs.
315+func TestAmdgpuRuleRejectsUnknownModeBlob(t *testing.T) {
316+ req := &atomicReq{}
317+ req.set(tCrtc, pMode, 123)
318+ if err := newAmdgpuModel(t).rule(req, atomicAllowModes); err == nil {
319+ t.Fatal("rule accepted an unknown MODE_ID")
320+ }
321+}
322+
280 func TestAmdgpuRuleRejectsDestroyedModeBlob(t *testing.T) {323 func TestAmdgpuRuleRejectsDestroyedModeBlob(t *testing.T) {
281 m := newAmdgpuModel(t)324 m := newAmdgpuModel(t)
282 req := &atomicReq{}325 req := &atomicReq{}
@@ -411,3 +454,89 @@ func TestRunProtectedActivationRetriesWithoutStoppingOrEarlyProof(t *testing.T)
411 }454 }
412 }455 }
413 }456 }
457+
458+// Async commits may change FB_ID and IN_FENCE_FD only; writing a value a
459+// property already has is allowed.
460+func TestAmdgpuRuleAsyncChangesOnlyFB(t *testing.T) {
461+ for _, tc := range []struct {
462+ name string
463+ build func(*atomicReq)
464+ ok bool
465+ }{
466+ {"FB_ID and fence", func(r *atomicReq) { r.set(tPrimary, pFB, 71); r.set(tPrimary, pFence, 5) }, true},
467+ {"the current value of another property", func(r *atomicReq) { r.set(tPrimary, pFB, 71); r.set(tPrimary, pCrtcID, tCrtc) }, true},
468+ {"COLOR_PIPELINE changes", func(r *atomicReq) { r.set(tPrimary, pFB, 71); r.set(tPrimary, pColorPipe, opBase) }, false},
469+ {"colorop BYPASS changes", func(r *atomicReq) { r.set(tPrimary, pFB, 71); r.set(opBase, bypassProp(opBase), 1) }, false},
470+ {"VRR changes", func(r *atomicReq) { r.set(tPrimary, pFB, 71); r.set(tCrtc, pVRR, 1) }, false},
471+ {"cursor moves", func(r *atomicReq) { r.set(tPrimary, pFB, 71); r.set(tCursor, 14, 9) }, false},
472+ } {
473+ t.Run(tc.name, func(t *testing.T) {
474+ req := &atomicReq{}
475+ tc.build(req)
476+ err := amdgpuRule(t)(req, flipAsyncFlag)
477+ if (err == nil) != tc.ok {
478+ t.Fatalf("async rule error %v, want ok=%v", err, tc.ok)
479+ }
480+ })
481+ }
482+}
483+
484+// The colour pipeline sequence the output drives on an HDR output meets the
485+// amdgpu rules: modeset on Bypass, SDR scanout selecting the pipeline in a
486+// synchronous commit, async flips of it carrying FB_ID alone, a composed
487+// frame back on Bypass, and Close.
488+func TestColorPipelineSequenceAcceptedByAmdgpuRules(t *testing.T) {
489+ m := newAmdgpuModel(t)
490+ o, k, commits := colorOutput(t, true, m.rule)
491+ m.attach(k)
492+ k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil).Maybe()
493+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
494+ enableTestHDR(o)
495+ o.hdr.blob = 55
496+ m.live[55], m.live[61] = true, true
497+ o.modeBlob = 99
498+ if err := o.modeset(); err != nil {
499+ t.Fatalf("modeset: %v", err)
500+ }
501+ c := sdrContent(o)
502+ c.Async = true
503+ o.shown = 9
504+ flip := func(mode colorMode) {
505+ t.Helper()
506+ o.frame.endPending()
507+ o.vrrOn = true // keep VRR out of the way: it is its own commit field
508+ if ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), mode); !ok || err != nil {
509+ t.Fatalf("scanout %d: ok=%v err=%v", mode, ok, err)
510+ }
511+ }
512+ flip(colorSDRToPQ)
513+ flip(colorSDRToPQ)
514+ flip(colorSDRToPQ)
515+ if n := len(*commits); (*commits)[n-1].flags&flipAsyncFlag == 0 {
516+ t.Fatal("steady-state flip with the pipeline was not async")
517+ }
518+ flip(colorBypass)
519+ o.frame.endPending()
520+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
521+ t.Fatalf("composed: %v", err)
522+ }
523+ o.Close()
524+}
525+
526+// The pipeline is never selected next to a CRTC DEGAMMA_LUT: the modeset
527+// state this compositor commits has none.
528+func TestColorPipelineNeverSetsDegamma(t *testing.T) {
529+ m := newAmdgpuModel(t)
530+ o, k, commits := colorOutput(t, false, m.rule)
531+ m.attach(k)
532+ k.EXPECT().objProps(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
533+ c := sdrContent(o)
534+ if ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
535+ t.Fatalf("ok=%v err=%v", ok, err)
536+ }
537+ for _, rec := range *commits {
538+ if _, ok := rec.req.value(tCrtc, pDegamma); ok {
539+ t.Fatal("commit touches DEGAMMA_LUT")
540+ }
541+ }
542+}
M internal/adapters/drm/atomic.go +58 -11
@@ -29,6 +29,7 @@ const (
2929
3030 clientCapUniversalPlanes = 2
3131 clientCapAtomic = 3
32+ clientCapColorPipeline = 7 // DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE
3233 capTimestampMonotonic = 0x6
3334 capAtomicAsync = 0x15
3435
@@ -83,6 +84,8 @@ type kms interface {
8384 // planes lists every plane with the CRTCs it can show on.
8485 planes() ([]planeRes, error)
8586 objProps(obj, typ uint32) (map[string][2]uint64, error)
87+ // propEnums returns the enum names of a property with their values.
88+ propEnums(prop uint32) (map[string]uint64, error)
8689 // addFB imports a single-plane dmabuf as a framebuffer of format.
8790 addFB(b *ports.DMABuf, format uint32) (uint32, error)
8891 rmFB(id uint32) error
@@ -102,9 +105,23 @@ type planeRes struct {
102105 }
103106
104107 // atomicReq collects (object, property, value) triples in insertion order.
108+// A request is owned by one goroutine and reused through reset: its slices,
109+// and the flat scratch kmsDevice.commit fills for the ioctl, keep their
110+// capacity, so steady-state commits allocate nothing. The scratch lives on
111+// the request, never on the shared kms device.
105112 type atomicReq struct {
106113 objs []uint32
107114 props [][]propValue
115+ // counts, flatProps and flatVals are the ioctl arrays commit builds.
116+ counts []uint32
117+ flatProps []uint32
118+ flatVals []uint64
119+}
120+
121+// reset empties the request, keeping every buffer's capacity.
122+func (r *atomicReq) reset() {
123+ r.objs = r.objs[:0]
124+ r.props = r.props[:0]
108125 }
109126
110127 type propValue struct {
@@ -120,7 +137,13 @@ func (r *atomicReq) set(obj, prop uint32, v uint64) {
120137 i := slices.Index(r.objs, obj)
121138 if i < 0 {
122139 r.objs = append(r.objs, obj)
123- r.props = append(r.props, nil)
140+ if n := len(r.props); n < cap(r.props) {
141+ // Reuse the property list kept by reset.
142+ r.props = r.props[:n+1]
143+ r.props[n] = r.props[n][:0]
144+ } else {
145+ r.props = append(r.props, nil)
146+ }
124147 i = len(r.objs) - 1
125148 }
126149 for j, p := range r.props[i] {
@@ -160,16 +183,15 @@ func (k kmsDevice) commit(req *atomicReq, flags uint32, userData uint64) error {
160183 if len(req.objs) == 0 {
161184 return errors.New("empty atomic commit")
162185 }
163- counts := make([]uint32, len(req.objs))
164- var props []uint32
165- var vals []uint64
166- for i, ps := range req.props {
167- counts[i] = uint32(len(ps))
186+ req.counts, req.flatProps, req.flatVals = req.counts[:0], req.flatProps[:0], req.flatVals[:0]
187+ for _, ps := range req.props {
188+ req.counts = append(req.counts, uint32(len(ps)))
168189 for _, p := range ps {
169- props = append(props, p.prop)
170- vals = append(vals, p.val)
190+ req.flatProps = append(req.flatProps, p.prop)
191+ req.flatVals = append(req.flatVals, p.val)
171192 }
172193 }
194+ counts, props, vals := req.counts, req.flatProps, req.flatVals
173195 a := modeAtomic{flags: flags, countObjs: uint32(len(req.objs)), userData: userData,
174196 objs: uint64(uintptr(unsafe.Pointer(&req.objs[0]))), countProps: uint64(uintptr(unsafe.Pointer(&counts[0]))),
175197 props: uint64(uintptr(unsafe.Pointer(&props[0]))), values: uint64(uintptr(unsafe.Pointer(&vals[0])))}
@@ -246,6 +268,10 @@ func (k kmsDevice) objProps(obj, typ uint32) (map[string][2]uint64, error) {
246268 return objProps(k.fd, obj, typ)
247269 }
248270
271+func (k kmsDevice) propEnums(prop uint32) (map[string]uint64, error) {
272+ return propEnums(k.fd, prop)
273+}
274+
249275 func (k kmsDevice) rmFB(id uint32) error {
250276 v := id
251277 return ioctl(k.fd, ioctlRmFB, unsafe.Pointer(&v))
@@ -265,6 +291,18 @@ func enableAtomic(fd int) error {
265291 return nil
266292 }
267293
294+// enableColorPipeline asks for the plane COLOR_PIPELINE property and the
295+// colorop objects. It must run after enableAtomic and before any plane or
296+// property read: the property set differs per fd. Failure leaves the
297+// feature off. With the cap on, the kernel rejects COLOR_ENCODING and
298+// COLOR_RANGE on planes (they are replaced by the pipeline), so a YUV buffer
299+// can never be put on a plane: planeColor refuses YUV, and every YUV format
300+// is composed.
301+func enableColorPipeline(fd int) error {
302+ v := setClientCap{capability: clientCapColorPipeline, value: 1}
303+ return ioctl(fd, ioctlSetClientCap, unsafe.Pointer(&v))
304+}
305+
268306 func hasCap(fd int, c uint64) bool {
269307 v := getCap{capability: c}
270308 return ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value == 1
@@ -276,6 +314,15 @@ type plane struct {
276314 props map[string]uint32
277315 crtc uint32 // CRTC_ID when read
278316 formats []ports.DMABufFormat
317+ // pipeline is the colour pipeline the plane offers for SDR content on
318+ // an HDR output (colorop.go); nil: none. colorApplied is the mode the
319+ // plane was last committed with, valid while colorKnown.
320+ pipeline *colorPipeline
321+ colorApplied colorMode
322+ colorKnown bool
323+ colorFormat uint32 // format of the buffer shown with colorApplied
324+ // verdicts are KMS's cached TEST_ONLY answers for the pipeline.
325+ verdicts []colorVerdict
279326 // zposValue is the plane's zpos when read (its "zpos" property).
280327 zposValue uint64
281328 }
@@ -357,9 +404,9 @@ type flipEvent struct {
357404 seq uint32
358405 }
359406
360-// parseFlips parses DRM events read from the card fd.
361-func parseFlips(buf []byte) []flipEvent {
362- var out []flipEvent
407+// parseFlips appends the flip events of buf, DRM events read from the card
408+// fd, to out (pass out[:0] to reuse its storage) and returns it.
409+func parseFlips(out []flipEvent, buf []byte) []flipEvent {
363410 le := binary.LittleEndian
364411 for len(buf) >= 8 {
365412 typ := le.Uint32(buf)
@@ -29,6 +29,7 @@ const (
29 29
30 clientCapUniversalPlanes = 230 clientCapUniversalPlanes = 2
31 clientCapAtomic = 331 clientCapAtomic = 3
32+ clientCapColorPipeline = 7 // DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE
32 capTimestampMonotonic = 0x633 capTimestampMonotonic = 0x6
33 capAtomicAsync = 0x1534 capAtomicAsync = 0x15
34 35
@@ -83,6 +84,8 @@ type kms interface {
83 // planes lists every plane with the CRTCs it can show on.84 // planes lists every plane with the CRTCs it can show on.
84 planes() ([]planeRes, error)85 planes() ([]planeRes, error)
85 objProps(obj, typ uint32) (map[string][2]uint64, error)86 objProps(obj, typ uint32) (map[string][2]uint64, error)
87+ // propEnums returns the enum names of a property with their values.
88+ propEnums(prop uint32) (map[string]uint64, error)
86 // addFB imports a single-plane dmabuf as a framebuffer of format.89 // addFB imports a single-plane dmabuf as a framebuffer of format.
87 addFB(b *ports.DMABuf, format uint32) (uint32, error)90 addFB(b *ports.DMABuf, format uint32) (uint32, error)
88 rmFB(id uint32) error91 rmFB(id uint32) error
@@ -102,9 +105,23 @@ type planeRes struct {
102 }105 }
103 106
104 // atomicReq collects (object, property, value) triples in insertion order.107 // atomicReq collects (object, property, value) triples in insertion order.
108+// A request is owned by one goroutine and reused through reset: its slices,
109+// and the flat scratch kmsDevice.commit fills for the ioctl, keep their
110+// capacity, so steady-state commits allocate nothing. The scratch lives on
111+// the request, never on the shared kms device.
105 type atomicReq struct {112 type atomicReq struct {
106 objs []uint32113 objs []uint32
107 props [][]propValue114 props [][]propValue
115+ // counts, flatProps and flatVals are the ioctl arrays commit builds.
116+ counts []uint32
117+ flatProps []uint32
118+ flatVals []uint64
119+}
120+
121+// reset empties the request, keeping every buffer's capacity.
122+func (r *atomicReq) reset() {
123+ r.objs = r.objs[:0]
124+ r.props = r.props[:0]
108 }125 }
109 126
110 type propValue struct {127 type propValue struct {
@@ -120,7 +137,13 @@ func (r *atomicReq) set(obj, prop uint32, v uint64) {
120 i := slices.Index(r.objs, obj)137 i := slices.Index(r.objs, obj)
121 if i < 0 {138 if i < 0 {
122 r.objs = append(r.objs, obj)139 r.objs = append(r.objs, obj)
123- r.props = append(r.props, nil)140+ if n := len(r.props); n < cap(r.props) {
141+ // Reuse the property list kept by reset.
142+ r.props = r.props[:n+1]
143+ r.props[n] = r.props[n][:0]
144+ } else {
145+ r.props = append(r.props, nil)
146+ }
124 i = len(r.objs) - 1147 i = len(r.objs) - 1
125 }148 }
126 for j, p := range r.props[i] {149 for j, p := range r.props[i] {
@@ -160,16 +183,15 @@ func (k kmsDevice) commit(req *atomicReq, flags uint32, userData uint64) error {
160 if len(req.objs) == 0 {183 if len(req.objs) == 0 {
161 return errors.New("empty atomic commit")184 return errors.New("empty atomic commit")
162 }185 }
163- counts := make([]uint32, len(req.objs))186+ req.counts, req.flatProps, req.flatVals = req.counts[:0], req.flatProps[:0], req.flatVals[:0]
164- var props []uint32187+ for _, ps := range req.props {
165- var vals []uint64188+ req.counts = append(req.counts, uint32(len(ps)))
166- for i, ps := range req.props {
167- counts[i] = uint32(len(ps))
168 for _, p := range ps {189 for _, p := range ps {
169- props = append(props, p.prop)190+ req.flatProps = append(req.flatProps, p.prop)
170- vals = append(vals, p.val)191+ req.flatVals = append(req.flatVals, p.val)
171 }192 }
172 }193 }
194+ counts, props, vals := req.counts, req.flatProps, req.flatVals
173 a := modeAtomic{flags: flags, countObjs: uint32(len(req.objs)), userData: userData,195 a := modeAtomic{flags: flags, countObjs: uint32(len(req.objs)), userData: userData,
174 objs: uint64(uintptr(unsafe.Pointer(&req.objs[0]))), countProps: uint64(uintptr(unsafe.Pointer(&counts[0]))),196 objs: uint64(uintptr(unsafe.Pointer(&req.objs[0]))), countProps: uint64(uintptr(unsafe.Pointer(&counts[0]))),
175 props: uint64(uintptr(unsafe.Pointer(&props[0]))), values: uint64(uintptr(unsafe.Pointer(&vals[0])))}197 props: uint64(uintptr(unsafe.Pointer(&props[0]))), values: uint64(uintptr(unsafe.Pointer(&vals[0])))}
@@ -246,6 +268,10 @@ func (k kmsDevice) objProps(obj, typ uint32) (map[string][2]uint64, error) {
246 return objProps(k.fd, obj, typ)268 return objProps(k.fd, obj, typ)
247 }269 }
248 270
271+func (k kmsDevice) propEnums(prop uint32) (map[string]uint64, error) {
272+ return propEnums(k.fd, prop)
273+}
274+
249 func (k kmsDevice) rmFB(id uint32) error {275 func (k kmsDevice) rmFB(id uint32) error {
250 v := id276 v := id
251 return ioctl(k.fd, ioctlRmFB, unsafe.Pointer(&v))277 return ioctl(k.fd, ioctlRmFB, unsafe.Pointer(&v))
@@ -265,6 +291,18 @@ func enableAtomic(fd int) error {
265 return nil291 return nil
266 }292 }
267 293
294+// enableColorPipeline asks for the plane COLOR_PIPELINE property and the
295+// colorop objects. It must run after enableAtomic and before any plane or
296+// property read: the property set differs per fd. Failure leaves the
297+// feature off. With the cap on, the kernel rejects COLOR_ENCODING and
298+// COLOR_RANGE on planes (they are replaced by the pipeline), so a YUV buffer
299+// can never be put on a plane: planeColor refuses YUV, and every YUV format
300+// is composed.
301+func enableColorPipeline(fd int) error {
302+ v := setClientCap{capability: clientCapColorPipeline, value: 1}
303+ return ioctl(fd, ioctlSetClientCap, unsafe.Pointer(&v))
304+}
305+
268 func hasCap(fd int, c uint64) bool {306 func hasCap(fd int, c uint64) bool {
269 v := getCap{capability: c}307 v := getCap{capability: c}
270 return ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value == 1308 return ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value == 1
@@ -276,6 +314,15 @@ type plane struct {
276 props map[string]uint32314 props map[string]uint32
277 crtc uint32 // CRTC_ID when read315 crtc uint32 // CRTC_ID when read
278 formats []ports.DMABufFormat316 formats []ports.DMABufFormat
317+ // pipeline is the colour pipeline the plane offers for SDR content on
318+ // an HDR output (colorop.go); nil: none. colorApplied is the mode the
319+ // plane was last committed with, valid while colorKnown.
320+ pipeline *colorPipeline
321+ colorApplied colorMode
322+ colorKnown bool
323+ colorFormat uint32 // format of the buffer shown with colorApplied
324+ // verdicts are KMS's cached TEST_ONLY answers for the pipeline.
325+ verdicts []colorVerdict
279 // zposValue is the plane's zpos when read (its "zpos" property).326 // zposValue is the plane's zpos when read (its "zpos" property).
280 zposValue uint64327 zposValue uint64
281 }328 }
@@ -357,9 +404,9 @@ type flipEvent struct {
357 seq uint32404 seq uint32
358 }405 }
359 406
360-// parseFlips parses DRM events read from the card fd.407+// parseFlips appends the flip events of buf, DRM events read from the card
361-func parseFlips(buf []byte) []flipEvent {408+// fd, to out (pass out[:0] to reuse its storage) and returns it.
362- var out []flipEvent409+func parseFlips(out []flipEvent, buf []byte) []flipEvent {
363 le := binary.LittleEndian410 le := binary.LittleEndian
364 for len(buf) >= 8 {411 for len(buf) >= 8 {
365 typ := le.Uint32(buf)412 typ := le.Uint32(buf)
M internal/adapters/drm/card.go +7 -1
@@ -47,6 +47,10 @@ func OpenCard(fd int, path string, want Want, log zerowrap.Logger) (*Card, error
4747 if err := enableAtomic(fd); err != nil {
4848 return nil, fmt.Errorf("drm: atomic modesetting unsupported by %s: %w", path, err)
4949 }
50+ // Before any plane or property read: the property set depends on it.
51+ if err := enableColorPipeline(fd); err != nil {
52+ log.Info().Str("component", "drm").Err(err).Str("card", path).Msg("plane colour pipelines unavailable")
53+ }
5054 crtcs, _, err := resources(fd)
5155 if err != nil {
5256 return nil, fmt.Errorf("%s: %w", path, err)
@@ -240,6 +244,7 @@ func (c *Card) open(conn connector, mode modeInfo) (*Output, error) {
240244 // the output owes it a pending commit.
241245 func (c *Card) ReadEvents(ctx context.Context) error {
242246 buf := make([]byte, 1024)
247+ var events []flipEvent // reused by every read
243248 for ctx.Err() == nil {
244249 fds := []unix.PollFd{{Fd: int32(c.fd), Events: unix.POLLIN}}
245250 n, err := unix.Poll(fds, 100)
@@ -256,7 +261,8 @@ func (c *Card) ReadEvents(ctx context.Context) error {
256261 }
257262 return fmt.Errorf("drm read: %w", err)
258263 }
259- for _, ev := range parseFlips(buf[:m]) {
264+ events = parseFlips(events[:0], buf[:m])
265+ for _, ev := range events {
260266 ch := c.flips[ev.crtc]
261267 if ch == nil {
262268 continue
@@ -47,6 +47,10 @@ func OpenCard(fd int, path string, want Want, log zerowrap.Logger) (*Card, error
47 if err := enableAtomic(fd); err != nil {47 if err := enableAtomic(fd); err != nil {
48 return nil, fmt.Errorf("drm: atomic modesetting unsupported by %s: %w", path, err)48 return nil, fmt.Errorf("drm: atomic modesetting unsupported by %s: %w", path, err)
49 }49 }
50+ // Before any plane or property read: the property set depends on it.
51+ if err := enableColorPipeline(fd); err != nil {
52+ log.Info().Str("component", "drm").Err(err).Str("card", path).Msg("plane colour pipelines unavailable")
53+ }
50 crtcs, _, err := resources(fd)54 crtcs, _, err := resources(fd)
51 if err != nil {55 if err != nil {
52 return nil, fmt.Errorf("%s: %w", path, err)56 return nil, fmt.Errorf("%s: %w", path, err)
@@ -240,6 +244,7 @@ func (c *Card) open(conn connector, mode modeInfo) (*Output, error) {
240 // the output owes it a pending commit.244 // the output owes it a pending commit.
241 func (c *Card) ReadEvents(ctx context.Context) error {245 func (c *Card) ReadEvents(ctx context.Context) error {
242 buf := make([]byte, 1024)246 buf := make([]byte, 1024)
247+ var events []flipEvent // reused by every read
243 for ctx.Err() == nil {248 for ctx.Err() == nil {
244 fds := []unix.PollFd{{Fd: int32(c.fd), Events: unix.POLLIN}}249 fds := []unix.PollFd{{Fd: int32(c.fd), Events: unix.POLLIN}}
245 n, err := unix.Poll(fds, 100)250 n, err := unix.Poll(fds, 100)
@@ -256,7 +261,8 @@ func (c *Card) ReadEvents(ctx context.Context) error {
256 }261 }
257 return fmt.Errorf("drm read: %w", err)262 return fmt.Errorf("drm read: %w", err)
258 }263 }
259- for _, ev := range parseFlips(buf[:m]) {264+ events = parseFlips(events[:0], buf[:m])
265+ for _, ev := range events {
260 ch := c.flips[ev.crtc]266 ch := c.flips[ev.crtc]
261 if ch == nil {267 if ch == nil {
262 continue268 continue
A internal/adapters/drm/colorop.go +281 -0
@@ -0,0 +1,281 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "os"
6+
7+ "golang.org/x/sys/unix"
8+)
9+
10+// The output side of the plane colour pipelines: per-plane state, commit
11+// properties, refusal caching. The pure part (reading and matching
12+// pipelines, the maths, planeColor) is in colorop_match.go.
13+
14+// hasColor reports whether the plane has a COLOR_PIPELINE property.
15+func (p *plane) hasColor() bool { return p != nil && p.props[propColorPipeline] != 0 }
16+
17+// colorStale reports whether mode must be written: the plane applied
18+// another one, or nobody knows. A plane without a matched pipeline never
19+// leaves Bypass (modesets and Close write it), so it is never stale.
20+func (p *plane) colorStale(mode colorMode) bool {
21+ return p.hasColor() && p.pipeline != nil && (!p.colorKnown || p.colorApplied != mode)
22+}
23+
24+// colorProps puts mode on p: Bypass selects the plane's Bypass pipeline;
25+// colorSDRToPQ selects the matched one with its four operations enabled and
26+// every other one bypassed. mult is the S31.32 multiplier and ctm the matrix
27+// blob.
28+func (p *plane) colorProps(req *atomicReq, mode colorMode, mult uint64, ctm uint32) {
29+ if !p.hasColor() {
30+ return
31+ }
32+ prop := p.props[propColorPipeline]
33+ cp := p.pipeline
34+ if mode != colorSDRToPQ || cp == nil {
35+ req.set(p.id, prop, 0)
36+ return
37+ }
38+ req.set(p.id, prop, cp.id)
39+ for _, s := range cp.others {
40+ req.set(s.op, s.bypass, 1)
41+ }
42+ for _, s := range [...]struct {
43+ stage colorStage
44+ val uint64
45+ }{{cp.srgb, cp.srgb.curve}, {cp.mult, mult}, {cp.ctm, uint64(ctm)}, {cp.pq, cp.pq.curve}} {
46+ req.set(s.stage.op, s.stage.bypass, 0)
47+ req.set(s.stage.op, s.stage.prop, s.val)
48+ }
49+}
50+
51+// colorVerdict is KMS's answer to a TEST_ONLY with the pipeline on a plane,
52+// kept per buffer format and cursor state.
53+type colorVerdict struct {
54+ format uint32
55+ cursor bool
56+ ok bool
57+}
58+
59+// verdict returns a cached answer.
60+func (p *plane) verdict(format uint32, cursor bool) (ok, known bool) {
61+ for _, v := range p.verdicts {
62+ if v.format == format && v.cursor == cursor {
63+ return v.ok, true
64+ }
65+ }
66+ return false, false
67+}
68+
69+// setVerdict caches KMS's answer for the pipeline on p.
70+func (p *plane) setVerdict(format uint32, cursor, ok bool) {
71+ for i, v := range p.verdicts {
72+ if v.format == format && v.cursor == cursor {
73+ p.verdicts[i].ok = ok
74+ return
75+ }
76+ }
77+ p.verdicts = append(p.verdicts, colorVerdict{format: format, cursor: cursor, ok: ok})
78+}
79+
80+// cursorShown reports whether a frame committed now shows the hardware cursor.
81+func (o *Output) cursorShown() bool { return o.cursor != nil && !o.protected && o.cursor.desired().on }
82+
83+// primaryColorProps puts mode on the primary plane if it applies another one.
84+func (o *Output) primaryColorProps(req *atomicReq, mode colorMode) {
85+ if o.primary.colorStale(mode) {
86+ o.primary.colorProps(req, mode, o.colorMult, o.ctmBlob)
87+ }
88+}
89+
90+// 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 a
92+// known state (modeset, close).
93+func (o *Output) forceBypass(req *atomicReq) {
94+ o.primary.colorProps(req, colorBypass, 0, 0)
95+ o.overlay.colorProps(req, colorBypass, 0, 0)
96+}
97+
98+// overlayColorProps puts mode on the overlay plane if it applies another one.
99+func (o *Output) overlayColorProps(req *atomicReq, mode colorMode) {
100+ if o.overlay.colorStale(mode) {
101+ o.overlay.colorProps(req, mode, o.colorMult, o.ctmBlob)
102+ }
103+}
104+
105+// colorBypassed records that a commit left both planes on Bypass.
106+func (o *Output) colorBypassed() {
107+ for _, p := range [...]*plane{o.primary, o.overlay} {
108+ if p != nil {
109+ p.colorApplied, p.colorKnown = colorBypass, true
110+ }
111+ }
112+}
113+
114+// forgetColor drops what is known about the planes' colour state: the next
115+// frame commit writes it. Every modeset (VT resume and recovery included)
116+// calls it, since the planes' state is then not ours to assume. The async
117+// probe follows a modeset and needs no call of its own.
118+func (o *Output) forgetColor() {
119+ for _, p := range [...]*plane{o.primary, o.overlay} {
120+ if p != nil {
121+ p.colorKnown = false
122+ }
123+ }
124+}
125+
126+// forgetColorVerdicts drops the cached refusals: after a modeset the driver
127+// is asked again.
128+func (o *Output) forgetColorVerdicts() {
129+ for _, p := range [...]*plane{o.primary, o.overlay} {
130+ if p != nil {
131+ p.verdicts = p.verdicts[:0]
132+ }
133+ }
134+}
135+
136+// colorStaleFor reports whether a frame commit with primary colour pc and
137+// overlay ov writes colour state: such a commit cannot be async.
138+func (o *Output) colorStaleFor(pc colorUse, ov overlayWin) bool {
139+ return o.primary.colorStale(pc.mode) || o.overlay.colorStale(ov.color.mode)
140+}
141+
142+// colorCommitted records the colour state a successful synchronous frame
143+// commit left.
144+func (o *Output) colorCommitted(pc colorUse, ov overlayWin) {
145+ if o.primary.hasColor() {
146+ o.primary.colorApplied, o.primary.colorKnown, o.primary.colorFormat = pc.mode, true, pc.format
147+ }
148+ if o.overlay.hasColor() {
149+ o.overlay.colorApplied, o.overlay.colorKnown, o.overlay.colorFormat = ov.color.mode, true, ov.color.format
150+ }
151+}
152+
153+// errColorRefused: KMS refused a frame with a plane colour pipeline that it
154+// takes with Bypass. The caller composes the frame.
155+var errColorRefused = errors.New("plane colour pipeline refused")
156+
157+// colorRefused handles a frame commit that failed with EINVAL while a plane
158+// showed the pipeline: when the same frame passes TEST_ONLY with Bypass, and
159+// differs from the commit in colour alone, the pipeline is the cause. The refusal is cached for each plane that showed it,
160+// its buffer format and the cursor state, and the frame is not retried.
161+func (o *Output) colorRefused(err error, fb uint32, fence *os.File, vrr bool, cur cursorState, ov overlayWin, rect planeRect, pc colorUse) bool {
162+ if !o.colorUsed(pc, ov) {
163+ return false
164+ }
165+ bypassed := ov
166+ bypassed.color = colorUse{}
167+ // The same frame as the failed commit, content hint included, with
168+ // Bypass as the only difference: if the hint was the cause, this fails
169+ // too and the pipeline is not blamed.
170+ if !o.frameTest(fb, fence, vrr, cur, bypassed, rect, colorUse{}, true) {
171+ return false
172+ }
173+ if pc.mode != colorBypass {
174+ o.primary.setVerdict(pc.format, cur.on, false)
175+ }
176+ if ov.buf != 0 && ov.color.mode != colorBypass {
177+ o.overlay.setVerdict(ov.color.format, cur.on, false)
178+ }
179+ o.log.Info().Str("component", "render").Err(err).Str("connector", o.conn.name).Bool("cursor", cur.on).Msg("plane colour pipeline refused")
180+ return true
181+}
182+
183+// colorConflict handles a commit without a frame, which turned the cursor
184+// on or moved it, refused while a plane applies the pipeline: KMS may refuse
185+// that combination although it took each alone. The pipeline is refused for
186+// that buffer format with the cursor shown, so the next frame is composed
187+// (the cursor then commits with it). It reports whether it did.
188+func (o *Output) colorConflict(err error, cursorOn bool) bool {
189+ if !cursorOn || !errors.Is(err, unix.EINVAL) {
190+ return false
191+ }
192+ hit := false
193+ for _, p := range [...]*plane{o.primary, o.overlay} {
194+ if p.hasColor() && p.colorKnown && p.colorApplied == colorSDRToPQ {
195+ p.setVerdict(p.colorFormat, true, false)
196+ hit = true
197+ }
198+ }
199+ if hit {
200+ o.log.Info().Str("component", "render").Err(err).Str("connector", o.conn.name).Msg("plane colour pipeline refused with the cursor")
201+ }
202+ return hit
203+}
204+
205+// colorAllowed tests, once per plane, buffer format and cursor state, that
206+// KMS takes the SDR to PQ pipeline on p for the request test builds (the frame
207+// as it would commit). A refusal is cached: the buffer is composed from then
208+// on, never tested each frame.
209+func (o *Output) colorAllowed(p *plane, format uint32, test func(req *atomicReq)) bool {
210+ cursor := o.cursorShown()
211+ if ok, known := p.verdict(format, cursor); known {
212+ return ok
213+ }
214+ req := &o.probeReq
215+ req.reset()
216+ test(req)
217+ p.colorProps(req, colorSDRToPQ, o.colorMult, o.ctmBlob)
218+ if cursor {
219+ o.cursor.props(req, o.crtc, o.cursor.desired())
220+ }
221+ err := o.k.commit(req, atomicTestOnly, 0)
222+ if err != nil && !refused(err) {
223+ // Not an answer about the pipeline (e.g. master lost): ask again.
224+ return false
225+ }
226+ p.setVerdict(format, cursor, err == nil)
227+ if err != nil {
228+ o.log.Info().Str("component", "render").Err(err).Uint32("format", format).Uint32("plane", p.id).Bool("cursor", cursor).Str("connector", o.conn.name).Msg("plane colour pipeline refused")
229+ }
230+ return err == nil
231+}
232+
233+// setupColor makes the output's colour objects: the CTM blob and the
234+// multiplier, when HDR can be on and a plane has a matched pipeline. A
235+// failure drops the pipelines: SDR content is composed as before.
236+func (o *Output) setupColor() {
237+ if !o.hdr.wanted() {
238+ // The pipelines serve HDR outputs only: SDR stays on Bypass.
239+ o.dropPipelines()
240+ return
241+ }
242+ if o.primary.pipeline == nil && (o.overlay == nil || o.overlay.pipeline == nil) {
243+ return
244+ }
245+ blob, err := o.k.createBlob(ctmBytes(bt709ToBT2020))
246+ if err != nil {
247+ o.log.Warn().Str("component", "drm").Err(err).Str("connector", o.conn.name).Msg("colour matrix blob; plane colour pipelines off")
248+ o.dropPipelines()
249+ return
250+ }
251+ o.ctmBlob = blob
252+ o.colorMult = s3132(float64(o.hdr.settings.SDRBrightness) / 80)
253+}
254+
255+// dropPipelines leaves the output's planes without a pipeline: SDR content
256+// is composed.
257+func (o *Output) dropPipelines() {
258+ for _, p := range [...]*plane{o.primary, o.overlay} {
259+ if p != nil {
260+ p.pipeline = nil
261+ }
262+ }
263+}
264+
265+// readColor finds the planes' SDR to PQ pipelines, once at output creation.
266+func (o *Output) readColor() {
267+ for _, p := range [...]*plane{o.primary, o.overlay} {
268+ if p == nil || !p.hasColor() {
269+ continue
270+ }
271+ cp, why := readColorPipeline(o.k, p)
272+ p.pipeline = cp
273+ o.log.Info().Str("component", "drm").Str("connector", o.conn.name).Uint32("plane", p.id).Bool("matched", cp != nil).Str("reason", why).Msg("plane colour pipeline")
274+ }
275+}
276+
277+// colorUsed reports whether a frame with primary colour pc and overlay ov
278+// has a plane show the pipeline.
279+func (o *Output) colorUsed(pc colorUse, ov overlayWin) bool {
280+ return pc.mode != colorBypass || ov.buf != 0 && ov.color.mode != colorBypass
281+}
@@ -0,0 +1,281 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "os"
6+
7+ "golang.org/x/sys/unix"
8+)
9+
10+// The output side of the plane colour pipelines: per-plane state, commit
11+// properties, refusal caching. The pure part (reading and matching
12+// pipelines, the maths, planeColor) is in colorop_match.go.
13+
14+// hasColor reports whether the plane has a COLOR_PIPELINE property.
15+func (p *plane) hasColor() bool { return p != nil && p.props[propColorPipeline] != 0 }
16+
17+// colorStale reports whether mode must be written: the plane applied
18+// another one, or nobody knows. A plane without a matched pipeline never
19+// leaves Bypass (modesets and Close write it), so it is never stale.
20+func (p *plane) colorStale(mode colorMode) bool {
21+ return p.hasColor() && p.pipeline != nil && (!p.colorKnown || p.colorApplied != mode)
22+}
23+
24+// colorProps puts mode on p: Bypass selects the plane's Bypass pipeline;
25+// colorSDRToPQ selects the matched one with its four operations enabled and
26+// every other one bypassed. mult is the S31.32 multiplier and ctm the matrix
27+// blob.
28+func (p *plane) colorProps(req *atomicReq, mode colorMode, mult uint64, ctm uint32) {
29+ if !p.hasColor() {
30+ return
31+ }
32+ prop := p.props[propColorPipeline]
33+ cp := p.pipeline
34+ if mode != colorSDRToPQ || cp == nil {
35+ req.set(p.id, prop, 0)
36+ return
37+ }
38+ req.set(p.id, prop, cp.id)
39+ for _, s := range cp.others {
40+ req.set(s.op, s.bypass, 1)
41+ }
42+ for _, s := range [...]struct {
43+ stage colorStage
44+ val uint64
45+ }{{cp.srgb, cp.srgb.curve}, {cp.mult, mult}, {cp.ctm, uint64(ctm)}, {cp.pq, cp.pq.curve}} {
46+ req.set(s.stage.op, s.stage.bypass, 0)
47+ req.set(s.stage.op, s.stage.prop, s.val)
48+ }
49+}
50+
51+// colorVerdict is KMS's answer to a TEST_ONLY with the pipeline on a plane,
52+// kept per buffer format and cursor state.
53+type colorVerdict struct {
54+ format uint32
55+ cursor bool
56+ ok bool
57+}
58+
59+// verdict returns a cached answer.
60+func (p *plane) verdict(format uint32, cursor bool) (ok, known bool) {
61+ for _, v := range p.verdicts {
62+ if v.format == format && v.cursor == cursor {
63+ return v.ok, true
64+ }
65+ }
66+ return false, false
67+}
68+
69+// setVerdict caches KMS's answer for the pipeline on p.
70+func (p *plane) setVerdict(format uint32, cursor, ok bool) {
71+ for i, v := range p.verdicts {
72+ if v.format == format && v.cursor == cursor {
73+ p.verdicts[i].ok = ok
74+ return
75+ }
76+ }
77+ p.verdicts = append(p.verdicts, colorVerdict{format: format, cursor: cursor, ok: ok})
78+}
79+
80+// cursorShown reports whether a frame committed now shows the hardware cursor.
81+func (o *Output) cursorShown() bool { return o.cursor != nil && !o.protected && o.cursor.desired().on }
82+
83+// primaryColorProps puts mode on the primary plane if it applies another one.
84+func (o *Output) primaryColorProps(req *atomicReq, mode colorMode) {
85+ if o.primary.colorStale(mode) {
86+ o.primary.colorProps(req, mode, o.colorMult, o.ctmBlob)
87+ }
88+}
89+
90+// 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 a
92+// known state (modeset, close).
93+func (o *Output) forceBypass(req *atomicReq) {
94+ o.primary.colorProps(req, colorBypass, 0, 0)
95+ o.overlay.colorProps(req, colorBypass, 0, 0)
96+}
97+
98+// overlayColorProps puts mode on the overlay plane if it applies another one.
99+func (o *Output) overlayColorProps(req *atomicReq, mode colorMode) {
100+ if o.overlay.colorStale(mode) {
101+ o.overlay.colorProps(req, mode, o.colorMult, o.ctmBlob)
102+ }
103+}
104+
105+// colorBypassed records that a commit left both planes on Bypass.
106+func (o *Output) colorBypassed() {
107+ for _, p := range [...]*plane{o.primary, o.overlay} {
108+ if p != nil {
109+ p.colorApplied, p.colorKnown = colorBypass, true
110+ }
111+ }
112+}
113+
114+// forgetColor drops what is known about the planes' colour state: the next
115+// frame commit writes it. Every modeset (VT resume and recovery included)
116+// calls it, since the planes' state is then not ours to assume. The async
117+// probe follows a modeset and needs no call of its own.
118+func (o *Output) forgetColor() {
119+ for _, p := range [...]*plane{o.primary, o.overlay} {
120+ if p != nil {
121+ p.colorKnown = false
122+ }
123+ }
124+}
125+
126+// forgetColorVerdicts drops the cached refusals: after a modeset the driver
127+// is asked again.
128+func (o *Output) forgetColorVerdicts() {
129+ for _, p := range [...]*plane{o.primary, o.overlay} {
130+ if p != nil {
131+ p.verdicts = p.verdicts[:0]
132+ }
133+ }
134+}
135+
136+// colorStaleFor reports whether a frame commit with primary colour pc and
137+// overlay ov writes colour state: such a commit cannot be async.
138+func (o *Output) colorStaleFor(pc colorUse, ov overlayWin) bool {
139+ return o.primary.colorStale(pc.mode) || o.overlay.colorStale(ov.color.mode)
140+}
141+
142+// colorCommitted records the colour state a successful synchronous frame
143+// commit left.
144+func (o *Output) colorCommitted(pc colorUse, ov overlayWin) {
145+ if o.primary.hasColor() {
146+ o.primary.colorApplied, o.primary.colorKnown, o.primary.colorFormat = pc.mode, true, pc.format
147+ }
148+ if o.overlay.hasColor() {
149+ o.overlay.colorApplied, o.overlay.colorKnown, o.overlay.colorFormat = ov.color.mode, true, ov.color.format
150+ }
151+}
152+
153+// errColorRefused: KMS refused a frame with a plane colour pipeline that it
154+// takes with Bypass. The caller composes the frame.
155+var errColorRefused = errors.New("plane colour pipeline refused")
156+
157+// colorRefused handles a frame commit that failed with EINVAL while a plane
158+// showed the pipeline: when the same frame passes TEST_ONLY with Bypass, and
159+// differs from the commit in colour alone, the pipeline is the cause. The refusal is cached for each plane that showed it,
160+// its buffer format and the cursor state, and the frame is not retried.
161+func (o *Output) colorRefused(err error, fb uint32, fence *os.File, vrr bool, cur cursorState, ov overlayWin, rect planeRect, pc colorUse) bool {
162+ if !o.colorUsed(pc, ov) {
163+ return false
164+ }
165+ bypassed := ov
166+ bypassed.color = colorUse{}
167+ // The same frame as the failed commit, content hint included, with
168+ // Bypass as the only difference: if the hint was the cause, this fails
169+ // too and the pipeline is not blamed.
170+ if !o.frameTest(fb, fence, vrr, cur, bypassed, rect, colorUse{}, true) {
171+ return false
172+ }
173+ if pc.mode != colorBypass {
174+ o.primary.setVerdict(pc.format, cur.on, false)
175+ }
176+ if ov.buf != 0 && ov.color.mode != colorBypass {
177+ o.overlay.setVerdict(ov.color.format, cur.on, false)
178+ }
179+ o.log.Info().Str("component", "render").Err(err).Str("connector", o.conn.name).Bool("cursor", cur.on).Msg("plane colour pipeline refused")
180+ return true
181+}
182+
183+// colorConflict handles a commit without a frame, which turned the cursor
184+// on or moved it, refused while a plane applies the pipeline: KMS may refuse
185+// that combination although it took each alone. The pipeline is refused for
186+// that buffer format with the cursor shown, so the next frame is composed
187+// (the cursor then commits with it). It reports whether it did.
188+func (o *Output) colorConflict(err error, cursorOn bool) bool {
189+ if !cursorOn || !errors.Is(err, unix.EINVAL) {
190+ return false
191+ }
192+ hit := false
193+ for _, p := range [...]*plane{o.primary, o.overlay} {
194+ if p.hasColor() && p.colorKnown && p.colorApplied == colorSDRToPQ {
195+ p.setVerdict(p.colorFormat, true, false)
196+ hit = true
197+ }
198+ }
199+ if hit {
200+ o.log.Info().Str("component", "render").Err(err).Str("connector", o.conn.name).Msg("plane colour pipeline refused with the cursor")
201+ }
202+ return hit
203+}
204+
205+// colorAllowed tests, once per plane, buffer format and cursor state, that
206+// KMS takes the SDR to PQ pipeline on p for the request test builds (the frame
207+// as it would commit). A refusal is cached: the buffer is composed from then
208+// on, never tested each frame.
209+func (o *Output) colorAllowed(p *plane, format uint32, test func(req *atomicReq)) bool {
210+ cursor := o.cursorShown()
211+ if ok, known := p.verdict(format, cursor); known {
212+ return ok
213+ }
214+ req := &o.probeReq
215+ req.reset()
216+ test(req)
217+ p.colorProps(req, colorSDRToPQ, o.colorMult, o.ctmBlob)
218+ if cursor {
219+ o.cursor.props(req, o.crtc, o.cursor.desired())
220+ }
221+ err := o.k.commit(req, atomicTestOnly, 0)
222+ if err != nil && !refused(err) {
223+ // Not an answer about the pipeline (e.g. master lost): ask again.
224+ return false
225+ }
226+ p.setVerdict(format, cursor, err == nil)
227+ if err != nil {
228+ o.log.Info().Str("component", "render").Err(err).Uint32("format", format).Uint32("plane", p.id).Bool("cursor", cursor).Str("connector", o.conn.name).Msg("plane colour pipeline refused")
229+ }
230+ return err == nil
231+}
232+
233+// setupColor makes the output's colour objects: the CTM blob and the
234+// multiplier, when HDR can be on and a plane has a matched pipeline. A
235+// failure drops the pipelines: SDR content is composed as before.
236+func (o *Output) setupColor() {
237+ if !o.hdr.wanted() {
238+ // The pipelines serve HDR outputs only: SDR stays on Bypass.
239+ o.dropPipelines()
240+ return
241+ }
242+ if o.primary.pipeline == nil && (o.overlay == nil || o.overlay.pipeline == nil) {
243+ return
244+ }
245+ blob, err := o.k.createBlob(ctmBytes(bt709ToBT2020))
246+ if err != nil {
247+ o.log.Warn().Str("component", "drm").Err(err).Str("connector", o.conn.name).Msg("colour matrix blob; plane colour pipelines off")
248+ o.dropPipelines()
249+ return
250+ }
251+ o.ctmBlob = blob
252+ o.colorMult = s3132(float64(o.hdr.settings.SDRBrightness) / 80)
253+}
254+
255+// dropPipelines leaves the output's planes without a pipeline: SDR content
256+// is composed.
257+func (o *Output) dropPipelines() {
258+ for _, p := range [...]*plane{o.primary, o.overlay} {
259+ if p != nil {
260+ p.pipeline = nil
261+ }
262+ }
263+}
264+
265+// readColor finds the planes' SDR to PQ pipelines, once at output creation.
266+func (o *Output) readColor() {
267+ for _, p := range [...]*plane{o.primary, o.overlay} {
268+ if p == nil || !p.hasColor() {
269+ continue
270+ }
271+ cp, why := readColorPipeline(o.k, p)
272+ p.pipeline = cp
273+ o.log.Info().Str("component", "drm").Str("connector", o.conn.name).Uint32("plane", p.id).Bool("matched", cp != nil).Str("reason", why).Msg("plane colour pipeline")
274+ }
275+}
276+
277+// colorUsed reports whether a frame with primary colour pc and overlay ov
278+// has a plane show the pipeline.
279+func (o *Output) colorUsed(pc colorUse, ov overlayWin) bool {
280+ return pc.mode != colorBypass || ov.buf != 0 && ov.color.mode != colorBypass
281+}
A internal/adapters/drm/colorop_match.go +285 -0
@@ -0,0 +1,285 @@
1+package drm
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "math"
7+ "slices"
8+ "strings"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// Plane colour pipelines (DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE).
14+//
15+// On an HDR10 output the composed image is PQ-encoded BT.2020. A plain sRGB
16+// client buffer can still reach the screen without composition when the
17+// plane's colour pipeline does what the Vulkan HDR path does to it:
18+//
19+// sRGB EOTF -> x SDRBrightness/80 -> BT.709 to BT.2020 -> PQ inverse EOTF
20+//
21+// (PQ 125: 1.0 is 80 nits). The kernel exposes pipelines as chains of colorop
22+// objects. The matcher below is driver-agnostic: it looks for these four
23+// operations in this order and needs every other operation to be bypassable.
24+
25+// colorMode is what a plane's colour pipeline does to the buffer it shows.
26+type colorMode uint8
27+
28+const (
29+ // colorBypass is the plane's COLOR_PIPELINE "Bypass": pixels go as they are.
30+ colorBypass colorMode = iota
31+ // colorSDRToPQ turns sRGB pixels into the output's PQ BT.2020 signal.
32+ colorSDRToPQ
33+)
34+
35+// colorUse is a plane's colour mode with the format of the buffer it
36+// applies to (refusals are cached per format).
37+type colorUse struct {
38+ mode colorMode
39+ format uint32
40+}
41+
42+// Plane and colorop property names and the enum names the matcher looks for.
43+// Enum values are never hardcoded: they come from the property.
44+const (
45+ propColorPipeline = "COLOR_PIPELINE"
46+
47+ colorTypeCurve = "1D Curve"
48+ colorTypeMultiplier = "Multiplier"
49+ colorTypeMatrix = "3x4 Matrix"
50+ curveSRGBEOTF = "sRGB EOTF"
51+ curvePQInverseEOTF = "PQ 125 Inverse EOTF"
52+)
53+
54+// colorop is one operation of a pipeline as read from KMS: property IDs by
55+// role (0: the operation has none) and the enum of its curve.
56+type colorop struct {
57+ id uint32
58+ typ string
59+ bypass, curveProp, multiplier, data uint32
60+ curves map[string]uint64
61+}
62+
63+// colorStage is an operation the output drives: its object, BYPASS and the
64+// property that configures it (CURVE_1D_TYPE, MULTIPLIER or DATA).
65+type colorStage struct {
66+ op, bypass, prop uint32
67+ // curve is the CURVE_1D_TYPE enum value of a curve stage.
68+ curve uint64
69+}
70+
71+// colorPipeline is the plane's pipeline for SDR content on an HDR output.
72+type colorPipeline struct {
73+ // id is the COLOR_PIPELINE value: the pipeline's first colorop.
74+ id uint64
75+ // srgb, mult, ctm and pq are the four stages in pipeline order; others
76+ // are every other operation, which must stay bypassed.
77+ srgb, mult, ctm, pq colorStage
78+ others []colorStage
79+}
80+
81+// is reports whether the operation is the wanted type, with the wanted curve.
82+func (o colorop) is(typ, curve string) bool {
83+ if o.typ != typ {
84+ return false
85+ }
86+ switch typ {
87+ case colorTypeCurve:
88+ _, ok := o.curves[curve]
89+ return o.curveProp != 0 && ok
90+ case colorTypeMultiplier:
91+ return o.multiplier != 0
92+ case colorTypeMatrix:
93+ return o.data != 0
94+ }
95+ return false
96+}
97+
98+// matchColorPipeline looks in ops, the pipeline starting at colorop id first,
99+// for a 1D curve with sRGB EOTF, a multiplier, a 3x4 matrix and a 1D curve
100+// with PQ 125 inverse EOTF, in this order. Any other operation must be
101+// bypassable. The reason says why a pipeline does not match.
102+func matchColorPipeline(first uint64, ops []colorop) (*colorPipeline, string) {
103+ want := [4]struct{ typ, curve string }{
104+ {colorTypeCurve, curveSRGBEOTF}, {colorTypeMultiplier, ""}, {colorTypeMatrix, ""}, {colorTypeCurve, curvePQInverseEOTF},
105+ }
106+ cp := &colorPipeline{id: first}
107+ stages := [4]*colorStage{&cp.srgb, &cp.mult, &cp.ctm, &cp.pq}
108+ n := 0
109+ for _, op := range ops {
110+ if n < len(want) && op.is(want[n].typ, want[n].curve) {
111+ s := stages[n]
112+ s.op, s.bypass = op.id, op.bypass
113+ switch n {
114+ case 0:
115+ s.prop, s.curve = op.curveProp, op.curves[curveSRGBEOTF]
116+ case 1:
117+ s.prop = op.multiplier
118+ case 2:
119+ s.prop = op.data
120+ case 3:
121+ s.prop, s.curve = op.curveProp, op.curves[curvePQInverseEOTF]
122+ }
123+ n++
124+ continue
125+ }
126+ if op.bypass == 0 {
127+ return nil, fmt.Sprintf("colorop %d (%s) cannot be bypassed", op.id, op.typ)
128+ }
129+ cp.others = append(cp.others, colorStage{op: op.id, bypass: op.bypass})
130+ }
131+ if n < len(want) {
132+ return nil, fmt.Sprintf("no %s -> %s -> %s -> %s chain", want[0].curve, want[1].typ, want[2].typ, want[3].curve)
133+ }
134+ return cp, ""
135+}
136+
137+// maxColorops bounds a pipeline walk: a longer chain is a cycle or garbage.
138+const maxColorops = 32
139+
140+// walkColorops reads the pipeline starting at colorop first, following NEXT.
141+func walkColorops(k kms, first uint32) ([]colorop, error) {
142+ var ops []colorop
143+ for id := first; id != 0; {
144+ if len(ops) >= maxColorops {
145+ return nil, fmt.Errorf("colorop chain longer than %d", maxColorops)
146+ }
147+ props, err := k.objProps(id, objColorop)
148+ if err != nil {
149+ return nil, fmt.Errorf("colorop %d: %w", id, err)
150+ }
151+ op := colorop{id: id, bypass: uint32(props["BYPASS"][0]), curveProp: uint32(props["CURVE_1D_TYPE"][0]), multiplier: uint32(props["MULTIPLIER"][0]), data: uint32(props["DATA"][0])}
152+ if t := props["TYPE"]; t[0] != 0 {
153+ types, err := k.propEnums(uint32(t[0]))
154+ if err != nil {
155+ return nil, fmt.Errorf("colorop %d type: %w", id, err)
156+ }
157+ for name, v := range types {
158+ if v == t[1] {
159+ op.typ = name
160+ }
161+ }
162+ }
163+ if op.curveProp != 0 {
164+ if op.curves, err = k.propEnums(op.curveProp); err != nil {
165+ return nil, fmt.Errorf("colorop %d curves: %w", id, err)
166+ }
167+ }
168+ ops = append(ops, op)
169+ id = uint32(props["NEXT"][1])
170+ }
171+ return ops, nil
172+}
173+
174+// readColorPipeline returns the pipeline of p that does SDR to PQ, or why
175+// it has none. Call it once per plane (read at output creation).
176+func readColorPipeline(k kms, p *plane) (*colorPipeline, string) {
177+ prop := p.props[propColorPipeline]
178+ if prop == 0 {
179+ return nil, "no " + propColorPipeline + " property"
180+ }
181+ enums, err := k.propEnums(prop)
182+ if err != nil {
183+ return nil, err.Error()
184+ }
185+ // Name order is not stable: try the pipelines by colorop ID.
186+ var firsts []uint64
187+ for _, v := range enums {
188+ if v != 0 { // 0 is Bypass
189+ firsts = append(firsts, v)
190+ }
191+ }
192+ slices.Sort(firsts)
193+ var why []string
194+ for _, first := range firsts {
195+ ops, err := walkColorops(k, uint32(first))
196+ if err == nil {
197+ var cp *colorPipeline
198+ var reason string
199+ if cp, reason = matchColorPipeline(first, ops); cp != nil {
200+ return cp, ""
201+ }
202+ err = fmt.Errorf("%s", reason)
203+ }
204+ why = append(why, fmt.Sprintf("pipeline %d: %v", first, err))
205+ }
206+ if len(why) == 0 {
207+ return nil, "only Bypass"
208+ }
209+ return nil, strings.Join(why, "; ")
210+}
211+
212+// bt709ToBT2020 converts linear BT.709 to BT.2020 primaries, row-major. It is
213+// the mat3 of vulkan/shaders/hdr.frag, which GLSL writes column by column.
214+var bt709ToBT2020 = [3][3]float64{
215+ {0.627404, 0.329283, 0.043313},
216+ {0.069097, 0.919540, 0.011362},
217+ {0.016391, 0.088013, 0.895595},
218+}
219+
220+// s3132 encodes v as the S31.32 sign-magnitude fixed point of KMS colorops
221+// (not two's complement): bit 63 is the sign, the rest the magnitude.
222+func s3132(v float64) uint64 {
223+ m := uint64(math.Round(math.Abs(v) * (1 << 32)))
224+ if m > 1<<63-1 {
225+ m = 1<<63 - 1
226+ }
227+ if v < 0 {
228+ m |= 1 << 63
229+ }
230+ return m
231+}
232+
233+// ctmBytes is the DATA blob of a 3x4 matrix colorop (struct
234+// drm_color_ctm_3x4): 12 S31.32 values, row-major; the fourth column, an
235+// offset, is zero.
236+func ctmBytes(m [3][3]float64) []byte {
237+ b := make([]byte, 0, 12*8)
238+ for _, row := range m {
239+ for _, v := range row {
240+ b = binary.LittleEndian.AppendUint64(b, s3132(v))
241+ }
242+ b = binary.LittleEndian.AppendUint64(b, 0)
243+ }
244+ return b
245+}
246+
247+// planeColor decides how plane (with pipeline, nil: none) shows a client
248+// buffer of format whose content is c, on an output in HDR10 mode when hdrOn.
249+// opaque is the surface's Opaque. A buffer already in the output's encoding
250+// goes as it is; sRGB goes through the pipeline; anything else gets the reason
251+// it is composed.
252+func planeColor(c ports.SurfaceColor, format uint32, opaque, hdrOn bool, pipeline *colorPipeline) (colorMode, string) {
253+ switch {
254+ case isYUVFormat(format):
255+ // With the colour pipeline cap on, the kernel rejects the plane's
256+ // COLOR_ENCODING and COLOR_RANGE: a YUV buffer cannot be scanned
257+ // out whatever the output, and is composed.
258+ return colorBypass, "yuv"
259+ case !hdrOn:
260+ // An SDR connector must never show raw PQ values.
261+ if c.IsPQ2020() {
262+ return colorBypass, "sdr_pq_content"
263+ }
264+ return colorBypass, ""
265+ case c.IsPQ2020():
266+ if !isTenBit(format) {
267+ return colorBypass, "hdr_format"
268+ }
269+ return colorBypass, ""
270+ case pipeline == nil || !opaque || !isSDRColor(c):
271+ // The kernel applies the pipeline to premultiplied pixels: exact
272+ // only without alpha.
273+ return colorBypass, "hdr_sdr_content"
274+ }
275+ return colorSDRToPQ, ""
276+}
277+
278+// isSDRColor reports whether the surface is sRGB, set or unspecified: what the
279+// Vulkan path decodes with decodeSRGB. Extended linear and PQ are other encodings.
280+func isSDRColor(c ports.SurfaceColor) bool {
281+ if c.IsPQ2020() || c.IsExtendedLinear() {
282+ return false
283+ }
284+ return (c.TF == 0 || c.TF == ports.ColorTFSRGB) && (c.Primaries == 0 || c.Primaries == ports.ColorPrimariesSRGB)
285+}
@@ -0,0 +1,285 @@
1+package drm
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "math"
7+ "slices"
8+ "strings"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// Plane colour pipelines (DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE).
14+//
15+// On an HDR10 output the composed image is PQ-encoded BT.2020. A plain sRGB
16+// client buffer can still reach the screen without composition when the
17+// plane's colour pipeline does what the Vulkan HDR path does to it:
18+//
19+// sRGB EOTF -> x SDRBrightness/80 -> BT.709 to BT.2020 -> PQ inverse EOTF
20+//
21+// (PQ 125: 1.0 is 80 nits). The kernel exposes pipelines as chains of colorop
22+// objects. The matcher below is driver-agnostic: it looks for these four
23+// operations in this order and needs every other operation to be bypassable.
24+
25+// colorMode is what a plane's colour pipeline does to the buffer it shows.
26+type colorMode uint8
27+
28+const (
29+ // colorBypass is the plane's COLOR_PIPELINE "Bypass": pixels go as they are.
30+ colorBypass colorMode = iota
31+ // colorSDRToPQ turns sRGB pixels into the output's PQ BT.2020 signal.
32+ colorSDRToPQ
33+)
34+
35+// colorUse is a plane's colour mode with the format of the buffer it
36+// applies to (refusals are cached per format).
37+type colorUse struct {
38+ mode colorMode
39+ format uint32
40+}
41+
42+// Plane and colorop property names and the enum names the matcher looks for.
43+// Enum values are never hardcoded: they come from the property.
44+const (
45+ propColorPipeline = "COLOR_PIPELINE"
46+
47+ colorTypeCurve = "1D Curve"
48+ colorTypeMultiplier = "Multiplier"
49+ colorTypeMatrix = "3x4 Matrix"
50+ curveSRGBEOTF = "sRGB EOTF"
51+ curvePQInverseEOTF = "PQ 125 Inverse EOTF"
52+)
53+
54+// colorop is one operation of a pipeline as read from KMS: property IDs by
55+// role (0: the operation has none) and the enum of its curve.
56+type colorop struct {
57+ id uint32
58+ typ string
59+ bypass, curveProp, multiplier, data uint32
60+ curves map[string]uint64
61+}
62+
63+// colorStage is an operation the output drives: its object, BYPASS and the
64+// property that configures it (CURVE_1D_TYPE, MULTIPLIER or DATA).
65+type colorStage struct {
66+ op, bypass, prop uint32
67+ // curve is the CURVE_1D_TYPE enum value of a curve stage.
68+ curve uint64
69+}
70+
71+// colorPipeline is the plane's pipeline for SDR content on an HDR output.
72+type colorPipeline struct {
73+ // id is the COLOR_PIPELINE value: the pipeline's first colorop.
74+ id uint64
75+ // srgb, mult, ctm and pq are the four stages in pipeline order; others
76+ // are every other operation, which must stay bypassed.
77+ srgb, mult, ctm, pq colorStage
78+ others []colorStage
79+}
80+
81+// is reports whether the operation is the wanted type, with the wanted curve.
82+func (o colorop) is(typ, curve string) bool {
83+ if o.typ != typ {
84+ return false
85+ }
86+ switch typ {
87+ case colorTypeCurve:
88+ _, ok := o.curves[curve]
89+ return o.curveProp != 0 && ok
90+ case colorTypeMultiplier:
91+ return o.multiplier != 0
92+ case colorTypeMatrix:
93+ return o.data != 0
94+ }
95+ return false
96+}
97+
98+// matchColorPipeline looks in ops, the pipeline starting at colorop id first,
99+// for a 1D curve with sRGB EOTF, a multiplier, a 3x4 matrix and a 1D curve
100+// with PQ 125 inverse EOTF, in this order. Any other operation must be
101+// bypassable. The reason says why a pipeline does not match.
102+func matchColorPipeline(first uint64, ops []colorop) (*colorPipeline, string) {
103+ want := [4]struct{ typ, curve string }{
104+ {colorTypeCurve, curveSRGBEOTF}, {colorTypeMultiplier, ""}, {colorTypeMatrix, ""}, {colorTypeCurve, curvePQInverseEOTF},
105+ }
106+ cp := &colorPipeline{id: first}
107+ stages := [4]*colorStage{&cp.srgb, &cp.mult, &cp.ctm, &cp.pq}
108+ n := 0
109+ for _, op := range ops {
110+ if n < len(want) && op.is(want[n].typ, want[n].curve) {
111+ s := stages[n]
112+ s.op, s.bypass = op.id, op.bypass
113+ switch n {
114+ case 0:
115+ s.prop, s.curve = op.curveProp, op.curves[curveSRGBEOTF]
116+ case 1:
117+ s.prop = op.multiplier
118+ case 2:
119+ s.prop = op.data
120+ case 3:
121+ s.prop, s.curve = op.curveProp, op.curves[curvePQInverseEOTF]
122+ }
123+ n++
124+ continue
125+ }
126+ if op.bypass == 0 {
127+ return nil, fmt.Sprintf("colorop %d (%s) cannot be bypassed", op.id, op.typ)
128+ }
129+ cp.others = append(cp.others, colorStage{op: op.id, bypass: op.bypass})
130+ }
131+ if n < len(want) {
132+ return nil, fmt.Sprintf("no %s -> %s -> %s -> %s chain", want[0].curve, want[1].typ, want[2].typ, want[3].curve)
133+ }
134+ return cp, ""
135+}
136+
137+// maxColorops bounds a pipeline walk: a longer chain is a cycle or garbage.
138+const maxColorops = 32
139+
140+// walkColorops reads the pipeline starting at colorop first, following NEXT.
141+func walkColorops(k kms, first uint32) ([]colorop, error) {
142+ var ops []colorop
143+ for id := first; id != 0; {
144+ if len(ops) >= maxColorops {
145+ return nil, fmt.Errorf("colorop chain longer than %d", maxColorops)
146+ }
147+ props, err := k.objProps(id, objColorop)
148+ if err != nil {
149+ return nil, fmt.Errorf("colorop %d: %w", id, err)
150+ }
151+ op := colorop{id: id, bypass: uint32(props["BYPASS"][0]), curveProp: uint32(props["CURVE_1D_TYPE"][0]), multiplier: uint32(props["MULTIPLIER"][0]), data: uint32(props["DATA"][0])}
152+ if t := props["TYPE"]; t[0] != 0 {
153+ types, err := k.propEnums(uint32(t[0]))
154+ if err != nil {
155+ return nil, fmt.Errorf("colorop %d type: %w", id, err)
156+ }
157+ for name, v := range types {
158+ if v == t[1] {
159+ op.typ = name
160+ }
161+ }
162+ }
163+ if op.curveProp != 0 {
164+ if op.curves, err = k.propEnums(op.curveProp); err != nil {
165+ return nil, fmt.Errorf("colorop %d curves: %w", id, err)
166+ }
167+ }
168+ ops = append(ops, op)
169+ id = uint32(props["NEXT"][1])
170+ }
171+ return ops, nil
172+}
173+
174+// readColorPipeline returns the pipeline of p that does SDR to PQ, or why
175+// it has none. Call it once per plane (read at output creation).
176+func readColorPipeline(k kms, p *plane) (*colorPipeline, string) {
177+ prop := p.props[propColorPipeline]
178+ if prop == 0 {
179+ return nil, "no " + propColorPipeline + " property"
180+ }
181+ enums, err := k.propEnums(prop)
182+ if err != nil {
183+ return nil, err.Error()
184+ }
185+ // Name order is not stable: try the pipelines by colorop ID.
186+ var firsts []uint64
187+ for _, v := range enums {
188+ if v != 0 { // 0 is Bypass
189+ firsts = append(firsts, v)
190+ }
191+ }
192+ slices.Sort(firsts)
193+ var why []string
194+ for _, first := range firsts {
195+ ops, err := walkColorops(k, uint32(first))
196+ if err == nil {
197+ var cp *colorPipeline
198+ var reason string
199+ if cp, reason = matchColorPipeline(first, ops); cp != nil {
200+ return cp, ""
201+ }
202+ err = fmt.Errorf("%s", reason)
203+ }
204+ why = append(why, fmt.Sprintf("pipeline %d: %v", first, err))
205+ }
206+ if len(why) == 0 {
207+ return nil, "only Bypass"
208+ }
209+ return nil, strings.Join(why, "; ")
210+}
211+
212+// bt709ToBT2020 converts linear BT.709 to BT.2020 primaries, row-major. It is
213+// the mat3 of vulkan/shaders/hdr.frag, which GLSL writes column by column.
214+var bt709ToBT2020 = [3][3]float64{
215+ {0.627404, 0.329283, 0.043313},
216+ {0.069097, 0.919540, 0.011362},
217+ {0.016391, 0.088013, 0.895595},
218+}
219+
220+// s3132 encodes v as the S31.32 sign-magnitude fixed point of KMS colorops
221+// (not two's complement): bit 63 is the sign, the rest the magnitude.
222+func s3132(v float64) uint64 {
223+ m := uint64(math.Round(math.Abs(v) * (1 << 32)))
224+ if m > 1<<63-1 {
225+ m = 1<<63 - 1
226+ }
227+ if v < 0 {
228+ m |= 1 << 63
229+ }
230+ return m
231+}
232+
233+// ctmBytes is the DATA blob of a 3x4 matrix colorop (struct
234+// drm_color_ctm_3x4): 12 S31.32 values, row-major; the fourth column, an
235+// offset, is zero.
236+func ctmBytes(m [3][3]float64) []byte {
237+ b := make([]byte, 0, 12*8)
238+ for _, row := range m {
239+ for _, v := range row {
240+ b = binary.LittleEndian.AppendUint64(b, s3132(v))
241+ }
242+ b = binary.LittleEndian.AppendUint64(b, 0)
243+ }
244+ return b
245+}
246+
247+// planeColor decides how plane (with pipeline, nil: none) shows a client
248+// buffer of format whose content is c, on an output in HDR10 mode when hdrOn.
249+// opaque is the surface's Opaque. A buffer already in the output's encoding
250+// goes as it is; sRGB goes through the pipeline; anything else gets the reason
251+// it is composed.
252+func planeColor(c ports.SurfaceColor, format uint32, opaque, hdrOn bool, pipeline *colorPipeline) (colorMode, string) {
253+ switch {
254+ case isYUVFormat(format):
255+ // With the colour pipeline cap on, the kernel rejects the plane's
256+ // COLOR_ENCODING and COLOR_RANGE: a YUV buffer cannot be scanned
257+ // out whatever the output, and is composed.
258+ return colorBypass, "yuv"
259+ case !hdrOn:
260+ // An SDR connector must never show raw PQ values.
261+ if c.IsPQ2020() {
262+ return colorBypass, "sdr_pq_content"
263+ }
264+ return colorBypass, ""
265+ case c.IsPQ2020():
266+ if !isTenBit(format) {
267+ return colorBypass, "hdr_format"
268+ }
269+ return colorBypass, ""
270+ case pipeline == nil || !opaque || !isSDRColor(c):
271+ // The kernel applies the pipeline to premultiplied pixels: exact
272+ // only without alpha.
273+ return colorBypass, "hdr_sdr_content"
274+ }
275+ return colorSDRToPQ, ""
276+}
277+
278+// isSDRColor reports whether the surface is sRGB, set or unspecified: what the
279+// Vulkan path decodes with decodeSRGB. Extended linear and PQ are other encodings.
280+func isSDRColor(c ports.SurfaceColor) bool {
281+ if c.IsPQ2020() || c.IsExtendedLinear() {
282+ return false
283+ }
284+ return (c.TF == 0 || c.TF == ports.ColorTFSRGB) && (c.Primaries == 0 || c.Primaries == ports.ColorPrimariesSRGB)
285+}
A internal/adapters/drm/colorop_test.go +874 -0
@@ -0,0 +1,874 @@
1+package drm
2+
3+import (
4+ "encoding/binary"
5+ "math"
6+ "os"
7+ "regexp"
8+ "slices"
9+ "strconv"
10+ "strings"
11+ "testing"
12+
13+ "github.com/bnema/neferwl/internal/ports"
14+ "github.com/stretchr/testify/mock"
15+ "golang.org/x/sys/unix"
16+)
17+
18+// Plane COLOR_PIPELINE property IDs of the primary and overlay test planes,
19+// and the CRTC DEGAMMA_LUT.
20+const (
21+ pColorPipe, pColorPipeOverlay, pDegamma = 60, 61, 62
22+ opBase = 200 // first colorop ID of a plane; a plane's ops use 20 IDs
23+)
24+
25+// Property IDs of a colorop, by role: kernel properties are per object.
26+func bypassProp(op uint32) uint32 { return 1000 + op }
27+func curveProp(op uint32) uint32 { return 2000 + op }
28+func multProp(op uint32) uint32 { return 3000 + op }
29+func dataProp(op uint32) uint32 { return 4000 + op }
30+func typeProp(op uint32) uint32 { return 5000 + op }
31+
32+// The enum values are arbitrary: the matcher must read names.
33+var (
34+ colorTypes = map[string]uint64{"1D Curve": 9, "1D LUT": 4, "3x4 Matrix": 2, "Multiplier": 6, "3D LUT": 1}
35+ firstCurve = map[string]uint64{"sRGB EOTF": 5, "PQ 125 EOTF": 6, "BT.2020 Inverse OETF": 3, "Gamma 2.2": 1}
36+ lastCurve = map[string]uint64{"sRGB Inverse EOTF": 0, "PQ 125 Inverse EOTF": 8, "BT.2020 OETF": 2, "Gamma 2.2 Inverse": 7}
37+)
38+
39+// opSpec is a colorop of a test pipeline.
40+type opSpec struct {
41+ typ string
42+ curves map[string]uint64
43+ noBypass bool
44+}
45+
46+func curveOp(curves map[string]uint64) opSpec { return opSpec{typ: "1D Curve", curves: curves} }
47+
48+var (
49+ opSRGB, opPQ = curveOp(firstCurve), curveOp(lastCurve)
50+ opMult = opSpec{typ: "Multiplier"}
51+ opCTM = opSpec{typ: "3x4 Matrix"}
52+ opLUT1D = opSpec{typ: "1D LUT"}
53+ opLUT3D = opSpec{typ: "3D LUT"}
54+ // amdgpuChain is the pipeline read on kernel 7.2 (DCN3+).
55+ amdgpuChain = []opSpec{opSRGB, opMult, opCTM, opPQ, opLUT1D, opLUT3D, curveOp(map[string]uint64{"sRGB EOTF": 5}), opLUT1D}
56+)
57+
58+// colorPlane gives p a COLOR_PIPELINE property (id pipeProp) offering the
59+// chains, each with ops from base up, and registers the kernel's answers.
60+// It returns the first colorop of each chain.
61+func colorPlane(k *mockkms, p *plane, pipeProp, base uint32, chains ...[]opSpec) []uint64 {
62+ p.props = maps0(p.props)
63+ p.props["COLOR_PIPELINE"] = pipeProp
64+ enums := map[string]uint64{"Bypass": 0}
65+ var firsts []uint64
66+ k.EXPECT().propEnums(uint32(typeProp(0))).Return(colorTypes, nil).Maybe()
67+ id := base
68+ for c, chain := range chains {
69+ firsts = append(firsts, uint64(id))
70+ enums["Pipeline "+strconv.Itoa(c)] = uint64(id)
71+ for i, op := range chain {
72+ props := map[string][2]uint64{
73+ "TYPE": {uint64(typeProp(0)), colorTypes[op.typ]},
74+ "NEXT": {1, 0},
75+ }
76+ if i+1 < len(chain) {
77+ props["NEXT"] = [2]uint64{1, uint64(id + 1)}
78+ }
79+ if !op.noBypass {
80+ props["BYPASS"] = [2]uint64{uint64(bypassProp(id)), 1}
81+ }
82+ switch op.typ {
83+ case "1D Curve":
84+ props["CURVE_1D_TYPE"] = [2]uint64{uint64(curveProp(id)), 0}
85+ k.EXPECT().propEnums(curveProp(id)).Return(op.curves, nil).Maybe()
86+ case "Multiplier":
87+ props["MULTIPLIER"] = [2]uint64{uint64(multProp(id)), 0}
88+ case "3x4 Matrix", "1D LUT", "3D LUT":
89+ props["DATA"] = [2]uint64{uint64(dataProp(id)), 0}
90+ }
91+ k.EXPECT().objProps(id, uint32(objColorop)).Return(props, nil).Maybe()
92+ id++
93+ }
94+ }
95+ k.EXPECT().propEnums(pipeProp).Return(enums, nil).Maybe()
96+ return firsts
97+}
98+
99+func maps0(m map[string]uint32) map[string]uint32 {
100+ out := make(map[string]uint32, len(m)+1)
101+ for k, v := range m {
102+ out[k] = v
103+ }
104+ return out
105+}
106+
107+func TestMatchColorPipeline(t *testing.T) {
108+ for _, tc := range []struct {
109+ name string
110+ chains [][]opSpec
111+ want int // index of the chain that matches, -1: none
112+ reason string // substring of the reason when none matches
113+ }{
114+ {"amdgpu", [][]opSpec{amdgpuChain}, 0, ""},
115+ {"minimal", [][]opSpec{{opSRGB, opMult, opCTM, opPQ}}, 0, ""},
116+ {"bypassable op before the chain", [][]opSpec{{opLUT1D, opSRGB, opMult, opCTM, opPQ}}, 0, ""},
117+ {"second pipeline matches", [][]opSpec{{opLUT1D}, amdgpuChain}, 1, ""},
118+ {"no pipeline", nil, -1, "only Bypass"},
119+ {"no PQ curve", [][]opSpec{{opSRGB, opMult, opCTM, curveOp(map[string]uint64{"sRGB Inverse EOTF": 0})}}, -1, "no sRGB EOTF"},
120+ {"no sRGB curve", [][]opSpec{{curveOp(map[string]uint64{"Gamma 2.2": 1}), opMult, opCTM, opPQ}}, -1, "no sRGB EOTF"},
121+ {"no multiplier", [][]opSpec{{opSRGB, opCTM, opPQ}}, -1, "no sRGB EOTF"},
122+ {"no matrix", [][]opSpec{{opSRGB, opMult, opPQ}}, -1, "no sRGB EOTF"},
123+ {"wrong order", [][]opSpec{{opSRGB, opCTM, opMult, opPQ}}, -1, "no sRGB EOTF"},
124+ {"PQ before sRGB", [][]opSpec{{opPQ, opMult, opCTM, opSRGB}}, -1, "no sRGB EOTF"},
125+ {"op without BYPASS", [][]opSpec{{opSRGB, opMult, opCTM, opPQ, {typ: "1D LUT", noBypass: true}}}, -1, "cannot be bypassed"},
126+ {"op without BYPASS first", [][]opSpec{{{typ: "3D LUT", noBypass: true}, opSRGB, opMult, opCTM, opPQ}}, -1, "cannot be bypassed"},
127+ } {
128+ t.Run(tc.name, func(t *testing.T) {
129+ o, k, _ := testOutput(t)
130+ firsts := colorPlane(k, o.primary, pColorPipe, opBase, tc.chains...)
131+ cp, why := readColorPipeline(k, o.primary)
132+ if tc.want < 0 {
133+ if cp != nil || !strings.Contains(why, tc.reason) {
134+ t.Fatalf("matched %+v, reason %q; want none with %q", cp, why, tc.reason)
135+ }
136+ return
137+ }
138+ if cp == nil {
139+ t.Fatalf("no match: %s", why)
140+ }
141+ if cp.id != firsts[tc.want] {
142+ t.Fatalf("pipeline id %d, want %d", cp.id, firsts[tc.want])
143+ }
144+ // The matched ops are the chain's, found by type, with their
145+ // own properties and the enum values read from the curves.
146+ chain, base := tc.chains[tc.want], uint32(cp.id)
147+ find := func(match func(opSpec) bool, from int) uint32 {
148+ for i := from; i < len(chain); i++ {
149+ if match(chain[i]) {
150+ return base + uint32(i)
151+ }
152+ }
153+ t.Fatal("test chain lacks an op")
154+ return 0
155+ }
156+ srgb := find(func(s opSpec) bool { return s.typ == "1D Curve" && s.curves["sRGB EOTF"] == 5 }, 0)
157+ mult := find(func(s opSpec) bool { return s.typ == "Multiplier" }, 0)
158+ ctm := find(func(s opSpec) bool { return s.typ == "3x4 Matrix" }, 0)
159+ pq := find(func(s opSpec) bool { _, ok := s.curves["PQ 125 Inverse EOTF"]; return ok }, 0)
160+ if cp.srgb != (colorStage{op: srgb, bypass: bypassProp(srgb), prop: curveProp(srgb), curve: 5}) ||
161+ cp.mult != (colorStage{op: mult, bypass: bypassProp(mult), prop: multProp(mult)}) ||
162+ cp.ctm != (colorStage{op: ctm, bypass: bypassProp(ctm), prop: dataProp(ctm)}) ||
163+ cp.pq != (colorStage{op: pq, bypass: bypassProp(pq), prop: curveProp(pq), curve: 8}) {
164+ t.Fatalf("stages %+v", cp)
165+ }
166+ if len(cp.others) != len(chain)-4 {
167+ t.Fatalf("%d other ops, want %d", len(cp.others), len(chain)-4)
168+ }
169+ })
170+ }
171+}
172+
173+func TestReadColorPipelineWithoutProperty(t *testing.T) {
174+ o, k, _ := testOutput(t)
175+ if cp, why := readColorPipeline(k, o.primary); cp != nil || !strings.Contains(why, "COLOR_PIPELINE") {
176+ t.Fatalf("plane without the property: %+v %q", cp, why)
177+ }
178+}
179+
180+// A NEXT loop must not hang the read.
181+func TestWalkColoropsStopsOnCycle(t *testing.T) {
182+ o, k, _ := testOutput(t)
183+ k.EXPECT().objProps(uint32(7), uint32(objColorop)).Return(map[string][2]uint64{"NEXT": {1, 7}}, nil)
184+ if _, err := walkColorops(k, 7); err == nil {
185+ t.Fatal("cyclic chain accepted")
186+ }
187+ _ = o
188+}
189+
190+func TestS3132(t *testing.T) {
191+ for _, tc := range []struct {
192+ v float64
193+ want uint64
194+ }{
195+ {0, 0},
196+ {math.Copysign(0, -1), 0},
197+ {1, 1 << 32},
198+ {2.5, 5 << 31},
199+ {0.5, 1 << 31},
200+ {-1, 1<<63 | 1<<32},
201+ {-0.5, 1<<63 | 1<<31},
202+ {203.0 / 80, uint64(math.Round(203.0 / 80 * (1 << 32)))},
203+ {1e30, 1<<63 - 1},
204+ {-1e30, 1<<63 | (1<<63 - 1)},
205+ } {
206+ if got := s3132(tc.v); got != tc.want {
207+ t.Errorf("s3132(%v) = %#x, want %#x", tc.v, got, tc.want)
208+ }
209+ }
210+}
211+
212+// fromS3132 decodes sign-magnitude S31.32.
213+func fromS3132(v uint64) float64 {
214+ f := float64(v&(1<<63-1)) / (1 << 32)
215+ if v>>63 != 0 {
216+ return -f
217+ }
218+ return f
219+}
220+
221+// The CTM is the mat3 of hdr.frag, transposed: GLSL lists columns, the blob
222+// is row-major with a zero fourth column.
223+func TestCTMBlobMatchesHDRShader(t *testing.T) {
224+ src, err := os.ReadFile("../vulkan/shaders/hdr.frag")
225+ if err != nil {
226+ t.Fatal(err)
227+ }
228+ m := regexp.MustCompile(`mat3\(([^)]*)\)`).FindSubmatch(src)
229+ if m == nil {
230+ t.Fatal("no mat3 in hdr.frag")
231+ }
232+ var cols []float64 // column-major, as GLSL reads it
233+ for _, f := range strings.Split(string(m[1]), ",") {
234+ v, err := strconv.ParseFloat(strings.TrimSpace(f), 64)
235+ if err != nil {
236+ t.Fatal(err)
237+ }
238+ cols = append(cols, v)
239+ }
240+ if len(cols) != 9 {
241+ t.Fatalf("mat3 has %d values", len(cols))
242+ }
243+ blob := ctmBytes(bt709ToBT2020)
244+ if len(blob) != 96 {
245+ t.Fatalf("blob is %d bytes, struct drm_color_ctm_3x4 is 96", len(blob))
246+ }
247+ for row := range 3 {
248+ for col := range 4 {
249+ got := fromS3132(binary.LittleEndian.Uint64(blob[(row*4+col)*8:]))
250+ want := 0.0
251+ if col < 3 {
252+ want = cols[col*3+row] // out[row] = sum over col of M[col][row]*in[col]
253+ }
254+ if math.Abs(got-want) > 1e-9 {
255+ t.Errorf("blob[%d][%d] = %v, want %v", row, col, got, want)
256+ }
257+ }
258+ }
259+ // Applied to white, as the shader would: a column vector times mat3.
260+ var out [3]float64
261+ for row := range 3 {
262+ for col := range 3 {
263+ out[row] += fromS3132(binary.LittleEndian.Uint64(blob[(row*4+col)*8:]))
264+ }
265+ }
266+ for row, want := range [3]float64{
267+ cols[0] + cols[3] + cols[6], cols[1] + cols[4] + cols[7], cols[2] + cols[5] + cols[8],
268+ } {
269+ if math.Abs(out[row]-want) > 1e-9 {
270+ t.Errorf("white channel %d = %v, shader %v", row, out[row], want)
271+ }
272+ }
273+}
274+
275+func TestPlaneColor(t *testing.T) {
276+ pq := ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}
277+ srgb := ports.SurfaceColor{TF: ports.ColorTFSRGB, Primaries: ports.ColorPrimariesSRGB}
278+ ext := ports.SurfaceColor{TF: ports.ColorTFExtendedLinear, Primaries: ports.ColorPrimariesSRGB}
279+ gamma := ports.SurfaceColor{TF: 10, Primaries: ports.ColorPrimariesSRGB}
280+ p3 := ports.SurfaceColor{TF: ports.ColorTFSRGB, Primaries: 7}
281+ pipe := &colorPipeline{}
282+ for _, tc := range []struct {
283+ name string
284+ c ports.SurfaceColor
285+ format uint32
286+ opaque bool
287+ hdr bool
288+ pipe *colorPipeline
289+ want colorMode
290+ reason string
291+ }{
292+ {"SDR output, sRGB", ports.SurfaceColor{}, fourccXRGB, true, false, nil, colorBypass, ""},
293+ {"SDR output ignores a pipeline", srgb, fourccXRGB, true, false, pipe, colorBypass, ""},
294+ {"SDR output, PQ", pq, fourccXR30, true, false, nil, colorBypass, "sdr_pq_content"},
295+ {"SDR output, YUV", ports.SurfaceColor{}, fourccNV12, true, false, nil, colorBypass, "yuv"},
296+ {"HDR, PQ 10-bit", pq, fourccXR30, true, true, pipe, colorBypass, ""},
297+ {"HDR, PQ 10-bit with alpha", pq, fourccAR30, false, true, nil, colorBypass, ""},
298+ {"HDR, PQ 8-bit", pq, fourccXRGB, true, true, pipe, colorBypass, "hdr_format"},
299+ {"HDR, PQ YUV", pq, fourccP010, true, true, pipe, colorBypass, "yuv"},
300+ {"HDR, unspecified colour", ports.SurfaceColor{}, fourccXRGB, true, true, pipe, colorSDRToPQ, ""},
301+ {"HDR, sRGB", srgb, fourccXRGB, true, true, pipe, colorSDRToPQ, ""},
302+ {"HDR, sRGB, 10-bit buffer", srgb, fourccXR30, true, true, pipe, colorSDRToPQ, ""},
303+ {"HDR, sRGB, no pipeline", srgb, fourccXRGB, true, true, nil, colorBypass, "hdr_sdr_content"},
304+ {"HDR, sRGB with alpha", srgb, fourccARGB, false, true, pipe, colorBypass, "hdr_sdr_content"},
305+ {"HDR, extended linear", ext, fourccXRGB, true, true, pipe, colorBypass, "hdr_sdr_content"},
306+ {"HDR, other transfer function", gamma, fourccXRGB, true, true, pipe, colorBypass, "hdr_sdr_content"},
307+ {"HDR, other primaries", p3, fourccXRGB, true, true, pipe, colorBypass, "hdr_sdr_content"},
308+ {"HDR, sRGB YUV", srgb, fourccNV12, true, true, pipe, colorBypass, "yuv"},
309+ } {
310+ t.Run(tc.name, func(t *testing.T) {
311+ mode, reason := planeColor(tc.c, tc.format, tc.opaque, tc.hdr, tc.pipe)
312+ if mode != tc.want || reason != tc.reason {
313+ t.Fatalf("mode %d reason %q, want %d %q", mode, reason, tc.want, tc.reason)
314+ }
315+ })
316+ }
317+}
318+
319+// Colour test outputs.
320+
321+// colorOutput is an HDR output whose primary (and overlay) plane matches the
322+// amdgpu pipeline. rule, when set, is the driver model every commit meets.
323+func colorOutput(t *testing.T, overlay bool, rule func(*atomicReq, uint32) error) (*Output, *mockkms, *[]commitRec) {
324+ t.Helper()
325+ o, k, commits, _ := testOutputRules(t, rule)
326+ props := maps0(planeProps)
327+ props["zpos"] = pZpos
328+ o.primary.props = props
329+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
330+ colorPlane(k, o.primary, pColorPipe, opBase, amdgpuChain)
331+ if overlay {
332+ ov := &plane{id: tOverlay, typ: planeOverlay, props: props, zposValue: 2, formats: []ports.DMABufFormat{{Format: fourccXRGB}}}
333+ colorPlane(k, ov, pColorPipeOverlay, opBase+20, amdgpuChain)
334+ o.stray = []*plane{ov}
335+ o.pickOverlay()
336+ if o.overlay == nil {
337+ t.Fatal("no overlay")
338+ }
339+ }
340+ o.hdr.on = true
341+ o.readColor()
342+ o.ctmBlob, o.colorMult = 61, s3132(203.0/80)
343+ if o.primary.pipeline == nil || overlay && o.overlay.pipeline == nil {
344+ t.Fatal("pipeline not matched")
345+ }
346+ return o, k, commits
347+}
348+
349+// colorOps are the colorop objects a commit touches.
350+func colorOps(c commitRec) []uint32 {
351+ var out []uint32
352+ for _, obj := range c.req.objs {
353+ if obj >= opBase {
354+ out = append(out, obj)
355+ }
356+ }
357+ return out
358+}
359+
360+func sdrContent(o *Output) ports.SurfaceContent {
361+ o.clientFBs[9] = &clientFB{fbID: 80}
362+ return ports.SurfaceContent{ID: 1, Width: 200, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{ID: 9, Format: fourccXRGB}}
363+}
364+
365+func TestColorCommitTransitions(t *testing.T) {
366+ o, _, commits := colorOutput(t, false, nil)
367+ c := sdrContent(o)
368+ c.Async = true
369+ o.shown = 9
370+ cp := o.primary.pipeline
371+ last := func() commitRec { return (*commits)[len(*commits)-1] }
372+
373+ // First SDR scanout writes the pipeline, never async even though the
374+ // buffer asks for it: only FB_ID may change in an async flip.
375+ if ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
376+ t.Fatalf("ok=%v err=%v", ok, err)
377+ }
378+ first := last()
379+ if first.flags&flipAsyncFlag != 0 {
380+ t.Fatal("a colour change went async")
381+ }
382+ if v, ok := first.req.value(tPrimary, pColorPipe); !ok || v != cp.id {
383+ t.Fatalf("COLOR_PIPELINE %d %v, want %d", v, ok, cp.id)
384+ }
385+ for _, s := range []struct {
386+ op, prop uint32
387+ want uint64
388+ }{
389+ {cp.srgb.op, cp.srgb.bypass, 0}, {cp.srgb.op, cp.srgb.prop, cp.srgb.curve},
390+ {cp.mult.op, cp.mult.bypass, 0}, {cp.mult.op, cp.mult.prop, s3132(203.0 / 80)},
391+ {cp.ctm.op, cp.ctm.bypass, 0}, {cp.ctm.op, cp.ctm.prop, 61},
392+ {cp.pq.op, cp.pq.bypass, 0}, {cp.pq.op, cp.pq.prop, cp.pq.curve},
393+ } {
394+ if v, ok := first.req.value(s.op, s.prop); !ok || v != s.want {
395+ t.Errorf("colorop %d prop %d = %d %v, want %d", s.op, s.prop, v, ok, s.want)
396+ }
397+ }
398+ for _, other := range cp.others {
399+ if v, ok := first.req.value(other.op, other.bypass); !ok || v != 1 {
400+ t.Errorf("other colorop %d BYPASS %d %v, want 1", other.op, v, ok)
401+ }
402+ }
403+ if !o.primary.colorKnown || o.primary.colorApplied != colorSDRToPQ {
404+ t.Fatalf("applied %v known %v", o.primary.colorApplied, o.primary.colorKnown)
405+ }
406+
407+ // Same mode again: nothing is written, and the flip may be async.
408+ o.frame.endPending()
409+ o.vrrOn = true
410+ if ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
411+ t.Fatalf("ok=%v err=%v", ok, err)
412+ }
413+ if second := last(); second.flags&flipAsyncFlag == 0 || len(colorOps(second)) != 0 {
414+ t.Fatalf("steady state: flags %#x colorops %v", second.flags, colorOps(second))
415+ } else if _, ok := second.req.value(tPrimary, pColorPipe); ok {
416+ t.Fatal("steady state wrote COLOR_PIPELINE")
417+ }
418+
419+ // A composed frame sets Bypass once.
420+ o.frame.endPending()
421+ if err := o.commitFrame(70, nil, true, false, pendingFrame{}); err != nil {
422+ t.Fatal(err)
423+ }
424+ composed := last()
425+ if composed.flags&flipAsyncFlag != 0 {
426+ t.Fatal("the return to Bypass went async")
427+ }
428+ if v, ok := composed.req.value(tPrimary, pColorPipe); !ok || v != 0 || len(colorOps(composed)) != 0 {
429+ t.Fatalf("composed: COLOR_PIPELINE %d %v colorops %v", v, ok, colorOps(composed))
430+ }
431+ o.frame.endPending()
432+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
433+ t.Fatal(err)
434+ }
435+ again := last()
436+ if _, ok := again.req.value(tPrimary, pColorPipe); ok {
437+ t.Fatal("Bypass written twice")
438+ }
439+}
440+
441+// A plane that applies the pipeline never sees colorop properties in a
442+// cursor or VRR commit.
443+func TestCommitStateNeverCarriesColorProps(t *testing.T) {
444+ o, _, commits := colorOutput(t, false, nil)
445+ o.primary.colorKnown, o.primary.colorApplied = false, colorSDRToPQ // stale on purpose
446+ o.cursor.image = true
447+ o.cursor.Move(3, 4)
448+ if err := o.commitState(true); err != nil {
449+ t.Fatal(err)
450+ }
451+ c := (*commits)[0]
452+ if _, ok := c.req.value(tPrimary, pColorPipe); ok || len(colorOps(c)) != 0 {
453+ t.Fatalf("state commit carries colour: %+v", c.req)
454+ }
455+ if _, ok := c.req.value(tCursor, 14); !ok {
456+ t.Fatal("cursor not moved")
457+ }
458+}
459+
460+// Protected frames, power off, Close and every modeset leave Bypass, on the
461+// overlay too, whatever was applied or known.
462+func TestColorBypassOnComposedProtectedOffCloseAndModeset(t *testing.T) {
463+ apply := func(o *Output) {
464+ for _, p := range []*plane{o.primary, o.overlay} {
465+ p.colorKnown, p.colorApplied = true, colorSDRToPQ
466+ }
467+ }
468+ bypassed := func(t *testing.T, c commitRec) {
469+ t.Helper()
470+ for _, p := range []struct{ plane, prop uint32 }{{tPrimary, pColorPipe}, {tOverlay, pColorPipeOverlay}} {
471+ if v, ok := c.req.value(p.plane, p.prop); !ok || v != 0 {
472+ t.Errorf("plane %d COLOR_PIPELINE %d %v, want Bypass", p.plane, v, ok)
473+ }
474+ }
475+ if ops := colorOps(c); len(ops) != 0 {
476+ t.Errorf("Bypass commit touches colorops %v", ops)
477+ }
478+ }
479+ t.Run("power off", func(t *testing.T) {
480+ o, _, commits := colorOutput(t, true, nil)
481+ apply(o)
482+ if err := o.powerOff(); err != nil {
483+ t.Fatal(err)
484+ }
485+ bypassed(t, (*commits)[0])
486+ if !o.primary.colorKnown || o.primary.colorApplied != colorBypass || o.overlay.colorApplied != colorBypass {
487+ t.Fatal("power off did not record Bypass")
488+ }
489+ })
490+ t.Run("close", func(t *testing.T) {
491+ o, k, commits := colorOutput(t, true, nil)
492+ k.EXPECT().rmFB(mock.Anything).Return(nil)
493+ k.EXPECT().destroyBlob(uint32(61)).Return(nil).Once()
494+ // Unknown state: Close still writes Bypass.
495+ o.Close()
496+ bypassed(t, (*commits)[0])
497+ if o.ctmBlob != 0 {
498+ t.Fatal("CTM blob kept")
499+ }
500+ })
501+ t.Run("modeset", func(t *testing.T) {
502+ o, k, commits := colorOutput(t, true, nil)
503+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
504+ k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, 0}}, nil)
505+ apply(o)
506+ o.primary.verdicts = []colorVerdict{{format: fourccXRGB}}
507+ if err := o.modeset(); err != nil {
508+ t.Fatal(err)
509+ }
510+ bypassed(t, (*commits)[0])
511+ if o.primary.colorKnown || o.overlay.colorKnown || len(o.primary.verdicts) != 0 {
512+ t.Fatal("a modeset kept the colour state or the refusals")
513+ }
514+ })
515+ t.Run("protected frame", func(t *testing.T) {
516+ o, _, commits := colorOutput(t, true, nil)
517+ apply(o)
518+ o.protected = true
519+ if err := o.commitWith(70, nil, false, false, pendingFrame{}, overlayWin{}); err != nil {
520+ t.Fatal(err)
521+ }
522+ c := (*commits)[0]
523+ if v, ok := c.req.value(tPrimary, pColorPipe); !ok || v != 0 {
524+ t.Fatalf("protected frame: COLOR_PIPELINE %d %v", v, ok)
525+ }
526+ })
527+}
528+
529+// The overlay's pipeline is driven with the primary on Bypass; a frame
530+// without the overlay turns it back to Bypass once.
531+func TestColorOverlayTransitions(t *testing.T) {
532+ o, _, commits := colorOutput(t, true, nil)
533+ o.clientFBs[9] = &clientFB{fbID: 88}
534+ ov := overlayWin{id: 2, fb: 88, buf: 9, w: 100, h: 100, rect: ports.Rect{X: 100, W: 100, H: 100}, color: colorUse{mode: colorSDRToPQ, format: fourccXRGB}}
535+ if err := o.commitWith(70, nil, false, false, pendingFrame{}, ov); err != nil {
536+ t.Fatal(err)
537+ }
538+ c := (*commits)[0]
539+ cp := o.overlay.pipeline
540+ if v, ok := c.req.value(tOverlay, pColorPipeOverlay); !ok || v != cp.id {
541+ t.Fatalf("overlay COLOR_PIPELINE %d %v, want %d", v, ok, cp.id)
542+ }
543+ if v, ok := c.req.value(tPrimary, pColorPipe); !ok || v != 0 {
544+ t.Fatalf("composed primary COLOR_PIPELINE %d %v, want Bypass", v, ok)
545+ }
546+ o.frame.endPending()
547+ if err := o.commitWith(70, nil, false, false, pendingFrame{}, ov); err != nil {
548+ t.Fatal(err)
549+ }
550+ if again := (*commits)[1]; len(colorOps(again)) != 0 {
551+ t.Fatal("overlay colour written twice")
552+ } else if _, ok := again.req.value(tOverlay, pColorPipeOverlay); ok {
553+ t.Fatal("overlay COLOR_PIPELINE written twice")
554+ }
555+ o.frame.endPending()
556+ if err := o.commitWith(70, nil, false, false, pendingFrame{}, overlayWin{}); err != nil {
557+ t.Fatal(err)
558+ }
559+ if v, ok := (*commits)[2].req.value(tOverlay, pColorPipeOverlay); !ok || v != 0 {
560+ t.Fatalf("overlay left without Bypass: %d %v", v, ok)
561+ }
562+}
563+
564+// refusePipeline is a driver that refuses any request selecting a pipeline
565+// on a plane with EINVAL, TEST_ONLY included.
566+func refusePipeline(r *atomicReq, _ uint32) error {
567+ for _, obj := range [...]struct{ plane, prop uint32 }{{tPrimary, pColorPipe}, {tOverlay, pColorPipeOverlay}} {
568+ if v, ok := r.value(obj.plane, obj.prop); ok && v != 0 {
569+ return unix.EINVAL
570+ }
571+ }
572+ return nil
573+}
574+
575+func hdrScanoutScene() (ports.Scene, map[ports.WindowID]ports.SurfaceContent) {
576+ s, c := fullscreenScene()
577+ return s, c
578+}
579+
580+func countTests(commits []commitRec) int {
581+ n := 0
582+ for _, c := range commits {
583+ if c.flags&atomicTestOnly != 0 {
584+ n++
585+ }
586+ }
587+ return n
588+}
589+
590+// SDR scanout on HDR is tested before its first use. A refusal is cached per
591+// plane, format and cursor state and is never tested again; a modeset asks
592+// the driver again.
593+func TestColorRefusalIsTestedOnceAndCached(t *testing.T) {
594+ o, k, commits := colorOutput(t, false, refusePipeline)
595+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
596+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe()
597+ s, surfaces := hdrScanoutScene()
598+ decide := func() string {
599+ t.Helper()
600+ if fb, _, _ := o.scanoutFrame(s, surfaces); fb != 0 {
601+ t.Fatalf("refused pipeline scanned out (fb %d)", fb)
602+ }
603+ return o.reason
604+ }
605+ if got := decide(); got != "color_refused" || countTests(*commits) != 1 {
606+ t.Fatalf("reason %q after %d tests", got, countTests(*commits))
607+ }
608+ for range 3 {
609+ decide()
610+ }
611+ if n := countTests(*commits); n != 1 {
612+ t.Fatalf("a cached refusal was tested %d times", n)
613+ }
614+ if cfb := o.clientFBs[9]; cfb.failed != "" {
615+ t.Fatalf("buffer marked failed: %q", cfb.failed)
616+ }
617+ // The cursor shown is another state: asked once more, then cached.
618+ o.cursor.image = true
619+ o.cursor.Move(1, 1)
620+ decide()
621+ decide()
622+ if n := countTests(*commits); n != 2 {
623+ t.Fatalf("%d tests with the cursor shown, want 2", n)
624+ }
625+ // A modeset asks the driver again.
626+ o.forgetColorVerdicts()
627+ decide()
628+ if n := countTests(*commits); n != 3 {
629+ t.Fatalf("a modeset did not ask the driver again: %d tests", n)
630+ }
631+}
632+
633+// KMS accepts the colour test and refuses the real commit: the frame is
634+// composed, the refusal cached, the buffer not blamed.
635+func TestColorRefusalOfCommittedFrame(t *testing.T) {
636+ // The first request that selects the pipeline outside TEST_ONLY fails.
637+ o, k, commits := colorOutput(t, false, func(r *atomicReq, flags uint32) error {
638+ if flags&atomicTestOnly == 0 {
639+ return refusePipeline(r, flags)
640+ }
641+ return nil
642+ })
643+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
644+ s, surfaces := hdrScanoutScene()
645+ d := o.decideFrame(s, surfaces, false)
646+ if d.fb != 80 || d.color != colorSDRToPQ {
647+ t.Fatalf("decision %+v", d)
648+ }
649+ ok, err := o.commitScanoutRect(d.fb, d.content, pendingFrame{}, d.rect, d.color)
650+ if ok || err != nil || o.reason != "color_refused" {
651+ t.Fatalf("ok=%v err=%v reason %q", ok, err, o.reason)
652+ }
653+ if o.frame.pendingCommit() || o.primary.colorKnown {
654+ t.Fatal("a refused commit changed the state")
655+ }
656+ before := len(*commits)
657+ if d := o.decideFrame(s, surfaces, false); d.fb != 0 || o.reason != "color_refused" || len(*commits) != before {
658+ t.Fatalf("refusal not cached: fb %d reason %q commits %d->%d", d.fb, o.reason, before, len(*commits))
659+ }
660+ if cfb := o.clientFBs[9]; cfb.failed != "" || cfb.noAsync {
661+ t.Fatalf("buffer blamed: %+v", cfb)
662+ }
663+}
664+
665+// A buffer KMS refuses on its own is still blamed on the buffer, not the
666+// pipeline.
667+func TestColorRefusalOfTheBufferStaysTheBuffers(t *testing.T) {
668+ o, k, _ := colorOutput(t, false, func(r *atomicReq, flags uint32) error {
669+ // The driver takes the pipeline test, then refuses the buffer
670+ // whatever the pipeline: the commit and its Bypass test fail.
671+ if v, ok := r.value(tPrimary, pColorPipe); flags&atomicTestOnly == 0 || ok && v == 0 {
672+ return unix.EINVAL
673+ }
674+ return nil
675+ })
676+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
677+ s, surfaces := hdrScanoutScene()
678+ d := o.decideFrame(s, surfaces, false)
679+ if ok, err := o.commitScanoutRect(d.fb, d.content, pendingFrame{}, d.rect, d.color); ok || err != nil {
680+ t.Fatalf("ok=%v err=%v", ok, err)
681+ }
682+ if cfb := o.clientFBs[9]; cfb.failed != "flip_refused" || o.reason != "flip_refused" {
683+ t.Fatalf("failed %q reason %q", cfb.failed, o.reason)
684+ }
685+}
686+
687+// A cursor commit refused while a plane shows the pipeline caches the
688+// refusal for the cursor state and has the next frame composed.
689+func TestColorCursorConflict(t *testing.T) {
690+ o, _, _ := colorOutput(t, false, func(r *atomicReq, flags uint32) error {
691+ if v, ok := r.value(tCursor, pFB); ok && v != 0 {
692+ return unix.EINVAL
693+ }
694+ return nil
695+ })
696+ o.primary.colorKnown, o.primary.colorApplied, o.primary.colorFormat = true, colorSDRToPQ, fourccXRGB
697+ o.cursor.image = true
698+ o.cursor.Move(5, 5)
699+ if err := o.commitState(false); err != errOverlayDropped {
700+ t.Fatalf("commitState: %v", err)
701+ }
702+ if ok, known := o.primary.verdict(fourccXRGB, true); !known || ok {
703+ t.Fatal("refusal with the cursor not cached")
704+ }
705+ if o.cursor.off {
706+ t.Fatal("the cursor was blamed")
707+ }
708+}
709+
710+// Overlay: allowed in HDR when the colour resolves; a refusal is cached.
711+func TestColorOverlayInHDR(t *testing.T) {
712+ o, k, commits := colorOutput(t, true, refusePipeline)
713+ o.primary.pipeline = nil // only the overlay converts
714+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(88), nil).Maybe()
715+ s, surfaces := overlayScene()
716+ // PQ content and SDR content without alpha are both candidates now.
717+ ov, rest := o.overlayFrame(s, surfaces)
718+ if ov.fb != 88 || ov.color.mode != colorSDRToPQ || len(rest.Windows) != 1 {
719+ t.Fatalf("overlay %+v", ov)
720+ }
721+ if o.testOverlay(70, ov) {
722+ t.Fatal("the driver's refusal was taken")
723+ }
724+ if o.overlayReason != "color_refused" {
725+ t.Fatalf("reason %q", o.overlayReason)
726+ }
727+ if cfb := o.clientFBs[9]; cfb.overlayFailed != "" {
728+ t.Fatalf("buffer blamed: %q", cfb.overlayFailed)
729+ }
730+ tests := countTests(*commits)
731+ if ov, rest := o.overlayFrame(s, surfaces); ov.fb != 0 || o.overlayReason != "color_refused" || len(rest.Windows) != 2 || countTests(*commits) != tests {
732+ t.Fatalf("not cached: %+v reason %q", ov, o.overlayReason)
733+ }
734+ // Without a pipeline an SDR window stays composed on HDR.
735+ o.overlay.pipeline = nil
736+ o.forgetColorVerdicts()
737+ if ov, _ := o.overlayFrame(s, surfaces); ov.fb != 0 || o.overlayReason != "no_candidate" {
738+ t.Fatalf("no pipeline: %+v reason %q", ov, o.overlayReason)
739+ }
740+}
741+
742+func TestSetupColor(t *testing.T) {
743+ t.Run("HDR makes the matrix blob and the multiplier", func(t *testing.T) {
744+ o, k, _ := colorOutput(t, false, nil)
745+ o.ctmBlob, o.colorMult = 0, 0
746+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 160}
747+ o.hdr.cap.Capable = true
748+ k.EXPECT().createBlob(mock.MatchedBy(func(b []byte) bool { return slices.Equal(b, ctmBytes(bt709ToBT2020)) })).Return(uint32(61), nil).Once()
749+ o.setupColor()
750+ if o.ctmBlob != 61 || o.colorMult != 2<<32 {
751+ t.Fatalf("blob %d multiplier %#x", o.ctmBlob, o.colorMult)
752+ }
753+ })
754+ t.Run("blob failure turns the pipelines off", func(t *testing.T) {
755+ o, k, _ := colorOutput(t, true, nil)
756+ o.ctmBlob = 0
757+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 203}
758+ o.hdr.cap.Capable = true
759+ k.EXPECT().createBlob(mock.Anything).Return(uint32(0), unix.ENOMEM).Once()
760+ o.setupColor()
761+ if o.primary.pipeline != nil || o.overlay.pipeline != nil || o.ctmBlob != 0 {
762+ t.Fatal("pipelines kept without their blob")
763+ }
764+ })
765+ t.Run("SDR output keeps Bypass", func(t *testing.T) {
766+ o, _, _ := colorOutput(t, false, nil)
767+ o.ctmBlob = 0
768+ o.hdr.settings.Enabled = false
769+ o.setupColor()
770+ if o.primary.pipeline != nil || o.ctmBlob != 0 {
771+ t.Fatal("SDR output set up a pipeline")
772+ }
773+ })
774+}
775+
776+// A frame refused because of its pipeline never turns the hardware cursor
777+// off: only a refusal the frame has with Bypass does.
778+func TestColorRefusalDoesNotDisableTheCursor(t *testing.T) {
779+ // The driver refuses the pipeline, and also the cursor whenever it is
780+ // shown with the pipeline.
781+ rule := func(r *atomicReq, flags uint32) error {
782+ if v, ok := r.value(tPrimary, pColorPipe); ok && v != 0 && flags&atomicTestOnly == 0 {
783+ return unix.EINVAL
784+ }
785+ return nil
786+ }
787+ o, k, _ := colorOutput(t, false, rule)
788+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
789+ o.cursor.image = true
790+ o.cursor.Move(5, 5)
791+ c := sdrContent(o)
792+ ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ)
793+ if ok || err != nil || o.reason != "color_refused" {
794+ t.Fatalf("ok=%v err=%v reason %q", ok, err, o.reason)
795+ }
796+ if o.cursor.off {
797+ t.Fatal("a pipeline refusal turned the hardware cursor off")
798+ }
799+ if v, known := o.primary.verdict(fourccXRGB, true); !known || v {
800+ t.Fatal("refusal with the cursor not cached")
801+ }
802+ // A cursor the driver refuses on its own, pipeline or not, is still turned off.
803+ o2, k2, _ := colorOutput(t, false, func(r *atomicReq, flags uint32) error {
804+ if v, ok := r.value(tCursor, pFB); ok && v != 0 {
805+ return unix.EINVAL
806+ }
807+ return nil
808+ })
809+ k2.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
810+ o2.cursor.image = true
811+ o2.cursor.Move(5, 5)
812+ if ok, err := o2.commitScanoutRect(80, sdrContent(o2), pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
813+ t.Fatalf("ok=%v err=%v", ok, err)
814+ }
815+ if !o2.cursor.off {
816+ t.Fatal("a cursor refusal did not turn the cursor off")
817+ }
818+}
819+
820+// The colour test differs from the failed commit in colour alone. A frame
821+// refused for its content hint is not blamed on the pipeline; the hint is
822+// disabled as before. A frame refused for its pipeline, with the hint wanted,
823+// is blamed on the pipeline and the hint stays.
824+func TestColorRefusalBlamesOnlyColour(t *testing.T) {
825+ const pContent = 70
826+ setup := func(t *testing.T, rule func(*atomicReq, uint32) error) (*Output, ports.SurfaceContent) {
827+ o, k, _ := colorOutput(t, false, rule)
828+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
829+ o.contentProp, o.contentValues = pContent, [5]uint64{0, 1, 2, 3, 4}
830+ o.contentWanted = 4 // wanted Game, the applied value is still the first
831+ return o, sdrContent(o)
832+ }
833+ hasHint := func(r *atomicReq) bool { v, ok := r.value(tConn, pContent); return ok && v == 4 }
834+ usesPipeline := func(r *atomicReq) bool { v, ok := r.value(tPrimary, pColorPipe); return ok && v != 0 }
835+ t.Run("the hint is refused, the pipeline is accepted", func(t *testing.T) {
836+ o, c := setup(t, func(r *atomicReq, flags uint32) error {
837+ if hasHint(r) {
838+ return unix.EINVAL
839+ }
840+ return nil
841+ })
842+ ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ)
843+ if !ok || err != nil {
844+ t.Fatalf("ok=%v err=%v reason %q", ok, err, o.reason)
845+ }
846+ if o.contentProp != 0 {
847+ t.Fatal("the content hint was not disabled")
848+ }
849+ if _, known := o.primary.verdict(fourccXRGB, false); known || o.reason == "color_refused" {
850+ t.Fatal("the pipeline was blamed for the hint")
851+ }
852+ if !o.primary.colorKnown || o.primary.colorApplied != colorSDRToPQ {
853+ t.Fatal("the retried frame did not apply the pipeline")
854+ }
855+ })
856+ t.Run("the pipeline is refused, the hint is accepted", func(t *testing.T) {
857+ o, c := setup(t, func(r *atomicReq, flags uint32) error {
858+ if usesPipeline(r) && flags&atomicTestOnly == 0 {
859+ return unix.EINVAL
860+ }
861+ return nil
862+ })
863+ ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ)
864+ if ok || err != nil || o.reason != "color_refused" {
865+ t.Fatalf("ok=%v err=%v reason %q", ok, err, o.reason)
866+ }
867+ if v, known := o.primary.verdict(fourccXRGB, false); !known || v {
868+ t.Fatal("the pipeline refusal was not cached")
869+ }
870+ if o.contentProp != pContent {
871+ t.Fatal("the content hint was disabled for the pipeline's refusal")
872+ }
873+ })
874+}
@@ -0,0 +1,874 @@
1+package drm
2+
3+import (
4+ "encoding/binary"
5+ "math"
6+ "os"
7+ "regexp"
8+ "slices"
9+ "strconv"
10+ "strings"
11+ "testing"
12+
13+ "github.com/bnema/neferwl/internal/ports"
14+ "github.com/stretchr/testify/mock"
15+ "golang.org/x/sys/unix"
16+)
17+
18+// Plane COLOR_PIPELINE property IDs of the primary and overlay test planes,
19+// and the CRTC DEGAMMA_LUT.
20+const (
21+ pColorPipe, pColorPipeOverlay, pDegamma = 60, 61, 62
22+ opBase = 200 // first colorop ID of a plane; a plane's ops use 20 IDs
23+)
24+
25+// Property IDs of a colorop, by role: kernel properties are per object.
26+func bypassProp(op uint32) uint32 { return 1000 + op }
27+func curveProp(op uint32) uint32 { return 2000 + op }
28+func multProp(op uint32) uint32 { return 3000 + op }
29+func dataProp(op uint32) uint32 { return 4000 + op }
30+func typeProp(op uint32) uint32 { return 5000 + op }
31+
32+// The enum values are arbitrary: the matcher must read names.
33+var (
34+ colorTypes = map[string]uint64{"1D Curve": 9, "1D LUT": 4, "3x4 Matrix": 2, "Multiplier": 6, "3D LUT": 1}
35+ firstCurve = map[string]uint64{"sRGB EOTF": 5, "PQ 125 EOTF": 6, "BT.2020 Inverse OETF": 3, "Gamma 2.2": 1}
36+ lastCurve = map[string]uint64{"sRGB Inverse EOTF": 0, "PQ 125 Inverse EOTF": 8, "BT.2020 OETF": 2, "Gamma 2.2 Inverse": 7}
37+)
38+
39+// opSpec is a colorop of a test pipeline.
40+type opSpec struct {
41+ typ string
42+ curves map[string]uint64
43+ noBypass bool
44+}
45+
46+func curveOp(curves map[string]uint64) opSpec { return opSpec{typ: "1D Curve", curves: curves} }
47+
48+var (
49+ opSRGB, opPQ = curveOp(firstCurve), curveOp(lastCurve)
50+ opMult = opSpec{typ: "Multiplier"}
51+ opCTM = opSpec{typ: "3x4 Matrix"}
52+ opLUT1D = opSpec{typ: "1D LUT"}
53+ opLUT3D = opSpec{typ: "3D LUT"}
54+ // amdgpuChain is the pipeline read on kernel 7.2 (DCN3+).
55+ amdgpuChain = []opSpec{opSRGB, opMult, opCTM, opPQ, opLUT1D, opLUT3D, curveOp(map[string]uint64{"sRGB EOTF": 5}), opLUT1D}
56+)
57+
58+// colorPlane gives p a COLOR_PIPELINE property (id pipeProp) offering the
59+// chains, each with ops from base up, and registers the kernel's answers.
60+// It returns the first colorop of each chain.
61+func colorPlane(k *mockkms, p *plane, pipeProp, base uint32, chains ...[]opSpec) []uint64 {
62+ p.props = maps0(p.props)
63+ p.props["COLOR_PIPELINE"] = pipeProp
64+ enums := map[string]uint64{"Bypass": 0}
65+ var firsts []uint64
66+ k.EXPECT().propEnums(uint32(typeProp(0))).Return(colorTypes, nil).Maybe()
67+ id := base
68+ for c, chain := range chains {
69+ firsts = append(firsts, uint64(id))
70+ enums["Pipeline "+strconv.Itoa(c)] = uint64(id)
71+ for i, op := range chain {
72+ props := map[string][2]uint64{
73+ "TYPE": {uint64(typeProp(0)), colorTypes[op.typ]},
74+ "NEXT": {1, 0},
75+ }
76+ if i+1 < len(chain) {
77+ props["NEXT"] = [2]uint64{1, uint64(id + 1)}
78+ }
79+ if !op.noBypass {
80+ props["BYPASS"] = [2]uint64{uint64(bypassProp(id)), 1}
81+ }
82+ switch op.typ {
83+ case "1D Curve":
84+ props["CURVE_1D_TYPE"] = [2]uint64{uint64(curveProp(id)), 0}
85+ k.EXPECT().propEnums(curveProp(id)).Return(op.curves, nil).Maybe()
86+ case "Multiplier":
87+ props["MULTIPLIER"] = [2]uint64{uint64(multProp(id)), 0}
88+ case "3x4 Matrix", "1D LUT", "3D LUT":
89+ props["DATA"] = [2]uint64{uint64(dataProp(id)), 0}
90+ }
91+ k.EXPECT().objProps(id, uint32(objColorop)).Return(props, nil).Maybe()
92+ id++
93+ }
94+ }
95+ k.EXPECT().propEnums(pipeProp).Return(enums, nil).Maybe()
96+ return firsts
97+}
98+
99+func maps0(m map[string]uint32) map[string]uint32 {
100+ out := make(map[string]uint32, len(m)+1)
101+ for k, v := range m {
102+ out[k] = v
103+ }
104+ return out
105+}
106+
107+func TestMatchColorPipeline(t *testing.T) {
108+ for _, tc := range []struct {
109+ name string
110+ chains [][]opSpec
111+ want int // index of the chain that matches, -1: none
112+ reason string // substring of the reason when none matches
113+ }{
114+ {"amdgpu", [][]opSpec{amdgpuChain}, 0, ""},
115+ {"minimal", [][]opSpec{{opSRGB, opMult, opCTM, opPQ}}, 0, ""},
116+ {"bypassable op before the chain", [][]opSpec{{opLUT1D, opSRGB, opMult, opCTM, opPQ}}, 0, ""},
117+ {"second pipeline matches", [][]opSpec{{opLUT1D}, amdgpuChain}, 1, ""},
118+ {"no pipeline", nil, -1, "only Bypass"},
119+ {"no PQ curve", [][]opSpec{{opSRGB, opMult, opCTM, curveOp(map[string]uint64{"sRGB Inverse EOTF": 0})}}, -1, "no sRGB EOTF"},
120+ {"no sRGB curve", [][]opSpec{{curveOp(map[string]uint64{"Gamma 2.2": 1}), opMult, opCTM, opPQ}}, -1, "no sRGB EOTF"},
121+ {"no multiplier", [][]opSpec{{opSRGB, opCTM, opPQ}}, -1, "no sRGB EOTF"},
122+ {"no matrix", [][]opSpec{{opSRGB, opMult, opPQ}}, -1, "no sRGB EOTF"},
123+ {"wrong order", [][]opSpec{{opSRGB, opCTM, opMult, opPQ}}, -1, "no sRGB EOTF"},
124+ {"PQ before sRGB", [][]opSpec{{opPQ, opMult, opCTM, opSRGB}}, -1, "no sRGB EOTF"},
125+ {"op without BYPASS", [][]opSpec{{opSRGB, opMult, opCTM, opPQ, {typ: "1D LUT", noBypass: true}}}, -1, "cannot be bypassed"},
126+ {"op without BYPASS first", [][]opSpec{{{typ: "3D LUT", noBypass: true}, opSRGB, opMult, opCTM, opPQ}}, -1, "cannot be bypassed"},
127+ } {
128+ t.Run(tc.name, func(t *testing.T) {
129+ o, k, _ := testOutput(t)
130+ firsts := colorPlane(k, o.primary, pColorPipe, opBase, tc.chains...)
131+ cp, why := readColorPipeline(k, o.primary)
132+ if tc.want < 0 {
133+ if cp != nil || !strings.Contains(why, tc.reason) {
134+ t.Fatalf("matched %+v, reason %q; want none with %q", cp, why, tc.reason)
135+ }
136+ return
137+ }
138+ if cp == nil {
139+ t.Fatalf("no match: %s", why)
140+ }
141+ if cp.id != firsts[tc.want] {
142+ t.Fatalf("pipeline id %d, want %d", cp.id, firsts[tc.want])
143+ }
144+ // The matched ops are the chain's, found by type, with their
145+ // own properties and the enum values read from the curves.
146+ chain, base := tc.chains[tc.want], uint32(cp.id)
147+ find := func(match func(opSpec) bool, from int) uint32 {
148+ for i := from; i < len(chain); i++ {
149+ if match(chain[i]) {
150+ return base + uint32(i)
151+ }
152+ }
153+ t.Fatal("test chain lacks an op")
154+ return 0
155+ }
156+ srgb := find(func(s opSpec) bool { return s.typ == "1D Curve" && s.curves["sRGB EOTF"] == 5 }, 0)
157+ mult := find(func(s opSpec) bool { return s.typ == "Multiplier" }, 0)
158+ ctm := find(func(s opSpec) bool { return s.typ == "3x4 Matrix" }, 0)
159+ pq := find(func(s opSpec) bool { _, ok := s.curves["PQ 125 Inverse EOTF"]; return ok }, 0)
160+ if cp.srgb != (colorStage{op: srgb, bypass: bypassProp(srgb), prop: curveProp(srgb), curve: 5}) ||
161+ cp.mult != (colorStage{op: mult, bypass: bypassProp(mult), prop: multProp(mult)}) ||
162+ cp.ctm != (colorStage{op: ctm, bypass: bypassProp(ctm), prop: dataProp(ctm)}) ||
163+ cp.pq != (colorStage{op: pq, bypass: bypassProp(pq), prop: curveProp(pq), curve: 8}) {
164+ t.Fatalf("stages %+v", cp)
165+ }
166+ if len(cp.others) != len(chain)-4 {
167+ t.Fatalf("%d other ops, want %d", len(cp.others), len(chain)-4)
168+ }
169+ })
170+ }
171+}
172+
173+func TestReadColorPipelineWithoutProperty(t *testing.T) {
174+ o, k, _ := testOutput(t)
175+ if cp, why := readColorPipeline(k, o.primary); cp != nil || !strings.Contains(why, "COLOR_PIPELINE") {
176+ t.Fatalf("plane without the property: %+v %q", cp, why)
177+ }
178+}
179+
180+// A NEXT loop must not hang the read.
181+func TestWalkColoropsStopsOnCycle(t *testing.T) {
182+ o, k, _ := testOutput(t)
183+ k.EXPECT().objProps(uint32(7), uint32(objColorop)).Return(map[string][2]uint64{"NEXT": {1, 7}}, nil)
184+ if _, err := walkColorops(k, 7); err == nil {
185+ t.Fatal("cyclic chain accepted")
186+ }
187+ _ = o
188+}
189+
190+func TestS3132(t *testing.T) {
191+ for _, tc := range []struct {
192+ v float64
193+ want uint64
194+ }{
195+ {0, 0},
196+ {math.Copysign(0, -1), 0},
197+ {1, 1 << 32},
198+ {2.5, 5 << 31},
199+ {0.5, 1 << 31},
200+ {-1, 1<<63 | 1<<32},
201+ {-0.5, 1<<63 | 1<<31},
202+ {203.0 / 80, uint64(math.Round(203.0 / 80 * (1 << 32)))},
203+ {1e30, 1<<63 - 1},
204+ {-1e30, 1<<63 | (1<<63 - 1)},
205+ } {
206+ if got := s3132(tc.v); got != tc.want {
207+ t.Errorf("s3132(%v) = %#x, want %#x", tc.v, got, tc.want)
208+ }
209+ }
210+}
211+
212+// fromS3132 decodes sign-magnitude S31.32.
213+func fromS3132(v uint64) float64 {
214+ f := float64(v&(1<<63-1)) / (1 << 32)
215+ if v>>63 != 0 {
216+ return -f
217+ }
218+ return f
219+}
220+
221+// The CTM is the mat3 of hdr.frag, transposed: GLSL lists columns, the blob
222+// is row-major with a zero fourth column.
223+func TestCTMBlobMatchesHDRShader(t *testing.T) {
224+ src, err := os.ReadFile("../vulkan/shaders/hdr.frag")
225+ if err != nil {
226+ t.Fatal(err)
227+ }
228+ m := regexp.MustCompile(`mat3\(([^)]*)\)`).FindSubmatch(src)
229+ if m == nil {
230+ t.Fatal("no mat3 in hdr.frag")
231+ }
232+ var cols []float64 // column-major, as GLSL reads it
233+ for _, f := range strings.Split(string(m[1]), ",") {
234+ v, err := strconv.ParseFloat(strings.TrimSpace(f), 64)
235+ if err != nil {
236+ t.Fatal(err)
237+ }
238+ cols = append(cols, v)
239+ }
240+ if len(cols) != 9 {
241+ t.Fatalf("mat3 has %d values", len(cols))
242+ }
243+ blob := ctmBytes(bt709ToBT2020)
244+ if len(blob) != 96 {
245+ t.Fatalf("blob is %d bytes, struct drm_color_ctm_3x4 is 96", len(blob))
246+ }
247+ for row := range 3 {
248+ for col := range 4 {
249+ got := fromS3132(binary.LittleEndian.Uint64(blob[(row*4+col)*8:]))
250+ want := 0.0
251+ if col < 3 {
252+ want = cols[col*3+row] // out[row] = sum over col of M[col][row]*in[col]
253+ }
254+ if math.Abs(got-want) > 1e-9 {
255+ t.Errorf("blob[%d][%d] = %v, want %v", row, col, got, want)
256+ }
257+ }
258+ }
259+ // Applied to white, as the shader would: a column vector times mat3.
260+ var out [3]float64
261+ for row := range 3 {
262+ for col := range 3 {
263+ out[row] += fromS3132(binary.LittleEndian.Uint64(blob[(row*4+col)*8:]))
264+ }
265+ }
266+ for row, want := range [3]float64{
267+ cols[0] + cols[3] + cols[6], cols[1] + cols[4] + cols[7], cols[2] + cols[5] + cols[8],
268+ } {
269+ if math.Abs(out[row]-want) > 1e-9 {
270+ t.Errorf("white channel %d = %v, shader %v", row, out[row], want)
271+ }
272+ }
273+}
274+
275+func TestPlaneColor(t *testing.T) {
276+ pq := ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}
277+ srgb := ports.SurfaceColor{TF: ports.ColorTFSRGB, Primaries: ports.ColorPrimariesSRGB}
278+ ext := ports.SurfaceColor{TF: ports.ColorTFExtendedLinear, Primaries: ports.ColorPrimariesSRGB}
279+ gamma := ports.SurfaceColor{TF: 10, Primaries: ports.ColorPrimariesSRGB}
280+ p3 := ports.SurfaceColor{TF: ports.ColorTFSRGB, Primaries: 7}
281+ pipe := &colorPipeline{}
282+ for _, tc := range []struct {
283+ name string
284+ c ports.SurfaceColor
285+ format uint32
286+ opaque bool
287+ hdr bool
288+ pipe *colorPipeline
289+ want colorMode
290+ reason string
291+ }{
292+ {"SDR output, sRGB", ports.SurfaceColor{}, fourccXRGB, true, false, nil, colorBypass, ""},
293+ {"SDR output ignores a pipeline", srgb, fourccXRGB, true, false, pipe, colorBypass, ""},
294+ {"SDR output, PQ", pq, fourccXR30, true, false, nil, colorBypass, "sdr_pq_content"},
295+ {"SDR output, YUV", ports.SurfaceColor{}, fourccNV12, true, false, nil, colorBypass, "yuv"},
296+ {"HDR, PQ 10-bit", pq, fourccXR30, true, true, pipe, colorBypass, ""},
297+ {"HDR, PQ 10-bit with alpha", pq, fourccAR30, false, true, nil, colorBypass, ""},
298+ {"HDR, PQ 8-bit", pq, fourccXRGB, true, true, pipe, colorBypass, "hdr_format"},
299+ {"HDR, PQ YUV", pq, fourccP010, true, true, pipe, colorBypass, "yuv"},
300+ {"HDR, unspecified colour", ports.SurfaceColor{}, fourccXRGB, true, true, pipe, colorSDRToPQ, ""},
301+ {"HDR, sRGB", srgb, fourccXRGB, true, true, pipe, colorSDRToPQ, ""},
302+ {"HDR, sRGB, 10-bit buffer", srgb, fourccXR30, true, true, pipe, colorSDRToPQ, ""},
303+ {"HDR, sRGB, no pipeline", srgb, fourccXRGB, true, true, nil, colorBypass, "hdr_sdr_content"},
304+ {"HDR, sRGB with alpha", srgb, fourccARGB, false, true, pipe, colorBypass, "hdr_sdr_content"},
305+ {"HDR, extended linear", ext, fourccXRGB, true, true, pipe, colorBypass, "hdr_sdr_content"},
306+ {"HDR, other transfer function", gamma, fourccXRGB, true, true, pipe, colorBypass, "hdr_sdr_content"},
307+ {"HDR, other primaries", p3, fourccXRGB, true, true, pipe, colorBypass, "hdr_sdr_content"},
308+ {"HDR, sRGB YUV", srgb, fourccNV12, true, true, pipe, colorBypass, "yuv"},
309+ } {
310+ t.Run(tc.name, func(t *testing.T) {
311+ mode, reason := planeColor(tc.c, tc.format, tc.opaque, tc.hdr, tc.pipe)
312+ if mode != tc.want || reason != tc.reason {
313+ t.Fatalf("mode %d reason %q, want %d %q", mode, reason, tc.want, tc.reason)
314+ }
315+ })
316+ }
317+}
318+
319+// Colour test outputs.
320+
321+// colorOutput is an HDR output whose primary (and overlay) plane matches the
322+// amdgpu pipeline. rule, when set, is the driver model every commit meets.
323+func colorOutput(t *testing.T, overlay bool, rule func(*atomicReq, uint32) error) (*Output, *mockkms, *[]commitRec) {
324+ t.Helper()
325+ o, k, commits, _ := testOutputRules(t, rule)
326+ props := maps0(planeProps)
327+ props["zpos"] = pZpos
328+ o.primary.props = props
329+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
330+ colorPlane(k, o.primary, pColorPipe, opBase, amdgpuChain)
331+ if overlay {
332+ ov := &plane{id: tOverlay, typ: planeOverlay, props: props, zposValue: 2, formats: []ports.DMABufFormat{{Format: fourccXRGB}}}
333+ colorPlane(k, ov, pColorPipeOverlay, opBase+20, amdgpuChain)
334+ o.stray = []*plane{ov}
335+ o.pickOverlay()
336+ if o.overlay == nil {
337+ t.Fatal("no overlay")
338+ }
339+ }
340+ o.hdr.on = true
341+ o.readColor()
342+ o.ctmBlob, o.colorMult = 61, s3132(203.0/80)
343+ if o.primary.pipeline == nil || overlay && o.overlay.pipeline == nil {
344+ t.Fatal("pipeline not matched")
345+ }
346+ return o, k, commits
347+}
348+
349+// colorOps are the colorop objects a commit touches.
350+func colorOps(c commitRec) []uint32 {
351+ var out []uint32
352+ for _, obj := range c.req.objs {
353+ if obj >= opBase {
354+ out = append(out, obj)
355+ }
356+ }
357+ return out
358+}
359+
360+func sdrContent(o *Output) ports.SurfaceContent {
361+ o.clientFBs[9] = &clientFB{fbID: 80}
362+ return ports.SurfaceContent{ID: 1, Width: 200, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{ID: 9, Format: fourccXRGB}}
363+}
364+
365+func TestColorCommitTransitions(t *testing.T) {
366+ o, _, commits := colorOutput(t, false, nil)
367+ c := sdrContent(o)
368+ c.Async = true
369+ o.shown = 9
370+ cp := o.primary.pipeline
371+ last := func() commitRec { return (*commits)[len(*commits)-1] }
372+
373+ // First SDR scanout writes the pipeline, never async even though the
374+ // buffer asks for it: only FB_ID may change in an async flip.
375+ if ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
376+ t.Fatalf("ok=%v err=%v", ok, err)
377+ }
378+ first := last()
379+ if first.flags&flipAsyncFlag != 0 {
380+ t.Fatal("a colour change went async")
381+ }
382+ if v, ok := first.req.value(tPrimary, pColorPipe); !ok || v != cp.id {
383+ t.Fatalf("COLOR_PIPELINE %d %v, want %d", v, ok, cp.id)
384+ }
385+ for _, s := range []struct {
386+ op, prop uint32
387+ want uint64
388+ }{
389+ {cp.srgb.op, cp.srgb.bypass, 0}, {cp.srgb.op, cp.srgb.prop, cp.srgb.curve},
390+ {cp.mult.op, cp.mult.bypass, 0}, {cp.mult.op, cp.mult.prop, s3132(203.0 / 80)},
391+ {cp.ctm.op, cp.ctm.bypass, 0}, {cp.ctm.op, cp.ctm.prop, 61},
392+ {cp.pq.op, cp.pq.bypass, 0}, {cp.pq.op, cp.pq.prop, cp.pq.curve},
393+ } {
394+ if v, ok := first.req.value(s.op, s.prop); !ok || v != s.want {
395+ t.Errorf("colorop %d prop %d = %d %v, want %d", s.op, s.prop, v, ok, s.want)
396+ }
397+ }
398+ for _, other := range cp.others {
399+ if v, ok := first.req.value(other.op, other.bypass); !ok || v != 1 {
400+ t.Errorf("other colorop %d BYPASS %d %v, want 1", other.op, v, ok)
401+ }
402+ }
403+ if !o.primary.colorKnown || o.primary.colorApplied != colorSDRToPQ {
404+ t.Fatalf("applied %v known %v", o.primary.colorApplied, o.primary.colorKnown)
405+ }
406+
407+ // Same mode again: nothing is written, and the flip may be async.
408+ o.frame.endPending()
409+ o.vrrOn = true
410+ if ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
411+ t.Fatalf("ok=%v err=%v", ok, err)
412+ }
413+ if second := last(); second.flags&flipAsyncFlag == 0 || len(colorOps(second)) != 0 {
414+ t.Fatalf("steady state: flags %#x colorops %v", second.flags, colorOps(second))
415+ } else if _, ok := second.req.value(tPrimary, pColorPipe); ok {
416+ t.Fatal("steady state wrote COLOR_PIPELINE")
417+ }
418+
419+ // A composed frame sets Bypass once.
420+ o.frame.endPending()
421+ if err := o.commitFrame(70, nil, true, false, pendingFrame{}); err != nil {
422+ t.Fatal(err)
423+ }
424+ composed := last()
425+ if composed.flags&flipAsyncFlag != 0 {
426+ t.Fatal("the return to Bypass went async")
427+ }
428+ if v, ok := composed.req.value(tPrimary, pColorPipe); !ok || v != 0 || len(colorOps(composed)) != 0 {
429+ t.Fatalf("composed: COLOR_PIPELINE %d %v colorops %v", v, ok, colorOps(composed))
430+ }
431+ o.frame.endPending()
432+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
433+ t.Fatal(err)
434+ }
435+ again := last()
436+ if _, ok := again.req.value(tPrimary, pColorPipe); ok {
437+ t.Fatal("Bypass written twice")
438+ }
439+}
440+
441+// A plane that applies the pipeline never sees colorop properties in a
442+// cursor or VRR commit.
443+func TestCommitStateNeverCarriesColorProps(t *testing.T) {
444+ o, _, commits := colorOutput(t, false, nil)
445+ o.primary.colorKnown, o.primary.colorApplied = false, colorSDRToPQ // stale on purpose
446+ o.cursor.image = true
447+ o.cursor.Move(3, 4)
448+ if err := o.commitState(true); err != nil {
449+ t.Fatal(err)
450+ }
451+ c := (*commits)[0]
452+ if _, ok := c.req.value(tPrimary, pColorPipe); ok || len(colorOps(c)) != 0 {
453+ t.Fatalf("state commit carries colour: %+v", c.req)
454+ }
455+ if _, ok := c.req.value(tCursor, 14); !ok {
456+ t.Fatal("cursor not moved")
457+ }
458+}
459+
460+// Protected frames, power off, Close and every modeset leave Bypass, on the
461+// overlay too, whatever was applied or known.
462+func TestColorBypassOnComposedProtectedOffCloseAndModeset(t *testing.T) {
463+ apply := func(o *Output) {
464+ for _, p := range []*plane{o.primary, o.overlay} {
465+ p.colorKnown, p.colorApplied = true, colorSDRToPQ
466+ }
467+ }
468+ bypassed := func(t *testing.T, c commitRec) {
469+ t.Helper()
470+ for _, p := range []struct{ plane, prop uint32 }{{tPrimary, pColorPipe}, {tOverlay, pColorPipeOverlay}} {
471+ if v, ok := c.req.value(p.plane, p.prop); !ok || v != 0 {
472+ t.Errorf("plane %d COLOR_PIPELINE %d %v, want Bypass", p.plane, v, ok)
473+ }
474+ }
475+ if ops := colorOps(c); len(ops) != 0 {
476+ t.Errorf("Bypass commit touches colorops %v", ops)
477+ }
478+ }
479+ t.Run("power off", func(t *testing.T) {
480+ o, _, commits := colorOutput(t, true, nil)
481+ apply(o)
482+ if err := o.powerOff(); err != nil {
483+ t.Fatal(err)
484+ }
485+ bypassed(t, (*commits)[0])
486+ if !o.primary.colorKnown || o.primary.colorApplied != colorBypass || o.overlay.colorApplied != colorBypass {
487+ t.Fatal("power off did not record Bypass")
488+ }
489+ })
490+ t.Run("close", func(t *testing.T) {
491+ o, k, commits := colorOutput(t, true, nil)
492+ k.EXPECT().rmFB(mock.Anything).Return(nil)
493+ k.EXPECT().destroyBlob(uint32(61)).Return(nil).Once()
494+ // Unknown state: Close still writes Bypass.
495+ o.Close()
496+ bypassed(t, (*commits)[0])
497+ if o.ctmBlob != 0 {
498+ t.Fatal("CTM blob kept")
499+ }
500+ })
501+ t.Run("modeset", func(t *testing.T) {
502+ o, k, commits := colorOutput(t, true, nil)
503+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
504+ k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, 0}}, nil)
505+ apply(o)
506+ o.primary.verdicts = []colorVerdict{{format: fourccXRGB}}
507+ if err := o.modeset(); err != nil {
508+ t.Fatal(err)
509+ }
510+ bypassed(t, (*commits)[0])
511+ if o.primary.colorKnown || o.overlay.colorKnown || len(o.primary.verdicts) != 0 {
512+ t.Fatal("a modeset kept the colour state or the refusals")
513+ }
514+ })
515+ t.Run("protected frame", func(t *testing.T) {
516+ o, _, commits := colorOutput(t, true, nil)
517+ apply(o)
518+ o.protected = true
519+ if err := o.commitWith(70, nil, false, false, pendingFrame{}, overlayWin{}); err != nil {
520+ t.Fatal(err)
521+ }
522+ c := (*commits)[0]
523+ if v, ok := c.req.value(tPrimary, pColorPipe); !ok || v != 0 {
524+ t.Fatalf("protected frame: COLOR_PIPELINE %d %v", v, ok)
525+ }
526+ })
527+}
528+
529+// The overlay's pipeline is driven with the primary on Bypass; a frame
530+// without the overlay turns it back to Bypass once.
531+func TestColorOverlayTransitions(t *testing.T) {
532+ o, _, commits := colorOutput(t, true, nil)
533+ o.clientFBs[9] = &clientFB{fbID: 88}
534+ ov := overlayWin{id: 2, fb: 88, buf: 9, w: 100, h: 100, rect: ports.Rect{X: 100, W: 100, H: 100}, color: colorUse{mode: colorSDRToPQ, format: fourccXRGB}}
535+ if err := o.commitWith(70, nil, false, false, pendingFrame{}, ov); err != nil {
536+ t.Fatal(err)
537+ }
538+ c := (*commits)[0]
539+ cp := o.overlay.pipeline
540+ if v, ok := c.req.value(tOverlay, pColorPipeOverlay); !ok || v != cp.id {
541+ t.Fatalf("overlay COLOR_PIPELINE %d %v, want %d", v, ok, cp.id)
542+ }
543+ if v, ok := c.req.value(tPrimary, pColorPipe); !ok || v != 0 {
544+ t.Fatalf("composed primary COLOR_PIPELINE %d %v, want Bypass", v, ok)
545+ }
546+ o.frame.endPending()
547+ if err := o.commitWith(70, nil, false, false, pendingFrame{}, ov); err != nil {
548+ t.Fatal(err)
549+ }
550+ if again := (*commits)[1]; len(colorOps(again)) != 0 {
551+ t.Fatal("overlay colour written twice")
552+ } else if _, ok := again.req.value(tOverlay, pColorPipeOverlay); ok {
553+ t.Fatal("overlay COLOR_PIPELINE written twice")
554+ }
555+ o.frame.endPending()
556+ if err := o.commitWith(70, nil, false, false, pendingFrame{}, overlayWin{}); err != nil {
557+ t.Fatal(err)
558+ }
559+ if v, ok := (*commits)[2].req.value(tOverlay, pColorPipeOverlay); !ok || v != 0 {
560+ t.Fatalf("overlay left without Bypass: %d %v", v, ok)
561+ }
562+}
563+
564+// refusePipeline is a driver that refuses any request selecting a pipeline
565+// on a plane with EINVAL, TEST_ONLY included.
566+func refusePipeline(r *atomicReq, _ uint32) error {
567+ for _, obj := range [...]struct{ plane, prop uint32 }{{tPrimary, pColorPipe}, {tOverlay, pColorPipeOverlay}} {
568+ if v, ok := r.value(obj.plane, obj.prop); ok && v != 0 {
569+ return unix.EINVAL
570+ }
571+ }
572+ return nil
573+}
574+
575+func hdrScanoutScene() (ports.Scene, map[ports.WindowID]ports.SurfaceContent) {
576+ s, c := fullscreenScene()
577+ return s, c
578+}
579+
580+func countTests(commits []commitRec) int {
581+ n := 0
582+ for _, c := range commits {
583+ if c.flags&atomicTestOnly != 0 {
584+ n++
585+ }
586+ }
587+ return n
588+}
589+
590+// SDR scanout on HDR is tested before its first use. A refusal is cached per
591+// plane, format and cursor state and is never tested again; a modeset asks
592+// the driver again.
593+func TestColorRefusalIsTestedOnceAndCached(t *testing.T) {
594+ o, k, commits := colorOutput(t, false, refusePipeline)
595+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
596+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe()
597+ s, surfaces := hdrScanoutScene()
598+ decide := func() string {
599+ t.Helper()
600+ if fb, _, _ := o.scanoutFrame(s, surfaces); fb != 0 {
601+ t.Fatalf("refused pipeline scanned out (fb %d)", fb)
602+ }
603+ return o.reason
604+ }
605+ if got := decide(); got != "color_refused" || countTests(*commits) != 1 {
606+ t.Fatalf("reason %q after %d tests", got, countTests(*commits))
607+ }
608+ for range 3 {
609+ decide()
610+ }
611+ if n := countTests(*commits); n != 1 {
612+ t.Fatalf("a cached refusal was tested %d times", n)
613+ }
614+ if cfb := o.clientFBs[9]; cfb.failed != "" {
615+ t.Fatalf("buffer marked failed: %q", cfb.failed)
616+ }
617+ // The cursor shown is another state: asked once more, then cached.
618+ o.cursor.image = true
619+ o.cursor.Move(1, 1)
620+ decide()
621+ decide()
622+ if n := countTests(*commits); n != 2 {
623+ t.Fatalf("%d tests with the cursor shown, want 2", n)
624+ }
625+ // A modeset asks the driver again.
626+ o.forgetColorVerdicts()
627+ decide()
628+ if n := countTests(*commits); n != 3 {
629+ t.Fatalf("a modeset did not ask the driver again: %d tests", n)
630+ }
631+}
632+
633+// KMS accepts the colour test and refuses the real commit: the frame is
634+// composed, the refusal cached, the buffer not blamed.
635+func TestColorRefusalOfCommittedFrame(t *testing.T) {
636+ // The first request that selects the pipeline outside TEST_ONLY fails.
637+ o, k, commits := colorOutput(t, false, func(r *atomicReq, flags uint32) error {
638+ if flags&atomicTestOnly == 0 {
639+ return refusePipeline(r, flags)
640+ }
641+ return nil
642+ })
643+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
644+ s, surfaces := hdrScanoutScene()
645+ d := o.decideFrame(s, surfaces, false)
646+ if d.fb != 80 || d.color != colorSDRToPQ {
647+ t.Fatalf("decision %+v", d)
648+ }
649+ ok, err := o.commitScanoutRect(d.fb, d.content, pendingFrame{}, d.rect, d.color)
650+ if ok || err != nil || o.reason != "color_refused" {
651+ t.Fatalf("ok=%v err=%v reason %q", ok, err, o.reason)
652+ }
653+ if o.frame.pendingCommit() || o.primary.colorKnown {
654+ t.Fatal("a refused commit changed the state")
655+ }
656+ before := len(*commits)
657+ if d := o.decideFrame(s, surfaces, false); d.fb != 0 || o.reason != "color_refused" || len(*commits) != before {
658+ t.Fatalf("refusal not cached: fb %d reason %q commits %d->%d", d.fb, o.reason, before, len(*commits))
659+ }
660+ if cfb := o.clientFBs[9]; cfb.failed != "" || cfb.noAsync {
661+ t.Fatalf("buffer blamed: %+v", cfb)
662+ }
663+}
664+
665+// A buffer KMS refuses on its own is still blamed on the buffer, not the
666+// pipeline.
667+func TestColorRefusalOfTheBufferStaysTheBuffers(t *testing.T) {
668+ o, k, _ := colorOutput(t, false, func(r *atomicReq, flags uint32) error {
669+ // The driver takes the pipeline test, then refuses the buffer
670+ // whatever the pipeline: the commit and its Bypass test fail.
671+ if v, ok := r.value(tPrimary, pColorPipe); flags&atomicTestOnly == 0 || ok && v == 0 {
672+ return unix.EINVAL
673+ }
674+ return nil
675+ })
676+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
677+ s, surfaces := hdrScanoutScene()
678+ d := o.decideFrame(s, surfaces, false)
679+ if ok, err := o.commitScanoutRect(d.fb, d.content, pendingFrame{}, d.rect, d.color); ok || err != nil {
680+ t.Fatalf("ok=%v err=%v", ok, err)
681+ }
682+ if cfb := o.clientFBs[9]; cfb.failed != "flip_refused" || o.reason != "flip_refused" {
683+ t.Fatalf("failed %q reason %q", cfb.failed, o.reason)
684+ }
685+}
686+
687+// A cursor commit refused while a plane shows the pipeline caches the
688+// refusal for the cursor state and has the next frame composed.
689+func TestColorCursorConflict(t *testing.T) {
690+ o, _, _ := colorOutput(t, false, func(r *atomicReq, flags uint32) error {
691+ if v, ok := r.value(tCursor, pFB); ok && v != 0 {
692+ return unix.EINVAL
693+ }
694+ return nil
695+ })
696+ o.primary.colorKnown, o.primary.colorApplied, o.primary.colorFormat = true, colorSDRToPQ, fourccXRGB
697+ o.cursor.image = true
698+ o.cursor.Move(5, 5)
699+ if err := o.commitState(false); err != errOverlayDropped {
700+ t.Fatalf("commitState: %v", err)
701+ }
702+ if ok, known := o.primary.verdict(fourccXRGB, true); !known || ok {
703+ t.Fatal("refusal with the cursor not cached")
704+ }
705+ if o.cursor.off {
706+ t.Fatal("the cursor was blamed")
707+ }
708+}
709+
710+// Overlay: allowed in HDR when the colour resolves; a refusal is cached.
711+func TestColorOverlayInHDR(t *testing.T) {
712+ o, k, commits := colorOutput(t, true, refusePipeline)
713+ o.primary.pipeline = nil // only the overlay converts
714+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(88), nil).Maybe()
715+ s, surfaces := overlayScene()
716+ // PQ content and SDR content without alpha are both candidates now.
717+ ov, rest := o.overlayFrame(s, surfaces)
718+ if ov.fb != 88 || ov.color.mode != colorSDRToPQ || len(rest.Windows) != 1 {
719+ t.Fatalf("overlay %+v", ov)
720+ }
721+ if o.testOverlay(70, ov) {
722+ t.Fatal("the driver's refusal was taken")
723+ }
724+ if o.overlayReason != "color_refused" {
725+ t.Fatalf("reason %q", o.overlayReason)
726+ }
727+ if cfb := o.clientFBs[9]; cfb.overlayFailed != "" {
728+ t.Fatalf("buffer blamed: %q", cfb.overlayFailed)
729+ }
730+ tests := countTests(*commits)
731+ if ov, rest := o.overlayFrame(s, surfaces); ov.fb != 0 || o.overlayReason != "color_refused" || len(rest.Windows) != 2 || countTests(*commits) != tests {
732+ t.Fatalf("not cached: %+v reason %q", ov, o.overlayReason)
733+ }
734+ // Without a pipeline an SDR window stays composed on HDR.
735+ o.overlay.pipeline = nil
736+ o.forgetColorVerdicts()
737+ if ov, _ := o.overlayFrame(s, surfaces); ov.fb != 0 || o.overlayReason != "no_candidate" {
738+ t.Fatalf("no pipeline: %+v reason %q", ov, o.overlayReason)
739+ }
740+}
741+
742+func TestSetupColor(t *testing.T) {
743+ t.Run("HDR makes the matrix blob and the multiplier", func(t *testing.T) {
744+ o, k, _ := colorOutput(t, false, nil)
745+ o.ctmBlob, o.colorMult = 0, 0
746+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 160}
747+ o.hdr.cap.Capable = true
748+ k.EXPECT().createBlob(mock.MatchedBy(func(b []byte) bool { return slices.Equal(b, ctmBytes(bt709ToBT2020)) })).Return(uint32(61), nil).Once()
749+ o.setupColor()
750+ if o.ctmBlob != 61 || o.colorMult != 2<<32 {
751+ t.Fatalf("blob %d multiplier %#x", o.ctmBlob, o.colorMult)
752+ }
753+ })
754+ t.Run("blob failure turns the pipelines off", func(t *testing.T) {
755+ o, k, _ := colorOutput(t, true, nil)
756+ o.ctmBlob = 0
757+ o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 203}
758+ o.hdr.cap.Capable = true
759+ k.EXPECT().createBlob(mock.Anything).Return(uint32(0), unix.ENOMEM).Once()
760+ o.setupColor()
761+ if o.primary.pipeline != nil || o.overlay.pipeline != nil || o.ctmBlob != 0 {
762+ t.Fatal("pipelines kept without their blob")
763+ }
764+ })
765+ t.Run("SDR output keeps Bypass", func(t *testing.T) {
766+ o, _, _ := colorOutput(t, false, nil)
767+ o.ctmBlob = 0
768+ o.hdr.settings.Enabled = false
769+ o.setupColor()
770+ if o.primary.pipeline != nil || o.ctmBlob != 0 {
771+ t.Fatal("SDR output set up a pipeline")
772+ }
773+ })
774+}
775+
776+// A frame refused because of its pipeline never turns the hardware cursor
777+// off: only a refusal the frame has with Bypass does.
778+func TestColorRefusalDoesNotDisableTheCursor(t *testing.T) {
779+ // The driver refuses the pipeline, and also the cursor whenever it is
780+ // shown with the pipeline.
781+ rule := func(r *atomicReq, flags uint32) error {
782+ if v, ok := r.value(tPrimary, pColorPipe); ok && v != 0 && flags&atomicTestOnly == 0 {
783+ return unix.EINVAL
784+ }
785+ return nil
786+ }
787+ o, k, _ := colorOutput(t, false, rule)
788+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
789+ o.cursor.image = true
790+ o.cursor.Move(5, 5)
791+ c := sdrContent(o)
792+ ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ)
793+ if ok || err != nil || o.reason != "color_refused" {
794+ t.Fatalf("ok=%v err=%v reason %q", ok, err, o.reason)
795+ }
796+ if o.cursor.off {
797+ t.Fatal("a pipeline refusal turned the hardware cursor off")
798+ }
799+ if v, known := o.primary.verdict(fourccXRGB, true); !known || v {
800+ t.Fatal("refusal with the cursor not cached")
801+ }
802+ // A cursor the driver refuses on its own, pipeline or not, is still turned off.
803+ o2, k2, _ := colorOutput(t, false, func(r *atomicReq, flags uint32) error {
804+ if v, ok := r.value(tCursor, pFB); ok && v != 0 {
805+ return unix.EINVAL
806+ }
807+ return nil
808+ })
809+ k2.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
810+ o2.cursor.image = true
811+ o2.cursor.Move(5, 5)
812+ if ok, err := o2.commitScanoutRect(80, sdrContent(o2), pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
813+ t.Fatalf("ok=%v err=%v", ok, err)
814+ }
815+ if !o2.cursor.off {
816+ t.Fatal("a cursor refusal did not turn the cursor off")
817+ }
818+}
819+
820+// The colour test differs from the failed commit in colour alone. A frame
821+// refused for its content hint is not blamed on the pipeline; the hint is
822+// disabled as before. A frame refused for its pipeline, with the hint wanted,
823+// is blamed on the pipeline and the hint stays.
824+func TestColorRefusalBlamesOnlyColour(t *testing.T) {
825+ const pContent = 70
826+ setup := func(t *testing.T, rule func(*atomicReq, uint32) error) (*Output, ports.SurfaceContent) {
827+ o, k, _ := colorOutput(t, false, rule)
828+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
829+ o.contentProp, o.contentValues = pContent, [5]uint64{0, 1, 2, 3, 4}
830+ o.contentWanted = 4 // wanted Game, the applied value is still the first
831+ return o, sdrContent(o)
832+ }
833+ hasHint := func(r *atomicReq) bool { v, ok := r.value(tConn, pContent); return ok && v == 4 }
834+ usesPipeline := func(r *atomicReq) bool { v, ok := r.value(tPrimary, pColorPipe); return ok && v != 0 }
835+ t.Run("the hint is refused, the pipeline is accepted", func(t *testing.T) {
836+ o, c := setup(t, func(r *atomicReq, flags uint32) error {
837+ if hasHint(r) {
838+ return unix.EINVAL
839+ }
840+ return nil
841+ })
842+ ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ)
843+ if !ok || err != nil {
844+ t.Fatalf("ok=%v err=%v reason %q", ok, err, o.reason)
845+ }
846+ if o.contentProp != 0 {
847+ t.Fatal("the content hint was not disabled")
848+ }
849+ if _, known := o.primary.verdict(fourccXRGB, false); known || o.reason == "color_refused" {
850+ t.Fatal("the pipeline was blamed for the hint")
851+ }
852+ if !o.primary.colorKnown || o.primary.colorApplied != colorSDRToPQ {
853+ t.Fatal("the retried frame did not apply the pipeline")
854+ }
855+ })
856+ t.Run("the pipeline is refused, the hint is accepted", func(t *testing.T) {
857+ o, c := setup(t, func(r *atomicReq, flags uint32) error {
858+ if usesPipeline(r) && flags&atomicTestOnly == 0 {
859+ return unix.EINVAL
860+ }
861+ return nil
862+ })
863+ ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ)
864+ if ok || err != nil || o.reason != "color_refused" {
865+ t.Fatalf("ok=%v err=%v reason %q", ok, err, o.reason)
866+ }
867+ if v, known := o.primary.verdict(fourccXRGB, false); !known || v {
868+ t.Fatal("the pipeline refusal was not cached")
869+ }
870+ if o.contentProp != pContent {
871+ t.Fatal("the content hint was disabled for the pipeline's refusal")
872+ }
873+ })
874+}
A internal/adapters/drm/commit_alloc_test.go +280 -0
@@ -0,0 +1,280 @@
1+package drm
2+
3+import (
4+ "runtime"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/stretchr/testify/mock"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// mallocsPerRun is testing.AllocsPerRun without the integer truncation.
13+func mallocsPerRun(runs int, f func()) float64 {
14+ f()
15+ var before, after runtime.MemStats
16+ runtime.GC()
17+ runtime.ReadMemStats(&before)
18+ for range runs {
19+ f()
20+ }
21+ runtime.ReadMemStats(&after)
22+ return float64(after.Mallocs-before.Mallocs) / float64(runs)
23+}
24+
25+func scanoutAllocsOutput(t *testing.T) (*Output, ports.SurfaceContent) {
26+ o, _, _ := testOutput(t)
27+ o.cursor.image = true
28+ o.cursor.Move(12, 34)
29+ o.clientFBs[9] = &clientFB{fbID: 80}
30+ return o, ports.SurfaceContent{ID: 1, Width: 200, Height: 100, DMABuf: &ports.DMABuf{ID: 9}}
31+}
32+
33+// A steady-state direct-scanout commit builds its request, and the flat
34+// ioctl arrays, in buffers it reuses.
35+//
36+// The real kmsDevice on an invalid fd runs the whole request path up to the
37+// failing ioctl: exactly zero allocations. testify's own bookkeeping per
38+// mocked call allocates, so the success path (lifecycle, cursor, VRR) is
39+// measured on the generated mockkms against the same mocked call made
40+// directly: the commit may add nothing to it.
41+func TestCommitFrameAllocations(t *testing.T) {
42+ t.Run("request and ioctl arrays", func(t *testing.T) {
43+ o, c := scanoutAllocsOutput(t)
44+ o.k = kmsDevice{fd: -1}
45+ rect := fullPlaneRect(200, 100)
46+ commit := func() { _, _ = o.commitScanoutRect(80, c, pendingFrame{}, rect, colorBypass) }
47+ commit() // grow the buffers
48+ if len(o.frameReq.objs) != 3 || len(o.frameReq.counts) != 3 || len(o.frameReq.flatProps) == 0 {
49+ t.Fatalf("frame request %+v", o.frameReq)
50+ }
51+ if n := testing.AllocsPerRun(100, commit); n != 0 {
52+ t.Fatalf("direct scanout commit: %.1f allocs, want 0", n)
53+ }
54+ })
55+ t.Run("success path", func(t *testing.T) {
56+ o, c := scanoutAllocsOutput(t)
57+ k := newMockkms(t)
58+ k.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).Return(nil)
59+ o.k = k // no recorder: it copies every request
60+ rect := fullPlaneRect(200, 100)
61+ commit := func() {
62+ if _, err := o.commitScanoutRect(80, c, pendingFrame{}, rect, colorBypass); err != nil {
63+ t.Fatal(err)
64+ }
65+ }
66+ commit()
67+ // The mock boxes flags and userData (a fresh serial per commit):
68+ // the reference call passes them as variables too.
69+ serial, flags := uint64(1<<20), uint32(atomicNonblock|flipEventFlag)
70+ ref := mallocsPerRun(1000, func() {
71+ serial += 1 << userKindBits
72+ mockCallOuter(k, &o.frameReq, flags, serial|userFrame)
73+ })
74+ got := mallocsPerRun(1000, commit)
75+ t.Logf("commit %.2f allocs, the mocked call alone %.2f", got, ref)
76+ // A heap profile puts the whole difference inside testify (its
77+ // call-site lookup allocates per stack frame, and the stacks
78+ // differ by a frame or so); the first subtest proves the commit
79+ // itself allocates zero. A leaked request would add one per
80+ // slice and grow far past this margin (before: 9 more).
81+ if got-ref > 3 {
82+ t.Fatalf("direct scanout commit: %.2f allocs, %.2f of them the mock's", got, ref)
83+ }
84+ })
85+}
86+
87+// mockCallOuter and mockCallInner stand where commitScanoutRect and
88+// commitWithRect stand: testify's call-site lookup allocates per stack
89+// frame, so the reference call must be as deep as the real one.
90+//
91+//go:noinline
92+func mockCallOuter(k *mockkms, req *atomicReq, flags uint32, user uint64) {
93+ mockCallInner(k, req, flags, user)
94+}
95+
96+//go:noinline
97+func mockCallInner(k *mockkms, req *atomicReq, flags uint32, user uint64) {
98+ _ = k.commit(req, flags, user)
99+}
100+
101+// A retry resets the frame request and builds it again: the second commit
102+// carries the retried frame, not the first one's leftovers, and the caller
103+// never reads the request after the recursive call.
104+func TestCommitFrameRetryRebuildsRequest(t *testing.T) {
105+ o, _, commits := testOutput(t, unix.EINVAL)
106+ o.shown, o.vrrOn = 1, true
107+ o.clientFBs[9] = &clientFB{fbID: 80}
108+ c := ports.SurfaceContent{DMABuf: &ports.DMABuf{ID: 9}, Async: true}
109+ // The async attempt is refused, then retried synchronously with the
110+ // full plane state in the same request object.
111+ ok, err := o.commitScanout(80, c, pendingFrame{})
112+ if !ok || err != nil || len(*commits) != 2 {
113+ t.Fatalf("ok=%v err=%v commits=%d", ok, err, len(*commits))
114+ }
115+ first, second := (*commits)[0], (*commits)[1]
116+ if len(first.req.objs) != 1 || first.req.objs[0] != tPrimary || len(first.req.props[0]) != 1 {
117+ t.Fatalf("async request %+v", first.req)
118+ }
119+ if v, ok := second.req.value(tPrimary, pFB); !ok || v != 80 {
120+ t.Fatalf("retry primary fb %d %v", v, ok)
121+ }
122+ if _, ok := second.req.value(tPrimary, 17); !ok {
123+ t.Fatal("retry lost the plane geometry")
124+ }
125+ if _, ok := second.req.value(tCrtc, pVRR); !ok {
126+ t.Fatal("retry lost VRR")
127+ }
128+ // The retry's request shows no stale entry twice.
129+ for i, ps := range second.req.props {
130+ seen := map[uint32]bool{}
131+ for _, p := range ps {
132+ if seen[p.prop] {
133+ t.Fatalf("object %d: property %d twice", second.req.objs[i], p.prop)
134+ }
135+ seen[p.prop] = true
136+ }
137+ }
138+}
139+
140+// The cursor-refused retry rebuilds the request with the cursor off while
141+// the TEST_ONLY probe uses its own request.
142+func TestCommitFrameCursorRetryKeepsProbeApart(t *testing.T) {
143+ var frameSeen []*atomicReq
144+ o, _, _ := testOutput(t)
145+ o.cursor.image = true
146+ o.cursor.Move(1, 2)
147+ // First real commit refused with EINVAL, the cursor probe refused too,
148+ // then the retry without the cursor passes.
149+ k2 := newMockkms(t)
150+ n := 0
151+ k2.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(r *atomicReq, flags uint32, _ uint64) error {
152+ n++
153+ frameSeen = append(frameSeen, r)
154+ if n <= 2 {
155+ return unix.EINVAL
156+ }
157+ return nil
158+ })
159+ o.k = k2
160+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
161+ t.Fatal(err)
162+ }
163+ if n != 3 || !o.cursor.off {
164+ t.Fatalf("commits %d cursorOff %v", n, o.cursor.off)
165+ }
166+ if frameSeen[0] != &o.frameReq || frameSeen[1] == &o.frameReq || frameSeen[2] != &o.frameReq {
167+ t.Fatal("probe must not reuse the frame request, retry must")
168+ }
169+ if _, ok := o.frameReq.value(tCursor, pFB); !ok {
170+ t.Fatal("cursor plane not turned off in the retry")
171+ }
172+ if v, _ := o.frameReq.value(tCursor, pFB); v != 0 {
173+ t.Fatalf("retry still shows the cursor: fb %d", v)
174+ }
175+}
176+
177+// kmsDevice.commit fills the request's own scratch: once grown it allocates
178+// nothing. The bad fd makes the ioctl fail before reaching a kernel.
179+func TestKMSDeviceCommitAllocations(t *testing.T) {
180+ k := kmsDevice{fd: -1}
181+ req := &atomicReq{}
182+ build := func() {
183+ req.reset()
184+ for i := range 3 {
185+ obj := uint32(10 + i)
186+ for p := range 8 {
187+ req.set(obj, uint32(p+1), uint64(p))
188+ }
189+ }
190+ }
191+ build()
192+ if err := k.commit(req, atomicTestOnly, 0); err == nil {
193+ t.Fatal("ioctl on fd -1 succeeded")
194+ }
195+ if len(req.counts) != 3 || len(req.flatProps) != 24 || len(req.flatVals) != 24 || req.counts[1] != 8 {
196+ t.Fatalf("scratch counts %v props %d vals %d", req.counts, len(req.flatProps), len(req.flatVals))
197+ }
198+ if n := testing.AllocsPerRun(100, func() {
199+ build()
200+ _ = k.commit(req, atomicTestOnly, 0)
201+ }); n != 0 {
202+ t.Fatalf("build and commit: %.1f allocs", n)
203+ }
204+}
205+
206+func TestAtomicReqResetKeepsCapacityAndDropsContent(t *testing.T) {
207+ r := &atomicReq{}
208+ r.set(1, 1, 5)
209+ r.set(1, 2, 6)
210+ r.set(2, 1, 7)
211+ r.reset()
212+ if len(r.objs) != 0 || len(r.props) != 0 {
213+ t.Fatalf("not empty: %+v", r)
214+ }
215+ r.set(2, 9, 1)
216+ if len(r.objs) != 1 || len(r.props) != 1 || len(r.props[0]) != 1 || r.props[0][0] != (propValue{9, 1}) {
217+ t.Fatalf("stale content after reset: %+v", r)
218+ }
219+ if _, ok := r.value(1, 1); ok {
220+ t.Fatal("stale object")
221+ }
222+}
223+
224+// With a plane colour pipeline active, the steady-state frame decision and
225+// commit allocate nothing: the colour state is compared, not rebuilt, and a
226+// cached verdict spares the TEST_ONLY.
227+func TestColorPipelineAllocations(t *testing.T) {
228+ t.Run("scanout decision", func(t *testing.T) {
229+ o, _, _ := colorOutput(t, false, nil)
230+ o.cursor.image = true
231+ o.cursor.Move(12, 34)
232+ o.clientFBs[9] = &clientFB{fbID: 80}
233+ s, surfaces := hdrScanoutScene()
234+ d := o.decideFrame(s, surfaces, false) // first use: TEST_ONLY, then cached
235+ if d.fb != 80 || d.color != colorSDRToPQ {
236+ t.Fatalf("decision %+v reason %q", d, o.reason)
237+ }
238+ if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 {
239+ t.Fatalf("decision with the pipeline: %.1f allocs, want 0", n)
240+ }
241+ })
242+ t.Run("overlay decision", func(t *testing.T) {
243+ o, k, _ := colorOutput(t, true, nil)
244+ o.primary.pipeline = nil
245+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(88), nil).Maybe()
246+ s, surfaces := overlayScene()
247+ if d := o.decideFrame(s, surfaces, false); d.overlay.fb != 88 || d.overlay.color.mode != colorSDRToPQ {
248+ t.Fatalf("decision %+v reason %q", d, o.overlayReason)
249+ }
250+ if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 {
251+ t.Fatalf("overlay decision with the pipeline: %.1f allocs, want 0", n)
252+ }
253+ })
254+ // The real kmsDevice on an invalid fd runs the request path up to the
255+ // failing ioctl (see TestCommitFrameAllocations).
256+ for _, tc := range []struct {
257+ name string
258+ known bool // the plane applies the pipeline already
259+ }{{"commit writing the pipeline", false}, {"steady-state commit", true}} {
260+ t.Run(tc.name, func(t *testing.T) {
261+ o, _, _ := colorOutput(t, false, nil)
262+ c := sdrContent(o)
263+ o.cursor.image = true
264+ o.cursor.Move(12, 34)
265+ o.k = kmsDevice{fd: -1}
266+ rect := fullPlaneRect(200, 100)
267+ commit := func() {
268+ o.primary.colorKnown, o.primary.colorApplied = tc.known, colorSDRToPQ
269+ _, _ = o.commitScanoutRect(80, c, pendingFrame{}, rect, colorSDRToPQ)
270+ }
271+ commit() // grow the buffers
272+ if _, ok := o.frameReq.value(tPrimary, pColorPipe); ok == tc.known {
273+ t.Fatalf("COLOR_PIPELINE in the request: %v, known %v", ok, tc.known)
274+ }
275+ if n := testing.AllocsPerRun(100, commit); n != 0 {
276+ t.Fatalf("%s: %.1f allocs, want 0", tc.name, n)
277+ }
278+ })
279+ }
280+}
@@ -0,0 +1,280 @@
1+package drm
2+
3+import (
4+ "runtime"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/stretchr/testify/mock"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// mallocsPerRun is testing.AllocsPerRun without the integer truncation.
13+func mallocsPerRun(runs int, f func()) float64 {
14+ f()
15+ var before, after runtime.MemStats
16+ runtime.GC()
17+ runtime.ReadMemStats(&before)
18+ for range runs {
19+ f()
20+ }
21+ runtime.ReadMemStats(&after)
22+ return float64(after.Mallocs-before.Mallocs) / float64(runs)
23+}
24+
25+func scanoutAllocsOutput(t *testing.T) (*Output, ports.SurfaceContent) {
26+ o, _, _ := testOutput(t)
27+ o.cursor.image = true
28+ o.cursor.Move(12, 34)
29+ o.clientFBs[9] = &clientFB{fbID: 80}
30+ return o, ports.SurfaceContent{ID: 1, Width: 200, Height: 100, DMABuf: &ports.DMABuf{ID: 9}}
31+}
32+
33+// A steady-state direct-scanout commit builds its request, and the flat
34+// ioctl arrays, in buffers it reuses.
35+//
36+// The real kmsDevice on an invalid fd runs the whole request path up to the
37+// failing ioctl: exactly zero allocations. testify's own bookkeeping per
38+// mocked call allocates, so the success path (lifecycle, cursor, VRR) is
39+// measured on the generated mockkms against the same mocked call made
40+// directly: the commit may add nothing to it.
41+func TestCommitFrameAllocations(t *testing.T) {
42+ t.Run("request and ioctl arrays", func(t *testing.T) {
43+ o, c := scanoutAllocsOutput(t)
44+ o.k = kmsDevice{fd: -1}
45+ rect := fullPlaneRect(200, 100)
46+ commit := func() { _, _ = o.commitScanoutRect(80, c, pendingFrame{}, rect, colorBypass) }
47+ commit() // grow the buffers
48+ if len(o.frameReq.objs) != 3 || len(o.frameReq.counts) != 3 || len(o.frameReq.flatProps) == 0 {
49+ t.Fatalf("frame request %+v", o.frameReq)
50+ }
51+ if n := testing.AllocsPerRun(100, commit); n != 0 {
52+ t.Fatalf("direct scanout commit: %.1f allocs, want 0", n)
53+ }
54+ })
55+ t.Run("success path", func(t *testing.T) {
56+ o, c := scanoutAllocsOutput(t)
57+ k := newMockkms(t)
58+ k.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).Return(nil)
59+ o.k = k // no recorder: it copies every request
60+ rect := fullPlaneRect(200, 100)
61+ commit := func() {
62+ if _, err := o.commitScanoutRect(80, c, pendingFrame{}, rect, colorBypass); err != nil {
63+ t.Fatal(err)
64+ }
65+ }
66+ commit()
67+ // The mock boxes flags and userData (a fresh serial per commit):
68+ // the reference call passes them as variables too.
69+ serial, flags := uint64(1<<20), uint32(atomicNonblock|flipEventFlag)
70+ ref := mallocsPerRun(1000, func() {
71+ serial += 1 << userKindBits
72+ mockCallOuter(k, &o.frameReq, flags, serial|userFrame)
73+ })
74+ got := mallocsPerRun(1000, commit)
75+ t.Logf("commit %.2f allocs, the mocked call alone %.2f", got, ref)
76+ // A heap profile puts the whole difference inside testify (its
77+ // call-site lookup allocates per stack frame, and the stacks
78+ // differ by a frame or so); the first subtest proves the commit
79+ // itself allocates zero. A leaked request would add one per
80+ // slice and grow far past this margin (before: 9 more).
81+ if got-ref > 3 {
82+ t.Fatalf("direct scanout commit: %.2f allocs, %.2f of them the mock's", got, ref)
83+ }
84+ })
85+}
86+
87+// mockCallOuter and mockCallInner stand where commitScanoutRect and
88+// commitWithRect stand: testify's call-site lookup allocates per stack
89+// frame, so the reference call must be as deep as the real one.
90+//
91+//go:noinline
92+func mockCallOuter(k *mockkms, req *atomicReq, flags uint32, user uint64) {
93+ mockCallInner(k, req, flags, user)
94+}
95+
96+//go:noinline
97+func mockCallInner(k *mockkms, req *atomicReq, flags uint32, user uint64) {
98+ _ = k.commit(req, flags, user)
99+}
100+
101+// A retry resets the frame request and builds it again: the second commit
102+// carries the retried frame, not the first one's leftovers, and the caller
103+// never reads the request after the recursive call.
104+func TestCommitFrameRetryRebuildsRequest(t *testing.T) {
105+ o, _, commits := testOutput(t, unix.EINVAL)
106+ o.shown, o.vrrOn = 1, true
107+ o.clientFBs[9] = &clientFB{fbID: 80}
108+ c := ports.SurfaceContent{DMABuf: &ports.DMABuf{ID: 9}, Async: true}
109+ // The async attempt is refused, then retried synchronously with the
110+ // full plane state in the same request object.
111+ ok, err := o.commitScanout(80, c, pendingFrame{})
112+ if !ok || err != nil || len(*commits) != 2 {
113+ t.Fatalf("ok=%v err=%v commits=%d", ok, err, len(*commits))
114+ }
115+ first, second := (*commits)[0], (*commits)[1]
116+ if len(first.req.objs) != 1 || first.req.objs[0] != tPrimary || len(first.req.props[0]) != 1 {
117+ t.Fatalf("async request %+v", first.req)
118+ }
119+ if v, ok := second.req.value(tPrimary, pFB); !ok || v != 80 {
120+ t.Fatalf("retry primary fb %d %v", v, ok)
121+ }
122+ if _, ok := second.req.value(tPrimary, 17); !ok {
123+ t.Fatal("retry lost the plane geometry")
124+ }
125+ if _, ok := second.req.value(tCrtc, pVRR); !ok {
126+ t.Fatal("retry lost VRR")
127+ }
128+ // The retry's request shows no stale entry twice.
129+ for i, ps := range second.req.props {
130+ seen := map[uint32]bool{}
131+ for _, p := range ps {
132+ if seen[p.prop] {
133+ t.Fatalf("object %d: property %d twice", second.req.objs[i], p.prop)
134+ }
135+ seen[p.prop] = true
136+ }
137+ }
138+}
139+
140+// The cursor-refused retry rebuilds the request with the cursor off while
141+// the TEST_ONLY probe uses its own request.
142+func TestCommitFrameCursorRetryKeepsProbeApart(t *testing.T) {
143+ var frameSeen []*atomicReq
144+ o, _, _ := testOutput(t)
145+ o.cursor.image = true
146+ o.cursor.Move(1, 2)
147+ // First real commit refused with EINVAL, the cursor probe refused too,
148+ // then the retry without the cursor passes.
149+ k2 := newMockkms(t)
150+ n := 0
151+ k2.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(r *atomicReq, flags uint32, _ uint64) error {
152+ n++
153+ frameSeen = append(frameSeen, r)
154+ if n <= 2 {
155+ return unix.EINVAL
156+ }
157+ return nil
158+ })
159+ o.k = k2
160+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
161+ t.Fatal(err)
162+ }
163+ if n != 3 || !o.cursor.off {
164+ t.Fatalf("commits %d cursorOff %v", n, o.cursor.off)
165+ }
166+ if frameSeen[0] != &o.frameReq || frameSeen[1] == &o.frameReq || frameSeen[2] != &o.frameReq {
167+ t.Fatal("probe must not reuse the frame request, retry must")
168+ }
169+ if _, ok := o.frameReq.value(tCursor, pFB); !ok {
170+ t.Fatal("cursor plane not turned off in the retry")
171+ }
172+ if v, _ := o.frameReq.value(tCursor, pFB); v != 0 {
173+ t.Fatalf("retry still shows the cursor: fb %d", v)
174+ }
175+}
176+
177+// kmsDevice.commit fills the request's own scratch: once grown it allocates
178+// nothing. The bad fd makes the ioctl fail before reaching a kernel.
179+func TestKMSDeviceCommitAllocations(t *testing.T) {
180+ k := kmsDevice{fd: -1}
181+ req := &atomicReq{}
182+ build := func() {
183+ req.reset()
184+ for i := range 3 {
185+ obj := uint32(10 + i)
186+ for p := range 8 {
187+ req.set(obj, uint32(p+1), uint64(p))
188+ }
189+ }
190+ }
191+ build()
192+ if err := k.commit(req, atomicTestOnly, 0); err == nil {
193+ t.Fatal("ioctl on fd -1 succeeded")
194+ }
195+ if len(req.counts) != 3 || len(req.flatProps) != 24 || len(req.flatVals) != 24 || req.counts[1] != 8 {
196+ t.Fatalf("scratch counts %v props %d vals %d", req.counts, len(req.flatProps), len(req.flatVals))
197+ }
198+ if n := testing.AllocsPerRun(100, func() {
199+ build()
200+ _ = k.commit(req, atomicTestOnly, 0)
201+ }); n != 0 {
202+ t.Fatalf("build and commit: %.1f allocs", n)
203+ }
204+}
205+
206+func TestAtomicReqResetKeepsCapacityAndDropsContent(t *testing.T) {
207+ r := &atomicReq{}
208+ r.set(1, 1, 5)
209+ r.set(1, 2, 6)
210+ r.set(2, 1, 7)
211+ r.reset()
212+ if len(r.objs) != 0 || len(r.props) != 0 {
213+ t.Fatalf("not empty: %+v", r)
214+ }
215+ r.set(2, 9, 1)
216+ if len(r.objs) != 1 || len(r.props) != 1 || len(r.props[0]) != 1 || r.props[0][0] != (propValue{9, 1}) {
217+ t.Fatalf("stale content after reset: %+v", r)
218+ }
219+ if _, ok := r.value(1, 1); ok {
220+ t.Fatal("stale object")
221+ }
222+}
223+
224+// With a plane colour pipeline active, the steady-state frame decision and
225+// commit allocate nothing: the colour state is compared, not rebuilt, and a
226+// cached verdict spares the TEST_ONLY.
227+func TestColorPipelineAllocations(t *testing.T) {
228+ t.Run("scanout decision", func(t *testing.T) {
229+ o, _, _ := colorOutput(t, false, nil)
230+ o.cursor.image = true
231+ o.cursor.Move(12, 34)
232+ o.clientFBs[9] = &clientFB{fbID: 80}
233+ s, surfaces := hdrScanoutScene()
234+ d := o.decideFrame(s, surfaces, false) // first use: TEST_ONLY, then cached
235+ if d.fb != 80 || d.color != colorSDRToPQ {
236+ t.Fatalf("decision %+v reason %q", d, o.reason)
237+ }
238+ if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 {
239+ t.Fatalf("decision with the pipeline: %.1f allocs, want 0", n)
240+ }
241+ })
242+ t.Run("overlay decision", func(t *testing.T) {
243+ o, k, _ := colorOutput(t, true, nil)
244+ o.primary.pipeline = nil
245+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(88), nil).Maybe()
246+ s, surfaces := overlayScene()
247+ if d := o.decideFrame(s, surfaces, false); d.overlay.fb != 88 || d.overlay.color.mode != colorSDRToPQ {
248+ t.Fatalf("decision %+v reason %q", d, o.overlayReason)
249+ }
250+ if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 {
251+ t.Fatalf("overlay decision with the pipeline: %.1f allocs, want 0", n)
252+ }
253+ })
254+ // The real kmsDevice on an invalid fd runs the request path up to the
255+ // failing ioctl (see TestCommitFrameAllocations).
256+ for _, tc := range []struct {
257+ name string
258+ known bool // the plane applies the pipeline already
259+ }{{"commit writing the pipeline", false}, {"steady-state commit", true}} {
260+ t.Run(tc.name, func(t *testing.T) {
261+ o, _, _ := colorOutput(t, false, nil)
262+ c := sdrContent(o)
263+ o.cursor.image = true
264+ o.cursor.Move(12, 34)
265+ o.k = kmsDevice{fd: -1}
266+ rect := fullPlaneRect(200, 100)
267+ commit := func() {
268+ o.primary.colorKnown, o.primary.colorApplied = tc.known, colorSDRToPQ
269+ _, _ = o.commitScanoutRect(80, c, pendingFrame{}, rect, colorSDRToPQ)
270+ }
271+ commit() // grow the buffers
272+ if _, ok := o.frameReq.value(tPrimary, pColorPipe); ok == tc.known {
273+ t.Fatalf("COLOR_PIPELINE in the request: %v, known %v", ok, tc.known)
274+ }
275+ if n := testing.AllocsPerRun(100, commit); n != 0 {
276+ t.Fatalf("%s: %.1f allocs, want 0", tc.name, n)
277+ }
278+ })
279+ }
280+}
M internal/adapters/drm/drop_hint_test.go +1 -1
@@ -9,7 +9,7 @@ import (
99 func TestDropHintsBlockPlanes(t *testing.T) {
1010 s, c := overlayScene()
1111 s.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}
12- if _, _, reason := overlayCandidate(s, c); reason != "drop_hint" {
12+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "drop_hint" {
1313 t.Fatalf("overlay reason %q", reason)
1414 }
1515 fs, fc := fullscreenScene()
@@ -9,7 +9,7 @@ import (
9 func TestDropHintsBlockPlanes(t *testing.T) {9 func TestDropHintsBlockPlanes(t *testing.T) {
10 s, c := overlayScene()10 s, c := overlayScene()
11 s.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}11 s.DropHints = []ports.Rect{{X: 10, W: 4, H: 100}}
12- if _, _, reason := overlayCandidate(s, c); reason != "drop_hint" {12+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "drop_hint" {
13 t.Fatalf("overlay reason %q", reason)13 t.Fatalf("overlay reason %q", reason)
14 }14 }
15 fs, fc := fullscreenScene()15 fs, fc := fullscreenScene()
M internal/adapters/drm/frame_submit.go +7 -1
@@ -2,6 +2,7 @@ package drm
22
33 import (
44 "context"
5+ "errors"
56 "time"
67
78 "github.com/bnema/neferwl/internal/adapters/capture"
@@ -81,7 +82,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
8182 }
8283 if decision.fb != 0 {
8384 c := decision.content
84- if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect); direct {
85+ if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect, decision.color); direct {
8586 presented = err == nil
8687 return true, err
8788 }
@@ -202,6 +203,11 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
202203 done.Close()
203204 }
204205 ov.close()
206+ if errors.Is(err, errColorRefused) {
207+ // Cached for the overlay's buffer format: the next frame composes it.
208+ o.setOverlayReason("color_refused")
209+ return false, errOverlayDropped
210+ }
205211 if err != nil && o.overlayConflict(err, ov.buf) {
206212 return false, errOverlayDropped
207213 }
@@ -2,6 +2,7 @@ package drm
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "time"6 "time"
6 7
7 "github.com/bnema/neferwl/internal/adapters/capture"8 "github.com/bnema/neferwl/internal/adapters/capture"
@@ -81,7 +82,7 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
81 }82 }
82 if decision.fb != 0 {83 if decision.fb != 0 {
83 c := decision.content84 c := decision.content
84- if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect); direct {85+ if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{security: scene.Security, shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect, decision.color); direct {
85 presented = err == nil86 presented = err == nil
86 return true, err87 return true, err
87 }88 }
@@ -202,6 +203,11 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
202 done.Close()203 done.Close()
203 }204 }
204 ov.close()205 ov.close()
206+ if errors.Is(err, errColorRefused) {
207+ // Cached for the overlay's buffer format: the next frame composes it.
208+ o.setOverlayReason("color_refused")
209+ return false, errOverlayDropped
210+ }
205 if err != nil && o.overlayConflict(err, ov.buf) {211 if err != nil && o.overlayConflict(err, ov.buf) {
206 return false, errOverlayDropped212 return false, errOverlayDropped
207 }213 }
M internal/adapters/drm/game_performance_test.go +4 -4
@@ -20,7 +20,7 @@ func TestScaledScanoutRectAndCachedRefusal(t *testing.T) {
2020 var log bytes.Buffer
2121 o.log = zerowrap.New(zerowrap.Config{Output: &log})
2222 surfaces := map[ports.WindowID]ports.SurfaceContent{1: c}
23- if fb, _ := o.scanoutFrame(s, surfaces); fb != 0 || o.reason != "scale_refused" {
23+ if fb, _, _ := o.scanoutFrame(s, surfaces); fb != 0 || o.reason != "scale_refused" {
2424 t.Fatalf("fb %d reason %s", fb, o.reason)
2525 }
2626 if len(*commits) != 1 || (*commits)[0].flags != atomicTestOnly {
@@ -35,7 +35,7 @@ func TestScaledScanoutRectAndCachedRefusal(t *testing.T) {
3535 if v, _ := (*commits)[0].req.value(tPrimary, planeProps["CRTC_W"]); v != 200 {
3636 t.Fatalf("destination width: %d", v)
3737 }
38- if fb, _ := o.scanoutFrame(s, surfaces); fb != 0 || len(*commits) != 1 {
38+ if fb, _, _ := o.scanoutFrame(s, surfaces); fb != 0 || len(*commits) != 1 {
3939 t.Fatal("refused geometry retried")
4040 }
4141 if !bytes.Contains(log.Bytes(), []byte("scale_refused")) {
@@ -52,7 +52,7 @@ func TestModesetInvalidatesScaleDecision(t *testing.T) {
5252 s := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}}}
5353 c := ports.SurfaceContent{ID: 1, Width: 100, Height: 50, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 9, Format: fourccXRGB}}
5454 contents := map[ports.WindowID]ports.SurfaceContent{1: c}
55- if got, _ := o.scanoutFrame(s, contents); got != 87 || len(*commits) != 1 || !fb.scaleTestedOK {
55+ if got, _, _ := o.scanoutFrame(s, contents); got != 87 || len(*commits) != 1 || !fb.scaleTestedOK {
5656 t.Fatalf("initial scale: %d, commits: %d", got, len(*commits))
5757 }
5858 k.EXPECT().createBlob(mock.Anything).Return(uint32(77), nil).Once()
@@ -62,7 +62,7 @@ func TestModesetInvalidatesScaleDecision(t *testing.T) {
6262 if fb.scaleTestedOK || fb.scaleRefused {
6363 t.Fatal("cached scale survived modeset")
6464 }
65- if got, _ := o.scanoutFrame(s, contents); got != 87 || len(*commits) != 3 || (*commits)[2].flags != atomicTestOnly {
65+ if got, _, _ := o.scanoutFrame(s, contents); got != 87 || len(*commits) != 3 || (*commits)[2].flags != atomicTestOnly {
6666 t.Fatalf("scale not retested: fb %d commits %+v", got, *commits)
6767 }
6868 }
@@ -20,7 +20,7 @@ func TestScaledScanoutRectAndCachedRefusal(t *testing.T) {
20 var log bytes.Buffer20 var log bytes.Buffer
21 o.log = zerowrap.New(zerowrap.Config{Output: &log})21 o.log = zerowrap.New(zerowrap.Config{Output: &log})
22 surfaces := map[ports.WindowID]ports.SurfaceContent{1: c}22 surfaces := map[ports.WindowID]ports.SurfaceContent{1: c}
23- if fb, _ := o.scanoutFrame(s, surfaces); fb != 0 || o.reason != "scale_refused" {23+ if fb, _, _ := o.scanoutFrame(s, surfaces); fb != 0 || o.reason != "scale_refused" {
24 t.Fatalf("fb %d reason %s", fb, o.reason)24 t.Fatalf("fb %d reason %s", fb, o.reason)
25 }25 }
26 if len(*commits) != 1 || (*commits)[0].flags != atomicTestOnly {26 if len(*commits) != 1 || (*commits)[0].flags != atomicTestOnly {
@@ -35,7 +35,7 @@ func TestScaledScanoutRectAndCachedRefusal(t *testing.T) {
35 if v, _ := (*commits)[0].req.value(tPrimary, planeProps["CRTC_W"]); v != 200 {35 if v, _ := (*commits)[0].req.value(tPrimary, planeProps["CRTC_W"]); v != 200 {
36 t.Fatalf("destination width: %d", v)36 t.Fatalf("destination width: %d", v)
37 }37 }
38- if fb, _ := o.scanoutFrame(s, surfaces); fb != 0 || len(*commits) != 1 {38+ if fb, _, _ := o.scanoutFrame(s, surfaces); fb != 0 || len(*commits) != 1 {
39 t.Fatal("refused geometry retried")39 t.Fatal("refused geometry retried")
40 }40 }
41 if !bytes.Contains(log.Bytes(), []byte("scale_refused")) {41 if !bytes.Contains(log.Bytes(), []byte("scale_refused")) {
@@ -52,7 +52,7 @@ func TestModesetInvalidatesScaleDecision(t *testing.T) {
52 s := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}}}52 s := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}}}
53 c := ports.SurfaceContent{ID: 1, Width: 100, Height: 50, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 9, Format: fourccXRGB}}53 c := ports.SurfaceContent{ID: 1, Width: 100, Height: 50, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 9, Format: fourccXRGB}}
54 contents := map[ports.WindowID]ports.SurfaceContent{1: c}54 contents := map[ports.WindowID]ports.SurfaceContent{1: c}
55- if got, _ := o.scanoutFrame(s, contents); got != 87 || len(*commits) != 1 || !fb.scaleTestedOK {55+ if got, _, _ := o.scanoutFrame(s, contents); got != 87 || len(*commits) != 1 || !fb.scaleTestedOK {
56 t.Fatalf("initial scale: %d, commits: %d", got, len(*commits))56 t.Fatalf("initial scale: %d, commits: %d", got, len(*commits))
57 }57 }
58 k.EXPECT().createBlob(mock.Anything).Return(uint32(77), nil).Once()58 k.EXPECT().createBlob(mock.Anything).Return(uint32(77), nil).Once()
@@ -62,7 +62,7 @@ func TestModesetInvalidatesScaleDecision(t *testing.T) {
62 if fb.scaleTestedOK || fb.scaleRefused {62 if fb.scaleTestedOK || fb.scaleRefused {
63 t.Fatal("cached scale survived modeset")63 t.Fatal("cached scale survived modeset")
64 }64 }
65- if got, _ := o.scanoutFrame(s, contents); got != 87 || len(*commits) != 3 || (*commits)[2].flags != atomicTestOnly {65+ if got, _, _ := o.scanoutFrame(s, contents); got != 87 || len(*commits) != 3 || (*commits)[2].flags != atomicTestOnly {
66 t.Fatalf("scale not retested: fb %d commits %+v", got, *commits)66 t.Fatalf("scale not retested: fb %d commits %+v", got, *commits)
67 }67 }
68 }68 }
M internal/adapters/drm/hdr_test.go +2 -2
@@ -53,10 +53,10 @@ func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
5353 }
5454 }
5555 o.hdr.on = true
56- if fb, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "no_fullscreen" {
56+ if fb, _, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "no_fullscreen" {
5757 t.Fatalf("scanout: fb %d reason %q", fb, o.reason)
5858 }
59- if ov, _ := o.overlayFrame(ports.Scene{}, nil); ov.fb != 0 || o.overlayReason != "hdr" {
59+ if ov, _ := o.overlayFrame(ports.Scene{}, nil); ov.fb != 0 || o.overlayReason != "no_plane" {
6060 t.Fatalf("overlay: fb %d reason %q", ov.fb, o.overlayReason)
6161 }
6262 // Colorspace property without a Default enum must not receive invented 0.
@@ -53,10 +53,10 @@ func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
53 }53 }
54 }54 }
55 o.hdr.on = true55 o.hdr.on = true
56- if fb, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "no_fullscreen" {56+ if fb, _, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "no_fullscreen" {
57 t.Fatalf("scanout: fb %d reason %q", fb, o.reason)57 t.Fatalf("scanout: fb %d reason %q", fb, o.reason)
58 }58 }
59- if ov, _ := o.overlayFrame(ports.Scene{}, nil); ov.fb != 0 || o.overlayReason != "hdr" {59+ if ov, _ := o.overlayFrame(ports.Scene{}, nil); ov.fb != 0 || o.overlayReason != "no_plane" {
60 t.Fatalf("overlay: fb %d reason %q", ov.fb, o.overlayReason)60 t.Fatalf("overlay: fb %d reason %q", ov.fb, o.overlayReason)
61 }61 }
62 // Colorspace property without a Default enum must not receive invented 0.62 // Colorspace property without a Default enum must not receive invented 0.
M internal/adapters/drm/kms_mock_test.go +62 -0
@@ -672,6 +672,68 @@ func (_c *mockkms_planes_Call) RunAndReturn(run func() ([]planeRes, error)) *moc
672672 return _c
673673 }
674674
675+// propEnums provides a mock function for the type mockkms
676+func (_mock *mockkms) propEnums(prop uint32) (map[string]uint64, error) {
677+ ret := _mock.Called(prop)
678+
679+ if len(ret) == 0 {
680+ panic("no return value specified for propEnums")
681+ }
682+
683+ var r0 map[string]uint64
684+ var r1 error
685+ if returnFunc, ok := ret.Get(0).(func(uint32) (map[string]uint64, error)); ok {
686+ return returnFunc(prop)
687+ }
688+ if returnFunc, ok := ret.Get(0).(func(uint32) map[string]uint64); ok {
689+ r0 = returnFunc(prop)
690+ } else {
691+ if ret.Get(0) != nil {
692+ r0 = ret.Get(0).(map[string]uint64)
693+ }
694+ }
695+ if returnFunc, ok := ret.Get(1).(func(uint32) error); ok {
696+ r1 = returnFunc(prop)
697+ } else {
698+ r1 = ret.Error(1)
699+ }
700+ return r0, r1
701+}
702+
703+// mockkms_propEnums_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'propEnums'
704+type mockkms_propEnums_Call struct {
705+ *mock.Call
706+}
707+
708+// propEnums is a helper method to define mock.On call
709+// - prop uint32
710+func (_e *mockkms_Expecter) propEnums(prop any) *mockkms_propEnums_Call {
711+ return &mockkms_propEnums_Call{Call: _e.mock.On("propEnums", prop)}
712+}
713+
714+func (_c *mockkms_propEnums_Call) Run(run func(prop uint32)) *mockkms_propEnums_Call {
715+ _c.Call.Run(func(args mock.Arguments) {
716+ var arg0 uint32
717+ if args[0] != nil {
718+ arg0 = args[0].(uint32)
719+ }
720+ run(
721+ arg0,
722+ )
723+ })
724+ return _c
725+}
726+
727+func (_c *mockkms_propEnums_Call) Return(stringToUint64 map[string]uint64, err error) *mockkms_propEnums_Call {
728+ _c.Call.Return(stringToUint64, err)
729+ return _c
730+}
731+
732+func (_c *mockkms_propEnums_Call) RunAndReturn(run func(prop uint32) (map[string]uint64, error)) *mockkms_propEnums_Call {
733+ _c.Call.Return(run)
734+ return _c
735+}
736+
675737 // resources provides a mock function for the type mockkms
676738 func (_mock *mockkms) resources() ([]uint32, []uint32, error) {
677739 ret := _mock.Called()
@@ -672,6 +672,68 @@ func (_c *mockkms_planes_Call) RunAndReturn(run func() ([]planeRes, error)) *moc
672 return _c672 return _c
673 }673 }
674 674
675+// propEnums provides a mock function for the type mockkms
676+func (_mock *mockkms) propEnums(prop uint32) (map[string]uint64, error) {
677+ ret := _mock.Called(prop)
678+
679+ if len(ret) == 0 {
680+ panic("no return value specified for propEnums")
681+ }
682+
683+ var r0 map[string]uint64
684+ var r1 error
685+ if returnFunc, ok := ret.Get(0).(func(uint32) (map[string]uint64, error)); ok {
686+ return returnFunc(prop)
687+ }
688+ if returnFunc, ok := ret.Get(0).(func(uint32) map[string]uint64); ok {
689+ r0 = returnFunc(prop)
690+ } else {
691+ if ret.Get(0) != nil {
692+ r0 = ret.Get(0).(map[string]uint64)
693+ }
694+ }
695+ if returnFunc, ok := ret.Get(1).(func(uint32) error); ok {
696+ r1 = returnFunc(prop)
697+ } else {
698+ r1 = ret.Error(1)
699+ }
700+ return r0, r1
701+}
702+
703+// mockkms_propEnums_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'propEnums'
704+type mockkms_propEnums_Call struct {
705+ *mock.Call
706+}
707+
708+// propEnums is a helper method to define mock.On call
709+// - prop uint32
710+func (_e *mockkms_Expecter) propEnums(prop any) *mockkms_propEnums_Call {
711+ return &mockkms_propEnums_Call{Call: _e.mock.On("propEnums", prop)}
712+}
713+
714+func (_c *mockkms_propEnums_Call) Run(run func(prop uint32)) *mockkms_propEnums_Call {
715+ _c.Call.Run(func(args mock.Arguments) {
716+ var arg0 uint32
717+ if args[0] != nil {
718+ arg0 = args[0].(uint32)
719+ }
720+ run(
721+ arg0,
722+ )
723+ })
724+ return _c
725+}
726+
727+func (_c *mockkms_propEnums_Call) Return(stringToUint64 map[string]uint64, err error) *mockkms_propEnums_Call {
728+ _c.Call.Return(stringToUint64, err)
729+ return _c
730+}
731+
732+func (_c *mockkms_propEnums_Call) RunAndReturn(run func(prop uint32) (map[string]uint64, error)) *mockkms_propEnums_Call {
733+ _c.Call.Return(run)
734+ return _c
735+}
736+
675 // resources provides a mock function for the type mockkms737 // resources provides a mock function for the type mockkms
676 func (_mock *mockkms) resources() ([]uint32, []uint32, error) {738 func (_mock *mockkms) resources() ([]uint32, []uint32, error) {
677 ret := _mock.Called()739 ret := _mock.Called()
M internal/adapters/drm/kms_test.go +7 -2
@@ -111,14 +111,19 @@ func TestParseFlips(t *testing.T) {
111111 return b
112112 }
113113 buf := append(append(ev(eventFlipDone, 41, userFrame, 3, 250, 7), ev(1, 7, 0, 0, 0, 0)...), ev(eventFlipDone, 42, userState, 0, 1, 8)...)
114- got := parseFlips(buf)
114+ got := parseFlips(nil, buf)
115115 want := []flipEvent{{crtc: 41, user: userFrame, when: 3*time.Second + 250*time.Microsecond, seq: 7}, {crtc: 42, user: userState, when: time.Microsecond, seq: 8}}
116116 if !slices.Equal(got, want) {
117117 t.Fatalf("got %+v, want %+v", got, want)
118118 }
119- if got := parseFlips(buf[:20]); len(got) != 0 {
119+ if got := parseFlips(nil, buf[:20]); len(got) != 0 {
120120 t.Fatalf("truncated: got %v", got)
121121 }
122+ // ReadEvents reuses one slice across reads.
123+ reused := parseFlips(nil, buf)
124+ if n := testing.AllocsPerRun(100, func() { reused = parseFlips(reused[:0], buf) }); n != 0 || !slices.Equal(reused, want) {
125+ t.Fatalf("reused parse: %.1f allocs, got %+v", n, reused)
126+ }
122127 }
123128
124129 func TestParseInFormats(t *testing.T) {
@@ -111,14 +111,19 @@ func TestParseFlips(t *testing.T) {
111 return b111 return b
112 }112 }
113 buf := append(append(ev(eventFlipDone, 41, userFrame, 3, 250, 7), ev(1, 7, 0, 0, 0, 0)...), ev(eventFlipDone, 42, userState, 0, 1, 8)...)113 buf := append(append(ev(eventFlipDone, 41, userFrame, 3, 250, 7), ev(1, 7, 0, 0, 0, 0)...), ev(eventFlipDone, 42, userState, 0, 1, 8)...)
114- got := parseFlips(buf)114+ got := parseFlips(nil, buf)
115 want := []flipEvent{{crtc: 41, user: userFrame, when: 3*time.Second + 250*time.Microsecond, seq: 7}, {crtc: 42, user: userState, when: time.Microsecond, seq: 8}}115 want := []flipEvent{{crtc: 41, user: userFrame, when: 3*time.Second + 250*time.Microsecond, seq: 7}, {crtc: 42, user: userState, when: time.Microsecond, seq: 8}}
116 if !slices.Equal(got, want) {116 if !slices.Equal(got, want) {
117 t.Fatalf("got %+v, want %+v", got, want)117 t.Fatalf("got %+v, want %+v", got, want)
118 }118 }
119- if got := parseFlips(buf[:20]); len(got) != 0 {119+ if got := parseFlips(nil, buf[:20]); len(got) != 0 {
120 t.Fatalf("truncated: got %v", got)120 t.Fatalf("truncated: got %v", got)
121 }121 }
122+ // ReadEvents reuses one slice across reads.
123+ reused := parseFlips(nil, buf)
124+ if n := testing.AllocsPerRun(100, func() { reused = parseFlips(reused[:0], buf) }); n != 0 || !slices.Equal(reused, want) {
125+ t.Fatalf("reused parse: %.1f allocs, got %+v", n, reused)
126+ }
122 }127 }
123 128
124 func TestParseInFormats(t *testing.T) {129 func TestParseInFormats(t *testing.T) {
M internal/adapters/drm/output.go +112 -22
@@ -63,6 +63,14 @@ type Output struct {
6363 overlay *plane
6464 overlayOn uint64
6565 overlayReason string
66+ // ctmBlob is the 3x4 matrix blob of the planes' colour pipelines
67+ // (BT.709 to BT.2020), made at setup and destroyed by Close (0: none);
68+ // colorMult is the S31.32 multiplier, SDR white over 80 nits.
69+ ctmBlob uint32
70+ colorMult uint64
71+ // seatDisable carries a request to leave the planes on Bypass before
72+ // the seat is disabled (PrepareSeatDisable); Run answers it.
73+ seatDisable chan chan struct{}
6674 // fbs are the two renderer images frames alternate between, back the
6775 // one the next frame draws into (ADR 014: zero copy).
6876 fbs [2]uint32
@@ -117,6 +125,16 @@ type Output struct {
117125 contentValue uint64
118126 contentWanted uint64
119127 composedSince time.Time
128+ // Reusable atomic requests, owned by the output goroutine (zero value
129+ // ready). frameReq builds frame commits (commitWithRect), which retry
130+ // by recursing: a retry resets it, so a caller never reads it after a
131+ // recursive call. stateReq builds commitState. Paths that hold a second
132+ // request while one is alive use their own: withoutReq (commitState's
133+ // retry test) and probeReq (every TEST_ONLY probe: frameTest, the scale,
134+ // overlay and colour probes).
135+ frameReq, stateReq, withoutReq, probeReq atomicReq
136+ // restWindows is overlayFrame's scene windows, valid until its next call.
137+ restWindows []ports.SceneWindow
120138 // lastFrame is when the last frame was committed. cursorHeld: a cursor
121139 // move waits for the next frame (see cursorWaits).
122140 lastFrame time.Time
@@ -177,7 +195,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, t ports.BufferTransf
177195 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
178196 log := card.log
179197 pipe := slices.Index(card.crtcs, crtc)
180- o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1), traceFlips: card.want.TraceFlips, vrrFlipGap: card.want.VRRFlipGap}
198+ o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1), seatDisable: make(chan chan struct{}), traceFlips: card.want.TraceFlips, vrrFlipGap: card.want.VRRFlipGap}
181199 var err error
182200 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
183201 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -187,6 +205,8 @@ func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, er
187205 }
188206 o.hdr.settings = normalizedHDRSettings(card.want.HDR[c.name])
189207 o.hdr.cap = detectHDR(o.monitor, o.hdr.props)
208+ o.readColor()
209+ o.setupColor()
190210 log.Info().Str("connector", c.name).Bool("hdr_capable", o.hdr.cap.Capable).Str("reason", o.hdr.cap.Reason).Float64("max_luminance", o.hdr.cap.MaxLuminance).Float64("max_frame_average", o.hdr.cap.MaxFrameAverage).Float64("min_luminance", o.hdr.cap.MinLuminance).Msg("HDR capability")
191211 if o.hdr.settings.Enabled && !o.hdr.cap.Capable {
192212 log.Warn().Str("connector", c.name).Str("reason", o.hdr.cap.Reason).Msg("HDR requested but unavailable")
@@ -338,6 +358,7 @@ func (o *Output) modesetImage(fb uint32, active bool) error {
338358 return err
339359 }
340360 o.frame.resetAfterModeset()
361+ o.forgetColor()
341362 o.planeRect = fullPlaneRect(o.Width(), o.Height())
342363 o.signalReady()
343364 return nil
@@ -380,10 +401,13 @@ func (o *Output) modesetImage(fb uint32, active bool) error {
380401 // Only now is nothing of ours pending or on screen: on failure the
381402 // previous buffers may still show.
382403 o.frame.resetAfterModeset()
383- // Plane scaling capabilities may change after a modeset or VT resume.
404+ // Plane scaling and colour pipeline capabilities may change after a
405+ // modeset or VT resume; the planes' colour state is not assumed.
384406 for _, fb := range o.clientFBs {
385407 fb.scaleTestedOK, fb.scaleRefused = false, false
386408 }
409+ o.forgetColor()
410+ o.forgetColorVerdicts()
387411 o.shown, o.queued = 0, 0
388412 if o.modeBlob != 0 {
389413 _ = o.k.destroyBlob(o.modeBlob)
@@ -425,9 +449,11 @@ func (o *Output) powerOff() error {
425449 req.set(o.crtc, o.crtcProps["ACTIVE"], 0)
426450 req.set(o.crtc, o.vrrProp, 0)
427451 req.set(o.conn.id, o.contentProp, o.contentValues[0])
452+ o.forceBypass(req)
428453 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
429454 return fmt.Errorf("power off: %w", err)
430455 }
456+ o.colorBypassed()
431457 o.off, o.vrrGame = true, false
432458 o.setVRR(false)
433459 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
@@ -451,6 +477,10 @@ func (o *Output) sendFormats() {
451477 }
452478 if o.scanout && !o.off {
453479 for _, format := range o.scanoutFormats(o.sampled) {
480+ // The feedback is not widened for SDR buffers on HDR: HDR
481+ // clients pick from it and need 10-bit formats. An SDR
482+ // buffer is still scanned out through the pipeline when the
483+ // plane lists its format.
454484 if !isYUVFormat(format.Format) && isTenBit(format.Format) == o.hdr.on {
455485 f.Formats = append(f.Formats, format)
456486 }
@@ -515,6 +545,9 @@ func (o *Output) modesetBaseReq(blob uint32, active bool) *atomicReq {
515545 req.set(o.conn.id, o.contentProp, o.contentValues[0])
516546 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
517547 o.hdrConnectorProps(req, o.hdr.on)
548+ // A modeset starts from the planes' Bypass pipeline: whatever the
549+ // previous master left is not ours.
550+ o.forceBypass(req)
518551 return req
519552 }
520553
@@ -544,15 +577,20 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
544577 // also move the cursor or change VRR flips at vblank. fence is the
545578 // frame's GPU fence (nil: none); the caller keeps and closes it.
546579 func (o *Output) commitFrame(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame) error {
547- return o.commitWithRect(fb, fence, async, vrr, f, overlayWin{}, fullPlaneRect(o.Width(), o.Height()))
580+ return o.commitWithRect(fb, fence, async, vrr, f, overlayWin{}, fullPlaneRect(o.Width(), o.Height()), colorUse{})
548581 }
549582
550-// commitWith is commitFrame with ov on the overlay plane (zero: off).
583+// commitWith is commitFrame with ov on the overlay plane (zero: off). The
584+// primary plane shows a composed image: Bypass.
551585 func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin) error {
552- return o.commitWithRect(fb, fence, async, vrr, f, ov, fullPlaneRect(o.Width(), o.Height()))
586+ return o.commitWithRect(fb, fence, async, vrr, f, ov, fullPlaneRect(o.Width(), o.Height()), colorUse{})
553587 }
554588
555-func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect) error {
589+// commitWithRect commits fb on the primary plane at rect, with ov on the
590+// overlay, and the planes' colour pipelines as pc (primary) and ov.color
591+// (overlay) say. A frame that changes a plane's colour state is never async:
592+// only FB_ID may change in an async commit.
593+func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect, pc colorUse) error {
556594 o.observeSecurity()
557595 if o.Security != nil && (f.security != o.securityState || o.protected && !o.securityPrepared) {
558596 return errSecurityScene
@@ -562,24 +600,28 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
562600 cur = o.cursor.desired()
563601 }
564602 if o.protected {
565- async, vrr, ov = false, false, overlayWin{}
603+ async, vrr, ov, pc = false, false, overlayWin{}, colorUse{}
566604 }
567- if async && (vrr != o.vrrOn || o.cursor != nil && cur != o.cursor.applied || fence != nil && !o.asyncFence || ov.buf != o.overlayOn || rect != o.planeRect || o.contentWanted != o.contentValue) {
605+ if async && (vrr != o.vrrOn || o.cursor != nil && cur != o.cursor.applied || fence != nil && !o.asyncFence || ov.buf != o.overlayOn || rect != o.planeRect || o.contentWanted != o.contentValue || o.colorStaleFor(pc, ov)) {
568606 async = false
569607 }
570- req := &atomicReq{}
608+ req := &o.frameReq
609+ req.reset()
571610 flags := uint32(atomicNonblock | flipEventFlag)
572611 if async {
573612 flags |= flipAsyncFlag
574613 req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(fb))
575614 } else {
576615 o.primaryRectProps(req, fb, rect)
616+ o.primaryColorProps(req, pc.mode)
577617 req.set(o.crtc, o.vrrProp, boolValue(vrr))
578618 if o.cursor != nil {
579619 o.cursor.props(req, o.crtc, cur)
580620 }
581621 if ov.buf != 0 || o.overlayOn != 0 {
582622 o.overlayProps(req, ov)
623+ } else {
624+ o.overlayColorProps(req, ov.color.mode)
583625 }
584626 }
585627 o.contentProps(req)
@@ -594,19 +636,28 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
594636 }
595637 // The kernel takes its own reference on the fence.
596638 if err := o.k.commit(req, flags, o.frame.userData(userFrame)); err != nil {
639+ // The pipeline first: with it in the frame, its refusal must not
640+ // be taken for the cursor's, which would turn the hardware cursor
641+ // off for good. colorRefused tests the frame with Bypass and the
642+ // cursor as it is, so a real cursor refusal still reaches below.
643+ if !async && errors.Is(err, unix.EINVAL) && o.colorRefused(err, fb, fence, vrr, cur, ov, rect, pc) {
644+ return errColorRefused
645+ }
597646 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
598- return o.commitWithRect(fb, fence, false, vrr, f, ov, rect)
647+ return o.commitWithRect(fb, fence, false, vrr, f, ov, rect, pc)
599648 }
600- if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) && o.contentRefused(fb, fence, vrr, cur, ov, rect) {
649+ // The same frame without the connector hint tells whether the hint
650+ // is the cause.
651+ if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) && o.frameTest(fb, fence, vrr, cur, ov, rect, pc, false) {
601652 o.log.Warn().Str("component", "drm").Err(err).Msg("content type refused; disabled")
602653 o.contentProp = 0
603- return o.commitWithRect(fb, fence, false, vrr, f, ov, rect)
654+ return o.commitWithRect(fb, fence, false, vrr, f, ov, rect, pc)
604655 }
605656 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {
606657 // Retry without turning VRR on: if that passes, the driver
607658 // refuses VRR on this output. The overlay stays: the composed
608659 // image left its window out.
609- if o.commitWithRect(fb, fence, false, false, f, ov, rect) == nil {
660+ if o.commitWithRect(fb, fence, false, false, f, ov, rect, pc) == nil {
610661 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled")
611662 o.vrrProp = 0
612663 return nil
@@ -626,6 +677,7 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
626677 }
627678 if !async {
628679 o.overlayOn = ov.buf
680+ o.colorCommitted(pc, ov)
629681 }
630682 o.contentValue = o.contentWanted
631683 o.planeRect = rect
@@ -642,16 +694,26 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
642694 return nil
643695 }
644696
645-// contentRefused tests the same frame without the connector hint before disabling it.
646-func (o *Output) contentRefused(fb uint32, fence *os.File, vrr bool, cur cursorState, ov overlayWin, rect planeRect) bool {
647- req := &atomicReq{}
697+// frameTest is a TEST_ONLY of the frame commit as commitWithRect builds it,
698+// with the colour state pc and ov.color, and the connector content hint only
699+// when withContent. It reports whether KMS takes it. frameReq is still the
700+// caller's: it uses probeReq.
701+func (o *Output) frameTest(fb uint32, fence *os.File, vrr bool, cur cursorState, ov overlayWin, rect planeRect, pc colorUse, withContent bool) bool {
702+ req := &o.probeReq
703+ req.reset()
648704 o.primaryRectProps(req, fb, rect)
705+ o.primaryColorProps(req, pc.mode)
649706 req.set(o.crtc, o.vrrProp, boolValue(vrr))
650707 if o.cursor != nil {
651708 o.cursor.props(req, o.crtc, cur)
652709 }
653710 if ov.buf != 0 || o.overlayOn != 0 {
654711 o.overlayProps(req, ov)
712+ } else {
713+ o.overlayColorProps(req, ov.color.mode)
714+ }
715+ if withContent {
716+ o.contentProps(req)
655717 }
656718 if fence != nil {
657719 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
@@ -673,7 +735,8 @@ func (o *Output) commitState(vrr bool) error {
673735 if o.protected {
674736 return errSecurityScene
675737 }
676- req := &atomicReq{}
738+ req := &o.stateReq
739+ req.reset()
677740 cur := cursorState{}
678741 // A move back to the applied state holds nothing.
679742 o.cursorHeld = false
@@ -700,6 +763,11 @@ func (o *Output) commitState(vrr bool) error {
700763 return errSecurityScene
701764 }
702765 if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.frame.userData(userState)); err != nil {
766+ if o.colorConflict(err, o.cursor != nil && cur.on && cur != o.cursor.applied) {
767+ // A plane shows the pipeline and KMS refuses the cursor with
768+ // it: the next frame is composed.
769+ return errOverlayDropped
770+ }
703771 if o.overlayConflict(err, o.overlayOn) {
704772 // The next frame is composed without the overlay.
705773 return errOverlayDropped
@@ -708,7 +776,8 @@ func (o *Output) commitState(vrr bool) error {
708776 return o.commitState(vrr)
709777 }
710778 if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) {
711- without := &atomicReq{}
779+ without := &o.withoutReq
780+ without.reset()
712781 if o.cursor != nil && cur != o.cursor.applied {
713782 o.cursor.props(without, o.crtc, cur)
714783 }
@@ -757,10 +826,11 @@ func boolValue(b bool) uint64 {
757826 // commitScanout flips a client buffer. It reports false when KMS refused
758827 // the buffer: the frame must be composed.
759828 func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame) (bool, error) {
760- return o.commitScanoutRect(fb, c, f, fullPlaneRect(o.Width(), o.Height()))
829+ return o.commitScanoutRect(fb, c, f, fullPlaneRect(o.Width(), o.Height()), colorBypass)
761830 }
762831
763-func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingFrame, rect planeRect) (bool, error) {
832+// commitScanoutRect flips a client buffer at rect, shown with colour mode.
833+func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingFrame, rect planeRect, mode colorMode) (bool, error) {
764834 cfb := o.clientFBs[c.DMABuf.ID]
765835 // Drivers refuse async flips that change the format or modifier: the
766836 // flip from the composed image into scanout waits for vblank.
@@ -774,13 +844,20 @@ func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingF
774844 if fence != nil {
775845 defer fence.Close()
776846 }
777- err := o.commitWithRect(fb, fence, async, vrr, f, overlayWin{}, rect)
847+ pc := colorUse{mode: mode, format: c.DMABuf.Format}
848+ err := o.commitWithRect(fb, fence, async, vrr, f, overlayWin{}, rect, pc)
778849 if err != nil && async && errors.Is(err, unix.EINVAL) {
779850 // Refused (e.g. not a fast update): this buffer flips at vblank
780851 // from now on.
781852 o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused")
782853 cfb.noAsync = true
783- err = o.commitWithRect(fb, fence, false, vrr, f, overlayWin{}, rect)
854+ err = o.commitWithRect(fb, fence, false, vrr, f, overlayWin{}, rect, pc)
855+ }
856+ if errors.Is(err, errColorRefused) {
857+ // KMS takes the buffer without the pipeline: compose it; the
858+ // refusal is cached, the buffer is not marked failed.
859+ o.setScanoutReason("color_refused")
860+ return false, nil
784861 }
785862 if err != nil && (errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)) {
786863 // KMS refuses this buffer on the plane: compose it instead.
@@ -807,6 +884,8 @@ func (o *Output) Close() {
807884 o.cursor.props(req, o.crtc, cursorState{})
808885 }
809886 o.overlayProps(req, overlayWin{})
887+ // Whoever drives the planes next starts from Bypass.
888+ o.forceBypass(req)
810889 o.hdrConnectorProps(req, false)
811890 req.set(o.crtc, o.vrrProp, 0)
812891 s := o.saved
@@ -874,6 +953,10 @@ func (o *Output) Close() {
874953 o.modeBlob = 0
875954 }
876955 o.hdr.releaseBlob(o.k)
956+ if o.ctmBlob != 0 {
957+ _ = o.k.destroyBlob(o.ctmBlob)
958+ o.ctmBlob = 0
959+ }
877960 if o.cursor != nil {
878961 o.cursor.free(o.k)
879962 }
@@ -1186,6 +1269,13 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
11861269 // scene that shows it.
11871270 dirty = dirty || haveScene && capture.IndicatorShown(scene, q)
11881271 }
1272+ case ack := <-o.seatDisable:
1273+ o.bypassForSeatDisable()
1274+ // The seat is about to be disabled: no frame may select the
1275+ // pipeline again before the next enable (a modeset).
1276+ enabled = false
1277+ ack <- struct{}{}
1278+ continue
11891279 case on := <-active:
11901280 o.observeSecurity()
11911281 if o.protected {
@@ -63,6 +63,14 @@ type Output struct {
63 overlay *plane63 overlay *plane
64 overlayOn uint6464 overlayOn uint64
65 overlayReason string65 overlayReason string
66+ // ctmBlob is the 3x4 matrix blob of the planes' colour pipelines
67+ // (BT.709 to BT.2020), made at setup and destroyed by Close (0: none);
68+ // colorMult is the S31.32 multiplier, SDR white over 80 nits.
69+ ctmBlob uint32
70+ colorMult uint64
71+ // seatDisable carries a request to leave the planes on Bypass before
72+ // the seat is disabled (PrepareSeatDisable); Run answers it.
73+ seatDisable chan chan struct{}
66 // fbs are the two renderer images frames alternate between, back the74 // fbs are the two renderer images frames alternate between, back the
67 // one the next frame draws into (ADR 014: zero copy).75 // one the next frame draws into (ADR 014: zero copy).
68 fbs [2]uint3276 fbs [2]uint32
@@ -117,6 +125,16 @@ type Output struct {
117 contentValue uint64125 contentValue uint64
118 contentWanted uint64126 contentWanted uint64
119 composedSince time.Time127 composedSince time.Time
128+ // Reusable atomic requests, owned by the output goroutine (zero value
129+ // ready). frameReq builds frame commits (commitWithRect), which retry
130+ // by recursing: a retry resets it, so a caller never reads it after a
131+ // recursive call. stateReq builds commitState. Paths that hold a second
132+ // request while one is alive use their own: withoutReq (commitState's
133+ // retry test) and probeReq (every TEST_ONLY probe: frameTest, the scale,
134+ // overlay and colour probes).
135+ frameReq, stateReq, withoutReq, probeReq atomicReq
136+ // restWindows is overlayFrame's scene windows, valid until its next call.
137+ restWindows []ports.SceneWindow
120 // lastFrame is when the last frame was committed. cursorHeld: a cursor138 // lastFrame is when the last frame was committed. cursorHeld: a cursor
121 // move waits for the next frame (see cursorWaits).139 // move waits for the next frame (see cursorWaits).
122 lastFrame time.Time140 lastFrame time.Time
@@ -177,7 +195,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, t ports.BufferTransf
177 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {195 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
178 log := card.log196 log := card.log
179 pipe := slices.Index(card.crtcs, crtc)197 pipe := slices.Index(card.crtcs, crtc)
180- o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1), traceFlips: card.want.TraceFlips, vrrFlipGap: card.want.VRRFlipGap}198+ o := &Output{k: card.k, flipped: card.flips[crtc], frame: frameLifecycle{serials: &card.serials}, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1), seatDisable: make(chan chan struct{}), traceFlips: card.want.TraceFlips, vrrFlipGap: card.want.VRRFlipGap}
181 var err error199 var err error
182 if o.saved, err = getCrtc(card.fd, crtc); err != nil {200 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
183 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")201 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -187,6 +205,8 @@ func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, er
187 }205 }
188 o.hdr.settings = normalizedHDRSettings(card.want.HDR[c.name])206 o.hdr.settings = normalizedHDRSettings(card.want.HDR[c.name])
189 o.hdr.cap = detectHDR(o.monitor, o.hdr.props)207 o.hdr.cap = detectHDR(o.monitor, o.hdr.props)
208+ o.readColor()
209+ o.setupColor()
190 log.Info().Str("connector", c.name).Bool("hdr_capable", o.hdr.cap.Capable).Str("reason", o.hdr.cap.Reason).Float64("max_luminance", o.hdr.cap.MaxLuminance).Float64("max_frame_average", o.hdr.cap.MaxFrameAverage).Float64("min_luminance", o.hdr.cap.MinLuminance).Msg("HDR capability")210 log.Info().Str("connector", c.name).Bool("hdr_capable", o.hdr.cap.Capable).Str("reason", o.hdr.cap.Reason).Float64("max_luminance", o.hdr.cap.MaxLuminance).Float64("max_frame_average", o.hdr.cap.MaxFrameAverage).Float64("min_luminance", o.hdr.cap.MinLuminance).Msg("HDR capability")
191 if o.hdr.settings.Enabled && !o.hdr.cap.Capable {211 if o.hdr.settings.Enabled && !o.hdr.cap.Capable {
192 log.Warn().Str("connector", c.name).Str("reason", o.hdr.cap.Reason).Msg("HDR requested but unavailable")212 log.Warn().Str("connector", c.name).Str("reason", o.hdr.cap.Reason).Msg("HDR requested but unavailable")
@@ -338,6 +358,7 @@ func (o *Output) modesetImage(fb uint32, active bool) error {
338 return err358 return err
339 }359 }
340 o.frame.resetAfterModeset()360 o.frame.resetAfterModeset()
361+ o.forgetColor()
341 o.planeRect = fullPlaneRect(o.Width(), o.Height())362 o.planeRect = fullPlaneRect(o.Width(), o.Height())
342 o.signalReady()363 o.signalReady()
343 return nil364 return nil
@@ -380,10 +401,13 @@ func (o *Output) modesetImage(fb uint32, active bool) error {
380 // Only now is nothing of ours pending or on screen: on failure the401 // Only now is nothing of ours pending or on screen: on failure the
381 // previous buffers may still show.402 // previous buffers may still show.
382 o.frame.resetAfterModeset()403 o.frame.resetAfterModeset()
383- // Plane scaling capabilities may change after a modeset or VT resume.404+ // Plane scaling and colour pipeline capabilities may change after a
405+ // modeset or VT resume; the planes' colour state is not assumed.
384 for _, fb := range o.clientFBs {406 for _, fb := range o.clientFBs {
385 fb.scaleTestedOK, fb.scaleRefused = false, false407 fb.scaleTestedOK, fb.scaleRefused = false, false
386 }408 }
409+ o.forgetColor()
410+ o.forgetColorVerdicts()
387 o.shown, o.queued = 0, 0411 o.shown, o.queued = 0, 0
388 if o.modeBlob != 0 {412 if o.modeBlob != 0 {
389 _ = o.k.destroyBlob(o.modeBlob)413 _ = o.k.destroyBlob(o.modeBlob)
@@ -425,9 +449,11 @@ func (o *Output) powerOff() error {
425 req.set(o.crtc, o.crtcProps["ACTIVE"], 0)449 req.set(o.crtc, o.crtcProps["ACTIVE"], 0)
426 req.set(o.crtc, o.vrrProp, 0)450 req.set(o.crtc, o.vrrProp, 0)
427 req.set(o.conn.id, o.contentProp, o.contentValues[0])451 req.set(o.conn.id, o.contentProp, o.contentValues[0])
452+ o.forceBypass(req)
428 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {453 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
429 return fmt.Errorf("power off: %w", err)454 return fmt.Errorf("power off: %w", err)
430 }455 }
456+ o.colorBypassed()
431 o.off, o.vrrGame = true, false457 o.off, o.vrrGame = true, false
432 o.setVRR(false)458 o.setVRR(false)
433 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]459 o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0]
@@ -451,6 +477,10 @@ func (o *Output) sendFormats() {
451 }477 }
452 if o.scanout && !o.off {478 if o.scanout && !o.off {
453 for _, format := range o.scanoutFormats(o.sampled) {479 for _, format := range o.scanoutFormats(o.sampled) {
480+ // The feedback is not widened for SDR buffers on HDR: HDR
481+ // clients pick from it and need 10-bit formats. An SDR
482+ // buffer is still scanned out through the pipeline when the
483+ // plane lists its format.
454 if !isYUVFormat(format.Format) && isTenBit(format.Format) == o.hdr.on {484 if !isYUVFormat(format.Format) && isTenBit(format.Format) == o.hdr.on {
455 f.Formats = append(f.Formats, format)485 f.Formats = append(f.Formats, format)
456 }486 }
@@ -515,6 +545,9 @@ func (o *Output) modesetBaseReq(blob uint32, active bool) *atomicReq {
515 req.set(o.conn.id, o.contentProp, o.contentValues[0])545 req.set(o.conn.id, o.contentProp, o.contentValues[0])
516 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))546 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
517 o.hdrConnectorProps(req, o.hdr.on)547 o.hdrConnectorProps(req, o.hdr.on)
548+ // A modeset starts from the planes' Bypass pipeline: whatever the
549+ // previous master left is not ours.
550+ o.forceBypass(req)
518 return req551 return req
519 }552 }
520 553
@@ -544,15 +577,20 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
544 // also move the cursor or change VRR flips at vblank. fence is the577 // also move the cursor or change VRR flips at vblank. fence is the
545 // frame's GPU fence (nil: none); the caller keeps and closes it.578 // frame's GPU fence (nil: none); the caller keeps and closes it.
546 func (o *Output) commitFrame(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame) error {579 func (o *Output) commitFrame(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame) error {
547- return o.commitWithRect(fb, fence, async, vrr, f, overlayWin{}, fullPlaneRect(o.Width(), o.Height()))580+ return o.commitWithRect(fb, fence, async, vrr, f, overlayWin{}, fullPlaneRect(o.Width(), o.Height()), colorUse{})
548 }581 }
549 582
550-// commitWith is commitFrame with ov on the overlay plane (zero: off).583+// commitWith is commitFrame with ov on the overlay plane (zero: off). The
584+// primary plane shows a composed image: Bypass.
551 func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin) error {585 func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin) error {
552- return o.commitWithRect(fb, fence, async, vrr, f, ov, fullPlaneRect(o.Width(), o.Height()))586+ return o.commitWithRect(fb, fence, async, vrr, f, ov, fullPlaneRect(o.Width(), o.Height()), colorUse{})
553 }587 }
554 588
555-func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect) error {589+// commitWithRect commits fb on the primary plane at rect, with ov on the
590+// overlay, and the planes' colour pipelines as pc (primary) and ov.color
591+// (overlay) say. A frame that changes a plane's colour state is never async:
592+// only FB_ID may change in an async commit.
593+func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame, ov overlayWin, rect planeRect, pc colorUse) error {
556 o.observeSecurity()594 o.observeSecurity()
557 if o.Security != nil && (f.security != o.securityState || o.protected && !o.securityPrepared) {595 if o.Security != nil && (f.security != o.securityState || o.protected && !o.securityPrepared) {
558 return errSecurityScene596 return errSecurityScene
@@ -562,24 +600,28 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
562 cur = o.cursor.desired()600 cur = o.cursor.desired()
563 }601 }
564 if o.protected {602 if o.protected {
565- async, vrr, ov = false, false, overlayWin{}603+ async, vrr, ov, pc = false, false, overlayWin{}, colorUse{}
566 }604 }
567- if async && (vrr != o.vrrOn || o.cursor != nil && cur != o.cursor.applied || fence != nil && !o.asyncFence || ov.buf != o.overlayOn || rect != o.planeRect || o.contentWanted != o.contentValue) {605+ if async && (vrr != o.vrrOn || o.cursor != nil && cur != o.cursor.applied || fence != nil && !o.asyncFence || ov.buf != o.overlayOn || rect != o.planeRect || o.contentWanted != o.contentValue || o.colorStaleFor(pc, ov)) {
568 async = false606 async = false
569 }607 }
570- req := &atomicReq{}608+ req := &o.frameReq
609+ req.reset()
571 flags := uint32(atomicNonblock | flipEventFlag)610 flags := uint32(atomicNonblock | flipEventFlag)
572 if async {611 if async {
573 flags |= flipAsyncFlag612 flags |= flipAsyncFlag
574 req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(fb))613 req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(fb))
575 } else {614 } else {
576 o.primaryRectProps(req, fb, rect)615 o.primaryRectProps(req, fb, rect)
616+ o.primaryColorProps(req, pc.mode)
577 req.set(o.crtc, o.vrrProp, boolValue(vrr))617 req.set(o.crtc, o.vrrProp, boolValue(vrr))
578 if o.cursor != nil {618 if o.cursor != nil {
579 o.cursor.props(req, o.crtc, cur)619 o.cursor.props(req, o.crtc, cur)
580 }620 }
581 if ov.buf != 0 || o.overlayOn != 0 {621 if ov.buf != 0 || o.overlayOn != 0 {
582 o.overlayProps(req, ov)622 o.overlayProps(req, ov)
623+ } else {
624+ o.overlayColorProps(req, ov.color.mode)
583 }625 }
584 }626 }
585 o.contentProps(req)627 o.contentProps(req)
@@ -594,19 +636,28 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
594 }636 }
595 // The kernel takes its own reference on the fence.637 // The kernel takes its own reference on the fence.
596 if err := o.k.commit(req, flags, o.frame.userData(userFrame)); err != nil {638 if err := o.k.commit(req, flags, o.frame.userData(userFrame)); err != nil {
639+ // The pipeline first: with it in the frame, its refusal must not
640+ // be taken for the cursor's, which would turn the hardware cursor
641+ // off for good. colorRefused tests the frame with Bypass and the
642+ // cursor as it is, so a real cursor refusal still reaches below.
643+ if !async && errors.Is(err, unix.EINVAL) && o.colorRefused(err, fb, fence, vrr, cur, ov, rect, pc) {
644+ return errColorRefused
645+ }
597 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {646 if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
598- return o.commitWithRect(fb, fence, false, vrr, f, ov, rect)647+ return o.commitWithRect(fb, fence, false, vrr, f, ov, rect, pc)
599 }648 }
600- if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) && o.contentRefused(fb, fence, vrr, cur, ov, rect) {649+ // The same frame without the connector hint tells whether the hint
650+ // is the cause.
651+ if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) && o.frameTest(fb, fence, vrr, cur, ov, rect, pc, false) {
601 o.log.Warn().Str("component", "drm").Err(err).Msg("content type refused; disabled")652 o.log.Warn().Str("component", "drm").Err(err).Msg("content type refused; disabled")
602 o.contentProp = 0653 o.contentProp = 0
603- return o.commitWithRect(fb, fence, false, vrr, f, ov, rect)654+ return o.commitWithRect(fb, fence, false, vrr, f, ov, rect, pc)
604 }655 }
605 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {656 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {
606 // Retry without turning VRR on: if that passes, the driver657 // Retry without turning VRR on: if that passes, the driver
607 // refuses VRR on this output. The overlay stays: the composed658 // refuses VRR on this output. The overlay stays: the composed
608 // image left its window out.659 // image left its window out.
609- if o.commitWithRect(fb, fence, false, false, f, ov, rect) == nil {660+ if o.commitWithRect(fb, fence, false, false, f, ov, rect, pc) == nil {
610 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled")661 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled")
611 o.vrrProp = 0662 o.vrrProp = 0
612 return nil663 return nil
@@ -626,6 +677,7 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
626 }677 }
627 if !async {678 if !async {
628 o.overlayOn = ov.buf679 o.overlayOn = ov.buf
680+ o.colorCommitted(pc, ov)
629 }681 }
630 o.contentValue = o.contentWanted682 o.contentValue = o.contentWanted
631 o.planeRect = rect683 o.planeRect = rect
@@ -642,16 +694,26 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool,
642 return nil694 return nil
643 }695 }
644 696
645-// contentRefused tests the same frame without the connector hint before disabling it.697+// frameTest is a TEST_ONLY of the frame commit as commitWithRect builds it,
646-func (o *Output) contentRefused(fb uint32, fence *os.File, vrr bool, cur cursorState, ov overlayWin, rect planeRect) bool {698+// with the colour state pc and ov.color, and the connector content hint only
647- req := &atomicReq{}699+// when withContent. It reports whether KMS takes it. frameReq is still the
700+// caller's: it uses probeReq.
701+func (o *Output) frameTest(fb uint32, fence *os.File, vrr bool, cur cursorState, ov overlayWin, rect planeRect, pc colorUse, withContent bool) bool {
702+ req := &o.probeReq
703+ req.reset()
648 o.primaryRectProps(req, fb, rect)704 o.primaryRectProps(req, fb, rect)
705+ o.primaryColorProps(req, pc.mode)
649 req.set(o.crtc, o.vrrProp, boolValue(vrr))706 req.set(o.crtc, o.vrrProp, boolValue(vrr))
650 if o.cursor != nil {707 if o.cursor != nil {
651 o.cursor.props(req, o.crtc, cur)708 o.cursor.props(req, o.crtc, cur)
652 }709 }
653 if ov.buf != 0 || o.overlayOn != 0 {710 if ov.buf != 0 || o.overlayOn != 0 {
654 o.overlayProps(req, ov)711 o.overlayProps(req, ov)
712+ } else {
713+ o.overlayColorProps(req, ov.color.mode)
714+ }
715+ if withContent {
716+ o.contentProps(req)
655 }717 }
656 if fence != nil {718 if fence != nil {
657 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))719 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
@@ -673,7 +735,8 @@ func (o *Output) commitState(vrr bool) error {
673 if o.protected {735 if o.protected {
674 return errSecurityScene736 return errSecurityScene
675 }737 }
676- req := &atomicReq{}738+ req := &o.stateReq
739+ req.reset()
677 cur := cursorState{}740 cur := cursorState{}
678 // A move back to the applied state holds nothing.741 // A move back to the applied state holds nothing.
679 o.cursorHeld = false742 o.cursorHeld = false
@@ -700,6 +763,11 @@ func (o *Output) commitState(vrr bool) error {
700 return errSecurityScene763 return errSecurityScene
701 }764 }
702 if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.frame.userData(userState)); err != nil {765 if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.frame.userData(userState)); err != nil {
766+ if o.colorConflict(err, o.cursor != nil && cur.on && cur != o.cursor.applied) {
767+ // A plane shows the pipeline and KMS refuses the cursor with
768+ // it: the next frame is composed.
769+ return errOverlayDropped
770+ }
703 if o.overlayConflict(err, o.overlayOn) {771 if o.overlayConflict(err, o.overlayOn) {
704 // The next frame is composed without the overlay.772 // The next frame is composed without the overlay.
705 return errOverlayDropped773 return errOverlayDropped
@@ -708,7 +776,8 @@ func (o *Output) commitState(vrr bool) error {
708 return o.commitState(vrr)776 return o.commitState(vrr)
709 }777 }
710 if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) {778 if o.contentProp != 0 && o.contentWanted != o.contentValue && errors.Is(err, unix.EINVAL) {
711- without := &atomicReq{}779+ without := &o.withoutReq
780+ without.reset()
712 if o.cursor != nil && cur != o.cursor.applied {781 if o.cursor != nil && cur != o.cursor.applied {
713 o.cursor.props(without, o.crtc, cur)782 o.cursor.props(without, o.crtc, cur)
714 }783 }
@@ -757,10 +826,11 @@ func boolValue(b bool) uint64 {
757 // commitScanout flips a client buffer. It reports false when KMS refused826 // commitScanout flips a client buffer. It reports false when KMS refused
758 // the buffer: the frame must be composed.827 // the buffer: the frame must be composed.
759 func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame) (bool, error) {828 func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame) (bool, error) {
760- return o.commitScanoutRect(fb, c, f, fullPlaneRect(o.Width(), o.Height()))829+ return o.commitScanoutRect(fb, c, f, fullPlaneRect(o.Width(), o.Height()), colorBypass)
761 }830 }
762 831
763-func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingFrame, rect planeRect) (bool, error) {832+// commitScanoutRect flips a client buffer at rect, shown with colour mode.
833+func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingFrame, rect planeRect, mode colorMode) (bool, error) {
764 cfb := o.clientFBs[c.DMABuf.ID]834 cfb := o.clientFBs[c.DMABuf.ID]
765 // Drivers refuse async flips that change the format or modifier: the835 // Drivers refuse async flips that change the format or modifier: the
766 // flip from the composed image into scanout waits for vblank.836 // flip from the composed image into scanout waits for vblank.
@@ -774,13 +844,20 @@ func (o *Output) commitScanoutRect(fb uint32, c ports.SurfaceContent, f pendingF
774 if fence != nil {844 if fence != nil {
775 defer fence.Close()845 defer fence.Close()
776 }846 }
777- err := o.commitWithRect(fb, fence, async, vrr, f, overlayWin{}, rect)847+ pc := colorUse{mode: mode, format: c.DMABuf.Format}
848+ err := o.commitWithRect(fb, fence, async, vrr, f, overlayWin{}, rect, pc)
778 if err != nil && async && errors.Is(err, unix.EINVAL) {849 if err != nil && async && errors.Is(err, unix.EINVAL) {
779 // Refused (e.g. not a fast update): this buffer flips at vblank850 // Refused (e.g. not a fast update): this buffer flips at vblank
780 // from now on.851 // from now on.
781 o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused")852 o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused")
782 cfb.noAsync = true853 cfb.noAsync = true
783- err = o.commitWithRect(fb, fence, false, vrr, f, overlayWin{}, rect)854+ err = o.commitWithRect(fb, fence, false, vrr, f, overlayWin{}, rect, pc)
855+ }
856+ if errors.Is(err, errColorRefused) {
857+ // KMS takes the buffer without the pipeline: compose it; the
858+ // refusal is cached, the buffer is not marked failed.
859+ o.setScanoutReason("color_refused")
860+ return false, nil
784 }861 }
785 if err != nil && (errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)) {862 if err != nil && (errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)) {
786 // KMS refuses this buffer on the plane: compose it instead.863 // KMS refuses this buffer on the plane: compose it instead.
@@ -807,6 +884,8 @@ func (o *Output) Close() {
807 o.cursor.props(req, o.crtc, cursorState{})884 o.cursor.props(req, o.crtc, cursorState{})
808 }885 }
809 o.overlayProps(req, overlayWin{})886 o.overlayProps(req, overlayWin{})
887+ // Whoever drives the planes next starts from Bypass.
888+ o.forceBypass(req)
810 o.hdrConnectorProps(req, false)889 o.hdrConnectorProps(req, false)
811 req.set(o.crtc, o.vrrProp, 0)890 req.set(o.crtc, o.vrrProp, 0)
812 s := o.saved891 s := o.saved
@@ -874,6 +953,10 @@ func (o *Output) Close() {
874 o.modeBlob = 0953 o.modeBlob = 0
875 }954 }
876 o.hdr.releaseBlob(o.k)955 o.hdr.releaseBlob(o.k)
956+ if o.ctmBlob != 0 {
957+ _ = o.k.destroyBlob(o.ctmBlob)
958+ o.ctmBlob = 0
959+ }
877 if o.cursor != nil {960 if o.cursor != nil {
878 o.cursor.free(o.k)961 o.cursor.free(o.k)
879 }962 }
@@ -1186,6 +1269,13 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1186 // scene that shows it.1269 // scene that shows it.
1187 dirty = dirty || haveScene && capture.IndicatorShown(scene, q)1270 dirty = dirty || haveScene && capture.IndicatorShown(scene, q)
1188 }1271 }
1272+ case ack := <-o.seatDisable:
1273+ o.bypassForSeatDisable()
1274+ // The seat is about to be disabled: no frame may select the
1275+ // pipeline again before the next enable (a modeset).
1276+ enabled = false
1277+ ack <- struct{}{}
1278+ continue
1189 case on := <-active:1279 case on := <-active:
1190 o.observeSecurity()1280 o.observeSecurity()
1191 if o.protected {1281 if o.protected {
M internal/adapters/drm/output_test.go +3 -2
@@ -82,7 +82,7 @@ func testOutputRules(t *testing.T, rule func(*atomicReq, uint32) error, errs ...
8282 cur.fbs = [2]uint32{90, 91}
8383 o := &Output{k: k, frame: frameLifecycle{serials: &atomic.Uint64{}}, crtc: tCrtc, conn: connector{id: tConn, name: "DP-1"}, mode: modeInfo{HDisplay: 200, VDisplay: 100, VRefresh: 60}, log: zerowrap.Default(),
8484 crtcProps: map[string]uint32{"MODE_ID": pMode, "ACTIVE": pActive, "VRR_ENABLED": pVRR}, connCrtc: pConnCrtc, vrrProp: pVRR,
85- primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true, planeRect: fullPlaneRect(200, 100)}
85+ primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true, planeRect: fullPlaneRect(200, 100), seatDisable: make(chan chan struct{})}
8686 return o, k, &commits, commitMu
8787 }
8888
@@ -1043,7 +1043,8 @@ func TestRunSecurityResetClearsWantedAt(t *testing.T) {
10431043 scene.Seq = 2
10441044 sr.scenes <- scene
10451045 sr.contents <- ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{}} // stamped by now
1046- sr.state.Store(2) // a new security generation, not protected
1046+ // A new security generation, not protected.
1047+ sr.state.Store(2)
10471048 sr.wake <- ports.SecurityState{}
10481049 sr.contents <- ports.SurfaceContent{ID: 1, Seq: 2, SHM: &ports.SHMBuffer{}} // Run went round and reset
10491050 sr.stop()
@@ -82,7 +82,7 @@ func testOutputRules(t *testing.T, rule func(*atomicReq, uint32) error, errs ...
82 cur.fbs = [2]uint32{90, 91}82 cur.fbs = [2]uint32{90, 91}
83 o := &Output{k: k, frame: frameLifecycle{serials: &atomic.Uint64{}}, crtc: tCrtc, conn: connector{id: tConn, name: "DP-1"}, mode: modeInfo{HDisplay: 200, VDisplay: 100, VRefresh: 60}, log: zerowrap.Default(),83 o := &Output{k: k, frame: frameLifecycle{serials: &atomic.Uint64{}}, crtc: tCrtc, conn: connector{id: tConn, name: "DP-1"}, mode: modeInfo{HDisplay: 200, VDisplay: 100, VRefresh: 60}, log: zerowrap.Default(),
84 crtcProps: map[string]uint32{"MODE_ID": pMode, "ACTIVE": pActive, "VRR_ENABLED": pVRR}, connCrtc: pConnCrtc, vrrProp: pVRR,84 crtcProps: map[string]uint32{"MODE_ID": pMode, "ACTIVE": pActive, "VRR_ENABLED": pVRR}, connCrtc: pConnCrtc, vrrProp: pVRR,
85- primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true, planeRect: fullPlaneRect(200, 100)}85+ primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true, planeRect: fullPlaneRect(200, 100), seatDisable: make(chan chan struct{})}
86 return o, k, &commits, commitMu86 return o, k, &commits, commitMu
87 }87 }
88 88
@@ -1043,7 +1043,8 @@ func TestRunSecurityResetClearsWantedAt(t *testing.T) {
1043 scene.Seq = 21043 scene.Seq = 2
1044 sr.scenes <- scene1044 sr.scenes <- scene
1045 sr.contents <- ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{}} // stamped by now1045 sr.contents <- ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{}} // stamped by now
1046- sr.state.Store(2) // a new security generation, not protected1046+ // A new security generation, not protected.
1047+ sr.state.Store(2)
1047 sr.wake <- ports.SecurityState{}1048 sr.wake <- ports.SecurityState{}
1048 sr.contents <- ports.SurfaceContent{ID: 1, Seq: 2, SHM: &ports.SHMBuffer{}} // Run went round and reset1049 sr.contents <- ports.SurfaceContent{ID: 1, Seq: 2, SHM: &ports.SHMBuffer{}} // Run went round and reset
1049 sr.stop()1050 sr.stop()
M internal/adapters/drm/overlay.go +54 -27
@@ -49,6 +49,8 @@ type overlayWin struct {
4949 buf uint64
5050 rect ports.Rect // CRTC rect
5151 w, h int // buffer size
52+ // color is how the plane's colour pipeline shows the buffer.
53+ color colorUse
5254 // acquire is the client's explicit-sync fence, a duplicate owned by
5355 // the frame loop (closed after the commit); nil without one.
5456 acquire *os.File
@@ -56,16 +58,17 @@ type overlayWin struct {
5658
5759 // overlayCandidate finds the one window the overlay can show: an opaque
5860 // dmabuf at integer physical coordinates, drawn 1:1, with no other window
59-// or layer above it. reason is why none.
60-func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (ports.SceneWindow, ports.SurfaceContent, string) {
61+// or layer above it, and the plane colour mode it needs. reason is why none.
62+func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) {
6163 if s.Transform != 0 {
62- return ports.SceneWindow{}, ports.SurfaceContent{}, "output_transform"
64+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"
6365 }
6466 scale := s.Scale
6567 if scale <= 0 {
6668 scale = 1
6769 }
6870 var pick *ports.SceneWindow
71+ var mode colorMode
6972 for i := range s.Windows {
7073 w := &s.Windows[i]
7174 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
@@ -73,59 +76,62 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
7376 }
7477 if w.Popup {
7578 // Popups draw after every window, wherever they are listed.
76- return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"
79+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "window_above"
7780 }
7881 c := surfaces[w.ID]
79- // A viewport crop or PQ content is composed: the plane would show
80- // the whole buffer, and raw PQ values on this SDR output. A dimmed
82+ // A viewport crop is composed: the plane would show the whole
83+ // buffer, and so is a colour the plane cannot show (raw PQ values
84+ // on an SDR output, SDR on HDR without a pipeline). A dimmed
8185 // window (a peeking stashed one) needs the veil drawn over it, and
8286 // one with the focus effect needs its lift; an overview preview is
8387 // drawn smaller than its buffer.
84- if w.Dim <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() {
85- pick = w
86- continue
88+ if w.Dim <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) {
89+ if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
90+ pick, mode = w, m
91+ continue
92+ }
8793 }
8894 if pick != nil {
8995 // Something is drawn above the candidate.
90- return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"
96+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "window_above"
9197 }
9298 }
9399 if pick == nil {
94- return ports.SceneWindow{}, ports.SurfaceContent{}, "no_candidate"
100+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "no_candidate"
95101 }
96102 if s.Dim > 0 {
97103 // The renderer composes the veil (under the first float, or under
98104 // every window with DimBehind): a window on the plane would skip it.
99- return ports.SceneWindow{}, ports.SurfaceContent{}, "dim"
105+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "dim"
100106 }
101107 if len(s.DropHints) > 0 {
102108 // Drag hints are drawn over the windows.
103- return ports.SceneWindow{}, ports.SurfaceContent{}, "drop_hint"
109+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "drop_hint"
104110 }
105111 for _, l := range s.Layers {
106112 if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 {
107- return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"
113+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "layer_above"
108114 }
109115 }
110116 if !pick.Fullscreen && len(s.Separators) > 0 {
111117 // The overlay shows the buffer alone; the lines would go with it.
112- return ports.SceneWindow{}, ports.SurfaceContent{}, "border"
118+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "border"
113119 }
114120 if clip := s.WorkspaceClip; clip != (ports.Rect{}) &&
115121 (pick.Rect.X < clip.X || pick.Rect.Y < clip.Y || pick.Rect.X+pick.Rect.W > clip.X+clip.W || pick.Rect.Y+pick.Rect.H > clip.Y+clip.H) {
116122 // The plane cannot apply the workspace's logical viewport crop.
117- return ports.SceneWindow{}, ports.SurfaceContent{}, "workspace_clip"
123+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "workspace_clip"
118124 }
119125 c := surfaces[pick.ID]
120126 pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale
121127 if float64(c.Width) != pw || float64(c.Height) != ph || c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0) {
122- return ports.SceneWindow{}, ports.SurfaceContent{}, "scaled"
128+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "scaled"
123129 }
124130 x, y := float64(pick.Rect.X)*scale, float64(pick.Rect.Y)*scale
125131 if x != float64(int(x)) || y != float64(int(y)) {
126- return ports.SceneWindow{}, ports.SurfaceContent{}, "fractional_position"
132+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "fractional_position"
127133 }
128- return *pick, c, ""
134+ return *pick, c, mode, ""
129135 }
130136
131137 // overlayProps puts ov on the overlay plane, or turns it off.
@@ -137,8 +143,10 @@ func (o *Output) overlayProps(req *atomicReq, ov overlayWin) {
137143 if ov.fb == 0 {
138144 req.set(p.id, p.prop("FB_ID"), 0)
139145 req.set(p.id, p.prop("CRTC_ID"), 0)
146+ o.overlayColorProps(req, colorBypass)
140147 return
141148 }
149+ o.overlayColorProps(req, ov.color.mode)
142150 req.set(p.id, p.prop("FB_ID"), uint64(ov.fb))
143151 req.set(p.id, p.prop("CRTC_ID"), uint64(o.crtc))
144152 req.set(p.id, p.prop("SRC_X"), 0)
@@ -164,18 +172,20 @@ func (ov overlayWin) close() {
164172
165173 // testOverlay asks KMS whether it takes the frame with ov on the overlay,
166174 // the cursor at its current place included. A refusal is cached on the
167-// buffer.
175+// buffer; one that the plane's colour pipeline causes (the same frame passes
176+// with Bypass) is cached on the plane for the buffer format and the cursor
177+// state instead.
168178 func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {
169- req := &atomicReq{}
170- o.primaryProps(req, fb)
171- o.overlayProps(req, ov)
172- if o.cursor != nil {
173- o.cursor.props(req, o.crtc, o.cursor.desired())
174- }
175- err := o.k.commit(req, atomicTestOnly, 0)
179+ err := o.probeOverlay(fb, ov, ov.color.mode)
176180 if err == nil {
177181 return true
178182 }
183+ if ov.color.mode != colorBypass && refused(err) && o.probeOverlay(fb, ov, colorBypass) == nil {
184+ o.overlay.setVerdict(ov.color.format, o.cursorShown(), false)
185+ o.setOverlayReason("color_refused")
186+ o.log.Info().Str("component", "render").Err(err).Uint32("format", ov.color.format).Str("connector", o.conn.name).Msg("overlay colour pipeline refused")
187+ return false
188+ }
179189 if cfb := o.clientFBs[ov.buf]; cfb != nil && refused(err) {
180190 cfb.overlayFailed = "overlay_refused"
181191 }
@@ -184,6 +194,23 @@ func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {
184194 return false
185195 }
186196
197+// probeOverlay is a TEST_ONLY of the frame: the composed image on the
198+// primary plane (Bypass), ov on the overlay with colour mode, the cursor.
199+// Both planes' colour state is written whatever they applied: the test must
200+// not rely on the state of the last commit.
201+func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode) error {
202+ req := &o.probeReq // no frame request is alive: the frame commit follows
203+ req.reset()
204+ o.primaryProps(req, fb)
205+ o.primary.colorProps(req, colorBypass, 0, 0)
206+ o.overlayProps(req, ov)
207+ o.overlay.colorProps(req, mode, o.colorMult, o.ctmBlob)
208+ if o.cursor != nil {
209+ o.cursor.props(req, o.crtc, o.cursor.desired())
210+ }
211+ return o.k.commit(req, atomicTestOnly, 0)
212+}
213+
187214 // errOverlayDropped: a commit was refused with the overlay on; the frame is
188215 // composed again without it.
189216 var errOverlayDropped = errors.New("overlay dropped")
@@ -49,6 +49,8 @@ type overlayWin struct {
49 buf uint6449 buf uint64
50 rect ports.Rect // CRTC rect50 rect ports.Rect // CRTC rect
51 w, h int // buffer size51 w, h int // buffer size
52+ // color is how the plane's colour pipeline shows the buffer.
53+ color colorUse
52 // acquire is the client's explicit-sync fence, a duplicate owned by54 // acquire is the client's explicit-sync fence, a duplicate owned by
53 // the frame loop (closed after the commit); nil without one.55 // the frame loop (closed after the commit); nil without one.
54 acquire *os.File56 acquire *os.File
@@ -56,16 +58,17 @@ type overlayWin struct {
56 58
57 // overlayCandidate finds the one window the overlay can show: an opaque59 // overlayCandidate finds the one window the overlay can show: an opaque
58 // dmabuf at integer physical coordinates, drawn 1:1, with no other window60 // dmabuf at integer physical coordinates, drawn 1:1, with no other window
59-// or layer above it. reason is why none.61+// or layer above it, and the plane colour mode it needs. reason is why none.
60-func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (ports.SceneWindow, ports.SurfaceContent, string) {62+func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, hdrOn bool, pipeline *colorPipeline) (ports.SceneWindow, ports.SurfaceContent, colorMode, string) {
61 if s.Transform != 0 {63 if s.Transform != 0 {
62- return ports.SceneWindow{}, ports.SurfaceContent{}, "output_transform"64+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "output_transform"
63 }65 }
64 scale := s.Scale66 scale := s.Scale
65 if scale <= 0 {67 if scale <= 0 {
66 scale = 168 scale = 1
67 }69 }
68 var pick *ports.SceneWindow70 var pick *ports.SceneWindow
71+ var mode colorMode
69 for i := range s.Windows {72 for i := range s.Windows {
70 w := &s.Windows[i]73 w := &s.Windows[i]
71 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {74 if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
@@ -73,59 +76,62 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
73 }76 }
74 if w.Popup {77 if w.Popup {
75 // Popups draw after every window, wherever they are listed.78 // Popups draw after every window, wherever they are listed.
76- return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"79+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "window_above"
77 }80 }
78 c := surfaces[w.ID]81 c := surfaces[w.ID]
79- // A viewport crop or PQ content is composed: the plane would show82+ // A viewport crop is composed: the plane would show the whole
80- // the whole buffer, and raw PQ values on this SDR output. A dimmed83+ // buffer, and so is a colour the plane cannot show (raw PQ values
84+ // on an SDR output, SDR on HDR without a pipeline). A dimmed
81 // window (a peeking stashed one) needs the veil drawn over it, and85 // window (a peeking stashed one) needs the veil drawn over it, and
82 // one with the focus effect needs its lift; an overview preview is86 // one with the focus effect needs its lift; an overview preview is
83 // drawn smaller than its buffer.87 // drawn smaller than its buffer.
84- if w.Dim <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) && !c.Color.IsPQ2020() {88+ if w.Dim <= 0 && w.FocusEffect <= 0 && w.Preview <= 0 && c.DMABuf != nil && !isYUVFormat(c.DMABuf.Format) && c.Opaque && len(c.Children) == 0 && c.Transform == 0 && !cropped(c) {
85- pick = w89+ if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" {
86- continue90+ pick, mode = w, m
91+ continue
92+ }
87 }93 }
88 if pick != nil {94 if pick != nil {
89 // Something is drawn above the candidate.95 // Something is drawn above the candidate.
90- return ports.SceneWindow{}, ports.SurfaceContent{}, "window_above"96+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "window_above"
91 }97 }
92 }98 }
93 if pick == nil {99 if pick == nil {
94- return ports.SceneWindow{}, ports.SurfaceContent{}, "no_candidate"100+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "no_candidate"
95 }101 }
96 if s.Dim > 0 {102 if s.Dim > 0 {
97 // The renderer composes the veil (under the first float, or under103 // The renderer composes the veil (under the first float, or under
98 // every window with DimBehind): a window on the plane would skip it.104 // every window with DimBehind): a window on the plane would skip it.
99- return ports.SceneWindow{}, ports.SurfaceContent{}, "dim"105+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "dim"
100 }106 }
101 if len(s.DropHints) > 0 {107 if len(s.DropHints) > 0 {
102 // Drag hints are drawn over the windows.108 // Drag hints are drawn over the windows.
103- return ports.SceneWindow{}, ports.SurfaceContent{}, "drop_hint"109+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "drop_hint"
104 }110 }
105 for _, l := range s.Layers {111 for _, l := range s.Layers {
106 if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 {112 if l.Layer >= ports.LayerTop && l.Rect.W > 0 && l.Rect.H > 0 {
107- return ports.SceneWindow{}, ports.SurfaceContent{}, "layer_above"113+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "layer_above"
108 }114 }
109 }115 }
110 if !pick.Fullscreen && len(s.Separators) > 0 {116 if !pick.Fullscreen && len(s.Separators) > 0 {
111 // The overlay shows the buffer alone; the lines would go with it.117 // The overlay shows the buffer alone; the lines would go with it.
112- return ports.SceneWindow{}, ports.SurfaceContent{}, "border"118+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "border"
113 }119 }
114 if clip := s.WorkspaceClip; clip != (ports.Rect{}) &&120 if clip := s.WorkspaceClip; clip != (ports.Rect{}) &&
115 (pick.Rect.X < clip.X || pick.Rect.Y < clip.Y || pick.Rect.X+pick.Rect.W > clip.X+clip.W || pick.Rect.Y+pick.Rect.H > clip.Y+clip.H) {121 (pick.Rect.X < clip.X || pick.Rect.Y < clip.Y || pick.Rect.X+pick.Rect.W > clip.X+clip.W || pick.Rect.Y+pick.Rect.H > clip.Y+clip.H) {
116 // The plane cannot apply the workspace's logical viewport crop.122 // The plane cannot apply the workspace's logical viewport crop.
117- return ports.SceneWindow{}, ports.SurfaceContent{}, "workspace_clip"123+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "workspace_clip"
118 }124 }
119 c := surfaces[pick.ID]125 c := surfaces[pick.ID]
120 pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale126 pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale
121 if float64(c.Width) != pw || float64(c.Height) != ph || c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0) {127 if float64(c.Width) != pw || float64(c.Height) != ph || c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0) {
122- return ports.SceneWindow{}, ports.SurfaceContent{}, "scaled"128+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "scaled"
123 }129 }
124 x, y := float64(pick.Rect.X)*scale, float64(pick.Rect.Y)*scale130 x, y := float64(pick.Rect.X)*scale, float64(pick.Rect.Y)*scale
125 if x != float64(int(x)) || y != float64(int(y)) {131 if x != float64(int(x)) || y != float64(int(y)) {
126- return ports.SceneWindow{}, ports.SurfaceContent{}, "fractional_position"132+ return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "fractional_position"
127 }133 }
128- return *pick, c, ""134+ return *pick, c, mode, ""
129 }135 }
130 136
131 // overlayProps puts ov on the overlay plane, or turns it off.137 // overlayProps puts ov on the overlay plane, or turns it off.
@@ -137,8 +143,10 @@ func (o *Output) overlayProps(req *atomicReq, ov overlayWin) {
137 if ov.fb == 0 {143 if ov.fb == 0 {
138 req.set(p.id, p.prop("FB_ID"), 0)144 req.set(p.id, p.prop("FB_ID"), 0)
139 req.set(p.id, p.prop("CRTC_ID"), 0)145 req.set(p.id, p.prop("CRTC_ID"), 0)
146+ o.overlayColorProps(req, colorBypass)
140 return147 return
141 }148 }
149+ o.overlayColorProps(req, ov.color.mode)
142 req.set(p.id, p.prop("FB_ID"), uint64(ov.fb))150 req.set(p.id, p.prop("FB_ID"), uint64(ov.fb))
143 req.set(p.id, p.prop("CRTC_ID"), uint64(o.crtc))151 req.set(p.id, p.prop("CRTC_ID"), uint64(o.crtc))
144 req.set(p.id, p.prop("SRC_X"), 0)152 req.set(p.id, p.prop("SRC_X"), 0)
@@ -164,18 +172,20 @@ func (ov overlayWin) close() {
164 172
165 // testOverlay asks KMS whether it takes the frame with ov on the overlay,173 // testOverlay asks KMS whether it takes the frame with ov on the overlay,
166 // the cursor at its current place included. A refusal is cached on the174 // the cursor at its current place included. A refusal is cached on the
167-// buffer.175+// buffer; one that the plane's colour pipeline causes (the same frame passes
176+// with Bypass) is cached on the plane for the buffer format and the cursor
177+// state instead.
168 func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {178 func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {
169- req := &atomicReq{}179+ err := o.probeOverlay(fb, ov, ov.color.mode)
170- o.primaryProps(req, fb)
171- o.overlayProps(req, ov)
172- if o.cursor != nil {
173- o.cursor.props(req, o.crtc, o.cursor.desired())
174- }
175- err := o.k.commit(req, atomicTestOnly, 0)
176 if err == nil {180 if err == nil {
177 return true181 return true
178 }182 }
183+ if ov.color.mode != colorBypass && refused(err) && o.probeOverlay(fb, ov, colorBypass) == nil {
184+ o.overlay.setVerdict(ov.color.format, o.cursorShown(), false)
185+ o.setOverlayReason("color_refused")
186+ o.log.Info().Str("component", "render").Err(err).Uint32("format", ov.color.format).Str("connector", o.conn.name).Msg("overlay colour pipeline refused")
187+ return false
188+ }
179 if cfb := o.clientFBs[ov.buf]; cfb != nil && refused(err) {189 if cfb := o.clientFBs[ov.buf]; cfb != nil && refused(err) {
180 cfb.overlayFailed = "overlay_refused"190 cfb.overlayFailed = "overlay_refused"
181 }191 }
@@ -184,6 +194,23 @@ func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {
184 return false194 return false
185 }195 }
186 196
197+// probeOverlay is a TEST_ONLY of the frame: the composed image on the
198+// primary plane (Bypass), ov on the overlay with colour mode, the cursor.
199+// Both planes' colour state is written whatever they applied: the test must
200+// not rely on the state of the last commit.
201+func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode) error {
202+ req := &o.probeReq // no frame request is alive: the frame commit follows
203+ req.reset()
204+ o.primaryProps(req, fb)
205+ o.primary.colorProps(req, colorBypass, 0, 0)
206+ o.overlayProps(req, ov)
207+ o.overlay.colorProps(req, mode, o.colorMult, o.ctmBlob)
208+ if o.cursor != nil {
209+ o.cursor.props(req, o.crtc, o.cursor.desired())
210+ }
211+ return o.k.commit(req, atomicTestOnly, 0)
212+}
213+
187 // errOverlayDropped: a commit was refused with the overlay on; the frame is214 // errOverlayDropped: a commit was refused with the overlay on; the frame is
188 // composed again without it.215 // composed again without it.
189 var errOverlayDropped = errors.New("overlay dropped")216 var errOverlayDropped = errors.New("overlay dropped")
M internal/adapters/drm/overlay_test.go +17 -17
@@ -54,28 +54,28 @@ func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {
5454 func TestOverlayWorkspaceClip(t *testing.T) {
5555 s, contents := overlayScene()
5656 s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}
57- if _, _, reason := overlayCandidate(s, contents); reason != "workspace_clip" {
57+ if _, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "workspace_clip" {
5858 t.Fatalf("overflowing workspace overlay: %q", reason)
5959 }
6060 s.WorkspaceClip = ports.Rect{X: 100, W: 100, H: 100}
61- if win, _, reason := overlayCandidate(s, contents); reason != "" || win.ID != 2 {
61+ if win, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "" || win.ID != 2 {
6262 t.Fatalf("contained workspace overlay: %v %q", win.ID, reason)
6363 }
6464 }
6565
6666 func TestOverlayCandidate(t *testing.T) {
6767 s, c := overlayScene()
68- if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 {
68+ if w, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" || w.ID != 2 {
6969 t.Fatalf("candidate %v %q", w.ID, reason)
7070 }
7171 // A rotated output composes: planes are not rotated.
7272 rs, rc := overlayScene()
7373 rs.Transform = 1
74- if _, _, reason := overlayCandidate(rs, rc); reason != "output_transform" {
74+ if _, _, _, reason := overlayCandidate(rs, rc, false, nil); reason != "output_transform" {
7575 t.Fatalf("rotated output reason %q", reason)
7676 }
7777 c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}}
78- if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
78+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
7979 t.Fatalf("YUV overlay reason %q", reason)
8080 }
8181 // A transformed buffer is composed: the plane would show it unrotated.
@@ -83,46 +83,46 @@ func TestOverlayCandidate(t *testing.T) {
8383 rotated := c[2]
8484 rotated.Transform = 1
8585 c[2] = rotated
86- if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
86+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
8787 t.Fatalf("transformed overlay reason %q", reason)
8888 }
8989 _, c = overlayScene()
9090 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})
91- if _, _, reason := overlayCandidate(s, c); reason != "window_above" {
91+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" {
9292 t.Fatalf("reason %q", reason)
9393 }
9494 s, _ = overlayScene()
9595 s.Scale = 1.5
96- if _, _, reason := overlayCandidate(s, c); reason != "scaled" {
96+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "scaled" {
9797 t.Fatalf("reason %q", reason)
9898 }
9999 s, _ = overlayScene()
100100 s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 5, H: 5}}}
101- if _, _, reason := overlayCandidate(s, c); reason != "layer_above" {
101+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "layer_above" {
102102 t.Fatalf("reason %q", reason)
103103 }
104104 // The dim veil is composed under the float: nothing leaves for the plane.
105105 s, _ = overlayScene()
106106 s.Dim = 0.3
107- if _, _, reason := overlayCandidate(s, c); reason != "dim" {
107+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "dim" {
108108 t.Fatalf("dim reason %q", reason)
109109 }
110110 // A dimmed window (a peeking stashed one) needs its veil composed.
111111 s, _ = overlayScene()
112112 s.Windows[len(s.Windows)-1].Dim = 0.5
113- if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
113+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
114114 t.Fatalf("dimmed window reason %q", reason)
115115 }
116116 // A window with the focus effect needs it composed.
117117 s, _ = overlayScene()
118118 s.Windows[len(s.Windows)-1].FocusEffect = 0.05
119- if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
119+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
120120 t.Fatalf("pulsing window reason %q", reason)
121121 }
122122 // An overview preview is drawn smaller than its buffer: composed.
123123 s, _ = overlayScene()
124124 s.Windows[len(s.Windows)-1].Preview = 0.5
125- if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
125+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
126126 t.Fatalf("preview reason %q", reason)
127127 }
128128 // A viewport crop is composed: the plane would show the whole buffer.
@@ -130,13 +130,13 @@ func TestOverlayCandidate(t *testing.T) {
130130 cropped := c[2]
131131 cropped.Source = [4]float32{10, 10, 50, 50}
132132 c[2] = cropped
133- if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {
133+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
134134 t.Fatalf("cropped overlay reason %q", reason)
135135 }
136136 // A full-buffer source is no crop.
137137 cropped.Source = [4]float32{0, 0, 100, 100}
138138 c[2] = cropped
139- if _, _, reason := overlayCandidate(s, c); reason != "" {
139+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" {
140140 t.Fatalf("full source reason %q", reason)
141141 }
142142 }
@@ -212,12 +212,12 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) {
212212 func TestOverlayPopupAndBorder(t *testing.T) {
213213 s, c := overlayScene()
214214 s.Windows = append([]ports.SceneWindow{{ID: 7, Popup: true, Rect: ports.Rect{W: 5, H: 5}}}, s.Windows...)
215- if _, _, reason := overlayCandidate(s, c); reason != "window_above" {
215+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" {
216216 t.Fatalf("popup: %q", reason)
217217 }
218218 s, _ = overlayScene()
219219 s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}}
220- if _, _, reason := overlayCandidate(s, c); reason != "border" {
220+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "border" {
221221 t.Fatalf("border: %q", reason)
222222 }
223223 }
@@ -54,28 +54,28 @@ func TestScanoutWorkspaceClipCannotBeBypassed(t *testing.T) {
54 func TestOverlayWorkspaceClip(t *testing.T) {54 func TestOverlayWorkspaceClip(t *testing.T) {
55 s, contents := overlayScene()55 s, contents := overlayScene()
56 s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}56 s.WorkspaceClip = ports.Rect{X: 50, W: 100, H: 100}
57- if _, _, reason := overlayCandidate(s, contents); reason != "workspace_clip" {57+ if _, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "workspace_clip" {
58 t.Fatalf("overflowing workspace overlay: %q", reason)58 t.Fatalf("overflowing workspace overlay: %q", reason)
59 }59 }
60 s.WorkspaceClip = ports.Rect{X: 100, W: 100, H: 100}60 s.WorkspaceClip = ports.Rect{X: 100, W: 100, H: 100}
61- if win, _, reason := overlayCandidate(s, contents); reason != "" || win.ID != 2 {61+ if win, _, _, reason := overlayCandidate(s, contents, false, nil); reason != "" || win.ID != 2 {
62 t.Fatalf("contained workspace overlay: %v %q", win.ID, reason)62 t.Fatalf("contained workspace overlay: %v %q", win.ID, reason)
63 }63 }
64 }64 }
65 65
66 func TestOverlayCandidate(t *testing.T) {66 func TestOverlayCandidate(t *testing.T) {
67 s, c := overlayScene()67 s, c := overlayScene()
68- if w, _, reason := overlayCandidate(s, c); reason != "" || w.ID != 2 {68+ if w, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" || w.ID != 2 {
69 t.Fatalf("candidate %v %q", w.ID, reason)69 t.Fatalf("candidate %v %q", w.ID, reason)
70 }70 }
71 // A rotated output composes: planes are not rotated.71 // A rotated output composes: planes are not rotated.
72 rs, rc := overlayScene()72 rs, rc := overlayScene()
73 rs.Transform = 173 rs.Transform = 1
74- if _, _, reason := overlayCandidate(rs, rc); reason != "output_transform" {74+ if _, _, _, reason := overlayCandidate(rs, rc, false, nil); reason != "output_transform" {
75 t.Fatalf("rotated output reason %q", reason)75 t.Fatalf("rotated output reason %q", reason)
76 }76 }
77 c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}}77 c[2] = ports.SurfaceContent{ID: 2, Width: 100, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{Format: fourccNV12}}
78- if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {78+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
79 t.Fatalf("YUV overlay reason %q", reason)79 t.Fatalf("YUV overlay reason %q", reason)
80 }80 }
81 // A transformed buffer is composed: the plane would show it unrotated.81 // A transformed buffer is composed: the plane would show it unrotated.
@@ -83,46 +83,46 @@ func TestOverlayCandidate(t *testing.T) {
83 rotated := c[2]83 rotated := c[2]
84 rotated.Transform = 184 rotated.Transform = 1
85 c[2] = rotated85 c[2] = rotated
86- if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {86+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
87 t.Fatalf("transformed overlay reason %q", reason)87 t.Fatalf("transformed overlay reason %q", reason)
88 }88 }
89 _, c = overlayScene()89 _, c = overlayScene()
90 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})90 s.Windows = append(s.Windows, ports.SceneWindow{ID: 3, Rect: ports.Rect{W: 10, H: 10}})
91- if _, _, reason := overlayCandidate(s, c); reason != "window_above" {91+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" {
92 t.Fatalf("reason %q", reason)92 t.Fatalf("reason %q", reason)
93 }93 }
94 s, _ = overlayScene()94 s, _ = overlayScene()
95 s.Scale = 1.595 s.Scale = 1.5
96- if _, _, reason := overlayCandidate(s, c); reason != "scaled" {96+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "scaled" {
97 t.Fatalf("reason %q", reason)97 t.Fatalf("reason %q", reason)
98 }98 }
99 s, _ = overlayScene()99 s, _ = overlayScene()
100 s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 5, H: 5}}}100 s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 5, H: 5}}}
101- if _, _, reason := overlayCandidate(s, c); reason != "layer_above" {101+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "layer_above" {
102 t.Fatalf("reason %q", reason)102 t.Fatalf("reason %q", reason)
103 }103 }
104 // The dim veil is composed under the float: nothing leaves for the plane.104 // The dim veil is composed under the float: nothing leaves for the plane.
105 s, _ = overlayScene()105 s, _ = overlayScene()
106 s.Dim = 0.3106 s.Dim = 0.3
107- if _, _, reason := overlayCandidate(s, c); reason != "dim" {107+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "dim" {
108 t.Fatalf("dim reason %q", reason)108 t.Fatalf("dim reason %q", reason)
109 }109 }
110 // A dimmed window (a peeking stashed one) needs its veil composed.110 // A dimmed window (a peeking stashed one) needs its veil composed.
111 s, _ = overlayScene()111 s, _ = overlayScene()
112 s.Windows[len(s.Windows)-1].Dim = 0.5112 s.Windows[len(s.Windows)-1].Dim = 0.5
113- if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {113+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
114 t.Fatalf("dimmed window reason %q", reason)114 t.Fatalf("dimmed window reason %q", reason)
115 }115 }
116 // A window with the focus effect needs it composed.116 // A window with the focus effect needs it composed.
117 s, _ = overlayScene()117 s, _ = overlayScene()
118 s.Windows[len(s.Windows)-1].FocusEffect = 0.05118 s.Windows[len(s.Windows)-1].FocusEffect = 0.05
119- if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {119+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
120 t.Fatalf("pulsing window reason %q", reason)120 t.Fatalf("pulsing window reason %q", reason)
121 }121 }
122 // An overview preview is drawn smaller than its buffer: composed.122 // An overview preview is drawn smaller than its buffer: composed.
123 s, _ = overlayScene()123 s, _ = overlayScene()
124 s.Windows[len(s.Windows)-1].Preview = 0.5124 s.Windows[len(s.Windows)-1].Preview = 0.5
125- if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {125+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
126 t.Fatalf("preview reason %q", reason)126 t.Fatalf("preview reason %q", reason)
127 }127 }
128 // A viewport crop is composed: the plane would show the whole buffer.128 // A viewport crop is composed: the plane would show the whole buffer.
@@ -130,13 +130,13 @@ func TestOverlayCandidate(t *testing.T) {
130 cropped := c[2]130 cropped := c[2]
131 cropped.Source = [4]float32{10, 10, 50, 50}131 cropped.Source = [4]float32{10, 10, 50, 50}
132 c[2] = cropped132 c[2] = cropped
133- if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" {133+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" {
134 t.Fatalf("cropped overlay reason %q", reason)134 t.Fatalf("cropped overlay reason %q", reason)
135 }135 }
136 // A full-buffer source is no crop.136 // A full-buffer source is no crop.
137 cropped.Source = [4]float32{0, 0, 100, 100}137 cropped.Source = [4]float32{0, 0, 100, 100}
138 c[2] = cropped138 c[2] = cropped
139- if _, _, reason := overlayCandidate(s, c); reason != "" {139+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" {
140 t.Fatalf("full source reason %q", reason)140 t.Fatalf("full source reason %q", reason)
141 }141 }
142 }142 }
@@ -212,12 +212,12 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) {
212 func TestOverlayPopupAndBorder(t *testing.T) {212 func TestOverlayPopupAndBorder(t *testing.T) {
213 s, c := overlayScene()213 s, c := overlayScene()
214 s.Windows = append([]ports.SceneWindow{{ID: 7, Popup: true, Rect: ports.Rect{W: 5, H: 5}}}, s.Windows...)214 s.Windows = append([]ports.SceneWindow{{ID: 7, Popup: true, Rect: ports.Rect{W: 5, H: 5}}}, s.Windows...)
215- if _, _, reason := overlayCandidate(s, c); reason != "window_above" {215+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "window_above" {
216 t.Fatalf("popup: %q", reason)216 t.Fatalf("popup: %q", reason)
217 }217 }
218 s, _ = overlayScene()218 s, _ = overlayScene()
219 s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}}219 s.Separators = []ports.Separator{{Rect: ports.Rect{W: 2, H: 2}}}
220- if _, _, reason := overlayCandidate(s, c); reason != "border" {220+ if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "border" {
221 t.Fatalf("border: %q", reason)221 t.Fatalf("border: %q", reason)
222 }222 }
223 }223 }
M internal/adapters/drm/props.go +31 -23
@@ -15,6 +15,7 @@ const (
1515
1616 objCrtc = 0xcccccccc
1717 objConnector = 0xc0c0c0c0
18+ objColorop = 0xfafafafa // DRM_MODE_OBJECT_COLOROP
1819 )
1920
2021 type objGetProps struct {
@@ -61,40 +62,47 @@ func objProps(fd int, obj, typ uint32) (map[string][2]uint64, error) {
6162 return out, nil
6263 }
6364
64-// readContentTypeProp records the connector's content type enum values.
65-func readContentTypeProp(fd int, props map[string][2]uint64) (uint32, [5]uint64) {
66- id := uint32(props["content type"][0])
67- var values [5]uint64
68- if id == 0 {
69- return 0, values
70- }
65+// propEnums returns the enum names of property id with their values.
66+func propEnums(fd int, id uint32) (map[string]uint64, error) {
7167 p := getProp{propID: id}
72- if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) != nil || p.countEn == 0 {
73- return 0, values
68+ if err := ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)); err != nil {
69+ return nil, err
70+ }
71+ if p.countEn == 0 {
72+ return nil, nil
7473 }
7574 enums := make([]propEnum, p.countEn)
75+ // The kernel also copies an enum's values when countValues is set: a
76+ // nil values pointer makes the whole call fail (EFAULT).
7677 v := make([]uint64, max(p.countValues, 1))
7778 p.enumBlobs = uint64(uintptr(unsafe.Pointer(&enums[0])))
7879 p.values = uint64(uintptr(unsafe.Pointer(&v[0])))
79- if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) != nil {
80- return 0, values
80+ if err := ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)); err != nil {
81+ return nil, err
8182 }
83+ out := make(map[string]uint64, p.countEn)
8284 for _, e := range enums[:min(int(p.countEn), len(enums))] {
83- switch unix.ByteSliceToString(e.name[:]) {
84- case "No Data":
85- values[0] = e.value
86- case "Graphics":
87- values[1] = e.value
88- case "Photo":
89- values[2] = e.value
90- case "Cinema":
91- values[3] = e.value
92- case "Game":
93- values[4] = e.value
94- }
85+ out[unix.ByteSliceToString(e.name[:])] = e.value
9586 }
9687 runtime.KeepAlive(enums)
9788 runtime.KeepAlive(v)
89+ return out, nil
90+}
91+
92+// readContentTypeProp records the connector's content type enum values.
93+func readContentTypeProp(fd int, props map[string][2]uint64) (uint32, [5]uint64) {
94+ id := uint32(props["content type"][0])
95+ var values [5]uint64
96+ if id == 0 {
97+ return 0, values
98+ }
99+ enums, err := propEnums(fd, id)
100+ if err != nil || len(enums) == 0 {
101+ return 0, values
102+ }
103+ for i, name := range [...]string{"No Data", "Graphics", "Photo", "Cinema", "Game"} {
104+ values[i] = enums[name]
105+ }
98106 return id, values
99107 }
100108
@@ -15,6 +15,7 @@ const (
15 15
16 objCrtc = 0xcccccccc16 objCrtc = 0xcccccccc
17 objConnector = 0xc0c0c0c017 objConnector = 0xc0c0c0c0
18+ objColorop = 0xfafafafa // DRM_MODE_OBJECT_COLOROP
18 )19 )
19 20
20 type objGetProps struct {21 type objGetProps struct {
@@ -61,40 +62,47 @@ func objProps(fd int, obj, typ uint32) (map[string][2]uint64, error) {
61 return out, nil62 return out, nil
62 }63 }
63 64
64-// readContentTypeProp records the connector's content type enum values.65+// propEnums returns the enum names of property id with their values.
65-func readContentTypeProp(fd int, props map[string][2]uint64) (uint32, [5]uint64) {66+func propEnums(fd int, id uint32) (map[string]uint64, error) {
66- id := uint32(props["content type"][0])
67- var values [5]uint64
68- if id == 0 {
69- return 0, values
70- }
71 p := getProp{propID: id}67 p := getProp{propID: id}
72- if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) != nil || p.countEn == 0 {68+ if err := ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)); err != nil {
73- return 0, values69+ return nil, err
70+ }
71+ if p.countEn == 0 {
72+ return nil, nil
74 }73 }
75 enums := make([]propEnum, p.countEn)74 enums := make([]propEnum, p.countEn)
75+ // The kernel also copies an enum's values when countValues is set: a
76+ // nil values pointer makes the whole call fail (EFAULT).
76 v := make([]uint64, max(p.countValues, 1))77 v := make([]uint64, max(p.countValues, 1))
77 p.enumBlobs = uint64(uintptr(unsafe.Pointer(&enums[0])))78 p.enumBlobs = uint64(uintptr(unsafe.Pointer(&enums[0])))
78 p.values = uint64(uintptr(unsafe.Pointer(&v[0])))79 p.values = uint64(uintptr(unsafe.Pointer(&v[0])))
79- if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) != nil {80+ if err := ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)); err != nil {
80- return 0, values81+ return nil, err
81 }82 }
83+ out := make(map[string]uint64, p.countEn)
82 for _, e := range enums[:min(int(p.countEn), len(enums))] {84 for _, e := range enums[:min(int(p.countEn), len(enums))] {
83- switch unix.ByteSliceToString(e.name[:]) {85+ out[unix.ByteSliceToString(e.name[:])] = e.value
84- case "No Data":
85- values[0] = e.value
86- case "Graphics":
87- values[1] = e.value
88- case "Photo":
89- values[2] = e.value
90- case "Cinema":
91- values[3] = e.value
92- case "Game":
93- values[4] = e.value
94- }
95 }86 }
96 runtime.KeepAlive(enums)87 runtime.KeepAlive(enums)
97 runtime.KeepAlive(v)88 runtime.KeepAlive(v)
89+ return out, nil
90+}
91+
92+// readContentTypeProp records the connector's content type enum values.
93+func readContentTypeProp(fd int, props map[string][2]uint64) (uint32, [5]uint64) {
94+ id := uint32(props["content type"][0])
95+ var values [5]uint64
96+ if id == 0 {
97+ return 0, values
98+ }
99+ enums, err := propEnums(fd, id)
100+ if err != nil || len(enums) == 0 {
101+ return 0, values
102+ }
103+ for i, name := range [...]string{"No Data", "Graphics", "Photo", "Cinema", "Game"} {
104+ values[i] = enums[name]
105+ }
98 return id, values106 return id, values
99 }107 }
100 108
M internal/adapters/drm/protection.go +2 -0
@@ -87,12 +87,14 @@ func (o *Output) disableProtected() error {
8787 req.set(o.crtc, o.vrrProp, 0)
8888 req.set(o.conn.id, o.connCrtc, 0)
8989 o.hdrConnectorProps(req, false)
90+ o.forceBypass(req)
9091 if err := o.detachProtectedPlanes(req); err != nil {
9192 return protectedCommitError{err}
9293 }
9394 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
9495 return protectedCommitError{fmt.Errorf("disable protected output: %w", err)}
9596 }
97+ o.colorBypassed()
9698 if o.modeBlob != 0 {
9799 _ = o.k.destroyBlob(o.modeBlob) // MODE_ID is 0 now: nothing references it
98100 o.modeBlob = 0
@@ -87,12 +87,14 @@ func (o *Output) disableProtected() error {
87 req.set(o.crtc, o.vrrProp, 0)87 req.set(o.crtc, o.vrrProp, 0)
88 req.set(o.conn.id, o.connCrtc, 0)88 req.set(o.conn.id, o.connCrtc, 0)
89 o.hdrConnectorProps(req, false)89 o.hdrConnectorProps(req, false)
90+ o.forceBypass(req)
90 if err := o.detachProtectedPlanes(req); err != nil {91 if err := o.detachProtectedPlanes(req); err != nil {
91 return protectedCommitError{err}92 return protectedCommitError{err}
92 }93 }
93 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {94 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
94 return protectedCommitError{fmt.Errorf("disable protected output: %w", err)}95 return protectedCommitError{fmt.Errorf("disable protected output: %w", err)}
95 }96 }
97+ o.colorBypassed()
96 if o.modeBlob != 0 {98 if o.modeBlob != 0 {
97 _ = o.k.destroyBlob(o.modeBlob) // MODE_ID is 0 now: nothing references it99 _ = o.k.destroyBlob(o.modeBlob) // MODE_ID is 0 now: nothing references it
98 o.modeBlob = 0100 o.modeBlob = 0
M internal/adapters/drm/scanout.go +34 -28
@@ -197,23 +197,12 @@ func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) {
197197 // scanoutFrame picks a fullscreen client buffer to flip directly. It
198198 // returns fb 0, with the reason logged on change, when the frame must be
199199 // composed.
200-func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) {
200+func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent, mode colorMode) {
201201 reason := "disabled"
202202 if o.scanout {
203203 c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
204- if reason == "" && isYUVFormat(c.DMABuf.Format) {
205- reason = "yuv"
206- }
207- if o.hdr.on && reason == "" {
208- switch {
209- case !c.Color.IsPQ2020():
210- reason = "hdr_sdr_content"
211- case !isTenBit(c.DMABuf.Format):
212- reason = "hdr_format"
213- }
214- } else if !o.hdr.on && reason == "" && c.Color.IsPQ2020() {
215- // An SDR connector must never show raw PQ values.
216- reason = "sdr_pq_content"
204+ if reason == "" {
205+ mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline)
217206 }
218207 }
219208 if reason == "" {
@@ -224,7 +213,8 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por
224213 if cfb.scaleRefused && cfb.scaleFailed == rect {
225214 reason = "scale_refused"
226215 } else if rect != fullPlaneRect(o.Width(), o.Height()) && (!cfb.scaleTestedOK || cfb.scaleTested != rect) {
227- req := &atomicReq{}
216+ req := &o.probeReq
217+ req.reset()
228218 o.primaryRectProps(req, fb, rect)
229219 if err := o.k.commit(req, atomicTestOnly, 0); err != nil {
230220 cfb.scaleFailed = rect
@@ -236,27 +226,41 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por
236226 cfb.scaleTestedOK = true
237227 }
238228 }
229+ if reason == "" && mode != colorBypass && !o.colorAllowed(o.primary, c.DMABuf.Format, func(req *atomicReq) {
230+ o.primaryRectProps(req, fb, rect)
231+ if o.overlayOn != 0 {
232+ o.overlayProps(req, overlayWin{})
233+ }
234+ }) {
235+ reason = "color_refused"
236+ }
239237 }
240238 }
241239 o.setScanoutReason(reason)
242240 if reason != "" {
243- return 0, c
241+ return 0, c, colorBypass
244242 }
245- return fb, c
243+ return fb, c, mode
246244 }
247245
248246 // overlayFrame decides the overlay of a frame: the window, and the scene
249247 // the renderer composes (the window left out). A zero overlayWin means
250-// none; the reason is logged on change.
248+// none; the reason is logged on change. The returned scene's Windows is
249+// valid until the next call: it reuses o.restWindows (its consumers, Render,
250+// ExcludedScene and the capture tracking, run synchronously before then).
251251 func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) {
252252 reason := "no_plane"
253253 var ov overlayWin
254- if o.hdr.on {
255- reason = "hdr"
256- } else if o.overlay != nil && o.scanout {
254+ if o.overlay != nil && o.scanout {
257255 var w ports.SceneWindow
258256 var c ports.SurfaceContent
259- w, c, reason = overlayCandidate(s, surfaces)
257+ var mode colorMode
258+ w, c, mode, reason = overlayCandidate(s, surfaces, o.hdr.on, o.overlay.pipeline)
259+ if reason == "" && mode != colorBypass {
260+ if ok, known := o.overlay.verdict(c.DMABuf.Format, o.cursorShown()); known && !ok {
261+ reason = "color_refused"
262+ }
263+ }
260264 if reason == "" {
261265 var fb uint32
262266 if fb, reason = o.overlayFB(c.DMABuf, time.Now()); reason == "" {
@@ -266,22 +270,23 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S
266270 }
267271 ov = overlayWin{id: w.ID, fb: fb, buf: c.DMABuf.ID, w: c.Width, h: c.Height,
268272 rect: ports.Rect{X: int(float64(w.Rect.X) * scale), Y: int(float64(w.Rect.Y) * scale), W: c.Width, H: c.Height},
269- acquire: dupFence(c.Acquire)}
273+ acquire: dupFence(c.Acquire), color: colorUse{mode: mode, format: c.DMABuf.Format}}
270274 }
271275 }
272276 }
273277 o.setOverlayReason(reason)
274- if ov.fb == 0 {
278+ if ov.id == 0 {
275279 return overlayWin{}, s
276280 }
277281 // The composed frame leaves the window out: the overlay shows it.
278282 rest := s
279- rest.Windows = make([]ports.SceneWindow, 0, len(s.Windows))
283+ o.restWindows = o.restWindows[:0]
280284 for _, w := range s.Windows {
281285 if w.ID != ov.id {
282- rest.Windows = append(rest.Windows, w)
286+ o.restWindows = append(o.restWindows, w)
283287 }
284288 }
289+ rest.Windows = o.restWindows
285290 return ov, rest
286291 }
287292
@@ -290,6 +295,7 @@ type frameDecision struct {
290295 fb uint32
291296 content ports.SurfaceContent
292297 rect planeRect
298+ color colorMode // how the primary plane shows fb
293299 overlay overlayWin
294300 composed ports.Scene
295301 }
@@ -312,9 +318,9 @@ func (o *Output) decideFrame(s ports.Scene, surfaces map[ports.WindowID]ports.Su
312318 o.setOverlayReason("capture")
313319 return frameDecision{composed: s}
314320 }
315- fb, c := o.scanoutFrame(s, surfaces)
321+ fb, c, mode := o.scanoutFrame(s, surfaces)
316322 if fb != 0 {
317- return frameDecision{fb: fb, content: c, rect: scanoutRect(c, o.Width(), o.Height())}
323+ return frameDecision{fb: fb, content: c, rect: scanoutRect(c, o.Width(), o.Height()), color: mode}
318324 }
319325 return o.composeFrame(s, surfaces)
320326 }
@@ -197,23 +197,12 @@ func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) {
197 // scanoutFrame picks a fullscreen client buffer to flip directly. It197 // scanoutFrame picks a fullscreen client buffer to flip directly. It
198 // returns fb 0, with the reason logged on change, when the frame must be198 // returns fb 0, with the reason logged on change, when the frame must be
199 // composed.199 // composed.
200-func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) {200+func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent, mode colorMode) {
201 reason := "disabled"201 reason := "disabled"
202 if o.scanout {202 if o.scanout {
203 c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())203 c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
204- if reason == "" && isYUVFormat(c.DMABuf.Format) {204+ if reason == "" {
205- reason = "yuv"205+ mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline)
206- }
207- if o.hdr.on && reason == "" {
208- switch {
209- case !c.Color.IsPQ2020():
210- reason = "hdr_sdr_content"
211- case !isTenBit(c.DMABuf.Format):
212- reason = "hdr_format"
213- }
214- } else if !o.hdr.on && reason == "" && c.Color.IsPQ2020() {
215- // An SDR connector must never show raw PQ values.
216- reason = "sdr_pq_content"
217 }206 }
218 }207 }
219 if reason == "" {208 if reason == "" {
@@ -224,7 +213,8 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por
224 if cfb.scaleRefused && cfb.scaleFailed == rect {213 if cfb.scaleRefused && cfb.scaleFailed == rect {
225 reason = "scale_refused"214 reason = "scale_refused"
226 } else if rect != fullPlaneRect(o.Width(), o.Height()) && (!cfb.scaleTestedOK || cfb.scaleTested != rect) {215 } else if rect != fullPlaneRect(o.Width(), o.Height()) && (!cfb.scaleTestedOK || cfb.scaleTested != rect) {
227- req := &atomicReq{}216+ req := &o.probeReq
217+ req.reset()
228 o.primaryRectProps(req, fb, rect)218 o.primaryRectProps(req, fb, rect)
229 if err := o.k.commit(req, atomicTestOnly, 0); err != nil {219 if err := o.k.commit(req, atomicTestOnly, 0); err != nil {
230 cfb.scaleFailed = rect220 cfb.scaleFailed = rect
@@ -236,27 +226,41 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por
236 cfb.scaleTestedOK = true226 cfb.scaleTestedOK = true
237 }227 }
238 }228 }
229+ if reason == "" && mode != colorBypass && !o.colorAllowed(o.primary, c.DMABuf.Format, func(req *atomicReq) {
230+ o.primaryRectProps(req, fb, rect)
231+ if o.overlayOn != 0 {
232+ o.overlayProps(req, overlayWin{})
233+ }
234+ }) {
235+ reason = "color_refused"
236+ }
239 }237 }
240 }238 }
241 o.setScanoutReason(reason)239 o.setScanoutReason(reason)
242 if reason != "" {240 if reason != "" {
243- return 0, c241+ return 0, c, colorBypass
244 }242 }
245- return fb, c243+ return fb, c, mode
246 }244 }
247 245
248 // overlayFrame decides the overlay of a frame: the window, and the scene246 // overlayFrame decides the overlay of a frame: the window, and the scene
249 // the renderer composes (the window left out). A zero overlayWin means247 // the renderer composes (the window left out). A zero overlayWin means
250-// none; the reason is logged on change.248+// none; the reason is logged on change. The returned scene's Windows is
249+// valid until the next call: it reuses o.restWindows (its consumers, Render,
250+// ExcludedScene and the capture tracking, run synchronously before then).
251 func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) {251 func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) {
252 reason := "no_plane"252 reason := "no_plane"
253 var ov overlayWin253 var ov overlayWin
254- if o.hdr.on {254+ if o.overlay != nil && o.scanout {
255- reason = "hdr"
256- } else if o.overlay != nil && o.scanout {
257 var w ports.SceneWindow255 var w ports.SceneWindow
258 var c ports.SurfaceContent256 var c ports.SurfaceContent
259- w, c, reason = overlayCandidate(s, surfaces)257+ var mode colorMode
258+ w, c, mode, reason = overlayCandidate(s, surfaces, o.hdr.on, o.overlay.pipeline)
259+ if reason == "" && mode != colorBypass {
260+ if ok, known := o.overlay.verdict(c.DMABuf.Format, o.cursorShown()); known && !ok {
261+ reason = "color_refused"
262+ }
263+ }
260 if reason == "" {264 if reason == "" {
261 var fb uint32265 var fb uint32
262 if fb, reason = o.overlayFB(c.DMABuf, time.Now()); reason == "" {266 if fb, reason = o.overlayFB(c.DMABuf, time.Now()); reason == "" {
@@ -266,22 +270,23 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S
266 }270 }
267 ov = overlayWin{id: w.ID, fb: fb, buf: c.DMABuf.ID, w: c.Width, h: c.Height,271 ov = overlayWin{id: w.ID, fb: fb, buf: c.DMABuf.ID, w: c.Width, h: c.Height,
268 rect: ports.Rect{X: int(float64(w.Rect.X) * scale), Y: int(float64(w.Rect.Y) * scale), W: c.Width, H: c.Height},272 rect: ports.Rect{X: int(float64(w.Rect.X) * scale), Y: int(float64(w.Rect.Y) * scale), W: c.Width, H: c.Height},
269- acquire: dupFence(c.Acquire)}273+ acquire: dupFence(c.Acquire), color: colorUse{mode: mode, format: c.DMABuf.Format}}
270 }274 }
271 }275 }
272 }276 }
273 o.setOverlayReason(reason)277 o.setOverlayReason(reason)
274- if ov.fb == 0 {278+ if ov.id == 0 {
275 return overlayWin{}, s279 return overlayWin{}, s
276 }280 }
277 // The composed frame leaves the window out: the overlay shows it.281 // The composed frame leaves the window out: the overlay shows it.
278 rest := s282 rest := s
279- rest.Windows = make([]ports.SceneWindow, 0, len(s.Windows))283+ o.restWindows = o.restWindows[:0]
280 for _, w := range s.Windows {284 for _, w := range s.Windows {
281 if w.ID != ov.id {285 if w.ID != ov.id {
282- rest.Windows = append(rest.Windows, w)286+ o.restWindows = append(o.restWindows, w)
283 }287 }
284 }288 }
289+ rest.Windows = o.restWindows
285 return ov, rest290 return ov, rest
286 }291 }
287 292
@@ -290,6 +295,7 @@ type frameDecision struct {
290 fb uint32295 fb uint32
291 content ports.SurfaceContent296 content ports.SurfaceContent
292 rect planeRect297 rect planeRect
298+ color colorMode // how the primary plane shows fb
293 overlay overlayWin299 overlay overlayWin
294 composed ports.Scene300 composed ports.Scene
295 }301 }
@@ -312,9 +318,9 @@ func (o *Output) decideFrame(s ports.Scene, surfaces map[ports.WindowID]ports.Su
312 o.setOverlayReason("capture")318 o.setOverlayReason("capture")
313 return frameDecision{composed: s}319 return frameDecision{composed: s}
314 }320 }
315- fb, c := o.scanoutFrame(s, surfaces)321+ fb, c, mode := o.scanoutFrame(s, surfaces)
316 if fb != 0 {322 if fb != 0 {
317- return frameDecision{fb: fb, content: c, rect: scanoutRect(c, o.Width(), o.Height())}323+ return frameDecision{fb: fb, content: c, rect: scanoutRect(c, o.Width(), o.Height()), color: mode}
318 }324 }
319 return o.composeFrame(s, surfaces)325 return o.composeFrame(s, surfaces)
320 }326 }
M internal/adapters/drm/scanout_test.go +24 -1
@@ -247,7 +247,7 @@ func TestHDRScanoutDecision(t *testing.T) {
247247 c := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 11, Format: fourccXR30}}
248248 check := func(want string) {
249249 t.Helper()
250- fb, _ := o.scanoutFrame(scene, map[ports.WindowID]ports.SurfaceContent{1: c})
250+ fb, _, _ := o.scanoutFrame(scene, map[ports.WindowID]ports.SurfaceContent{1: c})
251251 if o.reason != want || (fb != 0) != (want == "") {
252252 t.Fatalf("fb %d reason %q, want %q", fb, o.reason, want)
253253 }
@@ -384,6 +384,29 @@ func TestFrameDecisionAllocations(t *testing.T) {
384384 if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 {
385385 t.Fatalf("composition decision: %.1f allocs", n)
386386 }
387+
388+ // Overlay active: the scene composed without the overlaid window reuses
389+ // the output's window slice.
390+ t.Run("overlay", func(t *testing.T) {
391+ o, _, _ := overlayOutput(t)
392+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
393+ s, surfaces := overlayScene()
394+ o.clientFBs[9] = &clientFB{fbID: 88}
395+ d := o.decideFrame(s, surfaces, false)
396+ if d.fb != 0 || d.overlay.fb != 88 || o.overlayReason != "" || len(d.composed.Windows) != 1 || d.composed.Windows[0].ID != 1 {
397+ t.Fatalf("overlay decision: %+v reason %q", d, o.overlayReason)
398+ }
399+ if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 {
400+ t.Fatalf("overlay decision: %.1f allocs", n)
401+ }
402+ // The slice is reused: the previous scene's Windows is overwritten
403+ // by the next call, and the input scene is never modified.
404+ first := d.composed.Windows
405+ d2 := o.decideFrame(s, surfaces, false)
406+ if &first[0] != &d2.composed.Windows[0] || len(s.Windows) != 2 || s.Windows[0].ID != 1 || s.Windows[1].ID != 2 {
407+ t.Fatal("rest windows not reused or the input scene changed")
408+ }
409+ })
387410 }
388411
389412 func TestFrameDecisionOverlayFallback(t *testing.T) {
@@ -247,7 +247,7 @@ func TestHDRScanoutDecision(t *testing.T) {
247 c := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 11, Format: fourccXR30}}247 c := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 11, Format: fourccXR30}}
248 check := func(want string) {248 check := func(want string) {
249 t.Helper()249 t.Helper()
250- fb, _ := o.scanoutFrame(scene, map[ports.WindowID]ports.SurfaceContent{1: c})250+ fb, _, _ := o.scanoutFrame(scene, map[ports.WindowID]ports.SurfaceContent{1: c})
251 if o.reason != want || (fb != 0) != (want == "") {251 if o.reason != want || (fb != 0) != (want == "") {
252 t.Fatalf("fb %d reason %q, want %q", fb, o.reason, want)252 t.Fatalf("fb %d reason %q, want %q", fb, o.reason, want)
253 }253 }
@@ -384,6 +384,29 @@ func TestFrameDecisionAllocations(t *testing.T) {
384 if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 {384 if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 {
385 t.Fatalf("composition decision: %.1f allocs", n)385 t.Fatalf("composition decision: %.1f allocs", n)
386 }386 }
387+
388+ // Overlay active: the scene composed without the overlaid window reuses
389+ // the output's window slice.
390+ t.Run("overlay", func(t *testing.T) {
391+ o, _, _ := overlayOutput(t)
392+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
393+ s, surfaces := overlayScene()
394+ o.clientFBs[9] = &clientFB{fbID: 88}
395+ d := o.decideFrame(s, surfaces, false)
396+ if d.fb != 0 || d.overlay.fb != 88 || o.overlayReason != "" || len(d.composed.Windows) != 1 || d.composed.Windows[0].ID != 1 {
397+ t.Fatalf("overlay decision: %+v reason %q", d, o.overlayReason)
398+ }
399+ if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 {
400+ t.Fatalf("overlay decision: %.1f allocs", n)
401+ }
402+ // The slice is reused: the previous scene's Windows is overwritten
403+ // by the next call, and the input scene is never modified.
404+ first := d.composed.Windows
405+ d2 := o.decideFrame(s, surfaces, false)
406+ if &first[0] != &d2.composed.Windows[0] || len(s.Windows) != 2 || s.Windows[0].ID != 1 || s.Windows[1].ID != 2 {
407+ t.Fatal("rest windows not reused or the input scene changed")
408+ }
409+ })
387 }410 }
388 411
389 func TestFrameDecisionOverlayFallback(t *testing.T) {412 func TestFrameDecisionOverlayFallback(t *testing.T) {
A internal/adapters/drm/seat_disable.go +61 -0
@@ -0,0 +1,61 @@
1+package drm
2+
3+import "time"
4+
5+// A seat disable revokes this process as DRM master. The planes keep what
6+// was last committed, and a client without DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE
7+// cannot clear COLOR_PIPELINE (amdgpu applies it regardless): the next master
8+// would inherit the pipeline. So before the disable is acked, every output
9+// that applies one commits Bypass. (A crash cannot do that: see docs/config.md.)
10+
11+// PrepareSeatDisable asks the output's goroutine to leave its planes on
12+// Bypass and waits for it, at most timeout. It reports false on timeout (the
13+// output is busy or not running): the caller goes on. It is safe to call
14+// from any goroutine.
15+func (o *Output) PrepareSeatDisable(timeout time.Duration) bool {
16+ timer := time.NewTimer(timeout)
17+ defer timer.Stop()
18+ ack := make(chan struct{}, 1) // buffered: Run never waits for us
19+ select {
20+ case o.seatDisable <- ack:
21+ case <-timer.C:
22+ return false
23+ }
24+ select {
25+ case <-ack:
26+ return true
27+ case <-timer.C:
28+ return false
29+ }
30+}
31+
32+// pipelineApplied reports whether a plane applies the colour pipeline.
33+func (o *Output) pipelineApplied() bool {
34+ for _, p := range [...]*plane{o.primary, o.overlay} {
35+ if p.hasColor() && p.colorKnown && p.colorApplied != colorBypass {
36+ return true
37+ }
38+ }
39+ return false
40+}
41+
42+// bypassForSeatDisable commits Bypass on the planes when one applies the
43+// pipeline, synchronously. It is a no-op (no commit) otherwise. A failure is
44+// only logged: the seat is disabled anyway. The commit blocks, and the kernel
45+// may make it wait for a flip in flight: a Bypass that finishes after
46+// PrepareSeatDisable's bound is lost, since the seat is disabled meanwhile.
47+// The caller logs the timeout.
48+func (o *Output) bypassForSeatDisable() {
49+ if !o.pipelineApplied() {
50+ return
51+ }
52+ req := &o.stateReq
53+ req.reset()
54+ o.forceBypass(req)
55+ if err := o.k.commit(req, 0, 0); err != nil {
56+ o.log.Warn().Str("component", "drm").Err(err).Str("connector", o.conn.name).Msg("colour pipeline not reset before seat disable")
57+ return
58+ }
59+ o.colorBypassed()
60+ o.log.Info().Str("component", "drm").Str("connector", o.conn.name).Msg("colour pipeline reset before seat disable")
61+}
@@ -0,0 +1,61 @@
1+package drm
2+
3+import "time"
4+
5+// A seat disable revokes this process as DRM master. The planes keep what
6+// was last committed, and a client without DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE
7+// cannot clear COLOR_PIPELINE (amdgpu applies it regardless): the next master
8+// would inherit the pipeline. So before the disable is acked, every output
9+// that applies one commits Bypass. (A crash cannot do that: see docs/config.md.)
10+
11+// PrepareSeatDisable asks the output's goroutine to leave its planes on
12+// Bypass and waits for it, at most timeout. It reports false on timeout (the
13+// output is busy or not running): the caller goes on. It is safe to call
14+// from any goroutine.
15+func (o *Output) PrepareSeatDisable(timeout time.Duration) bool {
16+ timer := time.NewTimer(timeout)
17+ defer timer.Stop()
18+ ack := make(chan struct{}, 1) // buffered: Run never waits for us
19+ select {
20+ case o.seatDisable <- ack:
21+ case <-timer.C:
22+ return false
23+ }
24+ select {
25+ case <-ack:
26+ return true
27+ case <-timer.C:
28+ return false
29+ }
30+}
31+
32+// pipelineApplied reports whether a plane applies the colour pipeline.
33+func (o *Output) pipelineApplied() bool {
34+ for _, p := range [...]*plane{o.primary, o.overlay} {
35+ if p.hasColor() && p.colorKnown && p.colorApplied != colorBypass {
36+ return true
37+ }
38+ }
39+ return false
40+}
41+
42+// bypassForSeatDisable commits Bypass on the planes when one applies the
43+// pipeline, synchronously. It is a no-op (no commit) otherwise. A failure is
44+// only logged: the seat is disabled anyway. The commit blocks, and the kernel
45+// may make it wait for a flip in flight: a Bypass that finishes after
46+// PrepareSeatDisable's bound is lost, since the seat is disabled meanwhile.
47+// The caller logs the timeout.
48+func (o *Output) bypassForSeatDisable() {
49+ if !o.pipelineApplied() {
50+ return
51+ }
52+ req := &o.stateReq
53+ req.reset()
54+ o.forceBypass(req)
55+ if err := o.k.commit(req, 0, 0); err != nil {
56+ o.log.Warn().Str("component", "drm").Err(err).Str("connector", o.conn.name).Msg("colour pipeline not reset before seat disable")
57+ return
58+ }
59+ o.colorBypassed()
60+ o.log.Info().Str("component", "drm").Str("connector", o.conn.name).Msg("colour pipeline reset before seat disable")
61+}
A internal/adapters/drm/seat_disable_test.go +116 -0
@@ -0,0 +1,116 @@
1+package drm
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/mock"
11+)
12+
13+func applyPipelines(o *Output) {
14+ for _, p := range []*plane{o.primary, o.overlay} {
15+ p.colorKnown, p.colorApplied = true, colorSDRToPQ
16+ }
17+}
18+
19+// Before the seat is disabled a plane that applies the pipeline is put back
20+// on Bypass, in a blocking commit of the planes alone.
21+func TestSeatDisableBypassesAppliedPipeline(t *testing.T) {
22+ o, _, commits := colorOutput(t, true, nil)
23+ applyPipelines(o)
24+ o.bypassForSeatDisable()
25+ if len(*commits) != 1 {
26+ t.Fatalf("%d commits", len(*commits))
27+ }
28+ c := (*commits)[0]
29+ if c.flags != 0 || c.user != 0 {
30+ t.Fatalf("not a blocking commit: flags %#x user %d", c.flags, c.user)
31+ }
32+ for _, p := range []struct{ plane, prop uint32 }{{tPrimary, pColorPipe}, {tOverlay, pColorPipeOverlay}} {
33+ if v, ok := c.req.value(p.plane, p.prop); !ok || v != 0 {
34+ t.Errorf("plane %d COLOR_PIPELINE %d %v, want Bypass", p.plane, v, ok)
35+ }
36+ }
37+ if len(c.req.objs) != 2 || o.pipelineApplied() {
38+ t.Fatalf("objects %v, applied %v", c.req.objs, o.pipelineApplied())
39+ }
40+ // Done: a second request has nothing to do.
41+ o.bypassForSeatDisable()
42+ if len(*commits) != 1 {
43+ t.Fatal("commit with Bypass applied")
44+ }
45+}
46+
47+// With nothing applied (or nothing known) no commit is made.
48+func TestSeatDisableWithoutPipelineDoesNotCommit(t *testing.T) {
49+ for name, setup := range map[string]func(*Output){
50+ "bypass": func(o *Output) { o.colorBypassed() },
51+ "unknown": func(o *Output) { o.forgetColor() },
52+ "no pipeline plane": func(o *Output) {
53+ o.primary.props = maps0(planeProps)
54+ o.overlay.props = maps0(planeProps)
55+ },
56+ } {
57+ t.Run(name, func(t *testing.T) {
58+ o, _, commits := colorOutput(t, true, nil)
59+ setup(o)
60+ o.bypassForSeatDisable()
61+ if len(*commits) != 0 {
62+ t.Fatalf("%d commits", len(*commits))
63+ }
64+ })
65+ }
66+}
67+
68+// Run answers the request: the commit is made by the output's goroutine and
69+// the caller is acked after it. The output stops drawing until the next enable.
70+func TestRunPreparesSeatDisable(t *testing.T) {
71+ o, k, commits, mu := testOutputMu(t)
72+ o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
73+ props := maps0(planeProps)
74+ props["COLOR_PIPELINE"] = pColorPipe
75+ o.primary.props = props
76+ o.primary.pipeline = &colorPipeline{}
77+ o.primary.colorKnown, o.primary.colorApplied = true, colorSDRToPQ
78+ r := portsmocks.NewMockRenderer(t)
79+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
80+ r.EXPECT().Close().Return().Once()
81+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
82+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
83+ active := make(chan bool, 1)
84+ active <- false // switched away: no modeset resets the state
85+ ctx, cancel := context.WithCancel(context.Background())
86+ done := make(chan error, 1)
87+ go func() {
88+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1), nil, nil)
89+ }()
90+ if !o.PrepareSeatDisable(5 * time.Second) {
91+ t.Fatal("no ack")
92+ }
93+ mu.Lock()
94+ n := len(*commits)
95+ var v uint64
96+ var ok bool
97+ if n > 0 {
98+ v, ok = (*commits)[0].req.value(tPrimary, pColorPipe)
99+ }
100+ mu.Unlock()
101+ if n != 1 || !ok || v != 0 {
102+ t.Fatalf("commits %d, COLOR_PIPELINE %d %v", n, v, ok)
103+ }
104+ cancel()
105+ if err := <-done; err != nil {
106+ t.Fatal(err)
107+ }
108+}
109+
110+// An output that is not running cannot answer: the caller is not held.
111+func TestPrepareSeatDisableTimesOut(t *testing.T) {
112+ o, _, _ := testOutput(t)
113+ if o.PrepareSeatDisable(5 * time.Millisecond) {
114+ t.Fatal("acked without a running output")
115+ }
116+}
@@ -0,0 +1,116 @@
1+package drm
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/mock"
11+)
12+
13+func applyPipelines(o *Output) {
14+ for _, p := range []*plane{o.primary, o.overlay} {
15+ p.colorKnown, p.colorApplied = true, colorSDRToPQ
16+ }
17+}
18+
19+// Before the seat is disabled a plane that applies the pipeline is put back
20+// on Bypass, in a blocking commit of the planes alone.
21+func TestSeatDisableBypassesAppliedPipeline(t *testing.T) {
22+ o, _, commits := colorOutput(t, true, nil)
23+ applyPipelines(o)
24+ o.bypassForSeatDisable()
25+ if len(*commits) != 1 {
26+ t.Fatalf("%d commits", len(*commits))
27+ }
28+ c := (*commits)[0]
29+ if c.flags != 0 || c.user != 0 {
30+ t.Fatalf("not a blocking commit: flags %#x user %d", c.flags, c.user)
31+ }
32+ for _, p := range []struct{ plane, prop uint32 }{{tPrimary, pColorPipe}, {tOverlay, pColorPipeOverlay}} {
33+ if v, ok := c.req.value(p.plane, p.prop); !ok || v != 0 {
34+ t.Errorf("plane %d COLOR_PIPELINE %d %v, want Bypass", p.plane, v, ok)
35+ }
36+ }
37+ if len(c.req.objs) != 2 || o.pipelineApplied() {
38+ t.Fatalf("objects %v, applied %v", c.req.objs, o.pipelineApplied())
39+ }
40+ // Done: a second request has nothing to do.
41+ o.bypassForSeatDisable()
42+ if len(*commits) != 1 {
43+ t.Fatal("commit with Bypass applied")
44+ }
45+}
46+
47+// With nothing applied (or nothing known) no commit is made.
48+func TestSeatDisableWithoutPipelineDoesNotCommit(t *testing.T) {
49+ for name, setup := range map[string]func(*Output){
50+ "bypass": func(o *Output) { o.colorBypassed() },
51+ "unknown": func(o *Output) { o.forgetColor() },
52+ "no pipeline plane": func(o *Output) {
53+ o.primary.props = maps0(planeProps)
54+ o.overlay.props = maps0(planeProps)
55+ },
56+ } {
57+ t.Run(name, func(t *testing.T) {
58+ o, _, commits := colorOutput(t, true, nil)
59+ setup(o)
60+ o.bypassForSeatDisable()
61+ if len(*commits) != 0 {
62+ t.Fatalf("%d commits", len(*commits))
63+ }
64+ })
65+ }
66+}
67+
68+// Run answers the request: the commit is made by the output's goroutine and
69+// the caller is acked after it. The output stops drawing until the next enable.
70+func TestRunPreparesSeatDisable(t *testing.T) {
71+ o, k, commits, mu := testOutputMu(t)
72+ o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
73+ props := maps0(planeProps)
74+ props["COLOR_PIPELINE"] = pColorPipe
75+ o.primary.props = props
76+ o.primary.pipeline = &colorPipeline{}
77+ o.primary.colorKnown, o.primary.colorApplied = true, colorSDRToPQ
78+ r := portsmocks.NewMockRenderer(t)
79+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
80+ r.EXPECT().Close().Return().Once()
81+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
82+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
83+ active := make(chan bool, 1)
84+ active <- false // switched away: no modeset resets the state
85+ ctx, cancel := context.WithCancel(context.Background())
86+ done := make(chan error, 1)
87+ go func() {
88+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1), nil, nil)
89+ }()
90+ if !o.PrepareSeatDisable(5 * time.Second) {
91+ t.Fatal("no ack")
92+ }
93+ mu.Lock()
94+ n := len(*commits)
95+ var v uint64
96+ var ok bool
97+ if n > 0 {
98+ v, ok = (*commits)[0].req.value(tPrimary, pColorPipe)
99+ }
100+ mu.Unlock()
101+ if n != 1 || !ok || v != 0 {
102+ t.Fatalf("commits %d, COLOR_PIPELINE %d %v", n, v, ok)
103+ }
104+ cancel()
105+ if err := <-done; err != nil {
106+ t.Fatal(err)
107+ }
108+}
109+
110+// An output that is not running cannot answer: the caller is not held.
111+func TestPrepareSeatDisableTimesOut(t *testing.T) {
112+ o, _, _ := testOutput(t)
113+ if o.PrepareSeatDisable(5 * time.Millisecond) {
114+ t.Fatal("acked without a running output")
115+ }
116+}
A internal/adapters/seat/disabler_mock_test.go +78 -0
@@ -0,0 +1,78 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package seat
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockdisabler creates a new instance of mockdisabler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockdisabler(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockdisabler {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockdisabler{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockdisabler is an autogenerated mock type for the disabler type
35+type mockdisabler struct {
36+ mock.Mock
37+}
38+
39+type mockdisabler_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockdisabler) EXPECT() *mockdisabler_Expecter {
44+ return &mockdisabler_Expecter{mock: &_m.Mock}
45+}
46+
47+// disable provides a mock function for the type mockdisabler
48+func (_mock *mockdisabler) disable() {
49+ _mock.Called()
50+ return
51+}
52+
53+// mockdisabler_disable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'disable'
54+type mockdisabler_disable_Call struct {
55+ *mock.Call
56+}
57+
58+// disable is a helper method to define mock.On call
59+func (_e *mockdisabler_Expecter) disable() *mockdisabler_disable_Call {
60+ return &mockdisabler_disable_Call{Call: _e.mock.On("disable")}
61+}
62+
63+func (_c *mockdisabler_disable_Call) Run(run func()) *mockdisabler_disable_Call {
64+ _c.Call.Run(func(args mock.Arguments) {
65+ run()
66+ })
67+ return _c
68+}
69+
70+func (_c *mockdisabler_disable_Call) Return() *mockdisabler_disable_Call {
71+ _c.Call.Return()
72+ return _c
73+}
74+
75+func (_c *mockdisabler_disable_Call) RunAndReturn(run func()) *mockdisabler_disable_Call {
76+ _c.Run(run)
77+ return _c
78+}
@@ -0,0 +1,78 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package seat
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockdisabler creates a new instance of mockdisabler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockdisabler(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockdisabler {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockdisabler{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockdisabler is an autogenerated mock type for the disabler type
35+type mockdisabler struct {
36+ mock.Mock
37+}
38+
39+type mockdisabler_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockdisabler) EXPECT() *mockdisabler_Expecter {
44+ return &mockdisabler_Expecter{mock: &_m.Mock}
45+}
46+
47+// disable provides a mock function for the type mockdisabler
48+func (_mock *mockdisabler) disable() {
49+ _mock.Called()
50+ return
51+}
52+
53+// mockdisabler_disable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'disable'
54+type mockdisabler_disable_Call struct {
55+ *mock.Call
56+}
57+
58+// disable is a helper method to define mock.On call
59+func (_e *mockdisabler_Expecter) disable() *mockdisabler_disable_Call {
60+ return &mockdisabler_disable_Call{Call: _e.mock.On("disable")}
61+}
62+
63+func (_c *mockdisabler_disable_Call) Run(run func()) *mockdisabler_disable_Call {
64+ _c.Call.Run(func(args mock.Arguments) {
65+ run()
66+ })
67+ return _c
68+}
69+
70+func (_c *mockdisabler_disable_Call) Return() *mockdisabler_disable_Call {
71+ _c.Call.Return()
72+ return _c
73+}
74+
75+func (_c *mockdisabler_disable_Call) RunAndReturn(run func()) *mockdisabler_disable_Call {
76+ _c.Run(run)
77+ return _c
78+}
M internal/adapters/seat/seat.go +28 -1
@@ -71,6 +71,27 @@ type Seat struct {
7171 devices map[int32]int32 // fd → libseat device id
7272 subs []chan bool
7373 log zerowrap.Logger
74+ // beforeDisable runs on a seat disable, before it is acked to the seat
75+ // manager: the last moment this process is still DRM master.
76+ beforeDisable func()
77+ disabler disabler
78+}
79+
80+// disabler acks a seat disable to the seat manager (libseat_disable_seat),
81+// which revokes the devices.
82+type disabler interface{ disable() }
83+
84+type libseatDisabler struct{ s *Seat }
85+
86+func (d libseatDisabler) disable() { disableSeat(d.s.handle) }
87+
88+// SetBeforeDisable registers f, run on every seat disable before it is
89+// acked. f runs inside libseat's dispatch, with the seat locked: it must be
90+// short and must not call the Seat. Call it before Run.
91+func (s *Seat) SetBeforeDisable(f func()) {
92+ s.mu.Lock()
93+ defer s.mu.Unlock()
94+ s.beforeDisable = f
7495 }
7596
7697 // Open connects to logind or seatd and waits for the first enable.
@@ -82,6 +103,7 @@ func Open(ctx context.Context, log zerowrap.Logger) (*Seat, error) {
82103 return nil, errors.New("seat already open")
83104 }
84105 s := &Seat{devices: make(map[int32]int32), log: log}
106+ s.disabler = libseatDisabler{s}
85107 current = s
86108 s.mu.Lock()
87109 defer s.mu.Unlock()
@@ -105,8 +127,13 @@ func (s *Seat) changed(active bool) {
105127 s.active = active
106128 s.log.Info().Bool("active", active).Msg("seat")
107129 if !active {
130+ // Once acked the devices are revoked and the next master inherits
131+ // the planes as they are: let the owner of the outputs reset them.
132+ if s.beforeDisable != nil {
133+ s.beforeDisable()
134+ }
108135 // Devices are revoked by the seat manager; ack so the VT can switch.
109- disableSeat(s.handle)
136+ s.disabler.disable()
110137 }
111138 for _, c := range s.subs {
112139 select {
@@ -71,6 +71,27 @@ type Seat struct {
71 devices map[int32]int32 // fd → libseat device id71 devices map[int32]int32 // fd → libseat device id
72 subs []chan bool72 subs []chan bool
73 log zerowrap.Logger73 log zerowrap.Logger
74+ // beforeDisable runs on a seat disable, before it is acked to the seat
75+ // manager: the last moment this process is still DRM master.
76+ beforeDisable func()
77+ disabler disabler
78+}
79+
80+// disabler acks a seat disable to the seat manager (libseat_disable_seat),
81+// which revokes the devices.
82+type disabler interface{ disable() }
83+
84+type libseatDisabler struct{ s *Seat }
85+
86+func (d libseatDisabler) disable() { disableSeat(d.s.handle) }
87+
88+// SetBeforeDisable registers f, run on every seat disable before it is
89+// acked. f runs inside libseat's dispatch, with the seat locked: it must be
90+// short and must not call the Seat. Call it before Run.
91+func (s *Seat) SetBeforeDisable(f func()) {
92+ s.mu.Lock()
93+ defer s.mu.Unlock()
94+ s.beforeDisable = f
74 }95 }
75 96
76 // Open connects to logind or seatd and waits for the first enable.97 // Open connects to logind or seatd and waits for the first enable.
@@ -82,6 +103,7 @@ func Open(ctx context.Context, log zerowrap.Logger) (*Seat, error) {
82 return nil, errors.New("seat already open")103 return nil, errors.New("seat already open")
83 }104 }
84 s := &Seat{devices: make(map[int32]int32), log: log}105 s := &Seat{devices: make(map[int32]int32), log: log}
106+ s.disabler = libseatDisabler{s}
85 current = s107 current = s
86 s.mu.Lock()108 s.mu.Lock()
87 defer s.mu.Unlock()109 defer s.mu.Unlock()
@@ -105,8 +127,13 @@ func (s *Seat) changed(active bool) {
105 s.active = active127 s.active = active
106 s.log.Info().Bool("active", active).Msg("seat")128 s.log.Info().Bool("active", active).Msg("seat")
107 if !active {129 if !active {
130+ // Once acked the devices are revoked and the next master inherits
131+ // the planes as they are: let the owner of the outputs reset them.
132+ if s.beforeDisable != nil {
133+ s.beforeDisable()
134+ }
108 // Devices are revoked by the seat manager; ack so the VT can switch.135 // Devices are revoked by the seat manager; ack so the VT can switch.
109- disableSeat(s.handle)136+ s.disabler.disable()
110 }137 }
111 for _, c := range s.subs {138 for _, c := range s.subs {
112 select {139 select {
A internal/adapters/seat/seat_test.go +43 -0
@@ -0,0 +1,43 @@
1+package seat
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zerowrap"
7+)
8+
9+// A seat disable lets the outputs reset the planes first, and only then
10+// acks the seat manager: after the ack the devices are revoked.
11+func TestDisableRunsBeforeTheAck(t *testing.T) {
12+ d := newMockdisabler(t)
13+ s := &Seat{log: zerowrap.Default(), disabler: d}
14+ var calls []string
15+ s.SetBeforeDisable(func() { calls = append(calls, "before") })
16+ d.EXPECT().disable().Run(func() { calls = append(calls, "ack") }).Return().Once()
17+ ch := s.Subscribe()
18+ <-ch // the initial state
19+ s.changed(false)
20+ if len(calls) != 2 || calls[0] != "before" || calls[1] != "ack" {
21+ t.Fatalf("order %v", calls)
22+ }
23+ if got := <-ch; got {
24+ t.Fatal("subscribers not told of the disable")
25+ }
26+}
27+
28+// An enable needs neither: nothing is revoked.
29+func TestEnableDoesNotDisable(t *testing.T) {
30+ s := &Seat{log: zerowrap.Default(), disabler: newMockdisabler(t)}
31+ s.SetBeforeDisable(func() { t.Fatal("prepared on enable") })
32+ s.changed(true)
33+ if !s.active {
34+ t.Fatal("not active")
35+ }
36+}
37+
38+// Without a registered callback the disable is acked as before.
39+func TestDisableWithoutCallback(t *testing.T) {
40+ d := newMockdisabler(t)
41+ d.EXPECT().disable().Return().Once()
42+ (&Seat{log: zerowrap.Default(), disabler: d}).changed(false)
43+}
@@ -0,0 +1,43 @@
1+package seat
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zerowrap"
7+)
8+
9+// A seat disable lets the outputs reset the planes first, and only then
10+// acks the seat manager: after the ack the devices are revoked.
11+func TestDisableRunsBeforeTheAck(t *testing.T) {
12+ d := newMockdisabler(t)
13+ s := &Seat{log: zerowrap.Default(), disabler: d}
14+ var calls []string
15+ s.SetBeforeDisable(func() { calls = append(calls, "before") })
16+ d.EXPECT().disable().Run(func() { calls = append(calls, "ack") }).Return().Once()
17+ ch := s.Subscribe()
18+ <-ch // the initial state
19+ s.changed(false)
20+ if len(calls) != 2 || calls[0] != "before" || calls[1] != "ack" {
21+ t.Fatalf("order %v", calls)
22+ }
23+ if got := <-ch; got {
24+ t.Fatal("subscribers not told of the disable")
25+ }
26+}
27+
28+// An enable needs neither: nothing is revoked.
29+func TestEnableDoesNotDisable(t *testing.T) {
30+ s := &Seat{log: zerowrap.Default(), disabler: newMockdisabler(t)}
31+ s.SetBeforeDisable(func() { t.Fatal("prepared on enable") })
32+ s.changed(true)
33+ if !s.active {
34+ t.Fatal("not active")
35+ }
36+}
37+
38+// Without a registered callback the disable is acked as before.
39+func TestDisableWithoutCallback(t *testing.T) {
40+ d := newMockdisabler(t)
41+ d.EXPECT().disable().Return().Once()
42+ (&Seat{log: zerowrap.Default(), disabler: d}).changed(false)
43+}
M internal/app/drm.go +61 -1
@@ -11,6 +11,7 @@ import (
1111 "slices"
1212 "sort"
1313 "sync"
14+ "sync/atomic"
1415 "time"
1516
1617 "github.com/bnema/neferwl/internal/adapters/clock"
@@ -115,6 +116,62 @@ type drmBackend struct {
115116 seat *seat.Seat
116117 cards []*drmCard
117118 clock ports.Clock
119+ log zerowrap.Logger
120+ // preparers are the running outputs, guarded by mu: the seat's goroutine
121+ // reads them while the output loop adds and removes them.
122+ mu sync.Mutex
123+ preparers map[seatDisablePreparer]struct{}
124+}
125+
126+// seatDisablePreparer is an output that can reset its planes before the
127+// seat is disabled (drm.Output).
128+type seatDisablePreparer interface {
129+ PrepareSeatDisable(timeout time.Duration) bool
130+}
131+
132+// seatDisableTimeout bounds how long a seat disable waits for the outputs:
133+// the VT switch must not hang on a stuck one.
134+const seatDisableTimeout = 100 * time.Millisecond
135+
136+func (b *drmBackend) addPreparer(p seatDisablePreparer) {
137+ b.mu.Lock()
138+ defer b.mu.Unlock()
139+ if b.preparers == nil {
140+ b.preparers = map[seatDisablePreparer]struct{}{}
141+ }
142+ b.preparers[p] = struct{}{}
143+}
144+
145+func (b *drmBackend) removePreparer(p seatDisablePreparer) {
146+ b.mu.Lock()
147+ defer b.mu.Unlock()
148+ delete(b.preparers, p)
149+}
150+
151+// prepareSeatDisable runs on the seat's goroutine before the seat is
152+// disabled and its devices revoked: every running output commits what it
153+// must leave behind (planes on Bypass), all in parallel under one bound. A
154+// timeout is logged and the disable goes on.
155+func (b *drmBackend) prepareSeatDisable() {
156+ b.mu.Lock()
157+ outs := make([]seatDisablePreparer, 0, len(b.preparers))
158+ for p := range b.preparers {
159+ outs = append(outs, p)
160+ }
161+ b.mu.Unlock()
162+ var wg sync.WaitGroup
163+ var late atomic.Int32
164+ for _, p := range outs {
165+ wg.Go(func() {
166+ if !p.PrepareSeatDisable(seatDisableTimeout) {
167+ late.Add(1)
168+ }
169+ })
170+ }
171+ wg.Wait()
172+ if n := late.Load(); n > 0 {
173+ b.log.Warn().Str("component", "drm").Int("outputs", int(n)).Dur("timeout", seatDisableTimeout).Msg("outputs did not prepare for the seat disable in time; disabling anyway")
174+ }
118175 }
119176
120177 type drmCard struct {
@@ -165,7 +222,8 @@ func openDRM(ctx context.Context, cfg ports.Config) (*drmBackend, error) {
165222 want.TraceFlips = logging.Enabled(ctx, "drm-flip")
166223 paths, _ := filepath.Glob("/dev/dri/card[0-9]*")
167224 sort.Strings(paths)
168- b := &drmBackend{seat: s, clock: clock.System{}}
225+ b := &drmBackend{seat: s, clock: clock.System{}, log: log}
226+ s.SetBeforeDisable(b.prepareSeatDisable)
169227 var errs []error
170228 for _, path := range paths {
171229 fd, err := s.OpenDevice(path)
@@ -483,6 +541,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
483541 o.Security, o.SecurityChanges, o.Instance = ch.security, secure, instance
484542 return safe("output "+name, func() error {
485543 defer o.Close()
544+ b.addPreparer(o)
545+ defer b.removePreparer(o) // before Close: it handles no request any more
486546 if realtime {
487547 runtime.LockOSThread()
488548 // Keep the thread locked until this goroutine exits: an RT
@@ -11,6 +11,7 @@ import (
11 "slices"11 "slices"
12 "sort"12 "sort"
13 "sync"13 "sync"
14+ "sync/atomic"
14 "time"15 "time"
15 16
16 "github.com/bnema/neferwl/internal/adapters/clock"17 "github.com/bnema/neferwl/internal/adapters/clock"
@@ -115,6 +116,62 @@ type drmBackend struct {
115 seat *seat.Seat116 seat *seat.Seat
116 cards []*drmCard117 cards []*drmCard
117 clock ports.Clock118 clock ports.Clock
119+ log zerowrap.Logger
120+ // preparers are the running outputs, guarded by mu: the seat's goroutine
121+ // reads them while the output loop adds and removes them.
122+ mu sync.Mutex
123+ preparers map[seatDisablePreparer]struct{}
124+}
125+
126+// seatDisablePreparer is an output that can reset its planes before the
127+// seat is disabled (drm.Output).
128+type seatDisablePreparer interface {
129+ PrepareSeatDisable(timeout time.Duration) bool
130+}
131+
132+// seatDisableTimeout bounds how long a seat disable waits for the outputs:
133+// the VT switch must not hang on a stuck one.
134+const seatDisableTimeout = 100 * time.Millisecond
135+
136+func (b *drmBackend) addPreparer(p seatDisablePreparer) {
137+ b.mu.Lock()
138+ defer b.mu.Unlock()
139+ if b.preparers == nil {
140+ b.preparers = map[seatDisablePreparer]struct{}{}
141+ }
142+ b.preparers[p] = struct{}{}
143+}
144+
145+func (b *drmBackend) removePreparer(p seatDisablePreparer) {
146+ b.mu.Lock()
147+ defer b.mu.Unlock()
148+ delete(b.preparers, p)
149+}
150+
151+// prepareSeatDisable runs on the seat's goroutine before the seat is
152+// disabled and its devices revoked: every running output commits what it
153+// must leave behind (planes on Bypass), all in parallel under one bound. A
154+// timeout is logged and the disable goes on.
155+func (b *drmBackend) prepareSeatDisable() {
156+ b.mu.Lock()
157+ outs := make([]seatDisablePreparer, 0, len(b.preparers))
158+ for p := range b.preparers {
159+ outs = append(outs, p)
160+ }
161+ b.mu.Unlock()
162+ var wg sync.WaitGroup
163+ var late atomic.Int32
164+ for _, p := range outs {
165+ wg.Go(func() {
166+ if !p.PrepareSeatDisable(seatDisableTimeout) {
167+ late.Add(1)
168+ }
169+ })
170+ }
171+ wg.Wait()
172+ if n := late.Load(); n > 0 {
173+ b.log.Warn().Str("component", "drm").Int("outputs", int(n)).Dur("timeout", seatDisableTimeout).Msg("outputs did not prepare for the seat disable in time; disabling anyway")
174+ }
118 }175 }
119 176
120 type drmCard struct {177 type drmCard struct {
@@ -165,7 +222,8 @@ func openDRM(ctx context.Context, cfg ports.Config) (*drmBackend, error) {
165 want.TraceFlips = logging.Enabled(ctx, "drm-flip")222 want.TraceFlips = logging.Enabled(ctx, "drm-flip")
166 paths, _ := filepath.Glob("/dev/dri/card[0-9]*")223 paths, _ := filepath.Glob("/dev/dri/card[0-9]*")
167 sort.Strings(paths)224 sort.Strings(paths)
168- b := &drmBackend{seat: s, clock: clock.System{}}225+ b := &drmBackend{seat: s, clock: clock.System{}, log: log}
226+ s.SetBeforeDisable(b.prepareSeatDisable)
169 var errs []error227 var errs []error
170 for _, path := range paths {228 for _, path := range paths {
171 fd, err := s.OpenDevice(path)229 fd, err := s.OpenDevice(path)
@@ -483,6 +541,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm
483 o.Security, o.SecurityChanges, o.Instance = ch.security, secure, instance541 o.Security, o.SecurityChanges, o.Instance = ch.security, secure, instance
484 return safe("output "+name, func() error {542 return safe("output "+name, func() error {
485 defer o.Close()543 defer o.Close()
544+ b.addPreparer(o)
545+ defer b.removePreparer(o) // before Close: it handles no request any more
486 if realtime {546 if realtime {
487 runtime.LockOSThread()547 runtime.LockOSThread()
488 // Keep the thread locked until this goroutine exits: an RT548 // Keep the thread locked until this goroutine exits: an RT
A internal/app/seatdisable_mock_test.go +98 -0
@@ -0,0 +1,98 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package app
6+
7+import (
8+ "time"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMockseatDisablePreparer creates a new instance of mockseatDisablePreparer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMockseatDisablePreparer(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mockseatDisablePreparer {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mockseatDisablePreparer{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mockseatDisablePreparer is an autogenerated mock type for the seatDisablePreparer type
37+type mockseatDisablePreparer struct {
38+ mock.Mock
39+}
40+
41+type mockseatDisablePreparer_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mockseatDisablePreparer) EXPECT() *mockseatDisablePreparer_Expecter {
46+ return &mockseatDisablePreparer_Expecter{mock: &_m.Mock}
47+}
48+
49+// PrepareSeatDisable provides a mock function for the type mockseatDisablePreparer
50+func (_mock *mockseatDisablePreparer) PrepareSeatDisable(timeout time.Duration) bool {
51+ ret := _mock.Called(timeout)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for PrepareSeatDisable")
55+ }
56+
57+ var r0 bool
58+ if returnFunc, ok := ret.Get(0).(func(time.Duration) bool); ok {
59+ r0 = returnFunc(timeout)
60+ } else {
61+ r0 = ret.Get(0).(bool)
62+ }
63+ return r0
64+}
65+
66+// mockseatDisablePreparer_PrepareSeatDisable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrepareSeatDisable'
67+type mockseatDisablePreparer_PrepareSeatDisable_Call struct {
68+ *mock.Call
69+}
70+
71+// PrepareSeatDisable is a helper method to define mock.On call
72+// - timeout time.Duration
73+func (_e *mockseatDisablePreparer_Expecter) PrepareSeatDisable(timeout any) *mockseatDisablePreparer_PrepareSeatDisable_Call {
74+ return &mockseatDisablePreparer_PrepareSeatDisable_Call{Call: _e.mock.On("PrepareSeatDisable", timeout)}
75+}
76+
77+func (_c *mockseatDisablePreparer_PrepareSeatDisable_Call) Run(run func(timeout time.Duration)) *mockseatDisablePreparer_PrepareSeatDisable_Call {
78+ _c.Call.Run(func(args mock.Arguments) {
79+ var arg0 time.Duration
80+ if args[0] != nil {
81+ arg0 = args[0].(time.Duration)
82+ }
83+ run(
84+ arg0,
85+ )
86+ })
87+ return _c
88+}
89+
90+func (_c *mockseatDisablePreparer_PrepareSeatDisable_Call) Return(b bool) *mockseatDisablePreparer_PrepareSeatDisable_Call {
91+ _c.Call.Return(b)
92+ return _c
93+}
94+
95+func (_c *mockseatDisablePreparer_PrepareSeatDisable_Call) RunAndReturn(run func(timeout time.Duration) bool) *mockseatDisablePreparer_PrepareSeatDisable_Call {
96+ _c.Call.Return(run)
97+ return _c
98+}
@@ -0,0 +1,98 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package app
6+
7+import (
8+ "time"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMockseatDisablePreparer creates a new instance of mockseatDisablePreparer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMockseatDisablePreparer(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mockseatDisablePreparer {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mockseatDisablePreparer{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mockseatDisablePreparer is an autogenerated mock type for the seatDisablePreparer type
37+type mockseatDisablePreparer struct {
38+ mock.Mock
39+}
40+
41+type mockseatDisablePreparer_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mockseatDisablePreparer) EXPECT() *mockseatDisablePreparer_Expecter {
46+ return &mockseatDisablePreparer_Expecter{mock: &_m.Mock}
47+}
48+
49+// PrepareSeatDisable provides a mock function for the type mockseatDisablePreparer
50+func (_mock *mockseatDisablePreparer) PrepareSeatDisable(timeout time.Duration) bool {
51+ ret := _mock.Called(timeout)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for PrepareSeatDisable")
55+ }
56+
57+ var r0 bool
58+ if returnFunc, ok := ret.Get(0).(func(time.Duration) bool); ok {
59+ r0 = returnFunc(timeout)
60+ } else {
61+ r0 = ret.Get(0).(bool)
62+ }
63+ return r0
64+}
65+
66+// mockseatDisablePreparer_PrepareSeatDisable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrepareSeatDisable'
67+type mockseatDisablePreparer_PrepareSeatDisable_Call struct {
68+ *mock.Call
69+}
70+
71+// PrepareSeatDisable is a helper method to define mock.On call
72+// - timeout time.Duration
73+func (_e *mockseatDisablePreparer_Expecter) PrepareSeatDisable(timeout any) *mockseatDisablePreparer_PrepareSeatDisable_Call {
74+ return &mockseatDisablePreparer_PrepareSeatDisable_Call{Call: _e.mock.On("PrepareSeatDisable", timeout)}
75+}
76+
77+func (_c *mockseatDisablePreparer_PrepareSeatDisable_Call) Run(run func(timeout time.Duration)) *mockseatDisablePreparer_PrepareSeatDisable_Call {
78+ _c.Call.Run(func(args mock.Arguments) {
79+ var arg0 time.Duration
80+ if args[0] != nil {
81+ arg0 = args[0].(time.Duration)
82+ }
83+ run(
84+ arg0,
85+ )
86+ })
87+ return _c
88+}
89+
90+func (_c *mockseatDisablePreparer_PrepareSeatDisable_Call) Return(b bool) *mockseatDisablePreparer_PrepareSeatDisable_Call {
91+ _c.Call.Return(b)
92+ return _c
93+}
94+
95+func (_c *mockseatDisablePreparer_PrepareSeatDisable_Call) RunAndReturn(run func(timeout time.Duration) bool) *mockseatDisablePreparer_PrepareSeatDisable_Call {
96+ _c.Call.Return(run)
97+ return _c
98+}
A internal/app/seatdisable_test.go +58 -0
@@ -0,0 +1,58 @@
1+package app
2+
3+import (
4+ "strings"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/mock"
11+)
12+
13+// Every running output is asked, in parallel, with the bound; an output
14+// that has stopped is not.
15+func TestPrepareSeatDisableAsksRunningOutputs(t *testing.T) {
16+ b := &drmBackend{log: zerowrap.Default()}
17+ a, c, gone := newMockseatDisablePreparer(t), newMockseatDisablePreparer(t), newMockseatDisablePreparer(t)
18+ var asked atomic.Int32
19+ for _, p := range []*mockseatDisablePreparer{a, c} {
20+ p.EXPECT().PrepareSeatDisable(seatDisableTimeout).RunAndReturn(func(time.Duration) bool { asked.Add(1); return true }).Once()
21+ }
22+ b.addPreparer(a)
23+ b.addPreparer(c)
24+ b.addPreparer(gone)
25+ b.removePreparer(gone)
26+ b.prepareSeatDisable()
27+ if asked.Load() != 2 {
28+ t.Fatalf("%d outputs asked", asked.Load())
29+ }
30+}
31+
32+// A late output is reported once and the disable goes on; outputs that
33+// answer in time, or none at all, log nothing.
34+func TestPrepareSeatDisableWarnsOnlyWhenLate(t *testing.T) {
35+ for _, tc := range []struct {
36+ name string
37+ acks []bool
38+ warns bool
39+ }{
40+ {"no outputs", nil, false},
41+ {"in time", []bool{true, true}, false},
42+ {"one late", []bool{true, false}, true},
43+ } {
44+ t.Run(tc.name, func(t *testing.T) {
45+ log := &lockedBuffer{}
46+ b := &drmBackend{log: zerowrap.New(zerowrap.Config{Format: "json", Output: log})}
47+ for _, ack := range tc.acks {
48+ p := newMockseatDisablePreparer(t)
49+ p.EXPECT().PrepareSeatDisable(mock.Anything).Return(ack).Once()
50+ b.addPreparer(p)
51+ }
52+ b.prepareSeatDisable()
53+ if got := strings.Contains(log.String(), `"outputs":1`); got != tc.warns {
54+ t.Fatalf("late warning %v, want %v: %s", got, tc.warns, log.String())
55+ }
56+ })
57+ }
58+}
@@ -0,0 +1,58 @@
1+package app
2+
3+import (
4+ "strings"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/mock"
11+)
12+
13+// Every running output is asked, in parallel, with the bound; an output
14+// that has stopped is not.
15+func TestPrepareSeatDisableAsksRunningOutputs(t *testing.T) {
16+ b := &drmBackend{log: zerowrap.Default()}
17+ a, c, gone := newMockseatDisablePreparer(t), newMockseatDisablePreparer(t), newMockseatDisablePreparer(t)
18+ var asked atomic.Int32
19+ for _, p := range []*mockseatDisablePreparer{a, c} {
20+ p.EXPECT().PrepareSeatDisable(seatDisableTimeout).RunAndReturn(func(time.Duration) bool { asked.Add(1); return true }).Once()
21+ }
22+ b.addPreparer(a)
23+ b.addPreparer(c)
24+ b.addPreparer(gone)
25+ b.removePreparer(gone)
26+ b.prepareSeatDisable()
27+ if asked.Load() != 2 {
28+ t.Fatalf("%d outputs asked", asked.Load())
29+ }
30+}
31+
32+// A late output is reported once and the disable goes on; outputs that
33+// answer in time, or none at all, log nothing.
34+func TestPrepareSeatDisableWarnsOnlyWhenLate(t *testing.T) {
35+ for _, tc := range []struct {
36+ name string
37+ acks []bool
38+ warns bool
39+ }{
40+ {"no outputs", nil, false},
41+ {"in time", []bool{true, true}, false},
42+ {"one late", []bool{true, false}, true},
43+ } {
44+ t.Run(tc.name, func(t *testing.T) {
45+ log := &lockedBuffer{}
46+ b := &drmBackend{log: zerowrap.New(zerowrap.Config{Format: "json", Output: log})}
47+ for _, ack := range tc.acks {
48+ p := newMockseatDisablePreparer(t)
49+ p.EXPECT().PrepareSeatDisable(mock.Anything).Return(ack).Once()
50+ b.addPreparer(p)
51+ }
52+ b.prepareSeatDisable()
53+ if got := strings.Contains(log.String(), `"outputs":1`); got != tc.warns {
54+ t.Fatalf("late warning %v, want %v: %s", got, tc.warns, log.String())
55+ }
56+ })
57+ }
58+}
M internal/ports/scene.go +1 -0
@@ -127,6 +127,7 @@ const (
127127 ColorTFExtendedLinear uint8 = 5 // wp_color_manager_v1 ext_linear
128128 ColorPrimariesBT2020 uint8 = 6 // wp_color_manager_v1 BT.2020
129129 ColorPrimariesSRGB uint8 = 1
130+ ColorTFSRGB uint8 = 9 // wp_color_manager_v1 sRGB
130131 )
131132
132133 func (c SurfaceColor) IsPQ2020() bool {
@@ -127,6 +127,7 @@ const (
127 ColorTFExtendedLinear uint8 = 5 // wp_color_manager_v1 ext_linear127 ColorTFExtendedLinear uint8 = 5 // wp_color_manager_v1 ext_linear
128 ColorPrimariesBT2020 uint8 = 6 // wp_color_manager_v1 BT.2020128 ColorPrimariesBT2020 uint8 = 6 // wp_color_manager_v1 BT.2020
129 ColorPrimariesSRGB uint8 = 1129 ColorPrimariesSRGB uint8 = 1
130+ ColorTFSRGB uint8 = 9 // wp_color_manager_v1 sRGB
130 )131 )
131 132
132 func (c SurfaceColor) IsPQ2020() bool {133 func (c SurfaceColor) IsPQ2020() bool {

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 25

Tags 10

↑↓ moveenter openesc close