detached a5f1806

fix(drm): allocate hardware cursor images as KMS dumb buffers

nvidia-drm refuses the cursor plane with the linear images Vulkan
allocates and exports, which leaves NVIDIA on the software cursor. The
cursor images are now KMS dumb buffers written by the CPU through their
mapping. Vulkan cursor images stay the fallback when dumb buffers fail
or the plane refuses them, then the software cursor.

The HDR cursor conversion moves to internal/adapters/hdrcursor so the
drm adapter writes the same pixels the renderer did.

Fixes neferwl#22.
commit
a5f1806e4c7593621ea5e50f34cd9c102fe5ed8b
author
brice · · 2026-10-22 13:10 UTC
parent
3c13b3d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
22 files · +1553 -150
M .mockery.yml +5 -0
@@ -44,6 +44,11 @@ packages:
4444 dir: internal/adapters/drm
4545 pkgname: drm
4646 filename: image_buffer_mock_test.go
47+ cursorImages:
48+ config:
49+ dir: internal/adapters/drm
50+ pkgname: drm
51+ filename: cursor_images_mock_test.go
4752 github.com/bnema/neferwl/internal/adapters/seat:
4853 interfaces:
4954 disabler:
@@ -44,6 +44,11 @@ packages:
44 dir: internal/adapters/drm44 dir: internal/adapters/drm
45 pkgname: drm45 pkgname: drm
46 filename: image_buffer_mock_test.go46 filename: image_buffer_mock_test.go
47+ cursorImages:
48+ config:
49+ dir: internal/adapters/drm
50+ pkgname: drm
51+ filename: cursor_images_mock_test.go
47 github.com/bnema/neferwl/internal/adapters/seat:52 github.com/bnema/neferwl/internal/adapters/seat:
48 interfaces:53 interfaces:
49 disabler:54 disabler:
M Makefile +1 -1
@@ -61,7 +61,7 @@ arch: adapter-imports
6161 # Adapters stay independent: production code may import only the shared
6262 # adapter libraries below. outputkit holds what both output backends (drm,
6363 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
64+ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|hdrcursor|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
6565 adapter-imports:
6666 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
6767 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
@@ -61,7 +61,7 @@ arch: adapter-imports
61 # Adapters stay independent: production code may import only the shared61 # Adapters stay independent: production code may import only the shared
62 # adapter libraries below. outputkit holds what both output backends (drm,62 # adapter libraries below. outputkit holds what both output backends (drm,
63 # headless) run; the others are leaf helpers.63 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb64+ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|hdrcursor|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
65 adapter-imports:65 adapter-imports:
66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
M docs/dev/architecture.md +7 -0
@@ -32,6 +32,13 @@ A DRM output flips between two images the renderer draws into, with no CPU copy.
3232 - **Fallback:** when libgbm is missing, GBM allocates nothing usable, or Vulkan or KMS refuses the images, the renderer allocates and exports them itself. Each kind (driver modifier, single-plane, linear) tries GBM, then the renderer. The log line `zero-copy output` names the allocator in its `alloc` field (`gbm` or `vulkan`), and is a warning for `vulkan`. This fallback is deprecated and goes away once GBM is validated on more setups (neferwl#23).
3333 - **Sync:** nvidia-drm before 560.35.3 does not wait for `IN_FENCE_FD`. On it, the output waits for each frame's fence on the CPU and commits without one. A client buffer whose fence is still pending is composed, not scanned out, while `IN_FENCE_FD` is unavailable.
3434
35+### Cursor images
36+
37+The cursor plane shows the hardware cursor from two ARGB8888 images that the CPU writes only when the cursor image changes, never on a move.
38+
39+- **Allocation:** the images are KMS dumb buffers, mapped for the CPU, with the pitch the driver picks. The output converts them to HDR10 when it is in HDR.
40+- **Fallback:** when KMS cannot make dumb buffers, or the plane refuses them in a TEST_ONLY commit, the renderer's linear cursor images are used. When the plane refuses those too, the cursor is drawn into every frame (software cursor). The log line `hardware cursor` names the allocator in its `alloc` field (`dumb` or `vulkan`), and is a warning for `vulkan`.
41+
3542 I also like bringing more tools to the Go ecosystem. NeferWL grew its own libraries along the way, and other Go projects can use them:
3643
3744 - [purego-libwayland](https://github.com/bnema/purego-libwayland): libwayland-server without cgo.
@@ -32,6 +32,13 @@ A DRM output flips between two images the renderer draws into, with no CPU copy.
32 - **Fallback:** when libgbm is missing, GBM allocates nothing usable, or Vulkan or KMS refuses the images, the renderer allocates and exports them itself. Each kind (driver modifier, single-plane, linear) tries GBM, then the renderer. The log line `zero-copy output` names the allocator in its `alloc` field (`gbm` or `vulkan`), and is a warning for `vulkan`. This fallback is deprecated and goes away once GBM is validated on more setups (neferwl#23).32 - **Fallback:** when libgbm is missing, GBM allocates nothing usable, or Vulkan or KMS refuses the images, the renderer allocates and exports them itself. Each kind (driver modifier, single-plane, linear) tries GBM, then the renderer. The log line `zero-copy output` names the allocator in its `alloc` field (`gbm` or `vulkan`), and is a warning for `vulkan`. This fallback is deprecated and goes away once GBM is validated on more setups (neferwl#23).
33 - **Sync:** nvidia-drm before 560.35.3 does not wait for `IN_FENCE_FD`. On it, the output waits for each frame's fence on the CPU and commits without one. A client buffer whose fence is still pending is composed, not scanned out, while `IN_FENCE_FD` is unavailable.33 - **Sync:** nvidia-drm before 560.35.3 does not wait for `IN_FENCE_FD`. On it, the output waits for each frame's fence on the CPU and commits without one. A client buffer whose fence is still pending is composed, not scanned out, while `IN_FENCE_FD` is unavailable.
34 34
35+### Cursor images
36+
37+The cursor plane shows the hardware cursor from two ARGB8888 images that the CPU writes only when the cursor image changes, never on a move.
38+
39+- **Allocation:** the images are KMS dumb buffers, mapped for the CPU, with the pitch the driver picks. The output converts them to HDR10 when it is in HDR.
40+- **Fallback:** when KMS cannot make dumb buffers, or the plane refuses them in a TEST_ONLY commit, the renderer's linear cursor images are used. When the plane refuses those too, the cursor is drawn into every frame (software cursor). The log line `hardware cursor` names the allocator in its `alloc` field (`dumb` or `vulkan`), and is a warning for `vulkan`.
41+
35 I also like bringing more tools to the Go ecosystem. NeferWL grew its own libraries along the way, and other Go projects can use them:42 I also like bringing more tools to the Go ecosystem. NeferWL grew its own libraries along the way, and other Go projects can use them:
36 43
37 - [purego-libwayland](https://github.com/bnema/purego-libwayland): libwayland-server without cgo.44 - [purego-libwayland](https://github.com/bnema/purego-libwayland): libwayland-server without cgo.
M internal/adapters/drm/atomic.go +9 -0
@@ -89,6 +89,15 @@ type kms interface {
8989 // addFB imports a single-plane dmabuf as a framebuffer of format.
9090 addFB(b *ports.DMABuf, format uint32) (uint32, error)
9191 rmFB(id uint32) error
92+ // createDumb allocates a width×height 32 bpp dumb buffer; mapDumb maps it
93+ // for the CPU (read/write) and unmapDumb releases the mapping;
94+ // addFBHandle makes a framebuffer of format from it; destroyDumb frees
95+ // it. The framebuffer is removed with rmFB first.
96+ createDumb(width, height int) (dumbBuffer, error)
97+ mapDumb(b dumbBuffer) ([]byte, error)
98+ unmapDumb(mem []byte) error
99+ addFBHandle(b dumbBuffer, width, height int, format uint32) (uint32, error)
100+ destroyDumb(handle uint32) error
92101 createLease(objects []uint32) (*os.File, uint32, error)
93102 revokeLease(id uint32) error
94103 resources() ([]uint32, []uint32, error)
@@ -89,6 +89,15 @@ type kms interface {
89 // addFB imports a single-plane dmabuf as a framebuffer of format.89 // addFB imports a single-plane dmabuf as a framebuffer of format.
90 addFB(b *ports.DMABuf, format uint32) (uint32, error)90 addFB(b *ports.DMABuf, format uint32) (uint32, error)
91 rmFB(id uint32) error91 rmFB(id uint32) error
92+ // createDumb allocates a width×height 32 bpp dumb buffer; mapDumb maps it
93+ // for the CPU (read/write) and unmapDumb releases the mapping;
94+ // addFBHandle makes a framebuffer of format from it; destroyDumb frees
95+ // it. The framebuffer is removed with rmFB first.
96+ createDumb(width, height int) (dumbBuffer, error)
97+ mapDumb(b dumbBuffer) ([]byte, error)
98+ unmapDumb(mem []byte) error
99+ addFBHandle(b dumbBuffer, width, height int, format uint32) (uint32, error)
100+ destroyDumb(handle uint32) error
92 createLease(objects []uint32) (*os.File, uint32, error)101 createLease(objects []uint32) (*os.File, uint32, error)
93 revokeLease(id uint32) error102 revokeLease(id uint32) error
94 resources() ([]uint32, []uint32, error)103 resources() ([]uint32, []uint32, error)
M internal/adapters/drm/commit_test.go +8 -6
@@ -55,9 +55,10 @@ func TestBusyFrameCommitsAfterEvent(t *testing.T) {
5555 func TestCursorImageNeverOverwritesScreen(t *testing.T) {
5656 o, _, _ := testOutput(t)
5757 c := o.cursor
58- r := portsmocks.NewMockRenderer(t)
58+ imgs := newMockcursorImages(t)
59+ c.imgs = imgs
5960 var written []int
60- r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {
61+ imgs.EXPECT().write(mock.Anything, mock.Anything, 1, 1, float64(0)).RunAndReturn(func(i int, _ []byte, _, _ int, _ float64) error {
6162 written = append(written, i)
6263 return nil
6364 })
@@ -65,7 +66,7 @@ func TestCursorImageNeverOverwritesScreen(t *testing.T) {
6566 // Image 0 on screen: two changes before a commit both go to image 1.
6667 c.screen, c.cur, c.image = 90, 0, true
6768 for range 2 {
68- if err := c.setImage(r, px, 1, 1, 0, 0); err != nil {
69+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil {
6970 t.Fatal(err)
7071 }
7172 }
@@ -74,12 +75,12 @@ func TestCursorImageNeverOverwritesScreen(t *testing.T) {
7475 }
7576 // Image 1 in flight, image 0 on screen: nothing is free.
7677 c.committed(cursorState{on: true, fb: 91})
77- if err := c.setImage(r, px, 1, 1, 0, 0); err != nil || len(written) != 2 || c.later == nil {
78+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil || len(written) != 2 || c.later == nil {
7879 t.Fatalf("wrote a busy image: %v %v", err, written)
7980 }
8081 // The flip frees image 0.
8182 c.landed()
82- if changed, err := c.flushLater(r); !changed || err != nil || len(written) != 3 || written[2] != 0 || c.cur != 0 {
83+ if changed, err := c.flushLater(0); !changed || err != nil || len(written) != 3 || written[2] != 0 || c.cur != 0 {
8384 t.Fatalf("after flip: %v %v %v cur %d", changed, err, written, c.cur)
8485 }
8586 }
@@ -464,7 +465,7 @@ func TestCursorOffSkipsLoads(t *testing.T) {
464465 o, _, _ := testOutput(t)
465466 o.cursor.off = true
466467 loaded := false
467- o.setCursor(nil, func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) {
468+ o.setCursor(func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) {
468469 loaded = true
469470 return ports.CursorImage{}, nil
470471 }, ports.CursorChange{}, 1, 0)
@@ -497,6 +498,7 @@ func runReportsSeenAfterCursorCommit(t *testing.T) {
497498 r.EXPECT().UseTarget(mock.Anything).Return()
498499 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
499500 r.EXPECT().Close().Return().Once()
501+ k.EXPECT().createDumb(64, 64).Return(dumbBuffer{}, unix.ENOSYS).Once() // renderer images
500502 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(90, nil).Once()
501503 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(91, nil).Once()
502504 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
@@ -55,9 +55,10 @@ func TestBusyFrameCommitsAfterEvent(t *testing.T) {
55 func TestCursorImageNeverOverwritesScreen(t *testing.T) {55 func TestCursorImageNeverOverwritesScreen(t *testing.T) {
56 o, _, _ := testOutput(t)56 o, _, _ := testOutput(t)
57 c := o.cursor57 c := o.cursor
58- r := portsmocks.NewMockRenderer(t)58+ imgs := newMockcursorImages(t)
59+ c.imgs = imgs
59 var written []int60 var written []int
60- r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {61+ imgs.EXPECT().write(mock.Anything, mock.Anything, 1, 1, float64(0)).RunAndReturn(func(i int, _ []byte, _, _ int, _ float64) error {
61 written = append(written, i)62 written = append(written, i)
62 return nil63 return nil
63 })64 })
@@ -65,7 +66,7 @@ func TestCursorImageNeverOverwritesScreen(t *testing.T) {
65 // Image 0 on screen: two changes before a commit both go to image 1.66 // Image 0 on screen: two changes before a commit both go to image 1.
66 c.screen, c.cur, c.image = 90, 0, true67 c.screen, c.cur, c.image = 90, 0, true
67 for range 2 {68 for range 2 {
68- if err := c.setImage(r, px, 1, 1, 0, 0); err != nil {69+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil {
69 t.Fatal(err)70 t.Fatal(err)
70 }71 }
71 }72 }
@@ -74,12 +75,12 @@ func TestCursorImageNeverOverwritesScreen(t *testing.T) {
74 }75 }
75 // Image 1 in flight, image 0 on screen: nothing is free.76 // Image 1 in flight, image 0 on screen: nothing is free.
76 c.committed(cursorState{on: true, fb: 91})77 c.committed(cursorState{on: true, fb: 91})
77- if err := c.setImage(r, px, 1, 1, 0, 0); err != nil || len(written) != 2 || c.later == nil {78+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil || len(written) != 2 || c.later == nil {
78 t.Fatalf("wrote a busy image: %v %v", err, written)79 t.Fatalf("wrote a busy image: %v %v", err, written)
79 }80 }
80 // The flip frees image 0.81 // The flip frees image 0.
81 c.landed()82 c.landed()
82- if changed, err := c.flushLater(r); !changed || err != nil || len(written) != 3 || written[2] != 0 || c.cur != 0 {83+ if changed, err := c.flushLater(0); !changed || err != nil || len(written) != 3 || written[2] != 0 || c.cur != 0 {
83 t.Fatalf("after flip: %v %v %v cur %d", changed, err, written, c.cur)84 t.Fatalf("after flip: %v %v %v cur %d", changed, err, written, c.cur)
84 }85 }
85 }86 }
@@ -464,7 +465,7 @@ func TestCursorOffSkipsLoads(t *testing.T) {
464 o, _, _ := testOutput(t)465 o, _, _ := testOutput(t)
465 o.cursor.off = true466 o.cursor.off = true
466 loaded := false467 loaded := false
467- o.setCursor(nil, func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) {468+ o.setCursor(func(ports.CursorChange, float64, ports.BufferTransform, int) (ports.CursorImage, error) {
468 loaded = true469 loaded = true
469 return ports.CursorImage{}, nil470 return ports.CursorImage{}, nil
470 }, ports.CursorChange{}, 1, 0)471 }, ports.CursorChange{}, 1, 0)
@@ -497,6 +498,7 @@ func runReportsSeenAfterCursorCommit(t *testing.T) {
497 r.EXPECT().UseTarget(mock.Anything).Return()498 r.EXPECT().UseTarget(mock.Anything).Return()
498 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)499 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
499 r.EXPECT().Close().Return().Once()500 r.EXPECT().Close().Return().Once()
501+ k.EXPECT().createDumb(64, 64).Return(dumbBuffer{}, unix.ENOSYS).Once() // renderer images
500 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(90, nil).Once()502 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(90, nil).Once()
501 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(91, nil).Once()503 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(91, nil).Once()
502 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()504 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
M internal/adapters/drm/cursor.go +73 -29
@@ -31,14 +31,24 @@ type Cursor struct {
3131 wake chan struct{}
3232
3333 // Owned by the output goroutine.
34- plane *plane
35- size int
36- fbs [2]uint32 // the two images; cur is on screen or next
34+ plane *plane
35+ size int
36+ // imgs are the two images (nil: none), alloc says who allocated them
37+ // (allocDumb or allocVulkan) and fallback makes the renderer's images
38+ // when KMS refuses the dumb ones. fbs mirrors their framebuffers; cur
39+ // is on screen or next.
40+ imgs cursorImages
41+ alloc string
42+ fallback func() (cursorImages, error)
43+ fbs [2]uint32
3744 cur int
3845 image bool // an image is loaded
3946 hotX, hotY int
40- applied cursorState // what the last commit put on the plane
41- commits int
47+ // last is a copy of the loaded image, written again into new images
48+ // when the allocator changes.
49+ last laterImage
50+ applied cursorState // what the last commit put on the plane
51+ commits int
4252 // screen is the image the plane scans out (0: none); flight is what
4353 // the pending commit puts on it when flying. An image is written only
4454 // when it is neither, so the plane never shows a half-written image.
@@ -111,37 +121,70 @@ func (c *Cursor) poke() {
111121 }
112122 }
113123
114-// setup allocates the two cursor images and their framebuffers.
115-func (c *Cursor) setup(k kms, r ports.Renderer) error {
116- bufs, err := r.CursorBuffers(c.size)
124+// setup allocates the two cursor images and their framebuffers: KMS dumb
125+// buffers first, then the renderer's images. dumbErr is why the dumb buffers
126+// were not used (nil when they are); err is set when no images exist.
127+func (c *Cursor) setup(k kms, r ports.Renderer) (dumbErr, err error) {
128+ c.fallback = func() (cursorImages, error) { return newRendererCursor(k, r, c.size) }
129+ d, dumbErr := newDumbCursor(k, c.size)
130+ if dumbErr == nil {
131+ c.install(d, allocDumb)
132+ return nil, nil
133+ }
134+ imgs, err := c.fallback()
117135 if err != nil {
118- return err
136+ return dumbErr, err
119137 }
120- for i := range bufs {
121- if err == nil {
122- c.fbs[i], err = k.addFB(&bufs[i], fourccARGB)
123- }
124- for _, p := range bufs[i].Planes {
125- p.File.Close()
126- }
138+ c.install(imgs, allocVulkan)
139+ return dumbErr, nil
140+}
141+
142+// install makes imgs the cursor images; none is on screen or in flight.
143+func (c *Cursor) install(imgs cursorImages, alloc string) {
144+ c.imgs, c.alloc = imgs, alloc
145+ for i := range c.fbs {
146+ c.fbs[i] = imgs.fb(i)
147+ }
148+ c.cur = 0
149+ c.screen, c.flight, c.flying, c.applied = 0, cursorState{}, false, cursorState{}
150+}
151+
152+// useFallback replaces refused dumb images with the renderer's and writes
153+// the current image into them. It reports false when it cannot: no fallback,
154+// already used, or no images either.
155+func (c *Cursor) useFallback(hdrNits float64) (bool, error) {
156+ if c.alloc != allocDumb || c.fallback == nil {
157+ return false, nil
127158 }
159+ imgs, err := c.fallback()
128160 if err != nil {
129- c.free(k)
161+ return false, err
162+ }
163+ c.imgs.close()
164+ c.install(imgs, allocVulkan)
165+ l := c.later
166+ if l == nil && c.image {
167+ l = &c.last
130168 }
131- return err
169+ c.image, c.later = false, nil
170+ if l == nil {
171+ return true, nil
172+ }
173+ return true, c.setImage(l.pixels, l.w, l.h, l.hotX, l.hotY, hdrNits)
132174 }
133175
134176 // setImage loads premultiplied ARGB8888 pixels into an image neither on
135177 // screen nor in flight; when there is none it waits for the next commit
136178 // event (flushLater). An empty image hides the cursor (a client asked for
137179 // none).
138-func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int) error {
180+// hdrNits is the SDR white of an HDR output (0: SDR).
181+func (c *Cursor) setImage(pixels []byte, w, h, hotX, hotY int, hdrNits float64) error {
139182 c.later = nil
140183 if w <= 0 || h <= 0 || len(pixels) < w*h*4 {
141184 c.image = false
142185 return nil
143186 }
144- if c.fbs[0] == 0 {
187+ if c.imgs == nil {
145188 return errors.New("no cursor images")
146189 }
147190 next := c.freeSlot()
@@ -149,10 +192,11 @@ func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int)
149192 c.later = &laterImage{pixels: slices.Clone(pixels[:w*h*4]), w: w, h: h, hotX: hotX, hotY: hotY}
150193 return nil
151194 }
152- if err := r.WriteCursor(next, pixels, w, h); err != nil {
195+ if err := c.imgs.write(next, pixels, w, h, hdrNits); err != nil {
153196 return err
154197 }
155198 c.cur, c.image, c.hotX, c.hotY = next, true, hotX, hotY
199+ c.last = laterImage{pixels: append(c.last.pixels[:0], pixels[:w*h*4]...), w: w, h: h, hotX: hotX, hotY: hotY}
156200 return nil
157201 }
158202
@@ -169,12 +213,12 @@ func (c *Cursor) freeSlot() int {
169213
170214 // flushLater loads an image that waited for a free slot. It reports
171215 // whether the cursor changed.
172-func (c *Cursor) flushLater(r ports.Renderer) (bool, error) {
216+func (c *Cursor) flushLater(hdrNits float64) (bool, error) {
173217 l := c.later
174218 if l == nil || c.freeSlot() < 0 {
175219 return false, nil
176220 }
177- return true, c.setImage(r, l.pixels, l.w, l.h, l.hotX, l.hotY)
221+ return true, c.setImage(l.pixels, l.w, l.h, l.hotX, l.hotY, hdrNits)
178222 }
179223
180224 // committed records that a commit carrying state s is in flight.
@@ -229,13 +273,13 @@ func (c *Cursor) props(req *atomicReq, crtc uint32, s cursorState) {
229273 req.set(p.id, p.prop("CRTC_H"), size)
230274 }
231275
232-func (c *Cursor) free(k kms) {
233- for i, fb := range c.fbs {
234- if fb != 0 {
235- _ = k.rmFB(fb)
236- c.fbs[i] = 0
237- }
276+// free removes the framebuffers and releases the images.
277+func (c *Cursor) free() {
278+ if c.imgs != nil {
279+ c.imgs.close()
280+ c.imgs, c.alloc = nil, ""
238281 }
282+ c.fbs = [2]uint32{}
239283 c.image, c.later = false, nil
240284 c.screen, c.flying = 0, false
241285 }
@@ -31,14 +31,24 @@ type Cursor struct {
31 wake chan struct{}31 wake chan struct{}
32 32
33 // Owned by the output goroutine.33 // Owned by the output goroutine.
34- plane *plane34+ plane *plane
35- size int35+ size int
36- fbs [2]uint32 // the two images; cur is on screen or next36+ // imgs are the two images (nil: none), alloc says who allocated them
37+ // (allocDumb or allocVulkan) and fallback makes the renderer's images
38+ // when KMS refuses the dumb ones. fbs mirrors their framebuffers; cur
39+ // is on screen or next.
40+ imgs cursorImages
41+ alloc string
42+ fallback func() (cursorImages, error)
43+ fbs [2]uint32
37 cur int44 cur int
38 image bool // an image is loaded45 image bool // an image is loaded
39 hotX, hotY int46 hotX, hotY int
40- applied cursorState // what the last commit put on the plane47+ // last is a copy of the loaded image, written again into new images
41- commits int48+ // when the allocator changes.
49+ last laterImage
50+ applied cursorState // what the last commit put on the plane
51+ commits int
42 // screen is the image the plane scans out (0: none); flight is what52 // screen is the image the plane scans out (0: none); flight is what
43 // the pending commit puts on it when flying. An image is written only53 // the pending commit puts on it when flying. An image is written only
44 // when it is neither, so the plane never shows a half-written image.54 // when it is neither, so the plane never shows a half-written image.
@@ -111,37 +121,70 @@ func (c *Cursor) poke() {
111 }121 }
112 }122 }
113 123
114-// setup allocates the two cursor images and their framebuffers.124+// setup allocates the two cursor images and their framebuffers: KMS dumb
115-func (c *Cursor) setup(k kms, r ports.Renderer) error {125+// buffers first, then the renderer's images. dumbErr is why the dumb buffers
116- bufs, err := r.CursorBuffers(c.size)126+// were not used (nil when they are); err is set when no images exist.
127+func (c *Cursor) setup(k kms, r ports.Renderer) (dumbErr, err error) {
128+ c.fallback = func() (cursorImages, error) { return newRendererCursor(k, r, c.size) }
129+ d, dumbErr := newDumbCursor(k, c.size)
130+ if dumbErr == nil {
131+ c.install(d, allocDumb)
132+ return nil, nil
133+ }
134+ imgs, err := c.fallback()
117 if err != nil {135 if err != nil {
118- return err136+ return dumbErr, err
119 }137 }
120- for i := range bufs {138+ c.install(imgs, allocVulkan)
121- if err == nil {139+ return dumbErr, nil
122- c.fbs[i], err = k.addFB(&bufs[i], fourccARGB)140+}
123- }141+
124- for _, p := range bufs[i].Planes {142+// install makes imgs the cursor images; none is on screen or in flight.
125- p.File.Close()143+func (c *Cursor) install(imgs cursorImages, alloc string) {
126- }144+ c.imgs, c.alloc = imgs, alloc
145+ for i := range c.fbs {
146+ c.fbs[i] = imgs.fb(i)
147+ }
148+ c.cur = 0
149+ c.screen, c.flight, c.flying, c.applied = 0, cursorState{}, false, cursorState{}
150+}
151+
152+// useFallback replaces refused dumb images with the renderer's and writes
153+// the current image into them. It reports false when it cannot: no fallback,
154+// already used, or no images either.
155+func (c *Cursor) useFallback(hdrNits float64) (bool, error) {
156+ if c.alloc != allocDumb || c.fallback == nil {
157+ return false, nil
127 }158 }
159+ imgs, err := c.fallback()
128 if err != nil {160 if err != nil {
129- c.free(k)161+ return false, err
162+ }
163+ c.imgs.close()
164+ c.install(imgs, allocVulkan)
165+ l := c.later
166+ if l == nil && c.image {
167+ l = &c.last
130 }168 }
131- return err169+ c.image, c.later = false, nil
170+ if l == nil {
171+ return true, nil
172+ }
173+ return true, c.setImage(l.pixels, l.w, l.h, l.hotX, l.hotY, hdrNits)
132 }174 }
133 175
134 // setImage loads premultiplied ARGB8888 pixels into an image neither on176 // setImage loads premultiplied ARGB8888 pixels into an image neither on
135 // screen nor in flight; when there is none it waits for the next commit177 // screen nor in flight; when there is none it waits for the next commit
136 // event (flushLater). An empty image hides the cursor (a client asked for178 // event (flushLater). An empty image hides the cursor (a client asked for
137 // none).179 // none).
138-func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int) error {180+// hdrNits is the SDR white of an HDR output (0: SDR).
181+func (c *Cursor) setImage(pixels []byte, w, h, hotX, hotY int, hdrNits float64) error {
139 c.later = nil182 c.later = nil
140 if w <= 0 || h <= 0 || len(pixels) < w*h*4 {183 if w <= 0 || h <= 0 || len(pixels) < w*h*4 {
141 c.image = false184 c.image = false
142 return nil185 return nil
143 }186 }
144- if c.fbs[0] == 0 {187+ if c.imgs == nil {
145 return errors.New("no cursor images")188 return errors.New("no cursor images")
146 }189 }
147 next := c.freeSlot()190 next := c.freeSlot()
@@ -149,10 +192,11 @@ func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int)
149 c.later = &laterImage{pixels: slices.Clone(pixels[:w*h*4]), w: w, h: h, hotX: hotX, hotY: hotY}192 c.later = &laterImage{pixels: slices.Clone(pixels[:w*h*4]), w: w, h: h, hotX: hotX, hotY: hotY}
150 return nil193 return nil
151 }194 }
152- if err := r.WriteCursor(next, pixels, w, h); err != nil {195+ if err := c.imgs.write(next, pixels, w, h, hdrNits); err != nil {
153 return err196 return err
154 }197 }
155 c.cur, c.image, c.hotX, c.hotY = next, true, hotX, hotY198 c.cur, c.image, c.hotX, c.hotY = next, true, hotX, hotY
199+ c.last = laterImage{pixels: append(c.last.pixels[:0], pixels[:w*h*4]...), w: w, h: h, hotX: hotX, hotY: hotY}
156 return nil200 return nil
157 }201 }
158 202
@@ -169,12 +213,12 @@ func (c *Cursor) freeSlot() int {
169 213
170 // flushLater loads an image that waited for a free slot. It reports214 // flushLater loads an image that waited for a free slot. It reports
171 // whether the cursor changed.215 // whether the cursor changed.
172-func (c *Cursor) flushLater(r ports.Renderer) (bool, error) {216+func (c *Cursor) flushLater(hdrNits float64) (bool, error) {
173 l := c.later217 l := c.later
174 if l == nil || c.freeSlot() < 0 {218 if l == nil || c.freeSlot() < 0 {
175 return false, nil219 return false, nil
176 }220 }
177- return true, c.setImage(r, l.pixels, l.w, l.h, l.hotX, l.hotY)221+ return true, c.setImage(l.pixels, l.w, l.h, l.hotX, l.hotY, hdrNits)
178 }222 }
179 223
180 // committed records that a commit carrying state s is in flight.224 // committed records that a commit carrying state s is in flight.
@@ -229,13 +273,13 @@ func (c *Cursor) props(req *atomicReq, crtc uint32, s cursorState) {
229 req.set(p.id, p.prop("CRTC_H"), size)273 req.set(p.id, p.prop("CRTC_H"), size)
230 }274 }
231 275
232-func (c *Cursor) free(k kms) {276+// free removes the framebuffers and releases the images.
233- for i, fb := range c.fbs {277+func (c *Cursor) free() {
234- if fb != 0 {278+ if c.imgs != nil {
235- _ = k.rmFB(fb)279+ c.imgs.close()
236- c.fbs[i] = 0280+ c.imgs, c.alloc = nil, ""
237- }
238 }281 }
282+ c.fbs = [2]uint32{}
239 c.image, c.later = false, nil283 c.image, c.later = false, nil
240 c.screen, c.flying = 0, false284 c.screen, c.flying = 0, false
241 }285 }
A internal/adapters/drm/cursor_images.go +164 -0
@@ -0,0 +1,164 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/neferwl/internal/adapters/hdrcursor"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Who allocated the cursor images (the "alloc" log field).
12+//
13+// The renderer's cursor images (allocVulkan, shared with the output images)
14+// are the fallback.
15+const allocDumb = "dumb"
16+
17+// cursorImages are the two ARGB8888 images of the cursor plane and their
18+// framebuffers. The output goroutine owns them.
19+type cursorImages interface {
20+ // fb is the framebuffer of image i.
21+ fb(i int) uint32
22+ // write fills image i with premultiplied ARGB8888 pixels (w×h, w*4 per
23+ // row), cropped to the image and cleared around. hdrNits > 0 converts
24+ // them to the HDR10 output encoding for that SDR white.
25+ write(i int, pixels []byte, w, h int, hdrNits float64) error
26+ // close removes the framebuffers and releases the images.
27+ close()
28+}
29+
30+// dumbImage is one KMS dumb buffer mapped for the CPU.
31+type dumbImage struct {
32+ handle, fb uint32
33+ pitch int
34+ mem []byte
35+}
36+
37+// dumbCursor are two KMS dumb buffers: KMS allocates what it scans out, the
38+// CPU writes the pixels. Renderer-allocated images are refused by some
39+// drivers (nvidia-drm) on the cursor plane.
40+type dumbCursor struct {
41+ k kms
42+ size int
43+ img [2]dumbImage
44+ cache hdrcursor.Cache
45+}
46+
47+func newDumbCursor(k kms, size int) (*dumbCursor, error) {
48+ d := &dumbCursor{k: k, size: size}
49+ for i := range d.img {
50+ if err := d.alloc(&d.img[i]); err != nil {
51+ d.close()
52+ return nil, err
53+ }
54+ }
55+ return d, nil
56+}
57+
58+func (d *dumbCursor) alloc(img *dumbImage) error {
59+ b, err := d.k.createDumb(d.size, d.size)
60+ if err != nil {
61+ return fmt.Errorf("create dumb buffer: %w", err)
62+ }
63+ img.handle, img.pitch = b.handle, int(b.pitch)
64+ if img.pitch < d.size*4 {
65+ return fmt.Errorf("dumb buffer pitch %d, want at least %d", img.pitch, d.size*4)
66+ }
67+ if img.mem, err = d.k.mapDumb(b); err != nil {
68+ return fmt.Errorf("map dumb buffer: %w", err)
69+ }
70+ if len(img.mem) < img.pitch*d.size {
71+ return fmt.Errorf("dumb buffer mapping %d bytes, want %d", len(img.mem), img.pitch*d.size)
72+ }
73+ clear(img.mem)
74+ if img.fb, err = d.k.addFBHandle(b, d.size, d.size, fourccARGB); err != nil {
75+ return fmt.Errorf("dumb buffer framebuffer: %w", err)
76+ }
77+ return nil
78+}
79+
80+func (d *dumbCursor) fb(i int) uint32 { return d.img[i].fb }
81+
82+func (d *dumbCursor) write(i int, pixels []byte, w, h int, hdrNits float64) error {
83+ if i < 0 || i >= len(d.img) || d.img[i].mem == nil {
84+ return errors.New("no cursor image")
85+ }
86+ if w < 0 || h < 0 || len(pixels) < w*h*4 {
87+ return errors.New("short cursor image")
88+ }
89+ img := &d.img[i]
90+ mem := img.mem[:img.pitch*d.size]
91+ clear(mem)
92+ cw := min(w, d.size) * 4
93+ src, stride := pixels, w*4
94+ if hdrNits > 0 {
95+ // Called only on image changes, never on moves.
96+ src, stride = d.cache.Convert(pixels, w, h, d.size, hdrNits), cw
97+ }
98+ for y := range min(h, d.size) {
99+ copy(mem[y*img.pitch:y*img.pitch+cw], src[y*stride:y*stride+cw])
100+ }
101+ return nil
102+}
103+
104+func (d *dumbCursor) close() {
105+ for i := range d.img {
106+ img := &d.img[i]
107+ if img.fb != 0 {
108+ _ = d.k.rmFB(img.fb)
109+ }
110+ if img.mem != nil {
111+ _ = d.k.unmapDumb(img.mem)
112+ }
113+ if img.handle != 0 {
114+ _ = d.k.destroyDumb(img.handle)
115+ }
116+ *img = dumbImage{}
117+ }
118+ d.cache.Reset()
119+}
120+
121+// rendererCursor are the renderer's cursor images (linear dmabufs the
122+// renderer allocates and writes), imported as framebuffers.
123+type rendererCursor struct {
124+ k kms
125+ r ports.Renderer
126+ fbs [2]uint32
127+}
128+
129+func newRendererCursor(k kms, r ports.Renderer, size int) (*rendererCursor, error) {
130+ bufs, err := r.CursorBuffers(size)
131+ if err != nil {
132+ return nil, err
133+ }
134+ c := &rendererCursor{k: k, r: r}
135+ for i := range bufs {
136+ if err == nil {
137+ c.fbs[i], err = k.addFB(&bufs[i], fourccARGB)
138+ }
139+ for _, p := range bufs[i].Planes {
140+ p.File.Close()
141+ }
142+ }
143+ if err != nil {
144+ c.close()
145+ return nil, err
146+ }
147+ return c, nil
148+}
149+
150+func (c *rendererCursor) fb(i int) uint32 { return c.fbs[i] }
151+
152+// write relies on the renderer's own HDR state: hdrNits is not used.
153+func (c *rendererCursor) write(i int, pixels []byte, w, h int, _ float64) error {
154+ return c.r.WriteCursor(i, pixels, w, h)
155+}
156+
157+func (c *rendererCursor) close() {
158+ for i, fb := range c.fbs {
159+ if fb != 0 {
160+ _ = c.k.rmFB(fb)
161+ c.fbs[i] = 0
162+ }
163+ }
164+}
@@ -0,0 +1,164 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/neferwl/internal/adapters/hdrcursor"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Who allocated the cursor images (the "alloc" log field).
12+//
13+// The renderer's cursor images (allocVulkan, shared with the output images)
14+// are the fallback.
15+const allocDumb = "dumb"
16+
17+// cursorImages are the two ARGB8888 images of the cursor plane and their
18+// framebuffers. The output goroutine owns them.
19+type cursorImages interface {
20+ // fb is the framebuffer of image i.
21+ fb(i int) uint32
22+ // write fills image i with premultiplied ARGB8888 pixels (w×h, w*4 per
23+ // row), cropped to the image and cleared around. hdrNits > 0 converts
24+ // them to the HDR10 output encoding for that SDR white.
25+ write(i int, pixels []byte, w, h int, hdrNits float64) error
26+ // close removes the framebuffers and releases the images.
27+ close()
28+}
29+
30+// dumbImage is one KMS dumb buffer mapped for the CPU.
31+type dumbImage struct {
32+ handle, fb uint32
33+ pitch int
34+ mem []byte
35+}
36+
37+// dumbCursor are two KMS dumb buffers: KMS allocates what it scans out, the
38+// CPU writes the pixels. Renderer-allocated images are refused by some
39+// drivers (nvidia-drm) on the cursor plane.
40+type dumbCursor struct {
41+ k kms
42+ size int
43+ img [2]dumbImage
44+ cache hdrcursor.Cache
45+}
46+
47+func newDumbCursor(k kms, size int) (*dumbCursor, error) {
48+ d := &dumbCursor{k: k, size: size}
49+ for i := range d.img {
50+ if err := d.alloc(&d.img[i]); err != nil {
51+ d.close()
52+ return nil, err
53+ }
54+ }
55+ return d, nil
56+}
57+
58+func (d *dumbCursor) alloc(img *dumbImage) error {
59+ b, err := d.k.createDumb(d.size, d.size)
60+ if err != nil {
61+ return fmt.Errorf("create dumb buffer: %w", err)
62+ }
63+ img.handle, img.pitch = b.handle, int(b.pitch)
64+ if img.pitch < d.size*4 {
65+ return fmt.Errorf("dumb buffer pitch %d, want at least %d", img.pitch, d.size*4)
66+ }
67+ if img.mem, err = d.k.mapDumb(b); err != nil {
68+ return fmt.Errorf("map dumb buffer: %w", err)
69+ }
70+ if len(img.mem) < img.pitch*d.size {
71+ return fmt.Errorf("dumb buffer mapping %d bytes, want %d", len(img.mem), img.pitch*d.size)
72+ }
73+ clear(img.mem)
74+ if img.fb, err = d.k.addFBHandle(b, d.size, d.size, fourccARGB); err != nil {
75+ return fmt.Errorf("dumb buffer framebuffer: %w", err)
76+ }
77+ return nil
78+}
79+
80+func (d *dumbCursor) fb(i int) uint32 { return d.img[i].fb }
81+
82+func (d *dumbCursor) write(i int, pixels []byte, w, h int, hdrNits float64) error {
83+ if i < 0 || i >= len(d.img) || d.img[i].mem == nil {
84+ return errors.New("no cursor image")
85+ }
86+ if w < 0 || h < 0 || len(pixels) < w*h*4 {
87+ return errors.New("short cursor image")
88+ }
89+ img := &d.img[i]
90+ mem := img.mem[:img.pitch*d.size]
91+ clear(mem)
92+ cw := min(w, d.size) * 4
93+ src, stride := pixels, w*4
94+ if hdrNits > 0 {
95+ // Called only on image changes, never on moves.
96+ src, stride = d.cache.Convert(pixels, w, h, d.size, hdrNits), cw
97+ }
98+ for y := range min(h, d.size) {
99+ copy(mem[y*img.pitch:y*img.pitch+cw], src[y*stride:y*stride+cw])
100+ }
101+ return nil
102+}
103+
104+func (d *dumbCursor) close() {
105+ for i := range d.img {
106+ img := &d.img[i]
107+ if img.fb != 0 {
108+ _ = d.k.rmFB(img.fb)
109+ }
110+ if img.mem != nil {
111+ _ = d.k.unmapDumb(img.mem)
112+ }
113+ if img.handle != 0 {
114+ _ = d.k.destroyDumb(img.handle)
115+ }
116+ *img = dumbImage{}
117+ }
118+ d.cache.Reset()
119+}
120+
121+// rendererCursor are the renderer's cursor images (linear dmabufs the
122+// renderer allocates and writes), imported as framebuffers.
123+type rendererCursor struct {
124+ k kms
125+ r ports.Renderer
126+ fbs [2]uint32
127+}
128+
129+func newRendererCursor(k kms, r ports.Renderer, size int) (*rendererCursor, error) {
130+ bufs, err := r.CursorBuffers(size)
131+ if err != nil {
132+ return nil, err
133+ }
134+ c := &rendererCursor{k: k, r: r}
135+ for i := range bufs {
136+ if err == nil {
137+ c.fbs[i], err = k.addFB(&bufs[i], fourccARGB)
138+ }
139+ for _, p := range bufs[i].Planes {
140+ p.File.Close()
141+ }
142+ }
143+ if err != nil {
144+ c.close()
145+ return nil, err
146+ }
147+ return c, nil
148+}
149+
150+func (c *rendererCursor) fb(i int) uint32 { return c.fbs[i] }
151+
152+// write relies on the renderer's own HDR state: hdrNits is not used.
153+func (c *rendererCursor) write(i int, pixels []byte, w, h int, _ float64) error {
154+ return c.r.WriteCursor(i, pixels, w, h)
155+}
156+
157+func (c *rendererCursor) close() {
158+ for i, fb := range c.fbs {
159+ if fb != 0 {
160+ _ = c.k.rmFB(fb)
161+ c.fbs[i] = 0
162+ }
163+ }
164+}
A internal/adapters/drm/cursor_images_mock_test.go +204 -0
@@ -0,0 +1,204 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package drm
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockcursorImages creates a new instance of mockcursorImages. 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 newMockcursorImages(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockcursorImages {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockcursorImages{}
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+// mockcursorImages is an autogenerated mock type for the cursorImages type
35+type mockcursorImages struct {
36+ mock.Mock
37+}
38+
39+type mockcursorImages_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockcursorImages) EXPECT() *mockcursorImages_Expecter {
44+ return &mockcursorImages_Expecter{mock: &_m.Mock}
45+}
46+
47+// close provides a mock function for the type mockcursorImages
48+func (_mock *mockcursorImages) close() {
49+ _mock.Called()
50+ return
51+}
52+
53+// mockcursorImages_close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'close'
54+type mockcursorImages_close_Call struct {
55+ *mock.Call
56+}
57+
58+// close is a helper method to define mock.On call
59+func (_e *mockcursorImages_Expecter) close() *mockcursorImages_close_Call {
60+ return &mockcursorImages_close_Call{Call: _e.mock.On("close")}
61+}
62+
63+func (_c *mockcursorImages_close_Call) Run(run func()) *mockcursorImages_close_Call {
64+ _c.Call.Run(func(args mock.Arguments) {
65+ run()
66+ })
67+ return _c
68+}
69+
70+func (_c *mockcursorImages_close_Call) Return() *mockcursorImages_close_Call {
71+ _c.Call.Return()
72+ return _c
73+}
74+
75+func (_c *mockcursorImages_close_Call) RunAndReturn(run func()) *mockcursorImages_close_Call {
76+ _c.Run(run)
77+ return _c
78+}
79+
80+// fb provides a mock function for the type mockcursorImages
81+func (_mock *mockcursorImages) fb(i int) uint32 {
82+ ret := _mock.Called(i)
83+
84+ if len(ret) == 0 {
85+ panic("no return value specified for fb")
86+ }
87+
88+ var r0 uint32
89+ if returnFunc, ok := ret.Get(0).(func(int) uint32); ok {
90+ r0 = returnFunc(i)
91+ } else {
92+ r0 = ret.Get(0).(uint32)
93+ }
94+ return r0
95+}
96+
97+// mockcursorImages_fb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'fb'
98+type mockcursorImages_fb_Call struct {
99+ *mock.Call
100+}
101+
102+// fb is a helper method to define mock.On call
103+// - i int
104+func (_e *mockcursorImages_Expecter) fb(i any) *mockcursorImages_fb_Call {
105+ return &mockcursorImages_fb_Call{Call: _e.mock.On("fb", i)}
106+}
107+
108+func (_c *mockcursorImages_fb_Call) Run(run func(i int)) *mockcursorImages_fb_Call {
109+ _c.Call.Run(func(args mock.Arguments) {
110+ var arg0 int
111+ if args[0] != nil {
112+ arg0 = args[0].(int)
113+ }
114+ run(
115+ arg0,
116+ )
117+ })
118+ return _c
119+}
120+
121+func (_c *mockcursorImages_fb_Call) Return(v uint32) *mockcursorImages_fb_Call {
122+ _c.Call.Return(v)
123+ return _c
124+}
125+
126+func (_c *mockcursorImages_fb_Call) RunAndReturn(run func(i int) uint32) *mockcursorImages_fb_Call {
127+ _c.Call.Return(run)
128+ return _c
129+}
130+
131+// write provides a mock function for the type mockcursorImages
132+func (_mock *mockcursorImages) write(i int, pixels []byte, w int, h int, hdrNits float64) error {
133+ ret := _mock.Called(i, pixels, w, h, hdrNits)
134+
135+ if len(ret) == 0 {
136+ panic("no return value specified for write")
137+ }
138+
139+ var r0 error
140+ if returnFunc, ok := ret.Get(0).(func(int, []byte, int, int, float64) error); ok {
141+ r0 = returnFunc(i, pixels, w, h, hdrNits)
142+ } else {
143+ r0 = ret.Error(0)
144+ }
145+ return r0
146+}
147+
148+// mockcursorImages_write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'write'
149+type mockcursorImages_write_Call struct {
150+ *mock.Call
151+}
152+
153+// write is a helper method to define mock.On call
154+// - i int
155+// - pixels []byte
156+// - w int
157+// - h int
158+// - hdrNits float64
159+func (_e *mockcursorImages_Expecter) write(i any, pixels any, w any, h any, hdrNits any) *mockcursorImages_write_Call {
160+ return &mockcursorImages_write_Call{Call: _e.mock.On("write", i, pixels, w, h, hdrNits)}
161+}
162+
163+func (_c *mockcursorImages_write_Call) Run(run func(i int, pixels []byte, w int, h int, hdrNits float64)) *mockcursorImages_write_Call {
164+ _c.Call.Run(func(args mock.Arguments) {
165+ var arg0 int
166+ if args[0] != nil {
167+ arg0 = args[0].(int)
168+ }
169+ var arg1 []byte
170+ if args[1] != nil {
171+ arg1 = args[1].([]byte)
172+ }
173+ var arg2 int
174+ if args[2] != nil {
175+ arg2 = args[2].(int)
176+ }
177+ var arg3 int
178+ if args[3] != nil {
179+ arg3 = args[3].(int)
180+ }
181+ var arg4 float64
182+ if args[4] != nil {
183+ arg4 = args[4].(float64)
184+ }
185+ run(
186+ arg0,
187+ arg1,
188+ arg2,
189+ arg3,
190+ arg4,
191+ )
192+ })
193+ return _c
194+}
195+
196+func (_c *mockcursorImages_write_Call) Return(err error) *mockcursorImages_write_Call {
197+ _c.Call.Return(err)
198+ return _c
199+}
200+
201+func (_c *mockcursorImages_write_Call) RunAndReturn(run func(i int, pixels []byte, w int, h int, hdrNits float64) error) *mockcursorImages_write_Call {
202+ _c.Call.Return(run)
203+ return _c
204+}
@@ -0,0 +1,204 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package drm
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockcursorImages creates a new instance of mockcursorImages. 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 newMockcursorImages(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockcursorImages {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockcursorImages{}
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+// mockcursorImages is an autogenerated mock type for the cursorImages type
35+type mockcursorImages struct {
36+ mock.Mock
37+}
38+
39+type mockcursorImages_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockcursorImages) EXPECT() *mockcursorImages_Expecter {
44+ return &mockcursorImages_Expecter{mock: &_m.Mock}
45+}
46+
47+// close provides a mock function for the type mockcursorImages
48+func (_mock *mockcursorImages) close() {
49+ _mock.Called()
50+ return
51+}
52+
53+// mockcursorImages_close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'close'
54+type mockcursorImages_close_Call struct {
55+ *mock.Call
56+}
57+
58+// close is a helper method to define mock.On call
59+func (_e *mockcursorImages_Expecter) close() *mockcursorImages_close_Call {
60+ return &mockcursorImages_close_Call{Call: _e.mock.On("close")}
61+}
62+
63+func (_c *mockcursorImages_close_Call) Run(run func()) *mockcursorImages_close_Call {
64+ _c.Call.Run(func(args mock.Arguments) {
65+ run()
66+ })
67+ return _c
68+}
69+
70+func (_c *mockcursorImages_close_Call) Return() *mockcursorImages_close_Call {
71+ _c.Call.Return()
72+ return _c
73+}
74+
75+func (_c *mockcursorImages_close_Call) RunAndReturn(run func()) *mockcursorImages_close_Call {
76+ _c.Run(run)
77+ return _c
78+}
79+
80+// fb provides a mock function for the type mockcursorImages
81+func (_mock *mockcursorImages) fb(i int) uint32 {
82+ ret := _mock.Called(i)
83+
84+ if len(ret) == 0 {
85+ panic("no return value specified for fb")
86+ }
87+
88+ var r0 uint32
89+ if returnFunc, ok := ret.Get(0).(func(int) uint32); ok {
90+ r0 = returnFunc(i)
91+ } else {
92+ r0 = ret.Get(0).(uint32)
93+ }
94+ return r0
95+}
96+
97+// mockcursorImages_fb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'fb'
98+type mockcursorImages_fb_Call struct {
99+ *mock.Call
100+}
101+
102+// fb is a helper method to define mock.On call
103+// - i int
104+func (_e *mockcursorImages_Expecter) fb(i any) *mockcursorImages_fb_Call {
105+ return &mockcursorImages_fb_Call{Call: _e.mock.On("fb", i)}
106+}
107+
108+func (_c *mockcursorImages_fb_Call) Run(run func(i int)) *mockcursorImages_fb_Call {
109+ _c.Call.Run(func(args mock.Arguments) {
110+ var arg0 int
111+ if args[0] != nil {
112+ arg0 = args[0].(int)
113+ }
114+ run(
115+ arg0,
116+ )
117+ })
118+ return _c
119+}
120+
121+func (_c *mockcursorImages_fb_Call) Return(v uint32) *mockcursorImages_fb_Call {
122+ _c.Call.Return(v)
123+ return _c
124+}
125+
126+func (_c *mockcursorImages_fb_Call) RunAndReturn(run func(i int) uint32) *mockcursorImages_fb_Call {
127+ _c.Call.Return(run)
128+ return _c
129+}
130+
131+// write provides a mock function for the type mockcursorImages
132+func (_mock *mockcursorImages) write(i int, pixels []byte, w int, h int, hdrNits float64) error {
133+ ret := _mock.Called(i, pixels, w, h, hdrNits)
134+
135+ if len(ret) == 0 {
136+ panic("no return value specified for write")
137+ }
138+
139+ var r0 error
140+ if returnFunc, ok := ret.Get(0).(func(int, []byte, int, int, float64) error); ok {
141+ r0 = returnFunc(i, pixels, w, h, hdrNits)
142+ } else {
143+ r0 = ret.Error(0)
144+ }
145+ return r0
146+}
147+
148+// mockcursorImages_write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'write'
149+type mockcursorImages_write_Call struct {
150+ *mock.Call
151+}
152+
153+// write is a helper method to define mock.On call
154+// - i int
155+// - pixels []byte
156+// - w int
157+// - h int
158+// - hdrNits float64
159+func (_e *mockcursorImages_Expecter) write(i any, pixels any, w any, h any, hdrNits any) *mockcursorImages_write_Call {
160+ return &mockcursorImages_write_Call{Call: _e.mock.On("write", i, pixels, w, h, hdrNits)}
161+}
162+
163+func (_c *mockcursorImages_write_Call) Run(run func(i int, pixels []byte, w int, h int, hdrNits float64)) *mockcursorImages_write_Call {
164+ _c.Call.Run(func(args mock.Arguments) {
165+ var arg0 int
166+ if args[0] != nil {
167+ arg0 = args[0].(int)
168+ }
169+ var arg1 []byte
170+ if args[1] != nil {
171+ arg1 = args[1].([]byte)
172+ }
173+ var arg2 int
174+ if args[2] != nil {
175+ arg2 = args[2].(int)
176+ }
177+ var arg3 int
178+ if args[3] != nil {
179+ arg3 = args[3].(int)
180+ }
181+ var arg4 float64
182+ if args[4] != nil {
183+ arg4 = args[4].(float64)
184+ }
185+ run(
186+ arg0,
187+ arg1,
188+ arg2,
189+ arg3,
190+ arg4,
191+ )
192+ })
193+ return _c
194+}
195+
196+func (_c *mockcursorImages_write_Call) Return(err error) *mockcursorImages_write_Call {
197+ _c.Call.Return(err)
198+ return _c
199+}
200+
201+func (_c *mockcursorImages_write_Call) RunAndReturn(run func(i int, pixels []byte, w int, h int, hdrNits float64) error) *mockcursorImages_write_Call {
202+ _c.Call.Return(run)
203+ return _c
204+}
A internal/adapters/drm/cursor_images_test.go +333 -0
@@ -0,0 +1,333 @@
1+package drm
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "os"
7+ "testing"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
13+)
14+
15+const tSide = 16
16+
17+// dumbOK expects two dumb buffers with handles 10 and 11, mappings (with
18+// pitch bytes per row) returned in mem, and framebuffers 90 and 91.
19+func dumbOK(k *mockkms, pitch int) (mem [2][]byte) {
20+ for i := range mem {
21+ mem[i] = make([]byte, pitch*tSide)
22+ h := uint32(10 + i)
23+ b := dumbBuffer{handle: h, pitch: uint32(pitch), size: uint64(len(mem[i]))}
24+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
25+ k.EXPECT().mapDumb(b).Return(mem[i], nil).Once()
26+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(90+uint32(i), nil).Once()
27+ }
28+ return mem
29+}
30+
31+func dumbFree(k *mockkms) {
32+ for i := range 2 {
33+ k.EXPECT().rmFB(90 + uint32(i)).Return(nil).Once()
34+ k.EXPECT().destroyDumb(10 + uint32(i)).Return(nil).Once()
35+ }
36+ k.EXPECT().unmapDumb(mock.Anything).Return(nil).Twice()
37+}
38+
39+func rendererBufs() [2]ports.DMABuf {
40+ var b [2]ports.DMABuf
41+ for i := range b {
42+ f, w, _ := os.Pipe()
43+ w.Close()
44+ b[i] = ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
45+ }
46+ return b
47+}
48+
49+func rendererOK(k *mockkms, r *portsmocks.MockRenderer) {
50+ r.EXPECT().CursorBuffers(tSide).Return(rendererBufs(), nil).Once()
51+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
52+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(81, nil).Once()
53+}
54+
55+// Dumb buffers come first: the renderer allocates nothing.
56+func TestCursorSetupUsesDumbBuffers(t *testing.T) {
57+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
58+ dumbOK(k, tSide*4)
59+ c := newCursor(&plane{}, tSide)
60+ dumbErr, err := c.setup(k, r)
61+ if dumbErr != nil || err != nil {
62+ t.Fatalf("setup: %v %v", dumbErr, err)
63+ }
64+ if c.alloc != allocDumb || c.fbs != [2]uint32{90, 91} {
65+ t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
66+ }
67+ dumbFree(k)
68+ c.free()
69+ if c.imgs != nil || c.fbs != [2]uint32{} {
70+ t.Fatal("images kept after free")
71+ }
72+}
73+
74+// A dumb buffer that cannot be made (or mapped, or wrapped) falls back to
75+// the renderer's images, and what was made is released.
76+func TestCursorSetupFallsBackToRenderer(t *testing.T) {
77+ for name, fail := range map[string]func(k *mockkms){
78+ "create": func(k *mockkms) {
79+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
80+ },
81+ "map": func(k *mockkms) {
82+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
83+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
84+ k.EXPECT().mapDumb(b).Return(nil, unix.ENOMEM).Once()
85+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
86+ },
87+ "framebuffer": func(k *mockkms) {
88+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
89+ mem := make([]byte, b.size)
90+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
91+ k.EXPECT().mapDumb(b).Return(mem, nil).Once()
92+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
93+ k.EXPECT().unmapDumb(mem).Return(nil).Once()
94+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
95+ },
96+ "second buffer": func(k *mockkms) {
97+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
98+ mem := make([]byte, b.size)
99+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
100+ k.EXPECT().mapDumb(b).Return(mem, nil).Once()
101+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(90, nil).Once()
102+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.EMFILE).Once()
103+ k.EXPECT().rmFB(uint32(90)).Return(nil).Once()
104+ k.EXPECT().unmapDumb(mem).Return(nil).Once()
105+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
106+ },
107+ } {
108+ t.Run(name, func(t *testing.T) {
109+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
110+ fail(k)
111+ rendererOK(k, r)
112+ c := newCursor(&plane{}, tSide)
113+ dumbErr, err := c.setup(k, r)
114+ if dumbErr == nil || err != nil {
115+ t.Fatalf("setup: %v %v", dumbErr, err)
116+ }
117+ if c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} {
118+ t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
119+ }
120+ })
121+ }
122+}
123+
124+// Without either allocator there are no images.
125+func TestCursorSetupFailsWithoutAllocators(t *testing.T) {
126+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
127+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
128+ r.EXPECT().CursorBuffers(tSide).Return([2]ports.DMABuf{}, errors.New("no export")).Once()
129+ c := newCursor(&plane{}, tSide)
130+ dumbErr, err := c.setup(k, r)
131+ if dumbErr == nil || err == nil || c.imgs != nil || c.fbs != [2]uint32{} {
132+ t.Fatalf("setup: %v %v imgs %v fbs %v", dumbErr, err, c.imgs, c.fbs)
133+ }
134+ if _, err := c.useFallback(0); err != nil {
135+ t.Fatal(err)
136+ }
137+}
138+
139+// A renderer image whose framebuffer fails is released.
140+func TestRendererCursorReleasesOnFailure(t *testing.T) {
141+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
142+ r.EXPECT().CursorBuffers(tSide).Return(rendererBufs(), nil).Once()
143+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
144+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
145+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
146+ if _, err := newRendererCursor(k, r, tSide); err == nil {
147+ t.Fatal("framebuffer failure not reported")
148+ }
149+}
150+
151+// cursorOutput is a test output whose cursor plane has size tSide.
152+func cursorOutput(t *testing.T, errs ...error) (*Output, *mockkms, *portsmocks.MockRenderer, *[]commitRec) {
153+ o, k, commits := testOutput(t, errs...)
154+ o.cursor = newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, tSide)
155+ return o, k, portsmocks.NewMockRenderer(t), commits
156+}
157+
158+// The plane refuses the dumb images: the renderer's are tried (TEST_ONLY),
159+// the image on the cursor is rewritten into them, and writes go there.
160+func TestCursorDumbRefusedUsesRendererImages(t *testing.T) {
161+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
162+ dumbOK(k, tSide*4)
163+ if _, err := o.cursor.setup(k, r); err != nil {
164+ t.Fatal(err)
165+ }
166+ px := []byte{1, 2, 3, 4}
167+ if err := o.cursor.setImage(px, 1, 1, 2, 3, 0); err != nil {
168+ t.Fatal(err)
169+ }
170+ dumbFree(k)
171+ rendererOK(k, r)
172+ var written []int
173+ r.EXPECT().WriteCursor(mock.Anything, px, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {
174+ written = append(written, i)
175+ return nil
176+ })
177+ o.testCursor()
178+ c := o.cursor
179+ if c.off || c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} || !c.image || c.hotX != 2 || c.hotY != 3 {
180+ t.Fatalf("off %v alloc %q fbs %v image %v hot %d,%d", c.off, c.alloc, c.fbs, c.image, c.hotX, c.hotY)
181+ }
182+ if len(written) != 1 || len(*commits) != 2 {
183+ t.Fatalf("written %v, %d TEST_ONLY commits", written, len(*commits))
184+ }
185+ if v, _ := (*commits)[1].req.value(tCursor, pFB); v != 80 || (*commits)[1].flags != atomicTestOnly {
186+ t.Fatalf("second test shows fb %d flags %#x", v, (*commits)[1].flags)
187+ }
188+ // Further changes are written by the renderer, not into the freed buffers.
189+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil || len(written) != 2 {
190+ t.Fatalf("write after fallback: %v %v", err, written)
191+ }
192+}
193+
194+// Both kinds refused: the software cursor takes over.
195+func TestCursorBothRefusedGoesOff(t *testing.T) {
196+ o, k, r, commits := cursorOutput(t, unix.EINVAL, unix.EINVAL)
197+ dumbOK(k, tSide*4)
198+ if _, err := o.cursor.setup(k, r); err != nil {
199+ t.Fatal(err)
200+ }
201+ dumbFree(k)
202+ rendererOK(k, r)
203+ if !o.testCursor() {
204+ t.Fatal("no change reported")
205+ }
206+ if !o.cursor.off || o.cursor.alloc != allocVulkan || len(*commits) != 2 {
207+ t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
208+ }
209+ if o.cursor.desired().on {
210+ t.Fatal("cursor shown while off")
211+ }
212+ // The renderer's images are released with the output.
213+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
214+ k.EXPECT().rmFB(uint32(81)).Return(nil).Once()
215+ o.cursor.free()
216+}
217+
218+// Dumb images the plane takes stay, with no second test.
219+func TestCursorDumbAcceptedStays(t *testing.T) {
220+ o, k, r, commits := cursorOutput(t)
221+ dumbOK(k, tSide*4)
222+ if _, err := o.cursor.setup(k, r); err != nil {
223+ t.Fatal(err)
224+ }
225+ if o.testCursor() || o.cursor.off || o.cursor.alloc != allocDumb || len(*commits) != 1 {
226+ t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
227+ }
228+ // An error other than a refusal changes nothing.
229+ dumbFree(k)
230+ o.cursor.free()
231+}
232+
233+// Renderer images refused while no dumb ones exist: off, no retry.
234+func TestCursorRendererRefusedGoesOff(t *testing.T) {
235+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
236+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
237+ rendererOK(k, r)
238+ if _, err := o.cursor.setup(k, r); err != nil {
239+ t.Fatal(err)
240+ }
241+ if !o.testCursor() || !o.cursor.off || len(*commits) != 1 {
242+ t.Fatalf("off %v commits %d", o.cursor.off, len(*commits))
243+ }
244+}
245+
246+// Writes go to the images that own the cursor, and the output's HDR white
247+// is passed along.
248+func TestCursorWritesGoToCurrentImages(t *testing.T) {
249+ o, _, _, _ := cursorOutput(t)
250+ imgs := newMockcursorImages(t)
251+ imgs.EXPECT().fb(0).Return(90).Once()
252+ imgs.EXPECT().fb(1).Return(91).Once()
253+ o.cursor.install(imgs, allocDumb)
254+ px := []byte{1, 2, 3, 4}
255+ imgs.EXPECT().write(0, px, 1, 1, float64(0)).Return(nil).Once()
256+ if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err != nil {
257+ t.Fatal(err)
258+ }
259+ o.hdr.on, o.hdr.settings.SDRBrightness = true, 203
260+ if got := o.cursorNits(); got != 203 {
261+ t.Fatalf("HDR white %v", got)
262+ }
263+ o.cursor.screen = 90
264+ imgs.EXPECT().write(1, px, 1, 1, float64(203)).Return(errors.New("full")).Once()
265+ if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err == nil {
266+ t.Fatal("write error swallowed")
267+ }
268+}
269+
270+// Dumb pitch may exceed the row size: rows land at the pitch, the image is
271+// cropped to the side and cleared around.
272+func TestDumbCursorWritePitchCropClear(t *testing.T) {
273+ k := newMockkms(t)
274+ const pitch = tSide*4 + 32
275+ mem := dumbOK(k, pitch)
276+ d, err := newDumbCursor(k, tSide)
277+ if err != nil {
278+ t.Fatal(err)
279+ }
280+ defer func() { dumbFree(k); d.close() }()
281+ big := bytes.Repeat([]byte{0xff}, 40*40*4)
282+ if err := d.write(1, big, 40, 40, 0); err != nil { // cropped to tSide
283+ t.Fatal(err)
284+ }
285+ last := (tSide - 1) * pitch
286+ if m := mem[1]; m[last+(tSide-1)*4] != 0xff || m[last+tSide*4] != 0 || m[pitch-1] != 0 {
287+ t.Fatal("cropped image misplaced at the pitch")
288+ }
289+ if err := d.write(1, []byte{1, 2, 3, 4}, 1, 1, 0); err != nil {
290+ t.Fatal(err)
291+ }
292+ m := mem[1]
293+ if !bytes.Equal(m[:4], []byte{1, 2, 3, 4}) || m[4] != 0 || m[pitch] != 0 || m[last+(tSide-1)*4] != 0 {
294+ t.Fatal("image not cleared around the new pixels")
295+ }
296+ // A wide, short image keeps its rows apart.
297+ row := []byte{9, 9, 9, 9, 8, 8, 8, 8}
298+ if err := d.write(0, append(row, row...), 2, 2, 0); err != nil {
299+ t.Fatal(err)
300+ }
301+ if m := mem[0]; !bytes.Equal(m[:8], row) || !bytes.Equal(m[pitch:pitch+8], row) || m[8] != 0 {
302+ t.Fatal("rows of a narrow image")
303+ }
304+ if err := d.write(0, []byte{1}, 1, 1, 0); err == nil {
305+ t.Fatal("short image accepted")
306+ }
307+ if err := d.write(2, nil, 0, 0, 0); err == nil {
308+ t.Fatal("bad index accepted")
309+ }
310+}
311+
312+// HDR cursors convert exactly as the renderer did (see hdrcursor's vector).
313+func TestDumbCursorWriteHDR(t *testing.T) {
314+ k := newMockkms(t)
315+ mem := dumbOK(k, tSide*4)
316+ d, err := newDumbCursor(k, tSide)
317+ if err != nil {
318+ t.Fatal(err)
319+ }
320+ defer func() { dumbFree(k); d.close() }()
321+ if err := d.write(0, []byte{255, 255, 255, 255}, 1, 1, 203); err != nil {
322+ t.Fatal(err)
323+ }
324+ if m := mem[0]; m[0] != 148 || m[1] != 148 || m[2] != 148 || m[3] != 255 {
325+ t.Fatalf("HDR white: %v", m[:4])
326+ }
327+ if err := d.write(1, []byte{255, 255, 255, 255}, 1, 1, 0); err != nil || mem[1][0] != 255 {
328+ t.Fatalf("SDR white: %v %v", err, mem[1][:4])
329+ }
330+ if d.cache.Len() != 4 {
331+ t.Fatalf("cache %d", d.cache.Len())
332+ }
333+}
@@ -0,0 +1,333 @@
1+package drm
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "os"
7+ "testing"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
13+)
14+
15+const tSide = 16
16+
17+// dumbOK expects two dumb buffers with handles 10 and 11, mappings (with
18+// pitch bytes per row) returned in mem, and framebuffers 90 and 91.
19+func dumbOK(k *mockkms, pitch int) (mem [2][]byte) {
20+ for i := range mem {
21+ mem[i] = make([]byte, pitch*tSide)
22+ h := uint32(10 + i)
23+ b := dumbBuffer{handle: h, pitch: uint32(pitch), size: uint64(len(mem[i]))}
24+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
25+ k.EXPECT().mapDumb(b).Return(mem[i], nil).Once()
26+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(90+uint32(i), nil).Once()
27+ }
28+ return mem
29+}
30+
31+func dumbFree(k *mockkms) {
32+ for i := range 2 {
33+ k.EXPECT().rmFB(90 + uint32(i)).Return(nil).Once()
34+ k.EXPECT().destroyDumb(10 + uint32(i)).Return(nil).Once()
35+ }
36+ k.EXPECT().unmapDumb(mock.Anything).Return(nil).Twice()
37+}
38+
39+func rendererBufs() [2]ports.DMABuf {
40+ var b [2]ports.DMABuf
41+ for i := range b {
42+ f, w, _ := os.Pipe()
43+ w.Close()
44+ b[i] = ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
45+ }
46+ return b
47+}
48+
49+func rendererOK(k *mockkms, r *portsmocks.MockRenderer) {
50+ r.EXPECT().CursorBuffers(tSide).Return(rendererBufs(), nil).Once()
51+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
52+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(81, nil).Once()
53+}
54+
55+// Dumb buffers come first: the renderer allocates nothing.
56+func TestCursorSetupUsesDumbBuffers(t *testing.T) {
57+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
58+ dumbOK(k, tSide*4)
59+ c := newCursor(&plane{}, tSide)
60+ dumbErr, err := c.setup(k, r)
61+ if dumbErr != nil || err != nil {
62+ t.Fatalf("setup: %v %v", dumbErr, err)
63+ }
64+ if c.alloc != allocDumb || c.fbs != [2]uint32{90, 91} {
65+ t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
66+ }
67+ dumbFree(k)
68+ c.free()
69+ if c.imgs != nil || c.fbs != [2]uint32{} {
70+ t.Fatal("images kept after free")
71+ }
72+}
73+
74+// A dumb buffer that cannot be made (or mapped, or wrapped) falls back to
75+// the renderer's images, and what was made is released.
76+func TestCursorSetupFallsBackToRenderer(t *testing.T) {
77+ for name, fail := range map[string]func(k *mockkms){
78+ "create": func(k *mockkms) {
79+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
80+ },
81+ "map": func(k *mockkms) {
82+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
83+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
84+ k.EXPECT().mapDumb(b).Return(nil, unix.ENOMEM).Once()
85+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
86+ },
87+ "framebuffer": func(k *mockkms) {
88+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
89+ mem := make([]byte, b.size)
90+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
91+ k.EXPECT().mapDumb(b).Return(mem, nil).Once()
92+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
93+ k.EXPECT().unmapDumb(mem).Return(nil).Once()
94+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
95+ },
96+ "second buffer": func(k *mockkms) {
97+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
98+ mem := make([]byte, b.size)
99+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
100+ k.EXPECT().mapDumb(b).Return(mem, nil).Once()
101+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(90, nil).Once()
102+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.EMFILE).Once()
103+ k.EXPECT().rmFB(uint32(90)).Return(nil).Once()
104+ k.EXPECT().unmapDumb(mem).Return(nil).Once()
105+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
106+ },
107+ } {
108+ t.Run(name, func(t *testing.T) {
109+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
110+ fail(k)
111+ rendererOK(k, r)
112+ c := newCursor(&plane{}, tSide)
113+ dumbErr, err := c.setup(k, r)
114+ if dumbErr == nil || err != nil {
115+ t.Fatalf("setup: %v %v", dumbErr, err)
116+ }
117+ if c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} {
118+ t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
119+ }
120+ })
121+ }
122+}
123+
124+// Without either allocator there are no images.
125+func TestCursorSetupFailsWithoutAllocators(t *testing.T) {
126+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
127+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
128+ r.EXPECT().CursorBuffers(tSide).Return([2]ports.DMABuf{}, errors.New("no export")).Once()
129+ c := newCursor(&plane{}, tSide)
130+ dumbErr, err := c.setup(k, r)
131+ if dumbErr == nil || err == nil || c.imgs != nil || c.fbs != [2]uint32{} {
132+ t.Fatalf("setup: %v %v imgs %v fbs %v", dumbErr, err, c.imgs, c.fbs)
133+ }
134+ if _, err := c.useFallback(0); err != nil {
135+ t.Fatal(err)
136+ }
137+}
138+
139+// A renderer image whose framebuffer fails is released.
140+func TestRendererCursorReleasesOnFailure(t *testing.T) {
141+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
142+ r.EXPECT().CursorBuffers(tSide).Return(rendererBufs(), nil).Once()
143+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
144+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
145+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
146+ if _, err := newRendererCursor(k, r, tSide); err == nil {
147+ t.Fatal("framebuffer failure not reported")
148+ }
149+}
150+
151+// cursorOutput is a test output whose cursor plane has size tSide.
152+func cursorOutput(t *testing.T, errs ...error) (*Output, *mockkms, *portsmocks.MockRenderer, *[]commitRec) {
153+ o, k, commits := testOutput(t, errs...)
154+ o.cursor = newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, tSide)
155+ return o, k, portsmocks.NewMockRenderer(t), commits
156+}
157+
158+// The plane refuses the dumb images: the renderer's are tried (TEST_ONLY),
159+// the image on the cursor is rewritten into them, and writes go there.
160+func TestCursorDumbRefusedUsesRendererImages(t *testing.T) {
161+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
162+ dumbOK(k, tSide*4)
163+ if _, err := o.cursor.setup(k, r); err != nil {
164+ t.Fatal(err)
165+ }
166+ px := []byte{1, 2, 3, 4}
167+ if err := o.cursor.setImage(px, 1, 1, 2, 3, 0); err != nil {
168+ t.Fatal(err)
169+ }
170+ dumbFree(k)
171+ rendererOK(k, r)
172+ var written []int
173+ r.EXPECT().WriteCursor(mock.Anything, px, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {
174+ written = append(written, i)
175+ return nil
176+ })
177+ o.testCursor()
178+ c := o.cursor
179+ if c.off || c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} || !c.image || c.hotX != 2 || c.hotY != 3 {
180+ t.Fatalf("off %v alloc %q fbs %v image %v hot %d,%d", c.off, c.alloc, c.fbs, c.image, c.hotX, c.hotY)
181+ }
182+ if len(written) != 1 || len(*commits) != 2 {
183+ t.Fatalf("written %v, %d TEST_ONLY commits", written, len(*commits))
184+ }
185+ if v, _ := (*commits)[1].req.value(tCursor, pFB); v != 80 || (*commits)[1].flags != atomicTestOnly {
186+ t.Fatalf("second test shows fb %d flags %#x", v, (*commits)[1].flags)
187+ }
188+ // Further changes are written by the renderer, not into the freed buffers.
189+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil || len(written) != 2 {
190+ t.Fatalf("write after fallback: %v %v", err, written)
191+ }
192+}
193+
194+// Both kinds refused: the software cursor takes over.
195+func TestCursorBothRefusedGoesOff(t *testing.T) {
196+ o, k, r, commits := cursorOutput(t, unix.EINVAL, unix.EINVAL)
197+ dumbOK(k, tSide*4)
198+ if _, err := o.cursor.setup(k, r); err != nil {
199+ t.Fatal(err)
200+ }
201+ dumbFree(k)
202+ rendererOK(k, r)
203+ if !o.testCursor() {
204+ t.Fatal("no change reported")
205+ }
206+ if !o.cursor.off || o.cursor.alloc != allocVulkan || len(*commits) != 2 {
207+ t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
208+ }
209+ if o.cursor.desired().on {
210+ t.Fatal("cursor shown while off")
211+ }
212+ // The renderer's images are released with the output.
213+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
214+ k.EXPECT().rmFB(uint32(81)).Return(nil).Once()
215+ o.cursor.free()
216+}
217+
218+// Dumb images the plane takes stay, with no second test.
219+func TestCursorDumbAcceptedStays(t *testing.T) {
220+ o, k, r, commits := cursorOutput(t)
221+ dumbOK(k, tSide*4)
222+ if _, err := o.cursor.setup(k, r); err != nil {
223+ t.Fatal(err)
224+ }
225+ if o.testCursor() || o.cursor.off || o.cursor.alloc != allocDumb || len(*commits) != 1 {
226+ t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
227+ }
228+ // An error other than a refusal changes nothing.
229+ dumbFree(k)
230+ o.cursor.free()
231+}
232+
233+// Renderer images refused while no dumb ones exist: off, no retry.
234+func TestCursorRendererRefusedGoesOff(t *testing.T) {
235+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
236+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
237+ rendererOK(k, r)
238+ if _, err := o.cursor.setup(k, r); err != nil {
239+ t.Fatal(err)
240+ }
241+ if !o.testCursor() || !o.cursor.off || len(*commits) != 1 {
242+ t.Fatalf("off %v commits %d", o.cursor.off, len(*commits))
243+ }
244+}
245+
246+// Writes go to the images that own the cursor, and the output's HDR white
247+// is passed along.
248+func TestCursorWritesGoToCurrentImages(t *testing.T) {
249+ o, _, _, _ := cursorOutput(t)
250+ imgs := newMockcursorImages(t)
251+ imgs.EXPECT().fb(0).Return(90).Once()
252+ imgs.EXPECT().fb(1).Return(91).Once()
253+ o.cursor.install(imgs, allocDumb)
254+ px := []byte{1, 2, 3, 4}
255+ imgs.EXPECT().write(0, px, 1, 1, float64(0)).Return(nil).Once()
256+ if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err != nil {
257+ t.Fatal(err)
258+ }
259+ o.hdr.on, o.hdr.settings.SDRBrightness = true, 203
260+ if got := o.cursorNits(); got != 203 {
261+ t.Fatalf("HDR white %v", got)
262+ }
263+ o.cursor.screen = 90
264+ imgs.EXPECT().write(1, px, 1, 1, float64(203)).Return(errors.New("full")).Once()
265+ if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err == nil {
266+ t.Fatal("write error swallowed")
267+ }
268+}
269+
270+// Dumb pitch may exceed the row size: rows land at the pitch, the image is
271+// cropped to the side and cleared around.
272+func TestDumbCursorWritePitchCropClear(t *testing.T) {
273+ k := newMockkms(t)
274+ const pitch = tSide*4 + 32
275+ mem := dumbOK(k, pitch)
276+ d, err := newDumbCursor(k, tSide)
277+ if err != nil {
278+ t.Fatal(err)
279+ }
280+ defer func() { dumbFree(k); d.close() }()
281+ big := bytes.Repeat([]byte{0xff}, 40*40*4)
282+ if err := d.write(1, big, 40, 40, 0); err != nil { // cropped to tSide
283+ t.Fatal(err)
284+ }
285+ last := (tSide - 1) * pitch
286+ if m := mem[1]; m[last+(tSide-1)*4] != 0xff || m[last+tSide*4] != 0 || m[pitch-1] != 0 {
287+ t.Fatal("cropped image misplaced at the pitch")
288+ }
289+ if err := d.write(1, []byte{1, 2, 3, 4}, 1, 1, 0); err != nil {
290+ t.Fatal(err)
291+ }
292+ m := mem[1]
293+ if !bytes.Equal(m[:4], []byte{1, 2, 3, 4}) || m[4] != 0 || m[pitch] != 0 || m[last+(tSide-1)*4] != 0 {
294+ t.Fatal("image not cleared around the new pixels")
295+ }
296+ // A wide, short image keeps its rows apart.
297+ row := []byte{9, 9, 9, 9, 8, 8, 8, 8}
298+ if err := d.write(0, append(row, row...), 2, 2, 0); err != nil {
299+ t.Fatal(err)
300+ }
301+ if m := mem[0]; !bytes.Equal(m[:8], row) || !bytes.Equal(m[pitch:pitch+8], row) || m[8] != 0 {
302+ t.Fatal("rows of a narrow image")
303+ }
304+ if err := d.write(0, []byte{1}, 1, 1, 0); err == nil {
305+ t.Fatal("short image accepted")
306+ }
307+ if err := d.write(2, nil, 0, 0, 0); err == nil {
308+ t.Fatal("bad index accepted")
309+ }
310+}
311+
312+// HDR cursors convert exactly as the renderer did (see hdrcursor's vector).
313+func TestDumbCursorWriteHDR(t *testing.T) {
314+ k := newMockkms(t)
315+ mem := dumbOK(k, tSide*4)
316+ d, err := newDumbCursor(k, tSide)
317+ if err != nil {
318+ t.Fatal(err)
319+ }
320+ defer func() { dumbFree(k); d.close() }()
321+ if err := d.write(0, []byte{255, 255, 255, 255}, 1, 1, 203); err != nil {
322+ t.Fatal(err)
323+ }
324+ if m := mem[0]; m[0] != 148 || m[1] != 148 || m[2] != 148 || m[3] != 255 {
325+ t.Fatalf("HDR white: %v", m[:4])
326+ }
327+ if err := d.write(1, []byte{255, 255, 255, 255}, 1, 1, 0); err != nil || mem[1][0] != 255 {
328+ t.Fatalf("SDR white: %v %v", err, mem[1][:4])
329+ }
330+ if d.cache.Len() != 4 {
331+ t.Fatalf("cache %d", d.cache.Len())
332+ }
333+}
A internal/adapters/drm/dumb.go +77 -0
@@ -0,0 +1,77 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "unsafe"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+// KMS dumb buffers: the driver allocates a linear buffer the CPU maps and
12+// the display scans out. Cursor images use them (see cursor_images.go).
13+
14+const (
15+ ioctlCreateDumb = 0xC02064B2 // DRM_IOWR('d', 0xB2, struct drm_mode_create_dumb)
16+ ioctlMapDumb = 0xC01064B3 // DRM_IOWR('d', 0xB3, struct drm_mode_map_dumb)
17+ ioctlDestroyDumb = 0xC00464B4 // DRM_IOWR('d', 0xB4, struct drm_mode_destroy_dumb)
18+)
19+
20+type createDumb struct {
21+ height, width, bpp, flags uint32
22+ handle, pitch uint32
23+ size uint64
24+}
25+
26+type mapDumb struct {
27+ handle, pad uint32
28+ offset uint64
29+}
30+
31+// dumbBuffer is a dumb buffer's GEM handle, row pitch and size in bytes.
32+type dumbBuffer struct {
33+ handle, pitch uint32
34+ size uint64
35+}
36+
37+func (k kmsDevice) createDumb(width, height int) (dumbBuffer, error) {
38+ if width <= 0 || height <= 0 {
39+ return dumbBuffer{}, fmt.Errorf("dumb buffer size %dx%d", width, height)
40+ }
41+ c := createDumb{height: uint32(height), width: uint32(width), bpp: 32}
42+ if err := ioctl(k.fd, ioctlCreateDumb, unsafe.Pointer(&c)); err != nil {
43+ return dumbBuffer{}, err
44+ }
45+ return dumbBuffer{handle: c.handle, pitch: c.pitch, size: c.size}, nil
46+}
47+
48+func (k kmsDevice) mapDumb(b dumbBuffer) ([]byte, error) {
49+ m := mapDumb{handle: b.handle}
50+ if err := ioctl(k.fd, ioctlMapDumb, unsafe.Pointer(&m)); err != nil {
51+ return nil, err
52+ }
53+ if b.size == 0 || b.size > 1<<30 {
54+ return nil, fmt.Errorf("dumb buffer size %d", b.size)
55+ }
56+ return unix.Mmap(k.fd, int64(m.offset), int(b.size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
57+}
58+
59+func (k kmsDevice) unmapDumb(mem []byte) error { return unix.Munmap(mem) }
60+
61+// addFBHandle makes a framebuffer of format over the dumb buffer, without
62+// modifier (the driver's linear layout).
63+func (k kmsDevice) addFBHandle(b dumbBuffer, width, height int, format uint32) (uint32, error) {
64+ if b.handle == 0 {
65+ return 0, errors.New("no dumb buffer")
66+ }
67+ cmd := fbCmd2{width: uint32(width), height: uint32(height), format: format}
68+ cmd.handles[0], cmd.pitches[0] = b.handle, b.pitch
69+ if err := ioctl(k.fd, ioctlAddFB2, unsafe.Pointer(&cmd)); err != nil {
70+ return 0, errors.Join(errors.New("add fb2"), err)
71+ }
72+ return cmd.fbID, nil
73+}
74+
75+func (k kmsDevice) destroyDumb(handle uint32) error {
76+ return ioctl(k.fd, ioctlDestroyDumb, unsafe.Pointer(&handle))
77+}
@@ -0,0 +1,77 @@
1+package drm
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "unsafe"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+// KMS dumb buffers: the driver allocates a linear buffer the CPU maps and
12+// the display scans out. Cursor images use them (see cursor_images.go).
13+
14+const (
15+ ioctlCreateDumb = 0xC02064B2 // DRM_IOWR('d', 0xB2, struct drm_mode_create_dumb)
16+ ioctlMapDumb = 0xC01064B3 // DRM_IOWR('d', 0xB3, struct drm_mode_map_dumb)
17+ ioctlDestroyDumb = 0xC00464B4 // DRM_IOWR('d', 0xB4, struct drm_mode_destroy_dumb)
18+)
19+
20+type createDumb struct {
21+ height, width, bpp, flags uint32
22+ handle, pitch uint32
23+ size uint64
24+}
25+
26+type mapDumb struct {
27+ handle, pad uint32
28+ offset uint64
29+}
30+
31+// dumbBuffer is a dumb buffer's GEM handle, row pitch and size in bytes.
32+type dumbBuffer struct {
33+ handle, pitch uint32
34+ size uint64
35+}
36+
37+func (k kmsDevice) createDumb(width, height int) (dumbBuffer, error) {
38+ if width <= 0 || height <= 0 {
39+ return dumbBuffer{}, fmt.Errorf("dumb buffer size %dx%d", width, height)
40+ }
41+ c := createDumb{height: uint32(height), width: uint32(width), bpp: 32}
42+ if err := ioctl(k.fd, ioctlCreateDumb, unsafe.Pointer(&c)); err != nil {
43+ return dumbBuffer{}, err
44+ }
45+ return dumbBuffer{handle: c.handle, pitch: c.pitch, size: c.size}, nil
46+}
47+
48+func (k kmsDevice) mapDumb(b dumbBuffer) ([]byte, error) {
49+ m := mapDumb{handle: b.handle}
50+ if err := ioctl(k.fd, ioctlMapDumb, unsafe.Pointer(&m)); err != nil {
51+ return nil, err
52+ }
53+ if b.size == 0 || b.size > 1<<30 {
54+ return nil, fmt.Errorf("dumb buffer size %d", b.size)
55+ }
56+ return unix.Mmap(k.fd, int64(m.offset), int(b.size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
57+}
58+
59+func (k kmsDevice) unmapDumb(mem []byte) error { return unix.Munmap(mem) }
60+
61+// addFBHandle makes a framebuffer of format over the dumb buffer, without
62+// modifier (the driver's linear layout).
63+func (k kmsDevice) addFBHandle(b dumbBuffer, width, height int, format uint32) (uint32, error) {
64+ if b.handle == 0 {
65+ return 0, errors.New("no dumb buffer")
66+ }
67+ cmd := fbCmd2{width: uint32(width), height: uint32(height), format: format}
68+ cmd.handles[0], cmd.pitches[0] = b.handle, b.pitch
69+ if err := ioctl(k.fd, ioctlAddFB2, unsafe.Pointer(&cmd)); err != nil {
70+ return 0, errors.Join(errors.New("add fb2"), err)
71+ }
72+ return cmd.fbID, nil
73+}
74+
75+func (k kmsDevice) destroyDumb(handle uint32) error {
76+ return ioctl(k.fd, ioctlDestroyDumb, unsafe.Pointer(&handle))
77+}
A internal/adapters/drm/dumb_device_test.go +109 -0
@@ -0,0 +1,109 @@
1+package drm
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "golang.org/x/sys/unix"
11+)
12+
13+// Real-device check of the dumb buffer layer: no modeset, no atomic commit.
14+// It skips when no card node can be opened or makes dumb buffers.
15+func TestDumbBuffersOnRealDevice(t *testing.T) {
16+ cards, _ := filepath.Glob("/dev/dri/card*")
17+ if len(cards) == 0 {
18+ t.Skip("no /dev/dri/card* node")
19+ }
20+ for _, path := range cards {
21+ f, err := os.OpenFile(path, os.O_RDWR|unix.O_CLOEXEC, 0)
22+ if err != nil {
23+ t.Logf("%s: %v", path, err)
24+ continue
25+ }
26+ done := dumbDeviceRoundTrip(t, path, kmsDevice{fd: int(f.Fd())})
27+ f.Close()
28+ if done {
29+ return
30+ }
31+ }
32+ t.Skip("no card node makes dumb buffers for this user")
33+}
34+
35+// dumbDeviceRoundTrip runs the buffer life cycle on k and reports false
36+// when the card cannot do it for a reason unrelated to the code under test
37+// (the permission or the driver).
38+func dumbDeviceRoundTrip(t *testing.T, path string, k kmsDevice) bool {
39+ size := cursorSize(k.fd)
40+ var imgs [2]dumbBuffer
41+ var mems [2][]byte
42+ var fbs [2]uint32
43+ cleanup := func() {
44+ for i := range imgs {
45+ if fbs[i] != 0 {
46+ if err := k.rmFB(fbs[i]); err != nil {
47+ t.Errorf("rmfb: %v", err)
48+ }
49+ }
50+ if mems[i] != nil {
51+ if err := k.unmapDumb(mems[i]); err != nil {
52+ t.Errorf("munmap: %v", err)
53+ }
54+ }
55+ if imgs[i].handle != 0 {
56+ if err := k.destroyDumb(imgs[i].handle); err != nil {
57+ t.Errorf("destroy dumb: %v", err)
58+ }
59+ }
60+ }
61+ }
62+ defer cleanup()
63+ for i := range imgs {
64+ b, err := k.createDumb(size, size)
65+ if err != nil {
66+ if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) || errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EINVAL) {
67+ t.Logf("%s: create dumb: %v", path, err)
68+ return false
69+ }
70+ t.Fatalf("%s: create dumb: %v", path, err)
71+ }
72+ imgs[i] = b
73+ if int(b.pitch) < size*4 || b.size < uint64(int(b.pitch)*size) {
74+ t.Fatalf("%s: dumb %dx%d pitch %d size %d", path, size, size, b.pitch, b.size)
75+ }
76+ if mems[i], err = k.mapDumb(b); err != nil {
77+ t.Fatalf("%s: map dumb: %v", path, err)
78+ }
79+ if fbs[i], err = k.addFBHandle(b, size, size, fourccARGB); err != nil {
80+ t.Fatalf("%s: add fb2 ARGB8888: %v", path, err)
81+ }
82+ t.Logf("%s: dumb buffer %d: %dx%d pitch %d size %d fb %d", path, i, size, size, b.pitch, b.size, fbs[i])
83+ }
84+ // Write a pattern through the shared mapping; each buffer keeps its own.
85+ for i, mem := range mems {
86+ d := &dumbCursor{k: k, size: size}
87+ d.img[i] = dumbImage{pitch: int(imgs[i].pitch), mem: mem}
88+ px := bytes.Repeat([]byte{byte(0x10 + i), 0x20, 0x30, 0xff}, 3*2)
89+ if err := d.write(i, px, 3, 2, 0); err != nil {
90+ t.Fatal(err)
91+ }
92+ }
93+ for i, mem := range mems {
94+ pitch := int(imgs[i].pitch)
95+ want := []byte{byte(0x10 + i), 0x20, 0x30, 0xff}
96+ for y := range 2 {
97+ if got := mem[y*pitch : y*pitch+4]; !bytes.Equal(got, want) {
98+ t.Fatalf("%s: buffer %d row %d read back %v, want %v", path, i, y, got, want)
99+ }
100+ if got := mem[y*pitch+3*4]; got != 0 {
101+ t.Fatalf("%s: buffer %d row %d not cleared after the image", path, i, y)
102+ }
103+ }
104+ if mem[2*pitch] != 0 || mem[(size-1)*pitch+(size-1)*4] != 0 {
105+ t.Fatalf("%s: buffer %d not cleared below the image", path, i)
106+ }
107+ }
108+ return true
109+}
@@ -0,0 +1,109 @@
1+package drm
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "golang.org/x/sys/unix"
11+)
12+
13+// Real-device check of the dumb buffer layer: no modeset, no atomic commit.
14+// It skips when no card node can be opened or makes dumb buffers.
15+func TestDumbBuffersOnRealDevice(t *testing.T) {
16+ cards, _ := filepath.Glob("/dev/dri/card*")
17+ if len(cards) == 0 {
18+ t.Skip("no /dev/dri/card* node")
19+ }
20+ for _, path := range cards {
21+ f, err := os.OpenFile(path, os.O_RDWR|unix.O_CLOEXEC, 0)
22+ if err != nil {
23+ t.Logf("%s: %v", path, err)
24+ continue
25+ }
26+ done := dumbDeviceRoundTrip(t, path, kmsDevice{fd: int(f.Fd())})
27+ f.Close()
28+ if done {
29+ return
30+ }
31+ }
32+ t.Skip("no card node makes dumb buffers for this user")
33+}
34+
35+// dumbDeviceRoundTrip runs the buffer life cycle on k and reports false
36+// when the card cannot do it for a reason unrelated to the code under test
37+// (the permission or the driver).
38+func dumbDeviceRoundTrip(t *testing.T, path string, k kmsDevice) bool {
39+ size := cursorSize(k.fd)
40+ var imgs [2]dumbBuffer
41+ var mems [2][]byte
42+ var fbs [2]uint32
43+ cleanup := func() {
44+ for i := range imgs {
45+ if fbs[i] != 0 {
46+ if err := k.rmFB(fbs[i]); err != nil {
47+ t.Errorf("rmfb: %v", err)
48+ }
49+ }
50+ if mems[i] != nil {
51+ if err := k.unmapDumb(mems[i]); err != nil {
52+ t.Errorf("munmap: %v", err)
53+ }
54+ }
55+ if imgs[i].handle != 0 {
56+ if err := k.destroyDumb(imgs[i].handle); err != nil {
57+ t.Errorf("destroy dumb: %v", err)
58+ }
59+ }
60+ }
61+ }
62+ defer cleanup()
63+ for i := range imgs {
64+ b, err := k.createDumb(size, size)
65+ if err != nil {
66+ if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) || errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EINVAL) {
67+ t.Logf("%s: create dumb: %v", path, err)
68+ return false
69+ }
70+ t.Fatalf("%s: create dumb: %v", path, err)
71+ }
72+ imgs[i] = b
73+ if int(b.pitch) < size*4 || b.size < uint64(int(b.pitch)*size) {
74+ t.Fatalf("%s: dumb %dx%d pitch %d size %d", path, size, size, b.pitch, b.size)
75+ }
76+ if mems[i], err = k.mapDumb(b); err != nil {
77+ t.Fatalf("%s: map dumb: %v", path, err)
78+ }
79+ if fbs[i], err = k.addFBHandle(b, size, size, fourccARGB); err != nil {
80+ t.Fatalf("%s: add fb2 ARGB8888: %v", path, err)
81+ }
82+ t.Logf("%s: dumb buffer %d: %dx%d pitch %d size %d fb %d", path, i, size, size, b.pitch, b.size, fbs[i])
83+ }
84+ // Write a pattern through the shared mapping; each buffer keeps its own.
85+ for i, mem := range mems {
86+ d := &dumbCursor{k: k, size: size}
87+ d.img[i] = dumbImage{pitch: int(imgs[i].pitch), mem: mem}
88+ px := bytes.Repeat([]byte{byte(0x10 + i), 0x20, 0x30, 0xff}, 3*2)
89+ if err := d.write(i, px, 3, 2, 0); err != nil {
90+ t.Fatal(err)
91+ }
92+ }
93+ for i, mem := range mems {
94+ pitch := int(imgs[i].pitch)
95+ want := []byte{byte(0x10 + i), 0x20, 0x30, 0xff}
96+ for y := range 2 {
97+ if got := mem[y*pitch : y*pitch+4]; !bytes.Equal(got, want) {
98+ t.Fatalf("%s: buffer %d row %d read back %v, want %v", path, i, y, got, want)
99+ }
100+ if got := mem[y*pitch+3*4]; got != 0 {
101+ t.Fatalf("%s: buffer %d row %d not cleared after the image", path, i, y)
102+ }
103+ }
104+ if mem[2*pitch] != 0 || mem[(size-1)*pitch+(size-1)*4] != 0 {
105+ t.Fatalf("%s: buffer %d not cleared below the image", path, i)
106+ }
107+ }
108+ return true
109+}
M internal/adapters/drm/kms_mock_test.go +308 -0
@@ -113,6 +113,84 @@ func (_c *mockkms_addFB_Call) RunAndReturn(run func(b *ports.DMABuf, format uint
113113 return _c
114114 }
115115
116+// addFBHandle provides a mock function for the type mockkms
117+func (_mock *mockkms) addFBHandle(b dumbBuffer, width int, height int, format uint32) (uint32, error) {
118+ ret := _mock.Called(b, width, height, format)
119+
120+ if len(ret) == 0 {
121+ panic("no return value specified for addFBHandle")
122+ }
123+
124+ var r0 uint32
125+ var r1 error
126+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer, int, int, uint32) (uint32, error)); ok {
127+ return returnFunc(b, width, height, format)
128+ }
129+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer, int, int, uint32) uint32); ok {
130+ r0 = returnFunc(b, width, height, format)
131+ } else {
132+ r0 = ret.Get(0).(uint32)
133+ }
134+ if returnFunc, ok := ret.Get(1).(func(dumbBuffer, int, int, uint32) error); ok {
135+ r1 = returnFunc(b, width, height, format)
136+ } else {
137+ r1 = ret.Error(1)
138+ }
139+ return r0, r1
140+}
141+
142+// mockkms_addFBHandle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'addFBHandle'
143+type mockkms_addFBHandle_Call struct {
144+ *mock.Call
145+}
146+
147+// addFBHandle is a helper method to define mock.On call
148+// - b dumbBuffer
149+// - width int
150+// - height int
151+// - format uint32
152+func (_e *mockkms_Expecter) addFBHandle(b any, width any, height any, format any) *mockkms_addFBHandle_Call {
153+ return &mockkms_addFBHandle_Call{Call: _e.mock.On("addFBHandle", b, width, height, format)}
154+}
155+
156+func (_c *mockkms_addFBHandle_Call) Run(run func(b dumbBuffer, width int, height int, format uint32)) *mockkms_addFBHandle_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ var arg0 dumbBuffer
159+ if args[0] != nil {
160+ arg0 = args[0].(dumbBuffer)
161+ }
162+ var arg1 int
163+ if args[1] != nil {
164+ arg1 = args[1].(int)
165+ }
166+ var arg2 int
167+ if args[2] != nil {
168+ arg2 = args[2].(int)
169+ }
170+ var arg3 uint32
171+ if args[3] != nil {
172+ arg3 = args[3].(uint32)
173+ }
174+ run(
175+ arg0,
176+ arg1,
177+ arg2,
178+ arg3,
179+ )
180+ })
181+ return _c
182+}
183+
184+func (_c *mockkms_addFBHandle_Call) Return(v uint32, err error) *mockkms_addFBHandle_Call {
185+ _c.Call.Return(v, err)
186+ return _c
187+}
188+
189+func (_c *mockkms_addFBHandle_Call) RunAndReturn(run func(b dumbBuffer, width int, height int, format uint32) (uint32, error)) *mockkms_addFBHandle_Call {
190+ _c.Call.Return(run)
191+ return _c
192+}
193+
116194 // commit provides a mock function for the type mockkms
117195 func (_mock *mockkms) commit(req *atomicReq, flags uint32, userData uint64) error {
118196 ret := _mock.Called(req, flags, userData)
@@ -296,6 +374,72 @@ func (_c *mockkms_createBlob_Call) RunAndReturn(run func(data []byte) (uint32, e
296374 return _c
297375 }
298376
377+// createDumb provides a mock function for the type mockkms
378+func (_mock *mockkms) createDumb(width int, height int) (dumbBuffer, error) {
379+ ret := _mock.Called(width, height)
380+
381+ if len(ret) == 0 {
382+ panic("no return value specified for createDumb")
383+ }
384+
385+ var r0 dumbBuffer
386+ var r1 error
387+ if returnFunc, ok := ret.Get(0).(func(int, int) (dumbBuffer, error)); ok {
388+ return returnFunc(width, height)
389+ }
390+ if returnFunc, ok := ret.Get(0).(func(int, int) dumbBuffer); ok {
391+ r0 = returnFunc(width, height)
392+ } else {
393+ r0 = ret.Get(0).(dumbBuffer)
394+ }
395+ if returnFunc, ok := ret.Get(1).(func(int, int) error); ok {
396+ r1 = returnFunc(width, height)
397+ } else {
398+ r1 = ret.Error(1)
399+ }
400+ return r0, r1
401+}
402+
403+// mockkms_createDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'createDumb'
404+type mockkms_createDumb_Call struct {
405+ *mock.Call
406+}
407+
408+// createDumb is a helper method to define mock.On call
409+// - width int
410+// - height int
411+func (_e *mockkms_Expecter) createDumb(width any, height any) *mockkms_createDumb_Call {
412+ return &mockkms_createDumb_Call{Call: _e.mock.On("createDumb", width, height)}
413+}
414+
415+func (_c *mockkms_createDumb_Call) Run(run func(width int, height int)) *mockkms_createDumb_Call {
416+ _c.Call.Run(func(args mock.Arguments) {
417+ var arg0 int
418+ if args[0] != nil {
419+ arg0 = args[0].(int)
420+ }
421+ var arg1 int
422+ if args[1] != nil {
423+ arg1 = args[1].(int)
424+ }
425+ run(
426+ arg0,
427+ arg1,
428+ )
429+ })
430+ return _c
431+}
432+
433+func (_c *mockkms_createDumb_Call) Return(dumbBufferMoqParam dumbBuffer, err error) *mockkms_createDumb_Call {
434+ _c.Call.Return(dumbBufferMoqParam, err)
435+ return _c
436+}
437+
438+func (_c *mockkms_createDumb_Call) RunAndReturn(run func(width int, height int) (dumbBuffer, error)) *mockkms_createDumb_Call {
439+ _c.Call.Return(run)
440+ return _c
441+}
442+
299443 // createLease provides a mock function for the type mockkms
300444 func (_mock *mockkms) createLease(objects []uint32) (*os.File, uint32, error) {
301445 ret := _mock.Called(objects)
@@ -415,6 +559,57 @@ func (_c *mockkms_destroyBlob_Call) RunAndReturn(run func(id uint32) error) *moc
415559 return _c
416560 }
417561
562+// destroyDumb provides a mock function for the type mockkms
563+func (_mock *mockkms) destroyDumb(handle uint32) error {
564+ ret := _mock.Called(handle)
565+
566+ if len(ret) == 0 {
567+ panic("no return value specified for destroyDumb")
568+ }
569+
570+ var r0 error
571+ if returnFunc, ok := ret.Get(0).(func(uint32) error); ok {
572+ r0 = returnFunc(handle)
573+ } else {
574+ r0 = ret.Error(0)
575+ }
576+ return r0
577+}
578+
579+// mockkms_destroyDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'destroyDumb'
580+type mockkms_destroyDumb_Call struct {
581+ *mock.Call
582+}
583+
584+// destroyDumb is a helper method to define mock.On call
585+// - handle uint32
586+func (_e *mockkms_Expecter) destroyDumb(handle any) *mockkms_destroyDumb_Call {
587+ return &mockkms_destroyDumb_Call{Call: _e.mock.On("destroyDumb", handle)}
588+}
589+
590+func (_c *mockkms_destroyDumb_Call) Run(run func(handle uint32)) *mockkms_destroyDumb_Call {
591+ _c.Call.Run(func(args mock.Arguments) {
592+ var arg0 uint32
593+ if args[0] != nil {
594+ arg0 = args[0].(uint32)
595+ }
596+ run(
597+ arg0,
598+ )
599+ })
600+ return _c
601+}
602+
603+func (_c *mockkms_destroyDumb_Call) Return(err error) *mockkms_destroyDumb_Call {
604+ _c.Call.Return(err)
605+ return _c
606+}
607+
608+func (_c *mockkms_destroyDumb_Call) RunAndReturn(run func(handle uint32) error) *mockkms_destroyDumb_Call {
609+ _c.Call.Return(run)
610+ return _c
611+}
612+
418613 // getBlob provides a mock function for the type mockkms
419614 func (_mock *mockkms) getBlob(id uint32) ([]byte, error) {
420615 ret := _mock.Called(id)
@@ -477,6 +672,68 @@ func (_c *mockkms_getBlob_Call) RunAndReturn(run func(id uint32) ([]byte, error)
477672 return _c
478673 }
479674
675+// mapDumb provides a mock function for the type mockkms
676+func (_mock *mockkms) mapDumb(b dumbBuffer) ([]byte, error) {
677+ ret := _mock.Called(b)
678+
679+ if len(ret) == 0 {
680+ panic("no return value specified for mapDumb")
681+ }
682+
683+ var r0 []byte
684+ var r1 error
685+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer) ([]byte, error)); ok {
686+ return returnFunc(b)
687+ }
688+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer) []byte); ok {
689+ r0 = returnFunc(b)
690+ } else {
691+ if ret.Get(0) != nil {
692+ r0 = ret.Get(0).([]byte)
693+ }
694+ }
695+ if returnFunc, ok := ret.Get(1).(func(dumbBuffer) error); ok {
696+ r1 = returnFunc(b)
697+ } else {
698+ r1 = ret.Error(1)
699+ }
700+ return r0, r1
701+}
702+
703+// mockkms_mapDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'mapDumb'
704+type mockkms_mapDumb_Call struct {
705+ *mock.Call
706+}
707+
708+// mapDumb is a helper method to define mock.On call
709+// - b dumbBuffer
710+func (_e *mockkms_Expecter) mapDumb(b any) *mockkms_mapDumb_Call {
711+ return &mockkms_mapDumb_Call{Call: _e.mock.On("mapDumb", b)}
712+}
713+
714+func (_c *mockkms_mapDumb_Call) Run(run func(b dumbBuffer)) *mockkms_mapDumb_Call {
715+ _c.Call.Run(func(args mock.Arguments) {
716+ var arg0 dumbBuffer
717+ if args[0] != nil {
718+ arg0 = args[0].(dumbBuffer)
719+ }
720+ run(
721+ arg0,
722+ )
723+ })
724+ return _c
725+}
726+
727+func (_c *mockkms_mapDumb_Call) Return(bytes []byte, err error) *mockkms_mapDumb_Call {
728+ _c.Call.Return(bytes, err)
729+ return _c
730+}
731+
732+func (_c *mockkms_mapDumb_Call) RunAndReturn(run func(b dumbBuffer) ([]byte, error)) *mockkms_mapDumb_Call {
733+ _c.Call.Return(run)
734+ return _c
735+}
736+
480737 // objProps provides a mock function for the type mockkms
481738 func (_mock *mockkms) objProps(obj uint32, typ uint32) (map[string][2]uint64, error) {
482739 ret := _mock.Called(obj, typ)
@@ -898,3 +1155,54 @@ func (_c *mockkms_rmFB_Call) RunAndReturn(run func(id uint32) error) *mockkms_rm
8981155 _c.Call.Return(run)
8991156 return _c
9001157 }
1158+
1159+// unmapDumb provides a mock function for the type mockkms
1160+func (_mock *mockkms) unmapDumb(mem []byte) error {
1161+ ret := _mock.Called(mem)
1162+
1163+ if len(ret) == 0 {
1164+ panic("no return value specified for unmapDumb")
1165+ }
1166+
1167+ var r0 error
1168+ if returnFunc, ok := ret.Get(0).(func([]byte) error); ok {
1169+ r0 = returnFunc(mem)
1170+ } else {
1171+ r0 = ret.Error(0)
1172+ }
1173+ return r0
1174+}
1175+
1176+// mockkms_unmapDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'unmapDumb'
1177+type mockkms_unmapDumb_Call struct {
1178+ *mock.Call
1179+}
1180+
1181+// unmapDumb is a helper method to define mock.On call
1182+// - mem []byte
1183+func (_e *mockkms_Expecter) unmapDumb(mem any) *mockkms_unmapDumb_Call {
1184+ return &mockkms_unmapDumb_Call{Call: _e.mock.On("unmapDumb", mem)}
1185+}
1186+
1187+func (_c *mockkms_unmapDumb_Call) Run(run func(mem []byte)) *mockkms_unmapDumb_Call {
1188+ _c.Call.Run(func(args mock.Arguments) {
1189+ var arg0 []byte
1190+ if args[0] != nil {
1191+ arg0 = args[0].([]byte)
1192+ }
1193+ run(
1194+ arg0,
1195+ )
1196+ })
1197+ return _c
1198+}
1199+
1200+func (_c *mockkms_unmapDumb_Call) Return(err error) *mockkms_unmapDumb_Call {
1201+ _c.Call.Return(err)
1202+ return _c
1203+}
1204+
1205+func (_c *mockkms_unmapDumb_Call) RunAndReturn(run func(mem []byte) error) *mockkms_unmapDumb_Call {
1206+ _c.Call.Return(run)
1207+ return _c
1208+}
@@ -113,6 +113,84 @@ func (_c *mockkms_addFB_Call) RunAndReturn(run func(b *ports.DMABuf, format uint
113 return _c113 return _c
114 }114 }
115 115
116+// addFBHandle provides a mock function for the type mockkms
117+func (_mock *mockkms) addFBHandle(b dumbBuffer, width int, height int, format uint32) (uint32, error) {
118+ ret := _mock.Called(b, width, height, format)
119+
120+ if len(ret) == 0 {
121+ panic("no return value specified for addFBHandle")
122+ }
123+
124+ var r0 uint32
125+ var r1 error
126+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer, int, int, uint32) (uint32, error)); ok {
127+ return returnFunc(b, width, height, format)
128+ }
129+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer, int, int, uint32) uint32); ok {
130+ r0 = returnFunc(b, width, height, format)
131+ } else {
132+ r0 = ret.Get(0).(uint32)
133+ }
134+ if returnFunc, ok := ret.Get(1).(func(dumbBuffer, int, int, uint32) error); ok {
135+ r1 = returnFunc(b, width, height, format)
136+ } else {
137+ r1 = ret.Error(1)
138+ }
139+ return r0, r1
140+}
141+
142+// mockkms_addFBHandle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'addFBHandle'
143+type mockkms_addFBHandle_Call struct {
144+ *mock.Call
145+}
146+
147+// addFBHandle is a helper method to define mock.On call
148+// - b dumbBuffer
149+// - width int
150+// - height int
151+// - format uint32
152+func (_e *mockkms_Expecter) addFBHandle(b any, width any, height any, format any) *mockkms_addFBHandle_Call {
153+ return &mockkms_addFBHandle_Call{Call: _e.mock.On("addFBHandle", b, width, height, format)}
154+}
155+
156+func (_c *mockkms_addFBHandle_Call) Run(run func(b dumbBuffer, width int, height int, format uint32)) *mockkms_addFBHandle_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ var arg0 dumbBuffer
159+ if args[0] != nil {
160+ arg0 = args[0].(dumbBuffer)
161+ }
162+ var arg1 int
163+ if args[1] != nil {
164+ arg1 = args[1].(int)
165+ }
166+ var arg2 int
167+ if args[2] != nil {
168+ arg2 = args[2].(int)
169+ }
170+ var arg3 uint32
171+ if args[3] != nil {
172+ arg3 = args[3].(uint32)
173+ }
174+ run(
175+ arg0,
176+ arg1,
177+ arg2,
178+ arg3,
179+ )
180+ })
181+ return _c
182+}
183+
184+func (_c *mockkms_addFBHandle_Call) Return(v uint32, err error) *mockkms_addFBHandle_Call {
185+ _c.Call.Return(v, err)
186+ return _c
187+}
188+
189+func (_c *mockkms_addFBHandle_Call) RunAndReturn(run func(b dumbBuffer, width int, height int, format uint32) (uint32, error)) *mockkms_addFBHandle_Call {
190+ _c.Call.Return(run)
191+ return _c
192+}
193+
116 // commit provides a mock function for the type mockkms194 // commit provides a mock function for the type mockkms
117 func (_mock *mockkms) commit(req *atomicReq, flags uint32, userData uint64) error {195 func (_mock *mockkms) commit(req *atomicReq, flags uint32, userData uint64) error {
118 ret := _mock.Called(req, flags, userData)196 ret := _mock.Called(req, flags, userData)
@@ -296,6 +374,72 @@ func (_c *mockkms_createBlob_Call) RunAndReturn(run func(data []byte) (uint32, e
296 return _c374 return _c
297 }375 }
298 376
377+// createDumb provides a mock function for the type mockkms
378+func (_mock *mockkms) createDumb(width int, height int) (dumbBuffer, error) {
379+ ret := _mock.Called(width, height)
380+
381+ if len(ret) == 0 {
382+ panic("no return value specified for createDumb")
383+ }
384+
385+ var r0 dumbBuffer
386+ var r1 error
387+ if returnFunc, ok := ret.Get(0).(func(int, int) (dumbBuffer, error)); ok {
388+ return returnFunc(width, height)
389+ }
390+ if returnFunc, ok := ret.Get(0).(func(int, int) dumbBuffer); ok {
391+ r0 = returnFunc(width, height)
392+ } else {
393+ r0 = ret.Get(0).(dumbBuffer)
394+ }
395+ if returnFunc, ok := ret.Get(1).(func(int, int) error); ok {
396+ r1 = returnFunc(width, height)
397+ } else {
398+ r1 = ret.Error(1)
399+ }
400+ return r0, r1
401+}
402+
403+// mockkms_createDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'createDumb'
404+type mockkms_createDumb_Call struct {
405+ *mock.Call
406+}
407+
408+// createDumb is a helper method to define mock.On call
409+// - width int
410+// - height int
411+func (_e *mockkms_Expecter) createDumb(width any, height any) *mockkms_createDumb_Call {
412+ return &mockkms_createDumb_Call{Call: _e.mock.On("createDumb", width, height)}
413+}
414+
415+func (_c *mockkms_createDumb_Call) Run(run func(width int, height int)) *mockkms_createDumb_Call {
416+ _c.Call.Run(func(args mock.Arguments) {
417+ var arg0 int
418+ if args[0] != nil {
419+ arg0 = args[0].(int)
420+ }
421+ var arg1 int
422+ if args[1] != nil {
423+ arg1 = args[1].(int)
424+ }
425+ run(
426+ arg0,
427+ arg1,
428+ )
429+ })
430+ return _c
431+}
432+
433+func (_c *mockkms_createDumb_Call) Return(dumbBufferMoqParam dumbBuffer, err error) *mockkms_createDumb_Call {
434+ _c.Call.Return(dumbBufferMoqParam, err)
435+ return _c
436+}
437+
438+func (_c *mockkms_createDumb_Call) RunAndReturn(run func(width int, height int) (dumbBuffer, error)) *mockkms_createDumb_Call {
439+ _c.Call.Return(run)
440+ return _c
441+}
442+
299 // createLease provides a mock function for the type mockkms443 // createLease provides a mock function for the type mockkms
300 func (_mock *mockkms) createLease(objects []uint32) (*os.File, uint32, error) {444 func (_mock *mockkms) createLease(objects []uint32) (*os.File, uint32, error) {
301 ret := _mock.Called(objects)445 ret := _mock.Called(objects)
@@ -415,6 +559,57 @@ func (_c *mockkms_destroyBlob_Call) RunAndReturn(run func(id uint32) error) *moc
415 return _c559 return _c
416 }560 }
417 561
562+// destroyDumb provides a mock function for the type mockkms
563+func (_mock *mockkms) destroyDumb(handle uint32) error {
564+ ret := _mock.Called(handle)
565+
566+ if len(ret) == 0 {
567+ panic("no return value specified for destroyDumb")
568+ }
569+
570+ var r0 error
571+ if returnFunc, ok := ret.Get(0).(func(uint32) error); ok {
572+ r0 = returnFunc(handle)
573+ } else {
574+ r0 = ret.Error(0)
575+ }
576+ return r0
577+}
578+
579+// mockkms_destroyDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'destroyDumb'
580+type mockkms_destroyDumb_Call struct {
581+ *mock.Call
582+}
583+
584+// destroyDumb is a helper method to define mock.On call
585+// - handle uint32
586+func (_e *mockkms_Expecter) destroyDumb(handle any) *mockkms_destroyDumb_Call {
587+ return &mockkms_destroyDumb_Call{Call: _e.mock.On("destroyDumb", handle)}
588+}
589+
590+func (_c *mockkms_destroyDumb_Call) Run(run func(handle uint32)) *mockkms_destroyDumb_Call {
591+ _c.Call.Run(func(args mock.Arguments) {
592+ var arg0 uint32
593+ if args[0] != nil {
594+ arg0 = args[0].(uint32)
595+ }
596+ run(
597+ arg0,
598+ )
599+ })
600+ return _c
601+}
602+
603+func (_c *mockkms_destroyDumb_Call) Return(err error) *mockkms_destroyDumb_Call {
604+ _c.Call.Return(err)
605+ return _c
606+}
607+
608+func (_c *mockkms_destroyDumb_Call) RunAndReturn(run func(handle uint32) error) *mockkms_destroyDumb_Call {
609+ _c.Call.Return(run)
610+ return _c
611+}
612+
418 // getBlob provides a mock function for the type mockkms613 // getBlob provides a mock function for the type mockkms
419 func (_mock *mockkms) getBlob(id uint32) ([]byte, error) {614 func (_mock *mockkms) getBlob(id uint32) ([]byte, error) {
420 ret := _mock.Called(id)615 ret := _mock.Called(id)
@@ -477,6 +672,68 @@ func (_c *mockkms_getBlob_Call) RunAndReturn(run func(id uint32) ([]byte, error)
477 return _c672 return _c
478 }673 }
479 674
675+// mapDumb provides a mock function for the type mockkms
676+func (_mock *mockkms) mapDumb(b dumbBuffer) ([]byte, error) {
677+ ret := _mock.Called(b)
678+
679+ if len(ret) == 0 {
680+ panic("no return value specified for mapDumb")
681+ }
682+
683+ var r0 []byte
684+ var r1 error
685+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer) ([]byte, error)); ok {
686+ return returnFunc(b)
687+ }
688+ if returnFunc, ok := ret.Get(0).(func(dumbBuffer) []byte); ok {
689+ r0 = returnFunc(b)
690+ } else {
691+ if ret.Get(0) != nil {
692+ r0 = ret.Get(0).([]byte)
693+ }
694+ }
695+ if returnFunc, ok := ret.Get(1).(func(dumbBuffer) error); ok {
696+ r1 = returnFunc(b)
697+ } else {
698+ r1 = ret.Error(1)
699+ }
700+ return r0, r1
701+}
702+
703+// mockkms_mapDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'mapDumb'
704+type mockkms_mapDumb_Call struct {
705+ *mock.Call
706+}
707+
708+// mapDumb is a helper method to define mock.On call
709+// - b dumbBuffer
710+func (_e *mockkms_Expecter) mapDumb(b any) *mockkms_mapDumb_Call {
711+ return &mockkms_mapDumb_Call{Call: _e.mock.On("mapDumb", b)}
712+}
713+
714+func (_c *mockkms_mapDumb_Call) Run(run func(b dumbBuffer)) *mockkms_mapDumb_Call {
715+ _c.Call.Run(func(args mock.Arguments) {
716+ var arg0 dumbBuffer
717+ if args[0] != nil {
718+ arg0 = args[0].(dumbBuffer)
719+ }
720+ run(
721+ arg0,
722+ )
723+ })
724+ return _c
725+}
726+
727+func (_c *mockkms_mapDumb_Call) Return(bytes []byte, err error) *mockkms_mapDumb_Call {
728+ _c.Call.Return(bytes, err)
729+ return _c
730+}
731+
732+func (_c *mockkms_mapDumb_Call) RunAndReturn(run func(b dumbBuffer) ([]byte, error)) *mockkms_mapDumb_Call {
733+ _c.Call.Return(run)
734+ return _c
735+}
736+
480 // objProps provides a mock function for the type mockkms737 // objProps provides a mock function for the type mockkms
481 func (_mock *mockkms) objProps(obj uint32, typ uint32) (map[string][2]uint64, error) {738 func (_mock *mockkms) objProps(obj uint32, typ uint32) (map[string][2]uint64, error) {
482 ret := _mock.Called(obj, typ)739 ret := _mock.Called(obj, typ)
@@ -898,3 +1155,54 @@ func (_c *mockkms_rmFB_Call) RunAndReturn(run func(id uint32) error) *mockkms_rm
898 _c.Call.Return(run)1155 _c.Call.Return(run)
899 return _c1156 return _c
900 }1157 }
1158+
1159+// unmapDumb provides a mock function for the type mockkms
1160+func (_mock *mockkms) unmapDumb(mem []byte) error {
1161+ ret := _mock.Called(mem)
1162+
1163+ if len(ret) == 0 {
1164+ panic("no return value specified for unmapDumb")
1165+ }
1166+
1167+ var r0 error
1168+ if returnFunc, ok := ret.Get(0).(func([]byte) error); ok {
1169+ r0 = returnFunc(mem)
1170+ } else {
1171+ r0 = ret.Error(0)
1172+ }
1173+ return r0
1174+}
1175+
1176+// mockkms_unmapDumb_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'unmapDumb'
1177+type mockkms_unmapDumb_Call struct {
1178+ *mock.Call
1179+}
1180+
1181+// unmapDumb is a helper method to define mock.On call
1182+// - mem []byte
1183+func (_e *mockkms_Expecter) unmapDumb(mem any) *mockkms_unmapDumb_Call {
1184+ return &mockkms_unmapDumb_Call{Call: _e.mock.On("unmapDumb", mem)}
1185+}
1186+
1187+func (_c *mockkms_unmapDumb_Call) Run(run func(mem []byte)) *mockkms_unmapDumb_Call {
1188+ _c.Call.Run(func(args mock.Arguments) {
1189+ var arg0 []byte
1190+ if args[0] != nil {
1191+ arg0 = args[0].([]byte)
1192+ }
1193+ run(
1194+ arg0,
1195+ )
1196+ })
1197+ return _c
1198+}
1199+
1200+func (_c *mockkms_unmapDumb_Call) Return(err error) *mockkms_unmapDumb_Call {
1201+ _c.Call.Return(err)
1202+ return _c
1203+}
1204+
1205+func (_c *mockkms_unmapDumb_Call) RunAndReturn(run func(mem []byte) error) *mockkms_unmapDumb_Call {
1206+ _c.Call.Return(run)
1207+ return _c
1208+}
M internal/adapters/drm/kms_test.go +3 -0
@@ -32,6 +32,9 @@ func TestStructSizesMatchIoctls(t *testing.T) {
3232 {"atomic", unsafe.Sizeof(modeAtomic{}), ioctlAtomic},
3333 {"create_blob", unsafe.Sizeof(createBlob{}), ioctlCreateBlob},
3434 {"get_cap", unsafe.Sizeof(getCap{}), ioctlGetCap},
35+ {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb},
36+ {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb},
37+ {"destroy_dumb", unsafe.Sizeof(uint32(0)), ioctlDestroyDumb},
3538 {"destroy_blob", unsafe.Sizeof(uint32(0)), ioctlDestroyBlob},
3639 } {
3740 if want := (tc.req >> 16) & 0x3fff; tc.got != want {
@@ -32,6 +32,9 @@ func TestStructSizesMatchIoctls(t *testing.T) {
32 {"atomic", unsafe.Sizeof(modeAtomic{}), ioctlAtomic},32 {"atomic", unsafe.Sizeof(modeAtomic{}), ioctlAtomic},
33 {"create_blob", unsafe.Sizeof(createBlob{}), ioctlCreateBlob},33 {"create_blob", unsafe.Sizeof(createBlob{}), ioctlCreateBlob},
34 {"get_cap", unsafe.Sizeof(getCap{}), ioctlGetCap},34 {"get_cap", unsafe.Sizeof(getCap{}), ioctlGetCap},
35+ {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb},
36+ {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb},
37+ {"destroy_dumb", unsafe.Sizeof(uint32(0)), ioctlDestroyDumb},
35 {"destroy_blob", unsafe.Sizeof(uint32(0)), ioctlDestroyBlob},38 {"destroy_blob", unsafe.Sizeof(uint32(0)), ioctlDestroyBlob},
36 } {39 } {
37 if want := (tc.req >> 16) & 0x3fff; tc.got != want {40 if want := (tc.req >> 16) & 0x3fff; tc.got != want {
M internal/adapters/drm/output.go +51 -20
@@ -579,18 +579,44 @@ func (o *Output) modeBlobFor(mode modeInfo) (uint32, error) {
579579 return blob, nil
580580 }
581581
582-// testCursor turns the hardware cursor off when KMS refuses its images on
583-// the cursor plane (e.g. a pitch the plane cannot scan out).
584-func (o *Output) testCursor() {
582+// testCursor asks KMS whether the cursor plane takes the cursor images.
583+// Refused dumb images are replaced by the renderer's; when those are refused
584+// too, or there are none, the hardware cursor goes off. It reports whether
585+// the cursor changed (new images or off).
586+func (o *Output) testCursor() (changed bool) {
585587 c := o.cursor
586588 if c == nil || c.off || c.fbs[0] == 0 {
587- return
589+ return false
588590 }
589- req := &atomicReq{}
590- c.props(req, o.crtc, cursorState{on: true, fb: c.fbs[0]})
591- if err := o.k.commit(req, atomicTestOnly, 0); refused(err) {
592- o.cursorOff(err)
591+ for {
592+ req := &atomicReq{}
593+ c.props(req, o.crtc, cursorState{on: true, fb: c.fbs[0]})
594+ err := o.k.commit(req, atomicTestOnly, 0)
595+ if !refused(err) {
596+ return changed
597+ }
598+ // Dumb images refused: the renderer's get one try.
599+ if c.alloc != allocDumb {
600+ o.cursorOff(err)
601+ return true
602+ }
603+ ok, ferr := c.useFallback(o.cursorNits())
604+ if !ok || ferr != nil {
605+ o.cursorOff(errors.Join(err, ferr))
606+ return true
607+ }
608+ changed = true
609+ o.log.Warn().Err(err).Str("connector", o.conn.name).Str("alloc", allocVulkan).Int("size", c.size).Msg("cursor plane refused dumb images; hardware cursor uses renderer images")
610+ }
611+}
612+
613+// cursorNits is the SDR white the cursor images are converted for on an HDR
614+// output (0: SDR).
615+func (o *Output) cursorNits() float64 {
616+ if !o.hdr.on {
617+ return 0
593618 }
619+ return float64(o.hdr.settings.SDRBrightness)
594620 }
595621
596622 // cursorOff stops using the cursor plane for the rest of the output's life.
@@ -797,11 +823,10 @@ func (o *Output) frameTest(fb uint32, fence *os.File, vrr bool, cur cursorState,
797823 }
798824
799825 // cursorRefused reports, after a commit carrying the cursor failed with
800-// EINVAL, whether the cursor plane was the cause; then the cursor is off
801-// and the commit can be retried without it.
826+// EINVAL, whether the cursor plane was the cause; then the cursor is off or
827+// on other images, and the commit can be retried.
802828 func (o *Output) cursorRefused() bool {
803- o.testCursor()
804- return o.cursor.off
829+ return o.testCursor()
805830 }
806831
807832 // commitState commits a cursor or VRR change without a new frame.
@@ -1042,7 +1067,7 @@ func (o *Output) Close() {
10421067 o.ctmBlob = 0
10431068 }
10441069 if o.cursor != nil {
1045- o.cursor.free(o.k)
1070+ o.cursor.free()
10461071 }
10471072 o.freeImages()
10481073 if o.alloc != nil {
@@ -1086,9 +1111,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
10861111 }
10871112 defer r.Close()
10881113 if o.cursor != nil {
1089- if err := o.cursor.setup(o.k, r); err != nil {
1114+ dumbErr, err := o.cursor.setup(o.k, r)
1115+ switch {
1116+ case err != nil:
10901117 o.cursor.off = true
1091- o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("no hardware cursor images; hardware cursor off")
1118+ o.log.Warn().Err(errors.Join(dumbErr, err)).Str("connector", o.conn.name).Msg("no hardware cursor images; hardware cursor off")
1119+ case dumbErr != nil:
1120+ o.log.Warn().Err(dumbErr).Str("connector", o.conn.name).Str("alloc", o.cursor.alloc).Int("size", o.cursor.size).Msg("hardware cursor")
1121+ default:
1122+ o.log.Info().Str("connector", o.conn.name).Str("alloc", o.cursor.alloc).Int("size", o.cursor.size).Msg("hardware cursor")
10921123 }
10931124 }
10941125 // The seat sends its state first; while switched away (e.g. a monitor
@@ -1463,7 +1494,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14631494 stateDirty = true
14641495 if o.cursor != nil && !o.protected {
14651496 // An image that waited for a free slot loads now.
1466- if _, err := o.cursor.flushLater(r); err != nil {
1497+ if _, err := o.cursor.flushLater(o.cursorNits()); err != nil {
14671498 o.log.Warn().Err(err).Msg("cursor")
14681499 }
14691500 }
@@ -1484,7 +1515,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14841515 }
14851516 if !o.protected && o.cursor != nil && loadCursor != nil && (s.Scale != cursorScale || s.Transform != cursorTransform) {
14861517 cursorScale, cursorTransform = s.Scale, s.Transform
1487- o.setCursor(r, loadCursor, want, s.Scale, s.Transform)
1518+ o.setCursor(loadCursor, want, s.Scale, s.Transform)
14881519 stateDirty = true
14891520 }
14901521 scene, haveScene, dirty = s, true, true
@@ -1499,7 +1530,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
14991530 want = c
15001531 // Before the first scene the scale is unknown: loaded then.
15011532 if o.cursor != nil && loadCursor != nil && cursorScale > 0 {
1502- o.setCursor(r, loadCursor, want, cursorScale, cursorTransform)
1533+ o.setCursor(loadCursor, want, cursorScale, cursorTransform)
15031534 stateDirty = true
15041535 }
15051536 case b := <-contents:
@@ -1736,14 +1767,14 @@ func (o *Output) wakeCore() { corewake.Send(o.Frames, o.conn.name) }
17361767
17371768 // setCursor loads a cursor for scale and output transform t into the cursor
17381769 // images.
1739-func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) {
1770+func (o *Output) setCursor(load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) {
17401771 o.observeSecurity()
17411772 if o.protected || o.cursor.off {
17421773 return
17431774 }
17441775 img, err := load(c, scale, t, o.cursor.Limit())
17451776 if err == nil {
1746- err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY)
1777+ err = o.cursor.setImage(img.Pixels, img.W, img.H, img.HotX, img.HotY, o.cursorNits())
17471778 }
17481779 if err != nil {
17491780 o.log.Warn().Err(err).Float64("scale", scale).Int("transform", int(t)).Msg("cursor")
@@ -579,18 +579,44 @@ func (o *Output) modeBlobFor(mode modeInfo) (uint32, error) {
579 return blob, nil579 return blob, nil
580 }580 }
581 581
582-// testCursor turns the hardware cursor off when KMS refuses its images on582+// testCursor asks KMS whether the cursor plane takes the cursor images.
583-// the cursor plane (e.g. a pitch the plane cannot scan out).583+// Refused dumb images are replaced by the renderer's; when those are refused
584-func (o *Output) testCursor() {584+// too, or there are none, the hardware cursor goes off. It reports whether
585+// the cursor changed (new images or off).
586+func (o *Output) testCursor() (changed bool) {
585 c := o.cursor587 c := o.cursor
586 if c == nil || c.off || c.fbs[0] == 0 {588 if c == nil || c.off || c.fbs[0] == 0 {
587- return589+ return false
588 }590 }
589- req := &atomicReq{}591+ for {
590- c.props(req, o.crtc, cursorState{on: true, fb: c.fbs[0]})592+ req := &atomicReq{}
591- if err := o.k.commit(req, atomicTestOnly, 0); refused(err) {593+ c.props(req, o.crtc, cursorState{on: true, fb: c.fbs[0]})
592- o.cursorOff(err)594+ err := o.k.commit(req, atomicTestOnly, 0)
595+ if !refused(err) {
596+ return changed
597+ }
598+ // Dumb images refused: the renderer's get one try.
599+ if c.alloc != allocDumb {
600+ o.cursorOff(err)
601+ return true
602+ }
603+ ok, ferr := c.useFallback(o.cursorNits())
604+ if !ok || ferr != nil {
605+ o.cursorOff(errors.Join(err, ferr))
606+ return true
607+ }
608+ changed = true
609+ o.log.Warn().Err(err).Str("connector", o.conn.name).Str("alloc", allocVulkan).Int("size", c.size).Msg("cursor plane refused dumb images; hardware cursor uses renderer images")
610+ }
611+}
612+
613+// cursorNits is the SDR white the cursor images are converted for on an HDR
614+// output (0: SDR).
615+func (o *Output) cursorNits() float64 {
616+ if !o.hdr.on {
617+ return 0
593 }618 }
619+ return float64(o.hdr.settings.SDRBrightness)
594 }620 }
595 621
596 // cursorOff stops using the cursor plane for the rest of the output's life.622 // cursorOff stops using the cursor plane for the rest of the output's life.
@@ -797,11 +823,10 @@ func (o *Output) frameTest(fb uint32, fence *os.File, vrr bool, cur cursorState,
797 }823 }
798 824
799 // cursorRefused reports, after a commit carrying the cursor failed with825 // cursorRefused reports, after a commit carrying the cursor failed with
800-// EINVAL, whether the cursor plane was the cause; then the cursor is off826+// EINVAL, whether the cursor plane was the cause; then the cursor is off or
801-// and the commit can be retried without it.827+// on other images, and the commit can be retried.
802 func (o *Output) cursorRefused() bool {828 func (o *Output) cursorRefused() bool {
803- o.testCursor()829+ return o.testCursor()
804- return o.cursor.off
805 }830 }
806 831
807 // commitState commits a cursor or VRR change without a new frame.832 // commitState commits a cursor or VRR change without a new frame.
@@ -1042,7 +1067,7 @@ func (o *Output) Close() {
1042 o.ctmBlob = 01067 o.ctmBlob = 0
1043 }1068 }
1044 if o.cursor != nil {1069 if o.cursor != nil {
1045- o.cursor.free(o.k)1070+ o.cursor.free()
1046 }1071 }
1047 o.freeImages()1072 o.freeImages()
1048 if o.alloc != nil {1073 if o.alloc != nil {
@@ -1086,9 +1111,15 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1086 }1111 }
1087 defer r.Close()1112 defer r.Close()
1088 if o.cursor != nil {1113 if o.cursor != nil {
1089- if err := o.cursor.setup(o.k, r); err != nil {1114+ dumbErr, err := o.cursor.setup(o.k, r)
1115+ switch {
1116+ case err != nil:
1090 o.cursor.off = true1117 o.cursor.off = true
1091- o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("no hardware cursor images; hardware cursor off")1118+ o.log.Warn().Err(errors.Join(dumbErr, err)).Str("connector", o.conn.name).Msg("no hardware cursor images; hardware cursor off")
1119+ case dumbErr != nil:
1120+ o.log.Warn().Err(dumbErr).Str("connector", o.conn.name).Str("alloc", o.cursor.alloc).Int("size", o.cursor.size).Msg("hardware cursor")
1121+ default:
1122+ o.log.Info().Str("connector", o.conn.name).Str("alloc", o.cursor.alloc).Int("size", o.cursor.size).Msg("hardware cursor")
1092 }1123 }
1093 }1124 }
1094 // The seat sends its state first; while switched away (e.g. a monitor1125 // The seat sends its state first; while switched away (e.g. a monitor
@@ -1463,7 +1494,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1463 stateDirty = true1494 stateDirty = true
1464 if o.cursor != nil && !o.protected {1495 if o.cursor != nil && !o.protected {
1465 // An image that waited for a free slot loads now.1496 // An image that waited for a free slot loads now.
1466- if _, err := o.cursor.flushLater(r); err != nil {1497+ if _, err := o.cursor.flushLater(o.cursorNits()); err != nil {
1467 o.log.Warn().Err(err).Msg("cursor")1498 o.log.Warn().Err(err).Msg("cursor")
1468 }1499 }
1469 }1500 }
@@ -1484,7 +1515,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1484 }1515 }
1485 if !o.protected && o.cursor != nil && loadCursor != nil && (s.Scale != cursorScale || s.Transform != cursorTransform) {1516 if !o.protected && o.cursor != nil && loadCursor != nil && (s.Scale != cursorScale || s.Transform != cursorTransform) {
1486 cursorScale, cursorTransform = s.Scale, s.Transform1517 cursorScale, cursorTransform = s.Scale, s.Transform
1487- o.setCursor(r, loadCursor, want, s.Scale, s.Transform)1518+ o.setCursor(loadCursor, want, s.Scale, s.Transform)
1488 stateDirty = true1519 stateDirty = true
1489 }1520 }
1490 scene, haveScene, dirty = s, true, true1521 scene, haveScene, dirty = s, true, true
@@ -1499,7 +1530,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
1499 want = c1530 want = c
1500 // Before the first scene the scale is unknown: loaded then.1531 // Before the first scene the scale is unknown: loaded then.
1501 if o.cursor != nil && loadCursor != nil && cursorScale > 0 {1532 if o.cursor != nil && loadCursor != nil && cursorScale > 0 {
1502- o.setCursor(r, loadCursor, want, cursorScale, cursorTransform)1533+ o.setCursor(loadCursor, want, cursorScale, cursorTransform)
1503 stateDirty = true1534 stateDirty = true
1504 }1535 }
1505 case b := <-contents:1536 case b := <-contents:
@@ -1736,14 +1767,14 @@ func (o *Output) wakeCore() { corewake.Send(o.Frames, o.conn.name) }
1736 1767
1737 // setCursor loads a cursor for scale and output transform t into the cursor1768 // setCursor loads a cursor for scale and output transform t into the cursor
1738 // images.1769 // images.
1739-func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) {1770+func (o *Output) setCursor(load CursorLoader, c ports.CursorChange, scale float64, t ports.BufferTransform) {
1740 o.observeSecurity()1771 o.observeSecurity()
1741 if o.protected || o.cursor.off {1772 if o.protected || o.cursor.off {
1742 return1773 return
1743 }1774 }
1744 img, err := load(c, scale, t, o.cursor.Limit())1775 img, err := load(c, scale, t, o.cursor.Limit())
1745 if err == nil {1776 if err == nil {
1746- err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY)1777+ err = o.cursor.setImage(img.Pixels, img.W, img.H, img.HotX, img.HotY, o.cursorNits())
1747 }1778 }
1748 if err != nil {1779 if err != nil {
1749 o.log.Warn().Err(err).Float64("scale", scale).Int("transform", int(t)).Msg("cursor")1780 o.log.Warn().Err(err).Float64("scale", scale).Int("transform", int(t)).Msg("cursor")
A internal/adapters/hdrcursor/hdrcursor.go +100 -0
@@ -0,0 +1,100 @@
1+// Package hdrcursor converts cursor images to the HDR10 output encoding
2+// (BT.2020 PQ) for cursor planes, whichever adapter owns the image memory.
3+package hdrcursor
4+
5+import (
6+ "crypto/sha256"
7+ "encoding/binary"
8+ "math"
9+)
10+
11+var srgbTable = func() (table [256]float64) {
12+ for i := range table {
13+ table[i] = SRGBToLinear(float64(i) / 255)
14+ }
15+ return
16+}()
17+
18+// SRGBToLinear decodes a unit-range sRGB component.
19+func SRGBToLinear(v float64) float64 {
20+ if v <= 0.04045 {
21+ return v / 12.92
22+ }
23+ return math.Pow((v+0.055)/1.055, 2.4)
24+}
25+
26+// PQEncode encodes absolute luminance in nits using SMPTE ST 2084.
27+func PQEncode(nits float64) float64 {
28+ if nits <= 0 {
29+ return 0
30+ }
31+ x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
32+ return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
33+}
34+
35+// Pixel converts premultiplied BGRA bytes in place, preserving alpha. The
36+// source is quantized to its 8-bit unassociated sRGB value for the LUT.
37+func Pixel(p []byte, nits float64) {
38+ a := float64(p[3]) / 255
39+ if a == 0 {
40+ clear(p[:3])
41+ return
42+ }
43+ index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
44+ r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
45+ r, g, b = PQEncode((0.627404*r+0.329283*g+0.043313*b)*nits), PQEncode((0.069097*r+0.919540*g+0.011362*b)*nits), PQEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
46+ p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
47+}
48+
49+// Cache converts cursor images and keeps only the last result, bounded by
50+// the cursor plane's maximum side regardless of cursor theme size. The
51+// zero value is ready; it is not safe for concurrent use.
52+type Cache struct {
53+ key [32]byte
54+ pixels []byte
55+}
56+
57+// Len is the size of the cached conversion in bytes (0: empty).
58+func (c *Cache) Len() int { return len(c.pixels) }
59+
60+// Key identifies the cached conversion (the zero key: empty).
61+func (c *Cache) Key() [32]byte { return c.key }
62+
63+// Reset drops the cached conversion.
64+func (c *Cache) Reset() { *c = Cache{} }
65+
66+func key(pixels []byte, w, h, side int, nits float64) [32]byte {
67+ var header [32]byte
68+ binary.LittleEndian.PutUint64(header[:8], uint64(w))
69+ binary.LittleEndian.PutUint64(header[8:16], uint64(h))
70+ binary.LittleEndian.PutUint64(header[16:24], math.Float64bits(nits))
71+ binary.LittleEndian.PutUint64(header[24:], uint64(side))
72+ digest := sha256.New()
73+ _, _ = digest.Write(header[:])
74+ _, _ = digest.Write(pixels)
75+ var k [32]byte
76+ digest.Sum(k[:0])
77+ return k
78+}
79+
80+// Convert returns the w×h premultiplied BGRA image (w*4 per row) cropped
81+// to side×side and converted for an SDR white of nits, packed without row
82+// padding: min(w, side) pixels per row, min(h, side) rows. The result is
83+// owned by the cache and valid until the next call.
84+func (c *Cache) Convert(pixels []byte, w, h, side int, nits float64) []byte {
85+ pixels = pixels[:w*h*4]
86+ k := key(pixels, w, h, side, nits)
87+ if c.pixels != nil && c.key == k {
88+ return c.pixels
89+ }
90+ cw, ch := min(w, side)*4, min(h, side)
91+ out := make([]byte, cw*ch)
92+ for y := range ch {
93+ copy(out[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
94+ for x := 0; x < cw; x += 4 {
95+ Pixel(out[y*cw+x:y*cw+x+4], nits)
96+ }
97+ }
98+ c.key, c.pixels = k, out
99+ return out
100+}
@@ -0,0 +1,100 @@
1+// Package hdrcursor converts cursor images to the HDR10 output encoding
2+// (BT.2020 PQ) for cursor planes, whichever adapter owns the image memory.
3+package hdrcursor
4+
5+import (
6+ "crypto/sha256"
7+ "encoding/binary"
8+ "math"
9+)
10+
11+var srgbTable = func() (table [256]float64) {
12+ for i := range table {
13+ table[i] = SRGBToLinear(float64(i) / 255)
14+ }
15+ return
16+}()
17+
18+// SRGBToLinear decodes a unit-range sRGB component.
19+func SRGBToLinear(v float64) float64 {
20+ if v <= 0.04045 {
21+ return v / 12.92
22+ }
23+ return math.Pow((v+0.055)/1.055, 2.4)
24+}
25+
26+// PQEncode encodes absolute luminance in nits using SMPTE ST 2084.
27+func PQEncode(nits float64) float64 {
28+ if nits <= 0 {
29+ return 0
30+ }
31+ x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
32+ return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
33+}
34+
35+// Pixel converts premultiplied BGRA bytes in place, preserving alpha. The
36+// source is quantized to its 8-bit unassociated sRGB value for the LUT.
37+func Pixel(p []byte, nits float64) {
38+ a := float64(p[3]) / 255
39+ if a == 0 {
40+ clear(p[:3])
41+ return
42+ }
43+ index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
44+ r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
45+ r, g, b = PQEncode((0.627404*r+0.329283*g+0.043313*b)*nits), PQEncode((0.069097*r+0.919540*g+0.011362*b)*nits), PQEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
46+ p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
47+}
48+
49+// Cache converts cursor images and keeps only the last result, bounded by
50+// the cursor plane's maximum side regardless of cursor theme size. The
51+// zero value is ready; it is not safe for concurrent use.
52+type Cache struct {
53+ key [32]byte
54+ pixels []byte
55+}
56+
57+// Len is the size of the cached conversion in bytes (0: empty).
58+func (c *Cache) Len() int { return len(c.pixels) }
59+
60+// Key identifies the cached conversion (the zero key: empty).
61+func (c *Cache) Key() [32]byte { return c.key }
62+
63+// Reset drops the cached conversion.
64+func (c *Cache) Reset() { *c = Cache{} }
65+
66+func key(pixels []byte, w, h, side int, nits float64) [32]byte {
67+ var header [32]byte
68+ binary.LittleEndian.PutUint64(header[:8], uint64(w))
69+ binary.LittleEndian.PutUint64(header[8:16], uint64(h))
70+ binary.LittleEndian.PutUint64(header[16:24], math.Float64bits(nits))
71+ binary.LittleEndian.PutUint64(header[24:], uint64(side))
72+ digest := sha256.New()
73+ _, _ = digest.Write(header[:])
74+ _, _ = digest.Write(pixels)
75+ var k [32]byte
76+ digest.Sum(k[:0])
77+ return k
78+}
79+
80+// Convert returns the w×h premultiplied BGRA image (w*4 per row) cropped
81+// to side×side and converted for an SDR white of nits, packed without row
82+// padding: min(w, side) pixels per row, min(h, side) rows. The result is
83+// owned by the cache and valid until the next call.
84+func (c *Cache) Convert(pixels []byte, w, h, side int, nits float64) []byte {
85+ pixels = pixels[:w*h*4]
86+ k := key(pixels, w, h, side, nits)
87+ if c.pixels != nil && c.key == k {
88+ return c.pixels
89+ }
90+ cw, ch := min(w, side)*4, min(h, side)
91+ out := make([]byte, cw*ch)
92+ for y := range ch {
93+ copy(out[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
94+ for x := 0; x < cw; x += 4 {
95+ Pixel(out[y*cw+x:y*cw+x+4], nits)
96+ }
97+ }
98+ c.key, c.pixels = k, out
99+ return out
100+}
A internal/adapters/hdrcursor/hdrcursor_test.go +76 -0
@@ -0,0 +1,76 @@
1+package hdrcursor
2+
3+import (
4+ "bytes"
5+ "math"
6+ "testing"
7+)
8+
9+// vector is a 5×3 premultiplied BGRA image: alpha ramps up with one fully
10+// transparent pixel (index 3).
11+func vector() (pixels []byte, w, h int) {
12+ w, h = 5, 3
13+ pixels = make([]byte, w*h*4)
14+ for i := range w * h {
15+ a := byte(i * 255 / (w*h - 1))
16+ if i == 3 {
17+ a = 0
18+ }
19+ c := func(v int) byte { return byte(v * int(a) / 255) }
20+ pixels[i*4], pixels[i*4+1], pixels[i*4+2], pixels[i*4+3] = c(i*37%256), c((i*91+20)%256), c((i*53+200)%256), a
21+ }
22+ return
23+}
24+
25+// The conversion is byte-identical to the one the Vulkan renderer did
26+// before it moved here (203 nits, cropped to 4×4).
27+func TestConvertMatchesRecordedVector(t *testing.T) {
28+ pixels, w, h := vector()
29+ want := []byte{0x0, 0x0, 0x0, 0x0, 0x5, 0x7, 0x9, 0x12, 0xd, 0x13, 0xf, 0x24, 0x0, 0x0, 0x0, 0x0, 0x2e, 0x31, 0x31, 0x5b, 0x3a, 0x1f, 0x20, 0x6d, 0x20, 0x39, 0x30, 0x7f, 0x33, 0x51, 0x46, 0x91, 0x4c, 0x58, 0x5f, 0xb6, 0x5f, 0x72, 0x5e, 0xc8, 0x6d, 0x4e, 0x4c, 0xda, 0x81, 0x75, 0x6c, 0xec}
30+ var c Cache
31+ if got := c.Convert(pixels, w, h, 4, 203); !bytes.Equal(got, want) {
32+ t.Fatalf("converted\n got %#v\nwant %#v", got, want)
33+ }
34+}
35+
36+func TestPixelWhite(t *testing.T) {
37+ p := []byte{255, 255, 255, 255}
38+ Pixel(p, 203)
39+ if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
40+ t.Fatalf("cursor white: %v", p)
41+ }
42+ if PQEncode(0) != 0 || math.Abs(PQEncode(10000)-1) > 1e-9 {
43+ t.Fatal("PQ endpoints")
44+ }
45+}
46+
47+func TestCacheKeepsLastConversion(t *testing.T) {
48+ var c Cache
49+ px := []byte{255, 255, 255, 255}
50+ a := c.Convert(px, 1, 1, 64, 203)
51+ if c.Len() != 4 || a[0] != 148 {
52+ t.Fatalf("len %d pixel %v", c.Len(), a)
53+ }
54+ first := c.Key()
55+ if b := c.Convert(px, 1, 1, 64, 203); &a[0] != &b[0] {
56+ t.Fatal("same image converted again")
57+ }
58+ for i := range 12 {
59+ v := byte(i * 18)
60+ c.Convert([]byte{v, v, v, 255}, 1, 1, 64, 203)
61+ if c.Len() != 4 {
62+ t.Fatalf("cache grew after image %d: %d", i, c.Len())
63+ }
64+ }
65+ if c.Key() == first {
66+ t.Fatal("conversion not replaced")
67+ }
68+ c.Convert(px, 1, 1, 64, 100)
69+ if c.Key() == first {
70+ t.Fatal("nits not part of the key")
71+ }
72+ c.Reset()
73+ if c.Len() != 0 || c.Key() != [32]byte{} {
74+ t.Fatal("reset left a conversion")
75+ }
76+}
@@ -0,0 +1,76 @@
1+package hdrcursor
2+
3+import (
4+ "bytes"
5+ "math"
6+ "testing"
7+)
8+
9+// vector is a 5×3 premultiplied BGRA image: alpha ramps up with one fully
10+// transparent pixel (index 3).
11+func vector() (pixels []byte, w, h int) {
12+ w, h = 5, 3
13+ pixels = make([]byte, w*h*4)
14+ for i := range w * h {
15+ a := byte(i * 255 / (w*h - 1))
16+ if i == 3 {
17+ a = 0
18+ }
19+ c := func(v int) byte { return byte(v * int(a) / 255) }
20+ pixels[i*4], pixels[i*4+1], pixels[i*4+2], pixels[i*4+3] = c(i*37%256), c((i*91+20)%256), c((i*53+200)%256), a
21+ }
22+ return
23+}
24+
25+// The conversion is byte-identical to the one the Vulkan renderer did
26+// before it moved here (203 nits, cropped to 4×4).
27+func TestConvertMatchesRecordedVector(t *testing.T) {
28+ pixels, w, h := vector()
29+ want := []byte{0x0, 0x0, 0x0, 0x0, 0x5, 0x7, 0x9, 0x12, 0xd, 0x13, 0xf, 0x24, 0x0, 0x0, 0x0, 0x0, 0x2e, 0x31, 0x31, 0x5b, 0x3a, 0x1f, 0x20, 0x6d, 0x20, 0x39, 0x30, 0x7f, 0x33, 0x51, 0x46, 0x91, 0x4c, 0x58, 0x5f, 0xb6, 0x5f, 0x72, 0x5e, 0xc8, 0x6d, 0x4e, 0x4c, 0xda, 0x81, 0x75, 0x6c, 0xec}
30+ var c Cache
31+ if got := c.Convert(pixels, w, h, 4, 203); !bytes.Equal(got, want) {
32+ t.Fatalf("converted\n got %#v\nwant %#v", got, want)
33+ }
34+}
35+
36+func TestPixelWhite(t *testing.T) {
37+ p := []byte{255, 255, 255, 255}
38+ Pixel(p, 203)
39+ if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
40+ t.Fatalf("cursor white: %v", p)
41+ }
42+ if PQEncode(0) != 0 || math.Abs(PQEncode(10000)-1) > 1e-9 {
43+ t.Fatal("PQ endpoints")
44+ }
45+}
46+
47+func TestCacheKeepsLastConversion(t *testing.T) {
48+ var c Cache
49+ px := []byte{255, 255, 255, 255}
50+ a := c.Convert(px, 1, 1, 64, 203)
51+ if c.Len() != 4 || a[0] != 148 {
52+ t.Fatalf("len %d pixel %v", c.Len(), a)
53+ }
54+ first := c.Key()
55+ if b := c.Convert(px, 1, 1, 64, 203); &a[0] != &b[0] {
56+ t.Fatal("same image converted again")
57+ }
58+ for i := range 12 {
59+ v := byte(i * 18)
60+ c.Convert([]byte{v, v, v, 255}, 1, 1, 64, 203)
61+ if c.Len() != 4 {
62+ t.Fatalf("cache grew after image %d: %d", i, c.Len())
63+ }
64+ }
65+ if c.Key() == first {
66+ t.Fatal("conversion not replaced")
67+ }
68+ c.Convert(px, 1, 1, 64, 100)
69+ if c.Key() == first {
70+ t.Fatal("nits not part of the key")
71+ }
72+ c.Reset()
73+ if c.Len() != 0 || c.Key() != [32]byte{} {
74+ t.Fatal("reset left a conversion")
75+ }
76+}
M internal/adapters/vulkan/cursor.go +5 -42
@@ -1,11 +1,8 @@
11 package vulkan
22
33 import (
4- "crypto/sha256"
5- "encoding/binary"
64 "errors"
75 "fmt"
8- "math"
96 "os"
107 "unsafe"
118
@@ -108,26 +105,6 @@ func (r *Renderer) cursorImage(size int) (*cursorImage, ports.DMABuf, error) {
108105 return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil
109106 }
110107
111-// cursorConversion keeps only the last converted image, bounded by the
112-// cursor plane's maximum dimensions, regardless of cursor theme size.
113-type cursorConversion struct {
114- key [32]byte
115- pixels []byte
116-}
117-
118-func cursorKey(pixels []byte, w, h int, nits float64) [32]byte {
119- var header [24]byte
120- binary.LittleEndian.PutUint64(header[:8], uint64(w))
121- binary.LittleEndian.PutUint64(header[8:16], uint64(h))
122- binary.LittleEndian.PutUint64(header[16:], math.Float64bits(nits))
123- digest := sha256.New()
124- _, _ = digest.Write(header[:])
125- _, _ = digest.Write(pixels)
126- var key [32]byte
127- digest.Sum(key[:0])
128- return key
129-}
130-
131108 // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row)
132109 // into cursor image i at its row pitch, cropped and cleared around.
133110 func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
@@ -141,27 +118,13 @@ func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
141118 mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size)
142119 clear(mem)
143120 cw := min(w, c.size) * 4
121+ src, stride := pixels, w*4
144122 if r.hdrNits > 0 {
145123 // WriteCursor is called only on image changes, never on moves.
146- key := cursorKey(pixels[:w*h*4], w, h, r.hdrNits)
147- converted := r.cursorCache.pixels
148- if converted == nil || r.cursorCache.key != key {
149- converted = make([]byte, cw*min(h, c.size))
150- for y := range min(h, c.size) {
151- copy(converted[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
152- for x := 0; x < cw; x += 4 {
153- hdrCursorPixel(converted[y*cw+x:y*cw+x+4], r.hdrNits)
154- }
155- }
156- r.cursorCache = cursorConversion{key: key, pixels: converted}
157- }
158- for y := range min(h, c.size) {
159- copy(mem[y*c.pitch:y*c.pitch+cw], converted[y*cw:(y+1)*cw])
160- }
161- } else {
162- for y := range min(h, c.size) {
163- copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw])
164- }
124+ src, stride = r.cursorCache.Convert(pixels, w, h, c.size, r.hdrNits), cw
125+ }
126+ for y := range min(h, c.size) {
127+ copy(mem[y*c.pitch:y*c.pitch+cw], src[y*stride:y*stride+cw])
165128 }
166129 return nil
167130 }
@@ -1,11 +1,8 @@
1 package vulkan1 package vulkan
2 2
3 import (3 import (
4- "crypto/sha256"
5- "encoding/binary"
6 "errors"4 "errors"
7 "fmt"5 "fmt"
8- "math"
9 "os"6 "os"
10 "unsafe"7 "unsafe"
11 8
@@ -108,26 +105,6 @@ func (r *Renderer) cursorImage(size int) (*cursorImage, ports.DMABuf, error) {
108 return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil105 return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil
109 }106 }
110 107
111-// cursorConversion keeps only the last converted image, bounded by the
112-// cursor plane's maximum dimensions, regardless of cursor theme size.
113-type cursorConversion struct {
114- key [32]byte
115- pixels []byte
116-}
117-
118-func cursorKey(pixels []byte, w, h int, nits float64) [32]byte {
119- var header [24]byte
120- binary.LittleEndian.PutUint64(header[:8], uint64(w))
121- binary.LittleEndian.PutUint64(header[8:16], uint64(h))
122- binary.LittleEndian.PutUint64(header[16:], math.Float64bits(nits))
123- digest := sha256.New()
124- _, _ = digest.Write(header[:])
125- _, _ = digest.Write(pixels)
126- var key [32]byte
127- digest.Sum(key[:0])
128- return key
129-}
130-
131 // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row)108 // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row)
132 // into cursor image i at its row pitch, cropped and cleared around.109 // into cursor image i at its row pitch, cropped and cleared around.
133 func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {110 func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
@@ -141,27 +118,13 @@ func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
141 mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size)118 mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size)
142 clear(mem)119 clear(mem)
143 cw := min(w, c.size) * 4120 cw := min(w, c.size) * 4
121+ src, stride := pixels, w*4
144 if r.hdrNits > 0 {122 if r.hdrNits > 0 {
145 // WriteCursor is called only on image changes, never on moves.123 // WriteCursor is called only on image changes, never on moves.
146- key := cursorKey(pixels[:w*h*4], w, h, r.hdrNits)124+ src, stride = r.cursorCache.Convert(pixels, w, h, c.size, r.hdrNits), cw
147- converted := r.cursorCache.pixels125+ }
148- if converted == nil || r.cursorCache.key != key {126+ for y := range min(h, c.size) {
149- converted = make([]byte, cw*min(h, c.size))127+ copy(mem[y*c.pitch:y*c.pitch+cw], src[y*stride:y*stride+cw])
150- for y := range min(h, c.size) {
151- copy(converted[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
152- for x := 0; x < cw; x += 4 {
153- hdrCursorPixel(converted[y*cw+x:y*cw+x+4], r.hdrNits)
154- }
155- }
156- r.cursorCache = cursorConversion{key: key, pixels: converted}
157- }
158- for y := range min(h, c.size) {
159- copy(mem[y*c.pitch:y*c.pitch+cw], converted[y*cw:(y+1)*cw])
160- }
161- } else {
162- for y := range min(h, c.size) {
163- copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw])
164- }
165 }128 }
166 return nil129 return nil
167 }130 }
M internal/adapters/vulkan/cursor_test.go +10 -10
@@ -65,26 +65,26 @@ func TestHDRCursorCachesConversion(t *testing.T) {
6565 if err := r.WriteCursor(0, px, 1, 1); err != nil {
6666 t.Fatal(err)
6767 }
68- if len(r.cursorCache.pixels) != 4 {
69- t.Fatalf("cache size %d", len(r.cursorCache.pixels))
68+ if r.cursorCache.Len() != 4 {
69+ t.Fatalf("cache size %d", r.cursorCache.Len())
7070 }
7171 if err := r.WriteCursor(1, px, 1, 1); err != nil {
7272 t.Fatal(err)
7373 }
74- if len(r.cursorCache.pixels) != 4 {
75- t.Fatalf("cache grew on reselect: %d", len(r.cursorCache.pixels))
74+ if r.cursorCache.Len() != 4 {
75+ t.Fatalf("cache grew on reselect: %d", r.cursorCache.Len())
7676 }
77- first := r.cursorCache.key
77+ first := r.cursorCache.Key()
7878 for i := 0; i < 12; i++ {
7979 color := byte(i * 18)
8080 if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil {
8181 t.Fatal(err)
8282 }
83- if len(r.cursorCache.pixels) != 4 {
84- t.Fatalf("cache grew after image %d: %d", i, len(r.cursorCache.pixels))
83+ if r.cursorCache.Len() != 4 {
84+ t.Fatalf("cache grew after image %d: %d", i, r.cursorCache.Len())
8585 }
8686 }
87- if first == r.cursorCache.key {
87+ if first == r.cursorCache.Key() {
8888 t.Fatal("cursor conversion not replaced")
8989 }
9090 if err := r.WriteCursor(1, px, 1, 1); err != nil {
@@ -100,7 +100,7 @@ func TestHDRCursorCachesConversion(t *testing.T) {
100100 if err := r.WriteCursor(0, px, 1, 1); err != nil {
101101 t.Fatal(err)
102102 }
103- if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || len(r.cursorCache.pixels) != 0 {
104- t.Fatalf("SDR cursor/cache: %v %d", got, len(r.cursorCache.pixels))
103+ if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || r.cursorCache.Len() != 0 {
104+ t.Fatalf("SDR cursor/cache: %v %d", got, r.cursorCache.Len())
105105 }
106106 }
@@ -65,26 +65,26 @@ func TestHDRCursorCachesConversion(t *testing.T) {
65 if err := r.WriteCursor(0, px, 1, 1); err != nil {65 if err := r.WriteCursor(0, px, 1, 1); err != nil {
66 t.Fatal(err)66 t.Fatal(err)
67 }67 }
68- if len(r.cursorCache.pixels) != 4 {68+ if r.cursorCache.Len() != 4 {
69- t.Fatalf("cache size %d", len(r.cursorCache.pixels))69+ t.Fatalf("cache size %d", r.cursorCache.Len())
70 }70 }
71 if err := r.WriteCursor(1, px, 1, 1); err != nil {71 if err := r.WriteCursor(1, px, 1, 1); err != nil {
72 t.Fatal(err)72 t.Fatal(err)
73 }73 }
74- if len(r.cursorCache.pixels) != 4 {74+ if r.cursorCache.Len() != 4 {
75- t.Fatalf("cache grew on reselect: %d", len(r.cursorCache.pixels))75+ t.Fatalf("cache grew on reselect: %d", r.cursorCache.Len())
76 }76 }
77- first := r.cursorCache.key77+ first := r.cursorCache.Key()
78 for i := 0; i < 12; i++ {78 for i := 0; i < 12; i++ {
79 color := byte(i * 18)79 color := byte(i * 18)
80 if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil {80 if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil {
81 t.Fatal(err)81 t.Fatal(err)
82 }82 }
83- if len(r.cursorCache.pixels) != 4 {83+ if r.cursorCache.Len() != 4 {
84- t.Fatalf("cache grew after image %d: %d", i, len(r.cursorCache.pixels))84+ t.Fatalf("cache grew after image %d: %d", i, r.cursorCache.Len())
85 }85 }
86 }86 }
87- if first == r.cursorCache.key {87+ if first == r.cursorCache.Key() {
88 t.Fatal("cursor conversion not replaced")88 t.Fatal("cursor conversion not replaced")
89 }89 }
90 if err := r.WriteCursor(1, px, 1, 1); err != nil {90 if err := r.WriteCursor(1, px, 1, 1); err != nil {
@@ -100,7 +100,7 @@ func TestHDRCursorCachesConversion(t *testing.T) {
100 if err := r.WriteCursor(0, px, 1, 1); err != nil {100 if err := r.WriteCursor(0, px, 1, 1); err != nil {
101 t.Fatal(err)101 t.Fatal(err)
102 }102 }
103- if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || len(r.cursorCache.pixels) != 0 {103+ if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || r.cursorCache.Len() != 0 {
104- t.Fatalf("SDR cursor/cache: %v %d", got, len(r.cursorCache.pixels))104+ t.Fatalf("SDR cursor/cache: %v %d", got, r.cursorCache.Len())
105 }105 }
106 }106 }
M internal/adapters/vulkan/hdr.go +2 -2
@@ -28,7 +28,7 @@ type hdrPass struct {
2828 // remains owner-goroutine confined; all GPU objects are reused each frame.
2929 func (r *Renderer) SetHDR(nits float64) {
3030 if nits != r.hdrNits {
31- r.cursorCache = cursorConversion{}
31+ r.cursorCache.Reset()
3232 if r.hdrNits > 0 && nits > 0 {
3333 r.hdrOwn.valid = false
3434 }
@@ -47,7 +47,7 @@ func (r *Renderer) SetHDR(nits float64) {
4747 }
4848 r.hdrNits = nits
4949 if nits <= 0 {
50- r.cursorCache = cursorConversion{}
50+ r.cursorCache.Reset()
5151 r.hdrError = nil
5252 return
5353 }
@@ -28,7 +28,7 @@ type hdrPass struct {
28 // remains owner-goroutine confined; all GPU objects are reused each frame.28 // remains owner-goroutine confined; all GPU objects are reused each frame.
29 func (r *Renderer) SetHDR(nits float64) {29 func (r *Renderer) SetHDR(nits float64) {
30 if nits != r.hdrNits {30 if nits != r.hdrNits {
31- r.cursorCache = cursorConversion{}31+ r.cursorCache.Reset()
32 if r.hdrNits > 0 && nits > 0 {32 if r.hdrNits > 0 && nits > 0 {
33 r.hdrOwn.valid = false33 r.hdrOwn.valid = false
34 }34 }
@@ -47,7 +47,7 @@ func (r *Renderer) SetHDR(nits float64) {
47 }47 }
48 r.hdrNits = nits48 r.hdrNits = nits
49 if nits <= 0 {49 if nits <= 0 {
50- r.cursorCache = cursorConversion{}50+ r.cursorCache.Reset()
51 r.hdrError = nil51 r.hdrError = nil
52 return52 return
53 }53 }
M internal/adapters/vulkan/pq.go +6 -34
@@ -1,21 +1,13 @@
11 package vulkan
22
3-import "math"
3+import (
4+ "math"
45
5-var srgbTable = func() (table [256]float64) {
6- for i := range table {
7- table[i] = srgbToLinear(float64(i) / 255)
8- }
9- return
10-}()
6+ "github.com/bnema/neferwl/internal/adapters/hdrcursor"
7+)
118
129 // srgbToLinear decodes a unit-range sRGB component.
13-func srgbToLinear(v float64) float64 {
14- if v <= 0.04045 {
15- return v / 12.92
16- }
17- return math.Pow((v+0.055)/1.055, 2.4)
18-}
10+func srgbToLinear(v float64) float64 { return hdrcursor.SRGBToLinear(v) }
1911
2012 // linearToSRGB encodes a unit-range linear component; the inverse of
2113 // srgbToLinear.
@@ -27,13 +19,7 @@ func linearToSRGB(v float64) float64 {
2719 }
2820
2921 // pqEncode encodes absolute luminance in nits using SMPTE ST 2084.
30-func pqEncode(nits float64) float64 {
31- if nits <= 0 {
32- return 0
33- }
34- x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
35- return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
36-}
22+func pqEncode(nits float64) float64 { return hdrcursor.PQEncode(nits) }
3723
3824 // hdrPixel transforms an unassociated sRGB triplet into BT.2020 PQ.
3925 func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
@@ -42,17 +28,3 @@ func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
4228 pqEncode((0.069097*r + 0.919540*g + 0.011362*b) * nits),
4329 pqEncode((0.016391*r + 0.088013*g + 0.895595*b) * nits)
4430 }
45-
46-// hdrCursorPixel converts premultiplied BGRA bytes, preserving alpha.
47-// The source is quantized to its 8-bit unassociated sRGB value for the LUT.
48-func hdrCursorPixel(p []byte, nits float64) {
49- a := float64(p[3]) / 255
50- if a == 0 {
51- clear(p[:3])
52- return
53- }
54- index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
55- r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
56- r, g, b = pqEncode((0.627404*r+0.329283*g+0.043313*b)*nits), pqEncode((0.069097*r+0.919540*g+0.011362*b)*nits), pqEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
57- p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
58-}
@@ -1,21 +1,13 @@
1 package vulkan1 package vulkan
2 2
3-import "math"3+import (
4+ "math"
4 5
5-var srgbTable = func() (table [256]float64) {6+ "github.com/bnema/neferwl/internal/adapters/hdrcursor"
6- for i := range table {7+)
7- table[i] = srgbToLinear(float64(i) / 255)
8- }
9- return
10-}()
11 8
12 // srgbToLinear decodes a unit-range sRGB component.9 // srgbToLinear decodes a unit-range sRGB component.
13-func srgbToLinear(v float64) float64 {10+func srgbToLinear(v float64) float64 { return hdrcursor.SRGBToLinear(v) }
14- if v <= 0.04045 {
15- return v / 12.92
16- }
17- return math.Pow((v+0.055)/1.055, 2.4)
18-}
19 11
20 // linearToSRGB encodes a unit-range linear component; the inverse of12 // linearToSRGB encodes a unit-range linear component; the inverse of
21 // srgbToLinear.13 // srgbToLinear.
@@ -27,13 +19,7 @@ func linearToSRGB(v float64) float64 {
27 }19 }
28 20
29 // pqEncode encodes absolute luminance in nits using SMPTE ST 2084.21 // pqEncode encodes absolute luminance in nits using SMPTE ST 2084.
30-func pqEncode(nits float64) float64 {22+func pqEncode(nits float64) float64 { return hdrcursor.PQEncode(nits) }
31- if nits <= 0 {
32- return 0
33- }
34- x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
35- return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
36-}
37 23
38 // hdrPixel transforms an unassociated sRGB triplet into BT.2020 PQ.24 // hdrPixel transforms an unassociated sRGB triplet into BT.2020 PQ.
39 func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {25 func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
@@ -42,17 +28,3 @@ func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
42 pqEncode((0.069097*r + 0.919540*g + 0.011362*b) * nits),28 pqEncode((0.069097*r + 0.919540*g + 0.011362*b) * nits),
43 pqEncode((0.016391*r + 0.088013*g + 0.895595*b) * nits)29 pqEncode((0.016391*r + 0.088013*g + 0.895595*b) * nits)
44 }30 }
45-
46-// hdrCursorPixel converts premultiplied BGRA bytes, preserving alpha.
47-// The source is quantized to its 8-bit unassociated sRGB value for the LUT.
48-func hdrCursorPixel(p []byte, nits float64) {
49- a := float64(p[3]) / 255
50- if a == 0 {
51- clear(p[:3])
52- return
53- }
54- index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
55- r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
56- r, g, b = pqEncode((0.627404*r+0.329283*g+0.043313*b)*nits), pqEncode((0.069097*r+0.919540*g+0.011362*b)*nits), pqEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
57- p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
58-}
M internal/adapters/vulkan/pq_test.go +0 -5
@@ -13,9 +13,4 @@ func TestPQMath(t *testing.T) {
1313 if pqEncode(0) != 0 || math.Abs(pqEncode(10000)-1) > 1e-9 {
1414 t.Fatal("PQ endpoints")
1515 }
16- p := []byte{255, 255, 255, 255}
17- hdrCursorPixel(p, 203)
18- if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
19- t.Fatalf("cursor white: %v", p)
20- }
2116 }
@@ -13,9 +13,4 @@ func TestPQMath(t *testing.T) {
13 if pqEncode(0) != 0 || math.Abs(pqEncode(10000)-1) > 1e-9 {13 if pqEncode(0) != 0 || math.Abs(pqEncode(10000)-1) > 1e-9 {
14 t.Fatal("PQ endpoints")14 t.Fatal("PQ endpoints")
15 }15 }
16- p := []byte{255, 255, 255, 255}
17- hdrCursorPixel(p, 203)
18- if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
19- t.Fatalf("cursor white: %v", p)
20- }
21 }16 }
M internal/adapters/vulkan/renderer.go +2 -1
@@ -9,6 +9,7 @@ import (
99 "strconv"
1010 "unsafe"
1111
12+ "github.com/bnema/neferwl/internal/adapters/hdrcursor"
1213 "github.com/bnema/neferwl/internal/adapters/hintlabel"
1314 "github.com/bnema/neferwl/internal/ports"
1415 "github.com/bnema/zerowrap"
@@ -55,7 +56,7 @@ type Renderer struct {
5556 virtual bool
5657 physical vk.PhysicalDevice
5758 // One converted cursor image is kept; changes replace it in bounded memory.
58- cursorCache cursorConversion
59+ cursorCache hdrcursor.Cache
5960 // lastHDRTarget is the index in targets of the exported PQ target the
6061 // last HDR frame rendered into; -1 when the current targets hold none.
6162 lastHDRTarget int
@@ -9,6 +9,7 @@ import (
9 "strconv"9 "strconv"
10 "unsafe"10 "unsafe"
11 11
12+ "github.com/bnema/neferwl/internal/adapters/hdrcursor"
12 "github.com/bnema/neferwl/internal/adapters/hintlabel"13 "github.com/bnema/neferwl/internal/adapters/hintlabel"
13 "github.com/bnema/neferwl/internal/ports"14 "github.com/bnema/neferwl/internal/ports"
14 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
@@ -55,7 +56,7 @@ type Renderer struct {
55 virtual bool56 virtual bool
56 physical vk.PhysicalDevice57 physical vk.PhysicalDevice
57 // One converted cursor image is kept; changes replace it in bounded memory.58 // One converted cursor image is kept; changes replace it in bounded memory.
58- cursorCache cursorConversion59+ cursorCache hdrcursor.Cache
59 // lastHDRTarget is the index in targets of the exported PQ target the60 // lastHDRTarget is the index in targets of the exported PQ target the
60 // last HDR frame rendered into; -1 when the current targets hold none.61 // last HDR frame rendered into; -1 when the current targets hold none.
61 lastHDRTarget int62 lastHDRTarget int

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close