v0.4.0 b6be3a8

capture_test.go RawHistoryFolder

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: a steady state of captures
295// allocates only the sync file, and a stale handle reports invalid.
296func TestCaptureLeaseReused(t *testing.T) {
297	r, err := New(8, 8)
298	if err != nil {
299		t.Skipf("Vulkan unavailable: %v", err)
300	}
301	defer r.Close()
302	if err := render(r, ports.Scene{Background: "#0000ff"}, nil); err != nil {
303		t.Fatal(err)
304	}
305	first := captureWait(t, r)
306	r.EndCapture(first)
307	second := captureWait(t, r)
308	if second != first {
309		t.Fatal("lease object not reused")
310	}
311	r.EndCapture(second)
312	cycle := func() {
313		cf, err := r.BeginCapture()
314		if err != nil {
315			t.Fatal(err)
316		}
317		if done := cf.Done(); done != nil {
318			if err := syncfile.Wait(context.Background(), done); err != nil {
319				t.Fatal(err)
320			}
321		}
322		r.EndCapture(cf)
323	}
324	cycle()
325	// No lease wrapper: the allocations left are the Vulkan structs the
326	// FFI boundary keeps on the heap (as in Render, budget 16) plus the
327	// sync file (os.NewFile and its finalizer).
328	if allocs := testing.AllocsPerRun(10, cycle); allocs > 16 {
329		t.Errorf("%.1f allocations per capture, want <= 16", allocs)
330	}
331}
332
333// A failed sync file export fails the capture without waiting: the slot
334// stays pending, its semaphore is kept until the fence polls signalled,
335// and only then replaced.
336func TestCaptureExportFailureKeepsSlotPending(t *testing.T) {
337	r, err := New(8, 8)
338	if err != nil {
339		t.Skipf("Vulkan unavailable: %v", err)
340	}
341	defer r.Close()
342	requireCaptureSync(t, r)
343	if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
344		t.Fatal(err)
345	}
346	m := newMockcaptureSync(t)
347	r.sync = m
348	dev := deviceSync{r}
349	// Slots are created on this first capture: their semaphores too.
350	m.EXPECT().newSemaphore().RunAndReturn(dev.newSemaphore).Times(captureSlots)
351	m.EXPECT().exportSyncFD(mock.Anything).Return(-1, vk.ErrorOutOfHostMemory).Once()
352	if _, err := r.BeginCapture(); err == nil || errors.Is(err, ports.ErrCaptureBusy) {
353		t.Fatalf("export failure: %v", err)
354	}
355	s := r.captures[0]
356	old := s.done
357	if !s.pending || s.leased || !s.replaceDone || s.done == 0 {
358		t.Fatalf("slot after export failure: leased=%v pending=%v replace=%v", s.leased, s.pending, s.replaceDone)
359	}
360	// While the copy runs the slot is skipped and its semaphore is not
361	// touched (no destroy expected yet).
362	m.EXPECT().fenceStatus(s.fence).Return(vk.Timeout).Once()
363	m.EXPECT().exportSyncFD(mock.Anything).RunAndReturn(dev.exportSyncFD).Once()
364	second := captureWait(t, r) // the other slot
365	if second.(*captureFrame).slot == s {
366		t.Fatal("pending slot reused")
367	}
368	r.EndCapture(second)
369	// Once the fence polls signalled the semaphore is replaced (the old
370	// one destroyed only now) and the slot works again.
371	if err := checked("vkWaitForFences", r.dd.WaitForFences(r.device, 1, &s.fence, 1, math.MaxUint64)); err != nil {
372		t.Fatal(err)
373	}
374	m.EXPECT().fenceStatus(s.fence).RunAndReturn(dev.fenceStatus).Once()
375	m.EXPECT().resetFence(s.fence).RunAndReturn(dev.resetFence).Once()
376	m.EXPECT().destroySemaphore(old).Run(dev.destroySemaphore).Once()
377	boom := errors.New("replacement failed")
378	m.EXPECT().newSemaphore().Return(vk.Semaphore(0), boom).Once()
379	if _, err := r.BeginCapture(); !errors.Is(err, boom) {
380		t.Fatalf("semaphore replacement error: %v", err)
381	}
382	if s.pending || !s.replaceDone || s.done != 0 {
383		t.Fatal("failed replacement lost retry state")
384	}
385	// Retry after the fence was already reset. No second destruction or
386	// fence poll is needed, and no null semaphore may be submitted.
387	m.EXPECT().newSemaphore().RunAndReturn(dev.newSemaphore).Once()
388	m.EXPECT().exportSyncFD(mock.Anything).RunAndReturn(dev.exportSyncFD).Once()
389	third := captureWait(t, r)
390	if third.(*captureFrame).slot != s || s.replaceDone {
391		t.Fatal("recovered slot not reused")
392	}
393	r.EndCapture(third)
394	// Close destroys both semaphores.
395	m.EXPECT().destroySemaphore(mock.Anything).Run(dev.destroySemaphore).Times(captureSlots)
396}
397
398// A device error from the fence poll or reset propagates instead of
399// being taken as busy.
400func TestCapturePollErrorPropagates(t *testing.T) {
401	r, err := New(8, 8)
402	if err != nil {
403		t.Skipf("Vulkan unavailable: %v", err)
404	}
405	defer r.Close()
406	if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
407		t.Fatal(err)
408	}
409	a := captureWait(t, r)
410	r.EndCapture(a)
411	m := newMockcaptureSync(t)
412	r.sync = m
413	m.EXPECT().fenceStatus(mock.Anything).Return(vk.ErrorDeviceLost).Once()
414	if _, err := r.BeginCapture(); err == nil || errors.Is(err, ports.ErrCaptureBusy) {
415		t.Fatalf("poll error: %v", err)
416	}
417	m.EXPECT().fenceStatus(mock.Anything).Return(vk.Success).Once()
418	m.EXPECT().resetFence(mock.Anything).Return(vk.ErrorOutOfDeviceMemory).Once()
419	if _, err := r.BeginCapture(); err == nil || errors.Is(err, ports.ErrCaptureBusy) {
420		t.Fatalf("reset error: %v", err)
421	}
422	// Close destroys the semaphores through the seam.
423	m.EXPECT().destroySemaphore(mock.Anything).Run(deviceSync{r}.destroySemaphore).Times(captureSlots)
424}
425
426// An fp16 client with NaN, +Inf and negative texels: the capture pass
427// maps NaN to black, bounds infinity to the shoulder's ceiling and lifts
428// the negative one to black, on the GPU as in captureSDR.
429func TestCaptureHDRNaNInf(t *testing.T) {
430	r := hdrTestRenderer(t)
431	format := ports.DMABufFormat{Format: fourcc('A', 'B', '4', 'H'), Modifier: 0}
432	if !slices.Contains(r.DMABuf().Formats, format) {
433		t.Skip("ABGR16161616F linear import unavailable")
434	}
435	// x<20: NaN; 20..40: +Inf; 40..: -2.0. Alpha 1.
436	texel := func(x int) uint16 {
437		switch {
438		case x < 20:
439			return 0x7e00
440		case x < 40:
441			return 0x7c00
442		}
443		return 0xc000
444	}
445	f := udmabuf16(t, 64, 16, texel)
446	buf := &ports.DMABuf{ID: 165, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 512}}}
447	c := ports.SurfaceContent{ID: 1, Width: 64, Height: 16, Opaque: true, DMABuf: buf, Color: ports.SurfaceColor{TF: ports.ColorTFExtendedLinear, Primaries: ports.ColorPrimariesSRGB}}
448	scene := ports.Scene{Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
449	if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
450		t.Fatal(err)
451	}
452	img := readPixels(t, r)
453	for _, tc := range []struct {
454		x    int
455		want [3]uint8
456	}{
457		{5, captureSDR([3]float64{math.NaN(), math.NaN(), math.NaN()})},
458		{25, captureSDR([3]float64{math.Inf(1), math.Inf(1), math.Inf(1)})},
459		{45, captureSDR([3]float64{-2, -2, -2})},
460	} {
461		got := img.RGBAAt(tc.x, 8)
462		if !near(got, color.RGBA{tc.want[0], tc.want[1], tc.want[2], 255}, 2) {
463			t.Errorf("x=%d got %v want %v", tc.x, got, tc.want)
464		}
465	}
466	if img.RGBAAt(5, 8) != (color.RGBA{0, 0, 0, 255}) || img.RGBAAt(45, 8) != (color.RGBA{0, 0, 0, 255}) {
467		t.Errorf("NaN %v / negative %v not black", img.RGBAAt(5, 8), img.RGBAAt(45, 8))
468	}
469	if inf := img.RGBAAt(25, 8); inf.R != 255 {
470		// The shoulder's ceiling rounds to 255 at infinity.
471		t.Errorf("+Inf %v", inf)
472	}
473}
474
475// udmabuf16 is a linear RGBA16F client buffer whose RGB texels are v(x).
476func udmabuf16(t *testing.T, w, h int, v func(x int) uint16) *os.File {
477	t.Helper()
478	pixels := make([]byte, w*h*8)
479	for y := range h {
480		for x := range w {
481			i := (y*w + x) * 8
482			for ch := range 3 {
483				binary.LittleEndian.PutUint16(pixels[i+ch*2:], v(x))
484			}
485			binary.LittleEndian.PutUint16(pixels[i+6:], 0x3c00)
486		}
487	}
488	mem, err := unix.MemfdCreate("fp16-client", unix.MFD_ALLOW_SEALING)
489	if err != nil {
490		t.Fatal(err)
491	}
492	defer unix.Close(mem)
493	if err := unix.Ftruncate(mem, int64(len(pixels))); err != nil {
494		t.Fatal(err)
495	}
496	if _, err := unix.Pwrite(mem, pixels, 0); err != nil {
497		t.Fatal(err)
498	}
499	if _, err := unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK); err != nil {
500		t.Fatal(err)
501	}
502	dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0)
503	if err != nil {
504		t.Skipf("udmabuf unavailable: %v", err)
505	}
506	defer dev.Close()
507	arg := struct {
508		memfd, flags uint32
509		offset, size uint64
510	}{memfd: uint32(mem), flags: 1, size: uint64(len(pixels))}
511	fd, _, errno := unix.Syscall(unix.SYS_IOCTL, dev.Fd(), 0x40187542, uintptr(unsafe.Pointer(&arg)))
512	if errno != 0 {
513		t.Skipf("udmabuf create: %v", errno)
514	}
515	f := os.NewFile(fd, "fp16-client")
516	t.Cleanup(func() { f.Close() })
517	return f
518}
519
520// Toggling HDR while a capture is leased: the lease's mapping stays
521// readable, the next captures follow the new mode, and a solid colour
522// round-trips SDR → HDR → SDR within the capture curve's tolerance.
523func TestCaptureSurvivesHDRToggle(t *testing.T) {
524	r := hdrTestRenderer(t) // HDR, exported target
525	scene := ports.Scene{Seq: 1, Background: "#c08040"}
526	if err := render(r, scene, nil); err != nil {
527		t.Fatal(err)
528	}
529	hdrShot := readPixels(t, r).RGBAAt(3, 3)
530	leased := captureWait(t, r) // HDR capture held across the toggle
531	r.SetHDR(0)
532	if _, err := r.ExportTargets(0, nil, false); err != nil {
533		t.Fatal(err)
534	}
535	scene.Seq++
536	if err := render(r, scene, nil); err != nil {
537		t.Fatal(err)
538	}
539	sdrShot := readPixels(t, r).RGBAAt(3, 3)
540	dst := make([]byte, 4)
541	if err := leased.Read(image.Rect(3, 3, 4, 4), dst, 4); err != nil {
542		t.Fatal(err)
543	}
544	if got := (color.RGBA{dst[2], dst[1], dst[0], 255}); got != hdrShot {
545		t.Fatalf("leased HDR capture changed by the toggle: %v vs %v", got, hdrShot)
546	}
547	r.EndCapture(leased)
548	// The SDR frame is exact; the HDR capture of the same colour is
549	// within the curve's rounding (coverage of the transition, not a
550	// claim about the curve's look).
551	if sdrShot != (color.RGBA{0xc0, 0x80, 0x40, 255}) {
552		t.Fatalf("SDR %v", sdrShot)
553	}
554	if !near(hdrShot, sdrShot, 10) {
555		t.Fatalf("HDR capture %v vs SDR %v", hdrShot, sdrShot)
556	}
557	// Back to HDR: the capture pass and slots are reused.
558	r.SetHDR(203)
559	if _, err := r.ExportTargets(1, r.hdrMods, false); err != nil {
560		t.Skipf("no HDR target: %v", err)
561	}
562	scene.Seq++
563	if err := render(r, scene, nil); err != nil {
564		t.Fatal(err)
565	}
566	if again := readPixels(t, r).RGBAAt(3, 3); again != hdrShot {
567		t.Fatalf("HDR capture after the round trip %v, before %v", again, hdrShot)
568	}
569}

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close