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 Unverified
B5690EEEBB952194The key is unknown
35 files · +2838 -149
M .mockery.yml +12 -0
| @@ -31,6 +31,13 @@ packages: | |||
| 31 | 31 | dir: internal/adapters/drm | |
| 32 | 32 | pkgname: drm | |
| 33 | 33 | 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 | 41 | github.com/bnema/neferwl/internal/adapters/libinput: | |
| 35 | 42 | interfaces: | |
| 36 | 43 | deviceConfig: | |
| @@ -79,6 +86,11 @@ packages: | |||
| 79 | 86 | dir: internal/app | |
| 80 | 87 | pkgname: app | |
| 81 | 88 | filename: cursor_mock_test.go | |
| 89 | + | seatDisablePreparer: | |
| 90 | + | config: | |
| 91 | + | dir: internal/app | |
| 92 | + | pkgname: app | |
| 93 | + | filename: seatdisable_mock_test.go | |
| 82 | 94 | github.com/bnema/neferwl/internal/adapters/statefile: | |
| 83 | 95 | interfaces: | |
| 84 | 96 | procTree: | |
| @@ -31,6 +31,13 @@ packages: | |||||
| 31 | dir: internal/adapters/drm | 31 | dir: internal/adapters/drm | ||
| 32 | pkgname: drm | 32 | pkgname: drm | ||
| 33 | filename: kms_mock_test.go | 33 | 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/app | 86 | dir: internal/app | ||
| 80 | pkgname: app | 87 | pkgname: app | ||
| 81 | filename: cursor_mock_test.go | 88 | 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: | |||
| 54 | 54 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | |
| 55 | 55 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 56 | 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=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 | |
| 58 | 58 | check: vet test arch fakes-check perf-check | |
| 59 | 59 | ||
| 60 | 60 | # 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=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 |
| 58 | check: vet test arch fakes-check perf-check | 58 | check: vet test arch fakes-check perf-check | ||
| 59 | 59 | ||||
| 60 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | 60 | # 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 | |||
| 55 | 55 | ||
| 56 | 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 | 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 | 59 | - `wp_tearing_control_v1` and VRR while a fullscreen window covers the output. | |
| 60 | 60 | - `wp_linux_drm_syncobj_v1`: client fences go to KMS. NVIDIA drivers need it. | |
| 61 | 61 | - `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 | |||
| 156 | 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 | 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 | 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 | 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 | 164 | - 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 | |||
| 17 | 17 | ||
| 18 | 18 | ## 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 | 22 | ## Measuring | |
| 23 | 23 | ||
| @@ -17,7 +17,7 @@ With the capability, the dynamic loader ignores `LD_PRELOAD` and `LD_LIBRARY_PAT | |||||
| 17 | 17 | ||||
| 18 | ## Guards | 18 | ## 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 | ## Measuring | 22 | ## 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) { | |||
| 89 | 89 | // - the active primary plane covers the whole mode (no partial src/dst); | |
| 90 | 90 | // - HDR_OUTPUT_METADATA needs the BT.2020 Colorspace; | |
| 91 | 91 | // - 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 | 99 | // amdgpuRule has no blob model: any MODE_ID is live. | |
| 95 | 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 | 110 | for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} { | |
| 106 | 111 | 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 | 121 | return func(req *atomicReq, flags uint32) error { | |
| 109 | 122 | next := make(map[[2]uint32]uint64, len(state)) | |
| 110 | 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 | 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 | 149 | if mode := get(tCrtc, pMode); mode != 0 && !live(uint32(mode)) { | |
| 120 | 150 | t.Logf("amdgpu: MODE_ID %d names an unknown or destroyed blob", mode) | |
| 121 | 151 | return unix.EINVAL | |
| @@ -194,10 +224,13 @@ func TestAmdgpuRuleRejectsWhatHardwareRejected(t *testing.T) { | |||
| 194 | 224 | }, | |
| 195 | 225 | "cursor with CRTC but no FB": func(r *atomicReq) { r.set(tCursor, pFB, 0) }, | |
| 196 | 226 | "partial primary": func(r *atomicReq) { r.set(tPrimary, 16, 100) }, | |
| 197 | - | "unknown MODE_ID": func(r *atomicReq) { r.set(tCrtc, pMode, 123) }, | |
| 198 | 227 | "HDR metadata without colorspace": func(r *atomicReq) { | |
| 199 | 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 | 235 | t.Run(name, func(t *testing.T) { | |
| 203 | 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 | 323 | func TestAmdgpuRuleRejectsDestroyedModeBlob(t *testing.T) { | |
| 281 | 324 | m := newAmdgpuModel(t) | |
| 282 | 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 | + | } | |
| @@ -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 the | 91 | // - 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, tCrtc | 111 | 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.EINVAL | 151 | 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 ( | |||
| 29 | 29 | ||
| 30 | 30 | clientCapUniversalPlanes = 2 | |
| 31 | 31 | clientCapAtomic = 3 | |
| 32 | + | clientCapColorPipeline = 7 // DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE | |
| 32 | 33 | capTimestampMonotonic = 0x6 | |
| 33 | 34 | capAtomicAsync = 0x15 | |
| 34 | 35 | ||
| @@ -83,6 +84,8 @@ type kms interface { | |||
| 83 | 84 | // planes lists every plane with the CRTCs it can show on. | |
| 84 | 85 | planes() ([]planeRes, error) | |
| 85 | 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 | 89 | // addFB imports a single-plane dmabuf as a framebuffer of format. | |
| 87 | 90 | addFB(b *ports.DMABuf, format uint32) (uint32, error) | |
| 88 | 91 | rmFB(id uint32) error | |
| @@ -102,9 +105,23 @@ type planeRes struct { | |||
| 102 | 105 | } | |
| 103 | 106 | ||
| 104 | 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 | 112 | type atomicReq struct { | |
| 106 | 113 | objs []uint32 | |
| 107 | 114 | 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 | 127 | type propValue struct { | |
| @@ -120,7 +137,13 @@ func (r *atomicReq) set(obj, prop uint32, v uint64) { | |||
| 120 | 137 | i := slices.Index(r.objs, obj) | |
| 121 | 138 | if i < 0 { | |
| 122 | 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 | 147 | i = len(r.objs) - 1 | |
| 125 | 148 | } | |
| 126 | 149 | for j, p := range r.props[i] { | |
| @@ -160,16 +183,15 @@ func (k kmsDevice) commit(req *atomicReq, flags uint32, userData uint64) error { | |||
| 160 | 183 | if len(req.objs) == 0 { | |
| 161 | 184 | return errors.New("empty atomic commit") | |
| 162 | 185 | } | |
| 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))) | |
| 168 | 189 | 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) | |
| 171 | 192 | } | |
| 172 | 193 | } | |
| 194 | + | counts, props, vals := req.counts, req.flatProps, req.flatVals | |
| 173 | 195 | a := modeAtomic{flags: flags, countObjs: uint32(len(req.objs)), userData: userData, | |
| 174 | 196 | objs: uint64(uintptr(unsafe.Pointer(&req.objs[0]))), countProps: uint64(uintptr(unsafe.Pointer(&counts[0]))), | |
| 175 | 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 | 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 | 275 | func (k kmsDevice) rmFB(id uint32) error { | |
| 250 | 276 | v := id | |
| 251 | 277 | return ioctl(k.fd, ioctlRmFB, unsafe.Pointer(&v)) | |
| @@ -265,6 +291,18 @@ func enableAtomic(fd int) error { | |||
| 265 | 291 | 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 | 306 | func hasCap(fd int, c uint64) bool { | |
| 269 | 307 | v := getCap{capability: c} | |
| 270 | 308 | return ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value == 1 | |
| @@ -276,6 +314,15 @@ type plane struct { | |||
| 276 | 314 | props map[string]uint32 | |
| 277 | 315 | crtc uint32 // CRTC_ID when read | |
| 278 | 316 | 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 | 326 | // zposValue is the plane's zpos when read (its "zpos" property). | |
| 280 | 327 | zposValue uint64 | |
| 281 | 328 | } | |
| @@ -357,9 +404,9 @@ type flipEvent struct { | |||
| 357 | 404 | seq uint32 | |
| 358 | 405 | } | |
| 359 | 406 | ||
| 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 { | |
| 363 | 410 | le := binary.LittleEndian | |
| 364 | 411 | for len(buf) >= 8 { | |
| 365 | 412 | typ := le.Uint32(buf) | |
| @@ -29,6 +29,7 @@ const ( | |||||
| 29 | 29 | ||||
| 30 | clientCapUniversalPlanes = 2 | 30 | clientCapUniversalPlanes = 2 | ||
| 31 | clientCapAtomic = 3 | 31 | clientCapAtomic = 3 | ||
| 32 | + | clientCapColorPipeline = 7 // DRM_CLIENT_CAP_PLANE_COLOR_PIPELINE | |||
| 32 | capTimestampMonotonic = 0x6 | 33 | capTimestampMonotonic = 0x6 | ||
| 33 | capAtomicAsync = 0x15 | 34 | 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) error | 91 | 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 []uint32 | 113 | objs []uint32 | ||
| 107 | props [][]propValue | 114 | 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) - 1 | 147 | 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 []uint32 | 187 | + | for _, ps := range req.props { |
| 165 | - | var vals []uint64 | 188 | + | 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 := id | 276 | 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 nil | 291 | 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 == 1 | 308 | return ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value == 1 | ||
| @@ -276,6 +314,15 @@ type plane struct { | |||||
| 276 | props map[string]uint32 | 314 | props map[string]uint32 | ||
| 277 | crtc uint32 // CRTC_ID when read | 315 | crtc uint32 // CRTC_ID when read | ||
| 278 | formats []ports.DMABufFormat | 316 | 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 uint64 | 327 | zposValue uint64 | ||
| 281 | } | 328 | } | ||
| @@ -357,9 +404,9 @@ type flipEvent struct { | |||||
| 357 | seq uint32 | 404 | 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 []flipEvent | 409 | + | func parseFlips(out []flipEvent, buf []byte) []flipEvent { |
| 363 | le := binary.LittleEndian | 410 | 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 | |||
| 47 | 47 | if err := enableAtomic(fd); err != nil { | |
| 48 | 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 | 54 | crtcs, _, err := resources(fd) | |
| 51 | 55 | if err != nil { | |
| 52 | 56 | return nil, fmt.Errorf("%s: %w", path, err) | |
| @@ -240,6 +244,7 @@ func (c *Card) open(conn connector, mode modeInfo) (*Output, error) { | |||
| 240 | 244 | // the output owes it a pending commit. | |
| 241 | 245 | func (c *Card) ReadEvents(ctx context.Context) error { | |
| 242 | 246 | buf := make([]byte, 1024) | |
| 247 | + | var events []flipEvent // reused by every read | |
| 243 | 248 | for ctx.Err() == nil { | |
| 244 | 249 | fds := []unix.PollFd{{Fd: int32(c.fd), Events: unix.POLLIN}} | |
| 245 | 250 | n, err := unix.Poll(fds, 100) | |
| @@ -256,7 +261,8 @@ func (c *Card) ReadEvents(ctx context.Context) error { | |||
| 256 | 261 | } | |
| 257 | 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 | 266 | ch := c.flips[ev.crtc] | |
| 261 | 267 | if ch == nil { | |
| 262 | 268 | 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 | continue | 268 | 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 ( | |||
| 9 | 9 | func TestDropHintsBlockPlanes(t *testing.T) { | |
| 10 | 10 | s, c := overlayScene() | |
| 11 | 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 | 13 | t.Fatalf("overlay reason %q", reason) | |
| 14 | 14 | } | |
| 15 | 15 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| 7 | 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 | 83 | if decision.fb != 0 { | |
| 83 | 84 | 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 | 86 | presented = err == nil | |
| 86 | 87 | return true, err | |
| 87 | 88 | } | |
| @@ -202,6 +203,11 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||
| 202 | 203 | done.Close() | |
| 203 | 204 | } | |
| 204 | 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 | 211 | if err != nil && o.overlayConflict(err, ov.buf) { | |
| 206 | 212 | return false, errOverlayDropped | |
| 207 | 213 | } | |
| @@ -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.content | 84 | 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 == nil | 86 | presented = err == nil | ||
| 86 | return true, err | 87 | 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, errOverlayDropped | 212 | return false, errOverlayDropped | ||
| 207 | } | 213 | } | ||
M internal/adapters/drm/game_performance_test.go +4 -4
| @@ -20,7 +20,7 @@ func TestScaledScanoutRectAndCachedRefusal(t *testing.T) { | |||
| 20 | 20 | var log bytes.Buffer | |
| 21 | 21 | o.log = zerowrap.New(zerowrap.Config{Output: &log}) | |
| 22 | 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 | 24 | t.Fatalf("fb %d reason %s", fb, o.reason) | |
| 25 | 25 | } | |
| 26 | 26 | if len(*commits) != 1 || (*commits)[0].flags != atomicTestOnly { | |
| @@ -35,7 +35,7 @@ func TestScaledScanoutRectAndCachedRefusal(t *testing.T) { | |||
| 35 | 35 | if v, _ := (*commits)[0].req.value(tPrimary, planeProps["CRTC_W"]); v != 200 { | |
| 36 | 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 | 39 | t.Fatal("refused geometry retried") | |
| 40 | 40 | } | |
| 41 | 41 | if !bytes.Contains(log.Bytes(), []byte("scale_refused")) { | |
| @@ -52,7 +52,7 @@ func TestModesetInvalidatesScaleDecision(t *testing.T) { | |||
| 52 | 52 | s := ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}}} | |
| 53 | 53 | c := ports.SurfaceContent{ID: 1, Width: 100, Height: 50, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 9, Format: fourccXRGB}} | |
| 54 | 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 | 56 | t.Fatalf("initial scale: %d, commits: %d", got, len(*commits)) | |
| 57 | 57 | } | |
| 58 | 58 | k.EXPECT().createBlob(mock.Anything).Return(uint32(77), nil).Once() | |
| @@ -62,7 +62,7 @@ func TestModesetInvalidatesScaleDecision(t *testing.T) { | |||
| 62 | 62 | if fb.scaleTestedOK || fb.scaleRefused { | |
| 63 | 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 | 66 | t.Fatalf("scale not retested: fb %d commits %+v", got, *commits) | |
| 67 | 67 | } | |
| 68 | 68 | } | |
| @@ -20,7 +20,7 @@ func TestScaledScanoutRectAndCachedRefusal(t *testing.T) { | |||||
| 20 | var log bytes.Buffer | 20 | 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) { | |||
| 53 | 53 | } | |
| 54 | 54 | } | |
| 55 | 55 | 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 | 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 | 60 | t.Fatalf("overlay: fb %d reason %q", ov.fb, o.overlayReason) | |
| 61 | 61 | } | |
| 62 | 62 | // 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 = true | 55 | 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 | |||
| 672 | 672 | 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 | 737 | // resources provides a mock function for the type mockkms | |
| 676 | 738 | func (_mock *mockkms) resources() ([]uint32, []uint32, error) { | |
| 677 | 739 | ret := _mock.Called() | |
| @@ -672,6 +672,68 @@ func (_c *mockkms_planes_Call) RunAndReturn(run func() ([]planeRes, error)) *moc | |||||
| 672 | return _c | 672 | 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 mockkms | 737 | // 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) { | |||
| 111 | 111 | return b | |
| 112 | 112 | } | |
| 113 | 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 | 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 | 116 | if !slices.Equal(got, want) { | |
| 117 | 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 | 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 | 129 | func TestParseInFormats(t *testing.T) { | |
| @@ -111,14 +111,19 @@ func TestParseFlips(t *testing.T) { | |||||
| 111 | return b | 111 | 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 { | |||
| 63 | 63 | overlay *plane | |
| 64 | 64 | overlayOn uint64 | |
| 65 | 65 | 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 | 74 | // fbs are the two renderer images frames alternate between, back the | |
| 67 | 75 | // one the next frame draws into (ADR 014: zero copy). | |
| 68 | 76 | fbs [2]uint32 | |
| @@ -117,6 +125,16 @@ type Output struct { | |||
| 117 | 125 | contentValue uint64 | |
| 118 | 126 | contentWanted uint64 | |
| 119 | 127 | 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 | 138 | // lastFrame is when the last frame was committed. cursorHeld: a cursor | |
| 121 | 139 | // move waits for the next frame (see cursorWaits). | |
| 122 | 140 | lastFrame time.Time | |
| @@ -177,7 +195,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, t ports.BufferTransf | |||
| 177 | 195 | func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) { | |
| 178 | 196 | log := card.log | |
| 179 | 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 | 199 | var err error | |
| 182 | 200 | if o.saved, err = getCrtc(card.fd, crtc); err != nil { | |
| 183 | 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 | 206 | o.hdr.settings = normalizedHDRSettings(card.want.HDR[c.name]) | |
| 189 | 207 | o.hdr.cap = detectHDR(o.monitor, o.hdr.props) | |
| 208 | + | o.readColor() | |
| 209 | + | o.setupColor() | |
| 190 | 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 | 211 | if o.hdr.settings.Enabled && !o.hdr.cap.Capable { | |
| 192 | 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 | 358 | return err | |
| 339 | 359 | } | |
| 340 | 360 | o.frame.resetAfterModeset() | |
| 361 | + | o.forgetColor() | |
| 341 | 362 | o.planeRect = fullPlaneRect(o.Width(), o.Height()) | |
| 342 | 363 | o.signalReady() | |
| 343 | 364 | return nil | |
| @@ -380,10 +401,13 @@ func (o *Output) modesetImage(fb uint32, active bool) error { | |||
| 380 | 401 | // Only now is nothing of ours pending or on screen: on failure the | |
| 381 | 402 | // previous buffers may still show. | |
| 382 | 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 | 406 | for _, fb := range o.clientFBs { | |
| 385 | 407 | fb.scaleTestedOK, fb.scaleRefused = false, false | |
| 386 | 408 | } | |
| 409 | + | o.forgetColor() | |
| 410 | + | o.forgetColorVerdicts() | |
| 387 | 411 | o.shown, o.queued = 0, 0 | |
| 388 | 412 | if o.modeBlob != 0 { | |
| 389 | 413 | _ = o.k.destroyBlob(o.modeBlob) | |
| @@ -425,9 +449,11 @@ func (o *Output) powerOff() error { | |||
| 425 | 449 | req.set(o.crtc, o.crtcProps["ACTIVE"], 0) | |
| 426 | 450 | req.set(o.crtc, o.vrrProp, 0) | |
| 427 | 451 | req.set(o.conn.id, o.contentProp, o.contentValues[0]) | |
| 452 | + | o.forceBypass(req) | |
| 428 | 453 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | |
| 429 | 454 | return fmt.Errorf("power off: %w", err) | |
| 430 | 455 | } | |
| 456 | + | o.colorBypassed() | |
| 431 | 457 | o.off, o.vrrGame = true, false | |
| 432 | 458 | o.setVRR(false) | |
| 433 | 459 | o.contentValue, o.contentWanted = o.contentValues[0], o.contentValues[0] | |
| @@ -451,6 +477,10 @@ func (o *Output) sendFormats() { | |||
| 451 | 477 | } | |
| 452 | 478 | if o.scanout && !o.off { | |
| 453 | 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 | 484 | if !isYUVFormat(format.Format) && isTenBit(format.Format) == o.hdr.on { | |
| 455 | 485 | f.Formats = append(f.Formats, format) | |
| 456 | 486 | } | |
| @@ -515,6 +545,9 @@ func (o *Output) modesetBaseReq(blob uint32, active bool) *atomicReq { | |||
| 515 | 545 | req.set(o.conn.id, o.contentProp, o.contentValues[0]) | |
| 516 | 546 | req.set(o.conn.id, o.connCrtc, uint64(o.crtc)) | |
| 517 | 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 | 551 | return req | |
| 519 | 552 | } | |
| 520 | 553 | ||
| @@ -544,15 +577,20 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq { | |||
| 544 | 577 | // also move the cursor or change VRR flips at vblank. fence is the | |
| 545 | 578 | // frame's GPU fence (nil: none); the caller keeps and closes it. | |
| 546 | 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 | 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 | 594 | o.observeSecurity() | |
| 557 | 595 | if o.Security != nil && (f.security != o.securityState || o.protected && !o.securityPrepared) { | |
| 558 | 596 | return errSecurityScene | |
| @@ -562,24 +600,28 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, | |||
| 562 | 600 | cur = o.cursor.desired() | |
| 563 | 601 | } | |
| 564 | 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 | 606 | async = false | |
| 569 | 607 | } | |
| 570 | - | req := &atomicReq{} | |
| 608 | + | req := &o.frameReq | |
| 609 | + | req.reset() | |
| 571 | 610 | flags := uint32(atomicNonblock | flipEventFlag) | |
| 572 | 611 | if async { | |
| 573 | 612 | flags |= flipAsyncFlag | |
| 574 | 613 | req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(fb)) | |
| 575 | 614 | } else { | |
| 576 | 615 | o.primaryRectProps(req, fb, rect) | |
| 616 | + | o.primaryColorProps(req, pc.mode) | |
| 577 | 617 | req.set(o.crtc, o.vrrProp, boolValue(vrr)) | |
| 578 | 618 | if o.cursor != nil { | |
| 579 | 619 | o.cursor.props(req, o.crtc, cur) | |
| 580 | 620 | } | |
| 581 | 621 | if ov.buf != 0 || o.overlayOn != 0 { | |
| 582 | 622 | o.overlayProps(req, ov) | |
| 623 | + | } else { | |
| 624 | + | o.overlayColorProps(req, ov.color.mode) | |
| 583 | 625 | } | |
| 584 | 626 | } | |
| 585 | 627 | o.contentProps(req) | |
| @@ -594,19 +636,28 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, | |||
| 594 | 636 | } | |
| 595 | 637 | // The kernel takes its own reference on the fence. | |
| 596 | 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 | 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 | 652 | o.log.Warn().Str("component", "drm").Err(err).Msg("content type refused; disabled") | |
| 602 | 653 | 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 | 656 | if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) { | |
| 606 | 657 | // Retry without turning VRR on: if that passes, the driver | |
| 607 | 658 | // refuses VRR on this output. The overlay stays: the composed | |
| 608 | 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 | 661 | o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled") | |
| 611 | 662 | o.vrrProp = 0 | |
| 612 | 663 | return nil | |
| @@ -626,6 +677,7 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, | |||
| 626 | 677 | } | |
| 627 | 678 | if !async { | |
| 628 | 679 | o.overlayOn = ov.buf | |
| 680 | + | o.colorCommitted(pc, ov) | |
| 629 | 681 | } | |
| 630 | 682 | o.contentValue = o.contentWanted | |
| 631 | 683 | o.planeRect = rect | |
| @@ -642,16 +694,26 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, | |||
| 642 | 694 | return nil | |
| 643 | 695 | } | |
| 644 | 696 | ||
| 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() | |
| 648 | 704 | o.primaryRectProps(req, fb, rect) | |
| 705 | + | o.primaryColorProps(req, pc.mode) | |
| 649 | 706 | req.set(o.crtc, o.vrrProp, boolValue(vrr)) | |
| 650 | 707 | if o.cursor != nil { | |
| 651 | 708 | o.cursor.props(req, o.crtc, cur) | |
| 652 | 709 | } | |
| 653 | 710 | if ov.buf != 0 || o.overlayOn != 0 { | |
| 654 | 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 | 718 | if fence != nil { | |
| 657 | 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 | 735 | if o.protected { | |
| 674 | 736 | return errSecurityScene | |
| 675 | 737 | } | |
| 676 | - | req := &atomicReq{} | |
| 738 | + | req := &o.stateReq | |
| 739 | + | req.reset() | |
| 677 | 740 | cur := cursorState{} | |
| 678 | 741 | // A move back to the applied state holds nothing. | |
| 679 | 742 | o.cursorHeld = false | |
| @@ -700,6 +763,11 @@ func (o *Output) commitState(vrr bool) error { | |||
| 700 | 763 | return errSecurityScene | |
| 701 | 764 | } | |
| 702 | 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 | 771 | if o.overlayConflict(err, o.overlayOn) { | |
| 704 | 772 | // The next frame is composed without the overlay. | |
| 705 | 773 | return errOverlayDropped | |
| @@ -708,7 +776,8 @@ func (o *Output) commitState(vrr bool) error { | |||
| 708 | 776 | return o.commitState(vrr) | |
| 709 | 777 | } | |
| 710 | 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 | 781 | if o.cursor != nil && cur != o.cursor.applied { | |
| 713 | 782 | o.cursor.props(without, o.crtc, cur) | |
| 714 | 783 | } | |
| @@ -757,10 +826,11 @@ func boolValue(b bool) uint64 { | |||
| 757 | 826 | // commitScanout flips a client buffer. It reports false when KMS refused | |
| 758 | 827 | // the buffer: the frame must be composed. | |
| 759 | 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 | 834 | cfb := o.clientFBs[c.DMABuf.ID] | |
| 765 | 835 | // Drivers refuse async flips that change the format or modifier: the | |
| 766 | 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 | 844 | if fence != nil { | |
| 775 | 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 | 849 | if err != nil && async && errors.Is(err, unix.EINVAL) { | |
| 779 | 850 | // Refused (e.g. not a fast update): this buffer flips at vblank | |
| 780 | 851 | // from now on. | |
| 781 | 852 | o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused") | |
| 782 | 853 | 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 | 862 | if err != nil && (errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)) { | |
| 786 | 863 | // KMS refuses this buffer on the plane: compose it instead. | |
| @@ -807,6 +884,8 @@ func (o *Output) Close() { | |||
| 807 | 884 | o.cursor.props(req, o.crtc, cursorState{}) | |
| 808 | 885 | } | |
| 809 | 886 | o.overlayProps(req, overlayWin{}) | |
| 887 | + | // Whoever drives the planes next starts from Bypass. | |
| 888 | + | o.forceBypass(req) | |
| 810 | 889 | o.hdrConnectorProps(req, false) | |
| 811 | 890 | req.set(o.crtc, o.vrrProp, 0) | |
| 812 | 891 | s := o.saved | |
| @@ -874,6 +953,10 @@ func (o *Output) Close() { | |||
| 874 | 953 | o.modeBlob = 0 | |
| 875 | 954 | } | |
| 876 | 955 | o.hdr.releaseBlob(o.k) | |
| 956 | + | if o.ctmBlob != 0 { | |
| 957 | + | _ = o.k.destroyBlob(o.ctmBlob) | |
| 958 | + | o.ctmBlob = 0 | |
| 959 | + | } | |
| 877 | 960 | if o.cursor != nil { | |
| 878 | 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 | 1269 | // scene that shows it. | |
| 1187 | 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 | 1279 | case on := <-active: | |
| 1190 | 1280 | o.observeSecurity() | |
| 1191 | 1281 | if o.protected { | |
| @@ -63,6 +63,14 @@ type Output struct { | |||||
| 63 | overlay *plane | 63 | overlay *plane | ||
| 64 | overlayOn uint64 | 64 | overlayOn uint64 | ||
| 65 | overlayReason string | 65 | 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 the | 74 | // 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]uint32 | 76 | fbs [2]uint32 | ||
| @@ -117,6 +125,16 @@ type Output struct { | |||||
| 117 | contentValue uint64 | 125 | contentValue uint64 | ||
| 118 | contentWanted uint64 | 126 | contentWanted uint64 | ||
| 119 | composedSince time.Time | 127 | 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 cursor | 138 | // 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.Time | 140 | 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.log | 196 | 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 error | 199 | 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 err | 358 | 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 nil | 364 | 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 the | 401 | // 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, false | 407 | fb.scaleTestedOK, fb.scaleRefused = false, false | ||
| 386 | } | 408 | } | ||
| 409 | + | o.forgetColor() | |||
| 410 | + | o.forgetColorVerdicts() | |||
| 387 | o.shown, o.queued = 0, 0 | 411 | 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, false | 457 | 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 req | 551 | 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 the | 577 | // 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 errSecurityScene | 596 | 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 = false | 606 | 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 |= flipAsyncFlag | 612 | 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 = 0 | 653 | 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 driver | 657 | // Retry without turning VRR on: if that passes, the driver | ||
| 607 | // refuses VRR on this output. The overlay stays: the composed | 658 | // 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 = 0 | 662 | o.vrrProp = 0 | ||
| 612 | return nil | 663 | 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.buf | 679 | o.overlayOn = ov.buf | ||
| 680 | + | o.colorCommitted(pc, ov) | |||
| 629 | } | 681 | } | ||
| 630 | o.contentValue = o.contentWanted | 682 | o.contentValue = o.contentWanted | ||
| 631 | o.planeRect = rect | 683 | o.planeRect = rect | ||
| @@ -642,16 +694,26 @@ func (o *Output) commitWithRect(fb uint32, fence *os.File, async bool, vrr bool, | |||||
| 642 | return nil | 694 | 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 errSecurityScene | 736 | 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 = false | 742 | o.cursorHeld = false | ||
| @@ -700,6 +763,11 @@ func (o *Output) commitState(vrr bool) error { | |||||
| 700 | return errSecurityScene | 763 | 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 errOverlayDropped | 773 | 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 refused | 826 | // 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: the | 835 | // 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 vblank | 850 | // 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 = true | 853 | 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.saved | 891 | s := o.saved | ||
| @@ -874,6 +953,10 @@ func (o *Output) Close() { | |||||
| 874 | o.modeBlob = 0 | 953 | 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 ... | |||
| 82 | 82 | cur.fbs = [2]uint32{90, 91} | |
| 83 | 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 | 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 | 86 | return o, k, &commits, commitMu | |
| 87 | 87 | } | |
| 88 | 88 | ||
| @@ -1043,7 +1043,8 @@ func TestRunSecurityResetClearsWantedAt(t *testing.T) { | |||
| 1043 | 1043 | scene.Seq = 2 | |
| 1044 | 1044 | sr.scenes <- scene | |
| 1045 | 1045 | 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) | |
| 1047 | 1048 | sr.wake <- ports.SecurityState{} | |
| 1048 | 1049 | sr.contents <- ports.SurfaceContent{ID: 1, Seq: 2, SHM: &ports.SHMBuffer{}} // Run went round and reset | |
| 1049 | 1050 | 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, commitMu | 86 | return o, k, &commits, commitMu | ||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| @@ -1043,7 +1043,8 @@ func TestRunSecurityResetClearsWantedAt(t *testing.T) { | |||||
| 1043 | scene.Seq = 2 | 1043 | scene.Seq = 2 | ||
| 1044 | sr.scenes <- scene | 1044 | sr.scenes <- scene | ||
| 1045 | sr.contents <- ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{}} // stamped by now | 1045 | 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) | |||
| 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 reset | 1049 | 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 { | |||
| 49 | 49 | buf uint64 | |
| 50 | 50 | rect ports.Rect // CRTC rect | |
| 51 | 51 | w, h int // buffer size | |
| 52 | + | // color is how the plane's colour pipeline shows the buffer. | |
| 53 | + | color colorUse | |
| 52 | 54 | // acquire is the client's explicit-sync fence, a duplicate owned by | |
| 53 | 55 | // the frame loop (closed after the commit); nil without one. | |
| 54 | 56 | acquire *os.File | |
| @@ -56,16 +58,17 @@ type overlayWin struct { | |||
| 56 | 58 | ||
| 57 | 59 | // overlayCandidate finds the one window the overlay can show: an opaque | |
| 58 | 60 | // 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) { | |
| 61 | 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 | 66 | scale := s.Scale | |
| 65 | 67 | if scale <= 0 { | |
| 66 | 68 | scale = 1 | |
| 67 | 69 | } | |
| 68 | 70 | var pick *ports.SceneWindow | |
| 71 | + | var mode colorMode | |
| 69 | 72 | for i := range s.Windows { | |
| 70 | 73 | w := &s.Windows[i] | |
| 71 | 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 | 77 | if w.Popup { | |
| 75 | 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 | 81 | 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 | |
| 81 | 85 | // window (a peeking stashed one) needs the veil drawn over it, and | |
| 82 | 86 | // one with the focus effect needs its lift; an overview preview is | |
| 83 | 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() { | |
| 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 | + | } | |
| 87 | 93 | } | |
| 88 | 94 | if pick != nil { | |
| 89 | 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 | 99 | if pick == nil { | |
| 94 | - | return ports.SceneWindow{}, ports.SurfaceContent{}, "no_candidate" | |
| 100 | + | return ports.SceneWindow{}, ports.SurfaceContent{}, colorBypass, "no_candidate" | |
| 95 | 101 | } | |
| 96 | 102 | if s.Dim > 0 { | |
| 97 | 103 | // The renderer composes the veil (under the first float, or under | |
| 98 | 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 | 107 | if len(s.DropHints) > 0 { | |
| 102 | 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 | 111 | for _, l := range s.Layers { | |
| 106 | 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 | 116 | if !pick.Fullscreen && len(s.Separators) > 0 { | |
| 111 | 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 | 120 | if clip := s.WorkspaceClip; clip != (ports.Rect{}) && | |
| 115 | 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 | 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 | 125 | c := surfaces[pick.ID] | |
| 120 | 126 | pw, ph := float64(pick.Rect.W)*scale, float64(pick.Rect.H)*scale | |
| 121 | 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 | 130 | x, y := float64(pick.Rect.X)*scale, float64(pick.Rect.Y)*scale | |
| 125 | 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 | 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 | 143 | if ov.fb == 0 { | |
| 138 | 144 | req.set(p.id, p.prop("FB_ID"), 0) | |
| 139 | 145 | req.set(p.id, p.prop("CRTC_ID"), 0) | |
| 146 | + | o.overlayColorProps(req, colorBypass) | |
| 140 | 147 | return | |
| 141 | 148 | } | |
| 149 | + | o.overlayColorProps(req, ov.color.mode) | |
| 142 | 150 | req.set(p.id, p.prop("FB_ID"), uint64(ov.fb)) | |
| 143 | 151 | req.set(p.id, p.prop("CRTC_ID"), uint64(o.crtc)) | |
| 144 | 152 | req.set(p.id, p.prop("SRC_X"), 0) | |
| @@ -164,18 +172,20 @@ func (ov overlayWin) close() { | |||
| 164 | 172 | ||
| 165 | 173 | // testOverlay asks KMS whether it takes the frame with ov on the overlay, | |
| 166 | 174 | // 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 | 178 | 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) | |
| 176 | 180 | if err == nil { | |
| 177 | 181 | 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 | 189 | if cfb := o.clientFBs[ov.buf]; cfb != nil && refused(err) { | |
| 180 | 190 | cfb.overlayFailed = "overlay_refused" | |
| 181 | 191 | } | |
| @@ -184,6 +194,23 @@ func (o *Output) testOverlay(fb uint32, ov overlayWin) bool { | |||
| 184 | 194 | 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 | 214 | // errOverlayDropped: a commit was refused with the overlay on; the frame is | |
| 188 | 215 | // composed again without it. | |
| 189 | 216 | var errOverlayDropped = errors.New("overlay dropped") | |
| @@ -49,6 +49,8 @@ type overlayWin struct { | |||||
| 49 | buf uint64 | 49 | buf uint64 | ||
| 50 | rect ports.Rect // CRTC rect | 50 | rect ports.Rect // CRTC rect | ||
| 51 | w, h int // buffer size | 51 | 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 by | 54 | // 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.File | 56 | acquire *os.File | ||
| @@ -56,16 +58,17 @@ type overlayWin struct { | |||||
| 56 | 58 | ||||
| 57 | // overlayCandidate finds the one window the overlay can show: an opaque | 59 | // overlayCandidate finds the one window the overlay can show: an opaque | ||
| 58 | // dmabuf at integer physical coordinates, drawn 1:1, with no other window | 60 | // 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.Scale | 66 | scale := s.Scale | ||
| 65 | if scale <= 0 { | 67 | if scale <= 0 { | ||
| 66 | scale = 1 | 68 | scale = 1 | ||
| 67 | } | 69 | } | ||
| 68 | var pick *ports.SceneWindow | 70 | 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 show | 82 | + | // A viewport crop is composed: the plane would show the whole |
| 80 | - | // the whole buffer, and raw PQ values on this SDR output. A dimmed | 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 | |||
| 81 | // window (a peeking stashed one) needs the veil drawn over it, and | 85 | // window (a peeking stashed one) needs the veil drawn over it, and | ||
| 82 | // one with the focus effect needs its lift; an overview preview is | 86 | // 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 = w | 89 | + | if m, why := planeColor(c.Color, c.DMABuf.Format, c.Opaque, hdrOn, pipeline); why == "" { |
| 86 | - | continue | 90 | + | 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 under | 103 | // 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)*scale | 126 | 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)*scale | 130 | 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 | return | 147 | 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 the | 174 | // 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 true | 181 | 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 false | 194 | 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 is | 214 | // 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) { | |||
| 54 | 54 | func TestOverlayWorkspaceClip(t *testing.T) { | |
| 55 | 55 | s, contents := overlayScene() | |
| 56 | 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 | 58 | t.Fatalf("overflowing workspace overlay: %q", reason) | |
| 59 | 59 | } | |
| 60 | 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 | 62 | t.Fatalf("contained workspace overlay: %v %q", win.ID, reason) | |
| 63 | 63 | } | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | 66 | func TestOverlayCandidate(t *testing.T) { | |
| 67 | 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 | 69 | t.Fatalf("candidate %v %q", w.ID, reason) | |
| 70 | 70 | } | |
| 71 | 71 | // A rotated output composes: planes are not rotated. | |
| 72 | 72 | rs, rc := overlayScene() | |
| 73 | 73 | rs.Transform = 1 | |
| 74 | - | if _, _, reason := overlayCandidate(rs, rc); reason != "output_transform" { | |
| 74 | + | if _, _, _, reason := overlayCandidate(rs, rc, false, nil); reason != "output_transform" { | |
| 75 | 75 | t.Fatalf("rotated output reason %q", reason) | |
| 76 | 76 | } | |
| 77 | 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 | 79 | t.Fatalf("YUV overlay reason %q", reason) | |
| 80 | 80 | } | |
| 81 | 81 | // A transformed buffer is composed: the plane would show it unrotated. | |
| @@ -83,46 +83,46 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 83 | 83 | rotated := c[2] | |
| 84 | 84 | rotated.Transform = 1 | |
| 85 | 85 | c[2] = rotated | |
| 86 | - | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | |
| 86 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | |
| 87 | 87 | t.Fatalf("transformed overlay reason %q", reason) | |
| 88 | 88 | } | |
| 89 | 89 | _, c = overlayScene() | |
| 90 | 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 | 92 | t.Fatalf("reason %q", reason) | |
| 93 | 93 | } | |
| 94 | 94 | s, _ = overlayScene() | |
| 95 | 95 | s.Scale = 1.5 | |
| 96 | - | if _, _, reason := overlayCandidate(s, c); reason != "scaled" { | |
| 96 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "scaled" { | |
| 97 | 97 | t.Fatalf("reason %q", reason) | |
| 98 | 98 | } | |
| 99 | 99 | s, _ = overlayScene() | |
| 100 | 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 | 102 | t.Fatalf("reason %q", reason) | |
| 103 | 103 | } | |
| 104 | 104 | // The dim veil is composed under the float: nothing leaves for the plane. | |
| 105 | 105 | s, _ = overlayScene() | |
| 106 | 106 | s.Dim = 0.3 | |
| 107 | - | if _, _, reason := overlayCandidate(s, c); reason != "dim" { | |
| 107 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "dim" { | |
| 108 | 108 | t.Fatalf("dim reason %q", reason) | |
| 109 | 109 | } | |
| 110 | 110 | // A dimmed window (a peeking stashed one) needs its veil composed. | |
| 111 | 111 | s, _ = overlayScene() | |
| 112 | 112 | 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 | 114 | t.Fatalf("dimmed window reason %q", reason) | |
| 115 | 115 | } | |
| 116 | 116 | // A window with the focus effect needs it composed. | |
| 117 | 117 | s, _ = overlayScene() | |
| 118 | 118 | 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 | 120 | t.Fatalf("pulsing window reason %q", reason) | |
| 121 | 121 | } | |
| 122 | 122 | // An overview preview is drawn smaller than its buffer: composed. | |
| 123 | 123 | s, _ = overlayScene() | |
| 124 | 124 | 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 | 126 | t.Fatalf("preview reason %q", reason) | |
| 127 | 127 | } | |
| 128 | 128 | // A viewport crop is composed: the plane would show the whole buffer. | |
| @@ -130,13 +130,13 @@ func TestOverlayCandidate(t *testing.T) { | |||
| 130 | 130 | cropped := c[2] | |
| 131 | 131 | cropped.Source = [4]float32{10, 10, 50, 50} | |
| 132 | 132 | c[2] = cropped | |
| 133 | - | if _, _, reason := overlayCandidate(s, c); reason != "no_candidate" { | |
| 133 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "no_candidate" { | |
| 134 | 134 | t.Fatalf("cropped overlay reason %q", reason) | |
| 135 | 135 | } | |
| 136 | 136 | // A full-buffer source is no crop. | |
| 137 | 137 | cropped.Source = [4]float32{0, 0, 100, 100} | |
| 138 | 138 | c[2] = cropped | |
| 139 | - | if _, _, reason := overlayCandidate(s, c); reason != "" { | |
| 139 | + | if _, _, _, reason := overlayCandidate(s, c, false, nil); reason != "" { | |
| 140 | 140 | t.Fatalf("full source reason %q", reason) | |
| 141 | 141 | } | |
| 142 | 142 | } | |
| @@ -212,12 +212,12 @@ func TestOverlayTestRefusedFallsBack(t *testing.T) { | |||
| 212 | 212 | func TestOverlayPopupAndBorder(t *testing.T) { | |
| 213 | 213 | s, c := overlayScene() | |
| 214 | 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 | 216 | t.Fatalf("popup: %q", reason) | |
| 217 | 217 | } | |
| 218 | 218 | s, _ = overlayScene() | |
| 219 | 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 | 221 | t.Fatalf("border: %q", reason) | |
| 222 | 222 | } | |
| 223 | 223 | } | |
| @@ -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 = 1 | 73 | 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 = 1 | 84 | rotated.Transform = 1 | ||
| 85 | c[2] = rotated | 85 | 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.5 | 95 | 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.3 | 106 | 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.5 | 112 | 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.05 | 118 | 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.5 | 124 | 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] = cropped | 132 | 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] = cropped | 138 | 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 ( | |||
| 15 | 15 | ||
| 16 | 16 | objCrtc = 0xcccccccc | |
| 17 | 17 | objConnector = 0xc0c0c0c0 | |
| 18 | + | objColorop = 0xfafafafa // DRM_MODE_OBJECT_COLOROP | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | type objGetProps struct { | |
| @@ -61,40 +62,47 @@ func objProps(fd int, obj, typ uint32) (map[string][2]uint64, error) { | |||
| 61 | 62 | return out, nil | |
| 62 | 63 | } | |
| 63 | 64 | ||
| 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) { | |
| 71 | 67 | 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 | |
| 74 | 73 | } | |
| 75 | 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 | 77 | v := make([]uint64, max(p.countValues, 1)) | |
| 77 | 78 | p.enumBlobs = uint64(uintptr(unsafe.Pointer(&enums[0]))) | |
| 78 | 79 | 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 | |
| 81 | 82 | } | |
| 83 | + | out := make(map[string]uint64, p.countEn) | |
| 82 | 84 | 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 | |
| 95 | 86 | } | |
| 96 | 87 | runtime.KeepAlive(enums) | |
| 97 | 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 | 106 | return id, values | |
| 99 | 107 | } | |
| 100 | 108 | ||
| @@ -15,6 +15,7 @@ const ( | |||||
| 15 | 15 | ||||
| 16 | objCrtc = 0xcccccccc | 16 | objCrtc = 0xcccccccc | ||
| 17 | objConnector = 0xc0c0c0c0 | 17 | 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, nil | 62 | 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, values | 69 | + | 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, values | 81 | + | 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, values | 106 | return id, values | ||
| 99 | } | 107 | } | ||
| 100 | 108 | ||||
M internal/adapters/drm/protection.go +2 -0
| @@ -87,12 +87,14 @@ func (o *Output) disableProtected() error { | |||
| 87 | 87 | req.set(o.crtc, o.vrrProp, 0) | |
| 88 | 88 | req.set(o.conn.id, o.connCrtc, 0) | |
| 89 | 89 | o.hdrConnectorProps(req, false) | |
| 90 | + | o.forceBypass(req) | |
| 90 | 91 | if err := o.detachProtectedPlanes(req); err != nil { | |
| 91 | 92 | return protectedCommitError{err} | |
| 92 | 93 | } | |
| 93 | 94 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | |
| 94 | 95 | return protectedCommitError{fmt.Errorf("disable protected output: %w", err)} | |
| 95 | 96 | } | |
| 97 | + | o.colorBypassed() | |
| 96 | 98 | if o.modeBlob != 0 { | |
| 97 | 99 | _ = o.k.destroyBlob(o.modeBlob) // MODE_ID is 0 now: nothing references it | |
| 98 | 100 | 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 it | 99 | _ = o.k.destroyBlob(o.modeBlob) // MODE_ID is 0 now: nothing references it | ||
| 98 | o.modeBlob = 0 | 100 | o.modeBlob = 0 | ||
M internal/adapters/drm/scanout.go +34 -28
| @@ -197,23 +197,12 @@ func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) { | |||
| 197 | 197 | // scanoutFrame picks a fullscreen client buffer to flip directly. It | |
| 198 | 198 | // returns fb 0, with the reason logged on change, when the frame must be | |
| 199 | 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 | 201 | reason := "disabled" | |
| 202 | 202 | if o.scanout { | |
| 203 | 203 | 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) | |
| 217 | 206 | } | |
| 218 | 207 | } | |
| 219 | 208 | if reason == "" { | |
| @@ -224,7 +213,8 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por | |||
| 224 | 213 | if cfb.scaleRefused && cfb.scaleFailed == rect { | |
| 225 | 214 | reason = "scale_refused" | |
| 226 | 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 | 218 | o.primaryRectProps(req, fb, rect) | |
| 229 | 219 | if err := o.k.commit(req, atomicTestOnly, 0); err != nil { | |
| 230 | 220 | cfb.scaleFailed = rect | |
| @@ -236,27 +226,41 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por | |||
| 236 | 226 | 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 | 239 | o.setScanoutReason(reason) | |
| 242 | 240 | if reason != "" { | |
| 243 | - | return 0, c | |
| 241 | + | return 0, c, colorBypass | |
| 244 | 242 | } | |
| 245 | - | return fb, c | |
| 243 | + | return fb, c, mode | |
| 246 | 244 | } | |
| 247 | 245 | ||
| 248 | 246 | // overlayFrame decides the overlay of a frame: the window, and the scene | |
| 249 | 247 | // 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 | 251 | func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) { | |
| 252 | 252 | reason := "no_plane" | |
| 253 | 253 | 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 { | |
| 257 | 255 | var w ports.SceneWindow | |
| 258 | 256 | 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 | 264 | if reason == "" { | |
| 261 | 265 | var fb uint32 | |
| 262 | 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 | 271 | ov = overlayWin{id: w.ID, fb: fb, buf: c.DMABuf.ID, w: c.Width, h: c.Height, | |
| 268 | 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 | 277 | o.setOverlayReason(reason) | |
| 274 | - | if ov.fb == 0 { | |
| 278 | + | if ov.id == 0 { | |
| 275 | 279 | return overlayWin{}, s | |
| 276 | 280 | } | |
| 277 | 281 | // The composed frame leaves the window out: the overlay shows it. | |
| 278 | 282 | rest := s | |
| 279 | - | rest.Windows = make([]ports.SceneWindow, 0, len(s.Windows)) | |
| 283 | + | o.restWindows = o.restWindows[:0] | |
| 280 | 284 | for _, w := range s.Windows { | |
| 281 | 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 | 290 | return ov, rest | |
| 286 | 291 | } | |
| 287 | 292 | ||
| @@ -290,6 +295,7 @@ type frameDecision struct { | |||
| 290 | 295 | fb uint32 | |
| 291 | 296 | content ports.SurfaceContent | |
| 292 | 297 | rect planeRect | |
| 298 | + | color colorMode // how the primary plane shows fb | |
| 293 | 299 | overlay overlayWin | |
| 294 | 300 | composed ports.Scene | |
| 295 | 301 | } | |
| @@ -312,9 +318,9 @@ func (o *Output) decideFrame(s ports.Scene, surfaces map[ports.WindowID]ports.Su | |||
| 312 | 318 | o.setOverlayReason("capture") | |
| 313 | 319 | return frameDecision{composed: s} | |
| 314 | 320 | } | |
| 315 | - | fb, c := o.scanoutFrame(s, surfaces) | |
| 321 | + | fb, c, mode := o.scanoutFrame(s, surfaces) | |
| 316 | 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 | 325 | return o.composeFrame(s, surfaces) | |
| 320 | 326 | } | |
| @@ -197,23 +197,12 @@ func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) { | |||||
| 197 | // scanoutFrame picks a fullscreen client buffer to flip directly. It | 197 | // scanoutFrame picks a fullscreen client buffer to flip directly. It | ||
| 198 | // returns fb 0, with the reason logged on change, when the frame must be | 198 | // 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 = rect | 220 | cfb.scaleFailed = rect | ||
| @@ -236,27 +226,41 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por | |||||
| 236 | cfb.scaleTestedOK = true | 226 | 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, c | 241 | + | return 0, c, colorBypass |
| 244 | } | 242 | } | ||
| 245 | - | return fb, c | 243 | + | return fb, c, mode |
| 246 | } | 244 | } | ||
| 247 | 245 | ||||
| 248 | // overlayFrame decides the overlay of a frame: the window, and the scene | 246 | // overlayFrame decides the overlay of a frame: the window, and the scene | ||
| 249 | // the renderer composes (the window left out). A zero overlayWin means | 247 | // 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 overlayWin | 253 | 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.SceneWindow | 255 | var w ports.SceneWindow | ||
| 258 | var c ports.SurfaceContent | 256 | 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 uint32 | 265 | 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{}, s | 279 | 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 := s | 282 | 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, rest | 290 | return ov, rest | ||
| 286 | } | 291 | } | ||
| 287 | 292 | ||||
| @@ -290,6 +295,7 @@ type frameDecision struct { | |||||
| 290 | fb uint32 | 295 | fb uint32 | ||
| 291 | content ports.SurfaceContent | 296 | content ports.SurfaceContent | ||
| 292 | rect planeRect | 297 | rect planeRect | ||
| 298 | + | color colorMode // how the primary plane shows fb | |||
| 293 | overlay overlayWin | 299 | overlay overlayWin | ||
| 294 | composed ports.Scene | 300 | 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) { | |||
| 247 | 247 | c := ports.SurfaceContent{ID: 1, Width: 200, Height: 100, LogicalW: 200, LogicalH: 100, DMABuf: &ports.DMABuf{ID: 11, Format: fourccXR30}} | |
| 248 | 248 | check := func(want string) { | |
| 249 | 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 | 251 | if o.reason != want || (fb != 0) != (want == "") { | |
| 252 | 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 | 384 | if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 { | |
| 385 | 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 | 412 | 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 { | |||
| 71 | 71 | devices map[int32]int32 // fd → libseat device id | |
| 72 | 72 | subs []chan bool | |
| 73 | 73 | 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 | 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 | 103 | return nil, errors.New("seat already open") | |
| 83 | 104 | } | |
| 84 | 105 | s := &Seat{devices: make(map[int32]int32), log: log} | |
| 106 | + | s.disabler = libseatDisabler{s} | |
| 85 | 107 | current = s | |
| 86 | 108 | s.mu.Lock() | |
| 87 | 109 | defer s.mu.Unlock() | |
| @@ -105,8 +127,13 @@ func (s *Seat) changed(active bool) { | |||
| 105 | 127 | s.active = active | |
| 106 | 128 | s.log.Info().Bool("active", active).Msg("seat") | |
| 107 | 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 | 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 | 138 | for _, c := range s.subs { | |
| 112 | 139 | select { | |
| @@ -71,6 +71,27 @@ type Seat struct { | |||||
| 71 | devices map[int32]int32 // fd → libseat device id | 71 | devices map[int32]int32 // fd → libseat device id | ||
| 72 | subs []chan bool | 72 | subs []chan bool | ||
| 73 | log zerowrap.Logger | 73 | 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 = s | 107 | 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 = active | 127 | 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 ( | |||
| 11 | 11 | "slices" | |
| 12 | 12 | "sort" | |
| 13 | 13 | "sync" | |
| 14 | + | "sync/atomic" | |
| 14 | 15 | "time" | |
| 15 | 16 | ||
| 16 | 17 | "github.com/bnema/neferwl/internal/adapters/clock" | |
| @@ -115,6 +116,62 @@ type drmBackend struct { | |||
| 115 | 116 | seat *seat.Seat | |
| 116 | 117 | cards []*drmCard | |
| 117 | 118 | 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 | 177 | type drmCard struct { | |
| @@ -165,7 +222,8 @@ func openDRM(ctx context.Context, cfg ports.Config) (*drmBackend, error) { | |||
| 165 | 222 | want.TraceFlips = logging.Enabled(ctx, "drm-flip") | |
| 166 | 223 | paths, _ := filepath.Glob("/dev/dri/card[0-9]*") | |
| 167 | 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 | 227 | var errs []error | |
| 170 | 228 | for _, path := range paths { | |
| 171 | 229 | fd, err := s.OpenDevice(path) | |
| @@ -483,6 +541,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 483 | 541 | o.Security, o.SecurityChanges, o.Instance = ch.security, secure, instance | |
| 484 | 542 | return safe("output "+name, func() error { | |
| 485 | 543 | defer o.Close() | |
| 544 | + | b.addPreparer(o) | |
| 545 | + | defer b.removePreparer(o) // before Close: it handles no request any more | |
| 486 | 546 | if realtime { | |
| 487 | 547 | runtime.LockOSThread() | |
| 488 | 548 | // 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.Seat | 116 | seat *seat.Seat | ||
| 116 | cards []*drmCard | 117 | cards []*drmCard | ||
| 117 | clock ports.Clock | 118 | 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 []error | 227 | 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, instance | 541 | 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 RT | 548 | // 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 ( | |||
| 127 | 127 | ColorTFExtendedLinear uint8 = 5 // wp_color_manager_v1 ext_linear | |
| 128 | 128 | ColorPrimariesBT2020 uint8 = 6 // wp_color_manager_v1 BT.2020 | |
| 129 | 129 | ColorPrimariesSRGB uint8 = 1 | |
| 130 | + | ColorTFSRGB uint8 = 9 // wp_color_manager_v1 sRGB | |
| 130 | 131 | ) | |
| 131 | 132 | ||
| 132 | 133 | func (c SurfaceColor) IsPQ2020() bool { | |
| @@ -127,6 +127,7 @@ const ( | |||||
| 127 | ColorTFExtendedLinear uint8 = 5 // wp_color_manager_v1 ext_linear | 127 | ColorTFExtendedLinear uint8 = 5 // wp_color_manager_v1 ext_linear | ||
| 128 | ColorPrimariesBT2020 uint8 = 6 // wp_color_manager_v1 BT.2020 | 128 | ColorPrimariesBT2020 uint8 = 6 // wp_color_manager_v1 BT.2020 | ||
| 129 | ColorPrimariesSRGB uint8 = 1 | 129 | 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 { | ||