detached 014854e

feat(headless): on-demand SDR/HDR colour check with a test-pattern client (#126)

* fix(headless): enable virtual HDR with a real GPU

ExportTargets refused an empty display modifier list for HDR, so a headless output on a real GPU always fell back to SDR. Virtual outputs now say so with SetVirtualOutput, set by the headless renderer factory.

* feat(headless): dump the raw PQ codes of a virtual HDR output

Add ports.Renderer.HDRPixels, which reads the exported XRGB2101010 target back as 16-bit codes, and the test-only --screenshot-raw flag, which writes them to latest-pq.png next to the screenshots. Readback is enabled on headless renderers only.

* feat(examples): add a colour test-pattern client

* test(app): add an on-demand colour check

Add TestColorCheckSDR and TestColorCheckHDR, built with the colorcheck tag and run by make color-check. They start examples/testpattern on a headless NeferWL and compare the screenshots, or the raw PQ dump of a virtual HDR output, with the expected colours. Add pattern.PQDecode for the near-black luminance tolerance, and document the check in docs/headless.md.
commit
014854eb7cb54633616dc079038a185c6b9b9986
author
bnema · · 2026-10-04 04:33 UTC
committer
GitHub · · 2026-10-04 04:33 UTC
parent
2e21488
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
23 files · +1696 -57
M Makefile +6 -1
@@ -1,4 +1,4 @@
1-.PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch check perf-check bin tty logs pkg install
1+.PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch check perf-check color-check bin tty logs pkg install
22
33 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
44 TTY_TIMEOUT ?= 0
@@ -63,6 +63,11 @@ perf-check:
6363 CGO_ENABLED=0 go test ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
6464 check: vet test arch fakes-check log-check perf-check
6565
66+# On-demand SDR/HDR colour check with examples/testpattern on a headless
67+# NeferWL. Needs a GPU and /dev/udmabuf; not part of check.
68+color-check:
69+ CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v
70+
6671 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
6772 # come from the module proxy in prepare(); the build itself runs offline.
6873 # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0.
@@ -1,4 +1,4 @@
1-.PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch check perf-check bin tty logs pkg install1+.PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch check perf-check color-check bin tty logs pkg install
2 2
3 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.3 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
4 TTY_TIMEOUT ?= 04 TTY_TIMEOUT ?= 0
@@ -63,6 +63,11 @@ perf-check:
63 CGO_ENABLED=0 go test ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=163 CGO_ENABLED=0 go test ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1
64 check: vet test arch fakes-check log-check perf-check64 check: vet test arch fakes-check log-check perf-check
65 65
66+# On-demand SDR/HDR colour check with examples/testpattern on a headless
67+# NeferWL. Needs a GPU and /dev/udmabuf; not part of check.
68+color-check:
69+ CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v
70+
66 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules71 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
67 # come from the module proxy in prepare(); the build itself runs offline.72 # come from the module proxy in prepare(); the build itself runs offline.
68 # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0.73 # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0.
M cmd/neferwl/main.go +7 -1
@@ -162,6 +162,7 @@ func run() error {
162162 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
163163 noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")
164164 headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
165+ screenshotRaw := flags.Bool("screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output")
165166 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
166167 debugFlag := flags.String("debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)")
167168 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
@@ -186,6 +187,11 @@ func run() error {
186187 fmt.Fprintln(os.Stderr, err)
187188 return err
188189 }
190+ if *screenshotRaw && (*screenshot == "" || !*headlessHDR) {
191+ err := usageError{fmt.Errorf("--screenshot-raw requires --screenshot and --headless-hdr")}
192+ fmt.Fprintln(os.Stderr, err)
193+ return err
194+ }
189195 var sizes [][2]int
190196 for _, s := range strings.Split(*size, ",") {
191197 w, h, _, sizeErr := config.ParseMode(strings.TrimSpace(s))
@@ -275,7 +281,7 @@ func run() error {
275281 for _, w := range warnings {
276282 configLog.Warn().Int("line", w.Line).Msg(w.Msg)
277283 }
278- err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, Sizes: sizes, Script: script})
284+ err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, ScreenshotRaw: *screenshotRaw, Sizes: sizes, Script: script})
279285 // SIGINT and SIGTERM cancel the context and are clean exits.
280286 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
281287 err = nil
@@ -162,6 +162,7 @@ func run() error {
162 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")162 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
163 noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")163 noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")
164 headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")164 headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
165+ screenshotRaw := flags.Bool("screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output")
165 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")166 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
166 debugFlag := flags.String("debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)")167 debugFlag := flags.String("debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)")
167 configFlag := flags.String("config", "", "config path (empty uses XDG default)")168 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
@@ -186,6 +187,11 @@ func run() error {
186 fmt.Fprintln(os.Stderr, err)187 fmt.Fprintln(os.Stderr, err)
187 return err188 return err
188 }189 }
190+ if *screenshotRaw && (*screenshot == "" || !*headlessHDR) {
191+ err := usageError{fmt.Errorf("--screenshot-raw requires --screenshot and --headless-hdr")}
192+ fmt.Fprintln(os.Stderr, err)
193+ return err
194+ }
189 var sizes [][2]int195 var sizes [][2]int
190 for _, s := range strings.Split(*size, ",") {196 for _, s := range strings.Split(*size, ",") {
191 w, h, _, sizeErr := config.ParseMode(strings.TrimSpace(s))197 w, h, _, sizeErr := config.ParseMode(strings.TrimSpace(s))
@@ -275,7 +281,7 @@ func run() error {
275 for _, w := range warnings {281 for _, w := range warnings {
276 configLog.Warn().Int("line", w.Line).Msg(w.Msg)282 configLog.Warn().Int("line", w.Line).Msg(w.Msg)
277 }283 }
278- err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, Sizes: sizes, Script: script})284+ err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, ScreenshotRaw: *screenshotRaw, Sizes: sizes, Script: script})
279 // SIGINT and SIGTERM cancel the context and are clean exits.285 // SIGINT and SIGTERM cancel the context and are clean exits.
280 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {286 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
281 err = nil287 err = nil
M cmd/neferwl/main_test.go +2 -0
@@ -23,6 +23,8 @@ func TestExitCodes(t *testing.T) {
2323 {[]string{"neferwl", "--help"}, 0},
2424 {[]string{"neferwl", "--backend=invalid"}, 2},
2525 {[]string{"neferwl", "--backend=drm", "--screenshot=/tmp/shots"}, 2},
26+ {[]string{"neferwl", "--backend=headless", "--screenshot=/tmp/shots", "--screenshot-raw"}, 2},
27+ {[]string{"neferwl", "--backend=headless", "--headless-hdr", "--screenshot-raw"}, 2},
2628 {[]string{"neferwl", "--timeout=-1s"}, 2},
2729 {[]string{"neferwl", "extra"}, 2},
2830 {[]string{"neferwl", "--config=/nonexistent/neferwl.conf"}, 1},
@@ -23,6 +23,8 @@ func TestExitCodes(t *testing.T) {
23 {[]string{"neferwl", "--help"}, 0},23 {[]string{"neferwl", "--help"}, 0},
24 {[]string{"neferwl", "--backend=invalid"}, 2},24 {[]string{"neferwl", "--backend=invalid"}, 2},
25 {[]string{"neferwl", "--backend=drm", "--screenshot=/tmp/shots"}, 2},25 {[]string{"neferwl", "--backend=drm", "--screenshot=/tmp/shots"}, 2},
26+ {[]string{"neferwl", "--backend=headless", "--screenshot=/tmp/shots", "--screenshot-raw"}, 2},
27+ {[]string{"neferwl", "--backend=headless", "--headless-hdr", "--screenshot-raw"}, 2},
26 {[]string{"neferwl", "--timeout=-1s"}, 2},28 {[]string{"neferwl", "--timeout=-1s"}, 2},
27 {[]string{"neferwl", "extra"}, 2},29 {[]string{"neferwl", "extra"}, 2},
28 {[]string{"neferwl", "--config=/nonexistent/neferwl.conf"}, 1},30 {[]string{"neferwl", "--config=/nonexistent/neferwl.conf"}, 1},
M docs/headless.md +11 -0
@@ -38,3 +38,14 @@ WAYLAND_DISPLAY=<name from the log> go run ./examples/testime -preedit nihon -co
3838 `-count <n>` exits after n commits, and `-delay` sets the time between preedit and commit.
3939
4040 The headless backend does not draw the cursor on screen. After a layout change, pointer focus updates on the next move.
41+
42+## Colour check
43+
44+`make color-check` checks that known colours come out right. It needs a GPU and `/dev/udmabuf`, and is not part of `make check` or CI (build tag `colorcheck`, test in `internal/app/colorcheck_test.go`). It runs a headless NeferWL with one 640×360 output, starts `examples/testpattern -fullscreen` as a real client, and compares 5×5 boxes at the centre of each patch of the pattern:
45+
46+- `TestColorCheckSDR`: an sRGB `wl_shm` client on an SDR output; the bytes of `latest.png` must equal the sRGB values (±1 per channel).
47+- `TestColorCheckHDR`: a virtual HDR output (`--headless-hdr`) read back through the raw PQ codes in `latest-pq.png`. An sRGB client must give `PQ(BT.709→BT.2020 · linear · 203 nits)`, and a PQ client (`-mode hdr`: BT.2020, `st2084_pq`, XR30 dmabuf) its own PQ codes. A channel passes within ±0.012 PQ, or, for a channel expected below 0.05 nit, within 0.03 nit of luminance, which absorbs the fp16 residue near black where PQ is very steep (about 0.02 nit measured on a zero channel of a BT.2020 primary on an RX 9070 XT). If the output does not report PQ (virtual HDR fell back to SDR), the test fails.
48+
49+`--screenshot-raw` is the flag behind it: with `--screenshot` and `--headless-hdr` it also writes `latest-pq.png` next to `latest.png`, a 16-bit PNG holding the 10-bit PQ codes of the exported HDR target (each code scaled to 16 bits, no cursor, no tone mapping). It is a test flag and follows the same session-protection rules as the other screenshot files. It needs an HDR modifier that supports transfer-src (test-only; the DRM path never asks for it).
50+
51+The check cannot see hardware scanout or the KMS plane colour pipeline. On a real HDR screen, run `WAYLAND_DISPLAY=<name> go run ./examples/testpattern -mode hdr -fullscreen` (or `-mode sdr`) and compare the patches by eye. The client asks for fullscreen 1.2 s after its first commit, because the compositor ignores earlier requests.
@@ -38,3 +38,14 @@ WAYLAND_DISPLAY=<name from the log> go run ./examples/testime -preedit nihon -co
38 `-count <n>` exits after n commits, and `-delay` sets the time between preedit and commit.38 `-count <n>` exits after n commits, and `-delay` sets the time between preedit and commit.
39 39
40 The headless backend does not draw the cursor on screen. After a layout change, pointer focus updates on the next move.40 The headless backend does not draw the cursor on screen. After a layout change, pointer focus updates on the next move.
41+
42+## Colour check
43+
44+`make color-check` checks that known colours come out right. It needs a GPU and `/dev/udmabuf`, and is not part of `make check` or CI (build tag `colorcheck`, test in `internal/app/colorcheck_test.go`). It runs a headless NeferWL with one 640×360 output, starts `examples/testpattern -fullscreen` as a real client, and compares 5×5 boxes at the centre of each patch of the pattern:
45+
46+- `TestColorCheckSDR`: an sRGB `wl_shm` client on an SDR output; the bytes of `latest.png` must equal the sRGB values (±1 per channel).
47+- `TestColorCheckHDR`: a virtual HDR output (`--headless-hdr`) read back through the raw PQ codes in `latest-pq.png`. An sRGB client must give `PQ(BT.709→BT.2020 · linear · 203 nits)`, and a PQ client (`-mode hdr`: BT.2020, `st2084_pq`, XR30 dmabuf) its own PQ codes. A channel passes within ±0.012 PQ, or, for a channel expected below 0.05 nit, within 0.03 nit of luminance, which absorbs the fp16 residue near black where PQ is very steep (about 0.02 nit measured on a zero channel of a BT.2020 primary on an RX 9070 XT). If the output does not report PQ (virtual HDR fell back to SDR), the test fails.
48+
49+`--screenshot-raw` is the flag behind it: with `--screenshot` and `--headless-hdr` it also writes `latest-pq.png` next to `latest.png`, a 16-bit PNG holding the 10-bit PQ codes of the exported HDR target (each code scaled to 16 bits, no cursor, no tone mapping). It is a test flag and follows the same session-protection rules as the other screenshot files. It needs an HDR modifier that supports transfer-src (test-only; the DRM path never asks for it).
50+
51+The check cannot see hardware scanout or the KMS plane colour pipeline. On a real HDR screen, run `WAYLAND_DISPLAY=<name> go run ./examples/testpattern -mode hdr -fullscreen` (or `-mode sdr`) and compare the patches by eye. The client asks for fullscreen 1.2 s after its first commit, because the compositor ignores earlier requests.
A examples/testpattern/main.go +432 -0
@@ -0,0 +1,432 @@
1+// Command testpattern is a minimal Wayland client that shows a grid of known
2+// colours, to check by eye or by screenshot that NeferWL renders them
3+// correctly:
4+//
5+// WAYLAND_DISPLAY=wayland-1 go run ./examples/testpattern -mode hdr -fullscreen
6+//
7+// Modes:
8+//
9+// sdr XRGB8888 wl_shm buffer of sRGB patches, no colour management
10+// hdr XR30 linear dmabuf of BT.2020 PQ patches, declared with
11+// wp_color_manager_v1 (st2084_pq, bt2020); needs /dev/udmabuf
12+//
13+// The patches are defined in the pattern sub-package. The client prints
14+// "ready <w> <h>" on stderr once the window is mapped and drawn at its final
15+// size, and, in hdr mode, "output-tf <n>" first: the named transfer function
16+// of the compositor's output (11 is PQ, so virtual or real HDR is on; 0 when
17+// the output does not report one).
18+//
19+// With -fullscreen the window asks for fullscreen and "ready" waits for the
20+// compositor to grant it. NeferWL drops set_fullscreen sent before the
21+// surface is mapped and ignores client fullscreen requests during the first
22+// second after the map (core's fullscreenGrace), so the client maps first and
23+// sends the request 1.2 s after its first buffer commit.
24+//
25+// The client runs until the display closes, then exits 0.
26+package main
27+
28+import (
29+ "errors"
30+ "flag"
31+ "fmt"
32+ "io"
33+ "math"
34+ "net"
35+ "os"
36+ "syscall"
37+ "time"
38+
39+ "github.com/bnema/neferwl/examples/testpattern/pattern"
40+ "github.com/bnema/wlturbo"
41+ "github.com/bnema/wlturbo/protocol/colormanagement"
42+ clientcore "github.com/bnema/wlturbo/protocol/core"
43+ "github.com/bnema/wlturbo/protocol/linuxdmabuf"
44+ "github.com/bnema/wlturbo/protocol/xdgshell"
45+ "golang.org/x/sys/unix"
46+)
47+
48+const (
49+ defaultWidth, defaultHeight = 640, 360
50+ // fullscreenDelay outlasts core's 1 s fullscreenGrace.
51+ fullscreenDelay = 1200 * time.Millisecond
52+
53+ shmXRGB8888 = 1
54+ fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24
55+)
56+
57+func main() {
58+ mode := flag.String("mode", "sdr", "patch set: sdr or hdr")
59+ fullscreen := flag.Bool("fullscreen", false, "ask for fullscreen after mapping")
60+ flag.Parse()
61+ if *mode != "sdr" && *mode != "hdr" {
62+ fmt.Fprintf(os.Stderr, "testpattern: unknown mode %q (want sdr or hdr)\n", *mode)
63+ os.Exit(2)
64+ }
65+ if err := run(*mode, *fullscreen); err != nil {
66+ fmt.Fprintln(os.Stderr, "testpattern:", err)
67+ os.Exit(1)
68+ }
69+}
70+
71+// client holds the objects and state of one window. Only the dispatch
72+// goroutine touches the state; the fullscreen timer only sends a request.
73+type client struct {
74+ d *wlturbo.Display
75+ mode string
76+ fullscreen bool
77+
78+ surface *clientcore.Surface
79+ top *xdgshell.XdgToplevel
80+ shm *clientcore.Shm
81+ dmabuf *linuxdmabuf.LinuxDmabuf
82+
83+ // pending toplevel configure, applied by the xdg_surface configure.
84+ pendW, pendH int32
85+ pendFull bool
86+
87+ w, h int
88+ buffer *clientcore.Buffer
89+ ready bool
90+ closed bool
91+ err error
92+ xr30Lin bool // XR30 with modifier 0 advertised
93+ timerSet bool
94+}
95+
96+func run(mode string, fullscreen bool) error {
97+ d, err := wlturbo.Connect("")
98+ if err != nil {
99+ return err
100+ }
101+ defer d.Close()
102+ if err := d.Roundtrip(); err != nil {
103+ return err
104+ }
105+ c := &client{d: d, mode: mode, fullscreen: fullscreen}
106+ bind := func(iface string, version uint32, proxy wlturbo.Proxy) error {
107+ _, err := d.Registry().BindNegotiated(iface, version, proxy)
108+ return err
109+ }
110+ compositor := clientcore.NewCompositor(d.Context())
111+ if err := bind(clientcore.CompositorInterface, 4, compositor); err != nil {
112+ return err
113+ }
114+ wm := xdgshell.NewXdgWmBase(d.Context())
115+ if err := bind(xdgshell.XdgWmBaseInterface, 1, wm); err != nil {
116+ return err
117+ }
118+ wm.OnPing(func(serial uint32) { _ = wm.Pong(serial) })
119+
120+ var colour *colormanagement.WpColorManager
121+ var desc *colormanagement.WpImageDescription
122+ if mode == "sdr" {
123+ c.shm = clientcore.NewShm(d.Context())
124+ if err := bind(clientcore.ShmInterface, 1, c.shm); err != nil {
125+ return err
126+ }
127+ c.shm.OnFormat(func(uint32) {})
128+ } else {
129+ c.dmabuf = linuxdmabuf.NewLinuxDmabuf(d.Context())
130+ if v, err := d.Registry().BindNegotiated(linuxdmabuf.LinuxDmabufInterface, 3, c.dmabuf); err != nil {
131+ return err
132+ } else if v < 3 {
133+ return errors.New("linear XR30 unsupported: zwp_linux_dmabuf_v1 older than version 3")
134+ }
135+ c.dmabuf.OnFormat(func(uint32) {})
136+ c.dmabuf.OnModifier(func(format, hi, lo uint32) {
137+ if format == fourccXR30 && hi == 0 && lo == 0 {
138+ c.xr30Lin = true
139+ }
140+ })
141+ colour = colormanagement.NewWpColorManager(d.Context())
142+ if err := bind(colormanagement.WpColorManagerInterface, 2, colour); err != nil {
143+ return err
144+ }
145+ if err := d.Roundtrip(); err != nil {
146+ return err
147+ }
148+ if !c.xr30Lin {
149+ return errors.New("linear XR30 unsupported")
150+ }
151+ if desc, err = c.describe(colour); err != nil {
152+ return err
153+ }
154+ if err := c.printOutputTF(colour); err != nil {
155+ return err
156+ }
157+ }
158+
159+ if c.surface, err = compositor.CreateSurface(); err != nil {
160+ return err
161+ }
162+ if desc != nil {
163+ cs, err := colour.GetSurface(c.surface)
164+ if err != nil {
165+ return err
166+ }
167+ if err := cs.SetImageDescription(desc, 0); err != nil {
168+ return err
169+ }
170+ }
171+ xs, err := wm.GetXdgSurface(c.surface)
172+ if err != nil {
173+ return err
174+ }
175+ if c.top, err = xs.GetToplevel(); err != nil {
176+ return err
177+ }
178+ _ = c.top.SetTitle("testpattern")
179+ _ = c.top.SetAppId("testpattern")
180+ c.top.OnConfigure(func(w, h int32, states []byte) {
181+ c.pendW, c.pendH, c.pendFull = w, h, hasState(states, xdgshell.STATE_FULLSCREEN)
182+ })
183+ c.top.OnClose(func() { c.closed = true })
184+ xs.OnConfigure(func(serial uint32) {
185+ if err := xs.AckConfigure(serial); err != nil {
186+ c.err = err
187+ return
188+ }
189+ if err := c.configured(); err != nil {
190+ c.err = err
191+ }
192+ })
193+ if err := c.surface.Commit(); err != nil { // initial commit asks for the first configure
194+ return err
195+ }
196+ for !c.closed && c.err == nil {
197+ if err := d.Dispatch(); err != nil {
198+ if isClosed(err) {
199+ return nil
200+ }
201+ return err
202+ }
203+ }
204+ return c.err
205+}
206+
207+// describe creates the PQ/BT.2020 image description and waits until it is
208+// ready: a surface may only use a ready description.
209+func (c *client) describe(colour *colormanagement.WpColorManager) (*colormanagement.WpImageDescription, error) {
210+ creator, err := colour.CreateParametricCreator()
211+ if err != nil {
212+ return nil, err
213+ }
214+ if err := creator.SetTfNamed(colormanagement.TRANSFER_FUNCTION_ST2084_PQ); err != nil {
215+ return nil, err
216+ }
217+ if err := creator.SetPrimariesNamed(colormanagement.PRIMARIES_BT2020); err != nil {
218+ return nil, err
219+ }
220+ desc, err := creator.Create()
221+ if err != nil {
222+ return nil, err
223+ }
224+ ready, failed := false, ""
225+ desc.OnReady2(func(uint32, uint32) { ready = true })
226+ desc.OnFailed(func(cause uint32, msg string) { failed = fmt.Sprintf("cause %d: %s", cause, msg) })
227+ if err := c.until(func() bool { return ready || failed != "" }); err != nil {
228+ return nil, err
229+ }
230+ if failed != "" {
231+ return nil, fmt.Errorf("image description failed: %s", failed)
232+ }
233+ return desc, nil
234+}
235+
236+// printOutputTF reports the named transfer function of the first output on
237+// stderr as "output-tf <n>" (0 when it reports none).
238+func (c *client) printOutputTF(colour *colormanagement.WpColorManager) error {
239+ g, ok := c.d.Registry().FindGlobal(clientcore.OutputInterface)
240+ if !ok {
241+ return errors.New("compositor has no wl_output")
242+ }
243+ output := clientcore.NewOutput(c.d.Context())
244+ if err := c.d.Registry().Bind(g.Name, g.Interface, min(g.Version, 4), output); err != nil {
245+ return err
246+ }
247+ co, err := colour.GetOutput(output)
248+ if err != nil {
249+ return err
250+ }
251+ desc, err := co.GetImageDescription()
252+ if err != nil {
253+ return err
254+ }
255+ ready, failed := false, false
256+ desc.OnReady2(func(uint32, uint32) { ready = true })
257+ desc.OnFailed(func(uint32, string) { failed = true })
258+ if err := c.until(func() bool { return ready || failed }); err != nil {
259+ return err
260+ }
261+ if failed {
262+ return errors.New("output image description failed")
263+ }
264+ info, err := desc.GetInformation()
265+ if err != nil {
266+ return err
267+ }
268+ tf, done := uint32(0), false
269+ info.OnTfNamed(func(v uint32) { tf = v })
270+ info.OnDone(func() { done = true })
271+ if err := c.until(func() bool { return done }); err != nil {
272+ return err
273+ }
274+ fmt.Fprintf(os.Stderr, "output-tf %d\n", tf)
275+ return nil
276+}
277+
278+// until roundtrips until cond holds; setup events arrive within a few.
279+func (c *client) until(cond func() bool) error {
280+ for range 20 {
281+ if cond() {
282+ return nil
283+ }
284+ if err := c.d.Roundtrip(); err != nil {
285+ return err
286+ }
287+ }
288+ if cond() {
289+ return nil
290+ }
291+ return errors.New("compositor did not answer")
292+}
293+
294+// configured applies a toplevel configure after it was acknowledged: it
295+// (re)builds the buffer when the size changed, commits, and reports ready.
296+func (c *client) configured() error {
297+ w, h := int(c.pendW), int(c.pendH)
298+ if w <= 0 || h <= 0 {
299+ w, h = c.w, c.h // the client picks the size
300+ if w == 0 || h == 0 {
301+ w, h = defaultWidth, defaultHeight
302+ }
303+ }
304+ if c.buffer == nil || w != c.w || h != c.h {
305+ buf, err := c.newBuffer(w, h)
306+ if err != nil {
307+ return err
308+ }
309+ old := c.buffer
310+ c.buffer, c.w, c.h = buf, w, h
311+ defer func() {
312+ if old != nil {
313+ _ = old.Destroy()
314+ }
315+ }()
316+ }
317+ if err := c.surface.Attach(c.buffer, 0, 0); err != nil {
318+ return err
319+ }
320+ if err := c.surface.Damage(0, 0, int32(c.w), int32(c.h)); err != nil {
321+ return err
322+ }
323+ if err := c.surface.Commit(); err != nil {
324+ return err
325+ }
326+ if c.fullscreen && !c.timerSet {
327+ c.timerSet = true
328+ top := c.top
329+ time.AfterFunc(fullscreenDelay, func() {
330+ if err := top.SetFullscreen(nil); err != nil {
331+ fmt.Fprintf(os.Stderr, "testpattern: set_fullscreen: %v\n", err)
332+ }
333+ })
334+ }
335+ if !c.ready && (!c.fullscreen || c.pendFull) {
336+ c.ready = true
337+ fmt.Fprintf(os.Stderr, "ready %d %d\n", c.w, c.h)
338+ }
339+ return nil
340+}
341+
342+func (c *client) newBuffer(w, h int) (*clientcore.Buffer, error) {
343+ patches := pattern.Patches(c.mode, w, h)
344+ if c.mode == "sdr" {
345+ return c.shmBuffer(w, h, patches)
346+ }
347+ return c.dmabufBuffer(w, h, patches)
348+}
349+
350+// shmBuffer draws the sRGB patches into XRGB8888 (little-endian B, G, R, X).
351+func (c *client) shmBuffer(w, h int, patches []pattern.Patch) (*clientcore.Buffer, error) {
352+ size := w * h * 4
353+ fd, err := wlturbo.CreateAnonymousFile(int64(size))
354+ if err != nil {
355+ return nil, err
356+ }
357+ data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
358+ if err != nil {
359+ _ = unix.Close(fd)
360+ return nil, err
361+ }
362+ for _, p := range patches {
363+ for y := p.Rect.Min.Y; y < p.Rect.Max.Y; y++ {
364+ for x := p.Rect.Min.X; x < p.Rect.Max.X; x++ {
365+ i := (y*w + x) * 4
366+ data[i], data[i+1], data[i+2], data[i+3] = p.SRGB[2], p.SRGB[1], p.SRGB[0], 0xff
367+ }
368+ }
369+ }
370+ // The compositor maps the file itself; ours is not needed any more.
371+ _ = unix.Munmap(data)
372+ pool, err := c.shm.CreatePool(fd, int32(size)) // closes fd once sent
373+ if err != nil {
374+ _ = unix.Close(fd)
375+ return nil, err
376+ }
377+ buf, err := pool.CreateBuffer(0, int32(w), int32(h), int32(w*4), shmXRGB8888)
378+ _ = pool.Destroy()
379+ return buf, err
380+}
381+
382+// dmabufBuffer draws the PQ patches into a udmabuf as XR30 words
383+// b | g<<10 | r<<20.
384+func (c *client) dmabufBuffer(w, h int, patches []pattern.Patch) (*clientcore.Buffer, error) {
385+ code := func(nits float64) uint32 { return uint32(math.Round(pattern.PQEncode(nits) * 1023)) }
386+ f, err := udmabuf(w*h*4, func(data []byte) {
387+ for _, p := range patches {
388+ word := code(p.Nits[2]) | code(p.Nits[1])<<10 | code(p.Nits[0])<<20
389+ for y := p.Rect.Min.Y; y < p.Rect.Max.Y; y++ {
390+ for x := p.Rect.Min.X; x < p.Rect.Max.X; x++ {
391+ i := (y*w + x) * 4
392+ data[i], data[i+1], data[i+2], data[i+3] = byte(word), byte(word>>8), byte(word>>16), byte(word>>24)
393+ }
394+ }
395+ }
396+ })
397+ if err != nil {
398+ return nil, err
399+ }
400+ defer f.Close()
401+ params, err := c.dmabuf.CreateParams()
402+ if err != nil {
403+ return nil, err
404+ }
405+ // Add sends a duplicate: the send closes the descriptor it is given.
406+ fd, err := unix.Dup(int(f.Fd()))
407+ if err != nil {
408+ return nil, err
409+ }
410+ if err := params.Add(fd, 0, 0, uint32(w*4), 0, 0); err != nil {
411+ _ = unix.Close(fd)
412+ return nil, err
413+ }
414+ buf, err := params.CreateImmed(int32(w), int32(h), fourccXR30, 0)
415+ _ = params.Destroy()
416+ return buf, err
417+}
418+
419+func hasState(states []byte, want uint32) bool {
420+ for i := 0; i+4 <= len(states); i += 4 {
421+ if uint32(states[i])|uint32(states[i+1])<<8|uint32(states[i+2])<<16|uint32(states[i+3])<<24 == want {
422+ return true
423+ }
424+ }
425+ return false
426+}
427+
428+// isClosed reports whether err is the compositor going away.
429+func isClosed(err error) bool {
430+ return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, os.ErrClosed) ||
431+ errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) || errors.Is(err, net.ErrClosed)
432+}
@@ -0,0 +1,432 @@
1+// Command testpattern is a minimal Wayland client that shows a grid of known
2+// colours, to check by eye or by screenshot that NeferWL renders them
3+// correctly:
4+//
5+// WAYLAND_DISPLAY=wayland-1 go run ./examples/testpattern -mode hdr -fullscreen
6+//
7+// Modes:
8+//
9+// sdr XRGB8888 wl_shm buffer of sRGB patches, no colour management
10+// hdr XR30 linear dmabuf of BT.2020 PQ patches, declared with
11+// wp_color_manager_v1 (st2084_pq, bt2020); needs /dev/udmabuf
12+//
13+// The patches are defined in the pattern sub-package. The client prints
14+// "ready <w> <h>" on stderr once the window is mapped and drawn at its final
15+// size, and, in hdr mode, "output-tf <n>" first: the named transfer function
16+// of the compositor's output (11 is PQ, so virtual or real HDR is on; 0 when
17+// the output does not report one).
18+//
19+// With -fullscreen the window asks for fullscreen and "ready" waits for the
20+// compositor to grant it. NeferWL drops set_fullscreen sent before the
21+// surface is mapped and ignores client fullscreen requests during the first
22+// second after the map (core's fullscreenGrace), so the client maps first and
23+// sends the request 1.2 s after its first buffer commit.
24+//
25+// The client runs until the display closes, then exits 0.
26+package main
27+
28+import (
29+ "errors"
30+ "flag"
31+ "fmt"
32+ "io"
33+ "math"
34+ "net"
35+ "os"
36+ "syscall"
37+ "time"
38+
39+ "github.com/bnema/neferwl/examples/testpattern/pattern"
40+ "github.com/bnema/wlturbo"
41+ "github.com/bnema/wlturbo/protocol/colormanagement"
42+ clientcore "github.com/bnema/wlturbo/protocol/core"
43+ "github.com/bnema/wlturbo/protocol/linuxdmabuf"
44+ "github.com/bnema/wlturbo/protocol/xdgshell"
45+ "golang.org/x/sys/unix"
46+)
47+
48+const (
49+ defaultWidth, defaultHeight = 640, 360
50+ // fullscreenDelay outlasts core's 1 s fullscreenGrace.
51+ fullscreenDelay = 1200 * time.Millisecond
52+
53+ shmXRGB8888 = 1
54+ fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24
55+)
56+
57+func main() {
58+ mode := flag.String("mode", "sdr", "patch set: sdr or hdr")
59+ fullscreen := flag.Bool("fullscreen", false, "ask for fullscreen after mapping")
60+ flag.Parse()
61+ if *mode != "sdr" && *mode != "hdr" {
62+ fmt.Fprintf(os.Stderr, "testpattern: unknown mode %q (want sdr or hdr)\n", *mode)
63+ os.Exit(2)
64+ }
65+ if err := run(*mode, *fullscreen); err != nil {
66+ fmt.Fprintln(os.Stderr, "testpattern:", err)
67+ os.Exit(1)
68+ }
69+}
70+
71+// client holds the objects and state of one window. Only the dispatch
72+// goroutine touches the state; the fullscreen timer only sends a request.
73+type client struct {
74+ d *wlturbo.Display
75+ mode string
76+ fullscreen bool
77+
78+ surface *clientcore.Surface
79+ top *xdgshell.XdgToplevel
80+ shm *clientcore.Shm
81+ dmabuf *linuxdmabuf.LinuxDmabuf
82+
83+ // pending toplevel configure, applied by the xdg_surface configure.
84+ pendW, pendH int32
85+ pendFull bool
86+
87+ w, h int
88+ buffer *clientcore.Buffer
89+ ready bool
90+ closed bool
91+ err error
92+ xr30Lin bool // XR30 with modifier 0 advertised
93+ timerSet bool
94+}
95+
96+func run(mode string, fullscreen bool) error {
97+ d, err := wlturbo.Connect("")
98+ if err != nil {
99+ return err
100+ }
101+ defer d.Close()
102+ if err := d.Roundtrip(); err != nil {
103+ return err
104+ }
105+ c := &client{d: d, mode: mode, fullscreen: fullscreen}
106+ bind := func(iface string, version uint32, proxy wlturbo.Proxy) error {
107+ _, err := d.Registry().BindNegotiated(iface, version, proxy)
108+ return err
109+ }
110+ compositor := clientcore.NewCompositor(d.Context())
111+ if err := bind(clientcore.CompositorInterface, 4, compositor); err != nil {
112+ return err
113+ }
114+ wm := xdgshell.NewXdgWmBase(d.Context())
115+ if err := bind(xdgshell.XdgWmBaseInterface, 1, wm); err != nil {
116+ return err
117+ }
118+ wm.OnPing(func(serial uint32) { _ = wm.Pong(serial) })
119+
120+ var colour *colormanagement.WpColorManager
121+ var desc *colormanagement.WpImageDescription
122+ if mode == "sdr" {
123+ c.shm = clientcore.NewShm(d.Context())
124+ if err := bind(clientcore.ShmInterface, 1, c.shm); err != nil {
125+ return err
126+ }
127+ c.shm.OnFormat(func(uint32) {})
128+ } else {
129+ c.dmabuf = linuxdmabuf.NewLinuxDmabuf(d.Context())
130+ if v, err := d.Registry().BindNegotiated(linuxdmabuf.LinuxDmabufInterface, 3, c.dmabuf); err != nil {
131+ return err
132+ } else if v < 3 {
133+ return errors.New("linear XR30 unsupported: zwp_linux_dmabuf_v1 older than version 3")
134+ }
135+ c.dmabuf.OnFormat(func(uint32) {})
136+ c.dmabuf.OnModifier(func(format, hi, lo uint32) {
137+ if format == fourccXR30 && hi == 0 && lo == 0 {
138+ c.xr30Lin = true
139+ }
140+ })
141+ colour = colormanagement.NewWpColorManager(d.Context())
142+ if err := bind(colormanagement.WpColorManagerInterface, 2, colour); err != nil {
143+ return err
144+ }
145+ if err := d.Roundtrip(); err != nil {
146+ return err
147+ }
148+ if !c.xr30Lin {
149+ return errors.New("linear XR30 unsupported")
150+ }
151+ if desc, err = c.describe(colour); err != nil {
152+ return err
153+ }
154+ if err := c.printOutputTF(colour); err != nil {
155+ return err
156+ }
157+ }
158+
159+ if c.surface, err = compositor.CreateSurface(); err != nil {
160+ return err
161+ }
162+ if desc != nil {
163+ cs, err := colour.GetSurface(c.surface)
164+ if err != nil {
165+ return err
166+ }
167+ if err := cs.SetImageDescription(desc, 0); err != nil {
168+ return err
169+ }
170+ }
171+ xs, err := wm.GetXdgSurface(c.surface)
172+ if err != nil {
173+ return err
174+ }
175+ if c.top, err = xs.GetToplevel(); err != nil {
176+ return err
177+ }
178+ _ = c.top.SetTitle("testpattern")
179+ _ = c.top.SetAppId("testpattern")
180+ c.top.OnConfigure(func(w, h int32, states []byte) {
181+ c.pendW, c.pendH, c.pendFull = w, h, hasState(states, xdgshell.STATE_FULLSCREEN)
182+ })
183+ c.top.OnClose(func() { c.closed = true })
184+ xs.OnConfigure(func(serial uint32) {
185+ if err := xs.AckConfigure(serial); err != nil {
186+ c.err = err
187+ return
188+ }
189+ if err := c.configured(); err != nil {
190+ c.err = err
191+ }
192+ })
193+ if err := c.surface.Commit(); err != nil { // initial commit asks for the first configure
194+ return err
195+ }
196+ for !c.closed && c.err == nil {
197+ if err := d.Dispatch(); err != nil {
198+ if isClosed(err) {
199+ return nil
200+ }
201+ return err
202+ }
203+ }
204+ return c.err
205+}
206+
207+// describe creates the PQ/BT.2020 image description and waits until it is
208+// ready: a surface may only use a ready description.
209+func (c *client) describe(colour *colormanagement.WpColorManager) (*colormanagement.WpImageDescription, error) {
210+ creator, err := colour.CreateParametricCreator()
211+ if err != nil {
212+ return nil, err
213+ }
214+ if err := creator.SetTfNamed(colormanagement.TRANSFER_FUNCTION_ST2084_PQ); err != nil {
215+ return nil, err
216+ }
217+ if err := creator.SetPrimariesNamed(colormanagement.PRIMARIES_BT2020); err != nil {
218+ return nil, err
219+ }
220+ desc, err := creator.Create()
221+ if err != nil {
222+ return nil, err
223+ }
224+ ready, failed := false, ""
225+ desc.OnReady2(func(uint32, uint32) { ready = true })
226+ desc.OnFailed(func(cause uint32, msg string) { failed = fmt.Sprintf("cause %d: %s", cause, msg) })
227+ if err := c.until(func() bool { return ready || failed != "" }); err != nil {
228+ return nil, err
229+ }
230+ if failed != "" {
231+ return nil, fmt.Errorf("image description failed: %s", failed)
232+ }
233+ return desc, nil
234+}
235+
236+// printOutputTF reports the named transfer function of the first output on
237+// stderr as "output-tf <n>" (0 when it reports none).
238+func (c *client) printOutputTF(colour *colormanagement.WpColorManager) error {
239+ g, ok := c.d.Registry().FindGlobal(clientcore.OutputInterface)
240+ if !ok {
241+ return errors.New("compositor has no wl_output")
242+ }
243+ output := clientcore.NewOutput(c.d.Context())
244+ if err := c.d.Registry().Bind(g.Name, g.Interface, min(g.Version, 4), output); err != nil {
245+ return err
246+ }
247+ co, err := colour.GetOutput(output)
248+ if err != nil {
249+ return err
250+ }
251+ desc, err := co.GetImageDescription()
252+ if err != nil {
253+ return err
254+ }
255+ ready, failed := false, false
256+ desc.OnReady2(func(uint32, uint32) { ready = true })
257+ desc.OnFailed(func(uint32, string) { failed = true })
258+ if err := c.until(func() bool { return ready || failed }); err != nil {
259+ return err
260+ }
261+ if failed {
262+ return errors.New("output image description failed")
263+ }
264+ info, err := desc.GetInformation()
265+ if err != nil {
266+ return err
267+ }
268+ tf, done := uint32(0), false
269+ info.OnTfNamed(func(v uint32) { tf = v })
270+ info.OnDone(func() { done = true })
271+ if err := c.until(func() bool { return done }); err != nil {
272+ return err
273+ }
274+ fmt.Fprintf(os.Stderr, "output-tf %d\n", tf)
275+ return nil
276+}
277+
278+// until roundtrips until cond holds; setup events arrive within a few.
279+func (c *client) until(cond func() bool) error {
280+ for range 20 {
281+ if cond() {
282+ return nil
283+ }
284+ if err := c.d.Roundtrip(); err != nil {
285+ return err
286+ }
287+ }
288+ if cond() {
289+ return nil
290+ }
291+ return errors.New("compositor did not answer")
292+}
293+
294+// configured applies a toplevel configure after it was acknowledged: it
295+// (re)builds the buffer when the size changed, commits, and reports ready.
296+func (c *client) configured() error {
297+ w, h := int(c.pendW), int(c.pendH)
298+ if w <= 0 || h <= 0 {
299+ w, h = c.w, c.h // the client picks the size
300+ if w == 0 || h == 0 {
301+ w, h = defaultWidth, defaultHeight
302+ }
303+ }
304+ if c.buffer == nil || w != c.w || h != c.h {
305+ buf, err := c.newBuffer(w, h)
306+ if err != nil {
307+ return err
308+ }
309+ old := c.buffer
310+ c.buffer, c.w, c.h = buf, w, h
311+ defer func() {
312+ if old != nil {
313+ _ = old.Destroy()
314+ }
315+ }()
316+ }
317+ if err := c.surface.Attach(c.buffer, 0, 0); err != nil {
318+ return err
319+ }
320+ if err := c.surface.Damage(0, 0, int32(c.w), int32(c.h)); err != nil {
321+ return err
322+ }
323+ if err := c.surface.Commit(); err != nil {
324+ return err
325+ }
326+ if c.fullscreen && !c.timerSet {
327+ c.timerSet = true
328+ top := c.top
329+ time.AfterFunc(fullscreenDelay, func() {
330+ if err := top.SetFullscreen(nil); err != nil {
331+ fmt.Fprintf(os.Stderr, "testpattern: set_fullscreen: %v\n", err)
332+ }
333+ })
334+ }
335+ if !c.ready && (!c.fullscreen || c.pendFull) {
336+ c.ready = true
337+ fmt.Fprintf(os.Stderr, "ready %d %d\n", c.w, c.h)
338+ }
339+ return nil
340+}
341+
342+func (c *client) newBuffer(w, h int) (*clientcore.Buffer, error) {
343+ patches := pattern.Patches(c.mode, w, h)
344+ if c.mode == "sdr" {
345+ return c.shmBuffer(w, h, patches)
346+ }
347+ return c.dmabufBuffer(w, h, patches)
348+}
349+
350+// shmBuffer draws the sRGB patches into XRGB8888 (little-endian B, G, R, X).
351+func (c *client) shmBuffer(w, h int, patches []pattern.Patch) (*clientcore.Buffer, error) {
352+ size := w * h * 4
353+ fd, err := wlturbo.CreateAnonymousFile(int64(size))
354+ if err != nil {
355+ return nil, err
356+ }
357+ data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
358+ if err != nil {
359+ _ = unix.Close(fd)
360+ return nil, err
361+ }
362+ for _, p := range patches {
363+ for y := p.Rect.Min.Y; y < p.Rect.Max.Y; y++ {
364+ for x := p.Rect.Min.X; x < p.Rect.Max.X; x++ {
365+ i := (y*w + x) * 4
366+ data[i], data[i+1], data[i+2], data[i+3] = p.SRGB[2], p.SRGB[1], p.SRGB[0], 0xff
367+ }
368+ }
369+ }
370+ // The compositor maps the file itself; ours is not needed any more.
371+ _ = unix.Munmap(data)
372+ pool, err := c.shm.CreatePool(fd, int32(size)) // closes fd once sent
373+ if err != nil {
374+ _ = unix.Close(fd)
375+ return nil, err
376+ }
377+ buf, err := pool.CreateBuffer(0, int32(w), int32(h), int32(w*4), shmXRGB8888)
378+ _ = pool.Destroy()
379+ return buf, err
380+}
381+
382+// dmabufBuffer draws the PQ patches into a udmabuf as XR30 words
383+// b | g<<10 | r<<20.
384+func (c *client) dmabufBuffer(w, h int, patches []pattern.Patch) (*clientcore.Buffer, error) {
385+ code := func(nits float64) uint32 { return uint32(math.Round(pattern.PQEncode(nits) * 1023)) }
386+ f, err := udmabuf(w*h*4, func(data []byte) {
387+ for _, p := range patches {
388+ word := code(p.Nits[2]) | code(p.Nits[1])<<10 | code(p.Nits[0])<<20
389+ for y := p.Rect.Min.Y; y < p.Rect.Max.Y; y++ {
390+ for x := p.Rect.Min.X; x < p.Rect.Max.X; x++ {
391+ i := (y*w + x) * 4
392+ data[i], data[i+1], data[i+2], data[i+3] = byte(word), byte(word>>8), byte(word>>16), byte(word>>24)
393+ }
394+ }
395+ }
396+ })
397+ if err != nil {
398+ return nil, err
399+ }
400+ defer f.Close()
401+ params, err := c.dmabuf.CreateParams()
402+ if err != nil {
403+ return nil, err
404+ }
405+ // Add sends a duplicate: the send closes the descriptor it is given.
406+ fd, err := unix.Dup(int(f.Fd()))
407+ if err != nil {
408+ return nil, err
409+ }
410+ if err := params.Add(fd, 0, 0, uint32(w*4), 0, 0); err != nil {
411+ _ = unix.Close(fd)
412+ return nil, err
413+ }
414+ buf, err := params.CreateImmed(int32(w), int32(h), fourccXR30, 0)
415+ _ = params.Destroy()
416+ return buf, err
417+}
418+
419+func hasState(states []byte, want uint32) bool {
420+ for i := 0; i+4 <= len(states); i += 4 {
421+ if uint32(states[i])|uint32(states[i+1])<<8|uint32(states[i+2])<<16|uint32(states[i+3])<<24 == want {
422+ return true
423+ }
424+ }
425+ return false
426+}
427+
428+// isClosed reports whether err is the compositor going away.
429+func isClosed(err error) bool {
430+ return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, os.ErrClosed) ||
431+ errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) || errors.Is(err, net.ErrClosed)
432+}
A examples/testpattern/pattern/pattern.go +122 -0
@@ -0,0 +1,122 @@
1+// Package pattern defines the colour patches of the testpattern client and
2+// the transfer functions that say what each patch must look like. It has no
3+// Wayland imports so tests can reuse it as an oracle; it is deliberately
4+// independent of the renderer's own colour code.
5+package pattern
6+
7+import (
8+ "image"
9+ "math"
10+)
11+
12+const (
13+ // Columns and Rows give the patch grid.
14+ Columns = 6
15+ Rows = 2
16+)
17+
18+// Patch is one solid rectangle of the test pattern. SRGB is set in "sdr"
19+// mode, Nits (linear BT.2020, absolute luminance) in "hdr" mode.
20+type Patch struct {
21+ Rect image.Rectangle
22+ SRGB [3]uint8
23+ Nits [3]float64
24+}
25+
26+// Centre returns the middle of the patch.
27+func (p Patch) Centre() image.Point {
28+ return image.Pt((p.Rect.Min.X+p.Rect.Max.X)/2, (p.Rect.Min.Y+p.Rect.Max.Y)/2)
29+}
30+
31+// sdrColours are the sRGB patches in row-major order.
32+var sdrColours = [Columns * Rows][3]uint8{
33+ {0, 0, 0}, {64, 64, 64}, {128, 128, 128}, {192, 192, 192}, {255, 255, 255}, {255, 128, 0},
34+ {255, 0, 0}, {0, 255, 0}, {0, 0, 255}, {0, 255, 255}, {255, 0, 255}, {255, 255, 0},
35+}
36+
37+// BT709ToBT2020 converts linear BT.709 RGB to linear BT.2020 RGB.
38+var BT709ToBT2020 = [3][3]float64{
39+ {0.627404, 0.329283, 0.043313},
40+ {0.069097, 0.919540, 0.011362},
41+ {0.016391, 0.088013, 0.895595},
42+}
43+
44+// ReferenceWhite is the luminance of SDR white in an HDR signal, in nits.
45+const ReferenceWhite = 203
46+
47+// hdrNits are the BT.2020 linear patches in row-major order.
48+var hdrNits = func() (n [Columns * Rows][3]float64) {
49+ for i, v := range []float64{0, 1, 100, ReferenceWhite, 400, 1000} {
50+ n[i] = [3]float64{v, v, v}
51+ }
52+ n[6] = [3]float64{ReferenceWhite, 0, 0}
53+ n[7] = [3]float64{0, ReferenceWhite, 0}
54+ n[8] = [3]float64{0, 0, ReferenceWhite}
55+ for c := range 3 {
56+ var v [3]float64
57+ for r := range 3 {
58+ v[r] = BT709ToBT2020[r][c] * ReferenceWhite
59+ }
60+ n[9+c] = v
61+ }
62+ return
63+}()
64+
65+// Patches lays the mode's patches out as a 6×2 grid filling w×h. Cell sizes
66+// use integer division; the last column and row take the remainder. Mode is
67+// "sdr" or "hdr"; any other value yields nil.
68+func Patches(mode string, w, h int) []Patch {
69+ if mode != "sdr" && mode != "hdr" {
70+ return nil
71+ }
72+ patches := make([]Patch, 0, Columns*Rows)
73+ for i := range Columns * Rows {
74+ col, row := i%Columns, i/Columns
75+ x0, x1 := col*(w/Columns), (col+1)*(w/Columns)
76+ y0, y1 := row*(h/Rows), (row+1)*(h/Rows)
77+ if col == Columns-1 {
78+ x1 = w
79+ }
80+ if row == Rows-1 {
81+ y1 = h
82+ }
83+ p := Patch{Rect: image.Rect(x0, y0, x1, y1)}
84+ if mode == "sdr" {
85+ p.SRGB = sdrColours[i]
86+ } else {
87+ p.Nits = hdrNits[i]
88+ }
89+ patches = append(patches, p)
90+ }
91+ return patches
92+}
93+
94+// PQEncode encodes absolute luminance in nits with SMPTE ST 2084, clamped to
95+// 0–10000 nits, and returns a unit-range signal.
96+func PQEncode(nits float64) float64 {
97+ if nits <= 0 {
98+ return 0
99+ }
100+ x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
101+ return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
102+}
103+
104+// PQDecode is the SMPTE ST 2084 EOTF: it turns a unit-range PQ signal into
105+// absolute luminance in nits (0–10000). It inverts PQEncode.
106+func PQDecode(signal float64) float64 {
107+ if signal <= 0 {
108+ return 0
109+ }
110+ e := math.Pow(min(signal, 1), 32.0/2523)
111+ num := max(e-3424.0/4096, 0)
112+ den := 2413.0/128 - 2392.0/128*e
113+ return 10000 * math.Pow(num/den, 16384.0/2610)
114+}
115+
116+// SRGBToLinear decodes a unit-range sRGB component.
117+func SRGBToLinear(v float64) float64 {
118+ if v <= 0.04045 {
119+ return v / 12.92
120+ }
121+ return math.Pow((v+0.055)/1.055, 2.4)
122+}
@@ -0,0 +1,122 @@
1+// Package pattern defines the colour patches of the testpattern client and
2+// the transfer functions that say what each patch must look like. It has no
3+// Wayland imports so tests can reuse it as an oracle; it is deliberately
4+// independent of the renderer's own colour code.
5+package pattern
6+
7+import (
8+ "image"
9+ "math"
10+)
11+
12+const (
13+ // Columns and Rows give the patch grid.
14+ Columns = 6
15+ Rows = 2
16+)
17+
18+// Patch is one solid rectangle of the test pattern. SRGB is set in "sdr"
19+// mode, Nits (linear BT.2020, absolute luminance) in "hdr" mode.
20+type Patch struct {
21+ Rect image.Rectangle
22+ SRGB [3]uint8
23+ Nits [3]float64
24+}
25+
26+// Centre returns the middle of the patch.
27+func (p Patch) Centre() image.Point {
28+ return image.Pt((p.Rect.Min.X+p.Rect.Max.X)/2, (p.Rect.Min.Y+p.Rect.Max.Y)/2)
29+}
30+
31+// sdrColours are the sRGB patches in row-major order.
32+var sdrColours = [Columns * Rows][3]uint8{
33+ {0, 0, 0}, {64, 64, 64}, {128, 128, 128}, {192, 192, 192}, {255, 255, 255}, {255, 128, 0},
34+ {255, 0, 0}, {0, 255, 0}, {0, 0, 255}, {0, 255, 255}, {255, 0, 255}, {255, 255, 0},
35+}
36+
37+// BT709ToBT2020 converts linear BT.709 RGB to linear BT.2020 RGB.
38+var BT709ToBT2020 = [3][3]float64{
39+ {0.627404, 0.329283, 0.043313},
40+ {0.069097, 0.919540, 0.011362},
41+ {0.016391, 0.088013, 0.895595},
42+}
43+
44+// ReferenceWhite is the luminance of SDR white in an HDR signal, in nits.
45+const ReferenceWhite = 203
46+
47+// hdrNits are the BT.2020 linear patches in row-major order.
48+var hdrNits = func() (n [Columns * Rows][3]float64) {
49+ for i, v := range []float64{0, 1, 100, ReferenceWhite, 400, 1000} {
50+ n[i] = [3]float64{v, v, v}
51+ }
52+ n[6] = [3]float64{ReferenceWhite, 0, 0}
53+ n[7] = [3]float64{0, ReferenceWhite, 0}
54+ n[8] = [3]float64{0, 0, ReferenceWhite}
55+ for c := range 3 {
56+ var v [3]float64
57+ for r := range 3 {
58+ v[r] = BT709ToBT2020[r][c] * ReferenceWhite
59+ }
60+ n[9+c] = v
61+ }
62+ return
63+}()
64+
65+// Patches lays the mode's patches out as a 6×2 grid filling w×h. Cell sizes
66+// use integer division; the last column and row take the remainder. Mode is
67+// "sdr" or "hdr"; any other value yields nil.
68+func Patches(mode string, w, h int) []Patch {
69+ if mode != "sdr" && mode != "hdr" {
70+ return nil
71+ }
72+ patches := make([]Patch, 0, Columns*Rows)
73+ for i := range Columns * Rows {
74+ col, row := i%Columns, i/Columns
75+ x0, x1 := col*(w/Columns), (col+1)*(w/Columns)
76+ y0, y1 := row*(h/Rows), (row+1)*(h/Rows)
77+ if col == Columns-1 {
78+ x1 = w
79+ }
80+ if row == Rows-1 {
81+ y1 = h
82+ }
83+ p := Patch{Rect: image.Rect(x0, y0, x1, y1)}
84+ if mode == "sdr" {
85+ p.SRGB = sdrColours[i]
86+ } else {
87+ p.Nits = hdrNits[i]
88+ }
89+ patches = append(patches, p)
90+ }
91+ return patches
92+}
93+
94+// PQEncode encodes absolute luminance in nits with SMPTE ST 2084, clamped to
95+// 0–10000 nits, and returns a unit-range signal.
96+func PQEncode(nits float64) float64 {
97+ if nits <= 0 {
98+ return 0
99+ }
100+ x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
101+ return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
102+}
103+
104+// PQDecode is the SMPTE ST 2084 EOTF: it turns a unit-range PQ signal into
105+// absolute luminance in nits (0–10000). It inverts PQEncode.
106+func PQDecode(signal float64) float64 {
107+ if signal <= 0 {
108+ return 0
109+ }
110+ e := math.Pow(min(signal, 1), 32.0/2523)
111+ num := max(e-3424.0/4096, 0)
112+ den := 2413.0/128 - 2392.0/128*e
113+ return 10000 * math.Pow(num/den, 16384.0/2610)
114+}
115+
116+// SRGBToLinear decodes a unit-range sRGB component.
117+func SRGBToLinear(v float64) float64 {
118+ if v <= 0.04045 {
119+ return v / 12.92
120+ }
121+ return math.Pow((v+0.055)/1.055, 2.4)
122+}
A examples/testpattern/pattern/pattern_test.go +100 -0
@@ -0,0 +1,100 @@
1+package pattern
2+
3+import (
4+ "image"
5+ "math"
6+ "testing"
7+)
8+
9+func TestPatchesTileTheSurface(t *testing.T) {
10+ for _, mode := range []string{"sdr", "hdr"} {
11+ for _, size := range [][2]int{{640, 360}, {641, 361}} {
12+ w, h := size[0], size[1]
13+ patches := Patches(mode, w, h)
14+ if len(patches) != Columns*Rows {
15+ t.Fatalf("%s %dx%d: %d patches", mode, w, h, len(patches))
16+ }
17+ covered := make([]int, w*h)
18+ for _, p := range patches {
19+ if !p.Rect.In(image.Rect(0, 0, w, h)) || p.Rect.Empty() {
20+ t.Fatalf("%s %dx%d: bad rect %v", mode, w, h, p.Rect)
21+ }
22+ for y := p.Rect.Min.Y; y < p.Rect.Max.Y; y++ {
23+ for x := p.Rect.Min.X; x < p.Rect.Max.X; x++ {
24+ covered[y*w+x]++
25+ }
26+ }
27+ }
28+ for i, n := range covered {
29+ if n != 1 {
30+ t.Fatalf("%s %dx%d: pixel (%d,%d) covered %d times", mode, w, h, i%w, i/w, n)
31+ }
32+ }
33+ }
34+ }
35+}
36+
37+func TestPatchesModes(t *testing.T) {
38+ if Patches("bogus", 640, 360) != nil {
39+ t.Fatal("unknown mode must yield nil")
40+ }
41+ sdr := Patches("sdr", 600, 200)
42+ if sdr[5].SRGB != [3]uint8{255, 128, 0} || sdr[11].SRGB != [3]uint8{255, 255, 0} {
43+ t.Fatalf("sdr patches: %v", sdr)
44+ }
45+ hdr := Patches("hdr", 600, 200)
46+ if hdr[3].Nits != [3]float64{203, 203, 203} || hdr[5].Nits != [3]float64{1000, 1000, 1000} {
47+ t.Fatalf("hdr neutrals: %v", hdr)
48+ }
49+ // BT.709 red at 203 nits in BT.2020.
50+ want := [3]float64{0.627404 * 203, 0.069097 * 203, 0.016391 * 203}
51+ for i, v := range hdr[9].Nits {
52+ if math.Abs(v-want[i]) > 1e-9 {
53+ t.Fatalf("709 red = %v, want %v", hdr[9].Nits, want)
54+ }
55+ }
56+ if c := sdr[0].Centre(); c != image.Pt(50, 50) {
57+ t.Fatalf("centre = %v", c)
58+ }
59+}
60+
61+func TestPQEncode(t *testing.T) {
62+ if v := PQEncode(203); math.Abs(v-0.5807) > 1e-4 {
63+ t.Fatalf("PQEncode(203) = %v", v)
64+ }
65+ if v := PQEncode(10000); math.Abs(v-1) > 1e-4 {
66+ t.Fatalf("PQEncode(10000) = %v", v)
67+ }
68+ if PQEncode(0) != 0 || PQEncode(-1) != 0 {
69+ t.Fatal("PQEncode must clamp to zero")
70+ }
71+}
72+
73+func TestPQDecodeInvertsPQEncode(t *testing.T) {
74+ for _, nits := range []float64{0.001, 0.02, 1, 10, 100, 203, 400, 1000, 4000, 10000} {
75+ if got := PQDecode(PQEncode(nits)); math.Abs(got-nits) > nits*1e-9+1e-9 {
76+ t.Errorf("PQDecode(PQEncode(%v)) = %v", nits, got)
77+ }
78+ }
79+ for _, v := range []float64{0, 0.0313, 0.1508, 0.5807, 0.75, 1} {
80+ if got := PQEncode(PQDecode(v)); math.Abs(got-v) > 1e-9 {
81+ t.Errorf("PQEncode(PQDecode(%v)) = %v", v, got)
82+ }
83+ }
84+ if PQDecode(0) != 0 || PQDecode(-1) != 0 || math.Abs(PQDecode(1)-10000) > 1e-6 {
85+ t.Fatal("PQDecode endpoints")
86+ }
87+ // The compositor's near-black residue (0.0313 PQ) is a few hundredths of a nit.
88+ if v := PQDecode(0.0313); v < 0.01 || v > 0.05 {
89+ t.Fatalf("PQDecode(0.0313) = %v nits", v)
90+ }
91+}
92+
93+func TestSRGBToLinear(t *testing.T) {
94+ if SRGBToLinear(0) != 0 || math.Abs(SRGBToLinear(1)-1) > 1e-12 {
95+ t.Fatal("endpoints")
96+ }
97+ if v := SRGBToLinear(128.0 / 255); math.Abs(v-0.2158605) > 1e-6 {
98+ t.Fatalf("SRGBToLinear(128/255) = %v", v)
99+ }
100+}
@@ -0,0 +1,100 @@
1+package pattern
2+
3+import (
4+ "image"
5+ "math"
6+ "testing"
7+)
8+
9+func TestPatchesTileTheSurface(t *testing.T) {
10+ for _, mode := range []string{"sdr", "hdr"} {
11+ for _, size := range [][2]int{{640, 360}, {641, 361}} {
12+ w, h := size[0], size[1]
13+ patches := Patches(mode, w, h)
14+ if len(patches) != Columns*Rows {
15+ t.Fatalf("%s %dx%d: %d patches", mode, w, h, len(patches))
16+ }
17+ covered := make([]int, w*h)
18+ for _, p := range patches {
19+ if !p.Rect.In(image.Rect(0, 0, w, h)) || p.Rect.Empty() {
20+ t.Fatalf("%s %dx%d: bad rect %v", mode, w, h, p.Rect)
21+ }
22+ for y := p.Rect.Min.Y; y < p.Rect.Max.Y; y++ {
23+ for x := p.Rect.Min.X; x < p.Rect.Max.X; x++ {
24+ covered[y*w+x]++
25+ }
26+ }
27+ }
28+ for i, n := range covered {
29+ if n != 1 {
30+ t.Fatalf("%s %dx%d: pixel (%d,%d) covered %d times", mode, w, h, i%w, i/w, n)
31+ }
32+ }
33+ }
34+ }
35+}
36+
37+func TestPatchesModes(t *testing.T) {
38+ if Patches("bogus", 640, 360) != nil {
39+ t.Fatal("unknown mode must yield nil")
40+ }
41+ sdr := Patches("sdr", 600, 200)
42+ if sdr[5].SRGB != [3]uint8{255, 128, 0} || sdr[11].SRGB != [3]uint8{255, 255, 0} {
43+ t.Fatalf("sdr patches: %v", sdr)
44+ }
45+ hdr := Patches("hdr", 600, 200)
46+ if hdr[3].Nits != [3]float64{203, 203, 203} || hdr[5].Nits != [3]float64{1000, 1000, 1000} {
47+ t.Fatalf("hdr neutrals: %v", hdr)
48+ }
49+ // BT.709 red at 203 nits in BT.2020.
50+ want := [3]float64{0.627404 * 203, 0.069097 * 203, 0.016391 * 203}
51+ for i, v := range hdr[9].Nits {
52+ if math.Abs(v-want[i]) > 1e-9 {
53+ t.Fatalf("709 red = %v, want %v", hdr[9].Nits, want)
54+ }
55+ }
56+ if c := sdr[0].Centre(); c != image.Pt(50, 50) {
57+ t.Fatalf("centre = %v", c)
58+ }
59+}
60+
61+func TestPQEncode(t *testing.T) {
62+ if v := PQEncode(203); math.Abs(v-0.5807) > 1e-4 {
63+ t.Fatalf("PQEncode(203) = %v", v)
64+ }
65+ if v := PQEncode(10000); math.Abs(v-1) > 1e-4 {
66+ t.Fatalf("PQEncode(10000) = %v", v)
67+ }
68+ if PQEncode(0) != 0 || PQEncode(-1) != 0 {
69+ t.Fatal("PQEncode must clamp to zero")
70+ }
71+}
72+
73+func TestPQDecodeInvertsPQEncode(t *testing.T) {
74+ for _, nits := range []float64{0.001, 0.02, 1, 10, 100, 203, 400, 1000, 4000, 10000} {
75+ if got := PQDecode(PQEncode(nits)); math.Abs(got-nits) > nits*1e-9+1e-9 {
76+ t.Errorf("PQDecode(PQEncode(%v)) = %v", nits, got)
77+ }
78+ }
79+ for _, v := range []float64{0, 0.0313, 0.1508, 0.5807, 0.75, 1} {
80+ if got := PQEncode(PQDecode(v)); math.Abs(got-v) > 1e-9 {
81+ t.Errorf("PQEncode(PQDecode(%v)) = %v", v, got)
82+ }
83+ }
84+ if PQDecode(0) != 0 || PQDecode(-1) != 0 || math.Abs(PQDecode(1)-10000) > 1e-6 {
85+ t.Fatal("PQDecode endpoints")
86+ }
87+ // The compositor's near-black residue (0.0313 PQ) is a few hundredths of a nit.
88+ if v := PQDecode(0.0313); v < 0.01 || v > 0.05 {
89+ t.Fatalf("PQDecode(0.0313) = %v nits", v)
90+ }
91+}
92+
93+func TestSRGBToLinear(t *testing.T) {
94+ if SRGBToLinear(0) != 0 || math.Abs(SRGBToLinear(1)-1) > 1e-12 {
95+ t.Fatal("endpoints")
96+ }
97+ if v := SRGBToLinear(128.0 / 255); math.Abs(v-0.2158605) > 1e-6 {
98+ t.Fatalf("SRGBToLinear(128/255) = %v", v)
99+ }
100+}
A examples/testpattern/udmabuf.go +50 -0
@@ -0,0 +1,50 @@
1+package main
2+
3+import (
4+ "fmt"
5+ "os"
6+ "unsafe"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+// udmabuf turns memfd pages into a real dmabuf: a linear buffer any GPU
12+// driver imports. fill gets the page-aligned backing store, size bytes long.
13+func udmabuf(size int, fill func(data []byte)) (*os.File, error) {
14+ dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0)
15+ if err != nil {
16+ return nil, fmt.Errorf("udmabuf unavailable: %w", err)
17+ }
18+ defer dev.Close()
19+ size = (size + os.Getpagesize() - 1) / os.Getpagesize() * os.Getpagesize()
20+ mem, err := unix.MemfdCreate("testpattern-dmabuf", unix.MFD_ALLOW_SEALING|unix.MFD_CLOEXEC)
21+ if err != nil {
22+ return nil, err
23+ }
24+ defer unix.Close(mem)
25+ if err := unix.Ftruncate(mem, int64(size)); err != nil {
26+ return nil, err
27+ }
28+ data, err := unix.Mmap(mem, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
29+ if err != nil {
30+ return nil, err
31+ }
32+ fill(data)
33+ if err := unix.Munmap(data); err != nil {
34+ return nil, err
35+ }
36+ if _, err := unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK); err != nil {
37+ return nil, err
38+ }
39+ // struct udmabuf_create { u32 memfd; u32 flags; u64 offset; u64 size; }
40+ arg := struct {
41+ memfd, flags uint32
42+ offset, size uint64
43+ }{memfd: uint32(mem), flags: 1 /* CLOEXEC */, size: uint64(size)}
44+ const udmabufCreate = 0x40187542 // _IOW('u', 0x42, struct udmabuf_create)
45+ fd, _, errno := unix.Syscall(unix.SYS_IOCTL, dev.Fd(), udmabufCreate, uintptr(unsafe.Pointer(&arg)))
46+ if errno != 0 {
47+ return nil, fmt.Errorf("udmabuf create: %w", errno)
48+ }
49+ return os.NewFile(fd, "dmabuf"), nil
50+}
@@ -0,0 +1,50 @@
1+package main
2+
3+import (
4+ "fmt"
5+ "os"
6+ "unsafe"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+// udmabuf turns memfd pages into a real dmabuf: a linear buffer any GPU
12+// driver imports. fill gets the page-aligned backing store, size bytes long.
13+func udmabuf(size int, fill func(data []byte)) (*os.File, error) {
14+ dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0)
15+ if err != nil {
16+ return nil, fmt.Errorf("udmabuf unavailable: %w", err)
17+ }
18+ defer dev.Close()
19+ size = (size + os.Getpagesize() - 1) / os.Getpagesize() * os.Getpagesize()
20+ mem, err := unix.MemfdCreate("testpattern-dmabuf", unix.MFD_ALLOW_SEALING|unix.MFD_CLOEXEC)
21+ if err != nil {
22+ return nil, err
23+ }
24+ defer unix.Close(mem)
25+ if err := unix.Ftruncate(mem, int64(size)); err != nil {
26+ return nil, err
27+ }
28+ data, err := unix.Mmap(mem, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
29+ if err != nil {
30+ return nil, err
31+ }
32+ fill(data)
33+ if err := unix.Munmap(data); err != nil {
34+ return nil, err
35+ }
36+ if _, err := unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK); err != nil {
37+ return nil, err
38+ }
39+ // struct udmabuf_create { u32 memfd; u32 flags; u64 offset; u64 size; }
40+ arg := struct {
41+ memfd, flags uint32
42+ offset, size uint64
43+ }{memfd: uint32(mem), flags: 1 /* CLOEXEC */, size: uint64(size)}
44+ const udmabufCreate = 0x40187542 // _IOW('u', 0x42, struct udmabuf_create)
45+ fd, _, errno := unix.Syscall(unix.SYS_IOCTL, dev.Fd(), udmabufCreate, uintptr(unsafe.Pointer(&arg)))
46+ if errno != 0 {
47+ return nil, fmt.Errorf("udmabuf create: %w", errno)
48+ }
49+ return os.NewFile(fd, "dmabuf"), nil
50+}
M internal/adapters/headless/output.go +23 -5
@@ -34,10 +34,13 @@ type Options struct {
3434 LoadCursor func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error)
3535 Width, Height int
3636 ScreenshotDir string
37- HDR bool // virtual HDR output for protocol testing only
38- Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting
39- Log zerowrap.Logger
40- NewRenderer func(w, h int) (ports.Renderer, error)
37+ HDR bool // virtual HDR output for protocol testing only
38+ // RawHDR also writes latest-pq.png, the raw PQ codes of an HDR output
39+ // (16-bit), next to the screenshots. Test-only; needs HDR and ScreenshotDir.
40+ RawHDR bool
41+ Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting
42+ Log zerowrap.Logger
43+ NewRenderer func(w, h int) (ports.Renderer, error)
4144 // NewCaptureRenderer makes the child renderer of a hidden workspace
4245 // capture session (Scene.CaptureScene). Nil: such captures fail closed.
4346 NewCaptureRenderer func(w, h int) (ports.Renderer, error)
@@ -594,6 +597,21 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
594597 if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil {
595598 return fmt.Errorf("latest screenshot: %w", err)
596599 }
600+ if opts.RawHDR && opts.HDR {
601+ // No cursor on the raw image. Only a confirmed HDR output
602+ // has a readable PQ target; else HDRPixels is nil.
603+ if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
604+ continue
605+ }
606+ raw := r.HDRPixels()
607+ if raw == nil {
608+ opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("raw HDR readback unavailable; frame skipped")
609+ continue
610+ }
611+ if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest-pq.png"), raw, opts.Security, scene.Security); err != nil {
612+ return fmt.Errorf("raw HDR screenshot: %w", err)
613+ }
614+ }
597615 }
598616 opts.Log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame")
599617 }
@@ -603,7 +621,7 @@ func (opts Options) protected() bool {
603621 return opts.Security != nil && opts.Security.Snapshot().Protected
604622 }
605623
606-func writePNGSecure(path string, img *image.RGBA, security ports.SessionSecurity, state ports.SecurityState) error {
624+func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error {
607625 if security != nil && (state.Protected || security.Snapshot() != state) {
608626 return nil
609627 }
@@ -34,10 +34,13 @@ type Options struct {
34 LoadCursor func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error)34 LoadCursor func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error)
35 Width, Height int35 Width, Height int
36 ScreenshotDir string36 ScreenshotDir string
37- HDR bool // virtual HDR output for protocol testing only37+ HDR bool // virtual HDR output for protocol testing only
38- Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting38+ // RawHDR also writes latest-pq.png, the raw PQ codes of an HDR output
39- Log zerowrap.Logger39+ // (16-bit), next to the screenshots. Test-only; needs HDR and ScreenshotDir.
40- NewRenderer func(w, h int) (ports.Renderer, error)40+ RawHDR bool
41+ Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting
42+ Log zerowrap.Logger
43+ NewRenderer func(w, h int) (ports.Renderer, error)
41 // NewCaptureRenderer makes the child renderer of a hidden workspace44 // NewCaptureRenderer makes the child renderer of a hidden workspace
42 // capture session (Scene.CaptureScene). Nil: such captures fail closed.45 // capture session (Scene.CaptureScene). Nil: such captures fail closed.
43 NewCaptureRenderer func(w, h int) (ports.Renderer, error)46 NewCaptureRenderer func(w, h int) (ports.Renderer, error)
@@ -594,6 +597,21 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
594 if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil {597 if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil {
595 return fmt.Errorf("latest screenshot: %w", err)598 return fmt.Errorf("latest screenshot: %w", err)
596 }599 }
600+ if opts.RawHDR && opts.HDR {
601+ // No cursor on the raw image. Only a confirmed HDR output
602+ // has a readable PQ target; else HDRPixels is nil.
603+ if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
604+ continue
605+ }
606+ raw := r.HDRPixels()
607+ if raw == nil {
608+ opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("raw HDR readback unavailable; frame skipped")
609+ continue
610+ }
611+ if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest-pq.png"), raw, opts.Security, scene.Security); err != nil {
612+ return fmt.Errorf("raw HDR screenshot: %w", err)
613+ }
614+ }
597 }615 }
598 opts.Log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame")616 opts.Log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame")
599 }617 }
@@ -603,7 +621,7 @@ func (opts Options) protected() bool {
603 return opts.Security != nil && opts.Security.Snapshot().Protected621 return opts.Security != nil && opts.Security.Snapshot().Protected
604 }622 }
605 623
606-func writePNGSecure(path string, img *image.RGBA, security ports.SessionSecurity, state ports.SecurityState) error {624+func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error {
607 if security != nil && (state.Protected || security.Snapshot() != state) {625 if security != nil && (state.Protected || security.Snapshot() != state) {
608 return nil626 return nil
609 }627 }
M internal/adapters/headless/output_test.go +76 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "image"
7+ "image/color"
78 "image/png"
89 "maps"
910 "os"
@@ -293,6 +294,81 @@ func TestCaptureForcesFreshHeadlessFrame(t *testing.T) {
293294 }
294295 }
295296
297+// RawHDR writes the renderer's raw PQ codes as a 16-bit latest-pq.png next
298+// to the 8-bit screenshots.
299+func TestHeadlessRawHDRScreenshot(t *testing.T) {
300+ dir := t.TempDir()
301+ want := image.NewRGBA64(image.Rect(0, 0, 2, 2))
302+ for i := range 4 {
303+ want.SetRGBA64(i%2, i/2, color.RGBA64{R: uint16(i*0x1111 + 0x0123), G: 0x8000 + uint16(i), B: 0xffc0, A: 0xffff})
304+ }
305+ f, err := os.CreateTemp(t.TempDir(), "target")
306+ if err != nil {
307+ t.Fatal(err)
308+ }
309+ r := portsmocks.NewMockRenderer(t)
310+ r.EXPECT().SetHDR(float64(203)).Return().Once()
311+ r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
312+ rendered := make(chan struct{}, 4)
313+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
314+ rendered <- struct{}{}
315+ return nil, nil
316+ }).Maybe()
317+ r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
318+ r.EXPECT().HDRPixels().Return(want).Maybe()
319+ r.EXPECT().Close().Return().Once()
320+ scenes := make(chan ports.Scene, 1)
321+ scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2}
322+ ctx, cancel := context.WithCancel(context.Background())
323+ done := make(chan error, 1)
324+ go func() {
325+ done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil)
326+ }()
327+ select {
328+ case <-rendered:
329+ case <-time.After(3 * time.Second):
330+ t.Fatal("no frame")
331+ }
332+ // The files are written after Render returns.
333+ path := filepath.Join(dir, "latest-pq.png")
334+ deadline := time.Now().Add(3 * time.Second)
335+ for {
336+ if _, err := os.Stat(path); err == nil {
337+ break
338+ }
339+ if time.Now().After(deadline) {
340+ t.Fatal("no latest-pq.png")
341+ }
342+ time.Sleep(10 * time.Millisecond)
343+ }
344+ cancel()
345+ if err := <-done; err != nil {
346+ t.Fatal(err)
347+ }
348+ file, err := os.Open(path)
349+ if err != nil {
350+ t.Fatal(err)
351+ }
352+ defer file.Close()
353+ got, err := png.Decode(file)
354+ if err != nil {
355+ t.Fatal(err)
356+ }
357+ if _, ok := got.(*image.NRGBA64); !ok && got.ColorModel() != color.RGBA64Model {
358+ t.Fatalf("not a 16-bit image: %T", got)
359+ }
360+ for y := range 2 {
361+ for x := range 2 {
362+ if g, w := color.RGBA64Model.Convert(got.At(x, y)), want.RGBA64At(x, y); g != color.Color(w) {
363+ t.Errorf("(%d,%d) = %v, want %v", x, y, g, w)
364+ }
365+ }
366+ }
367+ if _, err := os.Stat(filepath.Join(dir, "latest.png")); err != nil {
368+ t.Errorf("8-bit screenshot: %v", err)
369+ }
370+}
371+
296372 // Only the output which successfully prepared HDR reports confirmed HDR.
297373 // The fallback must explicitly withdraw it, and close exported descriptors.
298374 func TestHeadlessHDRFormatsConfirmed(t *testing.T) {
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "image"6 "image"
7+ "image/color"
7 "image/png"8 "image/png"
8 "maps"9 "maps"
9 "os"10 "os"
@@ -293,6 +294,81 @@ func TestCaptureForcesFreshHeadlessFrame(t *testing.T) {
293 }294 }
294 }295 }
295 296
297+// RawHDR writes the renderer's raw PQ codes as a 16-bit latest-pq.png next
298+// to the 8-bit screenshots.
299+func TestHeadlessRawHDRScreenshot(t *testing.T) {
300+ dir := t.TempDir()
301+ want := image.NewRGBA64(image.Rect(0, 0, 2, 2))
302+ for i := range 4 {
303+ want.SetRGBA64(i%2, i/2, color.RGBA64{R: uint16(i*0x1111 + 0x0123), G: 0x8000 + uint16(i), B: 0xffc0, A: 0xffff})
304+ }
305+ f, err := os.CreateTemp(t.TempDir(), "target")
306+ if err != nil {
307+ t.Fatal(err)
308+ }
309+ r := portsmocks.NewMockRenderer(t)
310+ r.EXPECT().SetHDR(float64(203)).Return().Once()
311+ r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
312+ rendered := make(chan struct{}, 4)
313+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
314+ rendered <- struct{}{}
315+ return nil, nil
316+ }).Maybe()
317+ r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
318+ r.EXPECT().HDRPixels().Return(want).Maybe()
319+ r.EXPECT().Close().Return().Once()
320+ scenes := make(chan ports.Scene, 1)
321+ scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2}
322+ ctx, cancel := context.WithCancel(context.Background())
323+ done := make(chan error, 1)
324+ go func() {
325+ done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil)
326+ }()
327+ select {
328+ case <-rendered:
329+ case <-time.After(3 * time.Second):
330+ t.Fatal("no frame")
331+ }
332+ // The files are written after Render returns.
333+ path := filepath.Join(dir, "latest-pq.png")
334+ deadline := time.Now().Add(3 * time.Second)
335+ for {
336+ if _, err := os.Stat(path); err == nil {
337+ break
338+ }
339+ if time.Now().After(deadline) {
340+ t.Fatal("no latest-pq.png")
341+ }
342+ time.Sleep(10 * time.Millisecond)
343+ }
344+ cancel()
345+ if err := <-done; err != nil {
346+ t.Fatal(err)
347+ }
348+ file, err := os.Open(path)
349+ if err != nil {
350+ t.Fatal(err)
351+ }
352+ defer file.Close()
353+ got, err := png.Decode(file)
354+ if err != nil {
355+ t.Fatal(err)
356+ }
357+ if _, ok := got.(*image.NRGBA64); !ok && got.ColorModel() != color.RGBA64Model {
358+ t.Fatalf("not a 16-bit image: %T", got)
359+ }
360+ for y := range 2 {
361+ for x := range 2 {
362+ if g, w := color.RGBA64Model.Convert(got.At(x, y)), want.RGBA64At(x, y); g != color.Color(w) {
363+ t.Errorf("(%d,%d) = %v, want %v", x, y, g, w)
364+ }
365+ }
366+ }
367+ if _, err := os.Stat(filepath.Join(dir, "latest.png")); err != nil {
368+ t.Errorf("8-bit screenshot: %v", err)
369+ }
370+}
371+
296 // Only the output which successfully prepared HDR reports confirmed HDR.372 // Only the output which successfully prepared HDR reports confirmed HDR.
297 // The fallback must explicitly withdraw it, and close exported descriptors.373 // The fallback must explicitly withdraw it, and close exported descriptors.
298 func TestHeadlessHDRFormatsConfirmed(t *testing.T) {374 func TestHeadlessHDRFormatsConfirmed(t *testing.T) {
M internal/adapters/headless/security_test.go +96 -0
@@ -10,6 +10,7 @@ import (
1010 "testing"
1111 "time"
1212
13+ "github.com/bnema/neferwl/internal/logging"
1314 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1415 "github.com/bnema/neferwl/internal/ports"
1516 "github.com/stretchr/testify/mock"
@@ -223,6 +224,101 @@ func TestSecurityCursorAndPNGSuppressed(t *testing.T) {
223224 }
224225 }
225226
227+// The raw HDR file follows the screenshot security gating: nothing is read
228+// back or written for a protected session, none is read back once the
229+// session turned protected after latest.png, and a frame that crossed a
230+// transition during the readback is not written.
231+func TestSecurityRawHDRSuppressed(t *testing.T) {
232+ unprotected := ports.SecurityState{Generation: 1}
233+ protected := ports.SecurityState{Generation: 2, Protected: true}
234+ tests := []struct {
235+ name string
236+ // hdrPixels: HDRPixels is expected and flips the session protected.
237+ hdrPixels bool
238+ // flipAfterLatest: the session turns protected once latest.png exists.
239+ flipAfterLatest bool
240+ startProtected bool
241+ wantLatest bool
242+ }{
243+ {name: "protected from the start", startProtected: true},
244+ {name: "protected after latest.png, before the readback", flipAfterLatest: true, wantLatest: true},
245+ {name: "protected during the readback", hdrPixels: true, wantLatest: true},
246+ }
247+ for _, tc := range tests {
248+ t.Run(tc.name, func(t *testing.T) {
249+ dir := t.TempDir()
250+ var state atomic.Value
251+ state.Store(unprotected)
252+ if tc.startProtected {
253+ state.Store(protected)
254+ }
255+ gate := portsmocks.NewMockSessionSecurity(t)
256+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
257+ if tc.flipAfterLatest {
258+ if _, err := os.Stat(filepath.Join(dir, "latest.png")); err == nil {
259+ state.Store(protected)
260+ }
261+ }
262+ return state.Load().(ports.SecurityState)
263+ })
264+ f, err := os.CreateTemp(t.TempDir(), "target")
265+ if err != nil {
266+ t.Fatal(err)
267+ }
268+ r := portsmocks.NewMockRenderer(t)
269+ r.EXPECT().SetHDR(float64(203)).Return().Once()
270+ r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
271+ rendered := make(chan ports.Scene, 4)
272+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
273+ rendered <- s
274+ return nil, nil
275+ }).Maybe()
276+ if !tc.startProtected {
277+ r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
278+ }
279+ // Without an HDRPixels expectation, a readback fails the test.
280+ if tc.hdrPixels {
281+ r.EXPECT().HDRPixels().RunAndReturn(func() *image.RGBA64 {
282+ state.Store(protected)
283+ return image.NewRGBA64(image.Rect(0, 0, 2, 2))
284+ }).Once()
285+ }
286+ r.EXPECT().Close().Return().Once()
287+ scenes := make(chan ports.Scene, 1)
288+ scenes <- ports.Scene{Seq: 1, Security: unprotected, OutputWidth: 2, OutputHeight: 2}
289+ ctx, cancel := context.WithCancel(context.Background())
290+ done := make(chan error, 1)
291+ go func() {
292+ done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Name: "H", Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil)
293+ }()
294+ // The black protection frame follows the frame under test (or is
295+ // the only one): the raw block is over once it is rendered.
296+ for s := securityReceive(t, rendered); !s.Security.Protected; s = securityReceive(t, rendered) {
297+ }
298+ cancel()
299+ if err := securityReceive(t, done); err != nil {
300+ t.Fatal(err)
301+ }
302+ if _, err := os.Stat(filepath.Join(dir, "latest.png")); (err == nil) != tc.wantLatest {
303+ t.Errorf("latest.png: %v, want present %v", err, tc.wantLatest)
304+ }
305+ if _, err := os.Stat(filepath.Join(dir, "latest-pq.png")); !os.IsNotExist(err) {
306+ t.Errorf("latest-pq.png delivered: %v", err)
307+ }
308+ })
309+ }
310+ // The write gate itself refuses a stale or protected 16-bit image.
311+ gate := portsmocks.NewMockSessionSecurity(t)
312+ gate.EXPECT().Snapshot().Return(protected)
313+ path := filepath.Join(t.TempDir(), "latest-pq.png")
314+ if err := writePNGSecure(path, image.NewRGBA64(image.Rect(0, 0, 1, 1)), gate, ports.SecurityState{}); err != nil {
315+ t.Fatal(err)
316+ }
317+ if _, err := os.Stat(path); !os.IsNotExist(err) {
318+ t.Fatal("stale raw screenshot delivered")
319+ }
320+}
321+
226322 func TestSecurityQueuedCaptureFailedOnceAndProofCancellation(t *testing.T) {
227323 var state atomic.Value
228324 state.Store(ports.SecurityState{})
@@ -10,6 +10,7 @@ import (
10 "testing"10 "testing"
11 "time"11 "time"
12 12
13+ "github.com/bnema/neferwl/internal/logging"
13 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"14 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
14 "github.com/bnema/neferwl/internal/ports"15 "github.com/bnema/neferwl/internal/ports"
15 "github.com/stretchr/testify/mock"16 "github.com/stretchr/testify/mock"
@@ -223,6 +224,101 @@ func TestSecurityCursorAndPNGSuppressed(t *testing.T) {
223 }224 }
224 }225 }
225 226
227+// The raw HDR file follows the screenshot security gating: nothing is read
228+// back or written for a protected session, none is read back once the
229+// session turned protected after latest.png, and a frame that crossed a
230+// transition during the readback is not written.
231+func TestSecurityRawHDRSuppressed(t *testing.T) {
232+ unprotected := ports.SecurityState{Generation: 1}
233+ protected := ports.SecurityState{Generation: 2, Protected: true}
234+ tests := []struct {
235+ name string
236+ // hdrPixels: HDRPixels is expected and flips the session protected.
237+ hdrPixels bool
238+ // flipAfterLatest: the session turns protected once latest.png exists.
239+ flipAfterLatest bool
240+ startProtected bool
241+ wantLatest bool
242+ }{
243+ {name: "protected from the start", startProtected: true},
244+ {name: "protected after latest.png, before the readback", flipAfterLatest: true, wantLatest: true},
245+ {name: "protected during the readback", hdrPixels: true, wantLatest: true},
246+ }
247+ for _, tc := range tests {
248+ t.Run(tc.name, func(t *testing.T) {
249+ dir := t.TempDir()
250+ var state atomic.Value
251+ state.Store(unprotected)
252+ if tc.startProtected {
253+ state.Store(protected)
254+ }
255+ gate := portsmocks.NewMockSessionSecurity(t)
256+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
257+ if tc.flipAfterLatest {
258+ if _, err := os.Stat(filepath.Join(dir, "latest.png")); err == nil {
259+ state.Store(protected)
260+ }
261+ }
262+ return state.Load().(ports.SecurityState)
263+ })
264+ f, err := os.CreateTemp(t.TempDir(), "target")
265+ if err != nil {
266+ t.Fatal(err)
267+ }
268+ r := portsmocks.NewMockRenderer(t)
269+ r.EXPECT().SetHDR(float64(203)).Return().Once()
270+ r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
271+ rendered := make(chan ports.Scene, 4)
272+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
273+ rendered <- s
274+ return nil, nil
275+ }).Maybe()
276+ if !tc.startProtected {
277+ r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
278+ }
279+ // Without an HDRPixels expectation, a readback fails the test.
280+ if tc.hdrPixels {
281+ r.EXPECT().HDRPixels().RunAndReturn(func() *image.RGBA64 {
282+ state.Store(protected)
283+ return image.NewRGBA64(image.Rect(0, 0, 2, 2))
284+ }).Once()
285+ }
286+ r.EXPECT().Close().Return().Once()
287+ scenes := make(chan ports.Scene, 1)
288+ scenes <- ports.Scene{Seq: 1, Security: unprotected, OutputWidth: 2, OutputHeight: 2}
289+ ctx, cancel := context.WithCancel(context.Background())
290+ done := make(chan error, 1)
291+ go func() {
292+ done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Name: "H", Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil)
293+ }()
294+ // The black protection frame follows the frame under test (or is
295+ // the only one): the raw block is over once it is rendered.
296+ for s := securityReceive(t, rendered); !s.Security.Protected; s = securityReceive(t, rendered) {
297+ }
298+ cancel()
299+ if err := securityReceive(t, done); err != nil {
300+ t.Fatal(err)
301+ }
302+ if _, err := os.Stat(filepath.Join(dir, "latest.png")); (err == nil) != tc.wantLatest {
303+ t.Errorf("latest.png: %v, want present %v", err, tc.wantLatest)
304+ }
305+ if _, err := os.Stat(filepath.Join(dir, "latest-pq.png")); !os.IsNotExist(err) {
306+ t.Errorf("latest-pq.png delivered: %v", err)
307+ }
308+ })
309+ }
310+ // The write gate itself refuses a stale or protected 16-bit image.
311+ gate := portsmocks.NewMockSessionSecurity(t)
312+ gate.EXPECT().Snapshot().Return(protected)
313+ path := filepath.Join(t.TempDir(), "latest-pq.png")
314+ if err := writePNGSecure(path, image.NewRGBA64(image.Rect(0, 0, 1, 1)), gate, ports.SecurityState{}); err != nil {
315+ t.Fatal(err)
316+ }
317+ if _, err := os.Stat(path); !os.IsNotExist(err) {
318+ t.Fatal("stale raw screenshot delivered")
319+ }
320+}
321+
226 func TestSecurityQueuedCaptureFailedOnceAndProofCancellation(t *testing.T) {322 func TestSecurityQueuedCaptureFailedOnceAndProofCancellation(t *testing.T) {
227 var state atomic.Value323 var state atomic.Value
228 state.Store(ports.SecurityState{})324 state.Store(ports.SecurityState{})
M internal/adapters/vulkan/capture.go +54 -0
@@ -508,6 +508,60 @@ func (r *Renderer) debugCapture() (*captureFrame, error) {
508508 return f, nil
509509 }
510510
511+// readHDRTarget copies the exported PQ target last rendered (XRGB2101010
512+// words, one per pixel) through a free capture slot and waits for the GPU.
513+// Exported targets are transfer sources only when hdrReadback is set; it
514+// errors when there is no HDR frame to read, every slot is leased or the
515+// copy fails. read gets the words in the slot's mapped memory, valid only
516+// during the call: copy what must outlive it.
517+func (r *Renderer) readHDRTarget(read func(words []uint32)) error {
518+ switch {
519+ case r.device == 0 || r.dd == nil:
520+ return errors.New("renderer closed")
521+ case r.hdrNits == 0 || !r.hdrReadback:
522+ return errors.New("HDR readback not enabled")
523+ case r.lastHDRTarget < 0 || r.lastHDRTarget >= len(r.targets):
524+ return errors.New("no exported HDR target")
525+ case r.last != &r.hdrOwn:
526+ return errors.New("no HDR frame rendered")
527+ case r.submitted != r.frame:
528+ return errors.New("last frame not submitted")
529+ }
530+ if err := r.waitFrame(r.submitted); err != nil {
531+ return err
532+ }
533+ if err := r.createCaptureSlots(); err != nil {
534+ return err
535+ }
536+ slot, err := r.freeCapture()
537+ if err != nil {
538+ return err
539+ }
540+ if slot == nil {
541+ return ports.ErrCaptureBusy
542+ }
543+ target := r.targets[r.lastHDRTarget]
544+ err = r.oneShot(func(cmd vk.CommandBuffer) {
545+ d := r.dd
546+ // Acquire from the display, copy, release back.
547+ b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: vk.ImageLayoutGeneral, NewLayout: vk.ImageLayoutTransferSrcOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: target.image, SubresourceRange: colorRange}
548+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
549+ region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
550+ d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, slot.buffer, 1, &region)
551+ hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: slot.buffer, Size: wholeSize}
552+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil)
553+ b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0
554+ b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral
555+ b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
556+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
557+ })
558+ if err != nil {
559+ return err
560+ }
561+ read(unsafe.Slice((*uint32)(slot.mapped), r.width*r.height))
562+ return nil
563+}
564+
511565 // recordCaptureCopy copies image t (in layout) into the slot's buffer.
512566 // Exported targets are borrowed from the display without a layout
513567 // change: the display may be reading them at the same time.
@@ -508,6 +508,60 @@ func (r *Renderer) debugCapture() (*captureFrame, error) {
508 return f, nil508 return f, nil
509 }509 }
510 510
511+// readHDRTarget copies the exported PQ target last rendered (XRGB2101010
512+// words, one per pixel) through a free capture slot and waits for the GPU.
513+// Exported targets are transfer sources only when hdrReadback is set; it
514+// errors when there is no HDR frame to read, every slot is leased or the
515+// copy fails. read gets the words in the slot's mapped memory, valid only
516+// during the call: copy what must outlive it.
517+func (r *Renderer) readHDRTarget(read func(words []uint32)) error {
518+ switch {
519+ case r.device == 0 || r.dd == nil:
520+ return errors.New("renderer closed")
521+ case r.hdrNits == 0 || !r.hdrReadback:
522+ return errors.New("HDR readback not enabled")
523+ case r.lastHDRTarget < 0 || r.lastHDRTarget >= len(r.targets):
524+ return errors.New("no exported HDR target")
525+ case r.last != &r.hdrOwn:
526+ return errors.New("no HDR frame rendered")
527+ case r.submitted != r.frame:
528+ return errors.New("last frame not submitted")
529+ }
530+ if err := r.waitFrame(r.submitted); err != nil {
531+ return err
532+ }
533+ if err := r.createCaptureSlots(); err != nil {
534+ return err
535+ }
536+ slot, err := r.freeCapture()
537+ if err != nil {
538+ return err
539+ }
540+ if slot == nil {
541+ return ports.ErrCaptureBusy
542+ }
543+ target := r.targets[r.lastHDRTarget]
544+ err = r.oneShot(func(cmd vk.CommandBuffer) {
545+ d := r.dd
546+ // Acquire from the display, copy, release back.
547+ b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: vk.ImageLayoutGeneral, NewLayout: vk.ImageLayoutTransferSrcOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: target.image, SubresourceRange: colorRange}
548+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
549+ region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
550+ d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, slot.buffer, 1, &region)
551+ hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: slot.buffer, Size: wholeSize}
552+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil)
553+ b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0
554+ b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral
555+ b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
556+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
557+ })
558+ if err != nil {
559+ return err
560+ }
561+ read(unsafe.Slice((*uint32)(slot.mapped), r.width*r.height))
562+ return nil
563+}
564+
511 // recordCaptureCopy copies image t (in layout) into the slot's buffer.565 // recordCaptureCopy copies image t (in layout) into the slot's buffer.
512 // Exported targets are borrowed from the display without a layout566 // Exported targets are borrowed from the display without a layout
513 // change: the display may be reading them at the same time.567 // change: the display may be reading them at the same time.
M internal/adapters/vulkan/frame.go +3 -0
@@ -310,6 +310,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
310310 tg.hold(s, dmg)
311311 r.damageDrawn = oldWindows
312312 r.last = tg
313+ if hdr {
314+ r.lastHDRTarget = r.current
315+ }
313316 r.dropUnused()
314317 r.dropShm()
315318 if !r.syncFD {
@@ -310,6 +310,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
310 tg.hold(s, dmg)310 tg.hold(s, dmg)
311 r.damageDrawn = oldWindows311 r.damageDrawn = oldWindows
312 r.last = tg312 r.last = tg
313+ if hdr {
314+ r.lastHDRTarget = r.current
315+ }
313 r.dropUnused()316 r.dropUnused()
314 r.dropShm()317 r.dropShm()
315 if !r.syncFD {318 if !r.syncFD {
M internal/adapters/vulkan/hdr_composition_test.go +119 -32
@@ -6,7 +6,6 @@ import (
66 "math"
77 "slices"
88 "testing"
9- "unsafe"
109
1110 "github.com/bnema/neferwl/internal/ports"
1211 vk "github.com/bnema/purego-vulkan/vulkan"
@@ -20,7 +19,7 @@ func hdrTestRenderer(t *testing.T) *Renderer {
2019 }
2120 t.Cleanup(r.Close)
2221 r.SetHDR(203)
23- r.hdrReadback = true
22+ r.SetHDRReadback(true)
2423 if r.physical == 0 {
2524 t.Skip("no exportable GPU")
2625 }
@@ -40,34 +39,11 @@ func hdrTestRenderer(t *testing.T) *Renderer {
4039 // not transfer-src), staging through a capture slot's buffer.
4140 func pqTargetWords(t *testing.T, r *Renderer) []uint32 {
4241 t.Helper()
43- if err := r.waitFrame(r.submitted); err != nil {
42+ var words []uint32
43+ if err := r.readHDRTarget(func(w []uint32) { words = slices.Clone(w) }); err != nil {
4444 t.Fatal(err)
4545 }
46- if err := r.createCaptureSlots(); err != nil {
47- t.Fatal(err)
48- }
49- slot := r.captures[0]
50- if slot.leased {
51- t.Fatal("capture slot 0 leased")
52- }
53- target := r.targets[0]
54- err := r.oneShot(func(cmd vk.CommandBuffer) {
55- d := r.dd
56- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: vk.ImageLayoutGeneral, NewLayout: vk.ImageLayoutTransferSrcOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: target.image, SubresourceRange: colorRange}
57- d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
58- region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
59- d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, slot.buffer, 1, &region)
60- hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: slot.buffer, Size: wholeSize}
61- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil)
62- b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0
63- b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral
64- b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
65- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
66- })
67- if err != nil {
68- t.Fatal(err)
69- }
70- return unsafe.Slice((*uint32)(slot.mapped), r.width*r.height)
46+ return words
7147 }
7248
7349 func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 {
@@ -76,13 +52,14 @@ func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 {
7652 return [3]float64{float64(pixel>>20&1023) / 1023, float64(pixel>>10&1023) / 1023, float64(pixel&1023) / 1023}
7753 }
7854
79-func TestHDRWindowedComposition(t *testing.T) {
80- r := hdrTestRenderer(t)
55+// hdrPQScene is a 64×16 XR30 udmabuf client holding 1000-nit neutral, red
56+// and blue BT.2020 thirds (x/20), drawn full-output.
57+func hdrPQScene(t *testing.T, r *Renderer) (ports.Scene, map[ports.WindowID]ports.SurfaceContent, [3][3]float64) {
58+ t.Helper()
8159 format := ports.DMABufFormat{Format: fourcc('X', 'R', '3', '0'), Modifier: 0}
8260 if !slices.Contains(r.DMABuf().Formats, format) {
8361 t.Skip("linear XR30 import unavailable")
8462 }
85- // 1000-nit neutral, red and blue BT.2020 client pixels.
8663 nits := [3][3]float64{{1000, 1000, 1000}, {1000, 0, 0}, {0, 0, 1000}}
8764 f := udmabuf(t, 64, 16, func(x, _ int) [4]byte {
8865 v := nits[min(x/20, 2)]
@@ -93,7 +70,117 @@ func TestHDRWindowedComposition(t *testing.T) {
9370 buf := &ports.DMABuf{ID: 2020, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
9471 c := ports.SurfaceContent{ID: 1, Seq: 1, Width: 64, Height: 16, LogicalW: 64, LogicalH: 16, Opaque: true, DMABuf: buf, Color: ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}}
9572 scene := ports.Scene{Seq: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
96- contents := map[ports.WindowID]ports.SurfaceContent{1: c}
73+ return scene, map[ports.WindowID]ports.SurfaceContent{1: c}, nits
74+}
75+
76+// HDRPixels returns the exported PQ target's codes scaled to 16 bits, and
77+// nothing on an SDR renderer.
78+func TestHDRPixels(t *testing.T) {
79+ r := hdrTestRenderer(t)
80+ if r.HDRPixels() != nil {
81+ t.Fatal("HDRPixels before any frame")
82+ }
83+ scene, contents, nits := hdrPQScene(t, r)
84+ if err := render(r, scene, contents); err != nil {
85+ t.Fatal(err)
86+ }
87+ img := r.HDRPixels()
88+ if img == nil {
89+ t.Fatal("HDRPixels nil after an HDR frame")
90+ }
91+ if img.Bounds() != image.Rect(0, 0, 64, 16) {
92+ t.Fatalf("bounds %v", img.Bounds())
93+ }
94+ for i, v := range nits {
95+ x := i*20 + 5
96+ got := img.RGBA64At(x, 8)
97+ raw := hdrTargetAt(t, r, x, 8) // code/1023, the same words
98+ for ch, c := range []uint16{got.R, got.G, got.B} {
99+ code := uint32(math.Round(raw[ch] * 1023))
100+ if want := uint16(code<<6 | code>>4); c != want {
101+ t.Errorf("sample %d channel %d: %#04x want %#04x", i, ch, c, want)
102+ }
103+ if d := math.Abs(float64(c)/65535 - pqEncode(v[ch])); d > .012 {
104+ t.Errorf("sample %d channel %d: %.4f want %.4f", i, ch, float64(c)/65535, pqEncode(v[ch]))
105+ }
106+ }
107+ if got.A != 0xffff {
108+ t.Errorf("sample %d alpha %#04x", i, got.A)
109+ }
110+ }
111+ // An HDR virtual output without SetHDRReadback has no readable target.
112+ plain, err := New(64, 16)
113+ if err != nil {
114+ t.Skipf("Vulkan unavailable: %v", err)
115+ }
116+ defer plain.Close()
117+ plain.SetHDR(203)
118+ plain.SetVirtualOutput(true)
119+ bufs, err := plain.ExportTargets(1, nil)
120+ if err != nil {
121+ t.Skipf("no HDR target: %v", err)
122+ }
123+ defer bufs[0].Planes[0].File.Close()
124+ if err := render(plain, ports.Scene{Background: "#ffffff"}, nil); err != nil {
125+ t.Fatal(err)
126+ }
127+ if plain.HDRPixels() != nil {
128+ t.Fatal("HDRPixels without SetHDRReadback")
129+ }
130+ sdr, err := New(64, 4)
131+ if err != nil {
132+ t.Skipf("Vulkan unavailable: %v", err)
133+ }
134+ defer sdr.Close()
135+ if err := render(sdr, ports.Scene{Background: "#ffffff"}, nil); err != nil {
136+ t.Fatal(err)
137+ }
138+ if sdr.HDRPixels() != nil {
139+ t.Fatal("HDRPixels on an SDR renderer")
140+ }
141+}
142+
143+// HDRPixels reads the target the last HDR frame drew into, not the one
144+// selected for the next frame, and never a target exported after it.
145+func TestHDRPixelsReadsRenderedTarget(t *testing.T) {
146+ r := hdrTestRenderer(t)
147+ scene, contents, nits := hdrPQScene(t, r)
148+ if err := render(r, scene, contents); err != nil {
149+ t.Fatal(err)
150+ }
151+ // New targets replace the one the frame drew into: nothing to read.
152+ bufs, err := r.ExportTargets(2, r.hdrMods)
153+ if err != nil {
154+ t.Skipf("no two HDR targets: %v", err)
155+ }
156+ for _, b := range bufs {
157+ defer b.Planes[0].File.Close()
158+ }
159+ if r.HDRPixels() != nil {
160+ t.Fatal("HDRPixels from targets no frame drew into")
161+ }
162+ r.UseTarget(0)
163+ if err := render(r, scene, contents); err != nil {
164+ t.Fatal(err)
165+ }
166+ r.UseTarget(1)
167+ img := r.HDRPixels()
168+ if img == nil {
169+ t.Fatal("HDRPixels nil after an HDR frame")
170+ }
171+ for i, v := range nits {
172+ got := img.RGBA64At(i*20+5, 8)
173+ for ch, c := range []uint16{got.R, got.G, got.B} {
174+ if d := math.Abs(float64(c)/65535 - pqEncode(v[ch])); d > .012 {
175+ t.Errorf("sample %d channel %d: %.4f want %.4f", i, ch, float64(c)/65535, pqEncode(v[ch]))
176+ }
177+ }
178+ }
179+}
180+
181+func TestHDRWindowedComposition(t *testing.T) {
182+ r := hdrTestRenderer(t)
183+ scene, contents, nits := hdrPQScene(t, r)
97184 if err := render(r, scene, contents); err != nil {
98185 t.Fatal(err)
99186 }
@@ -6,7 +6,6 @@ import (
6 "math"6 "math"
7 "slices"7 "slices"
8 "testing"8 "testing"
9- "unsafe"
10 9
11 "github.com/bnema/neferwl/internal/ports"10 "github.com/bnema/neferwl/internal/ports"
12 vk "github.com/bnema/purego-vulkan/vulkan"11 vk "github.com/bnema/purego-vulkan/vulkan"
@@ -20,7 +19,7 @@ func hdrTestRenderer(t *testing.T) *Renderer {
20 }19 }
21 t.Cleanup(r.Close)20 t.Cleanup(r.Close)
22 r.SetHDR(203)21 r.SetHDR(203)
23- r.hdrReadback = true22+ r.SetHDRReadback(true)
24 if r.physical == 0 {23 if r.physical == 0 {
25 t.Skip("no exportable GPU")24 t.Skip("no exportable GPU")
26 }25 }
@@ -40,34 +39,11 @@ func hdrTestRenderer(t *testing.T) *Renderer {
40 // not transfer-src), staging through a capture slot's buffer.39 // not transfer-src), staging through a capture slot's buffer.
41 func pqTargetWords(t *testing.T, r *Renderer) []uint32 {40 func pqTargetWords(t *testing.T, r *Renderer) []uint32 {
42 t.Helper()41 t.Helper()
43- if err := r.waitFrame(r.submitted); err != nil {42+ var words []uint32
43+ if err := r.readHDRTarget(func(w []uint32) { words = slices.Clone(w) }); err != nil {
44 t.Fatal(err)44 t.Fatal(err)
45 }45 }
46- if err := r.createCaptureSlots(); err != nil {46+ return words
47- t.Fatal(err)
48- }
49- slot := r.captures[0]
50- if slot.leased {
51- t.Fatal("capture slot 0 leased")
52- }
53- target := r.targets[0]
54- err := r.oneShot(func(cmd vk.CommandBuffer) {
55- d := r.dd
56- b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: vk.ImageLayoutGeneral, NewLayout: vk.ImageLayoutTransferSrcOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: target.image, SubresourceRange: colorRange}
57- d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
58- region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
59- d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, slot.buffer, 1, &region)
60- hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: slot.buffer, Size: wholeSize}
61- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil)
62- b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0
63- b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral
64- b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
65- d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
66- })
67- if err != nil {
68- t.Fatal(err)
69- }
70- return unsafe.Slice((*uint32)(slot.mapped), r.width*r.height)
71 }47 }
72 48
73 func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 {49 func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 {
@@ -76,13 +52,14 @@ func hdrTargetAt(t *testing.T, r *Renderer, x, y int) [3]float64 {
76 return [3]float64{float64(pixel>>20&1023) / 1023, float64(pixel>>10&1023) / 1023, float64(pixel&1023) / 1023}52 return [3]float64{float64(pixel>>20&1023) / 1023, float64(pixel>>10&1023) / 1023, float64(pixel&1023) / 1023}
77 }53 }
78 54
79-func TestHDRWindowedComposition(t *testing.T) {55+// hdrPQScene is a 64×16 XR30 udmabuf client holding 1000-nit neutral, red
80- r := hdrTestRenderer(t)56+// and blue BT.2020 thirds (x/20), drawn full-output.
57+func hdrPQScene(t *testing.T, r *Renderer) (ports.Scene, map[ports.WindowID]ports.SurfaceContent, [3][3]float64) {
58+ t.Helper()
81 format := ports.DMABufFormat{Format: fourcc('X', 'R', '3', '0'), Modifier: 0}59 format := ports.DMABufFormat{Format: fourcc('X', 'R', '3', '0'), Modifier: 0}
82 if !slices.Contains(r.DMABuf().Formats, format) {60 if !slices.Contains(r.DMABuf().Formats, format) {
83 t.Skip("linear XR30 import unavailable")61 t.Skip("linear XR30 import unavailable")
84 }62 }
85- // 1000-nit neutral, red and blue BT.2020 client pixels.
86 nits := [3][3]float64{{1000, 1000, 1000}, {1000, 0, 0}, {0, 0, 1000}}63 nits := [3][3]float64{{1000, 1000, 1000}, {1000, 0, 0}, {0, 0, 1000}}
87 f := udmabuf(t, 64, 16, func(x, _ int) [4]byte {64 f := udmabuf(t, 64, 16, func(x, _ int) [4]byte {
88 v := nits[min(x/20, 2)]65 v := nits[min(x/20, 2)]
@@ -93,7 +70,117 @@ func TestHDRWindowedComposition(t *testing.T) {
93 buf := &ports.DMABuf{ID: 2020, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}70 buf := &ports.DMABuf{ID: 2020, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
94 c := ports.SurfaceContent{ID: 1, Seq: 1, Width: 64, Height: 16, LogicalW: 64, LogicalH: 16, Opaque: true, DMABuf: buf, Color: ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}}71 c := ports.SurfaceContent{ID: 1, Seq: 1, Width: 64, Height: 16, LogicalW: 64, LogicalH: 16, Opaque: true, DMABuf: buf, Color: ports.SurfaceColor{TF: ports.ColorTFPQ, Primaries: ports.ColorPrimariesBT2020}}
95 scene := ports.Scene{Seq: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}72 scene := ports.Scene{Seq: 1, Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
96- contents := map[ports.WindowID]ports.SurfaceContent{1: c}73+ return scene, map[ports.WindowID]ports.SurfaceContent{1: c}, nits
74+}
75+
76+// HDRPixels returns the exported PQ target's codes scaled to 16 bits, and
77+// nothing on an SDR renderer.
78+func TestHDRPixels(t *testing.T) {
79+ r := hdrTestRenderer(t)
80+ if r.HDRPixels() != nil {
81+ t.Fatal("HDRPixels before any frame")
82+ }
83+ scene, contents, nits := hdrPQScene(t, r)
84+ if err := render(r, scene, contents); err != nil {
85+ t.Fatal(err)
86+ }
87+ img := r.HDRPixels()
88+ if img == nil {
89+ t.Fatal("HDRPixels nil after an HDR frame")
90+ }
91+ if img.Bounds() != image.Rect(0, 0, 64, 16) {
92+ t.Fatalf("bounds %v", img.Bounds())
93+ }
94+ for i, v := range nits {
95+ x := i*20 + 5
96+ got := img.RGBA64At(x, 8)
97+ raw := hdrTargetAt(t, r, x, 8) // code/1023, the same words
98+ for ch, c := range []uint16{got.R, got.G, got.B} {
99+ code := uint32(math.Round(raw[ch] * 1023))
100+ if want := uint16(code<<6 | code>>4); c != want {
101+ t.Errorf("sample %d channel %d: %#04x want %#04x", i, ch, c, want)
102+ }
103+ if d := math.Abs(float64(c)/65535 - pqEncode(v[ch])); d > .012 {
104+ t.Errorf("sample %d channel %d: %.4f want %.4f", i, ch, float64(c)/65535, pqEncode(v[ch]))
105+ }
106+ }
107+ if got.A != 0xffff {
108+ t.Errorf("sample %d alpha %#04x", i, got.A)
109+ }
110+ }
111+ // An HDR virtual output without SetHDRReadback has no readable target.
112+ plain, err := New(64, 16)
113+ if err != nil {
114+ t.Skipf("Vulkan unavailable: %v", err)
115+ }
116+ defer plain.Close()
117+ plain.SetHDR(203)
118+ plain.SetVirtualOutput(true)
119+ bufs, err := plain.ExportTargets(1, nil)
120+ if err != nil {
121+ t.Skipf("no HDR target: %v", err)
122+ }
123+ defer bufs[0].Planes[0].File.Close()
124+ if err := render(plain, ports.Scene{Background: "#ffffff"}, nil); err != nil {
125+ t.Fatal(err)
126+ }
127+ if plain.HDRPixels() != nil {
128+ t.Fatal("HDRPixels without SetHDRReadback")
129+ }
130+ sdr, err := New(64, 4)
131+ if err != nil {
132+ t.Skipf("Vulkan unavailable: %v", err)
133+ }
134+ defer sdr.Close()
135+ if err := render(sdr, ports.Scene{Background: "#ffffff"}, nil); err != nil {
136+ t.Fatal(err)
137+ }
138+ if sdr.HDRPixels() != nil {
139+ t.Fatal("HDRPixels on an SDR renderer")
140+ }
141+}
142+
143+// HDRPixels reads the target the last HDR frame drew into, not the one
144+// selected for the next frame, and never a target exported after it.
145+func TestHDRPixelsReadsRenderedTarget(t *testing.T) {
146+ r := hdrTestRenderer(t)
147+ scene, contents, nits := hdrPQScene(t, r)
148+ if err := render(r, scene, contents); err != nil {
149+ t.Fatal(err)
150+ }
151+ // New targets replace the one the frame drew into: nothing to read.
152+ bufs, err := r.ExportTargets(2, r.hdrMods)
153+ if err != nil {
154+ t.Skipf("no two HDR targets: %v", err)
155+ }
156+ for _, b := range bufs {
157+ defer b.Planes[0].File.Close()
158+ }
159+ if r.HDRPixels() != nil {
160+ t.Fatal("HDRPixels from targets no frame drew into")
161+ }
162+ r.UseTarget(0)
163+ if err := render(r, scene, contents); err != nil {
164+ t.Fatal(err)
165+ }
166+ r.UseTarget(1)
167+ img := r.HDRPixels()
168+ if img == nil {
169+ t.Fatal("HDRPixels nil after an HDR frame")
170+ }
171+ for i, v := range nits {
172+ got := img.RGBA64At(i*20+5, 8)
173+ for ch, c := range []uint16{got.R, got.G, got.B} {
174+ if d := math.Abs(float64(c)/65535 - pqEncode(v[ch])); d > .012 {
175+ t.Errorf("sample %d channel %d: %.4f want %.4f", i, ch, float64(c)/65535, pqEncode(v[ch]))
176+ }
177+ }
178+ }
179+}
180+
181+func TestHDRWindowedComposition(t *testing.T) {
182+ r := hdrTestRenderer(t)
183+ scene, contents, nits := hdrPQScene(t, r)
97 if err := render(r, scene, contents); err != nil {184 if err := render(r, scene, contents); err != nil {
98 t.Fatal(err)185 t.Fatal(err)
99 }186 }
M internal/adapters/vulkan/renderer.go +39 -3
@@ -35,11 +35,18 @@ type Renderer struct {
3535 hdrNits float64
3636 hdr hdrPass
3737 hdrError error
38- // hdrReadback is used only by the GPU test to permit transfer from a 10-bit target.
38+ // hdrReadback makes exported HDR targets transfer sources, for GPU tests
39+ // and headless --screenshot-raw (SetHDRReadback); never DRM.
3940 hdrReadback bool
40- physical vk.PhysicalDevice
41+ // virtual: the output has no display (headless), so ExportTargets
42+ // accepts an empty modifier list for HDR (SetVirtualOutput).
43+ virtual bool
44+ physical vk.PhysicalDevice
4145 // One converted cursor image is kept; changes replace it in bounded memory.
4246 cursorCache cursorConversion
47+ // lastHDRTarget is the index in targets of the exported PQ target the
48+ // last HDR frame rendered into; -1 when the current targets hold none.
49+ lastHDRTarget int
4350 // last is the target of the last frame: what captures copy.
4451 last *target
4552 // captures are the readback slots (capture.go), allocated on the
@@ -111,7 +118,7 @@ func New(width, height int) (r *Renderer, err error) {
111118 if width <= 0 || height <= 0 || width > math.MaxUint32 || height > math.MaxUint32 || uint64(width)*uint64(height) > uint64(math.MaxInt/4) {
112119 return nil, fmt.Errorf("invalid renderer dimensions %d x %d", width, height)
113120 }
114- r = &Renderer{width: width, height: height, imports: map[uint64]*imported{}, pools: map[uint64]*mapping{}, shm: map[shmKey]*shmSurface{}}
121+ r = &Renderer{width: width, height: height, lastHDRTarget: -1, imports: map[uint64]*imported{}, pools: map[uint64]*mapping{}, shm: map[shmKey]*shmSurface{}}
115122 defer func() {
116123 if err != nil {
117124 r.Close()
@@ -357,6 +364,35 @@ func (r *Renderer) Pixels() *image.RGBA {
357364 return out
358365 }
359366
367+// SetHDRReadback makes the exported HDR targets transfer sources so that
368+// HDRPixels can read them (they are not in production: the DRM path never
369+// sets it). Call before ExportTargets. Not on the port.
370+func (r *Renderer) SetHDRReadback(on bool) { r.hdrReadback = on }
371+
372+// HDRPixels reads the last HDR frame's XRGB2101010 target back, each 10-bit
373+// code scaled to 16 bits (c<<6 | c>>4), alpha opaque. It waits for the GPU:
374+// debug only. Nil when the output is SDR, the target is not readable (no
375+// SetHDRReadback), no HDR frame was rendered, every slot is leased or the
376+// copy failed.
377+func (r *Renderer) HDRPixels() *image.RGBA64 {
378+ var out *image.RGBA64
379+ err := r.readHDRTarget(func(words []uint32) {
380+ out = image.NewRGBA64(image.Rect(0, 0, r.width, r.height))
381+ for i, w := range words {
382+ p := out.Pix[i*8 : i*8+8]
383+ for ch, c := range [3]uint32{w >> 20 & 1023, w >> 10 & 1023, w & 1023} {
384+ v := uint16(c<<6 | c>>4)
385+ p[ch*2], p[ch*2+1] = byte(v>>8), byte(v)
386+ }
387+ p[6], p[7] = 0xff, 0xff
388+ }
389+ })
390+ if err != nil {
391+ return nil
392+ }
393+ return out
394+}
395+
360396 // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE.
361397 const (
362398 queueFamilyIgnored = ^uint32(0)
@@ -35,11 +35,18 @@ type Renderer struct {
35 hdrNits float6435 hdrNits float64
36 hdr hdrPass36 hdr hdrPass
37 hdrError error37 hdrError error
38- // hdrReadback is used only by the GPU test to permit transfer from a 10-bit target.38+ // hdrReadback makes exported HDR targets transfer sources, for GPU tests
39+ // and headless --screenshot-raw (SetHDRReadback); never DRM.
39 hdrReadback bool40 hdrReadback bool
40- physical vk.PhysicalDevice41+ // virtual: the output has no display (headless), so ExportTargets
42+ // accepts an empty modifier list for HDR (SetVirtualOutput).
43+ virtual bool
44+ physical vk.PhysicalDevice
41 // One converted cursor image is kept; changes replace it in bounded memory.45 // One converted cursor image is kept; changes replace it in bounded memory.
42 cursorCache cursorConversion46 cursorCache cursorConversion
47+ // lastHDRTarget is the index in targets of the exported PQ target the
48+ // last HDR frame rendered into; -1 when the current targets hold none.
49+ lastHDRTarget int
43 // last is the target of the last frame: what captures copy.50 // last is the target of the last frame: what captures copy.
44 last *target51 last *target
45 // captures are the readback slots (capture.go), allocated on the52 // captures are the readback slots (capture.go), allocated on the
@@ -111,7 +118,7 @@ func New(width, height int) (r *Renderer, err error) {
111 if width <= 0 || height <= 0 || width > math.MaxUint32 || height > math.MaxUint32 || uint64(width)*uint64(height) > uint64(math.MaxInt/4) {118 if width <= 0 || height <= 0 || width > math.MaxUint32 || height > math.MaxUint32 || uint64(width)*uint64(height) > uint64(math.MaxInt/4) {
112 return nil, fmt.Errorf("invalid renderer dimensions %d x %d", width, height)119 return nil, fmt.Errorf("invalid renderer dimensions %d x %d", width, height)
113 }120 }
114- r = &Renderer{width: width, height: height, imports: map[uint64]*imported{}, pools: map[uint64]*mapping{}, shm: map[shmKey]*shmSurface{}}121+ r = &Renderer{width: width, height: height, lastHDRTarget: -1, imports: map[uint64]*imported{}, pools: map[uint64]*mapping{}, shm: map[shmKey]*shmSurface{}}
115 defer func() {122 defer func() {
116 if err != nil {123 if err != nil {
117 r.Close()124 r.Close()
@@ -357,6 +364,35 @@ func (r *Renderer) Pixels() *image.RGBA {
357 return out364 return out
358 }365 }
359 366
367+// SetHDRReadback makes the exported HDR targets transfer sources so that
368+// HDRPixels can read them (they are not in production: the DRM path never
369+// sets it). Call before ExportTargets. Not on the port.
370+func (r *Renderer) SetHDRReadback(on bool) { r.hdrReadback = on }
371+
372+// HDRPixels reads the last HDR frame's XRGB2101010 target back, each 10-bit
373+// code scaled to 16 bits (c<<6 | c>>4), alpha opaque. It waits for the GPU:
374+// debug only. Nil when the output is SDR, the target is not readable (no
375+// SetHDRReadback), no HDR frame was rendered, every slot is leased or the
376+// copy failed.
377+func (r *Renderer) HDRPixels() *image.RGBA64 {
378+ var out *image.RGBA64
379+ err := r.readHDRTarget(func(words []uint32) {
380+ out = image.NewRGBA64(image.Rect(0, 0, r.width, r.height))
381+ for i, w := range words {
382+ p := out.Pix[i*8 : i*8+8]
383+ for ch, c := range [3]uint32{w >> 20 & 1023, w >> 10 & 1023, w & 1023} {
384+ v := uint16(c<<6 | c>>4)
385+ p[ch*2], p[ch*2+1] = byte(v>>8), byte(v)
386+ }
387+ p[6], p[7] = 0xff, 0xff
388+ }
389+ })
390+ if err != nil {
391+ return nil
392+ }
393+ return out
394+}
395+
360 // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE.396 // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE.
361 const (397 const (
362 queueFamilyIgnored = ^uint32(0)398 queueFamilyIgnored = ^uint32(0)
M internal/adapters/vulkan/targets.go +17 -5
@@ -47,7 +47,8 @@ const fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24
4747 func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {
4848 r.dropTargets()
4949 if n > 0 && r.hdrNits > 0 && r.hdrReadback && r.physical != 0 {
50- // Test-only transfer-source targets have different format requirements.
50+ // Readback targets (GPU tests, headless --screenshot-raw) are also
51+ // transfer sources, which changes the format requirements.
5152 r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32)
5253 }
5354 if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 {
@@ -85,6 +86,11 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err
8586 return out, nil
8687 }
8788
89+// SetVirtualOutput marks the output as having no display (headless): an
90+// empty display modifier list then accepts every exportable HDR modifier,
91+// as it does for SDR. DRM never sets it. Call before ExportTargets.
92+func (r *Renderer) SetVirtualOutput(on bool) { r.virtual = on }
93+
8894 // UseTarget selects the exported image the next frames draw into.
8995 func (r *Renderer) UseTarget(i int) {
9096 if i >= 0 && i < len(r.targets) {
@@ -101,7 +107,8 @@ func (r *Renderer) target() *target {
101107 }
102108
103109 // exportModifiers intersects the selected signal format modifiers with
104-// those supported by the display. HDR never assumes an unspecified list.
110+// those supported by the display. HDR never assumes an unspecified list,
111+// except for a virtual output (no display) or a test readback.
105112 func (r *Renderer) exportModifiers(display []uint64) []uint64 {
106113 var out []uint64
107114 available := r.renderMods
@@ -109,7 +116,7 @@ func (r *Renderer) exportModifiers(display []uint64) []uint64 {
109116 available = r.hdrMods
110117 }
111118 for _, m := range available {
112- if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback)) || slices.Contains(display, m) {
119+ if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback || r.virtual)) || slices.Contains(display, m) {
113120 out = append(out, m)
114121 }
115122 }
@@ -122,6 +129,9 @@ func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {
122129 r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32)
123130 }
124131
132+// probeModifiers lists the single-plane, exportable modifiers of format that
133+// support the target usage. HDR targets need transfer-src only when
134+// hdrReadback is set (GPU tests, headless --screenshot-raw; never DRM).
125135 func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 {
126136 list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}
127137 fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)}
@@ -139,7 +149,8 @@ func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format)
139149 need |= formatFeatureTransferSrc
140150 }
141151 var result []uint64
142- // SDR targets are composed and read back; HDR targets are color attachments.
152+ // SDR targets are composed and read back; HDR targets are color
153+ // attachments, and transfer sources only with hdrReadback.
143154 for _, m := range mods[:list.DrmFormatModifierCount] {
144155 if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, format, m.DrmFormatModifier) {
145156 result = append(result, m.DrmFormatModifier)
@@ -262,5 +273,6 @@ func (r *Renderer) dropTargets() {
262273 }
263274 r.freeTarget(t)
264275 }
265- r.targets, r.current = nil, 0
276+ // New targets hold no HDR frame until the next Render.
277+ r.targets, r.current, r.lastHDRTarget = nil, 0, -1
266278 }
@@ -47,7 +47,8 @@ const fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24
47 func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {47 func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {
48 r.dropTargets()48 r.dropTargets()
49 if n > 0 && r.hdrNits > 0 && r.hdrReadback && r.physical != 0 {49 if n > 0 && r.hdrNits > 0 && r.hdrReadback && r.physical != 0 {
50- // Test-only transfer-source targets have different format requirements.50+ // Readback targets (GPU tests, headless --screenshot-raw) are also
51+ // transfer sources, which changes the format requirements.
51 r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32)52 r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32)
52 }53 }
53 if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 {54 if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 {
@@ -85,6 +86,11 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err
85 return out, nil86 return out, nil
86 }87 }
87 88
89+// SetVirtualOutput marks the output as having no display (headless): an
90+// empty display modifier list then accepts every exportable HDR modifier,
91+// as it does for SDR. DRM never sets it. Call before ExportTargets.
92+func (r *Renderer) SetVirtualOutput(on bool) { r.virtual = on }
93+
88 // UseTarget selects the exported image the next frames draw into.94 // UseTarget selects the exported image the next frames draw into.
89 func (r *Renderer) UseTarget(i int) {95 func (r *Renderer) UseTarget(i int) {
90 if i >= 0 && i < len(r.targets) {96 if i >= 0 && i < len(r.targets) {
@@ -101,7 +107,8 @@ func (r *Renderer) target() *target {
101 }107 }
102 108
103 // exportModifiers intersects the selected signal format modifiers with109 // exportModifiers intersects the selected signal format modifiers with
104-// those supported by the display. HDR never assumes an unspecified list.110+// those supported by the display. HDR never assumes an unspecified list,
111+// except for a virtual output (no display) or a test readback.
105 func (r *Renderer) exportModifiers(display []uint64) []uint64 {112 func (r *Renderer) exportModifiers(display []uint64) []uint64 {
106 var out []uint64113 var out []uint64
107 available := r.renderMods114 available := r.renderMods
@@ -109,7 +116,7 @@ func (r *Renderer) exportModifiers(display []uint64) []uint64 {
109 available = r.hdrMods116 available = r.hdrMods
110 }117 }
111 for _, m := range available {118 for _, m := range available {
112- if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback)) || slices.Contains(display, m) {119+ if (len(display) == 0 && (r.hdrNits == 0 || r.hdrReadback || r.virtual)) || slices.Contains(display, m) {
113 out = append(out, m)120 out = append(out, m)
114 }121 }
115 }122 }
@@ -122,6 +129,9 @@ func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {
122 r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32)129 r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32)
123 }130 }
124 131
132+// probeModifiers lists the single-plane, exportable modifiers of format that
133+// support the target usage. HDR targets need transfer-src only when
134+// hdrReadback is set (GPU tests, headless --screenshot-raw; never DRM).
125 func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 {135 func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 {
126 list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}136 list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}
127 fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)}137 fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)}
@@ -139,7 +149,8 @@ func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format)
139 need |= formatFeatureTransferSrc149 need |= formatFeatureTransferSrc
140 }150 }
141 var result []uint64151 var result []uint64
142- // SDR targets are composed and read back; HDR targets are color attachments.152+ // SDR targets are composed and read back; HDR targets are color
153+ // attachments, and transfer sources only with hdrReadback.
143 for _, m := range mods[:list.DrmFormatModifierCount] {154 for _, m := range mods[:list.DrmFormatModifierCount] {
144 if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, format, m.DrmFormatModifier) {155 if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, format, m.DrmFormatModifier) {
145 result = append(result, m.DrmFormatModifier)156 result = append(result, m.DrmFormatModifier)
@@ -262,5 +273,6 @@ func (r *Renderer) dropTargets() {
262 }273 }
263 r.freeTarget(t)274 r.freeTarget(t)
264 }275 }
265- r.targets, r.current = nil, 0276+ // New targets hold no HDR frame until the next Render.
277+ r.targets, r.current, r.lastHDRTarget = nil, 0, -1
266 }278 }
M internal/adapters/vulkan/targets_test.go +30 -2
@@ -74,8 +74,8 @@ func TestHDRExportTarget(t *testing.T) {
7474 }
7575 defer r.Close()
7676 r.SetHDR(203)
77- // Only this GPU test requests transfer-src on an HDR target.
78- r.hdrReadback = true
77+ // GPU tests and headless --screenshot-raw (SetHDRReadback); never DRM.
78+ r.SetHDRReadback(true)
7979 if r.physical == 0 {
8080 t.Skip("no exportable GPU")
8181 }
@@ -127,6 +127,34 @@ func TestHDRExportTarget(t *testing.T) {
127127 }
128128 }
129129
130+// A virtual (headless) HDR output has no display to list modifiers: the
131+// export accepts every exportable one. A DRM output (no SetVirtualOutput)
132+// still refuses an unspecified list.
133+func TestVirtualHDRExportsWithoutDisplayList(t *testing.T) {
134+ r, err := New(64, 16)
135+ if err != nil {
136+ t.Skipf("Vulkan unavailable: %v", err)
137+ }
138+ defer r.Close()
139+ if r.physical == 0 || len(r.hdrMods) == 0 {
140+ t.Skip("no exportable HDR GPU")
141+ }
142+ r.SetHDR(203)
143+ if bufs, err := r.ExportTargets(1, nil); err == nil {
144+ bufs[0].Planes[0].File.Close()
145+ t.Fatal("HDR export without a display list succeeded on a non-virtual output")
146+ }
147+ r.SetVirtualOutput(true)
148+ bufs, err := r.ExportTargets(1, nil)
149+ if err != nil {
150+ t.Fatalf("virtual HDR export: %v", err)
151+ }
152+ defer bufs[0].Planes[0].File.Close()
153+ if bufs[0].Format != fourccXR30 {
154+ t.Fatalf("format: %#x", bufs[0].Format)
155+ }
156+}
157+
130158 // Test-only target readback: production HDR images do not have transfer-src.
131159 func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) {
132160 t.Helper()
@@ -74,8 +74,8 @@ func TestHDRExportTarget(t *testing.T) {
74 }74 }
75 defer r.Close()75 defer r.Close()
76 r.SetHDR(203)76 r.SetHDR(203)
77- // Only this GPU test requests transfer-src on an HDR target.77+ // GPU tests and headless --screenshot-raw (SetHDRReadback); never DRM.
78- r.hdrReadback = true78+ r.SetHDRReadback(true)
79 if r.physical == 0 {79 if r.physical == 0 {
80 t.Skip("no exportable GPU")80 t.Skip("no exportable GPU")
81 }81 }
@@ -127,6 +127,34 @@ func TestHDRExportTarget(t *testing.T) {
127 }127 }
128 }128 }
129 129
130+// A virtual (headless) HDR output has no display to list modifiers: the
131+// export accepts every exportable one. A DRM output (no SetVirtualOutput)
132+// still refuses an unspecified list.
133+func TestVirtualHDRExportsWithoutDisplayList(t *testing.T) {
134+ r, err := New(64, 16)
135+ if err != nil {
136+ t.Skipf("Vulkan unavailable: %v", err)
137+ }
138+ defer r.Close()
139+ if r.physical == 0 || len(r.hdrMods) == 0 {
140+ t.Skip("no exportable HDR GPU")
141+ }
142+ r.SetHDR(203)
143+ if bufs, err := r.ExportTargets(1, nil); err == nil {
144+ bufs[0].Planes[0].File.Close()
145+ t.Fatal("HDR export without a display list succeeded on a non-virtual output")
146+ }
147+ r.SetVirtualOutput(true)
148+ bufs, err := r.ExportTargets(1, nil)
149+ if err != nil {
150+ t.Fatalf("virtual HDR export: %v", err)
151+ }
152+ defer bufs[0].Planes[0].File.Close()
153+ if bufs[0].Format != fourccXR30 {
154+ t.Fatalf("format: %#x", bufs[0].Format)
155+ }
156+}
157+
130 // Test-only target readback: production HDR images do not have transfer-src.158 // Test-only target readback: production HDR images do not have transfer-src.
131 func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) {159 func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) {
132 t.Helper()160 t.Helper()
M internal/app/app.go +4 -1
@@ -49,6 +49,9 @@ type Options struct {
4949 // before any client starts, and withdraws them on exit.
5050 Session bool
5151 ScreenshotDir string
52+ // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a
53+ // virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR).
54+ ScreenshotRaw bool
5255 // Sizes are the headless outputs (width, height), left to right; empty
5356 // means one 1920x1080 output. With several outputs, screenshots go to
5457 // one subdirectory per output (HEADLESS-1, HEADLESS-2, ...).
@@ -306,7 +309,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
306309 })
307310 return
308311 }
309- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, apply, outputIO, curs, newRenderer, logging.For(ctx, "render"))
312+ done <- runHeadless(ctx, headlessOptions{sizes: sizes, shots: opts.ScreenshotDir, hdr: opts.HeadlessHDR, raw: opts.ScreenshotRaw}, apply, outputIO, curs, newRenderer, logging.For(ctx, "render"))
310313 }()
311314
312315 if hw != nil {
@@ -49,6 +49,9 @@ type Options struct {
49 // before any client starts, and withdraws them on exit.49 // before any client starts, and withdraws them on exit.
50 Session bool50 Session bool
51 ScreenshotDir string51 ScreenshotDir string
52+ // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a
53+ // virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR).
54+ ScreenshotRaw bool
52 // Sizes are the headless outputs (width, height), left to right; empty55 // Sizes are the headless outputs (width, height), left to right; empty
53 // means one 1920x1080 output. With several outputs, screenshots go to56 // means one 1920x1080 output. With several outputs, screenshots go to
54 // one subdirectory per output (HEADLESS-1, HEADLESS-2, ...).57 // one subdirectory per output (HEADLESS-1, HEADLESS-2, ...).
@@ -306,7 +309,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
306 })309 })
307 return310 return
308 }311 }
309- done <- runHeadless(ctx, sizes, opts.ScreenshotDir, opts.HeadlessHDR, apply, outputIO, curs, newRenderer, logging.For(ctx, "render"))312+ done <- runHeadless(ctx, headlessOptions{sizes: sizes, shots: opts.ScreenshotDir, hdr: opts.HeadlessHDR, raw: opts.ScreenshotRaw}, apply, outputIO, curs, newRenderer, logging.For(ctx, "render"))
310 }()313 }()
311 314
312 if hw != nil {315 if hw != nil {
A internal/app/colorcheck_test.go +365 -0
@@ -0,0 +1,365 @@
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`.
8+package app
9+
10+import (
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+
28+const (
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).
61+func 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.
93+func 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).
155+func 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+
171+func 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.
178+func 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.
202+func 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.
219+func 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.
251+func 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.
298+type 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.
307+func 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+}
@@ -0,0 +1,365 @@
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`.
8+package app
9+
10+import (
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+
28+const (
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).
61+func 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.
93+func 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).
155+func 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+
171+func 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.
178+func 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.
202+func 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.
219+func 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.
251+func 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.
298+type 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.
307+func 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+}
M internal/app/headless.go +42 -7
@@ -11,20 +11,36 @@ import (
1111 "github.com/bnema/zerowrap"
1212 )
1313
14+// headlessOptions are the settings of the virtual outputs.
15+type headlessOptions struct {
16+ sizes [][2]int
17+ shots string
18+ hdr bool
19+ raw bool // also write latest-pq.png (headless.Options.RawHDR)
20+}
21+
1422 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
1523 // With several outputs, screenshots go to a subdirectory per output. Modes
1624 // are fixed, so apply answers every configuration without backend work.
17-func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
25+//
26+// The raw PQ dump (o.raw) needs readable HDR targets, set on these
27+// renderers only: the DRM path never reads them back.
28+//
29+// Virtual outputs have no display to list modifiers, so each renderer is
30+// told it drives one (vulkan.Renderer.SetVirtualOutput); without it a real
31+// GPU refuses the HDR targets and the output falls back to SDR.
32+func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
33+ newRenderer = virtualRenderer(newRenderer, o.raw)
1834 set := newOutputSet(ctx, ch.captured)
1935 set.wireSecurity(ch)
2036 inventory := ports.OutputHeads{}
21- for i, size := range sizes {
37+ for i, size := range o.sizes {
2238 name := fmt.Sprintf("HEADLESS-%d", i+1)
2339 mode := ports.OutputMode{Width: size[0], Height: size[1], RefreshMilli: 60000, Preferred: true}
2440 inventory.Heads = append(inventory.Heads, ports.OutputHead{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1], RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true})
25- dir := shots
26- if dir != "" && len(sizes) > 1 {
27- dir = filepath.Join(shots, name)
41+ dir := o.shots
42+ if dir != "" && len(o.sizes) > 1 {
43+ dir = filepath.Join(o.shots, name)
2844 if err := os.MkdirAll(dir, 0o755); err != nil {
2945 // Outputs already started stop and release their captures.
3046 return joinErr(err, set.wait())
@@ -32,7 +48,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
3248 }
3349 cur := &headless.Cursor{}
3450 curs.set(name, cur)
35- opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured}
51+ opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured}
3652 started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {
3753 opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance
3854 return headless.Run(octx, opts, sc, cc, cu, cap)
@@ -52,7 +68,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
5268 }
5369 apply.heads(inventory)
5470 // The pointer starts centred on the first output, like libinput's.
55- curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2, false)
71+ curs.move("HEADLESS-1", float64(o.sizes[0][0])/2, float64(o.sizes[0][1])/2, false)
5672 for {
5773 configs, configNext := apply.configOut(ch.configured), apply.config
5874 replies, replyNext := apply.replyOut(ch.replies)
@@ -100,6 +116,25 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
100116 }
101117 }
102118
119+// virtualRenderer wraps a renderer factory for headless outputs. Vulkan is
120+// the only implementation of these settings; they are not on the port.
121+// readback makes the HDR targets readable (HDRPixels).
122+func virtualRenderer(newRenderer func(w, h int) (ports.Renderer, error), readback bool) func(w, h int) (ports.Renderer, error) {
123+ return func(w, h int) (ports.Renderer, error) {
124+ r, err := newRenderer(w, h)
125+ if err != nil {
126+ return nil, err
127+ }
128+ if v, ok := r.(interface{ SetVirtualOutput(bool) }); ok {
129+ v.SetVirtualOutput(true)
130+ }
131+ if v, ok := r.(interface{ SetHDRReadback(bool) }); ok && readback {
132+ v.SetHDRReadback(true)
133+ }
134+ return r, nil
135+ }
136+}
137+
103138 func joinErr(a, b error) error {
104139 if a != nil {
105140 return a
@@ -11,20 +11,36 @@ import (
11 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
12 )12 )
13 13
14+// headlessOptions are the settings of the virtual outputs.
15+type headlessOptions struct {
16+ sizes [][2]int
17+ shots string
18+ hdr bool
19+ raw bool // also write latest-pq.png (headless.Options.RawHDR)
20+}
21+
14 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...22 // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
15 // With several outputs, screenshots go to a subdirectory per output. Modes23 // With several outputs, screenshots go to a subdirectory per output. Modes
16 // are fixed, so apply answers every configuration without backend work.24 // are fixed, so apply answers every configuration without backend work.
17-func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {25+//
26+// The raw PQ dump (o.raw) needs readable HDR targets, set on these
27+// renderers only: the DRM path never reads them back.
28+//
29+// Virtual outputs have no display to list modifiers, so each renderer is
30+// told it drives one (vulkan.Renderer.SetVirtualOutput); without it a real
31+// GPU refuses the HDR targets and the output falls back to SDR.
32+func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
33+ newRenderer = virtualRenderer(newRenderer, o.raw)
18 set := newOutputSet(ctx, ch.captured)34 set := newOutputSet(ctx, ch.captured)
19 set.wireSecurity(ch)35 set.wireSecurity(ch)
20 inventory := ports.OutputHeads{}36 inventory := ports.OutputHeads{}
21- for i, size := range sizes {37+ for i, size := range o.sizes {
22 name := fmt.Sprintf("HEADLESS-%d", i+1)38 name := fmt.Sprintf("HEADLESS-%d", i+1)
23 mode := ports.OutputMode{Width: size[0], Height: size[1], RefreshMilli: 60000, Preferred: true}39 mode := ports.OutputMode{Width: size[0], Height: size[1], RefreshMilli: 60000, Preferred: true}
24 inventory.Heads = append(inventory.Heads, ports.OutputHead{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1], RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true})40 inventory.Heads = append(inventory.Heads, ports.OutputHead{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1], RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true})
25- dir := shots41+ dir := o.shots
26- if dir != "" && len(sizes) > 1 {42+ if dir != "" && len(o.sizes) > 1 {
27- dir = filepath.Join(shots, name)43+ dir = filepath.Join(o.shots, name)
28 if err := os.MkdirAll(dir, 0o755); err != nil {44 if err := os.MkdirAll(dir, 0o755); err != nil {
29 // Outputs already started stop and release their captures.45 // Outputs already started stop and release their captures.
30 return joinErr(err, set.wait())46 return joinErr(err, set.wait())
@@ -32,7 +48,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
32 }48 }
33 cur := &headless.Cursor{}49 cur := &headless.Cursor{}
34 curs.set(name, cur)50 curs.set(name, cur)
35- opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured}51+ opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured}
36 started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {52 started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {
37 opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance53 opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance
38 return headless.Run(octx, opts, sc, cc, cu, cap)54 return headless.Run(octx, opts, sc, cc, cu, cap)
@@ -52,7 +68,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
52 }68 }
53 apply.heads(inventory)69 apply.heads(inventory)
54 // The pointer starts centred on the first output, like libinput's.70 // The pointer starts centred on the first output, like libinput's.
55- curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2, false)71+ curs.move("HEADLESS-1", float64(o.sizes[0][0])/2, float64(o.sizes[0][1])/2, false)
56 for {72 for {
57 configs, configNext := apply.configOut(ch.configured), apply.config73 configs, configNext := apply.configOut(ch.configured), apply.config
58 replies, replyNext := apply.replyOut(ch.replies)74 replies, replyNext := apply.replyOut(ch.replies)
@@ -100,6 +116,25 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap
100 }116 }
101 }117 }
102 118
119+// virtualRenderer wraps a renderer factory for headless outputs. Vulkan is
120+// the only implementation of these settings; they are not on the port.
121+// readback makes the HDR targets readable (HDRPixels).
122+func virtualRenderer(newRenderer func(w, h int) (ports.Renderer, error), readback bool) func(w, h int) (ports.Renderer, error) {
123+ return func(w, h int) (ports.Renderer, error) {
124+ r, err := newRenderer(w, h)
125+ if err != nil {
126+ return nil, err
127+ }
128+ if v, ok := r.(interface{ SetVirtualOutput(bool) }); ok {
129+ v.SetVirtualOutput(true)
130+ }
131+ if v, ok := r.(interface{ SetHDRReadback(bool) }); ok && readback {
132+ v.SetHDRReadback(true)
133+ }
134+ return r, nil
135+ }
136+}
137+
103 func joinErr(a, b error) error {138 func joinErr(a, b error) error {
104 if a != nil {139 if a != nil {
105 return a140 return a
A internal/app/headless_test.go +47 -0
@@ -0,0 +1,47 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/adapters/vulkan"
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// The headless renderer factory tells a real renderer it drives a virtual
11+// output: without it ExportTargets(1, nil) refuses HDR targets on a real GPU
12+// and a headless output falls back to SDR. Skips without Vulkan or without a
13+// device that exports dmabufs (lavapipe in CI).
14+func TestVirtualRendererExportsHDRTargets(t *testing.T) {
15+ newRenderer := func(w, h int) (ports.Renderer, error) {
16+ r, err := vulkan.New(w, h)
17+ if err != nil {
18+ return nil, err
19+ }
20+ return r, nil
21+ }
22+ r, err := virtualRenderer(newRenderer, false)(64, 16)
23+ if err != nil {
24+ t.Skipf("Vulkan unavailable: %v", err)
25+ }
26+ defer r.Close()
27+ // A device that cannot export at all says nothing about the wiring.
28+ sdr, err := r.ExportTargets(1, nil)
29+ if err != nil {
30+ t.Skipf("no exportable target: %v", err)
31+ }
32+ for _, b := range sdr {
33+ for _, p := range b.Planes {
34+ _ = p.File.Close()
35+ }
36+ }
37+ r.SetHDR(203)
38+ bufs, err := r.ExportTargets(1, nil)
39+ if err != nil {
40+ t.Fatalf("virtual HDR export: %v", err)
41+ }
42+ for _, b := range bufs {
43+ for _, p := range b.Planes {
44+ _ = p.File.Close()
45+ }
46+ }
47+}
@@ -0,0 +1,47 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/adapters/vulkan"
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// The headless renderer factory tells a real renderer it drives a virtual
11+// output: without it ExportTargets(1, nil) refuses HDR targets on a real GPU
12+// and a headless output falls back to SDR. Skips without Vulkan or without a
13+// device that exports dmabufs (lavapipe in CI).
14+func TestVirtualRendererExportsHDRTargets(t *testing.T) {
15+ newRenderer := func(w, h int) (ports.Renderer, error) {
16+ r, err := vulkan.New(w, h)
17+ if err != nil {
18+ return nil, err
19+ }
20+ return r, nil
21+ }
22+ r, err := virtualRenderer(newRenderer, false)(64, 16)
23+ if err != nil {
24+ t.Skipf("Vulkan unavailable: %v", err)
25+ }
26+ defer r.Close()
27+ // A device that cannot export at all says nothing about the wiring.
28+ sdr, err := r.ExportTargets(1, nil)
29+ if err != nil {
30+ t.Skipf("no exportable target: %v", err)
31+ }
32+ for _, b := range sdr {
33+ for _, p := range b.Planes {
34+ _ = p.File.Close()
35+ }
36+ }
37+ r.SetHDR(203)
38+ bufs, err := r.ExportTargets(1, nil)
39+ if err != nil {
40+ t.Fatalf("virtual HDR export: %v", err)
41+ }
42+ for _, b := range bufs {
43+ for _, p := range b.Planes {
44+ _ = p.File.Close()
45+ }
46+ }
47+}
M internal/mocks/ports/devices_mock.go +46 -0
@@ -452,6 +452,52 @@ func (_c *MockRenderer_ExportTargets_Call) RunAndReturn(run func(n int, modifier
452452 return _c
453453 }
454454
455+// HDRPixels provides a mock function for the type MockRenderer
456+func (_mock *MockRenderer) HDRPixels() *image.RGBA64 {
457+ ret := _mock.Called()
458+
459+ if len(ret) == 0 {
460+ panic("no return value specified for HDRPixels")
461+ }
462+
463+ var r0 *image.RGBA64
464+ if returnFunc, ok := ret.Get(0).(func() *image.RGBA64); ok {
465+ r0 = returnFunc()
466+ } else {
467+ if ret.Get(0) != nil {
468+ r0 = ret.Get(0).(*image.RGBA64)
469+ }
470+ }
471+ return r0
472+}
473+
474+// MockRenderer_HDRPixels_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HDRPixels'
475+type MockRenderer_HDRPixels_Call struct {
476+ *mock.Call
477+}
478+
479+// HDRPixels is a helper method to define mock.On call
480+func (_e *MockRenderer_Expecter) HDRPixels() *MockRenderer_HDRPixels_Call {
481+ return &MockRenderer_HDRPixels_Call{Call: _e.mock.On("HDRPixels")}
482+}
483+
484+func (_c *MockRenderer_HDRPixels_Call) Run(run func()) *MockRenderer_HDRPixels_Call {
485+ _c.Call.Run(func(args mock.Arguments) {
486+ run()
487+ })
488+ return _c
489+}
490+
491+func (_c *MockRenderer_HDRPixels_Call) Return(rGBA64 *image.RGBA64) *MockRenderer_HDRPixels_Call {
492+ _c.Call.Return(rGBA64)
493+ return _c
494+}
495+
496+func (_c *MockRenderer_HDRPixels_Call) RunAndReturn(run func() *image.RGBA64) *MockRenderer_HDRPixels_Call {
497+ _c.Call.Return(run)
498+ return _c
499+}
500+
455501 // Pixels provides a mock function for the type MockRenderer
456502 func (_mock *MockRenderer) Pixels() *image.RGBA {
457503 ret := _mock.Called()
@@ -452,6 +452,52 @@ func (_c *MockRenderer_ExportTargets_Call) RunAndReturn(run func(n int, modifier
452 return _c452 return _c
453 }453 }
454 454
455+// HDRPixels provides a mock function for the type MockRenderer
456+func (_mock *MockRenderer) HDRPixels() *image.RGBA64 {
457+ ret := _mock.Called()
458+
459+ if len(ret) == 0 {
460+ panic("no return value specified for HDRPixels")
461+ }
462+
463+ var r0 *image.RGBA64
464+ if returnFunc, ok := ret.Get(0).(func() *image.RGBA64); ok {
465+ r0 = returnFunc()
466+ } else {
467+ if ret.Get(0) != nil {
468+ r0 = ret.Get(0).(*image.RGBA64)
469+ }
470+ }
471+ return r0
472+}
473+
474+// MockRenderer_HDRPixels_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HDRPixels'
475+type MockRenderer_HDRPixels_Call struct {
476+ *mock.Call
477+}
478+
479+// HDRPixels is a helper method to define mock.On call
480+func (_e *MockRenderer_Expecter) HDRPixels() *MockRenderer_HDRPixels_Call {
481+ return &MockRenderer_HDRPixels_Call{Call: _e.mock.On("HDRPixels")}
482+}
483+
484+func (_c *MockRenderer_HDRPixels_Call) Run(run func()) *MockRenderer_HDRPixels_Call {
485+ _c.Call.Run(func(args mock.Arguments) {
486+ run()
487+ })
488+ return _c
489+}
490+
491+func (_c *MockRenderer_HDRPixels_Call) Return(rGBA64 *image.RGBA64) *MockRenderer_HDRPixels_Call {
492+ _c.Call.Return(rGBA64)
493+ return _c
494+}
495+
496+func (_c *MockRenderer_HDRPixels_Call) RunAndReturn(run func() *image.RGBA64) *MockRenderer_HDRPixels_Call {
497+ _c.Call.Return(run)
498+ return _c
499+}
500+
455 // Pixels provides a mock function for the type MockRenderer501 // Pixels provides a mock function for the type MockRenderer
456 func (_mock *MockRenderer) Pixels() *image.RGBA {502 func (_mock *MockRenderer) Pixels() *image.RGBA {
457 ret := _mock.Called()503 ret := _mock.Called()
M internal/ports/devices.go +5 -0
@@ -62,6 +62,11 @@ type Renderer interface {
6262 // waits for the GPU: debug only. Nil when it cannot (every capture
6363 // slot leased, or the copy failed).
6464 Pixels() *image.RGBA
65+ // HDRPixels reads the last HDR frame's XRGB2101010 target back, each
66+ // 10-bit code scaled to 16 bits (c<<6 | c>>4), A = 0xffff. It waits for
67+ // the GPU: debug only. Nil when the output is SDR, the target is not
68+ // readable, a slot is busy or the copy failed.
69+ HDRPixels() *image.RGBA64
6570 // BeginCapture submits a GPU copy of the last rendered frame into a
6671 // free slot and returns without waiting. ErrCaptureBusy when every
6772 // slot is leased; another error when the copy cannot be tracked (no
@@ -62,6 +62,11 @@ type Renderer interface {
62 // waits for the GPU: debug only. Nil when it cannot (every capture62 // waits for the GPU: debug only. Nil when it cannot (every capture
63 // slot leased, or the copy failed).63 // slot leased, or the copy failed).
64 Pixels() *image.RGBA64 Pixels() *image.RGBA
65+ // HDRPixels reads the last HDR frame's XRGB2101010 target back, each
66+ // 10-bit code scaled to 16 bits (c<<6 | c>>4), A = 0xffff. It waits for
67+ // the GPU: debug only. Nil when the output is SDR, the target is not
68+ // readable, a slot is busy or the copy failed.
69+ HDRPixels() *image.RGBA64
65 // BeginCapture submits a GPU copy of the last rendered frame into a70 // BeginCapture submits a GPU copy of the last rendered frame into a
66 // free slot and returns without waiting. ErrCaptureBusy when every71 // free slot and returns without waiting. ErrCaptureBusy when every
67 // slot is leased; another error when the copy cannot be tracked (no72 // slot is leased; another error when the copy cannot be tracked (no

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