1package vulkan
2
3import (
4 "context"
5 "encoding/binary"
6 "errors"
7 "image"
8 "math"
9 "os"
10 "slices"
11 "strings"
12 "testing"
13 "unsafe"
14
15 "image/color"
16
17 "github.com/bnema/neferwl/internal/adapters/syncfile"
18 "github.com/bnema/neferwl/internal/ports"
19 vk "github.com/bnema/purego-vulkan/vulkan"
20 "github.com/stretchr/testify/mock"
21 "golang.org/x/sys/unix"
22)
23
24// requireCaptureSync skips only integration tests that need kernel sync-file
25// export. Software Vulkan devices can still exercise Pixels and rendering.
26func requireCaptureSync(t *testing.T, r *Renderer) {
27 t.Helper()
28 if !r.syncFD || !r.dd.HasGetSemaphoreFdKHR() {
29 t.Skip("Vulkan sync-file export unavailable")
30 }
31}
32
33// captureWait leases a capture of the last frame and waits for it.
34func captureWait(t *testing.T, r *Renderer) ports.CaptureFrame {
35 t.Helper()
36 requireCaptureSync(t, r)
37 cf, err := r.BeginCapture()
38 if err != nil {
39 t.Fatal(err)
40 }
41 if done := cf.Done(); done != nil {
42 if err := syncfile.Wait(context.Background(), done); err != nil {
43 t.Fatal(err)
44 }
45 }
46 return cf
47}
48
49func TestCaptureLastFrameRegion(t *testing.T) {
50 r, err := New(8, 8)
51 if err != nil {
52 t.Skipf("Vulkan unavailable: %v", err)
53 }
54 defer r.Close()
55 if _, err := r.BeginCapture(); err == nil {
56 t.Fatal("capture before any frame accepted")
57 }
58 if err := render(r, ports.Scene{Background: "#123456"}, nil); err != nil {
59 t.Fatal(err)
60 }
61 if r.captures != nil {
62 t.Fatal("slots allocated before the first capture")
63 }
64 cf := captureWait(t, r)
65 if len(r.captures) != captureSlots {
66 t.Fatalf("%d slots", len(r.captures))
67 }
68 dst := make([]byte, 24)
69 if err := cf.Read(image.Rect(2, 3, 4, 5), dst, 12); err != nil {
70 t.Fatal(err)
71 }
72 for _, at := range []int{0, 4, 12, 16} {
73 if got := dst[at : at+4]; got[0] != 0x56 || got[1] != 0x34 || got[2] != 0x12 || got[3] != 255 {
74 t.Fatalf("pixel %d: %x", at, got)
75 }
76 }
77 if err := cf.Read(image.Rect(-1, 0, 1, 1), dst, 12); err == nil {
78 t.Fatal("invalid region accepted")
79 }
80 if err := cf.Read(image.Rect(0, 0, 2, 2), dst[:10], 8); err == nil {
81 t.Fatal("short destination accepted")
82 }
83 // A stride near MaxInt must not overflow the row arithmetic.
84 if err := cf.Read(image.Rect(0, 0, 2, 2), dst, math.MaxInt-1); err == nil {
85 t.Fatal("overflowing stride accepted")
86 }
87 if err := cf.Read(image.Rect(0, 0, 2, 2), dst, math.MaxInt); err == nil {
88 t.Fatal("MaxInt stride accepted")
89 }
90 // A one-row read needs only w*4 bytes whatever the stride.
91 if err := cf.Read(image.Rect(0, 0, 2, 1), dst[:8], math.MaxInt); err != nil {
92 t.Fatal(err)
93 }
94 r.EndCapture(cf)
95 if err := cf.Read(image.Rect(2, 3, 4, 5), dst, 12); err == nil {
96 t.Fatal("read after EndCapture accepted")
97 }
98 r.EndCapture(cf) // idempotent
99}
100
101// Both slots leased: the third capture fails at once; returning one
102// frees it again once its copy finished.
103func TestCaptureSlotsBounded(t *testing.T) {
104 r, err := New(8, 8)
105 if err != nil {
106 t.Skipf("Vulkan unavailable: %v", err)
107 }
108 defer r.Close()
109 if err := render(r, ports.Scene{Background: "#ff0000"}, nil); err != nil {
110 t.Fatal(err)
111 }
112 a := captureWait(t, r)
113 b := captureWait(t, r)
114 if _, err := r.BeginCapture(); !errors.Is(err, ports.ErrCaptureBusy) {
115 t.Fatalf("third capture: %v", err)
116 }
117 r.EndCapture(a)
118 // a's copy finished (waited above): its fence polls signalled.
119 c := captureWait(t, r)
120 dst := make([]byte, 4)
121 if err := c.Read(image.Rect(0, 0, 1, 1), dst, 4); err != nil {
122 t.Fatal(err)
123 }
124 if dst[2] != 255 || dst[1] != 0 || dst[0] != 0 {
125 t.Fatalf("pixel %v", dst)
126 }
127 r.EndCapture(b)
128 r.EndCapture(c)
129 // Pixels never takes a leased slot: with both out it reports nil.
130 a, b = captureWait(t, r), captureWait(t, r)
131 if img := r.Pixels(); img != nil {
132 t.Fatal("Pixels stole a leased slot")
133 }
134 r.EndCapture(a)
135 r.EndCapture(b)
136 if img := r.Pixels(); img == nil || img.RGBAAt(0, 0) != (color.RGBA{255, 0, 0, 255}) {
137 t.Fatalf("Pixels after release: %v", img)
138 }
139 // Frames of a closed renderer are invalid, and Close survives leases
140 // still out.
141 d := captureWait(t, r)
142 r.Close()
143 if err := d.Read(image.Rect(0, 0, 1, 1), dst, 4); err == nil {
144 t.Fatal("read after Close accepted")
145 }
146 r.EndCapture(d)
147}
148
149// A returned slot whose copy has not finished is not reused: the poll
150// sees the fence unsignalled. Simulated by marking the slot pending with
151// its fence reset.
152func TestCaptureReturnedSlotWaitsForFence(t *testing.T) {
153 r, err := New(8, 8)
154 if err != nil {
155 t.Skipf("Vulkan unavailable: %v", err)
156 }
157 defer r.Close()
158 if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
159 t.Fatal(err)
160 }
161 a := captureWait(t, r)
162 b := captureWait(t, r)
163 r.EndCapture(a)
164 r.EndCapture(b)
165 for _, s := range r.captures {
166 // The copies finished (waited above); reset only signalled fences.
167 if err := checked("vkWaitForFences", r.dd.WaitForFences(r.device, 1, &s.fence, 1, math.MaxUint64)); err != nil {
168 t.Fatal(err)
169 }
170 if err := checked("vkResetFences", r.dd.ResetFences(r.device, 1, &s.fence)); err != nil {
171 t.Fatal(err)
172 }
173 s.pending = true
174 }
175 if _, err := r.BeginCapture(); !errors.Is(err, ports.ErrCaptureBusy) {
176 t.Fatalf("pending slots reused: %v", err)
177 }
178 // Signal them again with an empty submit, then the poll frees them.
179 for _, s := range r.captures {
180 if err := checked("vkQueueSubmit", r.dd.QueueSubmit(r.queue, 0, nil, s.fence)); err != nil {
181 t.Fatal(err)
182 }
183 if err := checked("vkWaitForFences", r.dd.WaitForFences(r.device, 1, &s.fence, 1, math.MaxUint64)); err != nil {
184 t.Fatal(err)
185 }
186 }
187 c := captureWait(t, r)
188 r.EndCapture(c)
189 if _, err := r.captureSize(); err != nil {
190 t.Fatal(err)
191 }
192 if r.captures[0].fence == 0 || r.captures[0].done == vk.Semaphore(0) {
193 t.Fatal("slot objects missing")
194 }
195}
196
197// A device without sync-file export refuses protocol capture before allocating
198// slots, but keeps synchronous debug screenshots functional (e.g. lavapipe).
199func TestCaptureWithoutSyncFileExport(t *testing.T) {
200 r, err := New(8, 8)
201 if err != nil {
202 t.Skipf("Vulkan unavailable: %v", err)
203 }
204 defer r.Close()
205 r.syncFD = false
206 if err := render(r, ports.Scene{Background: "#123456"}, nil); err != nil {
207 t.Fatal(err)
208 }
209 if _, err := r.BeginCapture(); err == nil || !strings.Contains(err.Error(), "sync file export") {
210 t.Fatalf("capture without sync-file export: %v", err)
211 }
212 if r.captures != nil {
213 t.Fatal("unsupported capture allocated slots")
214 }
215 pixels := r.Pixels()
216 if pixels == nil || pixels.RGBAAt(0, 0) != (color.RGBA{0x12, 0x34, 0x56, 255}) {
217 t.Fatalf("debug screenshot without sync-file export: %v", pixels)
218 }
219}
220
221// The capture size cap admits two 5K slots and rejects 8K.
222func TestCaptureSizeCap(t *testing.T) {
223 r := &Renderer{width: 5120, height: 2880}
224 if _, err := r.captureSize(); err != nil {
225 t.Fatalf("5K rejected: %v", err)
226 }
227 r.width, r.height = 7680, 4320
228 if _, err := r.captureSize(); err == nil {
229 t.Fatal("8K accepted")
230 }
231 r.width, r.height = 0, 4320
232 if _, err := r.captureSize(); err == nil {
233 t.Fatal("empty accepted")
234 }
235}
236
237// captureSDR follows the documented curve: exact below the knee, a
238// monotonic shoulder above it that never saturates, hue kept, and
239// negative components desaturated toward luminance.
240func TestCaptureCurve(t *testing.T) {
241 for _, x := range []float64{0, 0.25, 0.5, 0.9} {
242 if got := captureCurve(x); got != x {
243 t.Fatalf("curve(%v) = %v", x, got)
244 }
245 }
246 prev := captureCurve(0.9)
247 for _, x := range []float64{1, 1.5, 2, 4.93, 10, 100} {
248 got := captureCurve(x)
249 if got <= prev || got >= 1 {
250 t.Fatalf("curve(%v) = %v after %v", x, got, prev)
251 }
252 prev = got
253 }
254 // Diffuse white stays bright; 2x and 5x (1000 nits) white stay
255 // distinct from each other and from saturation.
256 white := captureSDR([3]float64{1, 1, 1})
257 twice := captureSDR([3]float64{2, 2, 2})
258 peak := captureSDR([3]float64{4.93, 4.93, 4.93})
259 if white[0] < 240 || white[0] != white[1] || white[1] != white[2] {
260 t.Fatalf("diffuse white %v", white)
261 }
262 if !(white[0] < twice[0] && twice[0] < peak[0] && peak[0] < 255) {
263 t.Fatalf("shoulder white %v 2x %v 5x %v", white, twice, peak)
264 }
265 // Bright red keeps its hue: green and blue stay zero.
266 if px := captureSDR([3]float64{4, 0, 0}); px[1] != 0 || px[2] != 0 || px[0] <= captureSDR([3]float64{0.9, 0, 0})[0] {
267 t.Fatalf("bright red %v", px)
268 }
269 // A BT.2020 red outside BT.709 (negative green and blue) is mixed
270 // toward its luminance until the most negative component (green)
271 // is zero; blue rises a little above zero, red stays dominant.
272 in := [3]float64{1.2, -0.1, -0.05}
273 px := captureSDR(in)
274 if px[1] != 0 || px[2] == 0 || px[2] > 60 || px[0] < 200 {
275 t.Fatalf("out-of-gamut red %v", px)
276 }
277 // The desaturation keeps luminance: the sRGB-decoded result has the
278 // input's Y (0.18) within rounding.
279 dec := func(b uint8) float64 {
280 v := float64(b) / 255
281 if v <= 0.04045 {
282 return v / 12.92
283 }
284 return math.Pow((v+0.055)/1.055, 2.4)
285 }
286 if y := 0.2126*dec(px[0]) + 0.7152*dec(px[1]) + 0.0722*dec(px[2]); math.Abs(y-0.18) > 0.01 {
287 t.Fatalf("luminance %.3f after gamut reduction of %v", y, px)
288 }
289 if nan := captureSDR([3]float64{math.NaN(), math.Inf(1), -1}); nan[0] != 0 || nan[1] < 250 {
290 t.Fatalf("NaN/Inf %v", nan)
291 }
292}
293
294// The lease object of a slot is reused, and a stale handle reports invalid.
295func TestCaptureLeaseReused(t *testing.T) {
296 r, err := New(8, 8)
297 if err != nil {
298 t.Skipf("Vulkan unavailable: %v", err)
299 }
300 defer r.Close()
301 if err := render(r, ports.Scene{Background: "#0000ff"}, nil); err != nil {
302 t.Fatal(err)
303 }
304 first := captureWait(t, r)
305 r.EndCapture(first)
306 second := captureWait(t, r)
307 if second != first {
308 t.Fatal("lease object not reused")
309 }
310 r.EndCapture(second)
311 dst := make([]byte, 4)
312 if err := second.Read(image.Rect(0, 0, 1, 1), dst, 4); err == nil {
313 t.Fatal("stale lease read accepted")
314 }
315}
316
317// pollSyncFile waits for a sync file without allocating (syncfile.Wait
318// builds its poll set per call).
319func pollSyncFile(f *os.File, fds []unix.PollFd) error {
320 fds[0] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN}
321 for {
322 n, err := unix.Poll(fds, 1000)
323 switch {
324 case errors.Is(err, unix.EINTR):
325 case err != nil:
326 return err
327 case n > 0 && fds[0].Revents&unix.POLLIN != 0:
328 return nil
329 case n > 0:
330 return errors.New("sync file poll error")
331 default:
332 return errors.New("sync file poll timeout")
333 }
334 }
335}
336
337// openFDs counts the process's open descriptors.
338func openFDs(t *testing.T) int {
339 t.Helper()
340 ents, err := os.ReadDir("/proc/self/fd")
341 if err != nil {
342 t.Skipf("no /proc/self/fd: %v", err)
343 }
344 return len(ents)
345}
346
347// The renderer capture cycle (lease, wait for the copy, read the rows,
348// return the lease) allocates nothing once the slots exist, on the SDR and
349// the HDR conversion path, and holds a constant number of descriptors: the
350// slot keeps its sync file for every lease. No mock stands in the measured
351// closure.
352func TestCaptureCycleAllocations(t *testing.T) {
353 for _, tc := range []struct {
354 name string
355 new func(*testing.T) *Renderer
356 }{
357 {"sdr", func(t *testing.T) *Renderer {
358 r, err := New(16, 8)
359 if err != nil {
360 t.Skipf("Vulkan unavailable: %v", err)
361 }
362 t.Cleanup(r.Close)
363 return r
364 }},
365 {"hdr", hdrTestRenderer},
366 } {
367 t.Run(tc.name, func(t *testing.T) {
368 r := tc.new(t)
369 requireCaptureSync(t, r)
370 if err := render(r, ports.Scene{Seq: 1, Background: "#3060c0"}, nil); err != nil {
371 t.Fatal(err)
372 }
373 dst := make([]byte, 16*8*4)
374 fds := make([]unix.PollFd, 1)
375 region := image.Rect(0, 0, 16, 8)
376 var cycleErr error
377 cycle := func() {
378 cf, err := r.BeginCapture()
379 if err != nil {
380 cycleErr = err
381 return
382 }
383 if done := cf.Done(); done != nil {
384 if err := pollSyncFile(done, fds); err != nil {
385 cycleErr = err
386 }
387 }
388 if err := cf.Read(region, dst, 16*4); err != nil {
389 cycleErr = err
390 }
391 r.EndCapture(cf)
392 }
393 cycle() // creates the slots, the capture pass and the sync files
394 cycle()
395 if cycleErr != nil {
396 t.Fatal(cycleErr)
397 }
398 fdsBefore := openFDs(t)
399 if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 {
400 t.Errorf("%.1f allocations per capture cycle, want 0", allocs)
401 }
402 if cycleErr != nil {
403 t.Fatal(cycleErr)
404 }
405 if after := openFDs(t); after != fdsBefore {
406 t.Errorf("%d descriptors open after the cycles, %d before: a sync file leaked", after, fdsBefore)
407 }
408 if dst[0] == 0 && dst[1] == 0 && dst[2] == 0 {
409 t.Error("capture read back black")
410 }
411 })
412 }
413}
414
415// adoptSync takes ownership of the descriptor it is given: the first one is
416// wrapped, a later one is moved onto the same descriptor number and its
417// source is closed, so nothing leaks and nothing is closed twice.
418func TestCaptureAdoptSync(t *testing.T) {
419 pipe := func() (r, w int) {
420 var p [2]int
421 if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
422 t.Fatal(err)
423 }
424 return p[0], p[1]
425 }
426 r1, w1 := pipe()
427 r2, w2 := pipe()
428 t.Cleanup(func() { _ = unix.Close(w1); _ = unix.Close(w2) })
429 var s captureSlot
430 if err := s.adoptSync(r1); err != nil {
431 t.Fatal(err)
432 }
433 if s.syncFD != r1 || s.file == nil {
434 t.Fatalf("first adoption: fd %d file %v, want %d", s.syncFD, s.file, r1)
435 }
436 file := s.file
437 if err := s.adoptSync(r2); err != nil {
438 t.Fatal(err)
439 }
440 if s.file != file || s.syncFD != r1 {
441 t.Fatalf("second adoption replaced the file: %p fd %d", s.file, s.syncFD)
442 }
443 if _, err := unix.FcntlInt(uintptr(r2), unix.F_GETFD, 0); err != unix.EBADF {
444 t.Errorf("source descriptor still open after the move: %v", err)
445 }
446 if _, err := unix.Write(w2, []byte{7}); err != nil {
447 t.Fatal(err)
448 }
449 var b [1]byte
450 if n, err := s.file.Read(b[:]); err != nil || n != 1 || b[0] != 7 {
451 t.Fatalf("slot file does not read the second pipe: %d %v %v", n, b[0], err)
452 }
453 // A failed move still closes the source.
454 r3, w3 := pipe()
455 t.Cleanup(func() { _ = unix.Close(w3) })
456 bad := captureSlot{file: file, syncFD: -1}
457 if err := bad.adoptSync(r3); err == nil {
458 t.Error("move onto an invalid descriptor accepted")
459 }
460 if _, err := unix.FcntlInt(uintptr(r3), unix.F_GETFD, 0); err != unix.EBADF {
461 t.Errorf("source descriptor leaked by a failed move: %v", err)
462 }
463 if err := file.Close(); err != nil {
464 t.Errorf("slot file close: %v", err)
465 }
466}
467
468// A slot keeps one descriptor for its sync files: it is the same number and
469// the same file for every lease, closed once by Close, and a lease the
470// device finished at export (no sync file) hands out none.
471func TestCaptureSyncFileOwnership(t *testing.T) {
472 r, err := New(8, 8)
473 if err != nil {
474 t.Skipf("Vulkan unavailable: %v", err)
475 }
476 requireCaptureSync(t, r)
477 closed := false
478 defer func() {
479 if !closed {
480 r.Close()
481 }
482 }()
483 if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
484 t.Fatal(err)
485 }
486 before := openFDs(t)
487 cf := captureWait(t, r).(*captureFrame)
488 s := cf.slot
489 file, fd := cf.Done(), s.syncFD
490 if file == nil || s.file != file {
491 t.Fatalf("first lease: done %v, slot file %v", file, s.file)
492 }
493 r.EndCapture(cf)
494 if cf.Done() != nil {
495 t.Error("ended lease still exposes the sync file")
496 }
497 if _, err := file.Stat(); err != nil {
498 t.Errorf("EndCapture closed the slot's sync file: %v", err)
499 }
500 for range 4 {
501 next := captureWait(t, r).(*captureFrame)
502 if next.slot == s {
503 if next.Done() != file || s.syncFD != fd {
504 t.Fatalf("slot sync file changed: %p fd %d, was %p fd %d", next.Done(), s.syncFD, file, fd)
505 }
506 }
507 r.EndCapture(next)
508 }
509 // Two slots, each holding one sync file.
510 if held := openFDs(t) - before; held > captureSlots {
511 t.Errorf("%d descriptors held by %d slots", held, captureSlots)
512 }
513 r.Close()
514 closed = true
515 if _, err := file.Stat(); err == nil {
516 t.Error("Close left the slot's sync file open")
517 }
518 if got := openFDs(t); got > before {
519 t.Errorf("%d descriptors open after Close, %d before the captures", got, before)
520 }
521}
522
523// A failed sync file export fails the capture without waiting: the slot
524// stays pending, its semaphore is kept until the fence polls signalled,
525// and only then replaced.
526func TestCaptureExportFailureKeepsSlotPending(t *testing.T) {
527 r, err := New(8, 8)
528 if err != nil {
529 t.Skipf("Vulkan unavailable: %v", err)
530 }
531 defer r.Close()
532 requireCaptureSync(t, r)
533 if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
534 t.Fatal(err)
535 }
536 m := newMockcaptureSync(t)
537 r.sync = m
538 dev := deviceSync{r}
539 // Slots are created on this first capture: their semaphores too.
540 m.EXPECT().newSemaphore().RunAndReturn(dev.newSemaphore).Times(captureSlots)
541 m.EXPECT().exportSyncFD(mock.Anything).Return(-1, vk.ErrorOutOfHostMemory).Once()
542 if _, err := r.BeginCapture(); err == nil || errors.Is(err, ports.ErrCaptureBusy) {
543 t.Fatalf("export failure: %v", err)
544 }
545 s := r.captures[0]
546 old := s.done
547 if !s.pending || s.leased || !s.replaceDone || s.done == 0 {
548 t.Fatalf("slot after export failure: leased=%v pending=%v replace=%v", s.leased, s.pending, s.replaceDone)
549 }
550 // While the copy runs the slot is skipped and its semaphore is not
551 // touched (no destroy expected yet).
552 m.EXPECT().fenceStatus(s.fence).Return(vk.Timeout).Once()
553 m.EXPECT().exportSyncFD(mock.Anything).RunAndReturn(dev.exportSyncFD).Once()
554 second := captureWait(t, r) // the other slot
555 if second.(*captureFrame).slot == s {
556 t.Fatal("pending slot reused")
557 }
558 r.EndCapture(second)
559 // Once the fence polls signalled the semaphore is replaced (the old
560 // one destroyed only now) and the slot works again.
561 if err := checked("vkWaitForFences", r.dd.WaitForFences(r.device, 1, &s.fence, 1, math.MaxUint64)); err != nil {
562 t.Fatal(err)
563 }
564 m.EXPECT().fenceStatus(s.fence).RunAndReturn(dev.fenceStatus).Once()
565 m.EXPECT().resetFence(s.fence).RunAndReturn(dev.resetFence).Once()
566 m.EXPECT().destroySemaphore(old).Run(dev.destroySemaphore).Once()
567 boom := errors.New("replacement failed")
568 m.EXPECT().newSemaphore().Return(vk.Semaphore(0), boom).Once()
569 if _, err := r.BeginCapture(); !errors.Is(err, boom) {
570 t.Fatalf("semaphore replacement error: %v", err)
571 }
572 if s.pending || !s.replaceDone || s.done != 0 {
573 t.Fatal("failed replacement lost retry state")
574 }
575 // Retry after the fence was already reset. No second destruction or
576 // fence poll is needed, and no null semaphore may be submitted.
577 m.EXPECT().newSemaphore().RunAndReturn(dev.newSemaphore).Once()
578 m.EXPECT().exportSyncFD(mock.Anything).RunAndReturn(dev.exportSyncFD).Once()
579 third := captureWait(t, r)
580 if third.(*captureFrame).slot != s || s.replaceDone {
581 t.Fatal("recovered slot not reused")
582 }
583 r.EndCapture(third)
584 // Close destroys both semaphores.
585 m.EXPECT().destroySemaphore(mock.Anything).Run(dev.destroySemaphore).Times(captureSlots)
586}
587
588// A device error from the fence poll or reset propagates instead of
589// being taken as busy.
590func TestCapturePollErrorPropagates(t *testing.T) {
591 r, err := New(8, 8)
592 if err != nil {
593 t.Skipf("Vulkan unavailable: %v", err)
594 }
595 defer r.Close()
596 if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
597 t.Fatal(err)
598 }
599 a := captureWait(t, r)
600 r.EndCapture(a)
601 m := newMockcaptureSync(t)
602 r.sync = m
603 m.EXPECT().fenceStatus(mock.Anything).Return(vk.ErrorDeviceLost).Once()
604 if _, err := r.BeginCapture(); err == nil || errors.Is(err, ports.ErrCaptureBusy) {
605 t.Fatalf("poll error: %v", err)
606 }
607 m.EXPECT().fenceStatus(mock.Anything).Return(vk.Success).Once()
608 m.EXPECT().resetFence(mock.Anything).Return(vk.ErrorOutOfDeviceMemory).Once()
609 if _, err := r.BeginCapture(); err == nil || errors.Is(err, ports.ErrCaptureBusy) {
610 t.Fatalf("reset error: %v", err)
611 }
612 // Close destroys the semaphores through the seam.
613 m.EXPECT().destroySemaphore(mock.Anything).Run(deviceSync{r}.destroySemaphore).Times(captureSlots)
614}
615
616// An fp16 client with NaN, +Inf and negative texels: the capture pass
617// maps NaN to black, bounds infinity to the shoulder's ceiling and lifts
618// the negative one to black, on the GPU as in captureSDR.
619func TestCaptureHDRNaNInf(t *testing.T) {
620 r := hdrTestRenderer(t)
621 format := ports.DMABufFormat{Format: fourcc('A', 'B', '4', 'H'), Modifier: 0}
622 if !slices.Contains(r.DMABuf().Formats, format) {
623 t.Skip("ABGR16161616F linear import unavailable")
624 }
625 // x<20: NaN; 20..40: +Inf; 40..: -2.0. Alpha 1.
626 texel := func(x int) uint16 {
627 switch {
628 case x < 20:
629 return 0x7e00
630 case x < 40:
631 return 0x7c00
632 }
633 return 0xc000
634 }
635 f := udmabuf16(t, 64, 16, texel)
636 buf := &ports.DMABuf{ID: 165, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 512}}}
637 c := ports.SurfaceContent{ID: 1, Width: 64, Height: 16, Opaque: true, DMABuf: buf, Color: ports.SurfaceColor{TF: ports.ColorTFExtendedLinear, Primaries: ports.ColorPrimariesSRGB}}
638 scene := ports.Scene{Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
639 if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
640 t.Fatal(err)
641 }
642 img := readPixels(t, r)
643 for _, tc := range []struct {
644 x int
645 want [3]uint8
646 }{
647 {5, captureSDR([3]float64{math.NaN(), math.NaN(), math.NaN()})},
648 {25, captureSDR([3]float64{math.Inf(1), math.Inf(1), math.Inf(1)})},
649 {45, captureSDR([3]float64{-2, -2, -2})},
650 } {
651 got := img.RGBAAt(tc.x, 8)
652 if !near(got, color.RGBA{tc.want[0], tc.want[1], tc.want[2], 255}, 2) {
653 t.Errorf("x=%d got %v want %v", tc.x, got, tc.want)
654 }
655 }
656 if img.RGBAAt(5, 8) != (color.RGBA{0, 0, 0, 255}) || img.RGBAAt(45, 8) != (color.RGBA{0, 0, 0, 255}) {
657 t.Errorf("NaN %v / negative %v not black", img.RGBAAt(5, 8), img.RGBAAt(45, 8))
658 }
659 if inf := img.RGBAAt(25, 8); inf.R != 255 {
660 // The shoulder's ceiling rounds to 255 at infinity.
661 t.Errorf("+Inf %v", inf)
662 }
663}
664
665// udmabuf16 is a linear RGBA16F client buffer whose RGB texels are v(x).
666func udmabuf16(t *testing.T, w, h int, v func(x int) uint16) *os.File {
667 t.Helper()
668 pixels := make([]byte, w*h*8)
669 for y := range h {
670 for x := range w {
671 i := (y*w + x) * 8
672 for ch := range 3 {
673 binary.LittleEndian.PutUint16(pixels[i+ch*2:], v(x))
674 }
675 binary.LittleEndian.PutUint16(pixels[i+6:], 0x3c00)
676 }
677 }
678 mem, err := unix.MemfdCreate("fp16-client", unix.MFD_ALLOW_SEALING)
679 if err != nil {
680 t.Fatal(err)
681 }
682 defer unix.Close(mem)
683 if err := unix.Ftruncate(mem, int64(len(pixels))); err != nil {
684 t.Fatal(err)
685 }
686 if _, err := unix.Pwrite(mem, pixels, 0); err != nil {
687 t.Fatal(err)
688 }
689 if _, err := unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK); err != nil {
690 t.Fatal(err)
691 }
692 dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0)
693 if err != nil {
694 t.Skipf("udmabuf unavailable: %v", err)
695 }
696 defer dev.Close()
697 arg := struct {
698 memfd, flags uint32
699 offset, size uint64
700 }{memfd: uint32(mem), flags: 1, size: uint64(len(pixels))}
701 fd, _, errno := unix.Syscall(unix.SYS_IOCTL, dev.Fd(), 0x40187542, uintptr(unsafe.Pointer(&arg)))
702 if errno != 0 {
703 t.Skipf("udmabuf create: %v", errno)
704 }
705 f := os.NewFile(fd, "fp16-client")
706 t.Cleanup(func() { f.Close() })
707 return f
708}
709
710// Toggling HDR while a capture is leased: the lease's mapping stays
711// readable, the next captures follow the new mode, and a solid colour
712// round-trips SDR → HDR → SDR within the capture curve's tolerance.
713func TestCaptureSurvivesHDRToggle(t *testing.T) {
714 r := hdrTestRenderer(t) // HDR, exported target
715 scene := ports.Scene{Seq: 1, Background: "#c08040"}
716 if err := render(r, scene, nil); err != nil {
717 t.Fatal(err)
718 }
719 hdrShot := readPixels(t, r).RGBAAt(3, 3)
720 leased := captureWait(t, r) // HDR capture held across the toggle
721 r.SetHDR(0)
722 if _, err := r.ExportTargets(0, nil, false); err != nil {
723 t.Fatal(err)
724 }
725 scene.Seq++
726 if err := render(r, scene, nil); err != nil {
727 t.Fatal(err)
728 }
729 sdrShot := readPixels(t, r).RGBAAt(3, 3)
730 dst := make([]byte, 4)
731 if err := leased.Read(image.Rect(3, 3, 4, 4), dst, 4); err != nil {
732 t.Fatal(err)
733 }
734 if got := (color.RGBA{dst[2], dst[1], dst[0], 255}); got != hdrShot {
735 t.Fatalf("leased HDR capture changed by the toggle: %v vs %v", got, hdrShot)
736 }
737 r.EndCapture(leased)
738 // The SDR frame is exact; the HDR capture of the same colour is
739 // within the curve's rounding (coverage of the transition, not a
740 // claim about the curve's look).
741 if sdrShot != (color.RGBA{0xc0, 0x80, 0x40, 255}) {
742 t.Fatalf("SDR %v", sdrShot)
743 }
744 if !near(hdrShot, sdrShot, 10) {
745 t.Fatalf("HDR capture %v vs SDR %v", hdrShot, sdrShot)
746 }
747 // Back to HDR: the capture pass and slots are reused.
748 r.SetHDR(203)
749 if _, err := r.ExportTargets(1, r.hdrMods, false); err != nil {
750 t.Skipf("no HDR target: %v", err)
751 }
752 scene.Seq++
753 if err := render(r, scene, nil); err != nil {
754 t.Fatal(err)
755 }
756 if again := readPixels(t, r).RGBAAt(3, 3); again != hdrShot {
757 t.Fatalf("HDR capture after the round trip %v, before %v", again, hdrShot)
758 }
759}