1package drm
2
3import (
4 "encoding/binary"
5 "maps"
6 "math"
7 "os"
8 "regexp"
9 "slices"
10 "strconv"
11 "strings"
12 "testing"
13
14 "github.com/bnema/neferwl/internal/ports"
15 "github.com/stretchr/testify/mock"
16 "golang.org/x/sys/unix"
17)
18
19// Plane COLOR_PIPELINE property IDs of the primary and overlay test planes,
20// and the CRTC DEGAMMA_LUT.
21const (
22 pColorPipe, pColorPipeOverlay, pDegamma = 60, 61, 62
23 opBase = 200 // first colorop ID of a plane; a plane's ops use 20 IDs
24)
25
26// Property IDs of a colorop, by role: kernel properties are per object.
27func bypassProp(op uint32) uint32 { return 1000 + op }
28func curveProp(op uint32) uint32 { return 2000 + op }
29func multProp(op uint32) uint32 { return 3000 + op }
30func dataProp(op uint32) uint32 { return 4000 + op }
31func typeProp(op uint32) uint32 { return 5000 + op }
32
33// The enum values are arbitrary: the matcher must read names.
34var (
35 colorTypes = map[string]uint64{"1D Curve": 9, "1D LUT": 4, "3x4 Matrix": 2, "Multiplier": 6, "3D LUT": 1}
36 firstCurve = map[string]uint64{"sRGB EOTF": 5, "PQ 125 EOTF": 6, "BT.2020 Inverse OETF": 3, "Gamma 2.2": 1}
37 lastCurve = map[string]uint64{"sRGB Inverse EOTF": 0, "PQ 125 Inverse EOTF": 8, "BT.2020 OETF": 2, "Gamma 2.2 Inverse": 7}
38)
39
40// opSpec is a colorop of a test pipeline.
41type opSpec struct {
42 typ string
43 curves map[string]uint64
44 noBypass bool
45}
46
47func curveOp(curves map[string]uint64) opSpec { return opSpec{typ: "1D Curve", curves: curves} }
48
49var (
50 opSRGB, opPQ = curveOp(firstCurve), curveOp(lastCurve)
51 opMult = opSpec{typ: "Multiplier"}
52 opCTM = opSpec{typ: "3x4 Matrix"}
53 opLUT1D = opSpec{typ: "1D LUT"}
54 opLUT3D = opSpec{typ: "3D LUT"}
55 // amdgpuChain is the pipeline read on kernel 7.2 (DCN3+).
56 amdgpuChain = []opSpec{opSRGB, opMult, opCTM, opPQ, opLUT1D, opLUT3D, curveOp(map[string]uint64{"sRGB EOTF": 5}), opLUT1D}
57)
58
59// colorPlane gives p a COLOR_PIPELINE property (id pipeProp) offering the
60// chains, each with ops from base up, and registers the kernel's answers.
61// It returns the first colorop of each chain.
62func colorPlane(k *mockkms, p *plane, pipeProp, base uint32, chains ...[]opSpec) []uint64 {
63 p.props = maps0(p.props)
64 p.props["COLOR_PIPELINE"] = pipeProp
65 enums := map[string]uint64{"Bypass": 0}
66 var firsts []uint64
67 k.EXPECT().propEnums(uint32(typeProp(0))).Return(colorTypes, nil).Maybe()
68 id := base
69 for c, chain := range chains {
70 firsts = append(firsts, uint64(id))
71 enums["Pipeline "+strconv.Itoa(c)] = uint64(id)
72 for i, op := range chain {
73 props := map[string][2]uint64{
74 "TYPE": {uint64(typeProp(0)), colorTypes[op.typ]},
75 "NEXT": {1, 0},
76 }
77 if i+1 < len(chain) {
78 props["NEXT"] = [2]uint64{1, uint64(id + 1)}
79 }
80 if !op.noBypass {
81 props["BYPASS"] = [2]uint64{uint64(bypassProp(id)), 1}
82 }
83 switch op.typ {
84 case "1D Curve":
85 props["CURVE_1D_TYPE"] = [2]uint64{uint64(curveProp(id)), 0}
86 k.EXPECT().propEnums(curveProp(id)).Return(op.curves, nil).Maybe()
87 case "Multiplier":
88 props["MULTIPLIER"] = [2]uint64{uint64(multProp(id)), 0}
89 case "3x4 Matrix", "1D LUT", "3D LUT":
90 props["DATA"] = [2]uint64{uint64(dataProp(id)), 0}
91 }
92 k.EXPECT().objProps(id, uint32(objColorop)).Return(props, nil).Maybe()
93 id++
94 }
95 }
96 k.EXPECT().propEnums(pipeProp).Return(enums, nil).Maybe()
97 return firsts
98}
99
100func maps0(m map[string]uint32) map[string]uint32 {
101 out := make(map[string]uint32, len(m)+1)
102 for k, v := range m {
103 out[k] = v
104 }
105 return out
106}
107
108func TestMatchColorPipeline(t *testing.T) {
109 for _, tc := range []struct {
110 name string
111 chains [][]opSpec
112 want int // index of the chain that matches, -1: none
113 reason string // substring of the reason when none matches
114 }{
115 {"amdgpu", [][]opSpec{amdgpuChain}, 0, ""},
116 {"minimal", [][]opSpec{{opSRGB, opMult, opCTM, opPQ}}, 0, ""},
117 {"bypassable op before the chain", [][]opSpec{{opLUT1D, opSRGB, opMult, opCTM, opPQ}}, 0, ""},
118 {"second pipeline matches", [][]opSpec{{opLUT1D}, amdgpuChain}, 1, ""},
119 {"no pipeline", nil, -1, "only Bypass"},
120 {"no PQ curve", [][]opSpec{{opSRGB, opMult, opCTM, curveOp(map[string]uint64{"sRGB Inverse EOTF": 0})}}, -1, "no sRGB EOTF"},
121 {"no sRGB curve", [][]opSpec{{curveOp(map[string]uint64{"Gamma 2.2": 1}), opMult, opCTM, opPQ}}, -1, "no sRGB EOTF"},
122 {"no multiplier", [][]opSpec{{opSRGB, opCTM, opPQ}}, -1, "no sRGB EOTF"},
123 {"no matrix", [][]opSpec{{opSRGB, opMult, opPQ}}, -1, "no sRGB EOTF"},
124 {"wrong order", [][]opSpec{{opSRGB, opCTM, opMult, opPQ}}, -1, "no sRGB EOTF"},
125 {"PQ before sRGB", [][]opSpec{{opPQ, opMult, opCTM, opSRGB}}, -1, "no sRGB EOTF"},
126 {"op without BYPASS", [][]opSpec{{opSRGB, opMult, opCTM, opPQ, {typ: "1D LUT", noBypass: true}}}, -1, "cannot be bypassed"},
127 {"op without BYPASS first", [][]opSpec{{{typ: "3D LUT", noBypass: true}, opSRGB, opMult, opCTM, opPQ}}, -1, "cannot be bypassed"},
128 } {
129 t.Run(tc.name, func(t *testing.T) {
130 o, k, _ := testOutput(t)
131 firsts := colorPlane(k, o.primary, pColorPipe, opBase, tc.chains...)
132 cp, why := readColorPipeline(k, o.primary)
133 if tc.want < 0 {
134 if cp != nil || !strings.Contains(why, tc.reason) {
135 t.Fatalf("matched %+v, reason %q; want none with %q", cp, why, tc.reason)
136 }
137 return
138 }
139 if cp == nil {
140 t.Fatalf("no match: %s", why)
141 }
142 if cp.id != firsts[tc.want] {
143 t.Fatalf("pipeline id %d, want %d", cp.id, firsts[tc.want])
144 }
145 // The matched ops are the chain's, found by type, with their
146 // own properties and the enum values read from the curves.
147 chain, base := tc.chains[tc.want], uint32(cp.id)
148 find := func(match func(opSpec) bool, from int) uint32 {
149 for i := from; i < len(chain); i++ {
150 if match(chain[i]) {
151 return base + uint32(i)
152 }
153 }
154 t.Fatal("test chain lacks an op")
155 return 0
156 }
157 srgb := find(func(s opSpec) bool { return s.typ == "1D Curve" && s.curves["sRGB EOTF"] == 5 }, 0)
158 mult := find(func(s opSpec) bool { return s.typ == "Multiplier" }, 0)
159 ctm := find(func(s opSpec) bool { return s.typ == "3x4 Matrix" }, 0)
160 pq := find(func(s opSpec) bool { _, ok := s.curves["PQ 125 Inverse EOTF"]; return ok }, 0)
161 if cp.srgb != (colorStage{op: srgb, bypass: bypassProp(srgb), prop: curveProp(srgb), curve: 5}) ||
162 cp.mult != (colorStage{op: mult, bypass: bypassProp(mult), prop: multProp(mult)}) ||
163 cp.ctm != (colorStage{op: ctm, bypass: bypassProp(ctm), prop: dataProp(ctm)}) ||
164 cp.pq != (colorStage{op: pq, bypass: bypassProp(pq), prop: curveProp(pq), curve: 8}) {
165 t.Fatalf("stages %+v", cp)
166 }
167 if len(cp.others) != len(chain)-4 {
168 t.Fatalf("%d other ops, want %d", len(cp.others), len(chain)-4)
169 }
170 })
171 }
172}
173
174func TestReadColorPipelineWithoutProperty(t *testing.T) {
175 o, k, _ := testOutput(t)
176 if cp, why := readColorPipeline(k, o.primary); cp != nil || !strings.Contains(why, "COLOR_PIPELINE") {
177 t.Fatalf("plane without the property: %+v %q", cp, why)
178 }
179}
180
181// A NEXT loop must not hang the read.
182func TestWalkColoropsStopsOnCycle(t *testing.T) {
183 o, k, _ := testOutput(t)
184 k.EXPECT().objProps(uint32(7), uint32(objColorop)).Return(map[string][2]uint64{"NEXT": {1, 7}}, nil)
185 if _, err := walkColorops(k, 7); err == nil {
186 t.Fatal("cyclic chain accepted")
187 }
188 _ = o
189}
190
191func TestS3132(t *testing.T) {
192 for _, tc := range []struct {
193 v float64
194 want uint64
195 }{
196 {0, 0},
197 {math.Copysign(0, -1), 0},
198 {1, 1 << 32},
199 {2.5, 5 << 31},
200 {0.5, 1 << 31},
201 {-1, 1<<63 | 1<<32},
202 {-0.5, 1<<63 | 1<<31},
203 {203.0 / 80, uint64(math.Round(203.0 / 80 * (1 << 32)))},
204 {1e30, 1<<63 - 1},
205 {-1e30, 1<<63 | (1<<63 - 1)},
206 } {
207 if got := s3132(tc.v); got != tc.want {
208 t.Errorf("s3132(%v) = %#x, want %#x", tc.v, got, tc.want)
209 }
210 }
211}
212
213// fromS3132 decodes sign-magnitude S31.32.
214func fromS3132(v uint64) float64 {
215 f := float64(v&(1<<63-1)) / (1 << 32)
216 if v>>63 != 0 {
217 return -f
218 }
219 return f
220}
221
222// The CTM is the mat3 of hdr.frag, transposed: GLSL lists columns, the blob
223// is row-major with a zero fourth column.
224func TestCTMBlobMatchesHDRShader(t *testing.T) {
225 src, err := os.ReadFile("../vulkan/shaders/hdr.frag")
226 if err != nil {
227 t.Fatal(err)
228 }
229 m := regexp.MustCompile(`mat3\(([^)]*)\)`).FindSubmatch(src)
230 if m == nil {
231 t.Fatal("no mat3 in hdr.frag")
232 }
233 var cols []float64 // column-major, as GLSL reads it
234 for _, f := range strings.Split(string(m[1]), ",") {
235 v, err := strconv.ParseFloat(strings.TrimSpace(f), 64)
236 if err != nil {
237 t.Fatal(err)
238 }
239 cols = append(cols, v)
240 }
241 if len(cols) != 9 {
242 t.Fatalf("mat3 has %d values", len(cols))
243 }
244 blob := ctmBytes(bt709ToBT2020)
245 if len(blob) != 96 {
246 t.Fatalf("blob is %d bytes, struct drm_color_ctm_3x4 is 96", len(blob))
247 }
248 for row := range 3 {
249 for col := range 4 {
250 got := fromS3132(binary.LittleEndian.Uint64(blob[(row*4+col)*8:]))
251 want := 0.0
252 if col < 3 {
253 want = cols[col*3+row] // out[row] = sum over col of M[col][row]*in[col]
254 }
255 if math.Abs(got-want) > 1e-9 {
256 t.Errorf("blob[%d][%d] = %v, want %v", row, col, got, want)
257 }
258 }
259 }
260 // Applied to white, as the shader would: a column vector times mat3.
261 var out [3]float64
262 for row := range 3 {
263 for col := range 3 {
264 out[row] += fromS3132(binary.LittleEndian.Uint64(blob[(row*4+col)*8:]))
265 }
266 }
267 for row, want := range [3]float64{
268 cols[0] + cols[3] + cols[6], cols[1] + cols[4] + cols[7], cols[2] + cols[5] + cols[8],
269 } {
270 if math.Abs(out[row]-want) > 1e-9 {
271 t.Errorf("white channel %d = %v, shader %v", row, out[row], want)
272 }
273 }
274}
275
276func TestPlaneColor(t *testing.T) {
277 pq := ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}
278 srgb := ports.SurfaceColor{TF: ports.ColorTFSRGB, Primaries: ports.ColorPrimariesSRGB}
279 ext := ports.SurfaceColor{TF: ports.ColorTFExtendedLinear, Primaries: ports.ColorPrimariesSRGB}
280 gamma := ports.SurfaceColor{TF: 10, Primaries: ports.ColorPrimariesSRGB}
281 p3 := ports.SurfaceColor{TF: ports.ColorTFSRGB, Primaries: 7}
282 pipe := &colorPipeline{}
283 for _, tc := range []struct {
284 name string
285 c ports.SurfaceColor
286 format uint32
287 opaque bool
288 hdr bool
289 pipe *colorPipeline
290 want colorMode
291 reason string
292 }{
293 {"SDR output, sRGB", ports.SurfaceColor{}, fourccXRGB, true, false, nil, colorBypass, ""},
294 {"SDR output ignores a pipeline", srgb, fourccXRGB, true, false, pipe, colorBypass, ""},
295 {"SDR output, PQ", pq, fourccXR30, true, false, nil, colorBypass, "sdr_pq_content"},
296 {"SDR output, YUV", ports.SurfaceColor{}, fourccNV12, true, false, nil, colorBypass, "yuv"},
297 {"HDR, PQ 10-bit", pq, fourccXR30, true, true, pipe, colorBypass, ""},
298 {"HDR, PQ 10-bit with alpha", pq, fourccAR30, false, true, nil, colorBypass, ""},
299 {"HDR, PQ 8-bit", pq, fourccXRGB, true, true, pipe, colorBypass, "hdr_format"},
300 {"HDR, PQ YUV", pq, fourccP010, true, true, pipe, colorBypass, "yuv"},
301 {"HDR, unspecified colour", ports.SurfaceColor{}, fourccXRGB, true, true, pipe, colorSDRToPQ, ""},
302 {"HDR, sRGB", srgb, fourccXRGB, true, true, pipe, colorSDRToPQ, ""},
303 {"HDR, sRGB, 10-bit buffer", srgb, fourccXR30, true, true, pipe, colorSDRToPQ, ""},
304 {"HDR, sRGB, no pipeline", srgb, fourccXRGB, true, true, nil, colorBypass, "hdr_sdr_content"},
305 {"HDR, sRGB with alpha", srgb, fourccARGB, false, true, pipe, colorBypass, "hdr_sdr_content"},
306 {"HDR, extended linear", ext, fourccXRGB, true, true, pipe, colorBypass, "hdr_sdr_content"},
307 {"HDR, other transfer function", gamma, fourccXRGB, true, true, pipe, colorBypass, "hdr_sdr_content"},
308 {"HDR, other primaries", p3, fourccXRGB, true, true, pipe, colorBypass, "hdr_sdr_content"},
309 {"HDR, sRGB YUV", srgb, fourccNV12, true, true, pipe, colorBypass, "yuv"},
310 } {
311 t.Run(tc.name, func(t *testing.T) {
312 mode, reason := planeColor(tc.c, tc.format, tc.opaque, tc.hdr, tc.pipe)
313 if mode != tc.want || reason != tc.reason {
314 t.Fatalf("mode %d reason %q, want %d %q", mode, reason, tc.want, tc.reason)
315 }
316 })
317 }
318}
319
320// Colour test outputs.
321
322// colorOutput is an HDR output whose primary (and overlay) plane matches the
323// amdgpu pipeline. rule, when set, is the driver model every commit meets.
324func colorOutput(t *testing.T, overlay bool, rule func(*atomicReq, uint32) error) (*Output, *mockkms, *[]commitRec) {
325 t.Helper()
326 o, k, commits, _ := testOutputRules(t, rule)
327 props := maps0(planeProps)
328 props["zpos"] = pZpos
329 o.primary.props = props
330 o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
331 colorPlane(k, o.primary, pColorPipe, opBase, amdgpuChain)
332 if overlay {
333 ov := &plane{id: tOverlay, typ: planeOverlay, props: props, zposValue: 2, formats: []ports.DMABufFormat{{Format: fourccXRGB}}}
334 colorPlane(k, ov, pColorPipeOverlay, opBase+20, amdgpuChain)
335 o.stray = []*plane{ov}
336 o.pickOverlay()
337 if o.overlay == nil {
338 t.Fatal("no overlay")
339 }
340 }
341 o.hdr.on = true
342 o.readColor()
343 o.ctmBlob, o.colorMult = 61, s3132(203.0/80)
344 if o.primary.pipeline == nil || overlay && o.overlay.pipeline == nil {
345 t.Fatal("pipeline not matched")
346 }
347 return o, k, commits
348}
349
350// colorOps are the colorop objects a commit touches.
351func colorOps(c commitRec) []uint32 {
352 var out []uint32
353 for _, obj := range c.req.objs {
354 if obj >= opBase {
355 out = append(out, obj)
356 }
357 }
358 return out
359}
360
361func sdrContent(o *Output) ports.SurfaceContent {
362 o.clientFBs[9] = &clientFB{fbID: 80}
363 return ports.SurfaceContent{ID: 1, Width: 200, Height: 100, Opaque: true, DMABuf: &ports.DMABuf{ID: 9, Format: fourccXRGB}}
364}
365
366func TestColorCommitTransitions(t *testing.T) {
367 o, _, commits := colorOutput(t, false, nil)
368 c := sdrContent(o)
369 c.Async = true
370 o.shown = 9
371 cp := o.primary.pipeline
372 last := func() commitRec { return (*commits)[len(*commits)-1] }
373
374 // First SDR scanout writes the pipeline, never async even though the
375 // buffer asks for it: only FB_ID may change in an async flip.
376 if ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
377 t.Fatalf("ok=%v err=%v", ok, err)
378 }
379 first := last()
380 if first.flags&flipAsyncFlag != 0 {
381 t.Fatal("a colour change went async")
382 }
383 if v, ok := first.req.value(tPrimary, pColorPipe); !ok || v != cp.id {
384 t.Fatalf("COLOR_PIPELINE %d %v, want %d", v, ok, cp.id)
385 }
386 for _, s := range []struct {
387 op, prop uint32
388 want uint64
389 }{
390 {cp.srgb.op, cp.srgb.bypass, 0}, {cp.srgb.op, cp.srgb.prop, cp.srgb.curve},
391 {cp.mult.op, cp.mult.bypass, 0}, {cp.mult.op, cp.mult.prop, s3132(203.0 / 80)},
392 {cp.ctm.op, cp.ctm.bypass, 0}, {cp.ctm.op, cp.ctm.prop, 61},
393 {cp.pq.op, cp.pq.bypass, 0}, {cp.pq.op, cp.pq.prop, cp.pq.curve},
394 } {
395 if v, ok := first.req.value(s.op, s.prop); !ok || v != s.want {
396 t.Errorf("colorop %d prop %d = %d %v, want %d", s.op, s.prop, v, ok, s.want)
397 }
398 }
399 for _, other := range cp.others {
400 if v, ok := first.req.value(other.op, other.bypass); !ok || v != 1 {
401 t.Errorf("other colorop %d BYPASS %d %v, want 1", other.op, v, ok)
402 }
403 }
404 if !o.primary.colorKnown || o.primary.colorApplied != colorSDRToPQ {
405 t.Fatalf("applied %v known %v", o.primary.colorApplied, o.primary.colorKnown)
406 }
407
408 // Same mode again: nothing is written, and the flip may be async.
409 o.frame.endPending()
410 o.vrrOn = true
411 if ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
412 t.Fatalf("ok=%v err=%v", ok, err)
413 }
414 if second := last(); second.flags&flipAsyncFlag == 0 || len(colorOps(second)) != 0 {
415 t.Fatalf("steady state: flags %#x colorops %v", second.flags, colorOps(second))
416 } else if _, ok := second.req.value(tPrimary, pColorPipe); ok {
417 t.Fatal("steady state wrote COLOR_PIPELINE")
418 }
419
420 // A composed frame sets Bypass once.
421 o.frame.endPending()
422 if err := o.commitFrame(70, nil, true, false, pendingFrame{}); err != nil {
423 t.Fatal(err)
424 }
425 composed := last()
426 if composed.flags&flipAsyncFlag != 0 {
427 t.Fatal("the return to Bypass went async")
428 }
429 if v, ok := composed.req.value(tPrimary, pColorPipe); !ok || v != 0 || len(colorOps(composed)) != 0 {
430 t.Fatalf("composed: COLOR_PIPELINE %d %v colorops %v", v, ok, colorOps(composed))
431 }
432 o.frame.endPending()
433 if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
434 t.Fatal(err)
435 }
436 again := last()
437 if _, ok := again.req.value(tPrimary, pColorPipe); ok {
438 t.Fatal("Bypass written twice")
439 }
440}
441
442// A plane that applies the pipeline never sees colorop properties in a
443// cursor or VRR commit.
444func TestCommitStateNeverCarriesColorProps(t *testing.T) {
445 o, _, commits := colorOutput(t, false, nil)
446 o.primary.colorKnown, o.primary.colorApplied = false, colorSDRToPQ // stale on purpose
447 o.cursor.image = true
448 o.cursor.Move(3, 4)
449 if err := o.commitState(true); err != nil {
450 t.Fatal(err)
451 }
452 c := (*commits)[0]
453 if _, ok := c.req.value(tPrimary, pColorPipe); ok || len(colorOps(c)) != 0 {
454 t.Fatalf("state commit carries colour: %+v", c.req)
455 }
456 if _, ok := c.req.value(tCursor, 14); !ok {
457 t.Fatal("cursor not moved")
458 }
459}
460
461// Protected frames, power off, Close and every modeset leave Bypass, on the
462// overlay too, whatever was applied or known.
463func TestColorBypassOnComposedProtectedOffCloseAndModeset(t *testing.T) {
464 apply := func(o *Output) {
465 for _, p := range []*plane{o.primary, o.overlay} {
466 p.colorKnown, p.colorApplied = true, colorSDRToPQ
467 }
468 }
469 bypassed := func(t *testing.T, c commitRec) {
470 t.Helper()
471 for _, p := range []struct{ plane, prop uint32 }{{tPrimary, pColorPipe}, {tOverlay, pColorPipeOverlay}} {
472 if v, ok := c.req.value(p.plane, p.prop); !ok || v != 0 {
473 t.Errorf("plane %d COLOR_PIPELINE %d %v, want Bypass", p.plane, v, ok)
474 }
475 }
476 if ops := colorOps(c); len(ops) != 0 {
477 t.Errorf("Bypass commit touches colorops %v", ops)
478 }
479 }
480 t.Run("power off", func(t *testing.T) {
481 o, _, commits := colorOutput(t, true, nil)
482 apply(o)
483 if err := o.powerOff(); err != nil {
484 t.Fatal(err)
485 }
486 bypassed(t, (*commits)[0])
487 if !o.primary.colorKnown || o.primary.colorApplied != colorBypass || o.overlay.colorApplied != colorBypass {
488 t.Fatal("power off did not record Bypass")
489 }
490 })
491 t.Run("close", func(t *testing.T) {
492 o, k, commits := colorOutput(t, true, nil)
493 k.EXPECT().rmFB(mock.Anything).Return(nil)
494 k.EXPECT().destroyBlob(uint32(61)).Return(nil).Once()
495 // Unknown state: Close still writes Bypass.
496 o.Close()
497 bypassed(t, (*commits)[0])
498 if o.ctmBlob != 0 {
499 t.Fatal("CTM blob kept")
500 }
501 })
502 t.Run("modeset", func(t *testing.T) {
503 o, k, commits := colorOutput(t, true, nil)
504 k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
505 k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, 0}}, nil)
506 apply(o)
507 o.primary.verdicts = []colorVerdict{{format: fourccXRGB}}
508 if err := o.modeset(); err != nil {
509 t.Fatal(err)
510 }
511 bypassed(t, (*commits)[0])
512 if o.primary.colorKnown || o.overlay.colorKnown || len(o.primary.verdicts) != 0 {
513 t.Fatal("a modeset kept the colour state or the refusals")
514 }
515 })
516 t.Run("protected frame", func(t *testing.T) {
517 o, _, commits := colorOutput(t, true, nil)
518 apply(o)
519 o.protected = true
520 if err := o.commitWith(70, nil, false, false, pendingFrame{}, overlayWin{}); err != nil {
521 t.Fatal(err)
522 }
523 c := (*commits)[0]
524 if v, ok := c.req.value(tPrimary, pColorPipe); !ok || v != 0 {
525 t.Fatalf("protected frame: COLOR_PIPELINE %d %v", v, ok)
526 }
527 })
528}
529
530// A modeset and Close clear the CRTC's DEGAMMA_LUT, CTM and GAMMA_LUT,
531// which another DRM master may have left: a ramp kept there distorts every
532// frame, the PQ ones included.
533func TestCRTCColorClearedOnModesetAndClose(t *testing.T) {
534 const pCTM, pGamma = 63, 64
535 cleared := func(t *testing.T, c commitRec) {
536 t.Helper()
537 for _, p := range []uint32{pDegamma, pCTM, pGamma} {
538 if v, ok := c.req.value(tCrtc, p); !ok || v != 0 {
539 t.Errorf("CRTC property %d = %d %v, want 0", p, v, ok)
540 }
541 }
542 }
543 setup := func(t *testing.T) (*Output, *mockkms, *[]commitRec) {
544 o, k, commits := colorOutput(t, false, nil)
545 o.crtcProps = maps.Clone(o.crtcProps)
546 o.crtcProps["DEGAMMA_LUT"], o.crtcProps["CTM"], o.crtcProps["GAMMA_LUT"] = pDegamma, pCTM, pGamma
547 return o, k, commits
548 }
549 t.Run("modeset", func(t *testing.T) {
550 o, k, commits := setup(t)
551 k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
552 if err := o.modeset(); err != nil {
553 t.Fatal(err)
554 }
555 cleared(t, (*commits)[0])
556 })
557 t.Run("close", func(t *testing.T) {
558 o, k, commits := setup(t)
559 k.EXPECT().rmFB(mock.Anything).Return(nil)
560 k.EXPECT().destroyBlob(uint32(61)).Return(nil).Once()
561 o.Close()
562 cleared(t, (*commits)[0])
563 })
564 t.Run("frame", func(t *testing.T) {
565 // A frame commit leaves them alone: they stay as the modeset left them.
566 o, _, commits := setup(t)
567 if ok, err := o.commitScanoutRect(80, sdrContent(o), pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
568 t.Fatalf("ok=%v err=%v", ok, err)
569 }
570 for _, p := range []uint32{pDegamma, pCTM, pGamma} {
571 if _, ok := (*commits)[0].req.value(tCrtc, p); ok {
572 t.Errorf("frame commit writes CRTC property %d", p)
573 }
574 }
575 })
576}
577
578// The overlay's pipeline is driven with the primary on Bypass; a frame
579// without the overlay turns it back to Bypass once.
580func TestColorOverlayTransitions(t *testing.T) {
581 o, _, commits := colorOutput(t, true, nil)
582 o.clientFBs[9] = &clientFB{fbID: 88}
583 ov := overlayWin{id: 2, fb: 88, buf: 9, w: 100, h: 100, rect: ports.Rect{X: 100, W: 100, H: 100}, color: colorUse{mode: colorSDRToPQ, format: fourccXRGB}}
584 if err := o.commitWith(70, nil, false, false, pendingFrame{}, ov); err != nil {
585 t.Fatal(err)
586 }
587 c := (*commits)[0]
588 cp := o.overlay.pipeline
589 if v, ok := c.req.value(tOverlay, pColorPipeOverlay); !ok || v != cp.id {
590 t.Fatalf("overlay COLOR_PIPELINE %d %v, want %d", v, ok, cp.id)
591 }
592 if v, ok := c.req.value(tPrimary, pColorPipe); !ok || v != 0 {
593 t.Fatalf("composed primary COLOR_PIPELINE %d %v, want Bypass", v, ok)
594 }
595 o.frame.endPending()
596 if err := o.commitWith(70, nil, false, false, pendingFrame{}, ov); err != nil {
597 t.Fatal(err)
598 }
599 if again := (*commits)[1]; len(colorOps(again)) != 0 {
600 t.Fatal("overlay colour written twice")
601 } else if _, ok := again.req.value(tOverlay, pColorPipeOverlay); ok {
602 t.Fatal("overlay COLOR_PIPELINE written twice")
603 }
604 o.frame.endPending()
605 if err := o.commitWith(70, nil, false, false, pendingFrame{}, overlayWin{}); err != nil {
606 t.Fatal(err)
607 }
608 if v, ok := (*commits)[2].req.value(tOverlay, pColorPipeOverlay); !ok || v != 0 {
609 t.Fatalf("overlay left without Bypass: %d %v", v, ok)
610 }
611}
612
613// refusePipeline is a driver that refuses any request selecting a pipeline
614// on a plane with EINVAL, TEST_ONLY included.
615func refusePipeline(r *atomicReq, _ uint32) error {
616 for _, obj := range [...]struct{ plane, prop uint32 }{{tPrimary, pColorPipe}, {tOverlay, pColorPipeOverlay}} {
617 if v, ok := r.value(obj.plane, obj.prop); ok && v != 0 {
618 return unix.EINVAL
619 }
620 }
621 return nil
622}
623
624func hdrScanoutScene() (ports.Scene, map[ports.WindowID]ports.SurfaceContent) {
625 s, c := fullscreenScene()
626 return s, c
627}
628
629func countTests(commits []commitRec) int {
630 n := 0
631 for _, c := range commits {
632 if c.flags&atomicTestOnly != 0 {
633 n++
634 }
635 }
636 return n
637}
638
639// SDR scanout on HDR is tested before its first use. A refusal is cached per
640// plane, format and cursor state and is never tested again; a modeset asks
641// the driver again.
642func TestColorRefusalIsTestedOnceAndCached(t *testing.T) {
643 o, k, commits := colorOutput(t, false, refusePipeline)
644 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
645 k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe()
646 s, surfaces := hdrScanoutScene()
647 decide := func() string {
648 t.Helper()
649 if fb, _, _ := o.scanoutFrame(s, surfaces); fb != 0 {
650 t.Fatalf("refused pipeline scanned out (fb %d)", fb)
651 }
652 return o.reason
653 }
654 if got := decide(); got != "color_refused" || countTests(*commits) != 1 {
655 t.Fatalf("reason %q after %d tests", got, countTests(*commits))
656 }
657 for range 3 {
658 decide()
659 }
660 if n := countTests(*commits); n != 1 {
661 t.Fatalf("a cached refusal was tested %d times", n)
662 }
663 if cfb := o.clientFBs[9]; cfb.failed != "" {
664 t.Fatalf("buffer marked failed: %q", cfb.failed)
665 }
666 // The cursor shown is another state: asked once more, then cached.
667 o.cursor.image = true
668 o.cursor.Move(1, 1)
669 decide()
670 decide()
671 if n := countTests(*commits); n != 2 {
672 t.Fatalf("%d tests with the cursor shown, want 2", n)
673 }
674 // A modeset asks the driver again.
675 o.forgetColorVerdicts()
676 decide()
677 if n := countTests(*commits); n != 3 {
678 t.Fatalf("a modeset did not ask the driver again: %d tests", n)
679 }
680}
681
682// KMS accepts the colour test and refuses the real commit: the frame is
683// composed, the refusal cached, the buffer not blamed.
684func TestColorRefusalOfCommittedFrame(t *testing.T) {
685 // The first request that selects the pipeline outside TEST_ONLY fails.
686 o, k, commits := colorOutput(t, false, func(r *atomicReq, flags uint32) error {
687 if flags&atomicTestOnly == 0 {
688 return refusePipeline(r, flags)
689 }
690 return nil
691 })
692 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
693 s, surfaces := hdrScanoutScene()
694 d := o.decideFrame(s, surfaces, false)
695 if d.fb != 80 || d.color != colorSDRToPQ {
696 t.Fatalf("decision %+v", d)
697 }
698 ok, err := o.commitScanoutRect(d.fb, d.content, pendingFrame{}, d.rect, d.color)
699 if ok || err != nil || o.reason != "color_refused" {
700 t.Fatalf("ok=%v err=%v reason %q", ok, err, o.reason)
701 }
702 if o.frame.pendingCommit() || o.primary.colorKnown {
703 t.Fatal("a refused commit changed the state")
704 }
705 before := len(*commits)
706 if d := o.decideFrame(s, surfaces, false); d.fb != 0 || o.reason != "color_refused" || len(*commits) != before {
707 t.Fatalf("refusal not cached: fb %d reason %q commits %d->%d", d.fb, o.reason, before, len(*commits))
708 }
709 if cfb := o.clientFBs[9]; cfb.failed != "" || cfb.noAsync {
710 t.Fatalf("buffer blamed: %+v", cfb)
711 }
712}
713
714// A buffer KMS refuses on its own is still blamed on the buffer, not the
715// pipeline.
716func TestColorRefusalOfTheBufferStaysTheBuffers(t *testing.T) {
717 o, k, _ := colorOutput(t, false, func(r *atomicReq, flags uint32) error {
718 // The driver takes the pipeline test, then refuses the buffer
719 // whatever the pipeline: the commit and its Bypass test fail.
720 if v, ok := r.value(tPrimary, pColorPipe); flags&atomicTestOnly == 0 || ok && v == 0 {
721 return unix.EINVAL
722 }
723 return nil
724 })
725 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
726 s, surfaces := hdrScanoutScene()
727 d := o.decideFrame(s, surfaces, false)
728 if ok, err := o.commitScanoutRect(d.fb, d.content, pendingFrame{}, d.rect, d.color); ok || err != nil {
729 t.Fatalf("ok=%v err=%v", ok, err)
730 }
731 if cfb := o.clientFBs[9]; cfb.failed != "flip_refused" || o.reason != "flip_refused" {
732 t.Fatalf("failed %q reason %q", cfb.failed, o.reason)
733 }
734}
735
736// A cursor commit refused while a plane shows the pipeline caches the
737// refusal for the cursor state and has the next frame composed.
738func TestColorCursorConflict(t *testing.T) {
739 o, _, _ := colorOutput(t, false, func(r *atomicReq, flags uint32) error {
740 if v, ok := r.value(tCursor, pFB); ok && v != 0 {
741 return unix.EINVAL
742 }
743 return nil
744 })
745 o.primary.colorKnown, o.primary.colorApplied, o.primary.colorFormat = true, colorSDRToPQ, fourccXRGB
746 o.cursor.image = true
747 o.cursor.Move(5, 5)
748 if err := o.commitState(false); err != errOverlayDropped {
749 t.Fatalf("commitState: %v", err)
750 }
751 if ok, known := o.primary.verdict(fourccXRGB, true); !known || ok {
752 t.Fatal("refusal with the cursor not cached")
753 }
754 if o.cursor.off {
755 t.Fatal("the cursor was blamed")
756 }
757}
758
759// Overlay: allowed in HDR when the colour resolves; a refusal is cached.
760func TestColorOverlayInHDR(t *testing.T) {
761 o, k, commits := colorOutput(t, true, refusePipeline)
762 o.primary.pipeline = nil // only the overlay converts
763 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(88), nil).Maybe()
764 s, surfaces := overlayScene()
765 // PQ content and SDR content without alpha are both candidates now.
766 ov, rest := o.overlayFrame(s, surfaces)
767 if ov.fb != 88 || ov.color.mode != colorSDRToPQ || len(rest.Windows) != 1 {
768 t.Fatalf("overlay %+v", ov)
769 }
770 if o.testOverlay(70, ov) {
771 t.Fatal("the driver's refusal was taken")
772 }
773 if o.overlayReason != "color_refused" {
774 t.Fatalf("reason %q", o.overlayReason)
775 }
776 if cfb := o.clientFBs[9]; cfb.overlayFailed != "" {
777 t.Fatalf("buffer blamed: %q", cfb.overlayFailed)
778 }
779 tests := countTests(*commits)
780 if ov, rest := o.overlayFrame(s, surfaces); ov.fb != 0 || o.overlayReason != "color_refused" || len(rest.Windows) != 2 || countTests(*commits) != tests {
781 t.Fatalf("not cached: %+v reason %q", ov, o.overlayReason)
782 }
783 // Without a pipeline an SDR window stays composed on HDR.
784 o.overlay.pipeline = nil
785 o.forgetColorVerdicts()
786 if ov, _ := o.overlayFrame(s, surfaces); ov.fb != 0 || o.overlayReason != "no_candidate" {
787 t.Fatalf("no pipeline: %+v reason %q", ov, o.overlayReason)
788 }
789}
790
791func TestSetupColor(t *testing.T) {
792 t.Run("HDR makes the matrix blob and the multiplier", func(t *testing.T) {
793 o, k, _ := colorOutput(t, false, nil)
794 o.ctmBlob, o.colorMult = 0, 0
795 o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 160}
796 o.hdr.cap.Capable = true
797 k.EXPECT().createBlob(mock.MatchedBy(func(b []byte) bool { return slices.Equal(b, ctmBytes(bt709ToBT2020)) })).Return(uint32(61), nil).Once()
798 o.setupColor()
799 if o.ctmBlob != 61 || o.colorMult != 2<<32 {
800 t.Fatalf("blob %d multiplier %#x", o.ctmBlob, o.colorMult)
801 }
802 })
803 t.Run("blob failure turns the pipelines off", func(t *testing.T) {
804 o, k, _ := colorOutput(t, true, nil)
805 o.ctmBlob = 0
806 o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 203}
807 o.hdr.cap.Capable = true
808 k.EXPECT().createBlob(mock.Anything).Return(uint32(0), unix.ENOMEM).Once()
809 o.setupColor()
810 if o.primary.pipeline != nil || o.overlay.pipeline != nil || o.ctmBlob != 0 {
811 t.Fatal("pipelines kept without their blob")
812 }
813 })
814 t.Run("SDR output keeps Bypass", func(t *testing.T) {
815 o, _, _ := colorOutput(t, false, nil)
816 o.ctmBlob = 0
817 o.hdr.settings.Enabled = false
818 o.setupColor()
819 if o.primary.pipeline != nil || o.ctmBlob != 0 {
820 t.Fatal("SDR output set up a pipeline")
821 }
822 })
823}
824
825// A frame refused because of its pipeline never turns the hardware cursor
826// off: only a refusal the frame has with Bypass does.
827func TestColorRefusalDoesNotDisableTheCursor(t *testing.T) {
828 // The driver refuses the pipeline, and also the cursor whenever it is
829 // shown with the pipeline.
830 rule := func(r *atomicReq, flags uint32) error {
831 if v, ok := r.value(tPrimary, pColorPipe); ok && v != 0 && flags&atomicTestOnly == 0 {
832 return unix.EINVAL
833 }
834 return nil
835 }
836 o, k, _ := colorOutput(t, false, rule)
837 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
838 o.cursor.image = true
839 o.cursor.Move(5, 5)
840 c := sdrContent(o)
841 ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ)
842 if ok || err != nil || o.reason != "color_refused" {
843 t.Fatalf("ok=%v err=%v reason %q", ok, err, o.reason)
844 }
845 if o.cursor.off {
846 t.Fatal("a pipeline refusal turned the hardware cursor off")
847 }
848 if v, known := o.primary.verdict(fourccXRGB, true); !known || v {
849 t.Fatal("refusal with the cursor not cached")
850 }
851 // A cursor the driver refuses on its own, pipeline or not, is still turned off.
852 o2, k2, _ := colorOutput(t, false, func(r *atomicReq, flags uint32) error {
853 if v, ok := r.value(tCursor, pFB); ok && v != 0 {
854 return unix.EINVAL
855 }
856 return nil
857 })
858 k2.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
859 o2.cursor.image = true
860 o2.cursor.Move(5, 5)
861 if ok, err := o2.commitScanoutRect(80, sdrContent(o2), pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ); !ok || err != nil {
862 t.Fatalf("ok=%v err=%v", ok, err)
863 }
864 if !o2.cursor.off {
865 t.Fatal("a cursor refusal did not turn the cursor off")
866 }
867}
868
869// The colour test differs from the failed commit in colour alone. A frame
870// refused for its content hint is not blamed on the pipeline; the hint is
871// disabled as before. A frame refused for its pipeline, with the hint wanted,
872// is blamed on the pipeline and the hint stays.
873func TestColorRefusalBlamesOnlyColour(t *testing.T) {
874 const pContent = 70
875 setup := func(t *testing.T, rule func(*atomicReq, uint32) error) (*Output, ports.SurfaceContent) {
876 o, k, _ := colorOutput(t, false, rule)
877 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(80), nil).Maybe()
878 o.contentProp, o.contentValues = pContent, [5]uint64{0, 1, 2, 3, 4}
879 o.contentWanted = 4 // wanted Game, the applied value is still the first
880 return o, sdrContent(o)
881 }
882 hasHint := func(r *atomicReq) bool { v, ok := r.value(tConn, pContent); return ok && v == 4 }
883 usesPipeline := func(r *atomicReq) bool { v, ok := r.value(tPrimary, pColorPipe); return ok && v != 0 }
884 t.Run("the hint is refused, the pipeline is accepted", func(t *testing.T) {
885 o, c := setup(t, func(r *atomicReq, flags uint32) error {
886 if hasHint(r) {
887 return unix.EINVAL
888 }
889 return nil
890 })
891 ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ)
892 if !ok || err != nil {
893 t.Fatalf("ok=%v err=%v reason %q", ok, err, o.reason)
894 }
895 if o.contentProp != 0 {
896 t.Fatal("the content hint was not disabled")
897 }
898 if _, known := o.primary.verdict(fourccXRGB, false); known || o.reason == "color_refused" {
899 t.Fatal("the pipeline was blamed for the hint")
900 }
901 if !o.primary.colorKnown || o.primary.colorApplied != colorSDRToPQ {
902 t.Fatal("the retried frame did not apply the pipeline")
903 }
904 })
905 t.Run("the pipeline is refused, the hint is accepted", func(t *testing.T) {
906 o, c := setup(t, func(r *atomicReq, flags uint32) error {
907 if usesPipeline(r) && flags&atomicTestOnly == 0 {
908 return unix.EINVAL
909 }
910 return nil
911 })
912 ok, err := o.commitScanoutRect(80, c, pendingFrame{}, fullPlaneRect(200, 100), colorSDRToPQ)
913 if ok || err != nil || o.reason != "color_refused" {
914 t.Fatalf("ok=%v err=%v reason %q", ok, err, o.reason)
915 }
916 if v, known := o.primary.verdict(fourccXRGB, false); !known || v {
917 t.Fatal("the pipeline refusal was not cached")
918 }
919 if o.contentProp != pContent {
920 t.Fatal("the content hint was disabled for the pipeline's refusal")
921 }
922 })
923}