detached 6d4359d

Merge branch 'fix/nvidia-cursor-plane' into main (!26)

fix(drm): allocate hardware cursor images as KMS dumb buffers
commit
6d4359dbea29ee59b995de9e90138af5f5b85898
author
ermite · · 2026-10-22 15:45 UTC
merge of
007c134 + bd83a2d · diff against the first parent
signature
SSH VerifiedSHA256:lY9fILbB/A+hln6Vh/iHoiiog74bbw/4qWrf/lrTU3U · ermite instance key Verified for the committer email
checks
1/1
23 files · +1738 -165
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 +491 -0
@@ -0,0 +1,491 @@
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(t *testing.T) [2]ports.DMABuf {
40+ var b [2]ports.DMABuf
41+ for i := range b {
42+ f, w, err := os.Pipe()
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ t.Cleanup(func() { _ = f.Close(); _ = w.Close() })
47+ if err := w.Close(); err != nil {
48+ t.Fatal(err)
49+ }
50+ b[i] = ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
51+ }
52+ return b
53+}
54+
55+func rendererOK(t *testing.T, k *mockkms, r *portsmocks.MockRenderer) {
56+ b := rendererBufs(t)
57+ r.EXPECT().CursorBuffers(tSide).Return(b, nil).Once()
58+ t.Cleanup(func() { assertPlanesClosed(t, b) })
59+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
60+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(81, nil).Once()
61+}
62+
63+// Dumb buffers come first: the renderer allocates nothing.
64+func TestCursorSetupUsesDumbBuffers(t *testing.T) {
65+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
66+ dumbOK(k, tSide*4)
67+ c := newCursor(&plane{}, tSide)
68+ dumbErr, err := c.setup(k, r)
69+ if dumbErr != nil || err != nil {
70+ t.Fatalf("setup: %v %v", dumbErr, err)
71+ }
72+ if c.alloc != allocDumb || c.fbs != [2]uint32{90, 91} {
73+ t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
74+ }
75+ dumbFree(k)
76+ c.free()
77+ if c.imgs != nil || c.fbs != [2]uint32{} {
78+ t.Fatal("images kept after free")
79+ }
80+}
81+
82+// A dumb buffer that cannot be made (or mapped, or wrapped) falls back to
83+// the renderer's images, and what was made is released.
84+func TestCursorSetupFallsBackToRenderer(t *testing.T) {
85+ for name, fail := range map[string]func(k *mockkms){
86+ "create": func(k *mockkms) {
87+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
88+ },
89+ "map": func(k *mockkms) {
90+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
91+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
92+ k.EXPECT().mapDumb(b).Return(nil, unix.ENOMEM).Once()
93+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
94+ },
95+ "framebuffer": func(k *mockkms) {
96+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
97+ mem := make([]byte, b.size)
98+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
99+ k.EXPECT().mapDumb(b).Return(mem, nil).Once()
100+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
101+ k.EXPECT().unmapDumb(mem).Return(nil).Once()
102+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
103+ },
104+ "second buffer": func(k *mockkms) {
105+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
106+ mem := make([]byte, b.size)
107+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
108+ k.EXPECT().mapDumb(b).Return(mem, nil).Once()
109+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(90, nil).Once()
110+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.EMFILE).Once()
111+ k.EXPECT().rmFB(uint32(90)).Return(nil).Once()
112+ k.EXPECT().unmapDumb(mem).Return(nil).Once()
113+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
114+ },
115+ } {
116+ t.Run(name, func(t *testing.T) {
117+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
118+ fail(k)
119+ rendererOK(t, k, r)
120+ c := newCursor(&plane{}, tSide)
121+ dumbErr, err := c.setup(k, r)
122+ if dumbErr == nil || err != nil {
123+ t.Fatalf("setup: %v %v", dumbErr, err)
124+ }
125+ if c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} {
126+ t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
127+ }
128+ })
129+ }
130+}
131+
132+// Without either allocator there are no images.
133+func TestCursorSetupFailsWithoutAllocators(t *testing.T) {
134+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
135+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
136+ r.EXPECT().CursorBuffers(tSide).Return([2]ports.DMABuf{}, errors.New("no export")).Once()
137+ c := newCursor(&plane{}, tSide)
138+ dumbErr, err := c.setup(k, r)
139+ if dumbErr == nil || err == nil || c.imgs != nil || c.fbs != [2]uint32{} {
140+ t.Fatalf("setup: %v %v imgs %v fbs %v", dumbErr, err, c.imgs, c.fbs)
141+ }
142+ if _, err := c.useFallback(0); err != nil {
143+ t.Fatal(err)
144+ }
145+}
146+
147+// A renderer image whose framebuffer fails is released.
148+func TestRendererCursorReleasesOnFailure(t *testing.T) {
149+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
150+ bufs := rendererBufs(t)
151+ r.EXPECT().CursorBuffers(tSide).Return(bufs, nil).Once()
152+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
153+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
154+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
155+ if _, err := newRendererCursor(k, r, tSide); err == nil {
156+ t.Fatal("framebuffer failure not reported")
157+ }
158+ assertPlanesClosed(t, bufs)
159+}
160+
161+func assertPlanesClosed(t *testing.T, bufs [2]ports.DMABuf) {
162+ t.Helper()
163+ for i, b := range bufs {
164+ for j, p := range b.Planes {
165+ if _, err := p.File.Stat(); !errors.Is(err, os.ErrClosed) {
166+ t.Errorf("buffer %d plane %d: Stat = %v, want os.ErrClosed", i, j, err)
167+ }
168+ }
169+ }
170+}
171+
172+// cursorOutput is a test output whose cursor plane has size tSide.
173+func cursorOutput(t *testing.T, errs ...error) (*Output, *mockkms, *portsmocks.MockRenderer, *[]commitRec) {
174+ o, k, commits := testOutput(t, errs...)
175+ o.cursor = newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, tSide)
176+ return o, k, portsmocks.NewMockRenderer(t), commits
177+}
178+
179+// The plane refuses the dumb images: the renderer's are tried (TEST_ONLY),
180+// the image on the cursor is rewritten into them, and writes go there.
181+func TestCursorDumbRefusedUsesRendererImages(t *testing.T) {
182+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
183+ dumbOK(k, tSide*4)
184+ if _, err := o.cursor.setup(k, r); err != nil {
185+ t.Fatal(err)
186+ }
187+ px := []byte{1, 2, 3, 4}
188+ if err := o.cursor.setImage(px, 1, 1, 2, 3, 0); err != nil {
189+ t.Fatal(err)
190+ }
191+ dumbFree(k)
192+ rendererOK(t, k, r)
193+ var written []int
194+ r.EXPECT().WriteCursor(mock.Anything, px, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {
195+ written = append(written, i)
196+ return nil
197+ })
198+ o.testCursor()
199+ c := o.cursor
200+ if c.off || c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} || !c.image || c.hotX != 2 || c.hotY != 3 {
201+ 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)
202+ }
203+ if len(written) != 1 || len(*commits) != 2 {
204+ t.Fatalf("written %v, %d TEST_ONLY commits", written, len(*commits))
205+ }
206+ if v, _ := (*commits)[1].req.value(tCursor, pFB); v != 80 || (*commits)[1].flags != atomicTestOnly {
207+ t.Fatalf("second test shows fb %d flags %#x", v, (*commits)[1].flags)
208+ }
209+ // Further changes are written by the renderer, not into the freed buffers.
210+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil || len(written) != 2 {
211+ t.Fatalf("write after fallback: %v %v", err, written)
212+ }
213+}
214+
215+// Both kinds refused: the software cursor takes over.
216+func TestCursorBothRefusedGoesOff(t *testing.T) {
217+ o, k, r, commits := cursorOutput(t, unix.EINVAL, unix.EINVAL)
218+ dumbOK(k, tSide*4)
219+ if _, err := o.cursor.setup(k, r); err != nil {
220+ t.Fatal(err)
221+ }
222+ dumbFree(k)
223+ rendererOK(t, k, r)
224+ if !o.testCursor() {
225+ t.Fatal("no change reported")
226+ }
227+ if !o.cursor.off || o.cursor.alloc != allocVulkan || len(*commits) != 2 {
228+ t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
229+ }
230+ if o.cursor.desired().on {
231+ t.Fatal("cursor shown while off")
232+ }
233+ // The renderer's images are released with the output.
234+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
235+ k.EXPECT().rmFB(uint32(81)).Return(nil).Once()
236+ o.cursor.free()
237+}
238+
239+// Dumb images the plane takes stay, with no second test.
240+func TestCursorDumbAcceptedStays(t *testing.T) {
241+ for _, tc := range []struct {
242+ name string
243+ err error
244+ }{{"accepted", nil}, {"non-refusal", unix.EIO}} {
245+ t.Run(tc.name, func(t *testing.T) {
246+ o, k, r, commits := cursorOutput(t, tc.err)
247+ dumbOK(k, tSide*4)
248+ if _, err := o.cursor.setup(k, r); err != nil {
249+ t.Fatal(err)
250+ }
251+ if o.testCursor() || o.cursor.off || o.cursor.alloc != allocDumb || len(*commits) != 1 {
252+ t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
253+ }
254+ dumbFree(k)
255+ o.cursor.free()
256+ })
257+ }
258+}
259+
260+// Renderer images refused while no dumb ones exist: off, no retry.
261+func TestCursorRendererRefusedGoesOff(t *testing.T) {
262+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
263+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
264+ rendererOK(t, k, r)
265+ if _, err := o.cursor.setup(k, r); err != nil {
266+ t.Fatal(err)
267+ }
268+ if !o.testCursor() || !o.cursor.off || len(*commits) != 1 {
269+ t.Fatalf("off %v commits %d", o.cursor.off, len(*commits))
270+ }
271+}
272+
273+// Writes go to the images that own the cursor, and the output's HDR white
274+// is passed along.
275+func TestCursorWritesGoToCurrentImages(t *testing.T) {
276+ o, _, _, _ := cursorOutput(t)
277+ imgs := newMockcursorImages(t)
278+ imgs.EXPECT().fb(0).Return(90).Once()
279+ imgs.EXPECT().fb(1).Return(91).Once()
280+ o.cursor.install(imgs, allocDumb)
281+ px := []byte{1, 2, 3, 4}
282+ imgs.EXPECT().write(0, px, 1, 1, float64(0)).Return(nil).Once()
283+ if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err != nil {
284+ t.Fatal(err)
285+ }
286+ o.hdr.on, o.hdr.settings.SDRBrightness = true, 203
287+ if got := o.cursorNits(); got != 203 {
288+ t.Fatalf("HDR white %v", got)
289+ }
290+ o.cursor.screen = 90
291+ imgs.EXPECT().write(1, px, 1, 1, float64(203)).Return(errors.New("full")).Once()
292+ if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err == nil {
293+ t.Fatal("write error swallowed")
294+ }
295+}
296+
297+// Dumb pitch may exceed the row size: rows land at the pitch, the image is
298+// cropped to the side and cleared around.
299+func TestDumbCursorWritePitchCropClear(t *testing.T) {
300+ k := newMockkms(t)
301+ const pitch = tSide*4 + 32
302+ mem := dumbOK(k, pitch)
303+ d, err := newDumbCursor(k, tSide)
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ defer func() { dumbFree(k); d.close() }()
308+ big := make([]byte, 40*40*4)
309+ for y := range 40 {
310+ for x := range 40 {
311+ copy(big[(y*40+x)*4:], []byte{byte(x), byte(y), byte(x + y), 255})
312+ }
313+ }
314+ if err := d.write(1, big, 40, 40, 0); err != nil { // cropped to tSide
315+ t.Fatal(err)
316+ }
317+ last := (tSide - 1) * pitch
318+ for _, y := range []int{0, 1, 7, tSide - 1} {
319+ for _, x := range []int{0, 3, tSide - 1} {
320+ got := mem[1][y*pitch+x*4 : y*pitch+x*4+4]
321+ want := big[(y*40+x)*4 : (y*40+x)*4+4]
322+ if !bytes.Equal(got, want) {
323+ t.Fatalf("pixel %d,%d: %v want %v", x, y, got, want)
324+ }
325+ }
326+ }
327+ for y := range tSide {
328+ if !bytes.Equal(mem[1][y*pitch+tSide*4:(y+1)*pitch], make([]byte, pitch-tSide*4)) {
329+ t.Fatalf("row %d padding written", y)
330+ }
331+ }
332+ if err := d.write(1, []byte{1, 2, 3, 4}, 1, 1, 0); err != nil {
333+ t.Fatal(err)
334+ }
335+ m := mem[1]
336+ if !bytes.Equal(m[:4], []byte{1, 2, 3, 4}) || m[4] != 0 || m[pitch] != 0 || m[last+(tSide-1)*4] != 0 {
337+ t.Fatal("image not cleared around the new pixels")
338+ }
339+ // A wide, short image keeps its rows apart.
340+ row := []byte{9, 9, 9, 9, 8, 8, 8, 8}
341+ if err := d.write(0, append(row, row...), 2, 2, 0); err != nil {
342+ t.Fatal(err)
343+ }
344+ if m := mem[0]; !bytes.Equal(m[:8], row) || !bytes.Equal(m[pitch:pitch+8], row) || m[8] != 0 {
345+ t.Fatal("rows of a narrow image")
346+ }
347+ if err := d.write(0, []byte{1}, 1, 1, 0); err == nil {
348+ t.Fatal("short image accepted")
349+ }
350+ if err := d.write(2, nil, 0, 0, 0); err == nil {
351+ t.Fatal("bad index accepted")
352+ }
353+}
354+
355+// HDR cursors convert exactly as the renderer did (see hdrcursor's vector).
356+func TestDumbCursorWriteHDR(t *testing.T) {
357+ k := newMockkms(t)
358+ mem := dumbOK(k, tSide*4)
359+ d, err := newDumbCursor(k, tSide)
360+ if err != nil {
361+ t.Fatal(err)
362+ }
363+ defer func() { dumbFree(k); d.close() }()
364+ if err := d.write(0, []byte{255, 255, 255, 255}, 1, 1, 203); err != nil {
365+ t.Fatal(err)
366+ }
367+ if m := mem[0]; m[0] != 148 || m[1] != 148 || m[2] != 148 || m[3] != 255 {
368+ t.Fatalf("HDR white: %v", m[:4])
369+ }
370+ if err := d.write(1, []byte{255, 255, 255, 255}, 1, 1, 0); err != nil || mem[1][0] != 255 {
371+ t.Fatalf("SDR white: %v %v", err, mem[1][:4])
372+ }
373+ if d.cache.Len() != 4 {
374+ t.Fatalf("cache %d", d.cache.Len())
375+ }
376+}
377+
378+func TestCursorFallbackState(t *testing.T) {
379+ for _, mode := range []string{"loaded", "queued", "hidden"} {
380+ t.Run(mode, func(t *testing.T) {
381+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
382+ dumbOK(k, tSide*4)
383+ c := o.cursor
384+ if _, err := c.setup(k, r); err != nil {
385+ t.Fatal(err)
386+ }
387+ px := []byte{1, 2, 3, 4}
388+ if err := c.setImage(px, 1, 1, 2, 3, 0); err != nil {
389+ t.Fatal(err)
390+ }
391+ c.screen = 90
392+ c.committed(cursorState{on: true, fb: 91, x: 8, y: 9})
393+ want := []byte{1, 2, 3, 4}
394+ if mode == "queued" {
395+ px = []byte{5, 6, 7, 8}
396+ want = bytes.Clone(px)
397+ if err := c.setImage(px, 1, 1, 4, 5, 0); err != nil {
398+ t.Fatal(err)
399+ }
400+ if c.later == nil {
401+ t.Fatal("image not queued")
402+ }
403+ }
404+ if mode == "hidden" {
405+ if err := c.setImage(nil, 0, 0, 0, 0, 0); err != nil {
406+ t.Fatal(err)
407+ }
408+ }
409+ px[0] = 99 // Neither the loaded nor queued image may alias caller memory.
410+ dumbFree(k)
411+ rendererOK(t, k, r)
412+ if mode != "hidden" {
413+ r.EXPECT().WriteCursor(0, want, 1, 1).Return(nil).Once()
414+ }
415+ if !o.testCursor() {
416+ t.Fatal("swap not reported")
417+ }
418+ if c.screen != 0 || c.flying || c.flight != (cursorState{}) || c.applied != (cursorState{}) || c.later != nil {
419+ t.Fatalf("stale scanout state: screen %d flight %+v flying %v applied %+v", c.screen, c.flight, c.flying, c.applied)
420+ }
421+ c.landed()
422+ if c.screen != 0 {
423+ t.Fatal("old pending flight resurrected")
424+ }
425+ s := c.desired()
426+ if mode == "hidden" {
427+ if s.on || c.image {
428+ t.Fatal("hidden cursor shown")
429+ }
430+ } else {
431+ if !s.on || s.fb != 80 {
432+ t.Fatalf("desired %+v", s)
433+ }
434+ if mode == "queued" && (c.hotX != 4 || c.hotY != 5) {
435+ t.Fatal("queued hotspot lost")
436+ }
437+ }
438+ req := &atomicReq{}
439+ c.props(req, o.crtc, s)
440+ if err := k.commit(req, 0, 0); err != nil {
441+ t.Fatal(err)
442+ }
443+ wantFB := uint64(80)
444+ if mode == "hidden" {
445+ wantFB = 0
446+ }
447+ if fb, _ := (*commits)[2].req.value(tCursor, pFB); fb != wantFB {
448+ t.Fatalf("next commit fb %d want %d", fb, wantFB)
449+ }
450+ c.committed(s)
451+ c.landed()
452+ if c.screen != uint32(wantFB) {
453+ t.Fatalf("screen %d", c.screen)
454+ }
455+ })
456+ }
457+}
458+
459+func TestCursorFallbackFailureGoesOff(t *testing.T) {
460+ for _, mode := range []string{"allocation", "rewrite"} {
461+ t.Run(mode, func(t *testing.T) {
462+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
463+ dumbOK(k, tSide*4)
464+ c := o.cursor
465+ if _, err := c.setup(k, r); err != nil {
466+ t.Fatal(err)
467+ }
468+ px := []byte{1, 2, 3, 4}
469+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil {
470+ t.Fatal(err)
471+ }
472+ if mode == "allocation" {
473+ r.EXPECT().CursorBuffers(tSide).Return([2]ports.DMABuf{}, errors.New("allocation failed")).Once()
474+ } else {
475+ dumbFree(k)
476+ rendererOK(t, k, r)
477+ r.EXPECT().WriteCursor(0, px, 1, 1).Return(errors.New("rewrite failed")).Once()
478+ }
479+ if !o.testCursor() || !c.off || c.desired().on || len(*commits) != 1 {
480+ t.Fatalf("off %v desired %+v commits %d", c.off, c.desired(), len(*commits))
481+ }
482+ if mode == "allocation" {
483+ dumbFree(k)
484+ } else {
485+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
486+ k.EXPECT().rmFB(uint32(81)).Return(nil).Once()
487+ }
488+ c.free()
489+ })
490+ }
491+}
@@ -0,0 +1,491 @@
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(t *testing.T) [2]ports.DMABuf {
40+ var b [2]ports.DMABuf
41+ for i := range b {
42+ f, w, err := os.Pipe()
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ t.Cleanup(func() { _ = f.Close(); _ = w.Close() })
47+ if err := w.Close(); err != nil {
48+ t.Fatal(err)
49+ }
50+ b[i] = ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
51+ }
52+ return b
53+}
54+
55+func rendererOK(t *testing.T, k *mockkms, r *portsmocks.MockRenderer) {
56+ b := rendererBufs(t)
57+ r.EXPECT().CursorBuffers(tSide).Return(b, nil).Once()
58+ t.Cleanup(func() { assertPlanesClosed(t, b) })
59+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
60+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(81, nil).Once()
61+}
62+
63+// Dumb buffers come first: the renderer allocates nothing.
64+func TestCursorSetupUsesDumbBuffers(t *testing.T) {
65+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
66+ dumbOK(k, tSide*4)
67+ c := newCursor(&plane{}, tSide)
68+ dumbErr, err := c.setup(k, r)
69+ if dumbErr != nil || err != nil {
70+ t.Fatalf("setup: %v %v", dumbErr, err)
71+ }
72+ if c.alloc != allocDumb || c.fbs != [2]uint32{90, 91} {
73+ t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
74+ }
75+ dumbFree(k)
76+ c.free()
77+ if c.imgs != nil || c.fbs != [2]uint32{} {
78+ t.Fatal("images kept after free")
79+ }
80+}
81+
82+// A dumb buffer that cannot be made (or mapped, or wrapped) falls back to
83+// the renderer's images, and what was made is released.
84+func TestCursorSetupFallsBackToRenderer(t *testing.T) {
85+ for name, fail := range map[string]func(k *mockkms){
86+ "create": func(k *mockkms) {
87+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
88+ },
89+ "map": func(k *mockkms) {
90+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
91+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
92+ k.EXPECT().mapDumb(b).Return(nil, unix.ENOMEM).Once()
93+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
94+ },
95+ "framebuffer": func(k *mockkms) {
96+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
97+ mem := make([]byte, b.size)
98+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
99+ k.EXPECT().mapDumb(b).Return(mem, nil).Once()
100+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
101+ k.EXPECT().unmapDumb(mem).Return(nil).Once()
102+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
103+ },
104+ "second buffer": func(k *mockkms) {
105+ b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
106+ mem := make([]byte, b.size)
107+ k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
108+ k.EXPECT().mapDumb(b).Return(mem, nil).Once()
109+ k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(90, nil).Once()
110+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.EMFILE).Once()
111+ k.EXPECT().rmFB(uint32(90)).Return(nil).Once()
112+ k.EXPECT().unmapDumb(mem).Return(nil).Once()
113+ k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
114+ },
115+ } {
116+ t.Run(name, func(t *testing.T) {
117+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
118+ fail(k)
119+ rendererOK(t, k, r)
120+ c := newCursor(&plane{}, tSide)
121+ dumbErr, err := c.setup(k, r)
122+ if dumbErr == nil || err != nil {
123+ t.Fatalf("setup: %v %v", dumbErr, err)
124+ }
125+ if c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} {
126+ t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
127+ }
128+ })
129+ }
130+}
131+
132+// Without either allocator there are no images.
133+func TestCursorSetupFailsWithoutAllocators(t *testing.T) {
134+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
135+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
136+ r.EXPECT().CursorBuffers(tSide).Return([2]ports.DMABuf{}, errors.New("no export")).Once()
137+ c := newCursor(&plane{}, tSide)
138+ dumbErr, err := c.setup(k, r)
139+ if dumbErr == nil || err == nil || c.imgs != nil || c.fbs != [2]uint32{} {
140+ t.Fatalf("setup: %v %v imgs %v fbs %v", dumbErr, err, c.imgs, c.fbs)
141+ }
142+ if _, err := c.useFallback(0); err != nil {
143+ t.Fatal(err)
144+ }
145+}
146+
147+// A renderer image whose framebuffer fails is released.
148+func TestRendererCursorReleasesOnFailure(t *testing.T) {
149+ k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
150+ bufs := rendererBufs(t)
151+ r.EXPECT().CursorBuffers(tSide).Return(bufs, nil).Once()
152+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
153+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
154+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
155+ if _, err := newRendererCursor(k, r, tSide); err == nil {
156+ t.Fatal("framebuffer failure not reported")
157+ }
158+ assertPlanesClosed(t, bufs)
159+}
160+
161+func assertPlanesClosed(t *testing.T, bufs [2]ports.DMABuf) {
162+ t.Helper()
163+ for i, b := range bufs {
164+ for j, p := range b.Planes {
165+ if _, err := p.File.Stat(); !errors.Is(err, os.ErrClosed) {
166+ t.Errorf("buffer %d plane %d: Stat = %v, want os.ErrClosed", i, j, err)
167+ }
168+ }
169+ }
170+}
171+
172+// cursorOutput is a test output whose cursor plane has size tSide.
173+func cursorOutput(t *testing.T, errs ...error) (*Output, *mockkms, *portsmocks.MockRenderer, *[]commitRec) {
174+ o, k, commits := testOutput(t, errs...)
175+ o.cursor = newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, tSide)
176+ return o, k, portsmocks.NewMockRenderer(t), commits
177+}
178+
179+// The plane refuses the dumb images: the renderer's are tried (TEST_ONLY),
180+// the image on the cursor is rewritten into them, and writes go there.
181+func TestCursorDumbRefusedUsesRendererImages(t *testing.T) {
182+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
183+ dumbOK(k, tSide*4)
184+ if _, err := o.cursor.setup(k, r); err != nil {
185+ t.Fatal(err)
186+ }
187+ px := []byte{1, 2, 3, 4}
188+ if err := o.cursor.setImage(px, 1, 1, 2, 3, 0); err != nil {
189+ t.Fatal(err)
190+ }
191+ dumbFree(k)
192+ rendererOK(t, k, r)
193+ var written []int
194+ r.EXPECT().WriteCursor(mock.Anything, px, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {
195+ written = append(written, i)
196+ return nil
197+ })
198+ o.testCursor()
199+ c := o.cursor
200+ if c.off || c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} || !c.image || c.hotX != 2 || c.hotY != 3 {
201+ 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)
202+ }
203+ if len(written) != 1 || len(*commits) != 2 {
204+ t.Fatalf("written %v, %d TEST_ONLY commits", written, len(*commits))
205+ }
206+ if v, _ := (*commits)[1].req.value(tCursor, pFB); v != 80 || (*commits)[1].flags != atomicTestOnly {
207+ t.Fatalf("second test shows fb %d flags %#x", v, (*commits)[1].flags)
208+ }
209+ // Further changes are written by the renderer, not into the freed buffers.
210+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil || len(written) != 2 {
211+ t.Fatalf("write after fallback: %v %v", err, written)
212+ }
213+}
214+
215+// Both kinds refused: the software cursor takes over.
216+func TestCursorBothRefusedGoesOff(t *testing.T) {
217+ o, k, r, commits := cursorOutput(t, unix.EINVAL, unix.EINVAL)
218+ dumbOK(k, tSide*4)
219+ if _, err := o.cursor.setup(k, r); err != nil {
220+ t.Fatal(err)
221+ }
222+ dumbFree(k)
223+ rendererOK(t, k, r)
224+ if !o.testCursor() {
225+ t.Fatal("no change reported")
226+ }
227+ if !o.cursor.off || o.cursor.alloc != allocVulkan || len(*commits) != 2 {
228+ t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
229+ }
230+ if o.cursor.desired().on {
231+ t.Fatal("cursor shown while off")
232+ }
233+ // The renderer's images are released with the output.
234+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
235+ k.EXPECT().rmFB(uint32(81)).Return(nil).Once()
236+ o.cursor.free()
237+}
238+
239+// Dumb images the plane takes stay, with no second test.
240+func TestCursorDumbAcceptedStays(t *testing.T) {
241+ for _, tc := range []struct {
242+ name string
243+ err error
244+ }{{"accepted", nil}, {"non-refusal", unix.EIO}} {
245+ t.Run(tc.name, func(t *testing.T) {
246+ o, k, r, commits := cursorOutput(t, tc.err)
247+ dumbOK(k, tSide*4)
248+ if _, err := o.cursor.setup(k, r); err != nil {
249+ t.Fatal(err)
250+ }
251+ if o.testCursor() || o.cursor.off || o.cursor.alloc != allocDumb || len(*commits) != 1 {
252+ t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
253+ }
254+ dumbFree(k)
255+ o.cursor.free()
256+ })
257+ }
258+}
259+
260+// Renderer images refused while no dumb ones exist: off, no retry.
261+func TestCursorRendererRefusedGoesOff(t *testing.T) {
262+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
263+ k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
264+ rendererOK(t, k, r)
265+ if _, err := o.cursor.setup(k, r); err != nil {
266+ t.Fatal(err)
267+ }
268+ if !o.testCursor() || !o.cursor.off || len(*commits) != 1 {
269+ t.Fatalf("off %v commits %d", o.cursor.off, len(*commits))
270+ }
271+}
272+
273+// Writes go to the images that own the cursor, and the output's HDR white
274+// is passed along.
275+func TestCursorWritesGoToCurrentImages(t *testing.T) {
276+ o, _, _, _ := cursorOutput(t)
277+ imgs := newMockcursorImages(t)
278+ imgs.EXPECT().fb(0).Return(90).Once()
279+ imgs.EXPECT().fb(1).Return(91).Once()
280+ o.cursor.install(imgs, allocDumb)
281+ px := []byte{1, 2, 3, 4}
282+ imgs.EXPECT().write(0, px, 1, 1, float64(0)).Return(nil).Once()
283+ if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err != nil {
284+ t.Fatal(err)
285+ }
286+ o.hdr.on, o.hdr.settings.SDRBrightness = true, 203
287+ if got := o.cursorNits(); got != 203 {
288+ t.Fatalf("HDR white %v", got)
289+ }
290+ o.cursor.screen = 90
291+ imgs.EXPECT().write(1, px, 1, 1, float64(203)).Return(errors.New("full")).Once()
292+ if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err == nil {
293+ t.Fatal("write error swallowed")
294+ }
295+}
296+
297+// Dumb pitch may exceed the row size: rows land at the pitch, the image is
298+// cropped to the side and cleared around.
299+func TestDumbCursorWritePitchCropClear(t *testing.T) {
300+ k := newMockkms(t)
301+ const pitch = tSide*4 + 32
302+ mem := dumbOK(k, pitch)
303+ d, err := newDumbCursor(k, tSide)
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ defer func() { dumbFree(k); d.close() }()
308+ big := make([]byte, 40*40*4)
309+ for y := range 40 {
310+ for x := range 40 {
311+ copy(big[(y*40+x)*4:], []byte{byte(x), byte(y), byte(x + y), 255})
312+ }
313+ }
314+ if err := d.write(1, big, 40, 40, 0); err != nil { // cropped to tSide
315+ t.Fatal(err)
316+ }
317+ last := (tSide - 1) * pitch
318+ for _, y := range []int{0, 1, 7, tSide - 1} {
319+ for _, x := range []int{0, 3, tSide - 1} {
320+ got := mem[1][y*pitch+x*4 : y*pitch+x*4+4]
321+ want := big[(y*40+x)*4 : (y*40+x)*4+4]
322+ if !bytes.Equal(got, want) {
323+ t.Fatalf("pixel %d,%d: %v want %v", x, y, got, want)
324+ }
325+ }
326+ }
327+ for y := range tSide {
328+ if !bytes.Equal(mem[1][y*pitch+tSide*4:(y+1)*pitch], make([]byte, pitch-tSide*4)) {
329+ t.Fatalf("row %d padding written", y)
330+ }
331+ }
332+ if err := d.write(1, []byte{1, 2, 3, 4}, 1, 1, 0); err != nil {
333+ t.Fatal(err)
334+ }
335+ m := mem[1]
336+ if !bytes.Equal(m[:4], []byte{1, 2, 3, 4}) || m[4] != 0 || m[pitch] != 0 || m[last+(tSide-1)*4] != 0 {
337+ t.Fatal("image not cleared around the new pixels")
338+ }
339+ // A wide, short image keeps its rows apart.
340+ row := []byte{9, 9, 9, 9, 8, 8, 8, 8}
341+ if err := d.write(0, append(row, row...), 2, 2, 0); err != nil {
342+ t.Fatal(err)
343+ }
344+ if m := mem[0]; !bytes.Equal(m[:8], row) || !bytes.Equal(m[pitch:pitch+8], row) || m[8] != 0 {
345+ t.Fatal("rows of a narrow image")
346+ }
347+ if err := d.write(0, []byte{1}, 1, 1, 0); err == nil {
348+ t.Fatal("short image accepted")
349+ }
350+ if err := d.write(2, nil, 0, 0, 0); err == nil {
351+ t.Fatal("bad index accepted")
352+ }
353+}
354+
355+// HDR cursors convert exactly as the renderer did (see hdrcursor's vector).
356+func TestDumbCursorWriteHDR(t *testing.T) {
357+ k := newMockkms(t)
358+ mem := dumbOK(k, tSide*4)
359+ d, err := newDumbCursor(k, tSide)
360+ if err != nil {
361+ t.Fatal(err)
362+ }
363+ defer func() { dumbFree(k); d.close() }()
364+ if err := d.write(0, []byte{255, 255, 255, 255}, 1, 1, 203); err != nil {
365+ t.Fatal(err)
366+ }
367+ if m := mem[0]; m[0] != 148 || m[1] != 148 || m[2] != 148 || m[3] != 255 {
368+ t.Fatalf("HDR white: %v", m[:4])
369+ }
370+ if err := d.write(1, []byte{255, 255, 255, 255}, 1, 1, 0); err != nil || mem[1][0] != 255 {
371+ t.Fatalf("SDR white: %v %v", err, mem[1][:4])
372+ }
373+ if d.cache.Len() != 4 {
374+ t.Fatalf("cache %d", d.cache.Len())
375+ }
376+}
377+
378+func TestCursorFallbackState(t *testing.T) {
379+ for _, mode := range []string{"loaded", "queued", "hidden"} {
380+ t.Run(mode, func(t *testing.T) {
381+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
382+ dumbOK(k, tSide*4)
383+ c := o.cursor
384+ if _, err := c.setup(k, r); err != nil {
385+ t.Fatal(err)
386+ }
387+ px := []byte{1, 2, 3, 4}
388+ if err := c.setImage(px, 1, 1, 2, 3, 0); err != nil {
389+ t.Fatal(err)
390+ }
391+ c.screen = 90
392+ c.committed(cursorState{on: true, fb: 91, x: 8, y: 9})
393+ want := []byte{1, 2, 3, 4}
394+ if mode == "queued" {
395+ px = []byte{5, 6, 7, 8}
396+ want = bytes.Clone(px)
397+ if err := c.setImage(px, 1, 1, 4, 5, 0); err != nil {
398+ t.Fatal(err)
399+ }
400+ if c.later == nil {
401+ t.Fatal("image not queued")
402+ }
403+ }
404+ if mode == "hidden" {
405+ if err := c.setImage(nil, 0, 0, 0, 0, 0); err != nil {
406+ t.Fatal(err)
407+ }
408+ }
409+ px[0] = 99 // Neither the loaded nor queued image may alias caller memory.
410+ dumbFree(k)
411+ rendererOK(t, k, r)
412+ if mode != "hidden" {
413+ r.EXPECT().WriteCursor(0, want, 1, 1).Return(nil).Once()
414+ }
415+ if !o.testCursor() {
416+ t.Fatal("swap not reported")
417+ }
418+ if c.screen != 0 || c.flying || c.flight != (cursorState{}) || c.applied != (cursorState{}) || c.later != nil {
419+ t.Fatalf("stale scanout state: screen %d flight %+v flying %v applied %+v", c.screen, c.flight, c.flying, c.applied)
420+ }
421+ c.landed()
422+ if c.screen != 0 {
423+ t.Fatal("old pending flight resurrected")
424+ }
425+ s := c.desired()
426+ if mode == "hidden" {
427+ if s.on || c.image {
428+ t.Fatal("hidden cursor shown")
429+ }
430+ } else {
431+ if !s.on || s.fb != 80 {
432+ t.Fatalf("desired %+v", s)
433+ }
434+ if mode == "queued" && (c.hotX != 4 || c.hotY != 5) {
435+ t.Fatal("queued hotspot lost")
436+ }
437+ }
438+ req := &atomicReq{}
439+ c.props(req, o.crtc, s)
440+ if err := k.commit(req, 0, 0); err != nil {
441+ t.Fatal(err)
442+ }
443+ wantFB := uint64(80)
444+ if mode == "hidden" {
445+ wantFB = 0
446+ }
447+ if fb, _ := (*commits)[2].req.value(tCursor, pFB); fb != wantFB {
448+ t.Fatalf("next commit fb %d want %d", fb, wantFB)
449+ }
450+ c.committed(s)
451+ c.landed()
452+ if c.screen != uint32(wantFB) {
453+ t.Fatalf("screen %d", c.screen)
454+ }
455+ })
456+ }
457+}
458+
459+func TestCursorFallbackFailureGoesOff(t *testing.T) {
460+ for _, mode := range []string{"allocation", "rewrite"} {
461+ t.Run(mode, func(t *testing.T) {
462+ o, k, r, commits := cursorOutput(t, unix.EINVAL)
463+ dumbOK(k, tSide*4)
464+ c := o.cursor
465+ if _, err := c.setup(k, r); err != nil {
466+ t.Fatal(err)
467+ }
468+ px := []byte{1, 2, 3, 4}
469+ if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil {
470+ t.Fatal(err)
471+ }
472+ if mode == "allocation" {
473+ r.EXPECT().CursorBuffers(tSide).Return([2]ports.DMABuf{}, errors.New("allocation failed")).Once()
474+ } else {
475+ dumbFree(k)
476+ rendererOK(t, k, r)
477+ r.EXPECT().WriteCursor(0, px, 1, 1).Return(errors.New("rewrite failed")).Once()
478+ }
479+ if !o.testCursor() || !c.off || c.desired().on || len(*commits) != 1 {
480+ t.Fatalf("off %v desired %+v commits %d", c.off, c.desired(), len(*commits))
481+ }
482+ if mode == "allocation" {
483+ dumbFree(k)
484+ } else {
485+ k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
486+ k.EXPECT().rmFB(uint32(81)).Return(nil).Once()
487+ }
488+ c.free()
489+ })
490+ }
491+}
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 +111 -0
@@ -0,0 +1,111 @@
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 := func() bool {
27+ defer f.Close()
28+ return dumbDeviceRoundTrip(t, path, kmsDevice{fd: int(f.Fd())})
29+ }()
30+ if done {
31+ return
32+ }
33+ }
34+ t.Skip("no card node makes dumb buffers for this user")
35+}
36+
37+// dumbDeviceRoundTrip runs the buffer life cycle on k and reports false
38+// when the card cannot do it for a reason unrelated to the code under test
39+// (the permission or the driver).
40+func dumbDeviceRoundTrip(t *testing.T, path string, k kmsDevice) bool {
41+ size := cursorSize(k.fd)
42+ var imgs [2]dumbBuffer
43+ var mems [2][]byte
44+ var fbs [2]uint32
45+ cleanup := func() {
46+ for i := range imgs {
47+ if fbs[i] != 0 {
48+ if err := k.rmFB(fbs[i]); err != nil {
49+ t.Errorf("rmfb: %v", err)
50+ }
51+ }
52+ if mems[i] != nil {
53+ if err := k.unmapDumb(mems[i]); err != nil {
54+ t.Errorf("munmap: %v", err)
55+ }
56+ }
57+ if imgs[i].handle != 0 {
58+ if err := k.destroyDumb(imgs[i].handle); err != nil {
59+ t.Errorf("destroy dumb: %v", err)
60+ }
61+ }
62+ }
63+ }
64+ defer cleanup()
65+ for i := range imgs {
66+ b, err := k.createDumb(size, size)
67+ if err != nil {
68+ 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) {
69+ t.Logf("%s: create dumb: %v", path, err)
70+ return false
71+ }
72+ t.Fatalf("%s: create dumb: %v", path, err)
73+ }
74+ imgs[i] = b
75+ if int(b.pitch) < size*4 || b.size < uint64(int(b.pitch)*size) {
76+ t.Fatalf("%s: dumb %dx%d pitch %d size %d", path, size, size, b.pitch, b.size)
77+ }
78+ if mems[i], err = k.mapDumb(b); err != nil {
79+ t.Fatalf("%s: map dumb: %v", path, err)
80+ }
81+ if fbs[i], err = k.addFBHandle(b, size, size, fourccARGB); err != nil {
82+ t.Fatalf("%s: add fb2 ARGB8888: %v", path, err)
83+ }
84+ t.Logf("%s: dumb buffer %d: %dx%d pitch %d size %d fb %d", path, i, size, size, b.pitch, b.size, fbs[i])
85+ }
86+ // Write a pattern through the shared mapping; each buffer keeps its own.
87+ for i, mem := range mems {
88+ d := &dumbCursor{k: k, size: size}
89+ d.img[i] = dumbImage{pitch: int(imgs[i].pitch), mem: mem}
90+ px := bytes.Repeat([]byte{byte(0x10 + i), 0x20, 0x30, 0xff}, 3*2)
91+ if err := d.write(i, px, 3, 2, 0); err != nil {
92+ t.Fatal(err)
93+ }
94+ }
95+ for i, mem := range mems {
96+ pitch := int(imgs[i].pitch)
97+ want := []byte{byte(0x10 + i), 0x20, 0x30, 0xff}
98+ for y := range 2 {
99+ if got := mem[y*pitch : y*pitch+4]; !bytes.Equal(got, want) {
100+ t.Fatalf("%s: buffer %d row %d read back %v, want %v", path, i, y, got, want)
101+ }
102+ if got := mem[y*pitch+3*4]; got != 0 {
103+ t.Fatalf("%s: buffer %d row %d not cleared after the image", path, i, y)
104+ }
105+ }
106+ if mem[2*pitch] != 0 || mem[(size-1)*pitch+(size-1)*4] != 0 {
107+ t.Fatalf("%s: buffer %d not cleared below the image", path, i)
108+ }
109+ }
110+ return true
111+}
@@ -0,0 +1,111 @@
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 := func() bool {
27+ defer f.Close()
28+ return dumbDeviceRoundTrip(t, path, kmsDevice{fd: int(f.Fd())})
29+ }()
30+ if done {
31+ return
32+ }
33+ }
34+ t.Skip("no card node makes dumb buffers for this user")
35+}
36+
37+// dumbDeviceRoundTrip runs the buffer life cycle on k and reports false
38+// when the card cannot do it for a reason unrelated to the code under test
39+// (the permission or the driver).
40+func dumbDeviceRoundTrip(t *testing.T, path string, k kmsDevice) bool {
41+ size := cursorSize(k.fd)
42+ var imgs [2]dumbBuffer
43+ var mems [2][]byte
44+ var fbs [2]uint32
45+ cleanup := func() {
46+ for i := range imgs {
47+ if fbs[i] != 0 {
48+ if err := k.rmFB(fbs[i]); err != nil {
49+ t.Errorf("rmfb: %v", err)
50+ }
51+ }
52+ if mems[i] != nil {
53+ if err := k.unmapDumb(mems[i]); err != nil {
54+ t.Errorf("munmap: %v", err)
55+ }
56+ }
57+ if imgs[i].handle != 0 {
58+ if err := k.destroyDumb(imgs[i].handle); err != nil {
59+ t.Errorf("destroy dumb: %v", err)
60+ }
61+ }
62+ }
63+ }
64+ defer cleanup()
65+ for i := range imgs {
66+ b, err := k.createDumb(size, size)
67+ if err != nil {
68+ 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) {
69+ t.Logf("%s: create dumb: %v", path, err)
70+ return false
71+ }
72+ t.Fatalf("%s: create dumb: %v", path, err)
73+ }
74+ imgs[i] = b
75+ if int(b.pitch) < size*4 || b.size < uint64(int(b.pitch)*size) {
76+ t.Fatalf("%s: dumb %dx%d pitch %d size %d", path, size, size, b.pitch, b.size)
77+ }
78+ if mems[i], err = k.mapDumb(b); err != nil {
79+ t.Fatalf("%s: map dumb: %v", path, err)
80+ }
81+ if fbs[i], err = k.addFBHandle(b, size, size, fourccARGB); err != nil {
82+ t.Fatalf("%s: add fb2 ARGB8888: %v", path, err)
83+ }
84+ t.Logf("%s: dumb buffer %d: %dx%d pitch %d size %d fb %d", path, i, size, size, b.pitch, b.size, fbs[i])
85+ }
86+ // Write a pattern through the shared mapping; each buffer keeps its own.
87+ for i, mem := range mems {
88+ d := &dumbCursor{k: k, size: size}
89+ d.img[i] = dumbImage{pitch: int(imgs[i].pitch), mem: mem}
90+ px := bytes.Repeat([]byte{byte(0x10 + i), 0x20, 0x30, 0xff}, 3*2)
91+ if err := d.write(i, px, 3, 2, 0); err != nil {
92+ t.Fatal(err)
93+ }
94+ }
95+ for i, mem := range mems {
96+ pitch := int(imgs[i].pitch)
97+ want := []byte{byte(0x10 + i), 0x20, 0x30, 0xff}
98+ for y := range 2 {
99+ if got := mem[y*pitch : y*pitch+4]; !bytes.Equal(got, want) {
100+ t.Fatalf("%s: buffer %d row %d read back %v, want %v", path, i, y, got, want)
101+ }
102+ if got := mem[y*pitch+3*4]; got != 0 {
103+ t.Fatalf("%s: buffer %d row %d not cleared after the image", path, i, y)
104+ }
105+ }
106+ if mem[2*pitch] != 0 || mem[(size-1)*pitch+(size-1)*4] != 0 {
107+ t.Fatalf("%s: buffer %d not cleared below the image", path, i)
108+ }
109+ }
110+ return true
111+}
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/convert_test.go +29 -0
@@ -0,0 +1,29 @@
1+package hdrcursor
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestCacheKeyGeometry(t *testing.T) {
9+ px := []byte{10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 100, 110, 120, 255}
10+ for _, tc := range []struct {
11+ name string
12+ w, h, side int
13+ }{{"dimensions", 1, 4, 4}, {"max-side", 2, 2, 1}} {
14+ t.Run(tc.name, func(t *testing.T) {
15+ var c Cache
16+ c.Convert(px, 2, 2, 4, 203)
17+ before := c.Key()
18+ got := bytes.Clone(c.Convert(px, tc.w, tc.h, tc.side, 203))
19+ if c.Key() == before {
20+ t.Fatal("geometry missing from cache key")
21+ }
22+ var fresh Cache
23+ want := fresh.Convert(px, tc.w, tc.h, tc.side, 203)
24+ if !bytes.Equal(got, want) {
25+ t.Fatalf("stale conversion: %v want %v", got, want)
26+ }
27+ })
28+ }
29+}
@@ -0,0 +1,29 @@
1+package hdrcursor
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestCacheKeyGeometry(t *testing.T) {
9+ px := []byte{10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 100, 110, 120, 255}
10+ for _, tc := range []struct {
11+ name string
12+ w, h, side int
13+ }{{"dimensions", 1, 4, 4}, {"max-side", 2, 2, 1}} {
14+ t.Run(tc.name, func(t *testing.T) {
15+ var c Cache
16+ c.Convert(px, 2, 2, 4, 203)
17+ before := c.Key()
18+ got := bytes.Clone(c.Convert(px, tc.w, tc.h, tc.side, 203))
19+ if c.Key() == before {
20+ t.Fatal("geometry missing from cache key")
21+ }
22+ var fresh Cache
23+ want := fresh.Convert(px, tc.w, tc.h, tc.side, 203)
24+ if !bytes.Equal(got, want) {
25+ t.Fatalf("stale conversion: %v want %v", got, want)
26+ }
27+ })
28+ }
29+}
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 +6 -25
@@ -47,7 +47,7 @@ func TestCursorBuffersWriteCursor(t *testing.T) {
4747 }
4848 }
4949
50-func TestHDRCursorCachesConversion(t *testing.T) {
50+func TestHDRCursorWritesBothImagesAndInvalidates(t *testing.T) {
5151 r, err := New(32, 16)
5252 if err != nil {
5353 t.Skipf("Vulkan unavailable: %v", err)
@@ -65,28 +65,6 @@ 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))
70- }
71- if err := r.WriteCursor(1, px, 1, 1); err != nil {
72- t.Fatal(err)
73- }
74- if len(r.cursorCache.pixels) != 4 {
75- t.Fatalf("cache grew on reselect: %d", len(r.cursorCache.pixels))
76- }
77- first := r.cursorCache.key
78- for i := 0; i < 12; i++ {
79- color := byte(i * 18)
80- if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil {
81- t.Fatal(err)
82- }
83- if len(r.cursorCache.pixels) != 4 {
84- t.Fatalf("cache grew after image %d: %d", i, len(r.cursorCache.pixels))
85- }
86- }
87- if first == r.cursorCache.key {
88- t.Fatal("cursor conversion not replaced")
89- }
9068 if err := r.WriteCursor(1, px, 1, 1); err != nil {
9169 t.Fatal(err)
9270 }
@@ -97,10 +75,13 @@ func TestHDRCursorCachesConversion(t *testing.T) {
9775 }
9876 }
9977 r.SetHDR(0)
78+ if r.cursorCache.Len() != 0 {
79+ t.Fatal("SetHDR kept conversion")
80+ }
10081 if err := r.WriteCursor(0, px, 1, 1); err != nil {
10182 t.Fatal(err)
10283 }
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))
84+ if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || r.cursorCache.Len() != 0 {
85+ t.Fatalf("SDR cursor/cache: %v %d", got, r.cursorCache.Len())
10586 }
10687 }
@@ -47,7 +47,7 @@ func TestCursorBuffersWriteCursor(t *testing.T) {
47 }47 }
48 }48 }
49 49
50-func TestHDRCursorCachesConversion(t *testing.T) {50+func TestHDRCursorWritesBothImagesAndInvalidates(t *testing.T) {
51 r, err := New(32, 16)51 r, err := New(32, 16)
52 if err != nil {52 if err != nil {
53 t.Skipf("Vulkan unavailable: %v", err)53 t.Skipf("Vulkan unavailable: %v", err)
@@ -65,28 +65,6 @@ 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 {
69- t.Fatalf("cache size %d", len(r.cursorCache.pixels))
70- }
71- if err := r.WriteCursor(1, px, 1, 1); err != nil {
72- t.Fatal(err)
73- }
74- if len(r.cursorCache.pixels) != 4 {
75- t.Fatalf("cache grew on reselect: %d", len(r.cursorCache.pixels))
76- }
77- first := r.cursorCache.key
78- for i := 0; i < 12; i++ {
79- color := byte(i * 18)
80- if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil {
81- t.Fatal(err)
82- }
83- if len(r.cursorCache.pixels) != 4 {
84- t.Fatalf("cache grew after image %d: %d", i, len(r.cursorCache.pixels))
85- }
86- }
87- if first == r.cursorCache.key {
88- t.Fatal("cursor conversion not replaced")
89- }
90 if err := r.WriteCursor(1, px, 1, 1); err != nil {68 if err := r.WriteCursor(1, px, 1, 1); err != nil {
91 t.Fatal(err)69 t.Fatal(err)
92 }70 }
@@ -97,10 +75,13 @@ func TestHDRCursorCachesConversion(t *testing.T) {
97 }75 }
98 }76 }
99 r.SetHDR(0)77 r.SetHDR(0)
78+ if r.cursorCache.Len() != 0 {
79+ t.Fatal("SetHDR kept conversion")
80+ }
100 if err := r.WriteCursor(0, px, 1, 1); err != nil {81 if err := r.WriteCursor(0, px, 1, 1); err != nil {
101 t.Fatal(err)82 t.Fatal(err)
102 }83 }
103- if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || len(r.cursorCache.pixels) != 0 {84+ 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))85+ t.Fatalf("SDR cursor/cache: %v %d", got, r.cursorCache.Len())
105 }86 }
106 }87 }
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 23

Tags 10

↑↓ moveenter openesc close