1//go:build colorcheck
2
3// On-demand colour check: a real Wayland client (examples/testpattern) shows
4// known colours on a headless NeferWL and the screenshots are compared with
5// what those colours must become. It needs a GPU and, for the PQ client, /dev/udmabuf,
6// so it is excluded from `go test ./...`, `make check` and CI. Run it with
7// `make color-check`.
8package app
9
10import (
11 "context"
12 "fmt"
13 "image"
14 "image/png"
15 "math"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "strconv"
20 "strings"
21 "testing"
22 "time"
23
24 "github.com/bnema/neferwl/examples/testpattern/pattern"
25 "github.com/bnema/neferwl/internal/adapters/config"
26)
27
28const (
29 colorW, colorH = 640, 360
30 // sampleRadius gives the 5×5 box sampled at each patch centre.
31 sampleRadius = 2
32 // cursorRadius is the half size of the box around the output centre that
33 // headless screenshots draw the cursor into.
34 cursorRadius = 48
35 // pqTolerance is the per-channel PQ signal tolerance, the one of
36 // TestHDRWindowedComposition; it covers the fp16 round trip.
37 pqTolerance = 0.012
38 // nitTolerance is the alternative HDR tolerance, in absolute luminance,
39 // for the channels expected below nitFloor.
40 //
41 // Near black PQ is very steep, and the compositor's fp16 linear
42 // intermediate leaves a small residue there: with BT.2020 primaries
43 // outside BT.709 the BT.709 intermediate has a slightly negative or
44 // rounded component, and the BT.2020 green primary patch measures
45 // R=0.0313 PQ instead of 0 on an RX 9070 XT. That is about 0.02 nit,
46 // invisible and not a colour bug, so a channel expected below nitFloor
47 // also passes when the decoded luminance differs by no more than this.
48 nitTolerance = 0.03
49 // nitFloor is the expected luminance (nits) under which the luminance
50 // fallback applies; brighter channels must meet pqTolerance.
51 nitFloor = 0.05
52 // pqNamedTF is the wp_color_manager_v1 transfer function of an HDR output
53 // (st2084_pq).
54 pqNamedTF = 11
55 // colorTimeout bounds each wait for the client and for a matching frame.
56 colorTimeout = 5 * time.Second
57)
58
59// TestColorCheckSDR: an sRGB wl_shm client on an SDR output is read back
60// byte for byte (±1 per channel).
61func TestColorCheckSDR(t *testing.T) {
62 dir := t.TempDir()
63 socket := startColorCompositor(t, Options{ScreenshotDir: dir})
64 client := startPatternClient(t, socket, "sdr")
65 patches := pattern.Patches("sdr", colorW, colorH)
66 assertNoCursorOverlap(t, patches)
67
68 pollImage(t, filepath.Join(dir, "latest.png"), client.ready, "", func(img image.Image) []string {
69 return comparePatches(img, patches, func(p pattern.Patch, got [3]float64) string {
70 for c := range 3 {
71 if math.Abs(got[c]*255-float64(p.SRGB[c])) > 1 {
72 return fmt.Sprintf("got %v, want sRGB %v ±1", bytes3(got), p.SRGB)
73 }
74 }
75 return ""
76 })
77 })
78}
79
80// TestColorCheckHDR checks a virtual HDR output through the raw PQ dump
81// (latest-pq.png), with an sRGB client and with a PQ client.
82//
83// Virtual HDR must be on: output-tf other than 11 means it fell back to SDR,
84// which is a failure, not a skip. Since headless exports its targets without
85// a display modifier list, it works on any GPU that exports XR30, and the dev
86// box does. The target requires a GPU: a Vulkan error fails the test. Only a
87// missing /dev/udmabuf or linear XR30 import (PQ client) skips.
88//
89// Expected path: PQ client content is decoded to fp16 linear BT.709 (/203,
90// compose_hdr.frag), then re-encoded (×203, BT.709→BT.2020, PQ, hdr.frag);
91// sRGB content maps to PQ(M·linear·203). There is no dithering, and the
92// cursor and the capture indicator are not in the exported target.
93func TestColorCheckHDR(t *testing.T) {
94 dir := t.TempDir()
95 socket := startColorCompositor(t, Options{ScreenshotDir: dir, HeadlessHDR: true, ScreenshotRaw: true})
96 pq := filepath.Join(dir, "latest-pq.png")
97
98 t.Run("sdr-client", func(t *testing.T) {
99 client := startPatternClient(t, socket, "sdr")
100 patches := pattern.Patches("sdr", colorW, colorH)
101 assertNoCursorOverlap(t, patches)
102 pollImage(t, pq, client.ready, "no raw PQ dump (virtual HDR off?)", func(img image.Image) []string {
103 return comparePatches(img, patches, func(p pattern.Patch, got [3]float64) string {
104 var want [3]float64
105 var lin [3]float64
106 for c := range 3 {
107 lin[c] = pattern.SRGBToLinear(float64(p.SRGB[c]) / 255)
108 }
109 for r := range 3 {
110 for c := range 3 {
111 want[r] += pattern.BT709ToBT2020[r][c] * lin[c]
112 }
113 want[r] = pattern.PQEncode(want[r] * pattern.ReferenceWhite)
114 }
115 return comparePQ(got, want)
116 })
117 })
118 })
119
120 t.Run("pq-client", func(t *testing.T) {
121 if _, err := os.Stat("/dev/udmabuf"); err != nil {
122 t.Skipf("no /dev/udmabuf: %v", err)
123 }
124 client := startPatternClient(t, socket, "hdr")
125 if client.tf != pqNamedTF {
126 t.Fatalf("output-tf %d, want %d: virtual HDR fell back to SDR", client.tf, pqNamedTF)
127 }
128 patches := pattern.Patches("hdr", colorW, colorH)
129 assertNoCursorOverlap(t, patches)
130 pollImage(t, pq, client.ready, "no raw PQ dump (virtual HDR off?)", func(img image.Image) []string {
131 return comparePatches(img, patches, func(p pattern.Patch, got [3]float64) string {
132 var want [3]float64
133 for c := range 3 {
134 want[c] = pattern.PQEncode(p.Nits[c])
135 }
136 return comparePQ(got, want)
137 })
138 })
139 // Tone mapping itself is unit-tested: only check the 8-bit
140 // screenshot is not black at the 1000-nit patch.
141 pollImage(t, filepath.Join(dir, "latest.png"), client.ready, "", func(img image.Image) []string {
142 c := patches[5].Centre()
143 r, g, b, _ := img.At(c.X, c.Y).RGBA()
144 if r|g|b == 0 {
145 return []string{fmt.Sprintf("latest.png is black at the 1000-nit patch (%d,%d)", c.X, c.Y)}
146 }
147 return nil
148 })
149 })
150}
151
152// comparePQ compares one patch, per channel: it passes within pqTolerance of
153// signal or, for a channel expected below nitFloor, within nitTolerance of
154// luminance (see nitTolerance).
155func comparePQ(got, want [3]float64) string {
156 for c := range 3 {
157 if math.Abs(got[c]-want[c]) <= pqTolerance {
158 continue
159 }
160 if wantNits := pattern.PQDecode(want[c]); wantNits < nitFloor && math.Abs(pattern.PQDecode(got[c])-wantNits) <= nitTolerance {
161 continue
162 }
163 return fmt.Sprintf("got PQ %.4f %.4f %.4f (%.3f %.3f %.3f nit), want %.4f %.4f %.4f (%.3f %.3f %.3f nit); ±%v PQ, or ±%v nit under %v nit",
164 got[0], got[1], got[2], pattern.PQDecode(got[0]), pattern.PQDecode(got[1]), pattern.PQDecode(got[2]),
165 want[0], want[1], want[2], pattern.PQDecode(want[0]), pattern.PQDecode(want[1]), pattern.PQDecode(want[2]),
166 pqTolerance, nitTolerance, nitFloor)
167 }
168 return ""
169}
170
171func bytes3(v [3]float64) [3]int {
172 return [3]int{int(math.Round(v[0] * 255)), int(math.Round(v[1] * 255)), int(math.Round(v[2] * 255))}
173}
174
175// comparePatches samples the 5×5 box at the centre of every patch; check gets
176// each pixel as unit-range channels and returns a mismatch description or "".
177// It returns one line per mismatching patch, or one for a wrong image size.
178func comparePatches(img image.Image, patches []pattern.Patch, check func(p pattern.Patch, got [3]float64) string) []string {
179 if b := img.Bounds(); b.Dx() != colorW || b.Dy() != colorH {
180 return []string{fmt.Sprintf("image is %v, want %dx%d", b.Size(), colorW, colorH)}
181 }
182 var bad []string
183 for i, p := range patches {
184 c := p.Centre()
185 box:
186 for y := c.Y - sampleRadius; y <= c.Y+sampleRadius; y++ {
187 for x := c.X - sampleRadius; x <= c.X+sampleRadius; x++ {
188 r, g, b, _ := img.At(x, y).RGBA()
189 got := [3]float64{float64(r) / 65535, float64(g) / 65535, float64(b) / 65535}
190 if msg := check(p, got); msg != "" {
191 bad = append(bad, fmt.Sprintf("patch %d at (%d,%d): %s", i, x, y, msg))
192 break box
193 }
194 }
195 }
196 }
197 return bad
198}
199
200// assertNoCursorOverlap checks no sampling box touches the cursor, which
201// headless screenshots draw at the output centre.
202func assertNoCursorOverlap(t *testing.T, patches []pattern.Patch) {
203 t.Helper()
204 cursor := image.Rect(colorW/2-cursorRadius, colorH/2-cursorRadius, colorW/2+cursorRadius, colorH/2+cursorRadius)
205 for i, p := range patches {
206 c := p.Centre()
207 box := image.Rect(c.X-sampleRadius, c.Y-sampleRadius, c.X+sampleRadius+1, c.Y+sampleRadius+1)
208 if box.Overlaps(cursor) {
209 t.Fatalf("patch %d sampling box %v intersects the cursor box %v", i, box, cursor)
210 }
211 }
212}
213
214// pollImage decodes path every 50 ms, up to colorTimeout, until check returns
215// no mismatch. Only files modified after notBefore count, so a frame of a
216// previous client is never judged (files are replaced atomically). On timeout
217// it reports every mismatch of the last attempt; missing, when not empty,
218// replaces the error of a file that does not exist.
219func pollImage(t *testing.T, path string, notBefore time.Time, missing string, check func(image.Image) []string) {
220 t.Helper()
221 var last []string
222 for deadline := time.Now().Add(colorTimeout); ; time.Sleep(50 * time.Millisecond) {
223 last = nil
224 if fi, err := os.Stat(path); err != nil {
225 last = []string{err.Error()}
226 if missing != "" && os.IsNotExist(err) {
227 last = []string{missing}
228 }
229 } else if !fi.ModTime().After(notBefore) {
230 last = []string{fmt.Sprintf("%s not rewritten since the client started", filepath.Base(path))}
231 } else if f, err := os.Open(path); err != nil {
232 last = []string{err.Error()}
233 } else {
234 img, err := png.Decode(f)
235 _ = f.Close()
236 if err != nil {
237 last = []string{err.Error()}
238 } else if last = check(img); len(last) == 0 {
239 return
240 }
241 }
242 if time.Now().After(deadline) {
243 t.Fatalf("%s does not match after %v:\n %s", filepath.Base(path), colorTimeout, strings.Join(last, "\n "))
244 }
245 }
246}
247
248// startColorCompositor runs a headless NeferWL with one 640×360 output in
249// temporary runtime and state directories and returns its socket path. A Run
250// error, Vulkan included, fails the test: the target requires a GPU.
251func startColorCompositor(t *testing.T, opts Options) string {
252 t.Helper()
253 runtime := t.TempDir()
254 t.Setenv("XDG_RUNTIME_DIR", runtime)
255 t.Setenv("XDG_STATE_HOME", t.TempDir())
256 opts.Backend = "headless"
257 opts.NoXwayland = true
258 opts.NoTerminal = true
259 opts.Config = config.Defaults()
260 opts.Sizes = [][2]int{{colorW, colorH}}
261
262 ctx, cancel := context.WithCancel(context.Background())
263 done := make(chan error, 1)
264 go func() { done <- Run(ctx, opts) }()
265 stopped := false // Run's result was already received
266 t.Cleanup(func() {
267 cancel()
268 if stopped {
269 return
270 }
271 select {
272 case err := <-done:
273 if err != nil {
274 t.Errorf("Run: %v", err)
275 }
276 case <-time.After(5 * time.Second):
277 t.Error("Run did not stop")
278 }
279 })
280 socket := filepath.Join(runtime, "wayland-1")
281 for deadline := time.Now().Add(colorTimeout); ; time.Sleep(20 * time.Millisecond) {
282 if _, err := os.Stat(socket); err == nil {
283 return socket
284 }
285 select {
286 case err := <-done:
287 stopped = true
288 t.Fatalf("neferwl exited: %v", err)
289 default:
290 }
291 if time.Now().After(deadline) {
292 t.Fatal("no wayland socket")
293 }
294 }
295}
296
297// patternClient is a running examples/testpattern.
298type patternClient struct {
299 ready time.Time // just before the client was started: older files are not judged
300 tf int // "output-tf" of the compositor's output (hdr mode)
301}
302
303// startPatternClient builds and starts examples/testpattern with -fullscreen
304// and returns once it printed "ready 640 360". It skips the test when the
305// client reports that linear XR30 is unsupported, and stops the client when
306// the test (or subtest) ends.
307func startPatternClient(t *testing.T, socket, mode string) *patternClient {
308 t.Helper()
309 bin := filepath.Join(t.TempDir(), "testpattern")
310 build := exec.Command("go", "build", "-o", bin, "../../examples/testpattern")
311 build.Env = append(os.Environ(), "CGO_ENABLED=0")
312 if out, err := build.CombinedOutput(); err != nil {
313 t.Fatalf("build testpattern: %v: %s", err, out)
314 }
315 cmd := exec.Command(bin, "-mode", mode, "-fullscreen")
316 cmd.Env = append(os.Environ(), "WAYLAND_DISPLAY="+filepath.Base(socket))
317 stderr := &lockedBuffer{}
318 cmd.Stderr = stderr
319 // Taken before the start so that no frame of this client predates it.
320 // Frames of its windowed state (before fullscreen) are newer, so they
321 // are judged: they just fail the check and are retried.
322 started := time.Now()
323 if err := cmd.Start(); err != nil {
324 t.Fatal(err)
325 }
326 waited := make(chan error, 1)
327 go func() { waited <- cmd.Wait() }()
328 exited := false
329 t.Cleanup(func() {
330 if !exited {
331 _ = cmd.Process.Kill()
332 <-waited
333 }
334 })
335
336 for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(10 * time.Millisecond) { // includes the 1.2 s fullscreen delay
337 log := stderr.String()
338 if strings.Contains(log, fmt.Sprintf("ready %d %d\n", colorW, colorH)) {
339 c := &patternClient{ready: started}
340 for _, l := range strings.Split(log, "\n") {
341 if v, ok := strings.CutPrefix(l, "output-tf "); ok {
342 tf, err := strconv.Atoi(v)
343 if err != nil {
344 t.Fatalf("bad %q", l)
345 }
346 c.tf = tf
347 }
348 }
349 return c
350 }
351 select {
352 case err := <-waited:
353 exited = true
354 log = stderr.String()
355 if strings.Contains(log, "linear XR30 unsupported") {
356 t.Skipf("testpattern: %s", strings.TrimSpace(log))
357 }
358 t.Fatalf("testpattern exited before ready (%v): %s", err, strings.TrimSpace(log))
359 default:
360 }
361 if time.Now().After(deadline) {
362 t.Fatalf("testpattern not ready: %s", strings.TrimSpace(log))
363 }
364 }
365}