detached 368d496

feat(hdr): output HDR10 on capable displays (#6)

* build(deps): bump purego-libwayland to v0.4.0

* feat(config): add opt-in HDR output settings

* feat(drm): detect HDR output capabilities from EDID and KMS

* feat(hdr): prepare metadata transfer math and output reload

* feat(vulkan): render SDR scenes into HDR10 scanout targets

* feat(drm): activate HDR10 modesets with SDR fallback

* fix(vulkan): preserve HDR image layouts and bound cursor conversion cache

* fix(drm): retain HDR metadata blobs and validate static type 1

* feat(drm): log HDR10 activation and restoration

Log HDR10 on (with SDR brightness and display luminance) and off at close; a failed restore after HDR is a warning, since the display may stay in HDR. Drop the duplicate component field on HDR logs: the DRM logger already carries it.

* fix(drm): warn on a failed restore while HDR metadata may still be shown

After a failed HDR setup and a failed SDR fallback, hdrOn is false but the HDR modeset can remain on screen. Close now treats a live metadata blob as HDR shown, so a failed restore is still a warning.

* fix(drm): read connector Colorspace enums with a values buffer

DRM_IOCTL_MODE_GETPROPERTY copies an enum's values when count_values is set, so reading the enum names with a nil values pointer failed with EFAULT. Colorspace then looked unsupported and every display was reported not HDR capable.
commit
368d49677d9d13b08b1fbad0b9f0174f99d25fe6
author
bnema · · 2026-09-27 05:12 UTC
committer
GitHub · · 2026-09-27 05:12 UTC
parent
471cea3
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
36 files · +1445 -76
M docs/config.md +2 -0
@@ -34,6 +34,8 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
3434 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
3535 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |
3636 | `output.<name>.primary` | `off` | Gets focus and pointer at startup |
37+| `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) |
38+| `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |
3739 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |
3840 | `render.tearing` / `render.vrr` | `on` / `on` | For games in direct scanout |
3941 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
@@ -34,6 +34,8 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`).
34 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |34 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
35 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |35 | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` |
36 | `output.<name>.primary` | `off` | Gets focus and pointer at startup |36 | `output.<name>.primary` | `off` | Gets focus and pointer at startup |
37+| `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) |
38+| `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) |
37 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |39 | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display |
38 | `render.tearing` / `render.vrr` | `on` / `on` | For games in direct scanout |40 | `render.tearing` / `render.vrr` | `on` / `on` | For games in direct scanout |
39 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |41 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
M internal/adapters/config/config.go +24 -2
@@ -209,7 +209,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
209209 return &c.Outputs[i]
210210 }
211211 outputs[name] = len(c.Outputs)
212- c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true})
212+ c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness})
213213 return &c.Outputs[len(c.Outputs)-1]
214214 }
215215 if base, ok := strings.CutSuffix(name, ".primary"); ok {
@@ -223,6 +223,28 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
223223 entry().Primary = b
224224 continue
225225 }
226+ if base, ok := strings.CutSuffix(name, ".hdr"); ok {
227+ name = base
228+ b, err := onOff(value)
229+ if err != nil {
230+ warn("%s: %v", key, err)
231+ continue
232+ }
233+ override()
234+ entry().HDR = b
235+ continue
236+ }
237+ if base, ok := strings.CutSuffix(name, ".sdr-brightness"); ok {
238+ name = base
239+ nits, err := strconv.Atoi(value)
240+ if err != nil || nits < 80 || nits > 1000 {
241+ warn("%s: must be between 80 and 1000", key)
242+ continue
243+ }
244+ override()
245+ entry().SDRBrightness = nits
246+ continue
247+ }
226248 if base, ok := strings.CutSuffix(name, ".scale"); ok {
227249 name = base
228250 s, err := parseScale(value)
@@ -245,7 +267,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
245267 }
246268 override()
247269 e := entry()
248- o.Scale, o.Primary = e.Scale, e.Primary
270+ o.Scale, o.Primary, o.HDR, o.SDRBrightness = e.Scale, e.Primary, e.HDR, e.SDRBrightness
249271 *e = o
250272 continue
251273 }
@@ -209,7 +209,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
209 return &c.Outputs[i]209 return &c.Outputs[i]
210 }210 }
211 outputs[name] = len(c.Outputs)211 outputs[name] = len(c.Outputs)
212- c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true})212+ c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness})
213 return &c.Outputs[len(c.Outputs)-1]213 return &c.Outputs[len(c.Outputs)-1]
214 }214 }
215 if base, ok := strings.CutSuffix(name, ".primary"); ok {215 if base, ok := strings.CutSuffix(name, ".primary"); ok {
@@ -223,6 +223,28 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
223 entry().Primary = b223 entry().Primary = b
224 continue224 continue
225 }225 }
226+ if base, ok := strings.CutSuffix(name, ".hdr"); ok {
227+ name = base
228+ b, err := onOff(value)
229+ if err != nil {
230+ warn("%s: %v", key, err)
231+ continue
232+ }
233+ override()
234+ entry().HDR = b
235+ continue
236+ }
237+ if base, ok := strings.CutSuffix(name, ".sdr-brightness"); ok {
238+ name = base
239+ nits, err := strconv.Atoi(value)
240+ if err != nil || nits < 80 || nits > 1000 {
241+ warn("%s: must be between 80 and 1000", key)
242+ continue
243+ }
244+ override()
245+ entry().SDRBrightness = nits
246+ continue
247+ }
226 if base, ok := strings.CutSuffix(name, ".scale"); ok {248 if base, ok := strings.CutSuffix(name, ".scale"); ok {
227 name = base249 name = base
228 s, err := parseScale(value)250 s, err := parseScale(value)
@@ -245,7 +267,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
245 }267 }
246 override()268 override()
247 e := entry()269 e := entry()
248- o.Scale, o.Primary = e.Scale, e.Primary270+ o.Scale, o.Primary, o.HDR, o.SDRBrightness = e.Scale, e.Primary, e.HDR, e.SDRBrightness
249 *e = o271 *e = o
250 continue272 continue
251 }273 }
A internal/adapters/config/hdr_test.go +27 -0
@@ -0,0 +1,27 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestHDROutputConfig(t *testing.T) {
9+ c, w := parseString(t, "output.DP-1.hdr = on\noutput.DP-1.sdr-brightness = 80\noutput.DP-1 = preferred\noutput.DP-2 = preferred\noutput.DP-3.hdr = off\noutput.DP-4.sdr-brightness = 1000\n")
10+ if len(w) != 0 || len(c.Outputs) != 4 || !c.Outputs[0].HDR || c.Outputs[0].SDRBrightness != 80 || c.Outputs[0].ScaleOnly || c.Outputs[1].HDR || c.Outputs[1].SDRBrightness != 203 || !c.Outputs[2].ScaleOnly || c.Outputs[3].SDRBrightness != 1000 {
11+ t.Fatalf("%+v %v", c.Outputs, w)
12+ }
13+ for _, v := range []string{"79", "1001", "-1", "abc", "1.5"} {
14+ c, w = parseString(t, "output.DP-1.sdr-brightness = "+v+"\n")
15+ if len(w) != 1 || !strings.Contains(w[0].Msg, "between 80 and 1000") || len(c.Outputs) != 0 {
16+ t.Fatalf("%s: %+v %v", v, c.Outputs, w)
17+ }
18+ }
19+ c, w = parseString(t, "output.DP-1.sdr-brightness = 300\noutput.DP-1.sdr-brightness = 79\noutput.DP-1.hdr = on\noutput.DP-1.hdr = yes\n")
20+ if len(w) != 2 || len(c.Outputs) != 1 || c.Outputs[0].SDRBrightness != 300 || !c.Outputs[0].HDR {
21+ t.Fatalf("invalid override: %+v %v", c.Outputs, w)
22+ }
23+ c, w = parseString(t, "output.DP-1.hdr = yes\n")
24+ if len(w) != 1 || len(c.Outputs) != 0 {
25+ t.Fatalf("%+v %v", c.Outputs, w)
26+ }
27+}
@@ -0,0 +1,27 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestHDROutputConfig(t *testing.T) {
9+ c, w := parseString(t, "output.DP-1.hdr = on\noutput.DP-1.sdr-brightness = 80\noutput.DP-1 = preferred\noutput.DP-2 = preferred\noutput.DP-3.hdr = off\noutput.DP-4.sdr-brightness = 1000\n")
10+ if len(w) != 0 || len(c.Outputs) != 4 || !c.Outputs[0].HDR || c.Outputs[0].SDRBrightness != 80 || c.Outputs[0].ScaleOnly || c.Outputs[1].HDR || c.Outputs[1].SDRBrightness != 203 || !c.Outputs[2].ScaleOnly || c.Outputs[3].SDRBrightness != 1000 {
11+ t.Fatalf("%+v %v", c.Outputs, w)
12+ }
13+ for _, v := range []string{"79", "1001", "-1", "abc", "1.5"} {
14+ c, w = parseString(t, "output.DP-1.sdr-brightness = "+v+"\n")
15+ if len(w) != 1 || !strings.Contains(w[0].Msg, "between 80 and 1000") || len(c.Outputs) != 0 {
16+ t.Fatalf("%s: %+v %v", v, c.Outputs, w)
17+ }
18+ }
19+ c, w = parseString(t, "output.DP-1.sdr-brightness = 300\noutput.DP-1.sdr-brightness = 79\noutput.DP-1.hdr = on\noutput.DP-1.hdr = yes\n")
20+ if len(w) != 2 || len(c.Outputs) != 1 || c.Outputs[0].SDRBrightness != 300 || !c.Outputs[0].HDR {
21+ t.Fatalf("invalid override: %+v %v", c.Outputs, w)
22+ }
23+ c, w = parseString(t, "output.DP-1.hdr = yes\n")
24+ if len(w) != 1 || len(c.Outputs) != 0 {
25+ t.Fatalf("%+v %v", c.Outputs, w)
26+ }
27+}
M internal/adapters/drm/card.go +17 -2
@@ -74,7 +74,9 @@ func (c *Card) Device() uint64 {
7474 func (c *Card) SetFormats(ch chan<- ports.OutputFormats) { c.formats = ch }
7575
7676 // SetWant replaces the connector config; the next Scan applies it.
77-func (c *Card) SetWant(w Want) { c.want = w }
77+func (c *Card) SetWant(w Want) {
78+ c.want = w
79+}
7880
7981 // Scan compares connectors with the outputs driven now. It returns outputs to
8082 // start (newly connected, or with a new mode after Release), names of
@@ -99,7 +101,7 @@ func (c *Card) Scan() (added []*Output, removed, replaced []string, err error) {
99101 seen[conn.name] = true
100102 mode := c.want.pickMode(conn)
101103 if o := c.outputs[conn.name]; o != nil {
102- if o.mode == mode {
104+ if !outputNeedsRestart(o, mode, c.want.HDR[conn.name]) {
103105 continue
104106 }
105107 replaced = append(replaced, conn.name)
@@ -203,3 +205,16 @@ func (c *Card) ReadEvents(ctx context.Context) error {
203205 }
204206 return nil
205207 }
208+
209+func normalizedHDRSettings(settings HDRSettings) HDRSettings {
210+ if settings.SDRBrightness == 0 {
211+ settings.SDRBrightness = ports.DefaultSDRBrightness
212+ }
213+ return settings
214+}
215+
216+// outputNeedsRestart applies the same replacement path to mode and HDR
217+// configuration changes; Release drops the old output before reopening it.
218+func outputNeedsRestart(o *Output, mode modeInfo, settings HDRSettings) bool {
219+ return o.mode != mode || o.hdrSettings != normalizedHDRSettings(settings)
220+}
@@ -74,7 +74,9 @@ func (c *Card) Device() uint64 {
74 func (c *Card) SetFormats(ch chan<- ports.OutputFormats) { c.formats = ch }74 func (c *Card) SetFormats(ch chan<- ports.OutputFormats) { c.formats = ch }
75 75
76 // SetWant replaces the connector config; the next Scan applies it.76 // SetWant replaces the connector config; the next Scan applies it.
77-func (c *Card) SetWant(w Want) { c.want = w }77+func (c *Card) SetWant(w Want) {
78+ c.want = w
79+}
78 80
79 // Scan compares connectors with the outputs driven now. It returns outputs to81 // Scan compares connectors with the outputs driven now. It returns outputs to
80 // start (newly connected, or with a new mode after Release), names of82 // start (newly connected, or with a new mode after Release), names of
@@ -99,7 +101,7 @@ func (c *Card) Scan() (added []*Output, removed, replaced []string, err error) {
99 seen[conn.name] = true101 seen[conn.name] = true
100 mode := c.want.pickMode(conn)102 mode := c.want.pickMode(conn)
101 if o := c.outputs[conn.name]; o != nil {103 if o := c.outputs[conn.name]; o != nil {
102- if o.mode == mode {104+ if !outputNeedsRestart(o, mode, c.want.HDR[conn.name]) {
103 continue105 continue
104 }106 }
105 replaced = append(replaced, conn.name)107 replaced = append(replaced, conn.name)
@@ -203,3 +205,16 @@ func (c *Card) ReadEvents(ctx context.Context) error {
203 }205 }
204 return nil206 return nil
205 }207 }
208+
209+func normalizedHDRSettings(settings HDRSettings) HDRSettings {
210+ if settings.SDRBrightness == 0 {
211+ settings.SDRBrightness = ports.DefaultSDRBrightness
212+ }
213+ return settings
214+}
215+
216+// outputNeedsRestart applies the same replacement path to mode and HDR
217+// configuration changes; Release drops the old output before reopening it.
218+func outputNeedsRestart(o *Output, mode modeInfo, settings HDRSettings) bool {
219+ return o.mode != mode || o.hdrSettings != normalizedHDRSettings(settings)
220+}
M internal/adapters/drm/commit_test.go +5 -0
@@ -122,7 +122,9 @@ func TestShowImagesTestsBeforeModeset(t *testing.T) {
122122 w.Close()
123123 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
124124 }
125+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
125126 r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
127+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
126128 r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
127129 r.EXPECT().UseTarget(mock.Anything).Return()
128130 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
@@ -158,6 +160,7 @@ func TestEnableAfterStartAwayModesetsOnce(t *testing.T) {
158160 w.Close()
159161 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
160162 }
163+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
161164 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
162165 r.EXPECT().UseTarget(mock.Anything).Return()
163166 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
@@ -258,6 +261,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) {
258261 }
259262 r.EXPECT().CursorBuffers(64).Return([2]ports.DMABuf{buf(), buf()}, nil).Once()
260263 r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).Return(nil)
264+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
261265 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
262266 r.EXPECT().UseTarget(mock.Anything).Return()
263267 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
@@ -350,6 +354,7 @@ func TestRunClosesFrameFences(t *testing.T) {
350354 w.Close()
351355 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
352356 }
357+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
353358 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
354359 r.EXPECT().UseTarget(mock.Anything).Return()
355360 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
@@ -122,7 +122,9 @@ func TestShowImagesTestsBeforeModeset(t *testing.T) {
122 w.Close()122 w.Close()
123 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}123 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
124 }124 }
125+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
125 r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once()126 r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
127+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
126 r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once()128 r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
127 r.EXPECT().UseTarget(mock.Anything).Return()129 r.EXPECT().UseTarget(mock.Anything).Return()
128 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)130 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
@@ -158,6 +160,7 @@ func TestEnableAfterStartAwayModesetsOnce(t *testing.T) {
158 w.Close()160 w.Close()
159 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}161 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
160 }162 }
163+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
161 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()164 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
162 r.EXPECT().UseTarget(mock.Anything).Return()165 r.EXPECT().UseTarget(mock.Anything).Return()
163 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)166 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
@@ -258,6 +261,7 @@ func TestRunReportsSeenAfterCursorCommit(t *testing.T) {
258 }261 }
259 r.EXPECT().CursorBuffers(64).Return([2]ports.DMABuf{buf(), buf()}, nil).Once()262 r.EXPECT().CursorBuffers(64).Return([2]ports.DMABuf{buf(), buf()}, nil).Once()
260 r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).Return(nil)263 r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).Return(nil)
264+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
261 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()265 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
262 r.EXPECT().UseTarget(mock.Anything).Return()266 r.EXPECT().UseTarget(mock.Anything).Return()
263 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)267 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
@@ -350,6 +354,7 @@ func TestRunClosesFrameFences(t *testing.T) {
350 w.Close()354 w.Close()
351 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}355 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
352 }356 }
357+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
353 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()358 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
354 r.EXPECT().UseTarget(mock.Anything).Return()359 r.EXPECT().UseTarget(mock.Anything).Return()
355 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {360 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
M internal/adapters/drm/edid.go +68 -0
@@ -2,6 +2,7 @@ package drm
22
33 import (
44 "bufio"
5+ "math"
56 "os"
67 "path/filepath"
78 "strings"
@@ -10,6 +11,20 @@ import (
1011 // Monitor identifies the display attached to a connector.
1112 type Monitor struct {
1213 Make, Model, Serial string
14+ Chromaticity Chromaticity
15+ BitsPerColor int // 0 when unspecified
16+ HDR HDRMetadata
17+}
18+
19+// Chromaticity is the EDID base block's 10-bit CIE xy coordinates.
20+type Chromaticity struct {
21+ RedX, RedY, GreenX, GreenY, BlueX, BlueY, WhiteX, WhiteY float64
22+}
23+
24+// HDRMetadata is the CTA-861 HDR static metadata and BT.2020 colorimetry.
25+type HDRMetadata struct {
26+ SDR, PQ, HLG, StaticType1, BT2020RGB, BT2020YCC bool
27+ MaxLuminance, MaxFrameAverage, MinLuminance float64 // cd/m²; zero when unspecified
1328 }
1429
1530 // readMonitor parses the connector's EDID from sysfs. Missing data yields "Unknown".
@@ -29,6 +44,19 @@ func parseEDID(d []byte) Monitor {
2944 id := uint16(d[8])<<8 | uint16(d[9])
3045 pnp := string([]byte{byte(id>>10&0x1f) + '@', byte(id>>5&0x1f) + '@', byte(id&0x1f) + '@'})
3146 m.Make = pnpName(pnp)
47+ if d[20]&0x80 != 0 {
48+ m.BitsPerColor = []int{0, 6, 8, 10, 12, 14, 16, 0}[d[20]>>4&7]
49+ }
50+ xy := func(hi, lo byte) float64 { return float64(uint16(hi)<<2|uint16(lo)) / 1024 }
51+ m.Chromaticity = Chromaticity{
52+ RedX: xy(d[27], d[25]>>6), RedY: xy(d[28], d[25]>>4&3),
53+ GreenX: xy(d[29], d[25]>>2&3), GreenY: xy(d[30], d[25]&3),
54+ BlueX: xy(d[31], d[26]>>6), BlueY: xy(d[32], d[26]>>4&3),
55+ WhiteX: xy(d[33], d[26]>>2&3), WhiteY: xy(d[34], d[26]&3),
56+ }
57+ for i := 0; i < int(d[126]) && (i+2)*128 <= len(d); i++ {
58+ m.HDR.parseCTA(d[(i+1)*128 : (i+2)*128])
59+ }
3260 // Display descriptors: 0xFC name, 0xFF serial.
3361 for off := 54; off+18 <= 126; off += 18 {
3462 b := d[off : off+18]
@@ -46,6 +74,46 @@ func parseEDID(d []byte) Monitor {
4674 return m
4775 }
4876
77+// parseCTA skips malformed data blocks rather than reading into the DTD area.
78+func (h *HDRMetadata) parseCTA(b []byte) {
79+ if len(b) < 128 || b[0] != 0x02 || b[2] < 4 || b[2] > 127 {
80+ return
81+ }
82+ for off := 4; off < int(b[2]); {
83+ size := int(b[off] & 0x1f)
84+ end := off + 1 + size
85+ if end > int(b[2]) {
86+ return
87+ }
88+ if b[off]>>5 == 7 && size >= 2 {
89+ v := b[off+1 : end]
90+ switch v[0] {
91+ case 5: // colorimetry: BT2020RGB bit 7, BT2020YCC bit 6
92+ h.BT2020RGB = h.BT2020RGB || v[1]&0x80 != 0
93+ h.BT2020YCC = h.BT2020YCC || v[1]&0x40 != 0
94+ case 6: // HDR static metadata: EOTF, descriptors, luminances
95+ if len(v) < 3 {
96+ break
97+ }
98+ h.SDR = h.SDR || v[1]&1 != 0
99+ h.PQ = h.PQ || v[1]&4 != 0
100+ h.HLG = h.HLG || v[1]&8 != 0
101+ h.StaticType1 = h.StaticType1 || v[2]&1 != 0
102+ if len(v) > 3 && v[3] != 0 {
103+ h.MaxLuminance = 50 * math.Exp2(float64(v[3])/32)
104+ }
105+ if len(v) > 4 && v[4] != 0 {
106+ h.MaxFrameAverage = 50 * math.Exp2(float64(v[4])/32)
107+ }
108+ if len(v) > 5 && h.MaxLuminance != 0 {
109+ h.MinLuminance = h.MaxLuminance * math.Pow(float64(v[5])/255, 2) / 100
110+ }
111+ }
112+ }
113+ off = end
114+ }
115+}
116+
49117 // pnpName maps a PNP id to a vendor name via hwdata, falling back to the id.
50118 func pnpName(id string) string {
51119 f, err := os.Open("/usr/share/hwdata/pnp.ids")
@@ -2,6 +2,7 @@ package drm
2 2
3 import (3 import (
4 "bufio"4 "bufio"
5+ "math"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
7 "strings"8 "strings"
@@ -10,6 +11,20 @@ import (
10 // Monitor identifies the display attached to a connector.11 // Monitor identifies the display attached to a connector.
11 type Monitor struct {12 type Monitor struct {
12 Make, Model, Serial string13 Make, Model, Serial string
14+ Chromaticity Chromaticity
15+ BitsPerColor int // 0 when unspecified
16+ HDR HDRMetadata
17+}
18+
19+// Chromaticity is the EDID base block's 10-bit CIE xy coordinates.
20+type Chromaticity struct {
21+ RedX, RedY, GreenX, GreenY, BlueX, BlueY, WhiteX, WhiteY float64
22+}
23+
24+// HDRMetadata is the CTA-861 HDR static metadata and BT.2020 colorimetry.
25+type HDRMetadata struct {
26+ SDR, PQ, HLG, StaticType1, BT2020RGB, BT2020YCC bool
27+ MaxLuminance, MaxFrameAverage, MinLuminance float64 // cd/m²; zero when unspecified
13 }28 }
14 29
15 // readMonitor parses the connector's EDID from sysfs. Missing data yields "Unknown".30 // readMonitor parses the connector's EDID from sysfs. Missing data yields "Unknown".
@@ -29,6 +44,19 @@ func parseEDID(d []byte) Monitor {
29 id := uint16(d[8])<<8 | uint16(d[9])44 id := uint16(d[8])<<8 | uint16(d[9])
30 pnp := string([]byte{byte(id>>10&0x1f) + '@', byte(id>>5&0x1f) + '@', byte(id&0x1f) + '@'})45 pnp := string([]byte{byte(id>>10&0x1f) + '@', byte(id>>5&0x1f) + '@', byte(id&0x1f) + '@'})
31 m.Make = pnpName(pnp)46 m.Make = pnpName(pnp)
47+ if d[20]&0x80 != 0 {
48+ m.BitsPerColor = []int{0, 6, 8, 10, 12, 14, 16, 0}[d[20]>>4&7]
49+ }
50+ xy := func(hi, lo byte) float64 { return float64(uint16(hi)<<2|uint16(lo)) / 1024 }
51+ m.Chromaticity = Chromaticity{
52+ RedX: xy(d[27], d[25]>>6), RedY: xy(d[28], d[25]>>4&3),
53+ GreenX: xy(d[29], d[25]>>2&3), GreenY: xy(d[30], d[25]&3),
54+ BlueX: xy(d[31], d[26]>>6), BlueY: xy(d[32], d[26]>>4&3),
55+ WhiteX: xy(d[33], d[26]>>2&3), WhiteY: xy(d[34], d[26]&3),
56+ }
57+ for i := 0; i < int(d[126]) && (i+2)*128 <= len(d); i++ {
58+ m.HDR.parseCTA(d[(i+1)*128 : (i+2)*128])
59+ }
32 // Display descriptors: 0xFC name, 0xFF serial.60 // Display descriptors: 0xFC name, 0xFF serial.
33 for off := 54; off+18 <= 126; off += 18 {61 for off := 54; off+18 <= 126; off += 18 {
34 b := d[off : off+18]62 b := d[off : off+18]
@@ -46,6 +74,46 @@ func parseEDID(d []byte) Monitor {
46 return m74 return m
47 }75 }
48 76
77+// parseCTA skips malformed data blocks rather than reading into the DTD area.
78+func (h *HDRMetadata) parseCTA(b []byte) {
79+ if len(b) < 128 || b[0] != 0x02 || b[2] < 4 || b[2] > 127 {
80+ return
81+ }
82+ for off := 4; off < int(b[2]); {
83+ size := int(b[off] & 0x1f)
84+ end := off + 1 + size
85+ if end > int(b[2]) {
86+ return
87+ }
88+ if b[off]>>5 == 7 && size >= 2 {
89+ v := b[off+1 : end]
90+ switch v[0] {
91+ case 5: // colorimetry: BT2020RGB bit 7, BT2020YCC bit 6
92+ h.BT2020RGB = h.BT2020RGB || v[1]&0x80 != 0
93+ h.BT2020YCC = h.BT2020YCC || v[1]&0x40 != 0
94+ case 6: // HDR static metadata: EOTF, descriptors, luminances
95+ if len(v) < 3 {
96+ break
97+ }
98+ h.SDR = h.SDR || v[1]&1 != 0
99+ h.PQ = h.PQ || v[1]&4 != 0
100+ h.HLG = h.HLG || v[1]&8 != 0
101+ h.StaticType1 = h.StaticType1 || v[2]&1 != 0
102+ if len(v) > 3 && v[3] != 0 {
103+ h.MaxLuminance = 50 * math.Exp2(float64(v[3])/32)
104+ }
105+ if len(v) > 4 && v[4] != 0 {
106+ h.MaxFrameAverage = 50 * math.Exp2(float64(v[4])/32)
107+ }
108+ if len(v) > 5 && h.MaxLuminance != 0 {
109+ h.MinLuminance = h.MaxLuminance * math.Pow(float64(v[5])/255, 2) / 100
110+ }
111+ }
112+ }
113+ off = end
114+ }
115+}
116+
49 // pnpName maps a PNP id to a vendor name via hwdata, falling back to the id.117 // pnpName maps a PNP id to a vendor name via hwdata, falling back to the id.
50 func pnpName(id string) string {118 func pnpName(id string) string {
51 f, err := os.Open("/usr/share/hwdata/pnp.ids")119 f, err := os.Open("/usr/share/hwdata/pnp.ids")
A internal/adapters/drm/edid_test.go +84 -0
@@ -0,0 +1,84 @@
1+package drm
2+
3+import (
4+ "math"
5+ "testing"
6+)
7+
8+func TestEDIDHDR(t *testing.T) {
9+ d := make([]byte, 256)
10+ copy(d, "\x00\xff\xff\xff\xff\xff\xff\x00")
11+ d[20] = 0xb0 // digital, 10 bits per color
12+ d[25], d[26] = 0b_10_01_11_00, 0b_01_11_10_00
13+ d[27], d[28] = 128, 64
14+ d[29], d[30] = 32, 16
15+ d[31], d[32] = 8, 4
16+ d[33], d[34] = 2, 1
17+ d[126] = 1
18+ d[128], d[130] = 2, 15
19+ // CTA extended colorimetry: BT2020RGB + BT2020YCC.
20+ copy(d[132:], []byte{0xe3, 5, 0xc0, 0})
21+ // EOTF SDR, PQ, HLG; static type 1; max 200, average 100, min 200/100.
22+ copy(d[136:], []byte{0xe6, 6, 0x0d, 1, 64, 32, 255})
23+ m := parseEDID(d)
24+ if m.BitsPerColor != 10 || !m.HDR.SDR || !m.HDR.PQ || !m.HDR.HLG || !m.HDR.StaticType1 || !m.HDR.BT2020RGB || !m.HDR.BT2020YCC {
25+ t.Fatalf("%+v", m)
26+ }
27+ if m.Chromaticity.RedX != float64(128*4+2)/1024 || m.Chromaticity.GreenY != float64(16*4)/1024 || m.Chromaticity.WhiteY != float64(1*4)/1024 {
28+ t.Fatalf("chromaticity: %+v", m.Chromaticity)
29+ }
30+ if math.Abs(m.HDR.MaxLuminance-200) > 1e-9 || math.Abs(m.HDR.MaxFrameAverage-100) > 1e-9 || math.Abs(m.HDR.MinLuminance-2) > 1e-9 {
31+ t.Fatalf("luminances: %+v", m.HDR)
32+ }
33+ for _, n := range []int{0, 7, 127, 128, 129, 255} {
34+ got := parseEDID(d[:n])
35+ if got.HDR.PQ {
36+ t.Fatalf("truncated %d: %+v", n, got)
37+ }
38+ }
39+ d[132] = 0xff // block exceeds DTD boundary
40+ if parseEDID(d).HDR.PQ {
41+ t.Fatal("parsed malformed block")
42+ }
43+ d[132] = 0xe3
44+ d[130] = 3 // invalid CTA offset
45+ if parseEDID(d).HDR.PQ {
46+ t.Fatal("parsed invalid CTA offset")
47+ }
48+ d[130] = 15
49+ d[136] = 0xe2 // HDR payload without mandatory descriptor byte
50+ if parseEDID(d).HDR.PQ {
51+ t.Fatal("parsed truncated HDR payload")
52+ }
53+ d[20] = 0 // analog input cannot specify digital bit depth
54+ if parseEDID(d).BitsPerColor != 0 {
55+ t.Fatal("analog input has bit depth")
56+ }
57+}
58+
59+func TestDetectHDR(t *testing.T) {
60+ m := Monitor{HDR: HDRMetadata{PQ: true, BT2020RGB: true, StaticType1: true}}
61+ p := connectorHDRProps{Metadata: 1, Colorspace: 2, MaxBPC: 3, HasDefault: true}
62+ cases := []struct {
63+ name string
64+ m Monitor
65+ p connectorHDRProps
66+ reason string
67+ }{
68+ {"ready", m, p, ""},
69+ {"no PQ", Monitor{HDR: HDRMetadata{BT2020RGB: true, StaticType1: true}}, p, "edid: no PQ"},
70+ {"no RGB", Monitor{HDR: HDRMetadata{PQ: true, StaticType1: true}}, p, "edid: no BT2020RGB"},
71+ {"no type 1", Monitor{HDR: HDRMetadata{PQ: true, BT2020RGB: true}}, p, "edid: no static metadata type 1"},
72+ {"no metadata", m, connectorHDRProps{Colorspace: 2, MaxBPC: 3, HasDefault: true}, "connector: no HDR_OUTPUT_METADATA"},
73+ {"no colorspace", m, connectorHDRProps{Metadata: 1, MaxBPC: 3}, "connector: no BT2020_RGB Colorspace"},
74+ {"no bpc", m, connectorHDRProps{Metadata: 1, Colorspace: 2, HasDefault: true}, "connector: max bpc below 10"},
75+ }
76+ for _, tt := range cases {
77+ t.Run(tt.name, func(t *testing.T) {
78+ c := detectHDR(tt.m, tt.p)
79+ if c.Capable != (tt.reason == "") || c.Reason != tt.reason {
80+ t.Fatalf("%+v", c)
81+ }
82+ })
83+ }
84+}
@@ -0,0 +1,84 @@
1+package drm
2+
3+import (
4+ "math"
5+ "testing"
6+)
7+
8+func TestEDIDHDR(t *testing.T) {
9+ d := make([]byte, 256)
10+ copy(d, "\x00\xff\xff\xff\xff\xff\xff\x00")
11+ d[20] = 0xb0 // digital, 10 bits per color
12+ d[25], d[26] = 0b_10_01_11_00, 0b_01_11_10_00
13+ d[27], d[28] = 128, 64
14+ d[29], d[30] = 32, 16
15+ d[31], d[32] = 8, 4
16+ d[33], d[34] = 2, 1
17+ d[126] = 1
18+ d[128], d[130] = 2, 15
19+ // CTA extended colorimetry: BT2020RGB + BT2020YCC.
20+ copy(d[132:], []byte{0xe3, 5, 0xc0, 0})
21+ // EOTF SDR, PQ, HLG; static type 1; max 200, average 100, min 200/100.
22+ copy(d[136:], []byte{0xe6, 6, 0x0d, 1, 64, 32, 255})
23+ m := parseEDID(d)
24+ if m.BitsPerColor != 10 || !m.HDR.SDR || !m.HDR.PQ || !m.HDR.HLG || !m.HDR.StaticType1 || !m.HDR.BT2020RGB || !m.HDR.BT2020YCC {
25+ t.Fatalf("%+v", m)
26+ }
27+ if m.Chromaticity.RedX != float64(128*4+2)/1024 || m.Chromaticity.GreenY != float64(16*4)/1024 || m.Chromaticity.WhiteY != float64(1*4)/1024 {
28+ t.Fatalf("chromaticity: %+v", m.Chromaticity)
29+ }
30+ if math.Abs(m.HDR.MaxLuminance-200) > 1e-9 || math.Abs(m.HDR.MaxFrameAverage-100) > 1e-9 || math.Abs(m.HDR.MinLuminance-2) > 1e-9 {
31+ t.Fatalf("luminances: %+v", m.HDR)
32+ }
33+ for _, n := range []int{0, 7, 127, 128, 129, 255} {
34+ got := parseEDID(d[:n])
35+ if got.HDR.PQ {
36+ t.Fatalf("truncated %d: %+v", n, got)
37+ }
38+ }
39+ d[132] = 0xff // block exceeds DTD boundary
40+ if parseEDID(d).HDR.PQ {
41+ t.Fatal("parsed malformed block")
42+ }
43+ d[132] = 0xe3
44+ d[130] = 3 // invalid CTA offset
45+ if parseEDID(d).HDR.PQ {
46+ t.Fatal("parsed invalid CTA offset")
47+ }
48+ d[130] = 15
49+ d[136] = 0xe2 // HDR payload without mandatory descriptor byte
50+ if parseEDID(d).HDR.PQ {
51+ t.Fatal("parsed truncated HDR payload")
52+ }
53+ d[20] = 0 // analog input cannot specify digital bit depth
54+ if parseEDID(d).BitsPerColor != 0 {
55+ t.Fatal("analog input has bit depth")
56+ }
57+}
58+
59+func TestDetectHDR(t *testing.T) {
60+ m := Monitor{HDR: HDRMetadata{PQ: true, BT2020RGB: true, StaticType1: true}}
61+ p := connectorHDRProps{Metadata: 1, Colorspace: 2, MaxBPC: 3, HasDefault: true}
62+ cases := []struct {
63+ name string
64+ m Monitor
65+ p connectorHDRProps
66+ reason string
67+ }{
68+ {"ready", m, p, ""},
69+ {"no PQ", Monitor{HDR: HDRMetadata{BT2020RGB: true, StaticType1: true}}, p, "edid: no PQ"},
70+ {"no RGB", Monitor{HDR: HDRMetadata{PQ: true, StaticType1: true}}, p, "edid: no BT2020RGB"},
71+ {"no type 1", Monitor{HDR: HDRMetadata{PQ: true, BT2020RGB: true}}, p, "edid: no static metadata type 1"},
72+ {"no metadata", m, connectorHDRProps{Colorspace: 2, MaxBPC: 3, HasDefault: true}, "connector: no HDR_OUTPUT_METADATA"},
73+ {"no colorspace", m, connectorHDRProps{Metadata: 1, MaxBPC: 3}, "connector: no BT2020_RGB Colorspace"},
74+ {"no bpc", m, connectorHDRProps{Metadata: 1, Colorspace: 2, HasDefault: true}, "connector: max bpc below 10"},
75+ }
76+ for _, tt := range cases {
77+ t.Run(tt.name, func(t *testing.T) {
78+ c := detectHDR(tt.m, tt.p)
79+ if c.Capable != (tt.reason == "") || c.Reason != tt.reason {
80+ t.Fatalf("%+v", c)
81+ }
82+ })
83+ }
84+}
A internal/adapters/drm/hdr.go +64 -0
@@ -0,0 +1,64 @@
1+package drm
2+
3+import (
4+ "math"
5+ "unsafe"
6+)
7+
8+// HDMI static metadata type 1, embedded in struct hdr_output_metadata.
9+// All coordinates use 0.00002; minimum luminance uses 0.0001 nits.
10+type hdmiMetadataType1 struct {
11+ EOTF, MetadataType uint8
12+ DisplayPrimaries [3][2]uint16
13+ WhitePoint [2]uint16
14+ MaxDisplayMasteringLuminance uint16
15+ MinDisplayMasteringLuminance uint16
16+ MaxCLL, MaxFALL uint16
17+}
18+
19+type hdrOutputMetadata struct {
20+ MetadataType uint32
21+ HDMI hdmiMetadataType1
22+}
23+
24+func hdrMetadata(m Monitor) hdrOutputMetadata {
25+ unit := func(v, scale float64) uint16 {
26+ return uint16(math.Round(max(0, min(v*scale, math.MaxUint16))))
27+ }
28+ c, h := m.Chromaticity, m.HDR
29+ return hdrOutputMetadata{HDMI: hdmiMetadataType1{
30+ EOTF: 2,
31+ DisplayPrimaries: [3][2]uint16{{unit(c.RedX, 50000), unit(c.RedY, 50000)}, {unit(c.GreenX, 50000), unit(c.GreenY, 50000)}, {unit(c.BlueX, 50000), unit(c.BlueY, 50000)}},
32+ WhitePoint: [2]uint16{unit(c.WhiteX, 50000), unit(c.WhiteY, 50000)},
33+ MaxDisplayMasteringLuminance: unit(h.MaxLuminance, 1),
34+ MinDisplayMasteringLuminance: unit(h.MinLuminance, 10000),
35+ MaxCLL: unit(h.MaxLuminance, 1), MaxFALL: unit(maxFrameAverage(h), 1),
36+ }}
37+}
38+
39+func maxFrameAverage(h HDRMetadata) float64 {
40+ if h.MaxFrameAverage > 0 {
41+ return h.MaxFrameAverage
42+ }
43+ return h.MaxLuminance
44+}
45+
46+// hdrConnectorProps sets the connector's signal metadata on a modeset or restore.
47+func (o *Output) hdrConnectorProps(req *atomicReq, enabled bool) {
48+ p := o.hdrProps
49+ if enabled {
50+ req.set(o.conn.id, p.MaxBPC, 10)
51+ req.set(o.conn.id, p.Colorspace, p.BT2020Value)
52+ req.set(o.conn.id, p.Metadata, uint64(o.hdrBlob))
53+ return
54+ }
55+ req.set(o.conn.id, p.MaxBPC, p.MaxBPCValue)
56+ if p.HasDefault {
57+ req.set(o.conn.id, p.Colorspace, p.DefaultValue)
58+ }
59+ req.set(o.conn.id, p.Metadata, 0)
60+}
61+
62+func (m *hdrOutputMetadata) bytes() []byte {
63+ return unsafe.Slice((*byte)(unsafe.Pointer(m)), unsafe.Sizeof(*m))
64+}
@@ -0,0 +1,64 @@
1+package drm
2+
3+import (
4+ "math"
5+ "unsafe"
6+)
7+
8+// HDMI static metadata type 1, embedded in struct hdr_output_metadata.
9+// All coordinates use 0.00002; minimum luminance uses 0.0001 nits.
10+type hdmiMetadataType1 struct {
11+ EOTF, MetadataType uint8
12+ DisplayPrimaries [3][2]uint16
13+ WhitePoint [2]uint16
14+ MaxDisplayMasteringLuminance uint16
15+ MinDisplayMasteringLuminance uint16
16+ MaxCLL, MaxFALL uint16
17+}
18+
19+type hdrOutputMetadata struct {
20+ MetadataType uint32
21+ HDMI hdmiMetadataType1
22+}
23+
24+func hdrMetadata(m Monitor) hdrOutputMetadata {
25+ unit := func(v, scale float64) uint16 {
26+ return uint16(math.Round(max(0, min(v*scale, math.MaxUint16))))
27+ }
28+ c, h := m.Chromaticity, m.HDR
29+ return hdrOutputMetadata{HDMI: hdmiMetadataType1{
30+ EOTF: 2,
31+ DisplayPrimaries: [3][2]uint16{{unit(c.RedX, 50000), unit(c.RedY, 50000)}, {unit(c.GreenX, 50000), unit(c.GreenY, 50000)}, {unit(c.BlueX, 50000), unit(c.BlueY, 50000)}},
32+ WhitePoint: [2]uint16{unit(c.WhiteX, 50000), unit(c.WhiteY, 50000)},
33+ MaxDisplayMasteringLuminance: unit(h.MaxLuminance, 1),
34+ MinDisplayMasteringLuminance: unit(h.MinLuminance, 10000),
35+ MaxCLL: unit(h.MaxLuminance, 1), MaxFALL: unit(maxFrameAverage(h), 1),
36+ }}
37+}
38+
39+func maxFrameAverage(h HDRMetadata) float64 {
40+ if h.MaxFrameAverage > 0 {
41+ return h.MaxFrameAverage
42+ }
43+ return h.MaxLuminance
44+}
45+
46+// hdrConnectorProps sets the connector's signal metadata on a modeset or restore.
47+func (o *Output) hdrConnectorProps(req *atomicReq, enabled bool) {
48+ p := o.hdrProps
49+ if enabled {
50+ req.set(o.conn.id, p.MaxBPC, 10)
51+ req.set(o.conn.id, p.Colorspace, p.BT2020Value)
52+ req.set(o.conn.id, p.Metadata, uint64(o.hdrBlob))
53+ return
54+ }
55+ req.set(o.conn.id, p.MaxBPC, p.MaxBPCValue)
56+ if p.HasDefault {
57+ req.set(o.conn.id, p.Colorspace, p.DefaultValue)
58+ }
59+ req.set(o.conn.id, p.Metadata, 0)
60+}
61+
62+func (m *hdrOutputMetadata) bytes() []byte {
63+ return unsafe.Slice((*byte)(unsafe.Pointer(m)), unsafe.Sizeof(*m))
64+}
A internal/adapters/drm/hdr_test.go +288 -0
@@ -0,0 +1,288 @@
1+package drm
2+
3+import (
4+ "os"
5+ "slices"
6+ "testing"
7+ "unsafe"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
13+)
14+
15+func TestHDRMetadataLayoutAndContent(t *testing.T) {
16+ var layout hdrOutputMetadata
17+ if unsafe.Sizeof(layout) != 32 || unsafe.Offsetof(layout.HDMI) != 4 || unsafe.Offsetof(layout.HDMI.DisplayPrimaries) != 2 || unsafe.Offsetof(layout.HDMI.WhitePoint) != 14 || unsafe.Offsetof(layout.HDMI.MaxDisplayMasteringLuminance) != 18 || unsafe.Offsetof(layout.HDMI.MinDisplayMasteringLuminance) != 20 || unsafe.Offsetof(layout.HDMI.MaxCLL) != 22 || unsafe.Offsetof(layout.HDMI.MaxFALL) != 24 {
18+ t.Fatalf("unexpected HDR metadata layout: size=%d HDMI=%d", unsafe.Sizeof(layout), unsafe.Offsetof(layout.HDMI))
19+ }
20+ m := Monitor{Chromaticity: Chromaticity{RedX: 0.64, RedY: 0.33, GreenX: 0.3, GreenY: 0.6, BlueX: 0.15, BlueY: 0.06, WhiteX: 0.3127, WhiteY: 0.329}, HDR: HDRMetadata{MaxLuminance: 1000, MinLuminance: 0.005}}
21+ data := hdrMetadata(m)
22+ if len(data.bytes()) != 32 || data.MetadataType != 0 || data.HDMI.EOTF != 2 || data.HDMI.MetadataType != 0 || data.HDMI.DisplayPrimaries != [3][2]uint16{{32000, 16500}, {15000, 30000}, {7500, 3000}} || data.HDMI.WhitePoint != [2]uint16{15635, 16450} || data.HDMI.MaxDisplayMasteringLuminance != 1000 || data.HDMI.MinDisplayMasteringLuminance != 50 || data.HDMI.MaxCLL != 1000 || data.HDMI.MaxFALL != 1000 {
23+ t.Fatalf("metadata: %+v", data)
24+ }
25+ m.HDR.MaxFrameAverage = 400
26+ if withFALL := hdrMetadata(m); withFALL.HDMI.MaxFALL != 400 {
27+ t.Fatalf("frame-average luminance: %+v", withFALL)
28+ }
29+ if zero := hdrMetadata(Monitor{}); zero.HDMI.MaxCLL != 0 || zero.HDMI.MaxFALL != 0 {
30+ t.Fatalf("unknown luminance: %+v", zero)
31+ }
32+}
33+
34+func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
35+ o, _, _ := testOutput(t)
36+ o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, DefaultValue: 0, HasDefault: true, MaxBPCValue: 8}
37+ o.hdrBlob, o.hdrOn = 321, true
38+ for _, tc := range []struct {
39+ on bool
40+ bpc, colorspace, metadata uint64
41+ }{
42+ {true, 10, 7, 321}, {false, 8, 0, 0},
43+ } {
44+ o.hdrOn = tc.on
45+ req := o.modesetReq(11, true)
46+ for _, prop := range []struct {
47+ id uint32
48+ want uint64
49+ }{{102, tc.bpc}, {101, tc.colorspace}, {100, tc.metadata}} {
50+ if got, ok := req.value(o.conn.id, prop.id); !ok || got != prop.want {
51+ t.Fatalf("HDR %t prop %d: got %d (%t), want %d", tc.on, prop.id, got, ok, prop.want)
52+ }
53+ }
54+ }
55+ o.hdrOn = true
56+ if fb, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "hdr" {
57+ t.Fatalf("scanout: fb %d reason %q", fb, o.reason)
58+ }
59+ if ov, _ := o.overlayFrame(ports.Scene{}, nil); ov.fb != 0 || o.overlayReason != "hdr" {
60+ t.Fatalf("overlay: fb %d reason %q", ov.fb, o.overlayReason)
61+ }
62+ // Colorspace property without a Default enum must not receive invented 0.
63+ o.hdrOn = false
64+ o.hdrProps.HasDefault = false
65+ if _, ok := o.modesetReq(11, true).value(o.conn.id, o.hdrProps.Colorspace); ok {
66+ t.Fatal("SDR set Colorspace with no Default enum")
67+ }
68+ restore := &atomicReq{}
69+ o.hdrConnectorProps(restore, false)
70+ if _, ok := restore.value(o.conn.id, o.hdrProps.Colorspace); ok {
71+ t.Fatal("restore set Colorspace with no Default enum")
72+ }
73+}
74+
75+func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {
76+ o, k, commits := testOutput(t, unix.EINVAL, unix.EINVAL)
77+ o.cursor = nil
78+ o.hdr = hdrCapability{Capable: true}
79+ o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: 203}
80+ o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
81+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 0}}
82+ r := portsmocks.NewMockRenderer(t)
83+ buf := func() ports.DMABuf {
84+ f, w, _ := os.Pipe()
85+ w.Close()
86+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
87+ }
88+ var calls []string
89+ r.EXPECT().SetHDR(float64(203)).Run(func(float64) { calls = append(calls, "hdr") }).Return().Once()
90+ r.EXPECT().SetHDR(float64(0)).Run(func(float64) { calls = append(calls, "sdr") }).Return().Once()
91+ r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Twice()
92+ r.EXPECT().ExportTargets(0, []uint64(nil)).Run(func(int, []uint64) { calls = append(calls, "drop") }).Return(nil, nil).Once()
93+ r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
94+ r.EXPECT().UseTarget(mock.Anything).Return()
95+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
96+ k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)
97+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(71, nil)
98+ k.EXPECT().rmFB(mock.Anything).Return(nil)
99+ k.EXPECT().createBlob(mock.Anything).Return(321, nil).Once()
100+ k.EXPECT().createBlob(mock.Anything).Return(11, nil)
101+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
102+ if err := o.showImages(r, imagesDriver, nil); err != nil {
103+ t.Fatal(err)
104+ }
105+ if !slices.Equal(calls, []string{"hdr", "sdr", "drop"}) {
106+ t.Fatalf("fallback order: %v", calls)
107+ }
108+ if o.hdrOn || !o.hdrFailed {
109+ t.Fatalf("HDR on %t failed %t", o.hdrOn, o.hdrFailed)
110+ }
111+ if len(*commits) != 4 {
112+ t.Fatalf("commits: %d", len(*commits))
113+ }
114+ for i := range 2 {
115+ if (*commits)[i].flags != atomicTestOnly|atomicAllowModes {
116+ t.Fatalf("HDR test %d flags: %#x", i, (*commits)[i].flags)
117+ }
118+ }
119+ if (*commits)[2].flags != atomicTestOnly|atomicAllowModes || (*commits)[3].flags != atomicAllowModes {
120+ t.Fatalf("SDR commits: %#x %#x", (*commits)[2].flags, (*commits)[3].flags)
121+ }
122+ if v, _ := (*commits)[0].req.value(o.conn.id, 100); v != 321 {
123+ t.Fatalf("HDR test metadata: %d", v)
124+ }
125+ if v, _ := (*commits)[len(*commits)-1].req.value(o.conn.id, 100); v != 0 {
126+ t.Fatalf("SDR metadata: %d", v)
127+ }
128+}
129+
130+func TestHDRSettingsRestartComparison(t *testing.T) {
131+ current := HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
132+ for _, tc := range []struct {
133+ settings HDRSettings
134+ restart bool
135+ }{
136+ {HDRSettings{Enabled: true}, false},
137+ {HDRSettings{Enabled: true, SDRBrightness: 350}, true},
138+ {HDRSettings{SDRBrightness: ports.DefaultSDRBrightness}, true},
139+ } {
140+ if restart := outputNeedsRestart(&Output{hdrSettings: current}, modeInfo{}, tc.settings); restart != tc.restart {
141+ t.Fatalf("settings %+v: restart %t, want %t", tc.settings, restart, tc.restart)
142+ }
143+ }
144+ if !outputNeedsRestart(&Output{hdrSettings: current}, modeInfo{VRefresh: 60}, current) {
145+ t.Fatal("mode change did not restart")
146+ }
147+}
148+
149+func TestHDRSuccessfulModeset(t *testing.T) {
150+ o, k, commits := testOutput(t)
151+ o.cursor = nil
152+ o.hdr = hdrCapability{Capable: true}
153+ o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
154+ o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
155+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}
156+ r := portsmocks.NewMockRenderer(t)
157+ buf := func() ports.DMABuf {
158+ f, w, _ := os.Pipe()
159+ w.Close()
160+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
161+ }
162+ r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Once()
163+ r.EXPECT().ExportTargets(2, []uint64{19}).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
164+ r.EXPECT().UseTarget(mock.Anything).Return()
165+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
166+ k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Twice()
167+ k.EXPECT().createBlob(mock.Anything).Return(321, nil).Once()
168+ k.EXPECT().createBlob(mock.Anything).Return(11, nil).Twice()
169+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
170+ if err := o.showImages(r, imagesDriver, nil); err != nil {
171+ t.Fatal(err)
172+ }
173+ if !o.hdrOn || o.hdrFailed || len(*commits) != 2 {
174+ t.Fatalf("on=%t failed=%t commits=%d", o.hdrOn, o.hdrFailed, len(*commits))
175+ }
176+ for _, c := range *commits {
177+ for _, tc := range []struct {
178+ prop uint32
179+ want uint64
180+ }{{100, 321}, {101, 7}, {102, 10}} {
181+ if got, ok := c.req.value(o.conn.id, tc.prop); !ok || got != tc.want {
182+ t.Fatalf("commit prop %d: %d (%t), want %d", tc.prop, got, ok, tc.want)
183+ }
184+ }
185+ }
186+}
187+
188+// The metadata blob is retained across repeated image setup (e.g. VT resume)
189+// and destroyed only once when Close restores SDR.
190+func TestHDRBlobReusedAcrossImageSetup(t *testing.T) {
191+ o, k, commits := testOutput(t)
192+ o.cursor = nil
193+ o.hdr = hdrCapability{Capable: true}
194+ o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
195+ o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
196+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}
197+ r := portsmocks.NewMockRenderer(t)
198+ buf := func() ports.DMABuf {
199+ f, w, _ := os.Pipe()
200+ w.Close()
201+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
202+ }
203+ r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Twice()
204+ r.EXPECT().ExportTargets(2, []uint64{19}).RunAndReturn(func(int, []uint64) ([]ports.DMABuf, error) { return []ports.DMABuf{buf(), buf()}, nil }).Twice()
205+ r.EXPECT().UseTarget(mock.Anything).Return()
206+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
207+ k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)
208+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
209+ created, destroyed := 0, 0
210+ k.EXPECT().createBlob(mock.Anything).RunAndReturn(func(data []byte) (uint32, error) {
211+ if len(data) == 32 {
212+ created++
213+ return 321, nil
214+ }
215+ return 11, nil
216+ })
217+ k.EXPECT().destroyBlob(mock.Anything).RunAndReturn(func(id uint32) error {
218+ if id == 321 {
219+ destroyed++
220+ }
221+ return nil
222+ })
223+ for i := 0; i < 2; i++ {
224+ if err := o.showImages(r, imagesDriver, nil); err != nil {
225+ t.Fatal(err)
226+ }
227+ if o.hdrBlob != 321 || created != 1 || destroyed != 0 {
228+ t.Fatalf("pass %d blob %d created %d destroyed %d", i, o.hdrBlob, created, destroyed)
229+ }
230+ }
231+ if len(*commits) != 4 {
232+ t.Fatalf("commits: %d", len(*commits))
233+ }
234+ o.Close()
235+ if created != 1 || destroyed != 1 {
236+ t.Fatalf("created %d destroyed %d", created, destroyed)
237+ }
238+}
239+
240+func TestHDRBlobReplacementWaitsForCommit(t *testing.T) {
241+ o, k, _ := testOutput(t)
242+ o.cursor = nil
243+ o.hdr = hdrCapability{Capable: true}
244+ o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
245+ o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
246+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}
247+ r := portsmocks.NewMockRenderer(t)
248+ buf := func() ports.DMABuf {
249+ f, w, _ := os.Pipe()
250+ w.Close()
251+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
252+ }
253+ r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Twice()
254+ r.EXPECT().ExportTargets(2, []uint64{19}).RunAndReturn(func(int, []uint64) ([]ports.DMABuf, error) { return []ports.DMABuf{buf(), buf()}, nil }).Twice()
255+ r.EXPECT().UseTarget(mock.Anything).Return()
256+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
257+ k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)
258+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
259+ metadata := uint32(320)
260+ k.EXPECT().createBlob(mock.Anything).RunAndReturn(func(data []byte) (uint32, error) {
261+ if len(data) == 32 {
262+ metadata++
263+ return metadata, nil
264+ }
265+ return 11, nil
266+ })
267+ var destroyed []uint32
268+ k.EXPECT().destroyBlob(mock.Anything).RunAndReturn(func(id uint32) error {
269+ if id >= 321 {
270+ destroyed = append(destroyed, id)
271+ }
272+ return nil
273+ })
274+ if err := o.showImages(r, imagesDriver, nil); err != nil {
275+ t.Fatal(err)
276+ }
277+ o.monitor.HDR.MaxLuminance = 1000
278+ if err := o.showImages(r, imagesDriver, nil); err != nil {
279+ t.Fatal(err)
280+ }
281+ if o.hdrBlob != 322 || !slices.Equal(destroyed, []uint32{321}) {
282+ t.Fatalf("blob %d destroyed %v", o.hdrBlob, destroyed)
283+ }
284+ o.Close()
285+ if !slices.Equal(destroyed, []uint32{321, 322}) {
286+ t.Fatalf("destroyed %v", destroyed)
287+ }
288+}
@@ -0,0 +1,288 @@
1+package drm
2+
3+import (
4+ "os"
5+ "slices"
6+ "testing"
7+ "unsafe"
8+
9+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
13+)
14+
15+func TestHDRMetadataLayoutAndContent(t *testing.T) {
16+ var layout hdrOutputMetadata
17+ if unsafe.Sizeof(layout) != 32 || unsafe.Offsetof(layout.HDMI) != 4 || unsafe.Offsetof(layout.HDMI.DisplayPrimaries) != 2 || unsafe.Offsetof(layout.HDMI.WhitePoint) != 14 || unsafe.Offsetof(layout.HDMI.MaxDisplayMasteringLuminance) != 18 || unsafe.Offsetof(layout.HDMI.MinDisplayMasteringLuminance) != 20 || unsafe.Offsetof(layout.HDMI.MaxCLL) != 22 || unsafe.Offsetof(layout.HDMI.MaxFALL) != 24 {
18+ t.Fatalf("unexpected HDR metadata layout: size=%d HDMI=%d", unsafe.Sizeof(layout), unsafe.Offsetof(layout.HDMI))
19+ }
20+ m := Monitor{Chromaticity: Chromaticity{RedX: 0.64, RedY: 0.33, GreenX: 0.3, GreenY: 0.6, BlueX: 0.15, BlueY: 0.06, WhiteX: 0.3127, WhiteY: 0.329}, HDR: HDRMetadata{MaxLuminance: 1000, MinLuminance: 0.005}}
21+ data := hdrMetadata(m)
22+ if len(data.bytes()) != 32 || data.MetadataType != 0 || data.HDMI.EOTF != 2 || data.HDMI.MetadataType != 0 || data.HDMI.DisplayPrimaries != [3][2]uint16{{32000, 16500}, {15000, 30000}, {7500, 3000}} || data.HDMI.WhitePoint != [2]uint16{15635, 16450} || data.HDMI.MaxDisplayMasteringLuminance != 1000 || data.HDMI.MinDisplayMasteringLuminance != 50 || data.HDMI.MaxCLL != 1000 || data.HDMI.MaxFALL != 1000 {
23+ t.Fatalf("metadata: %+v", data)
24+ }
25+ m.HDR.MaxFrameAverage = 400
26+ if withFALL := hdrMetadata(m); withFALL.HDMI.MaxFALL != 400 {
27+ t.Fatalf("frame-average luminance: %+v", withFALL)
28+ }
29+ if zero := hdrMetadata(Monitor{}); zero.HDMI.MaxCLL != 0 || zero.HDMI.MaxFALL != 0 {
30+ t.Fatalf("unknown luminance: %+v", zero)
31+ }
32+}
33+
34+func TestHDRModesetPropertiesAndPlaneRefusal(t *testing.T) {
35+ o, _, _ := testOutput(t)
36+ o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, DefaultValue: 0, HasDefault: true, MaxBPCValue: 8}
37+ o.hdrBlob, o.hdrOn = 321, true
38+ for _, tc := range []struct {
39+ on bool
40+ bpc, colorspace, metadata uint64
41+ }{
42+ {true, 10, 7, 321}, {false, 8, 0, 0},
43+ } {
44+ o.hdrOn = tc.on
45+ req := o.modesetReq(11, true)
46+ for _, prop := range []struct {
47+ id uint32
48+ want uint64
49+ }{{102, tc.bpc}, {101, tc.colorspace}, {100, tc.metadata}} {
50+ if got, ok := req.value(o.conn.id, prop.id); !ok || got != prop.want {
51+ t.Fatalf("HDR %t prop %d: got %d (%t), want %d", tc.on, prop.id, got, ok, prop.want)
52+ }
53+ }
54+ }
55+ o.hdrOn = true
56+ if fb, _ := o.scanoutFrame(ports.Scene{}, nil); fb != 0 || o.reason != "hdr" {
57+ t.Fatalf("scanout: fb %d reason %q", fb, o.reason)
58+ }
59+ if ov, _ := o.overlayFrame(ports.Scene{}, nil); ov.fb != 0 || o.overlayReason != "hdr" {
60+ t.Fatalf("overlay: fb %d reason %q", ov.fb, o.overlayReason)
61+ }
62+ // Colorspace property without a Default enum must not receive invented 0.
63+ o.hdrOn = false
64+ o.hdrProps.HasDefault = false
65+ if _, ok := o.modesetReq(11, true).value(o.conn.id, o.hdrProps.Colorspace); ok {
66+ t.Fatal("SDR set Colorspace with no Default enum")
67+ }
68+ restore := &atomicReq{}
69+ o.hdrConnectorProps(restore, false)
70+ if _, ok := restore.value(o.conn.id, o.hdrProps.Colorspace); ok {
71+ t.Fatal("restore set Colorspace with no Default enum")
72+ }
73+}
74+
75+func TestHDRTestCommitFallbackReexportsSDR(t *testing.T) {
76+ o, k, commits := testOutput(t, unix.EINVAL, unix.EINVAL)
77+ o.cursor = nil
78+ o.hdr = hdrCapability{Capable: true}
79+ o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: 203}
80+ o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
81+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 0}}
82+ r := portsmocks.NewMockRenderer(t)
83+ buf := func() ports.DMABuf {
84+ f, w, _ := os.Pipe()
85+ w.Close()
86+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
87+ }
88+ var calls []string
89+ r.EXPECT().SetHDR(float64(203)).Run(func(float64) { calls = append(calls, "hdr") }).Return().Once()
90+ r.EXPECT().SetHDR(float64(0)).Run(func(float64) { calls = append(calls, "sdr") }).Return().Once()
91+ r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Twice()
92+ r.EXPECT().ExportTargets(0, []uint64(nil)).Run(func(int, []uint64) { calls = append(calls, "drop") }).Return(nil, nil).Once()
93+ r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
94+ r.EXPECT().UseTarget(mock.Anything).Return()
95+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
96+ k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)
97+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(71, nil)
98+ k.EXPECT().rmFB(mock.Anything).Return(nil)
99+ k.EXPECT().createBlob(mock.Anything).Return(321, nil).Once()
100+ k.EXPECT().createBlob(mock.Anything).Return(11, nil)
101+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
102+ if err := o.showImages(r, imagesDriver, nil); err != nil {
103+ t.Fatal(err)
104+ }
105+ if !slices.Equal(calls, []string{"hdr", "sdr", "drop"}) {
106+ t.Fatalf("fallback order: %v", calls)
107+ }
108+ if o.hdrOn || !o.hdrFailed {
109+ t.Fatalf("HDR on %t failed %t", o.hdrOn, o.hdrFailed)
110+ }
111+ if len(*commits) != 4 {
112+ t.Fatalf("commits: %d", len(*commits))
113+ }
114+ for i := range 2 {
115+ if (*commits)[i].flags != atomicTestOnly|atomicAllowModes {
116+ t.Fatalf("HDR test %d flags: %#x", i, (*commits)[i].flags)
117+ }
118+ }
119+ if (*commits)[2].flags != atomicTestOnly|atomicAllowModes || (*commits)[3].flags != atomicAllowModes {
120+ t.Fatalf("SDR commits: %#x %#x", (*commits)[2].flags, (*commits)[3].flags)
121+ }
122+ if v, _ := (*commits)[0].req.value(o.conn.id, 100); v != 321 {
123+ t.Fatalf("HDR test metadata: %d", v)
124+ }
125+ if v, _ := (*commits)[len(*commits)-1].req.value(o.conn.id, 100); v != 0 {
126+ t.Fatalf("SDR metadata: %d", v)
127+ }
128+}
129+
130+func TestHDRSettingsRestartComparison(t *testing.T) {
131+ current := HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
132+ for _, tc := range []struct {
133+ settings HDRSettings
134+ restart bool
135+ }{
136+ {HDRSettings{Enabled: true}, false},
137+ {HDRSettings{Enabled: true, SDRBrightness: 350}, true},
138+ {HDRSettings{SDRBrightness: ports.DefaultSDRBrightness}, true},
139+ } {
140+ if restart := outputNeedsRestart(&Output{hdrSettings: current}, modeInfo{}, tc.settings); restart != tc.restart {
141+ t.Fatalf("settings %+v: restart %t, want %t", tc.settings, restart, tc.restart)
142+ }
143+ }
144+ if !outputNeedsRestart(&Output{hdrSettings: current}, modeInfo{VRefresh: 60}, current) {
145+ t.Fatal("mode change did not restart")
146+ }
147+}
148+
149+func TestHDRSuccessfulModeset(t *testing.T) {
150+ o, k, commits := testOutput(t)
151+ o.cursor = nil
152+ o.hdr = hdrCapability{Capable: true}
153+ o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
154+ o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
155+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}
156+ r := portsmocks.NewMockRenderer(t)
157+ buf := func() ports.DMABuf {
158+ f, w, _ := os.Pipe()
159+ w.Close()
160+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
161+ }
162+ r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Once()
163+ r.EXPECT().ExportTargets(2, []uint64{19}).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
164+ r.EXPECT().UseTarget(mock.Anything).Return()
165+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
166+ k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Twice()
167+ k.EXPECT().createBlob(mock.Anything).Return(321, nil).Once()
168+ k.EXPECT().createBlob(mock.Anything).Return(11, nil).Twice()
169+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
170+ if err := o.showImages(r, imagesDriver, nil); err != nil {
171+ t.Fatal(err)
172+ }
173+ if !o.hdrOn || o.hdrFailed || len(*commits) != 2 {
174+ t.Fatalf("on=%t failed=%t commits=%d", o.hdrOn, o.hdrFailed, len(*commits))
175+ }
176+ for _, c := range *commits {
177+ for _, tc := range []struct {
178+ prop uint32
179+ want uint64
180+ }{{100, 321}, {101, 7}, {102, 10}} {
181+ if got, ok := c.req.value(o.conn.id, tc.prop); !ok || got != tc.want {
182+ t.Fatalf("commit prop %d: %d (%t), want %d", tc.prop, got, ok, tc.want)
183+ }
184+ }
185+ }
186+}
187+
188+// The metadata blob is retained across repeated image setup (e.g. VT resume)
189+// and destroyed only once when Close restores SDR.
190+func TestHDRBlobReusedAcrossImageSetup(t *testing.T) {
191+ o, k, commits := testOutput(t)
192+ o.cursor = nil
193+ o.hdr = hdrCapability{Capable: true}
194+ o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
195+ o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
196+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}
197+ r := portsmocks.NewMockRenderer(t)
198+ buf := func() ports.DMABuf {
199+ f, w, _ := os.Pipe()
200+ w.Close()
201+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
202+ }
203+ r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Twice()
204+ r.EXPECT().ExportTargets(2, []uint64{19}).RunAndReturn(func(int, []uint64) ([]ports.DMABuf, error) { return []ports.DMABuf{buf(), buf()}, nil }).Twice()
205+ r.EXPECT().UseTarget(mock.Anything).Return()
206+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
207+ k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)
208+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
209+ created, destroyed := 0, 0
210+ k.EXPECT().createBlob(mock.Anything).RunAndReturn(func(data []byte) (uint32, error) {
211+ if len(data) == 32 {
212+ created++
213+ return 321, nil
214+ }
215+ return 11, nil
216+ })
217+ k.EXPECT().destroyBlob(mock.Anything).RunAndReturn(func(id uint32) error {
218+ if id == 321 {
219+ destroyed++
220+ }
221+ return nil
222+ })
223+ for i := 0; i < 2; i++ {
224+ if err := o.showImages(r, imagesDriver, nil); err != nil {
225+ t.Fatal(err)
226+ }
227+ if o.hdrBlob != 321 || created != 1 || destroyed != 0 {
228+ t.Fatalf("pass %d blob %d created %d destroyed %d", i, o.hdrBlob, created, destroyed)
229+ }
230+ }
231+ if len(*commits) != 4 {
232+ t.Fatalf("commits: %d", len(*commits))
233+ }
234+ o.Close()
235+ if created != 1 || destroyed != 1 {
236+ t.Fatalf("created %d destroyed %d", created, destroyed)
237+ }
238+}
239+
240+func TestHDRBlobReplacementWaitsForCommit(t *testing.T) {
241+ o, k, _ := testOutput(t)
242+ o.cursor = nil
243+ o.hdr = hdrCapability{Capable: true}
244+ o.hdrSettings = HDRSettings{Enabled: true, SDRBrightness: ports.DefaultSDRBrightness}
245+ o.hdrProps = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
246+ o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 19}}
247+ r := portsmocks.NewMockRenderer(t)
248+ buf := func() ports.DMABuf {
249+ f, w, _ := os.Pipe()
250+ w.Close()
251+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
252+ }
253+ r.EXPECT().SetHDR(float64(ports.DefaultSDRBrightness)).Return().Twice()
254+ r.EXPECT().ExportTargets(2, []uint64{19}).RunAndReturn(func(int, []uint64) ([]ports.DMABuf, error) { return []ports.DMABuf{buf(), buf()}, nil }).Twice()
255+ r.EXPECT().UseTarget(mock.Anything).Return()
256+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
257+ k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil)
258+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
259+ metadata := uint32(320)
260+ k.EXPECT().createBlob(mock.Anything).RunAndReturn(func(data []byte) (uint32, error) {
261+ if len(data) == 32 {
262+ metadata++
263+ return metadata, nil
264+ }
265+ return 11, nil
266+ })
267+ var destroyed []uint32
268+ k.EXPECT().destroyBlob(mock.Anything).RunAndReturn(func(id uint32) error {
269+ if id >= 321 {
270+ destroyed = append(destroyed, id)
271+ }
272+ return nil
273+ })
274+ if err := o.showImages(r, imagesDriver, nil); err != nil {
275+ t.Fatal(err)
276+ }
277+ o.monitor.HDR.MaxLuminance = 1000
278+ if err := o.showImages(r, imagesDriver, nil); err != nil {
279+ t.Fatal(err)
280+ }
281+ if o.hdrBlob != 322 || !slices.Equal(destroyed, []uint32{321}) {
282+ t.Fatalf("blob %d destroyed %v", o.hdrBlob, destroyed)
283+ }
284+ o.Close()
285+ if !slices.Equal(destroyed, []uint32{321, 322}) {
286+ t.Fatalf("destroyed %v", destroyed)
287+ }
288+}
M internal/adapters/drm/kms.go +8 -0
@@ -94,6 +94,8 @@ type Want struct {
9494 Disabled map[string]bool
9595 // Modes maps a connector to W, H and Hz (0: highest refresh at W x H).
9696 Modes map[string][3]float64
97+ // HDR settings are retained per connector; phase 1 never applies them to KMS.
98+ HDR map[string]HDRSettings
9799 // NoScanout disables direct scanout (render.direct-scanout = off).
98100 NoScanout bool
99101 // NoTearing ignores tearing requests (render.tearing = off).
@@ -107,6 +109,12 @@ type Want struct {
107109 Device uint64
108110 }
109111
112+// HDRSettings is the requested HDR state and SDR reference white (nits).
113+type HDRSettings struct {
114+ Enabled bool
115+ SDRBrightness int
116+}
117+
110118 // usable reports whether a connector should be driven.
111119 func (w Want) usable(c connector) bool {
112120 return c.connected && len(c.modes) > 0 && !w.Disabled[c.name]
@@ -94,6 +94,8 @@ type Want struct {
94 Disabled map[string]bool94 Disabled map[string]bool
95 // Modes maps a connector to W, H and Hz (0: highest refresh at W x H).95 // Modes maps a connector to W, H and Hz (0: highest refresh at W x H).
96 Modes map[string][3]float6496 Modes map[string][3]float64
97+ // HDR settings are retained per connector; phase 1 never applies them to KMS.
98+ HDR map[string]HDRSettings
97 // NoScanout disables direct scanout (render.direct-scanout = off).99 // NoScanout disables direct scanout (render.direct-scanout = off).
98 NoScanout bool100 NoScanout bool
99 // NoTearing ignores tearing requests (render.tearing = off).101 // NoTearing ignores tearing requests (render.tearing = off).
@@ -107,6 +109,12 @@ type Want struct {
107 Device uint64109 Device uint64
108 }110 }
109 111
112+// HDRSettings is the requested HDR state and SDR reference white (nits).
113+type HDRSettings struct {
114+ Enabled bool
115+ SDRBrightness int
116+}
117+
110 // usable reports whether a connector should be driven.118 // usable reports whether a connector should be driven.
111 func (w Want) usable(c connector) bool {119 func (w Want) usable(c connector) bool {
112 return c.connected && len(c.modes) > 0 && !w.Disabled[c.name]120 return c.connected && len(c.modes) > 0 && !w.Disabled[c.name]
M internal/adapters/drm/output.go +126 -15
@@ -26,14 +26,22 @@ import (
2626 // it with EBUSY (it retries soon), and an event that never comes is
2727 // bounded (stuckTimeout, then a modeset).
2828 type Output struct {
29- k kms
30- flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
31- crtc uint32
32- conn connector
33- mode modeInfo
34- saved modeCrtc
35- monitor Monitor
36- log zerowrap.Logger
29+ k kms
30+ flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
31+ crtc uint32
32+ conn connector
33+ mode modeInfo
34+ saved modeCrtc
35+ monitor Monitor
36+ hdr hdrCapability
37+ hdrProps connectorHDRProps
38+ hdrSettings HDRSettings
39+ // hdrFailed disables retries until this Output is replaced; hdrOn is
40+ // the currently selected signal encoding, including on VT resume.
41+ hdrOn, hdrFailed bool
42+ hdrBlob uint32
43+ hdrBlobData hdrOutputMetadata
44+ log zerowrap.Logger
3745 // Properties: CRTC and connector property IDs by name.
3846 crtcProps map[string]uint32
3947 connCrtc uint32 // the connector's CRTC_ID property
@@ -283,9 +291,15 @@ func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, er
283291 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
284292 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
285293 }
286- if err := o.readProps(pipe, card.taken, cursorSize(card.fd)); err != nil {
294+ if err := o.readProps(card.fd, pipe, card.taken, cursorSize(card.fd)); err != nil {
287295 return nil, err
288296 }
297+ o.hdrSettings = normalizedHDRSettings(card.want.HDR[c.name])
298+ o.hdr = detectHDR(o.monitor, o.hdrProps)
299+ log.Info().Str("connector", c.name).Bool("hdr_capable", o.hdr.Capable).Str("reason", o.hdr.Reason).Float64("max_luminance", o.hdr.MaxLuminance).Float64("max_frame_average", o.hdr.MaxFrameAverage).Float64("min_luminance", o.hdr.MinLuminance).Msg("HDR capability")
300+ if o.hdrSettings.Enabled && !o.hdr.Capable {
301+ log.Warn().Str("connector", c.name).Str("reason", o.hdr.Reason).Msg("HDR requested but unavailable")
302+ }
289303 o.tearing = card.async && !card.want.NoTearing
290304 if !card.want.NoVRR {
291305 o.vrrProp = vrrProperty(o.k, c.id, crtc)
@@ -296,7 +310,7 @@ func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, er
296310
297311 // readProps finds the output's planes and property IDs. taken are planes
298312 // other outputs drive.
299-func (o *Output) readProps(pipe int, taken map[uint32]bool, cursorSide int) error {
313+func (o *Output) readProps(fd, pipe int, taken map[uint32]bool, cursorSide int) error {
300314 cp, err := o.k.objProps(o.crtc, objCrtc)
301315 if err != nil {
302316 return fmt.Errorf("crtc properties: %w", err)
@@ -310,6 +324,8 @@ func (o *Output) readProps(pipe int, taken map[uint32]bool, cursorSide int) erro
310324 return fmt.Errorf("connector properties: %w", err)
311325 }
312326 o.connCrtc = uint32(np["CRTC_ID"][0])
327+ // The connector's property metadata is needed for enum/range capability.
328+ o.hdrProps = readConnectorHDRProps(fd, np)
313329 if o.crtcProps["MODE_ID"] == 0 || o.crtcProps["ACTIVE"] == 0 || o.connCrtc == 0 {
314330 return errors.New("crtc or connector lacks atomic properties")
315331 }
@@ -467,7 +483,7 @@ func (o *Output) sendFormats() {
467483 return
468484 }
469485 f := ports.OutputFormats{Output: o.conn.name, Device: o.device}
470- if o.scanout {
486+ if o.scanout && !o.hdrOn {
471487 f.Formats = o.scanoutFormats(o.sampled)
472488 }
473489 select {
@@ -528,6 +544,7 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
528544 req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active))
529545 req.set(o.crtc, o.vrrProp, 0)
530546 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
547+ o.hdrConnectorProps(req, o.hdrOn)
531548 o.primaryProps(req, o.fbs[1-o.back])
532549 if o.cursor != nil {
533550 o.cursor.props(req, o.crtc, cursorState{})
@@ -712,7 +729,9 @@ func boolValue(b bool) uint64 {
712729 // composed.
713730 func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) {
714731 reason := "disabled"
715- if o.scanout {
732+ if o.hdrOn {
733+ reason = "hdr"
734+ } else if o.scanout {
716735 c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
717736 }
718737 if reason == "" {
@@ -784,6 +803,7 @@ func (o *Output) Close() {
784803 o.cursor.props(req, o.crtc, cursorState{})
785804 }
786805 o.overlayProps(req, overlayWin{})
806+ o.hdrConnectorProps(req, false)
787807 req.set(o.crtc, o.vrrProp, 0)
788808 s := o.saved
789809 var blob uint32
@@ -815,9 +835,20 @@ func (o *Output) Close() {
815835 req.set(p.id, p.prop("CRTC_ID"), 0)
816836 req.set(o.conn.id, o.connCrtc, 0)
817837 }
838+ // A live metadata blob means an HDR modeset may still be on screen, even
839+ // after a failed SDR fallback (hdrOn is then false): it is freed only once
840+ // an SDR modeset succeeds.
841+ hdrShown := o.hdrOn || o.hdrBlob != 0
818842 // Best effort: at exit the card may already belong to another session.
819843 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
820- o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("restore crtc")
844+ ev := o.log.Debug()
845+ if hdrShown {
846+ // The display may stay in HDR mode until the next modeset.
847+ ev = o.log.Warn()
848+ }
849+ ev.Err(err).Str("connector", o.conn.name).Bool("hdr", hdrShown).Msg("restore crtc")
850+ } else if hdrShown {
851+ o.log.Info().Str("connector", o.conn.name).Msg("HDR10 off")
821852 }
822853 if blob != 0 {
823854 _ = o.k.destroyBlob(blob)
@@ -826,6 +857,10 @@ func (o *Output) Close() {
826857 _ = o.k.destroyBlob(o.modeBlob)
827858 o.modeBlob = 0
828859 }
860+ if o.hdrBlob != 0 {
861+ _ = o.k.destroyBlob(o.hdrBlob)
862+ o.hdrBlob = 0
863+ }
829864 if o.cursor != nil {
830865 o.cursor.free(o.k)
831866 }
@@ -1283,6 +1318,63 @@ const (
12831318 // next kind is tried on refusal. cause is why the
12841319 // previous kind failed, for the log.
12851320 func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error {
1321+ if o.hdrSettings.Enabled && o.hdr.Capable && !o.hdrFailed {
1322+ o.hdrOn = true
1323+ meta := hdrMetadata(o.monitor)
1324+ // Reuse the live blob across VT resume. A changed EDID creates a
1325+ // replacement, but the old one remains alive until KMS accepts it.
1326+ oldBlob := o.hdrBlob
1327+ created := false
1328+ var err error
1329+ if oldBlob == 0 || meta != o.hdrBlobData {
1330+ var newBlob uint32
1331+ newBlob, err = o.k.createBlob(meta.bytes())
1332+ if err == nil {
1333+ o.hdrBlob = newBlob
1334+ created = true
1335+ }
1336+ }
1337+ if err == nil {
1338+ r.SetHDR(float64(o.hdrSettings.SDRBrightness))
1339+ err = o.showImageKind(r, imagesDriver, nil)
1340+ }
1341+ if err == nil {
1342+ if created {
1343+ o.hdrBlobData = meta
1344+ if oldBlob != 0 {
1345+ _ = o.k.destroyBlob(oldBlob)
1346+ }
1347+ }
1348+ o.log.Info().Str("connector", o.conn.name).Int("sdr_brightness", o.hdrSettings.SDRBrightness).Float64("max_luminance", o.hdr.MaxLuminance).Float64("max_frame_average", o.hdr.MaxFrameAverage).Msg("HDR10 on")
1349+ return nil
1350+ }
1351+ if created && oldBlob != 0 {
1352+ // Failed replacement: the existing modeset may still use it.
1353+ _ = o.k.destroyBlob(o.hdrBlob)
1354+ o.hdrBlob = oldBlob
1355+ }
1356+ o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("HDR modeset unavailable; falling back to SDR")
1357+ o.hdrFailed = true
1358+ o.hdrOn = false
1359+ o.freeImages()
1360+ r.SetHDR(0)
1361+ _, _ = r.ExportTargets(0, nil)
1362+ // A failed test commit can leave the old HDR mode on screen;
1363+ // retain its blob until the SDR modeset succeeds or Close restores it.
1364+ } else {
1365+ r.SetHDR(0)
1366+ }
1367+ err := o.showImageKind(r, kind, cause)
1368+ if err == nil && o.hdrFailed && o.hdrBlob != 0 {
1369+ // The SDR commit has completed; the old HDR metadata is no longer in use.
1370+ _ = o.k.destroyBlob(o.hdrBlob)
1371+ o.hdrBlob = 0
1372+ o.hdrBlobData = hdrOutputMetadata{}
1373+ }
1374+ return err
1375+}
1376+
1377+func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) error {
12861378 for {
12871379 got, err := o.setupImages(r, kind, cause)
12881380 if err != nil {
@@ -1319,7 +1411,22 @@ func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (ima
13191411 err := cause
13201412 for ; kind <= imagesLinear; kind++ {
13211413 mods := []uint64(nil)
1322- if kind == imagesLinear {
1414+ if o.hdrOn {
1415+ for _, f := range o.primary.formats {
1416+ if f.Format == fourccXR30 {
1417+ mods = append(mods, f.Modifier)
1418+ }
1419+ }
1420+ if len(mods) == 0 {
1421+ return kind, fmt.Errorf("primary plane has no XRGB2101010 modifiers")
1422+ }
1423+ if kind == imagesLinear {
1424+ if !slices.Contains(mods, uint64(0)) {
1425+ continue
1426+ }
1427+ mods = []uint64{0}
1428+ }
1429+ } else if kind == imagesLinear {
13231430 mods = []uint64{0}
13241431 }
13251432 if err = o.exportImages(r, mods); err == nil {
@@ -1338,7 +1445,11 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
13381445 }
13391446 for i := range bufs {
13401447 if err == nil {
1341- o.fbs[i], err = o.k.addFB(&bufs[i], fourccXRGB)
1448+ format := uint32(fourccXRGB)
1449+ if o.hdrOn {
1450+ format = fourccXR30
1451+ }
1452+ o.fbs[i], err = o.k.addFB(&bufs[i], format)
13421453 }
13431454 bufs[i].Planes[0].File.Close()
13441455 }
@@ -26,14 +26,22 @@ import (
26 // it with EBUSY (it retries soon), and an event that never comes is26 // it with EBUSY (it retries soon), and an event that never comes is
27 // bounded (stuckTimeout, then a modeset).27 // bounded (stuckTimeout, then a modeset).
28 type Output struct {28 type Output struct {
29- k kms29+ k kms
30- flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents30+ flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
31- crtc uint3231+ crtc uint32
32- conn connector32+ conn connector
33- mode modeInfo33+ mode modeInfo
34- saved modeCrtc34+ saved modeCrtc
35- monitor Monitor35+ monitor Monitor
36- log zerowrap.Logger36+ hdr hdrCapability
37+ hdrProps connectorHDRProps
38+ hdrSettings HDRSettings
39+ // hdrFailed disables retries until this Output is replaced; hdrOn is
40+ // the currently selected signal encoding, including on VT resume.
41+ hdrOn, hdrFailed bool
42+ hdrBlob uint32
43+ hdrBlobData hdrOutputMetadata
44+ log zerowrap.Logger
37 // Properties: CRTC and connector property IDs by name.45 // Properties: CRTC and connector property IDs by name.
38 crtcProps map[string]uint3246 crtcProps map[string]uint32
39 connCrtc uint32 // the connector's CRTC_ID property47 connCrtc uint32 // the connector's CRTC_ID property
@@ -283,9 +291,15 @@ func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, er
283 if o.saved, err = getCrtc(card.fd, crtc); err != nil {291 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
284 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")292 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
285 }293 }
286- if err := o.readProps(pipe, card.taken, cursorSize(card.fd)); err != nil {294+ if err := o.readProps(card.fd, pipe, card.taken, cursorSize(card.fd)); err != nil {
287 return nil, err295 return nil, err
288 }296 }
297+ o.hdrSettings = normalizedHDRSettings(card.want.HDR[c.name])
298+ o.hdr = detectHDR(o.monitor, o.hdrProps)
299+ log.Info().Str("connector", c.name).Bool("hdr_capable", o.hdr.Capable).Str("reason", o.hdr.Reason).Float64("max_luminance", o.hdr.MaxLuminance).Float64("max_frame_average", o.hdr.MaxFrameAverage).Float64("min_luminance", o.hdr.MinLuminance).Msg("HDR capability")
300+ if o.hdrSettings.Enabled && !o.hdr.Capable {
301+ log.Warn().Str("connector", c.name).Str("reason", o.hdr.Reason).Msg("HDR requested but unavailable")
302+ }
289 o.tearing = card.async && !card.want.NoTearing303 o.tearing = card.async && !card.want.NoTearing
290 if !card.want.NoVRR {304 if !card.want.NoVRR {
291 o.vrrProp = vrrProperty(o.k, c.id, crtc)305 o.vrrProp = vrrProperty(o.k, c.id, crtc)
@@ -296,7 +310,7 @@ func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, er
296 310
297 // readProps finds the output's planes and property IDs. taken are planes311 // readProps finds the output's planes and property IDs. taken are planes
298 // other outputs drive.312 // other outputs drive.
299-func (o *Output) readProps(pipe int, taken map[uint32]bool, cursorSide int) error {313+func (o *Output) readProps(fd, pipe int, taken map[uint32]bool, cursorSide int) error {
300 cp, err := o.k.objProps(o.crtc, objCrtc)314 cp, err := o.k.objProps(o.crtc, objCrtc)
301 if err != nil {315 if err != nil {
302 return fmt.Errorf("crtc properties: %w", err)316 return fmt.Errorf("crtc properties: %w", err)
@@ -310,6 +324,8 @@ func (o *Output) readProps(pipe int, taken map[uint32]bool, cursorSide int) erro
310 return fmt.Errorf("connector properties: %w", err)324 return fmt.Errorf("connector properties: %w", err)
311 }325 }
312 o.connCrtc = uint32(np["CRTC_ID"][0])326 o.connCrtc = uint32(np["CRTC_ID"][0])
327+ // The connector's property metadata is needed for enum/range capability.
328+ o.hdrProps = readConnectorHDRProps(fd, np)
313 if o.crtcProps["MODE_ID"] == 0 || o.crtcProps["ACTIVE"] == 0 || o.connCrtc == 0 {329 if o.crtcProps["MODE_ID"] == 0 || o.crtcProps["ACTIVE"] == 0 || o.connCrtc == 0 {
314 return errors.New("crtc or connector lacks atomic properties")330 return errors.New("crtc or connector lacks atomic properties")
315 }331 }
@@ -467,7 +483,7 @@ func (o *Output) sendFormats() {
467 return483 return
468 }484 }
469 f := ports.OutputFormats{Output: o.conn.name, Device: o.device}485 f := ports.OutputFormats{Output: o.conn.name, Device: o.device}
470- if o.scanout {486+ if o.scanout && !o.hdrOn {
471 f.Formats = o.scanoutFormats(o.sampled)487 f.Formats = o.scanoutFormats(o.sampled)
472 }488 }
473 select {489 select {
@@ -528,6 +544,7 @@ func (o *Output) modesetReq(blob uint32, active bool) *atomicReq {
528 req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active))544 req.set(o.crtc, o.crtcProps["ACTIVE"], boolValue(active))
529 req.set(o.crtc, o.vrrProp, 0)545 req.set(o.crtc, o.vrrProp, 0)
530 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))546 req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
547+ o.hdrConnectorProps(req, o.hdrOn)
531 o.primaryProps(req, o.fbs[1-o.back])548 o.primaryProps(req, o.fbs[1-o.back])
532 if o.cursor != nil {549 if o.cursor != nil {
533 o.cursor.props(req, o.crtc, cursorState{})550 o.cursor.props(req, o.crtc, cursorState{})
@@ -712,7 +729,9 @@ func boolValue(b bool) uint64 {
712 // composed.729 // composed.
713 func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) {730 func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) {
714 reason := "disabled"731 reason := "disabled"
715- if o.scanout {732+ if o.hdrOn {
733+ reason = "hdr"
734+ } else if o.scanout {
716 c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())735 c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
717 }736 }
718 if reason == "" {737 if reason == "" {
@@ -784,6 +803,7 @@ func (o *Output) Close() {
784 o.cursor.props(req, o.crtc, cursorState{})803 o.cursor.props(req, o.crtc, cursorState{})
785 }804 }
786 o.overlayProps(req, overlayWin{})805 o.overlayProps(req, overlayWin{})
806+ o.hdrConnectorProps(req, false)
787 req.set(o.crtc, o.vrrProp, 0)807 req.set(o.crtc, o.vrrProp, 0)
788 s := o.saved808 s := o.saved
789 var blob uint32809 var blob uint32
@@ -815,9 +835,20 @@ func (o *Output) Close() {
815 req.set(p.id, p.prop("CRTC_ID"), 0)835 req.set(p.id, p.prop("CRTC_ID"), 0)
816 req.set(o.conn.id, o.connCrtc, 0)836 req.set(o.conn.id, o.connCrtc, 0)
817 }837 }
838+ // A live metadata blob means an HDR modeset may still be on screen, even
839+ // after a failed SDR fallback (hdrOn is then false): it is freed only once
840+ // an SDR modeset succeeds.
841+ hdrShown := o.hdrOn || o.hdrBlob != 0
818 // Best effort: at exit the card may already belong to another session.842 // Best effort: at exit the card may already belong to another session.
819 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {843 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
820- o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("restore crtc")844+ ev := o.log.Debug()
845+ if hdrShown {
846+ // The display may stay in HDR mode until the next modeset.
847+ ev = o.log.Warn()
848+ }
849+ ev.Err(err).Str("connector", o.conn.name).Bool("hdr", hdrShown).Msg("restore crtc")
850+ } else if hdrShown {
851+ o.log.Info().Str("connector", o.conn.name).Msg("HDR10 off")
821 }852 }
822 if blob != 0 {853 if blob != 0 {
823 _ = o.k.destroyBlob(blob)854 _ = o.k.destroyBlob(blob)
@@ -826,6 +857,10 @@ func (o *Output) Close() {
826 _ = o.k.destroyBlob(o.modeBlob)857 _ = o.k.destroyBlob(o.modeBlob)
827 o.modeBlob = 0858 o.modeBlob = 0
828 }859 }
860+ if o.hdrBlob != 0 {
861+ _ = o.k.destroyBlob(o.hdrBlob)
862+ o.hdrBlob = 0
863+ }
829 if o.cursor != nil {864 if o.cursor != nil {
830 o.cursor.free(o.k)865 o.cursor.free(o.k)
831 }866 }
@@ -1283,6 +1318,63 @@ const (
1283 // next kind is tried on refusal. cause is why the1318 // next kind is tried on refusal. cause is why the
1284 // previous kind failed, for the log.1319 // previous kind failed, for the log.
1285 func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error {1320 func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error {
1321+ if o.hdrSettings.Enabled && o.hdr.Capable && !o.hdrFailed {
1322+ o.hdrOn = true
1323+ meta := hdrMetadata(o.monitor)
1324+ // Reuse the live blob across VT resume. A changed EDID creates a
1325+ // replacement, but the old one remains alive until KMS accepts it.
1326+ oldBlob := o.hdrBlob
1327+ created := false
1328+ var err error
1329+ if oldBlob == 0 || meta != o.hdrBlobData {
1330+ var newBlob uint32
1331+ newBlob, err = o.k.createBlob(meta.bytes())
1332+ if err == nil {
1333+ o.hdrBlob = newBlob
1334+ created = true
1335+ }
1336+ }
1337+ if err == nil {
1338+ r.SetHDR(float64(o.hdrSettings.SDRBrightness))
1339+ err = o.showImageKind(r, imagesDriver, nil)
1340+ }
1341+ if err == nil {
1342+ if created {
1343+ o.hdrBlobData = meta
1344+ if oldBlob != 0 {
1345+ _ = o.k.destroyBlob(oldBlob)
1346+ }
1347+ }
1348+ o.log.Info().Str("connector", o.conn.name).Int("sdr_brightness", o.hdrSettings.SDRBrightness).Float64("max_luminance", o.hdr.MaxLuminance).Float64("max_frame_average", o.hdr.MaxFrameAverage).Msg("HDR10 on")
1349+ return nil
1350+ }
1351+ if created && oldBlob != 0 {
1352+ // Failed replacement: the existing modeset may still use it.
1353+ _ = o.k.destroyBlob(o.hdrBlob)
1354+ o.hdrBlob = oldBlob
1355+ }
1356+ o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("HDR modeset unavailable; falling back to SDR")
1357+ o.hdrFailed = true
1358+ o.hdrOn = false
1359+ o.freeImages()
1360+ r.SetHDR(0)
1361+ _, _ = r.ExportTargets(0, nil)
1362+ // A failed test commit can leave the old HDR mode on screen;
1363+ // retain its blob until the SDR modeset succeeds or Close restores it.
1364+ } else {
1365+ r.SetHDR(0)
1366+ }
1367+ err := o.showImageKind(r, kind, cause)
1368+ if err == nil && o.hdrFailed && o.hdrBlob != 0 {
1369+ // The SDR commit has completed; the old HDR metadata is no longer in use.
1370+ _ = o.k.destroyBlob(o.hdrBlob)
1371+ o.hdrBlob = 0
1372+ o.hdrBlobData = hdrOutputMetadata{}
1373+ }
1374+ return err
1375+}
1376+
1377+func (o *Output) showImageKind(r ports.Renderer, kind imageKind, cause error) error {
1286 for {1378 for {
1287 got, err := o.setupImages(r, kind, cause)1379 got, err := o.setupImages(r, kind, cause)
1288 if err != nil {1380 if err != nil {
@@ -1319,7 +1411,22 @@ func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (ima
1319 err := cause1411 err := cause
1320 for ; kind <= imagesLinear; kind++ {1412 for ; kind <= imagesLinear; kind++ {
1321 mods := []uint64(nil)1413 mods := []uint64(nil)
1322- if kind == imagesLinear {1414+ if o.hdrOn {
1415+ for _, f := range o.primary.formats {
1416+ if f.Format == fourccXR30 {
1417+ mods = append(mods, f.Modifier)
1418+ }
1419+ }
1420+ if len(mods) == 0 {
1421+ return kind, fmt.Errorf("primary plane has no XRGB2101010 modifiers")
1422+ }
1423+ if kind == imagesLinear {
1424+ if !slices.Contains(mods, uint64(0)) {
1425+ continue
1426+ }
1427+ mods = []uint64{0}
1428+ }
1429+ } else if kind == imagesLinear {
1323 mods = []uint64{0}1430 mods = []uint64{0}
1324 }1431 }
1325 if err = o.exportImages(r, mods); err == nil {1432 if err = o.exportImages(r, mods); err == nil {
@@ -1338,7 +1445,11 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
1338 }1445 }
1339 for i := range bufs {1446 for i := range bufs {
1340 if err == nil {1447 if err == nil {
1341- o.fbs[i], err = o.k.addFB(&bufs[i], fourccXRGB)1448+ format := uint32(fourccXRGB)
1449+ if o.hdrOn {
1450+ format = fourccXR30
1451+ }
1452+ o.fbs[i], err = o.k.addFB(&bufs[i], format)
1342 }1453 }
1343 bufs[i].Planes[0].File.Close()1454 bufs[i].Planes[0].File.Close()
1344 }1455 }
M internal/adapters/drm/output_test.go +2 -0
@@ -366,6 +366,7 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
366366 w.Close()
367367 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
368368 }
369+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
369370 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
370371 r.EXPECT().UseTarget(mock.Anything).Return()
371372 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
@@ -461,6 +462,7 @@ func TestCaptureForcesDRMComposition(t *testing.T) {
461462 w.Close()
462463 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
463464 }
465+ r.EXPECT().SetHDR(float64(0)).Return().Maybe() // SDR output
464466 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{makeBuf(), makeBuf()}, nil).Once()
465467 r.EXPECT().UseTarget(mock.Anything).Return()
466468 rendered := make(chan ports.Scene, 3)
@@ -366,6 +366,7 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
366 w.Close()366 w.Close()
367 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}367 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
368 }368 }
369+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
369 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()370 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
370 r.EXPECT().UseTarget(mock.Anything).Return()371 r.EXPECT().UseTarget(mock.Anything).Return()
371 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {372 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
@@ -461,6 +462,7 @@ func TestCaptureForcesDRMComposition(t *testing.T) {
461 w.Close()462 w.Close()
462 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}463 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
463 }464 }
465+ r.EXPECT().SetHDR(float64(0)).Return().Maybe() // SDR output
464 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{makeBuf(), makeBuf()}, nil).Once()466 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{makeBuf(), makeBuf()}, nil).Once()
465 r.EXPECT().UseTarget(mock.Anything).Return()467 r.EXPECT().UseTarget(mock.Anything).Return()
466 rendered := make(chan ports.Scene, 3)468 rendered := make(chan ports.Scene, 3)
M internal/adapters/drm/overlay.go +3 -1
@@ -113,7 +113,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
113113 func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) {
114114 reason := "no_plane"
115115 var ov overlayWin
116- if o.overlay != nil && o.scanout {
116+ if o.hdrOn {
117+ reason = "hdr"
118+ } else if o.overlay != nil && o.scanout {
117119 var w ports.SceneWindow
118120 var c ports.SurfaceContent
119121 w, c, reason = overlayCandidate(s, surfaces)
@@ -113,7 +113,9 @@ func overlayCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
113 func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) {113 func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (overlayWin, ports.Scene) {
114 reason := "no_plane"114 reason := "no_plane"
115 var ov overlayWin115 var ov overlayWin
116- if o.overlay != nil && o.scanout {116+ if o.hdrOn {
117+ reason = "hdr"
118+ } else if o.overlay != nil && o.scanout {
117 var w ports.SceneWindow119 var w ports.SceneWindow
118 var c ports.SurfaceContent120 var c ports.SurfaceContent
119 w, c, reason = overlayCandidate(s, surfaces)121 w, c, reason = overlayCandidate(s, surfaces)
M internal/adapters/drm/power_test.go +1 -0
@@ -24,6 +24,7 @@ func TestRunOutputPower(t *testing.T) {
2424 w.Close()
2525 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
2626 }
27+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
2728 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
2829 r.EXPECT().UseTarget(mock.Anything).Return()
2930 renders := make(chan struct{}, 8)
@@ -24,6 +24,7 @@ func TestRunOutputPower(t *testing.T) {
24 w.Close()24 w.Close()
25 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}25 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
26 }26 }
27+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
27 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()28 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
28 r.EXPECT().UseTarget(mock.Anything).Return()29 r.EXPECT().UseTarget(mock.Anything).Return()
29 renders := make(chan struct{}, 8)30 renders := make(chan struct{}, 8)
M internal/adapters/drm/props.go +98 -0
@@ -1,6 +1,7 @@
11 package drm
22
33 import (
4+ "runtime"
45 "unsafe"
56
67 "golang.org/x/sys/unix"
@@ -22,6 +23,11 @@ type objGetProps struct {
2223 objType, _ uint32
2324 }
2425
26+type propEnum struct {
27+ value uint64
28+ name [32]byte
29+}
30+
2531 type getProp struct {
2632 values, enumBlobs uint64
2733 propID, flags uint32
@@ -55,6 +61,98 @@ func objProps(fd int, obj, typ uint32) (map[string][2]uint64, error) {
5561 return out, nil
5662 }
5763
64+// connectorHDRProps retains connector property IDs, enum values and the
65+// original max bpc for SDR modesets and restoration.
66+type connectorHDRProps struct {
67+ Metadata, Colorspace, MaxBPC uint32
68+ BT2020Value uint64
69+ DefaultValue uint64 // connector Colorspace "Default" enum
70+ HasDefault bool
71+ MaxBPCValue uint64 // current value for eventual restoration
72+}
73+
74+// readConnectorHDRProps checks property metadata, not only current values.
75+func readConnectorHDRProps(fd int, props map[string][2]uint64) connectorHDRProps {
76+ var out connectorHDRProps
77+ out.Metadata = uint32(props["HDR_OUTPUT_METADATA"][0])
78+ if id := uint32(props["Colorspace"][0]); id != 0 {
79+ p := getProp{propID: id}
80+ if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil && p.countEn > 0 {
81+ enums := make([]propEnum, p.countEn)
82+ p.enumBlobs = uint64(uintptr(unsafe.Pointer(&enums[0])))
83+ // The kernel also copies an enum's values when countValues is
84+ // set: a nil values pointer makes the whole call fail (EFAULT).
85+ values := make([]uint64, max(p.countValues, 1))
86+ p.values = uint64(uintptr(unsafe.Pointer(&values[0])))
87+ if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil {
88+ out.Colorspace, out.BT2020Value = bt2020Enum(id, enums[:min(int(p.countEn), len(enums))])
89+ for _, e := range enums[:min(int(p.countEn), len(enums))] {
90+ if unix.ByteSliceToString(e.name[:]) == "Default" {
91+ out.DefaultValue, out.HasDefault = e.value, true
92+ }
93+ }
94+ }
95+ runtime.KeepAlive(enums)
96+ runtime.KeepAlive(values)
97+ }
98+ }
99+ if id := uint32(props["max bpc"][0]); id != 0 {
100+ p := getProp{propID: id}
101+ if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil && p.countValues >= 2 {
102+ values := make([]uint64, p.countValues)
103+ p.values = uint64(uintptr(unsafe.Pointer(&values[0])))
104+ if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil && p.countValues >= 2 && bpcAllows10(values) {
105+ out.MaxBPC, out.MaxBPCValue = id, props["max bpc"][1]
106+ }
107+ runtime.KeepAlive(values)
108+ }
109+ }
110+ return out
111+}
112+
113+func bt2020Enum(id uint32, enums []propEnum) (uint32, uint64) {
114+ for _, e := range enums {
115+ if unix.ByteSliceToString(e.name[:]) == "BT2020_RGB" {
116+ return id, e.value
117+ }
118+ }
119+ return 0, 0
120+}
121+
122+func bpcAllows10(values []uint64) bool {
123+ return len(values) >= 2 && values[0] <= 10 && values[1] >= 10
124+}
125+
126+// hdrCapability combines EDID and connector support, retaining the first reason.
127+type hdrCapability struct {
128+ Capable bool
129+ Reason string
130+ HDRMetadata
131+}
132+
133+func detectHDR(m Monitor, p connectorHDRProps) hdrCapability {
134+ c := hdrCapability{HDRMetadata: m.HDR}
135+ switch {
136+ case !m.HDR.PQ:
137+ c.Reason = "edid: no PQ"
138+ case !m.HDR.StaticType1:
139+ c.Reason = "edid: no static metadata type 1"
140+ case !m.HDR.BT2020RGB:
141+ c.Reason = "edid: no BT2020RGB"
142+ case p.Metadata == 0:
143+ c.Reason = "connector: no HDR_OUTPUT_METADATA"
144+ case p.Colorspace == 0:
145+ c.Reason = "connector: no BT2020_RGB Colorspace"
146+ case p.MaxBPC == 0:
147+ c.Reason = "connector: max bpc below 10"
148+ case !p.HasDefault:
149+ c.Reason = "connector: no Default Colorspace"
150+ default:
151+ c.Capable = true
152+ }
153+ return c
154+}
155+
58156 // vrrProperty returns the CRTC's VRR_ENABLED property when the connector
59157 // is VRR capable, else 0.
60158 func vrrProperty(k kms, conn, crtc uint32) uint32 {
@@ -1,6 +1,7 @@
1 package drm1 package drm
2 2
3 import (3 import (
4+ "runtime"
4 "unsafe"5 "unsafe"
5 6
6 "golang.org/x/sys/unix"7 "golang.org/x/sys/unix"
@@ -22,6 +23,11 @@ type objGetProps struct {
22 objType, _ uint3223 objType, _ uint32
23 }24 }
24 25
26+type propEnum struct {
27+ value uint64
28+ name [32]byte
29+}
30+
25 type getProp struct {31 type getProp struct {
26 values, enumBlobs uint6432 values, enumBlobs uint64
27 propID, flags uint3233 propID, flags uint32
@@ -55,6 +61,98 @@ func objProps(fd int, obj, typ uint32) (map[string][2]uint64, error) {
55 return out, nil61 return out, nil
56 }62 }
57 63
64+// connectorHDRProps retains connector property IDs, enum values and the
65+// original max bpc for SDR modesets and restoration.
66+type connectorHDRProps struct {
67+ Metadata, Colorspace, MaxBPC uint32
68+ BT2020Value uint64
69+ DefaultValue uint64 // connector Colorspace "Default" enum
70+ HasDefault bool
71+ MaxBPCValue uint64 // current value for eventual restoration
72+}
73+
74+// readConnectorHDRProps checks property metadata, not only current values.
75+func readConnectorHDRProps(fd int, props map[string][2]uint64) connectorHDRProps {
76+ var out connectorHDRProps
77+ out.Metadata = uint32(props["HDR_OUTPUT_METADATA"][0])
78+ if id := uint32(props["Colorspace"][0]); id != 0 {
79+ p := getProp{propID: id}
80+ if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil && p.countEn > 0 {
81+ enums := make([]propEnum, p.countEn)
82+ p.enumBlobs = uint64(uintptr(unsafe.Pointer(&enums[0])))
83+ // The kernel also copies an enum's values when countValues is
84+ // set: a nil values pointer makes the whole call fail (EFAULT).
85+ values := make([]uint64, max(p.countValues, 1))
86+ p.values = uint64(uintptr(unsafe.Pointer(&values[0])))
87+ if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil {
88+ out.Colorspace, out.BT2020Value = bt2020Enum(id, enums[:min(int(p.countEn), len(enums))])
89+ for _, e := range enums[:min(int(p.countEn), len(enums))] {
90+ if unix.ByteSliceToString(e.name[:]) == "Default" {
91+ out.DefaultValue, out.HasDefault = e.value, true
92+ }
93+ }
94+ }
95+ runtime.KeepAlive(enums)
96+ runtime.KeepAlive(values)
97+ }
98+ }
99+ if id := uint32(props["max bpc"][0]); id != 0 {
100+ p := getProp{propID: id}
101+ if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil && p.countValues >= 2 {
102+ values := make([]uint64, p.countValues)
103+ p.values = uint64(uintptr(unsafe.Pointer(&values[0])))
104+ if ioctl(fd, ioctlGetProp, unsafe.Pointer(&p)) == nil && p.countValues >= 2 && bpcAllows10(values) {
105+ out.MaxBPC, out.MaxBPCValue = id, props["max bpc"][1]
106+ }
107+ runtime.KeepAlive(values)
108+ }
109+ }
110+ return out
111+}
112+
113+func bt2020Enum(id uint32, enums []propEnum) (uint32, uint64) {
114+ for _, e := range enums {
115+ if unix.ByteSliceToString(e.name[:]) == "BT2020_RGB" {
116+ return id, e.value
117+ }
118+ }
119+ return 0, 0
120+}
121+
122+func bpcAllows10(values []uint64) bool {
123+ return len(values) >= 2 && values[0] <= 10 && values[1] >= 10
124+}
125+
126+// hdrCapability combines EDID and connector support, retaining the first reason.
127+type hdrCapability struct {
128+ Capable bool
129+ Reason string
130+ HDRMetadata
131+}
132+
133+func detectHDR(m Monitor, p connectorHDRProps) hdrCapability {
134+ c := hdrCapability{HDRMetadata: m.HDR}
135+ switch {
136+ case !m.HDR.PQ:
137+ c.Reason = "edid: no PQ"
138+ case !m.HDR.StaticType1:
139+ c.Reason = "edid: no static metadata type 1"
140+ case !m.HDR.BT2020RGB:
141+ c.Reason = "edid: no BT2020RGB"
142+ case p.Metadata == 0:
143+ c.Reason = "connector: no HDR_OUTPUT_METADATA"
144+ case p.Colorspace == 0:
145+ c.Reason = "connector: no BT2020_RGB Colorspace"
146+ case p.MaxBPC == 0:
147+ c.Reason = "connector: max bpc below 10"
148+ case !p.HasDefault:
149+ c.Reason = "connector: no Default Colorspace"
150+ default:
151+ c.Capable = true
152+ }
153+ return c
154+}
155+
58 // vrrProperty returns the CRTC's VRR_ENABLED property when the connector156 // vrrProperty returns the CRTC's VRR_ENABLED property when the connector
59 // is VRR capable, else 0.157 // is VRR capable, else 0.
60 func vrrProperty(k kms, conn, crtc uint32) uint32 {158 func vrrProperty(k kms, conn, crtc uint32) uint32 {
A internal/adapters/drm/props_test.go +24 -0
@@ -0,0 +1,24 @@
1+package drm
2+
3+import "testing"
4+
5+func TestHDRPropertyMetadata(t *testing.T) {
6+ e := propEnum{value: 9}
7+ copy(e.name[:], "BT2020_RGB")
8+ if id, v := bt2020Enum(3, []propEnum{e}); id != 3 || v != 9 {
9+ t.Fatalf("enum %d %d", id, v)
10+ }
11+ if id, _ := bt2020Enum(3, nil); id != 0 {
12+ t.Fatal("missing enum accepted")
13+ }
14+ for _, tt := range []struct {
15+ values []uint64
16+ want bool
17+ }{
18+ {nil, false}, {[]uint64{8}, false}, {[]uint64{8, 9}, false}, {[]uint64{8, 10}, true}, {[]uint64{10, 12}, true}, {[]uint64{11, 12}, false},
19+ } {
20+ if got := bpcAllows10(tt.values); got != tt.want {
21+ t.Fatalf("%v: %t", tt.values, got)
22+ }
23+ }
24+}
@@ -0,0 +1,24 @@
1+package drm
2+
3+import "testing"
4+
5+func TestHDRPropertyMetadata(t *testing.T) {
6+ e := propEnum{value: 9}
7+ copy(e.name[:], "BT2020_RGB")
8+ if id, v := bt2020Enum(3, []propEnum{e}); id != 3 || v != 9 {
9+ t.Fatalf("enum %d %d", id, v)
10+ }
11+ if id, _ := bt2020Enum(3, nil); id != 0 {
12+ t.Fatal("missing enum accepted")
13+ }
14+ for _, tt := range []struct {
15+ values []uint64
16+ want bool
17+ }{
18+ {nil, false}, {[]uint64{8}, false}, {[]uint64{8, 9}, false}, {[]uint64{8, 10}, true}, {[]uint64{10, 12}, true}, {[]uint64{11, 12}, false},
19+ } {
20+ if got := bpcAllows10(tt.values); got != tt.want {
21+ t.Fatalf("%v: %t", tt.values, got)
22+ }
23+ }
24+}
M internal/adapters/drm/stuck_test.go +1 -0
@@ -51,6 +51,7 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
5151 w.Close()
5252 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
5353 }
54+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
5455 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
5556 r.EXPECT().UseTarget(mock.Anything).Return()
5657 var started atomic.Bool
@@ -51,6 +51,7 @@ func startStuck(t *testing.T, render func() *os.File, errs ...error) *stuckRun {
51 w.Close()51 w.Close()
52 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}52 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
53 }53 }
54+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
54 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()55 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
55 r.EXPECT().UseTarget(mock.Anything).Return()56 r.EXPECT().UseTarget(mock.Anything).Return()
56 var started atomic.Bool57 var started atomic.Bool
M internal/adapters/vulkan/compose.go +14 -6
@@ -20,6 +20,8 @@ import (
2020
2121 //go:generate glslc -O --target-env=vulkan1.3 shaders/compose.vert -o shaders/compose.vert.spv
2222 //go:generate glslc -O --target-env=vulkan1.3 shaders/compose.frag -o shaders/compose.frag.spv
23+//go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.vert -o shaders/hdr.vert.spv
24+//go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.frag -o shaders/hdr.frag.spv
2325
2426 var (
2527 //go:embed shaders/compose.vert.spv
@@ -98,13 +100,17 @@ func (r *Renderer) createComposer() error {
98100 }
99101
100102 func (r *Renderer) createPipeline() error {
101- d, c := r.dd, &r.compose
102- vert, err := r.shaderModule(composeVert)
103+ return r.createGraphicsPipeline(composeVert, composeFrag, vk.FormatB8g8r8a8Unorm, r.compose.layout, true, &r.compose.pipeline)
104+}
105+
106+func (r *Renderer) createGraphicsPipeline(vertex, fragment []byte, format vk.Format, layout vk.PipelineLayout, blend bool, pipeline *vk.Pipeline) error {
107+ d := r.dd
108+ vert, err := r.shaderModule(vertex)
103109 if err != nil {
104110 return err
105111 }
106112 defer d.DestroyShaderModule(r.device, vert, nil)
107- frag, err := r.shaderModule(composeFrag)
113+ frag, err := r.shaderModule(fragment)
108114 if err != nil {
109115 return err
110116 }
@@ -128,16 +134,18 @@ func (r *Renderer) createPipeline() error {
128134 SrcAlphaBlendFactor: vk.BlendFactorOne, DstAlphaBlendFactor: vk.BlendFactorOneMinusSrcAlpha, AlphaBlendOp: vk.BlendOpAdd,
129135 ColorWriteMask: vk.ColorComponentRBit | vk.ColorComponentGBit | vk.ColorComponentBBit | vk.ColorComponentABit,
130136 }
137+ if !blend {
138+ attachment.BlendEnable = 0
139+ }
131140 blendState := vk.PipelineColorBlendStateCreateInfo{SType: vk.StructureTypePipelineColorBlendStateCreateInfo, AttachmentCount: 1, Attachments: &attachment}
132- format := vk.Format(vk.FormatB8g8r8a8Unorm)
133141 rendering := vk.PipelineRenderingCreateInfo{SType: vk.StructureTypePipelineRenderingCreateInfo, ColorAttachmentCount: 1, ColorAttachmentFormats: &format}
134142 gpi := vk.GraphicsPipelineCreateInfo{
135143 SType: vk.StructureTypeGraphicsPipelineCreateInfo, Next: unsafe.Pointer(&rendering),
136144 StageCount: 2, Stages: &stages[0], VertexInputState: &vertexInput, InputAssemblyState: &assembly,
137145 ViewportState: &viewportState, RasterizationState: &raster, MultisampleState: &multisample,
138- ColorBlendState: &blendState, Layout: c.layout, BasePipelineIndex: -1,
146+ ColorBlendState: &blendState, Layout: layout, BasePipelineIndex: -1,
139147 }
140- return checked("vkCreateGraphicsPipelines", d.CreateGraphicsPipelines(r.device, 0, 1, &gpi, nil, &c.pipeline))
148+ return checked("vkCreateGraphicsPipelines", d.CreateGraphicsPipelines(r.device, 0, 1, &gpi, nil, pipeline))
141149 }
142150
143151 // createDummies makes the placeholder image (1×1, shader-read layout) and
@@ -20,6 +20,8 @@ import (
20 20
21 //go:generate glslc -O --target-env=vulkan1.3 shaders/compose.vert -o shaders/compose.vert.spv21 //go:generate glslc -O --target-env=vulkan1.3 shaders/compose.vert -o shaders/compose.vert.spv
22 //go:generate glslc -O --target-env=vulkan1.3 shaders/compose.frag -o shaders/compose.frag.spv22 //go:generate glslc -O --target-env=vulkan1.3 shaders/compose.frag -o shaders/compose.frag.spv
23+//go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.vert -o shaders/hdr.vert.spv
24+//go:generate glslc -O --target-env=vulkan1.3 shaders/hdr.frag -o shaders/hdr.frag.spv
23 25
24 var (26 var (
25 //go:embed shaders/compose.vert.spv27 //go:embed shaders/compose.vert.spv
@@ -98,13 +100,17 @@ func (r *Renderer) createComposer() error {
98 }100 }
99 101
100 func (r *Renderer) createPipeline() error {102 func (r *Renderer) createPipeline() error {
101- d, c := r.dd, &r.compose103+ return r.createGraphicsPipeline(composeVert, composeFrag, vk.FormatB8g8r8a8Unorm, r.compose.layout, true, &r.compose.pipeline)
102- vert, err := r.shaderModule(composeVert)104+}
105+
106+func (r *Renderer) createGraphicsPipeline(vertex, fragment []byte, format vk.Format, layout vk.PipelineLayout, blend bool, pipeline *vk.Pipeline) error {
107+ d := r.dd
108+ vert, err := r.shaderModule(vertex)
103 if err != nil {109 if err != nil {
104 return err110 return err
105 }111 }
106 defer d.DestroyShaderModule(r.device, vert, nil)112 defer d.DestroyShaderModule(r.device, vert, nil)
107- frag, err := r.shaderModule(composeFrag)113+ frag, err := r.shaderModule(fragment)
108 if err != nil {114 if err != nil {
109 return err115 return err
110 }116 }
@@ -128,16 +134,18 @@ func (r *Renderer) createPipeline() error {
128 SrcAlphaBlendFactor: vk.BlendFactorOne, DstAlphaBlendFactor: vk.BlendFactorOneMinusSrcAlpha, AlphaBlendOp: vk.BlendOpAdd,134 SrcAlphaBlendFactor: vk.BlendFactorOne, DstAlphaBlendFactor: vk.BlendFactorOneMinusSrcAlpha, AlphaBlendOp: vk.BlendOpAdd,
129 ColorWriteMask: vk.ColorComponentRBit | vk.ColorComponentGBit | vk.ColorComponentBBit | vk.ColorComponentABit,135 ColorWriteMask: vk.ColorComponentRBit | vk.ColorComponentGBit | vk.ColorComponentBBit | vk.ColorComponentABit,
130 }136 }
137+ if !blend {
138+ attachment.BlendEnable = 0
139+ }
131 blendState := vk.PipelineColorBlendStateCreateInfo{SType: vk.StructureTypePipelineColorBlendStateCreateInfo, AttachmentCount: 1, Attachments: &attachment}140 blendState := vk.PipelineColorBlendStateCreateInfo{SType: vk.StructureTypePipelineColorBlendStateCreateInfo, AttachmentCount: 1, Attachments: &attachment}
132- format := vk.Format(vk.FormatB8g8r8a8Unorm)
133 rendering := vk.PipelineRenderingCreateInfo{SType: vk.StructureTypePipelineRenderingCreateInfo, ColorAttachmentCount: 1, ColorAttachmentFormats: &format}141 rendering := vk.PipelineRenderingCreateInfo{SType: vk.StructureTypePipelineRenderingCreateInfo, ColorAttachmentCount: 1, ColorAttachmentFormats: &format}
134 gpi := vk.GraphicsPipelineCreateInfo{142 gpi := vk.GraphicsPipelineCreateInfo{
135 SType: vk.StructureTypeGraphicsPipelineCreateInfo, Next: unsafe.Pointer(&rendering),143 SType: vk.StructureTypeGraphicsPipelineCreateInfo, Next: unsafe.Pointer(&rendering),
136 StageCount: 2, Stages: &stages[0], VertexInputState: &vertexInput, InputAssemblyState: &assembly,144 StageCount: 2, Stages: &stages[0], VertexInputState: &vertexInput, InputAssemblyState: &assembly,
137 ViewportState: &viewportState, RasterizationState: &raster, MultisampleState: &multisample,145 ViewportState: &viewportState, RasterizationState: &raster, MultisampleState: &multisample,
138- ColorBlendState: &blendState, Layout: c.layout, BasePipelineIndex: -1,146+ ColorBlendState: &blendState, Layout: layout, BasePipelineIndex: -1,
139 }147 }
140- return checked("vkCreateGraphicsPipelines", d.CreateGraphicsPipelines(r.device, 0, 1, &gpi, nil, &c.pipeline))148+ return checked("vkCreateGraphicsPipelines", d.CreateGraphicsPipelines(r.device, 0, 1, &gpi, nil, pipeline))
141 }149 }
142 150
143 // createDummies makes the placeholder image (1×1, shader-read layout) and151 // createDummies makes the placeholder image (1×1, shader-read layout) and
M internal/adapters/vulkan/cursor.go +44 -2
@@ -1,8 +1,11 @@
11 package vulkan
22
33 import (
4+ "crypto/sha256"
5+ "encoding/binary"
46 "errors"
57 "fmt"
8+ "math"
69 "os"
710 "unsafe"
811
@@ -105,6 +108,26 @@ func (r *Renderer) cursorImage(size int) (*cursorImage, ports.DMABuf, error) {
105108 return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil
106109 }
107110
111+// cursorConversion keeps only the last converted image, bounded by the
112+// cursor plane's maximum dimensions, regardless of cursor theme size.
113+type cursorConversion struct {
114+ key [32]byte
115+ pixels []byte
116+}
117+
118+func cursorKey(pixels []byte, w, h int, nits float64) [32]byte {
119+ var header [24]byte
120+ binary.LittleEndian.PutUint64(header[:8], uint64(w))
121+ binary.LittleEndian.PutUint64(header[8:16], uint64(h))
122+ binary.LittleEndian.PutUint64(header[16:], math.Float64bits(nits))
123+ digest := sha256.New()
124+ _, _ = digest.Write(header[:])
125+ _, _ = digest.Write(pixels)
126+ var key [32]byte
127+ digest.Sum(key[:0])
128+ return key
129+}
130+
108131 // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row)
109132 // into cursor image i at its row pitch, cropped and cleared around.
110133 func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
@@ -118,8 +141,27 @@ func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
118141 mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size)
119142 clear(mem)
120143 cw := min(w, c.size) * 4
121- for y := range min(h, c.size) {
122- copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw])
144+ if r.hdrNits > 0 {
145+ // WriteCursor is called only on image changes, never on moves.
146+ key := cursorKey(pixels[:w*h*4], w, h, r.hdrNits)
147+ converted := r.cursorCache.pixels
148+ if converted == nil || r.cursorCache.key != key {
149+ converted = make([]byte, cw*min(h, c.size))
150+ for y := range min(h, c.size) {
151+ copy(converted[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
152+ for x := 0; x < cw; x += 4 {
153+ hdrCursorPixel(converted[y*cw+x:y*cw+x+4], r.hdrNits)
154+ }
155+ }
156+ r.cursorCache = cursorConversion{key: key, pixels: converted}
157+ }
158+ for y := range min(h, c.size) {
159+ copy(mem[y*c.pitch:y*c.pitch+cw], converted[y*cw:(y+1)*cw])
160+ }
161+ } else {
162+ for y := range min(h, c.size) {
163+ copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw])
164+ }
123165 }
124166 return nil
125167 }
@@ -1,8 +1,11 @@
1 package vulkan1 package vulkan
2 2
3 import (3 import (
4+ "crypto/sha256"
5+ "encoding/binary"
4 "errors"6 "errors"
5 "fmt"7 "fmt"
8+ "math"
6 "os"9 "os"
7 "unsafe"10 "unsafe"
8 11
@@ -105,6 +108,26 @@ func (r *Renderer) cursorImage(size int) (*cursorImage, ports.DMABuf, error) {
105 return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil108 return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil
106 }109 }
107 110
111+// cursorConversion keeps only the last converted image, bounded by the
112+// cursor plane's maximum dimensions, regardless of cursor theme size.
113+type cursorConversion struct {
114+ key [32]byte
115+ pixels []byte
116+}
117+
118+func cursorKey(pixels []byte, w, h int, nits float64) [32]byte {
119+ var header [24]byte
120+ binary.LittleEndian.PutUint64(header[:8], uint64(w))
121+ binary.LittleEndian.PutUint64(header[8:16], uint64(h))
122+ binary.LittleEndian.PutUint64(header[16:], math.Float64bits(nits))
123+ digest := sha256.New()
124+ _, _ = digest.Write(header[:])
125+ _, _ = digest.Write(pixels)
126+ var key [32]byte
127+ digest.Sum(key[:0])
128+ return key
129+}
130+
108 // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row)131 // WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row)
109 // into cursor image i at its row pitch, cropped and cleared around.132 // into cursor image i at its row pitch, cropped and cleared around.
110 func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {133 func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
@@ -118,8 +141,27 @@ func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
118 mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size)141 mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size)
119 clear(mem)142 clear(mem)
120 cw := min(w, c.size) * 4143 cw := min(w, c.size) * 4
121- for y := range min(h, c.size) {144+ if r.hdrNits > 0 {
122- copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw])145+ // WriteCursor is called only on image changes, never on moves.
146+ key := cursorKey(pixels[:w*h*4], w, h, r.hdrNits)
147+ converted := r.cursorCache.pixels
148+ if converted == nil || r.cursorCache.key != key {
149+ converted = make([]byte, cw*min(h, c.size))
150+ for y := range min(h, c.size) {
151+ copy(converted[y*cw:(y+1)*cw], pixels[y*w*4:y*w*4+cw])
152+ for x := 0; x < cw; x += 4 {
153+ hdrCursorPixel(converted[y*cw+x:y*cw+x+4], r.hdrNits)
154+ }
155+ }
156+ r.cursorCache = cursorConversion{key: key, pixels: converted}
157+ }
158+ for y := range min(h, c.size) {
159+ copy(mem[y*c.pitch:y*c.pitch+cw], converted[y*cw:(y+1)*cw])
160+ }
161+ } else {
162+ for y := range min(h, c.size) {
163+ copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw])
164+ }
123 }165 }
124 return nil166 return nil
125 }167 }
M internal/adapters/vulkan/cursor_test.go +58 -0
@@ -46,3 +46,61 @@ func TestCursorBuffersWriteCursor(t *testing.T) {
4646 t.Fatal("short image accepted")
4747 }
4848 }
49+
50+func TestHDRCursorCachesConversion(t *testing.T) {
51+ r, err := New(32, 16)
52+ if err != nil {
53+ t.Skipf("Vulkan unavailable: %v", err)
54+ }
55+ defer r.Close()
56+ bufs, err := r.CursorBuffers(64)
57+ if err != nil {
58+ t.Skipf("no cursor images: %v", err)
59+ }
60+ for _, b := range bufs {
61+ b.Planes[0].File.Close()
62+ }
63+ r.SetHDR(203)
64+ px := []byte{255, 255, 255, 255}
65+ if err := r.WriteCursor(0, px, 1, 1); err != nil {
66+ t.Fatal(err)
67+ }
68+ if len(r.cursorCache.pixels) != 4 {
69+ t.Fatalf("cache size %d", len(r.cursorCache.pixels))
70+ }
71+ if err := r.WriteCursor(1, px, 1, 1); err != nil {
72+ t.Fatal(err)
73+ }
74+ if len(r.cursorCache.pixels) != 4 {
75+ t.Fatalf("cache grew on reselect: %d", len(r.cursorCache.pixels))
76+ }
77+ first := r.cursorCache.key
78+ for i := 0; i < 12; i++ {
79+ color := byte(i * 18)
80+ if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil {
81+ t.Fatal(err)
82+ }
83+ if len(r.cursorCache.pixels) != 4 {
84+ t.Fatalf("cache grew after image %d: %d", i, len(r.cursorCache.pixels))
85+ }
86+ }
87+ if first == r.cursorCache.key {
88+ t.Fatal("cursor conversion not replaced")
89+ }
90+ if err := r.WriteCursor(1, px, 1, 1); err != nil {
91+ t.Fatal(err)
92+ }
93+ for _, c := range r.cursors {
94+ got := unsafe.Slice((*byte)(c.mapped), 4)
95+ if got[0] != 148 || got[1] != 148 || got[2] != 148 || got[3] != 255 {
96+ t.Fatalf("cursor HDR pixel: %v", got)
97+ }
98+ }
99+ r.SetHDR(0)
100+ if err := r.WriteCursor(0, px, 1, 1); err != nil {
101+ t.Fatal(err)
102+ }
103+ if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || len(r.cursorCache.pixels) != 0 {
104+ t.Fatalf("SDR cursor/cache: %v %d", got, len(r.cursorCache.pixels))
105+ }
106+}
@@ -46,3 +46,61 @@ func TestCursorBuffersWriteCursor(t *testing.T) {
46 t.Fatal("short image accepted")46 t.Fatal("short image accepted")
47 }47 }
48 }48 }
49+
50+func TestHDRCursorCachesConversion(t *testing.T) {
51+ r, err := New(32, 16)
52+ if err != nil {
53+ t.Skipf("Vulkan unavailable: %v", err)
54+ }
55+ defer r.Close()
56+ bufs, err := r.CursorBuffers(64)
57+ if err != nil {
58+ t.Skipf("no cursor images: %v", err)
59+ }
60+ for _, b := range bufs {
61+ b.Planes[0].File.Close()
62+ }
63+ r.SetHDR(203)
64+ px := []byte{255, 255, 255, 255}
65+ if err := r.WriteCursor(0, px, 1, 1); err != nil {
66+ t.Fatal(err)
67+ }
68+ if len(r.cursorCache.pixels) != 4 {
69+ t.Fatalf("cache size %d", len(r.cursorCache.pixels))
70+ }
71+ if err := r.WriteCursor(1, px, 1, 1); err != nil {
72+ t.Fatal(err)
73+ }
74+ if len(r.cursorCache.pixels) != 4 {
75+ t.Fatalf("cache grew on reselect: %d", len(r.cursorCache.pixels))
76+ }
77+ first := r.cursorCache.key
78+ for i := 0; i < 12; i++ {
79+ color := byte(i * 18)
80+ if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil {
81+ t.Fatal(err)
82+ }
83+ if len(r.cursorCache.pixels) != 4 {
84+ t.Fatalf("cache grew after image %d: %d", i, len(r.cursorCache.pixels))
85+ }
86+ }
87+ if first == r.cursorCache.key {
88+ t.Fatal("cursor conversion not replaced")
89+ }
90+ if err := r.WriteCursor(1, px, 1, 1); err != nil {
91+ t.Fatal(err)
92+ }
93+ for _, c := range r.cursors {
94+ got := unsafe.Slice((*byte)(c.mapped), 4)
95+ if got[0] != 148 || got[1] != 148 || got[2] != 148 || got[3] != 255 {
96+ t.Fatalf("cursor HDR pixel: %v", got)
97+ }
98+ }
99+ r.SetHDR(0)
100+ if err := r.WriteCursor(0, px, 1, 1); err != nil {
101+ t.Fatal(err)
102+ }
103+ if got := unsafe.Slice((*byte)(r.cursors[0].mapped), 4); got[0] != 255 || len(r.cursorCache.pixels) != 0 {
104+ t.Fatalf("SDR cursor/cache: %v %d", got, len(r.cursorCache.pixels))
105+ }
106+}
M internal/adapters/vulkan/frame.go +26 -11
@@ -169,6 +169,10 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
169169 }
170170 }
171171 tg := r.target()
172+ hdr := r.hdrNits > 0 && len(r.targets) > 0
173+ if hdr {
174+ tg = &r.own
175+ }
172176 dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height))
173177 ds := r.draws(s, contents, dmg)
174178 dmg.finish()
@@ -219,16 +223,21 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
219223 r.ownership(cmd, dmas, true)
220224 r.recordDraws(cmd, tg.view, parseColor(s.Background), ds, partial)
221225 r.ownership(cmd, dmas, false)
222- b.SrcAccessMask, b.OldLayout = vk.AccessColorAttachmentWriteBit, vk.ImageLayoutColorAttachmentOptimal
223- b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = queueFamilyIgnored, queueFamilyIgnored
224- if tg.exported {
225- // Release to the display: KMS scans it out after the fence.
226- b.NewLayout, b.DstAccessMask = vk.ImageLayoutGeneral, 0
227- b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
228- d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
229- } else {
230- b.NewLayout, b.DstAccessMask = vk.ImageLayoutTransferSrcOptimal, vk.AccessTransferReadBit
231- d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
226+ if hdr {
227+ r.recordHDR(cmd, r.targets[r.current])
228+ }
229+ if !hdr {
230+ b.SrcAccessMask, b.OldLayout = vk.AccessColorAttachmentWriteBit, vk.ImageLayoutColorAttachmentOptimal
231+ b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = queueFamilyIgnored, queueFamilyIgnored
232+ if tg.exported {
233+ // Release to the display: KMS scans it out after the fence.
234+ b.NewLayout, b.DstAccessMask = vk.ImageLayoutGeneral, 0
235+ b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
236+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
237+ } else {
238+ b.NewLayout, b.DstAccessMask = vk.ImageLayoutTransferSrcOptimal, vk.AccessTransferReadBit
239+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
240+ }
232241 }
233242 if err := checked("vkEndCommandBuffer", d.EndCommandBuffer(cmd)); err != nil {
234243 return nil, err
@@ -268,7 +277,13 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
268277 runtime.KeepAlive(stages)
269278 runtime.KeepAlive(waits)
270279 slot.frame, slot.busy, r.submitted = frame, true, frame
271- tg.layout = b.NewLayout
280+ if hdr {
281+ // recordHDR left the composed image in TRANSFER_SRC for Pixels.
282+ tg.layout = vk.ImageLayoutTransferSrcOptimal
283+ r.targets[r.current].layout = vk.ImageLayoutGeneral
284+ } else {
285+ tg.layout = b.NewLayout
286+ }
272287 tg.hold(s, dmg)
273288 r.last, r.readBack = tg, false
274289 r.dropUnused()
@@ -169,6 +169,10 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
169 }169 }
170 }170 }
171 tg := r.target()171 tg := r.target()
172+ hdr := r.hdrNits > 0 && len(r.targets) > 0
173+ if hdr {
174+ tg = &r.own
175+ }
172 dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height))176 dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height))
173 ds := r.draws(s, contents, dmg)177 ds := r.draws(s, contents, dmg)
174 dmg.finish()178 dmg.finish()
@@ -219,16 +223,21 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
219 r.ownership(cmd, dmas, true)223 r.ownership(cmd, dmas, true)
220 r.recordDraws(cmd, tg.view, parseColor(s.Background), ds, partial)224 r.recordDraws(cmd, tg.view, parseColor(s.Background), ds, partial)
221 r.ownership(cmd, dmas, false)225 r.ownership(cmd, dmas, false)
222- b.SrcAccessMask, b.OldLayout = vk.AccessColorAttachmentWriteBit, vk.ImageLayoutColorAttachmentOptimal226+ if hdr {
223- b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = queueFamilyIgnored, queueFamilyIgnored227+ r.recordHDR(cmd, r.targets[r.current])
224- if tg.exported {228+ }
225- // Release to the display: KMS scans it out after the fence.229+ if !hdr {
226- b.NewLayout, b.DstAccessMask = vk.ImageLayoutGeneral, 0230+ b.SrcAccessMask, b.OldLayout = vk.AccessColorAttachmentWriteBit, vk.ImageLayoutColorAttachmentOptimal
227- b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT231+ b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = queueFamilyIgnored, queueFamilyIgnored
228- d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)232+ if tg.exported {
229- } else {233+ // Release to the display: KMS scans it out after the fence.
230- b.NewLayout, b.DstAccessMask = vk.ImageLayoutTransferSrcOptimal, vk.AccessTransferReadBit234+ b.NewLayout, b.DstAccessMask = vk.ImageLayoutGeneral, 0
231- d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)235+ b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
236+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
237+ } else {
238+ b.NewLayout, b.DstAccessMask = vk.ImageLayoutTransferSrcOptimal, vk.AccessTransferReadBit
239+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
240+ }
232 }241 }
233 if err := checked("vkEndCommandBuffer", d.EndCommandBuffer(cmd)); err != nil {242 if err := checked("vkEndCommandBuffer", d.EndCommandBuffer(cmd)); err != nil {
234 return nil, err243 return nil, err
@@ -268,7 +277,13 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
268 runtime.KeepAlive(stages)277 runtime.KeepAlive(stages)
269 runtime.KeepAlive(waits)278 runtime.KeepAlive(waits)
270 slot.frame, slot.busy, r.submitted = frame, true, frame279 slot.frame, slot.busy, r.submitted = frame, true, frame
271- tg.layout = b.NewLayout280+ if hdr {
281+ // recordHDR left the composed image in TRANSFER_SRC for Pixels.
282+ tg.layout = vk.ImageLayoutTransferSrcOptimal
283+ r.targets[r.current].layout = vk.ImageLayoutGeneral
284+ } else {
285+ tg.layout = b.NewLayout
286+ }
272 tg.hold(s, dmg)287 tg.hold(s, dmg)
273 r.last, r.readBack = tg, false288 r.last, r.readBack = tg, false
274 r.dropUnused()289 r.dropUnused()
A internal/adapters/vulkan/hdr.go +128 -0
@@ -0,0 +1,128 @@
1+package vulkan
2+
3+import (
4+ _ "embed"
5+ "unsafe"
6+
7+ vk "github.com/bnema/purego-vulkan/vulkan"
8+)
9+
10+var (
11+ //go:embed shaders/hdr.vert.spv
12+ hdrVert []byte
13+ //go:embed shaders/hdr.frag.spv
14+ hdrFrag []byte
15+)
16+
17+type hdrPass struct {
18+ setLayout vk.DescriptorSetLayout
19+ layout vk.PipelineLayout
20+ pipeline vk.Pipeline
21+ sampler vk.Sampler
22+ pool vk.DescriptorPool
23+ set vk.DescriptorSet
24+}
25+
26+// SetHDR prepares the output transform before ExportTargets. The renderer
27+// remains owner-goroutine confined; all GPU objects are reused each frame.
28+func (r *Renderer) SetHDR(nits float64) {
29+ if nits != r.hdrNits {
30+ r.cursorCache = cursorConversion{}
31+ }
32+ r.hdrNits = nits
33+ if nits <= 0 {
34+ r.cursorCache = cursorConversion{}
35+ r.hdrError = nil
36+ return
37+ }
38+ if r.hdr.pipeline != 0 {
39+ return
40+ }
41+ // The port has no error return: ExportTargets fails if preparation fails.
42+ r.hdrError = r.createHDR()
43+ if r.hdrError != nil {
44+ r.destroyHDR()
45+ }
46+}
47+
48+func (r *Renderer) createHDR() error {
49+ d, h := r.dd, &r.hdr
50+ binding := vk.DescriptorSetLayoutBinding{Binding: 0, DescriptorType: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit}
51+ sli := vk.DescriptorSetLayoutCreateInfo{SType: vk.StructureTypeDescriptorSetLayoutCreateInfo, BindingCount: 1, Bindings: &binding}
52+ if err := checked("vkCreateDescriptorSetLayout(HDR)", d.CreateDescriptorSetLayout(r.device, &sli, nil, &h.setLayout)); err != nil {
53+ return err
54+ }
55+ push := vk.PushConstantRange{StageFlags: vk.ShaderStageFragmentBit, Size: 4}
56+ pli := vk.PipelineLayoutCreateInfo{SType: vk.StructureTypePipelineLayoutCreateInfo, SetLayoutCount: 1, SetLayouts: &h.setLayout, PushConstantRangeCount: 1, PushConstantRanges: &push}
57+ if err := checked("vkCreatePipelineLayout(HDR)", d.CreatePipelineLayout(r.device, &pli, nil, &h.layout)); err != nil {
58+ return err
59+ }
60+ if err := r.createGraphicsPipeline(hdrVert, hdrFrag, vk.FormatA2r10g10b10UnormPack32, h.layout, false, &h.pipeline); err != nil {
61+ return err
62+ }
63+ si := vk.SamplerCreateInfo{SType: vk.StructureTypeSamplerCreateInfo, MagFilter: vk.FilterNearest, MinFilter: vk.FilterNearest, AddressModeU: vk.SamplerAddressModeClampToEdge, AddressModeV: vk.SamplerAddressModeClampToEdge, AddressModeW: vk.SamplerAddressModeClampToEdge}
64+ if err := checked("vkCreateSampler(HDR)", d.CreateSampler(r.device, &si, nil, &h.sampler)); err != nil {
65+ return err
66+ }
67+ size := vk.DescriptorPoolSize{Type: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1}
68+ dpi := vk.DescriptorPoolCreateInfo{SType: vk.StructureTypeDescriptorPoolCreateInfo, MaxSets: 1, PoolSizeCount: 1, PoolSizes: &size}
69+ if err := checked("vkCreateDescriptorPool(HDR)", d.CreateDescriptorPool(r.device, &dpi, nil, &h.pool)); err != nil {
70+ return err
71+ }
72+ dai := vk.DescriptorSetAllocateInfo{SType: vk.StructureTypeDescriptorSetAllocateInfo, DescriptorPool: h.pool, DescriptorSetCount: 1, SetLayouts: &h.setLayout}
73+ if err := checked("vkAllocateDescriptorSets(HDR)", d.AllocateDescriptorSets(r.device, &dai, &h.set)); err != nil {
74+ return err
75+ }
76+ img := vk.DescriptorImageInfo{Sampler: h.sampler, ImageView: r.own.view, ImageLayout: vk.ImageLayoutShaderReadOnlyOptimal}
77+ write := vk.WriteDescriptorSet{SType: vk.StructureTypeWriteDescriptorSet, DstSet: h.set, DstBinding: 0, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeCombinedImageSampler, ImageInfo: &img}
78+ d.UpdateDescriptorSets(r.device, 1, &write, 0, nil)
79+ return nil
80+}
81+
82+func (r *Renderer) destroyHDR() {
83+ if r.dd == nil {
84+ return
85+ }
86+ d, h := r.dd, &r.hdr
87+ if h.pool != 0 {
88+ d.DestroyDescriptorPool(r.device, h.pool, nil)
89+ }
90+ if h.sampler != 0 {
91+ d.DestroySampler(r.device, h.sampler, nil)
92+ }
93+ if h.pipeline != 0 {
94+ d.DestroyPipeline(r.device, h.pipeline, nil)
95+ }
96+ if h.layout != 0 {
97+ d.DestroyPipelineLayout(r.device, h.layout, nil)
98+ }
99+ if h.setLayout != 0 {
100+ d.DestroyDescriptorSetLayout(r.device, h.setLayout, nil)
101+ }
102+ *h = hdrPass{}
103+}
104+
105+// recordHDR converts the fully composed SDR image in the same command buffer.
106+func (r *Renderer) recordHDR(cmd vk.CommandBuffer, out *target) {
107+ d, h := r.dd, &r.hdr
108+ in := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessColorAttachmentWriteBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutColorAttachmentOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.own.image, SubresourceRange: colorRange}
109+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in)
110+ b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: out.image, SubresourceRange: colorRange}
111+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)
112+ attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore}
113+ info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment}
114+ d.CmdBeginRendering(cmd, &info)
115+ d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, h.pipeline)
116+ d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, h.layout, 0, 1, &h.set, 0, nil)
117+ nits := float32(r.hdrNits)
118+ d.CmdPushConstants(cmd, h.layout, vk.ShaderStageFragmentBit, 0, 4, unsafe.Pointer(&nits))
119+ d.CmdDraw(cmd, 3, 1, 0, 0)
120+ d.CmdEndRendering(cmd)
121+ b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, 0
122+ b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutGeneral
123+ b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
124+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
125+ in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit
126+ in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal
127+ d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in)
128+}
@@ -0,0 +1,128 @@
1+package vulkan
2+
3+import (
4+ _ "embed"
5+ "unsafe"
6+
7+ vk "github.com/bnema/purego-vulkan/vulkan"
8+)
9+
10+var (
11+ //go:embed shaders/hdr.vert.spv
12+ hdrVert []byte
13+ //go:embed shaders/hdr.frag.spv
14+ hdrFrag []byte
15+)
16+
17+type hdrPass struct {
18+ setLayout vk.DescriptorSetLayout
19+ layout vk.PipelineLayout
20+ pipeline vk.Pipeline
21+ sampler vk.Sampler
22+ pool vk.DescriptorPool
23+ set vk.DescriptorSet
24+}
25+
26+// SetHDR prepares the output transform before ExportTargets. The renderer
27+// remains owner-goroutine confined; all GPU objects are reused each frame.
28+func (r *Renderer) SetHDR(nits float64) {
29+ if nits != r.hdrNits {
30+ r.cursorCache = cursorConversion{}
31+ }
32+ r.hdrNits = nits
33+ if nits <= 0 {
34+ r.cursorCache = cursorConversion{}
35+ r.hdrError = nil
36+ return
37+ }
38+ if r.hdr.pipeline != 0 {
39+ return
40+ }
41+ // The port has no error return: ExportTargets fails if preparation fails.
42+ r.hdrError = r.createHDR()
43+ if r.hdrError != nil {
44+ r.destroyHDR()
45+ }
46+}
47+
48+func (r *Renderer) createHDR() error {
49+ d, h := r.dd, &r.hdr
50+ binding := vk.DescriptorSetLayoutBinding{Binding: 0, DescriptorType: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1, StageFlags: vk.ShaderStageFragmentBit}
51+ sli := vk.DescriptorSetLayoutCreateInfo{SType: vk.StructureTypeDescriptorSetLayoutCreateInfo, BindingCount: 1, Bindings: &binding}
52+ if err := checked("vkCreateDescriptorSetLayout(HDR)", d.CreateDescriptorSetLayout(r.device, &sli, nil, &h.setLayout)); err != nil {
53+ return err
54+ }
55+ push := vk.PushConstantRange{StageFlags: vk.ShaderStageFragmentBit, Size: 4}
56+ pli := vk.PipelineLayoutCreateInfo{SType: vk.StructureTypePipelineLayoutCreateInfo, SetLayoutCount: 1, SetLayouts: &h.setLayout, PushConstantRangeCount: 1, PushConstantRanges: &push}
57+ if err := checked("vkCreatePipelineLayout(HDR)", d.CreatePipelineLayout(r.device, &pli, nil, &h.layout)); err != nil {
58+ return err
59+ }
60+ if err := r.createGraphicsPipeline(hdrVert, hdrFrag, vk.FormatA2r10g10b10UnormPack32, h.layout, false, &h.pipeline); err != nil {
61+ return err
62+ }
63+ si := vk.SamplerCreateInfo{SType: vk.StructureTypeSamplerCreateInfo, MagFilter: vk.FilterNearest, MinFilter: vk.FilterNearest, AddressModeU: vk.SamplerAddressModeClampToEdge, AddressModeV: vk.SamplerAddressModeClampToEdge, AddressModeW: vk.SamplerAddressModeClampToEdge}
64+ if err := checked("vkCreateSampler(HDR)", d.CreateSampler(r.device, &si, nil, &h.sampler)); err != nil {
65+ return err
66+ }
67+ size := vk.DescriptorPoolSize{Type: vk.DescriptorTypeCombinedImageSampler, DescriptorCount: 1}
68+ dpi := vk.DescriptorPoolCreateInfo{SType: vk.StructureTypeDescriptorPoolCreateInfo, MaxSets: 1, PoolSizeCount: 1, PoolSizes: &size}
69+ if err := checked("vkCreateDescriptorPool(HDR)", d.CreateDescriptorPool(r.device, &dpi, nil, &h.pool)); err != nil {
70+ return err
71+ }
72+ dai := vk.DescriptorSetAllocateInfo{SType: vk.StructureTypeDescriptorSetAllocateInfo, DescriptorPool: h.pool, DescriptorSetCount: 1, SetLayouts: &h.setLayout}
73+ if err := checked("vkAllocateDescriptorSets(HDR)", d.AllocateDescriptorSets(r.device, &dai, &h.set)); err != nil {
74+ return err
75+ }
76+ img := vk.DescriptorImageInfo{Sampler: h.sampler, ImageView: r.own.view, ImageLayout: vk.ImageLayoutShaderReadOnlyOptimal}
77+ write := vk.WriteDescriptorSet{SType: vk.StructureTypeWriteDescriptorSet, DstSet: h.set, DstBinding: 0, DescriptorCount: 1, DescriptorType: vk.DescriptorTypeCombinedImageSampler, ImageInfo: &img}
78+ d.UpdateDescriptorSets(r.device, 1, &write, 0, nil)
79+ return nil
80+}
81+
82+func (r *Renderer) destroyHDR() {
83+ if r.dd == nil {
84+ return
85+ }
86+ d, h := r.dd, &r.hdr
87+ if h.pool != 0 {
88+ d.DestroyDescriptorPool(r.device, h.pool, nil)
89+ }
90+ if h.sampler != 0 {
91+ d.DestroySampler(r.device, h.sampler, nil)
92+ }
93+ if h.pipeline != 0 {
94+ d.DestroyPipeline(r.device, h.pipeline, nil)
95+ }
96+ if h.layout != 0 {
97+ d.DestroyPipelineLayout(r.device, h.layout, nil)
98+ }
99+ if h.setLayout != 0 {
100+ d.DestroyDescriptorSetLayout(r.device, h.setLayout, nil)
101+ }
102+ *h = hdrPass{}
103+}
104+
105+// recordHDR converts the fully composed SDR image in the same command buffer.
106+func (r *Renderer) recordHDR(cmd vk.CommandBuffer, out *target) {
107+ d, h := r.dd, &r.hdr
108+ in := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessColorAttachmentWriteBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutColorAttachmentOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.own.image, SubresourceRange: colorRange}
109+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in)
110+ b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: out.image, SubresourceRange: colorRange}
111+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b)
112+ attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore}
113+ info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment}
114+ d.CmdBeginRendering(cmd, &info)
115+ d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, h.pipeline)
116+ d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, h.layout, 0, 1, &h.set, 0, nil)
117+ nits := float32(r.hdrNits)
118+ d.CmdPushConstants(cmd, h.layout, vk.ShaderStageFragmentBit, 0, 4, unsafe.Pointer(&nits))
119+ d.CmdDraw(cmd, 3, 1, 0, 0)
120+ d.CmdEndRendering(cmd)
121+ b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, 0
122+ b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutGeneral
123+ b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
124+ d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
125+ in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit
126+ in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal
127+ d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in)
128+}
A internal/adapters/vulkan/pq.go +49 -0
@@ -0,0 +1,49 @@
1+package vulkan
2+
3+import "math"
4+
5+var srgbTable = func() (table [256]float64) {
6+ for i := range table {
7+ table[i] = srgbToLinear(float64(i) / 255)
8+ }
9+ return
10+}()
11+
12+// srgbToLinear decodes a unit-range sRGB component.
13+func srgbToLinear(v float64) float64 {
14+ if v <= 0.04045 {
15+ return v / 12.92
16+ }
17+ return math.Pow((v+0.055)/1.055, 2.4)
18+}
19+
20+// pqEncode encodes absolute luminance in nits using SMPTE ST 2084.
21+func pqEncode(nits float64) float64 {
22+ if nits <= 0 {
23+ return 0
24+ }
25+ x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
26+ return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
27+}
28+
29+// hdrPixel transforms an unassociated sRGB triplet into BT.2020 PQ.
30+func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
31+ r, g, b := srgbToLinear(red), srgbToLinear(green), srgbToLinear(blue)
32+ return pqEncode((0.627404*r + 0.329283*g + 0.043313*b) * nits),
33+ pqEncode((0.069097*r + 0.919540*g + 0.011362*b) * nits),
34+ pqEncode((0.016391*r + 0.088013*g + 0.895595*b) * nits)
35+}
36+
37+// hdrCursorPixel converts premultiplied BGRA bytes, preserving alpha.
38+// The source is quantized to its 8-bit unassociated sRGB value for the LUT.
39+func hdrCursorPixel(p []byte, nits float64) {
40+ a := float64(p[3]) / 255
41+ if a == 0 {
42+ clear(p[:3])
43+ return
44+ }
45+ index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
46+ r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
47+ r, g, b = pqEncode((0.627404*r+0.329283*g+0.043313*b)*nits), pqEncode((0.069097*r+0.919540*g+0.011362*b)*nits), pqEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
48+ p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
49+}
@@ -0,0 +1,49 @@
1+package vulkan
2+
3+import "math"
4+
5+var srgbTable = func() (table [256]float64) {
6+ for i := range table {
7+ table[i] = srgbToLinear(float64(i) / 255)
8+ }
9+ return
10+}()
11+
12+// srgbToLinear decodes a unit-range sRGB component.
13+func srgbToLinear(v float64) float64 {
14+ if v <= 0.04045 {
15+ return v / 12.92
16+ }
17+ return math.Pow((v+0.055)/1.055, 2.4)
18+}
19+
20+// pqEncode encodes absolute luminance in nits using SMPTE ST 2084.
21+func pqEncode(nits float64) float64 {
22+ if nits <= 0 {
23+ return 0
24+ }
25+ x := math.Pow(min(nits, 10000)/10000, 2610.0/16384)
26+ return math.Pow((3424.0/4096+2413.0/128*x)/(1+2392.0/128*x), 2523.0/32)
27+}
28+
29+// hdrPixel transforms an unassociated sRGB triplet into BT.2020 PQ.
30+func hdrPixel(red, green, blue, nits float64) (float64, float64, float64) {
31+ r, g, b := srgbToLinear(red), srgbToLinear(green), srgbToLinear(blue)
32+ return pqEncode((0.627404*r + 0.329283*g + 0.043313*b) * nits),
33+ pqEncode((0.069097*r + 0.919540*g + 0.011362*b) * nits),
34+ pqEncode((0.016391*r + 0.088013*g + 0.895595*b) * nits)
35+}
36+
37+// hdrCursorPixel converts premultiplied BGRA bytes, preserving alpha.
38+// The source is quantized to its 8-bit unassociated sRGB value for the LUT.
39+func hdrCursorPixel(p []byte, nits float64) {
40+ a := float64(p[3]) / 255
41+ if a == 0 {
42+ clear(p[:3])
43+ return
44+ }
45+ index := func(v byte) int { return min(255, int(math.Round(float64(v)/a))) }
46+ r, g, b := srgbTable[index(p[2])], srgbTable[index(p[1])], srgbTable[index(p[0])]
47+ r, g, b = pqEncode((0.627404*r+0.329283*g+0.043313*b)*nits), pqEncode((0.069097*r+0.919540*g+0.011362*b)*nits), pqEncode((0.016391*r+0.088013*g+0.895595*b)*nits)
48+ p[0], p[1], p[2] = byte(math.Round(b*a*255)), byte(math.Round(g*a*255)), byte(math.Round(r*a*255))
49+}
A internal/adapters/vulkan/pq_test.go +21 -0
@@ -0,0 +1,21 @@
1+package vulkan
2+
3+import (
4+ "math"
5+ "testing"
6+)
7+
8+func TestPQMath(t *testing.T) {
9+ white, green, blue := hdrPixel(1, 1, 1, 203)
10+ if math.Abs(white-0.5806) > 0.002 || math.Abs(green-white) > 0.0001 || math.Abs(blue-white) > 0.0001 {
11+ t.Fatalf("SDR white at 203 nits: %v %v %v", white, green, blue)
12+ }
13+ if pqEncode(0) != 0 || math.Abs(pqEncode(10000)-1) > 1e-9 {
14+ t.Fatal("PQ endpoints")
15+ }
16+ p := []byte{255, 255, 255, 255}
17+ hdrCursorPixel(p, 203)
18+ if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
19+ t.Fatalf("cursor white: %v", p)
20+ }
21+}
@@ -0,0 +1,21 @@
1+package vulkan
2+
3+import (
4+ "math"
5+ "testing"
6+)
7+
8+func TestPQMath(t *testing.T) {
9+ white, green, blue := hdrPixel(1, 1, 1, 203)
10+ if math.Abs(white-0.5806) > 0.002 || math.Abs(green-white) > 0.0001 || math.Abs(blue-white) > 0.0001 {
11+ t.Fatalf("SDR white at 203 nits: %v %v %v", white, green, blue)
12+ }
13+ if pqEncode(0) != 0 || math.Abs(pqEncode(10000)-1) > 1e-9 {
14+ t.Fatal("PQ endpoints")
15+ }
16+ p := []byte{255, 255, 255, 255}
17+ hdrCursorPixel(p, 203)
18+ if p[3] != 255 || math.Abs(float64(p[0])-148) > 1 || p[0] != p[1] || p[1] != p[2] {
19+ t.Fatalf("cursor white: %v", p)
20+ }
21+}
M internal/adapters/vulkan/renderer.go +14 -3
@@ -22,12 +22,21 @@ type Renderer struct {
2222 device vk.Device
2323 dd *vk.DeviceDispatch
2424 queue vk.Queue
25- // own is the target until ExportTargets; targets are exported images
26- // (targets.go), current the one the next frame draws into.
25+ // own is the composed image until ExportTargets; in HDR it remains
26+ // the SDR composition/readback image while targets carry PQ.
2727 own target
2828 targets []*target
2929 current int
3030 renderMods []uint64
31+ hdrMods []uint64
32+ hdrNits float64
33+ hdr hdrPass
34+ hdrError error
35+ // hdrReadback is used only by the GPU test to permit transfer from a 10-bit target.
36+ hdrReadback bool
37+ physical vk.PhysicalDevice
38+ // One converted cursor image is kept; changes replace it in bounded memory.
39+ cursorCache cursorConversion
3140 // last is the target of the last frame; readback copies it into
3241 // buffer only when Pixels asks (stale until then).
3342 last *target
@@ -186,6 +195,7 @@ func New(width, height int) (r *Renderer, err error) {
186195 if sup := r.probeDMABuf(physical); sup.Device != 0 {
187196 r.dmabuf = sup
188197 r.probeRenderModifiers(physical)
198+ r.physical = physical
189199 }
190200 // VK_KHR_external_semaphore_fd (enabled above): frames export
191201 // their fence as a sync file.
@@ -193,7 +203,7 @@ func New(width, height int) (r *Renderer, err error) {
193203 }
194204 r.dd.GetDeviceQueue(r.device, family, 0, &r.queue)
195205 extent := vk.Extent3D{Width: uint32(width), Height: uint32(height), Depth: 1}
196- ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: extent, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingOptimal, Usage: targetUsage, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined}
206+ ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: extent, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingOptimal, Usage: targetUsage | vk.ImageUsageSampledBit, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined}
197207 if err = checked("vkCreateImage", r.dd.CreateImage(r.device, &ii, nil, &r.own.image)); err != nil {
198208 return
199209 }
@@ -356,6 +366,7 @@ func (r *Renderer) Close() {
356366 r.idle()
357367 }
358368 r.dropTargets()
369+ r.destroyHDR()
359370 r.dropCursors()
360371 for id, im := range r.imports {
361372 r.release(im)
@@ -22,12 +22,21 @@ type Renderer struct {
22 device vk.Device22 device vk.Device
23 dd *vk.DeviceDispatch23 dd *vk.DeviceDispatch
24 queue vk.Queue24 queue vk.Queue
25- // own is the target until ExportTargets; targets are exported images25+ // own is the composed image until ExportTargets; in HDR it remains
26- // (targets.go), current the one the next frame draws into.26+ // the SDR composition/readback image while targets carry PQ.
27 own target27 own target
28 targets []*target28 targets []*target
29 current int29 current int
30 renderMods []uint6430 renderMods []uint64
31+ hdrMods []uint64
32+ hdrNits float64
33+ hdr hdrPass
34+ hdrError error
35+ // hdrReadback is used only by the GPU test to permit transfer from a 10-bit target.
36+ hdrReadback bool
37+ physical vk.PhysicalDevice
38+ // One converted cursor image is kept; changes replace it in bounded memory.
39+ cursorCache cursorConversion
31 // last is the target of the last frame; readback copies it into40 // last is the target of the last frame; readback copies it into
32 // buffer only when Pixels asks (stale until then).41 // buffer only when Pixels asks (stale until then).
33 last *target42 last *target
@@ -186,6 +195,7 @@ func New(width, height int) (r *Renderer, err error) {
186 if sup := r.probeDMABuf(physical); sup.Device != 0 {195 if sup := r.probeDMABuf(physical); sup.Device != 0 {
187 r.dmabuf = sup196 r.dmabuf = sup
188 r.probeRenderModifiers(physical)197 r.probeRenderModifiers(physical)
198+ r.physical = physical
189 }199 }
190 // VK_KHR_external_semaphore_fd (enabled above): frames export200 // VK_KHR_external_semaphore_fd (enabled above): frames export
191 // their fence as a sync file.201 // their fence as a sync file.
@@ -193,7 +203,7 @@ func New(width, height int) (r *Renderer, err error) {
193 }203 }
194 r.dd.GetDeviceQueue(r.device, family, 0, &r.queue)204 r.dd.GetDeviceQueue(r.device, family, 0, &r.queue)
195 extent := vk.Extent3D{Width: uint32(width), Height: uint32(height), Depth: 1}205 extent := vk.Extent3D{Width: uint32(width), Height: uint32(height), Depth: 1}
196- ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: extent, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingOptimal, Usage: targetUsage, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined}206+ ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: extent, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingOptimal, Usage: targetUsage | vk.ImageUsageSampledBit, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined}
197 if err = checked("vkCreateImage", r.dd.CreateImage(r.device, &ii, nil, &r.own.image)); err != nil {207 if err = checked("vkCreateImage", r.dd.CreateImage(r.device, &ii, nil, &r.own.image)); err != nil {
198 return208 return
199 }209 }
@@ -356,6 +366,7 @@ func (r *Renderer) Close() {
356 r.idle()366 r.idle()
357 }367 }
358 r.dropTargets()368 r.dropTargets()
369+ r.destroyHDR()
359 r.dropCursors()370 r.dropCursors()
360 for id, im := range r.imports {371 for id, im := range r.imports {
361 r.release(im)372 r.release(im)
A internal/adapters/vulkan/shaders/hdr.frag +21 -0
@@ -0,0 +1,21 @@
1+#version 450
2+layout(set=0, binding=0) uniform sampler2D imageSDR;
3+layout(push_constant) uniform Brightness { float nits; } pc;
4+layout(location=0) out vec4 color;
5+
6+vec3 decodeSRGB(vec3 c) {
7+ return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), greaterThan(c, vec3(0.04045)));
8+}
9+vec3 encodePQ(vec3 nits) {
10+ vec3 x = pow(clamp(nits / 10000.0, vec3(0), vec3(1)), vec3(2610.0 / 16384.0));
11+ return pow((3424.0 / 4096.0 + 2413.0 / 128.0 * x) / (1.0 + 2392.0 / 128.0 * x), vec3(2523.0 / 32.0));
12+}
13+void main() {
14+ vec2 uv = gl_FragCoord.xy / vec2(textureSize(imageSDR, 0));
15+ vec3 rgb = decodeSRGB(texture(imageSDR, uv).rgb);
16+ vec3 bt2020 = mat3(
17+ 0.627404, 0.069097, 0.016391,
18+ 0.329283, 0.919540, 0.088013,
19+ 0.043313, 0.011362, 0.895595) * rgb;
20+ color = vec4(encodePQ(bt2020 * pc.nits), 1.0);
21+}
@@ -0,0 +1,21 @@
1+#version 450
2+layout(set=0, binding=0) uniform sampler2D imageSDR;
3+layout(push_constant) uniform Brightness { float nits; } pc;
4+layout(location=0) out vec4 color;
5+
6+vec3 decodeSRGB(vec3 c) {
7+ return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), greaterThan(c, vec3(0.04045)));
8+}
9+vec3 encodePQ(vec3 nits) {
10+ vec3 x = pow(clamp(nits / 10000.0, vec3(0), vec3(1)), vec3(2610.0 / 16384.0));
11+ return pow((3424.0 / 4096.0 + 2413.0 / 128.0 * x) / (1.0 + 2392.0 / 128.0 * x), vec3(2523.0 / 32.0));
12+}
13+void main() {
14+ vec2 uv = gl_FragCoord.xy / vec2(textureSize(imageSDR, 0));
15+ vec3 rgb = decodeSRGB(texture(imageSDR, uv).rgb);
16+ vec3 bt2020 = mat3(
17+ 0.627404, 0.069097, 0.016391,
18+ 0.329283, 0.919540, 0.088013,
19+ 0.043313, 0.011362, 0.895595) * rgb;
20+ color = vec4(encodePQ(bt2020 * pc.nits), 1.0);
21+}
A internal/adapters/vulkan/shaders/hdr.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

A internal/adapters/vulkan/shaders/hdr.vert +5 -0
@@ -0,0 +1,5 @@
1+#version 450
2+void main() {
3+ const vec2 corners[3] = vec2[](vec2(-1, -1), vec2(3, -1), vec2(-1, 3));
4+ gl_Position = vec4(corners[gl_VertexIndex], 0, 1);
5+}
@@ -0,0 +1,5 @@
1+#version 450
2+void main() {
3+ const vec2 corners[3] = vec2[](vec2(-1, -1), vec2(3, -1), vec2(-1, 3));
4+ gl_Position = vec4(corners[gl_VertexIndex], 0, 1);
5+}
A internal/adapters/vulkan/shaders/hdr.vert.spv +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/vulkan/targets.go +60 -22
@@ -16,8 +16,8 @@ import (
1616 // display accepts and exports their memory as dmabufs, which the output
1717 // turns into KMS framebuffers. Render then composes straight into the
1818 // selected target and the output flips to it; nothing is copied by the
19-// CPU. The internal image is the target until then (headless, tests).
20-// Readback into host memory happens only for Pixels (screenshots).
19+// CPU. In HDR the internal image retains the SDR scene, sampled by a
20+// full-screen GPU pass into the 10-bit target. Pixels reads internal SDR.
2121
2222 // target is an image frames are drawn into.
2323 type target struct {
@@ -38,10 +38,16 @@ type target struct {
3838 // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored.
3939 const fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24
4040
41+// fourccXR30 is DRM_FORMAT_XRGB2101010 (A2R10G10B10 packed).
42+const fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24
43+
4144 // ExportTargets allocates n exported images of the output size, replacing
4245 // the previous ones; n = 0 only drops them (back to the internal image).
4346 func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {
4447 r.dropTargets()
48+ if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 {
49+ return nil, fmt.Errorf("HDR transform unavailable: %w", r.hdrError)
50+ }
4551 if n == 0 {
4652 return nil, nil
4753 }
@@ -50,7 +56,10 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err
5056 }
5157 mods := r.exportModifiers(modifiers)
5258 if len(mods) == 0 {
53- return nil, errors.New("no XRGB8888 modifier both the device and the display accept")
59+ if r.hdrNits == 0 {
60+ return nil, errors.New("no XRGB8888 modifier both the device and the display accept")
61+ }
62+ return nil, errors.New("no XRGB2101010 modifier both the device and the display accept")
5463 }
5564 var out []ports.DMABuf
5665 for range n {
@@ -86,43 +95,58 @@ func (r *Renderer) target() *target {
8695 return &r.own
8796 }
8897
89-// exportModifiers are the XRGB8888 modifiers the device renders to and
90-// exports with one plane, restricted to the display's when given.
98+// exportModifiers intersects the selected signal format modifiers with
99+// those supported by the display. HDR never assumes an unspecified list.
91100 func (r *Renderer) exportModifiers(display []uint64) []uint64 {
92101 var out []uint64
93- for _, m := range r.renderMods {
94- if len(display) == 0 || slices.Contains(display, m) {
102+ available := r.renderMods
103+ if r.hdrNits > 0 {
104+ available = r.hdrMods
105+ }
106+ for _, m := range available {
107+ if (len(display) == 0 && r.hdrNits == 0) || slices.Contains(display, m) {
95108 out = append(out, m)
96109 }
97110 }
98111 return out
99112 }
100113
101-// probeRenderModifiers lists the B8G8R8A8 modifiers usable as a transfer
102-// destination with a single plane that the device can export.
114+// probeRenderModifiers lists single-plane, exportable SDR and HDR targets.
103115 func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {
116+ r.renderMods = r.probeModifiers(physical, vk.FormatB8g8r8a8Unorm)
117+ r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32)
118+}
119+
120+func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 {
104121 list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}
105122 fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)}
106- r.id.GetPhysicalDeviceFormatProperties2(physical, vk.FormatB8g8r8a8Unorm, &fp)
123+ r.id.GetPhysicalDeviceFormatProperties2(physical, format, &fp)
107124 if list.DrmFormatModifierCount == 0 {
108- return
125+ return nil
109126 }
110127 mods := make([]vk.DrmFormatModifierPropertiesEXT, list.DrmFormatModifierCount)
111128 list.DrmFormatModifierProperties = &mods[0]
112- r.id.GetPhysicalDeviceFormatProperties2(physical, vk.FormatB8g8r8a8Unorm, &fp)
113- need := vk.FormatFeatureFlags(formatFeatureTransferSrc | vk.FormatFeatureColorAttachmentBit | vk.FormatFeatureColorAttachmentBlendBit)
114- // Targets are drawn on (compose.go) and read back for screenshots.
129+ r.id.GetPhysicalDeviceFormatProperties2(physical, format, &fp)
130+ need := vk.FormatFeatureFlags(vk.FormatFeatureColorAttachmentBit)
131+ if format == vk.FormatB8g8r8a8Unorm {
132+ need |= formatFeatureTransferSrc | vk.FormatFeatureColorAttachmentBlendBit
133+ } else if r.hdrReadback {
134+ need |= formatFeatureTransferSrc
135+ }
136+ var result []uint64
137+ // SDR targets are composed and read back; HDR targets are color attachments.
115138 for _, m := range mods[:list.DrmFormatModifierCount] {
116- if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, m.DrmFormatModifier) {
117- r.renderMods = append(r.renderMods, m.DrmFormatModifier)
139+ if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, format, m.DrmFormatModifier) {
140+ result = append(result, m.DrmFormatModifier)
118141 }
119142 }
143+ return result
120144 }
121145
122-func (r *Renderer) exportable(physical vk.PhysicalDevice, modifier uint64) bool {
146+func (r *Renderer) exportable(physical vk.PhysicalDevice, format vk.Format, modifier uint64) bool {
123147 mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive}
124148 ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
125- info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Format: vk.FormatB8g8r8a8Unorm, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: targetUsage}
149+ info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Format: format, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: exportUsage(format, r.hdrReadback)}
126150 extOut := vk.ExternalImageFormatProperties{SType: vk.StructureTypeExternalImageFormatProperties}
127151 out := vk.ImageFormatProperties2{SType: vk.StructureTypeImageFormatProperties2, Next: unsafe.Pointer(&extOut)}
128152 if r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) != vk.Success {
@@ -135,13 +159,26 @@ func (r *Renderer) exportable(physical vk.PhysicalDevice, modifier uint64) bool
135159 // generated bindings.
136160 const formatFeatureTransferSrc = 1 << 14
137161
138-// Targets are drawn on (compose.go) and copied from for screenshots.
162+// Exported SDR targets compose directly; HDR targets receive the final pass.
139163 const targetUsage = vk.ImageUsageTransferSrcBit | vk.ImageUsageColorAttachmentBit
140164
165+// Production HDR targets need only a color attachment; test readback opts
166+// into transfer source before export and skips if the driver refuses it.
167+func exportUsage(format vk.Format, readback bool) vk.ImageUsageFlags {
168+ if format == vk.FormatA2r10g10b10UnormPack32 && !readback {
169+ return vk.ImageUsageColorAttachmentBit
170+ }
171+ return targetUsage
172+}
173+
141174 // exportTarget creates one exported image with a modifier from mods.
142175 func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {
143176 d := r.dd
144177 t := &target{exported: true, layout: vk.ImageLayoutUndefined}
178+ format, fourcc := vk.Format(vk.FormatB8g8r8a8Unorm), uint32(fourccXRGB)
179+ if r.hdrNits > 0 {
180+ format, fourcc = vk.FormatA2r10g10b10UnormPack32, fourccXR30
181+ }
145182 ok := false
146183 defer func() {
147184 if !ok {
@@ -150,7 +187,7 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {
150187 }()
151188 list := vk.ImageDrmFormatModifierListCreateInfoEXT{SType: vk.StructureTypeImageDRMFormatModifierListCreateInfoEXT, DrmFormatModifierCount: uint32(len(mods)), DrmFormatModifiers: &mods[0]}
152189 external := vk.ExternalMemoryImageCreateInfo{SType: vk.StructureTypeExternalMemoryImageCreateInfo, Next: unsafe.Pointer(&list), HandleTypes: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
153- ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, Next: unsafe.Pointer(&external), ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: targetUsage, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined}
190+ ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, Next: unsafe.Pointer(&external), ImageType: vk.ImageType2d, Format: format, Extent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: exportUsage(format, r.hdrReadback), SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined}
154191 if err := checked("vkCreateImage(target)", d.CreateImage(r.device, &ii, nil, &t.image)); err != nil {
155192 return nil, ports.DMABuf{}, err
156193 }
@@ -176,7 +213,8 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {
176213 var layout vk.SubresourceLayout
177214 sub := vk.ImageSubresource{AspectMask: vk.ImageAspectMemoryPlane0BitEXT}
178215 d.GetImageSubresourceLayout(r.device, t.image, &sub, &layout)
179- if err := r.createView(t); err != nil {
216+ t.view, err = r.imageView(t.image, format)
217+ if err != nil {
180218 return nil, ports.DMABuf{}, err
181219 }
182220 var fd int32
@@ -188,7 +226,7 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {
188226 if f == nil {
189227 return nil, ports.DMABuf{}, fmt.Errorf("invalid exported fd %d", fd)
190228 }
191- buf := ports.DMABuf{Width: r.width, Height: r.height, Format: fourccXRGB, Modifier: props.DrmFormatModifier, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}
229+ buf := ports.DMABuf{Width: r.width, Height: r.height, Format: fourcc, Modifier: props.DrmFormatModifier, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}
192230 ok = true
193231 return t, buf, nil
194232 }
@@ -16,8 +16,8 @@ import (
16 // display accepts and exports their memory as dmabufs, which the output16 // display accepts and exports their memory as dmabufs, which the output
17 // turns into KMS framebuffers. Render then composes straight into the17 // turns into KMS framebuffers. Render then composes straight into the
18 // selected target and the output flips to it; nothing is copied by the18 // selected target and the output flips to it; nothing is copied by the
19-// CPU. The internal image is the target until then (headless, tests).19+// CPU. In HDR the internal image retains the SDR scene, sampled by a
20-// Readback into host memory happens only for Pixels (screenshots).20+// full-screen GPU pass into the 10-bit target. Pixels reads internal SDR.
21 21
22 // target is an image frames are drawn into.22 // target is an image frames are drawn into.
23 type target struct {23 type target struct {
@@ -38,10 +38,16 @@ type target struct {
38 // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored.38 // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored.
39 const fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<2439 const fourccXRGB = 'X' | 'R'<<8 | '2'<<16 | '4'<<24
40 40
41+// fourccXR30 is DRM_FORMAT_XRGB2101010 (A2R10G10B10 packed).
42+const fourccXR30 = 'X' | 'R'<<8 | '3'<<16 | '0'<<24
43+
41 // ExportTargets allocates n exported images of the output size, replacing44 // ExportTargets allocates n exported images of the output size, replacing
42 // the previous ones; n = 0 only drops them (back to the internal image).45 // the previous ones; n = 0 only drops them (back to the internal image).
43 func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {46 func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, error) {
44 r.dropTargets()47 r.dropTargets()
48+ if n > 0 && r.hdrNits > 0 && r.hdr.pipeline == 0 {
49+ return nil, fmt.Errorf("HDR transform unavailable: %w", r.hdrError)
50+ }
45 if n == 0 {51 if n == 0 {
46 return nil, nil52 return nil, nil
47 }53 }
@@ -50,7 +56,10 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err
50 }56 }
51 mods := r.exportModifiers(modifiers)57 mods := r.exportModifiers(modifiers)
52 if len(mods) == 0 {58 if len(mods) == 0 {
53- return nil, errors.New("no XRGB8888 modifier both the device and the display accept")59+ if r.hdrNits == 0 {
60+ return nil, errors.New("no XRGB8888 modifier both the device and the display accept")
61+ }
62+ return nil, errors.New("no XRGB2101010 modifier both the device and the display accept")
54 }63 }
55 var out []ports.DMABuf64 var out []ports.DMABuf
56 for range n {65 for range n {
@@ -86,43 +95,58 @@ func (r *Renderer) target() *target {
86 return &r.own95 return &r.own
87 }96 }
88 97
89-// exportModifiers are the XRGB8888 modifiers the device renders to and98+// exportModifiers intersects the selected signal format modifiers with
90-// exports with one plane, restricted to the display's when given.99+// those supported by the display. HDR never assumes an unspecified list.
91 func (r *Renderer) exportModifiers(display []uint64) []uint64 {100 func (r *Renderer) exportModifiers(display []uint64) []uint64 {
92 var out []uint64101 var out []uint64
93- for _, m := range r.renderMods {102+ available := r.renderMods
94- if len(display) == 0 || slices.Contains(display, m) {103+ if r.hdrNits > 0 {
104+ available = r.hdrMods
105+ }
106+ for _, m := range available {
107+ if (len(display) == 0 && r.hdrNits == 0) || slices.Contains(display, m) {
95 out = append(out, m)108 out = append(out, m)
96 }109 }
97 }110 }
98 return out111 return out
99 }112 }
100 113
101-// probeRenderModifiers lists the B8G8R8A8 modifiers usable as a transfer114+// probeRenderModifiers lists single-plane, exportable SDR and HDR targets.
102-// destination with a single plane that the device can export.
103 func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {115 func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {
116+ r.renderMods = r.probeModifiers(physical, vk.FormatB8g8r8a8Unorm)
117+ r.hdrMods = r.probeModifiers(physical, vk.FormatA2r10g10b10UnormPack32)
118+}
119+
120+func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 {
104 list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}121 list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}
105 fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)}122 fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)}
106- r.id.GetPhysicalDeviceFormatProperties2(physical, vk.FormatB8g8r8a8Unorm, &fp)123+ r.id.GetPhysicalDeviceFormatProperties2(physical, format, &fp)
107 if list.DrmFormatModifierCount == 0 {124 if list.DrmFormatModifierCount == 0 {
108- return125+ return nil
109 }126 }
110 mods := make([]vk.DrmFormatModifierPropertiesEXT, list.DrmFormatModifierCount)127 mods := make([]vk.DrmFormatModifierPropertiesEXT, list.DrmFormatModifierCount)
111 list.DrmFormatModifierProperties = &mods[0]128 list.DrmFormatModifierProperties = &mods[0]
112- r.id.GetPhysicalDeviceFormatProperties2(physical, vk.FormatB8g8r8a8Unorm, &fp)129+ r.id.GetPhysicalDeviceFormatProperties2(physical, format, &fp)
113- need := vk.FormatFeatureFlags(formatFeatureTransferSrc | vk.FormatFeatureColorAttachmentBit | vk.FormatFeatureColorAttachmentBlendBit)130+ need := vk.FormatFeatureFlags(vk.FormatFeatureColorAttachmentBit)
114- // Targets are drawn on (compose.go) and read back for screenshots.131+ if format == vk.FormatB8g8r8a8Unorm {
132+ need |= formatFeatureTransferSrc | vk.FormatFeatureColorAttachmentBlendBit
133+ } else if r.hdrReadback {
134+ need |= formatFeatureTransferSrc
135+ }
136+ var result []uint64
137+ // SDR targets are composed and read back; HDR targets are color attachments.
115 for _, m := range mods[:list.DrmFormatModifierCount] {138 for _, m := range mods[:list.DrmFormatModifierCount] {
116- if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, m.DrmFormatModifier) {139+ if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, format, m.DrmFormatModifier) {
117- r.renderMods = append(r.renderMods, m.DrmFormatModifier)140+ result = append(result, m.DrmFormatModifier)
118 }141 }
119 }142 }
143+ return result
120 }144 }
121 145
122-func (r *Renderer) exportable(physical vk.PhysicalDevice, modifier uint64) bool {146+func (r *Renderer) exportable(physical vk.PhysicalDevice, format vk.Format, modifier uint64) bool {
123 mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive}147 mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive}
124 ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT}148 ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
125- info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Format: vk.FormatB8g8r8a8Unorm, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: targetUsage}149+ info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Format: format, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: exportUsage(format, r.hdrReadback)}
126 extOut := vk.ExternalImageFormatProperties{SType: vk.StructureTypeExternalImageFormatProperties}150 extOut := vk.ExternalImageFormatProperties{SType: vk.StructureTypeExternalImageFormatProperties}
127 out := vk.ImageFormatProperties2{SType: vk.StructureTypeImageFormatProperties2, Next: unsafe.Pointer(&extOut)}151 out := vk.ImageFormatProperties2{SType: vk.StructureTypeImageFormatProperties2, Next: unsafe.Pointer(&extOut)}
128 if r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) != vk.Success {152 if r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) != vk.Success {
@@ -135,13 +159,26 @@ func (r *Renderer) exportable(physical vk.PhysicalDevice, modifier uint64) bool
135 // generated bindings.159 // generated bindings.
136 const formatFeatureTransferSrc = 1 << 14160 const formatFeatureTransferSrc = 1 << 14
137 161
138-// Targets are drawn on (compose.go) and copied from for screenshots.162+// Exported SDR targets compose directly; HDR targets receive the final pass.
139 const targetUsage = vk.ImageUsageTransferSrcBit | vk.ImageUsageColorAttachmentBit163 const targetUsage = vk.ImageUsageTransferSrcBit | vk.ImageUsageColorAttachmentBit
140 164
165+// Production HDR targets need only a color attachment; test readback opts
166+// into transfer source before export and skips if the driver refuses it.
167+func exportUsage(format vk.Format, readback bool) vk.ImageUsageFlags {
168+ if format == vk.FormatA2r10g10b10UnormPack32 && !readback {
169+ return vk.ImageUsageColorAttachmentBit
170+ }
171+ return targetUsage
172+}
173+
141 // exportTarget creates one exported image with a modifier from mods.174 // exportTarget creates one exported image with a modifier from mods.
142 func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {175 func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {
143 d := r.dd176 d := r.dd
144 t := &target{exported: true, layout: vk.ImageLayoutUndefined}177 t := &target{exported: true, layout: vk.ImageLayoutUndefined}
178+ format, fourcc := vk.Format(vk.FormatB8g8r8a8Unorm), uint32(fourccXRGB)
179+ if r.hdrNits > 0 {
180+ format, fourcc = vk.FormatA2r10g10b10UnormPack32, fourccXR30
181+ }
145 ok := false182 ok := false
146 defer func() {183 defer func() {
147 if !ok {184 if !ok {
@@ -150,7 +187,7 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {
150 }()187 }()
151 list := vk.ImageDrmFormatModifierListCreateInfoEXT{SType: vk.StructureTypeImageDRMFormatModifierListCreateInfoEXT, DrmFormatModifierCount: uint32(len(mods)), DrmFormatModifiers: &mods[0]}188 list := vk.ImageDrmFormatModifierListCreateInfoEXT{SType: vk.StructureTypeImageDRMFormatModifierListCreateInfoEXT, DrmFormatModifierCount: uint32(len(mods)), DrmFormatModifiers: &mods[0]}
152 external := vk.ExternalMemoryImageCreateInfo{SType: vk.StructureTypeExternalMemoryImageCreateInfo, Next: unsafe.Pointer(&list), HandleTypes: vk.ExternalMemoryHandleTypeDMABUFBitEXT}189 external := vk.ExternalMemoryImageCreateInfo{SType: vk.StructureTypeExternalMemoryImageCreateInfo, Next: unsafe.Pointer(&list), HandleTypes: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
153- ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, Next: unsafe.Pointer(&external), ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: targetUsage, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined}190+ ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, Next: unsafe.Pointer(&external), ImageType: vk.ImageType2d, Format: format, Extent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: exportUsage(format, r.hdrReadback), SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutUndefined}
154 if err := checked("vkCreateImage(target)", d.CreateImage(r.device, &ii, nil, &t.image)); err != nil {191 if err := checked("vkCreateImage(target)", d.CreateImage(r.device, &ii, nil, &t.image)); err != nil {
155 return nil, ports.DMABuf{}, err192 return nil, ports.DMABuf{}, err
156 }193 }
@@ -176,7 +213,8 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {
176 var layout vk.SubresourceLayout213 var layout vk.SubresourceLayout
177 sub := vk.ImageSubresource{AspectMask: vk.ImageAspectMemoryPlane0BitEXT}214 sub := vk.ImageSubresource{AspectMask: vk.ImageAspectMemoryPlane0BitEXT}
178 d.GetImageSubresourceLayout(r.device, t.image, &sub, &layout)215 d.GetImageSubresourceLayout(r.device, t.image, &sub, &layout)
179- if err := r.createView(t); err != nil {216+ t.view, err = r.imageView(t.image, format)
217+ if err != nil {
180 return nil, ports.DMABuf{}, err218 return nil, ports.DMABuf{}, err
181 }219 }
182 var fd int32220 var fd int32
@@ -188,7 +226,7 @@ func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {
188 if f == nil {226 if f == nil {
189 return nil, ports.DMABuf{}, fmt.Errorf("invalid exported fd %d", fd)227 return nil, ports.DMABuf{}, fmt.Errorf("invalid exported fd %d", fd)
190 }228 }
191- buf := ports.DMABuf{Width: r.width, Height: r.height, Format: fourccXRGB, Modifier: props.DrmFormatModifier, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}229+ buf := ports.DMABuf{Width: r.width, Height: r.height, Format: fourcc, Modifier: props.DrmFormatModifier, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}
192 ok = true230 ok = true
193 return t, buf, nil231 return t, buf, nil
194 }232 }
M internal/adapters/vulkan/targets_test.go +96 -0
@@ -2,9 +2,12 @@ package vulkan
22
33 import (
44 "image/color"
5+ "math"
56 "testing"
7+ "unsafe"
68
79 "github.com/bnema/neferwl/internal/ports"
10+ vk "github.com/bnema/purego-vulkan/vulkan"
811 )
912
1013 // Frames drawn into exported targets reach their dmabufs: another renderer
@@ -62,3 +65,96 @@ func TestExportTargets(t *testing.T) {
6265 t.Fatalf("target 0 as seen by the display %v", got)
6366 }
6467 }
68+
69+// The exported HDR image carries PQ while Pixels remains the composed SDR
70+// image. A test-only transfer-src readback checks the actual shader output.
71+func TestHDRExportTarget(t *testing.T) {
72+ r, err := New(32, 16)
73+ if err != nil {
74+ t.Skipf("Vulkan unavailable: %v", err)
75+ }
76+ defer r.Close()
77+ r.SetHDR(203)
78+ // Only this GPU test requests transfer-src on an HDR target.
79+ r.hdrReadback = true
80+ if r.physical == 0 {
81+ t.Skip("no exportable GPU")
82+ }
83+ r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32)
84+ if len(r.hdrMods) == 0 {
85+ t.Skip("no HDR modifier supporting transfer-src")
86+ }
87+ bufs, err := r.ExportTargets(1, r.hdrMods)
88+ if err != nil {
89+ t.Skipf("no HDR-exportable target: %v", err)
90+ }
91+ defer bufs[0].Planes[0].File.Close()
92+ if bufs[0].Format != fourccXR30 {
93+ t.Fatalf("format: %#x", bufs[0].Format)
94+ }
95+
96+ // A real second frame with an unchanged scene must retain the internal
97+ // image and use a partial-damage load from its actual prior layout.
98+ scene := ports.Scene{Seq: 1, Background: "#ffffff"}
99+ for _, tc := range []struct {
100+ bg string
101+ rgb [3]float64
102+ }{
103+ {"#ffffff", [3]float64{1, 1, 1}},
104+ {"#ff0000", [3]float64{1, 0, 0}},
105+ {"#0000ff", [3]float64{0, 0, 1}},
106+ {"#808080", [3]float64{128.0 / 255, 128.0 / 255, 128.0 / 255}},
107+ } {
108+ scene.Seq++
109+ scene.Background = tc.bg
110+ if err := render(r, scene, nil); err != nil {
111+ t.Fatal(err)
112+ }
113+ checkHDRPixel(t, r, tc.rgb)
114+ }
115+ before := r.redrawn
116+ if err := render(r, scene, nil); err != nil {
117+ t.Fatal(err)
118+ }
119+ if r.redrawn-before >= r.width*r.height {
120+ t.Fatalf("unchanged HDR frame redrew %d pixels", r.redrawn-before)
121+ }
122+ checkHDRPixel(t, r, [3]float64{128.0 / 255, 128.0 / 255, 128.0 / 255})
123+ if r.own.layout != vk.ImageLayoutTransferSrcOptimal {
124+ t.Fatalf("internal layout after HDR: %v", r.own.layout)
125+ }
126+ if got := r.Pixels().RGBAAt(0, 0); got.R != 128 || got.G != 128 || got.B != 128 {
127+ t.Fatalf("SDR readback: %v", got)
128+ }
129+}
130+
131+// Test-only target readback: production HDR images do not have transfer-src.
132+func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) {
133+ t.Helper()
134+ if err := r.waitFrame(r.submitted); err != nil {
135+ t.Fatal(err)
136+ }
137+ target := r.targets[0]
138+ err := r.oneShot(func(cmd vk.CommandBuffer) {
139+ d := r.dd
140+ 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}
141+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
142+ region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
143+ d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, r.buffer, 1, &region)
144+ b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0
145+ b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral
146+ b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
147+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
148+ })
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ v := *(*uint32)(unsafe.Pointer(r.mapped))
153+ red, green, blue := hdrPixel(rgb[0], rgb[1], rgb[2], 203)
154+ for i, want := range []float64{blue, green, red} {
155+ got := float64(v>>uint(i*10)&1023) / 1023
156+ if math.Abs(got-want) > 0.005 {
157+ t.Fatalf("rgb %v channel %d: PQ %.5f want %.5f", rgb, i, got, want)
158+ }
159+ }
160+}
@@ -2,9 +2,12 @@ package vulkan
2 2
3 import (3 import (
4 "image/color"4 "image/color"
5+ "math"
5 "testing"6 "testing"
7+ "unsafe"
6 8
7 "github.com/bnema/neferwl/internal/ports"9 "github.com/bnema/neferwl/internal/ports"
10+ vk "github.com/bnema/purego-vulkan/vulkan"
8 )11 )
9 12
10 // Frames drawn into exported targets reach their dmabufs: another renderer13 // Frames drawn into exported targets reach their dmabufs: another renderer
@@ -62,3 +65,96 @@ func TestExportTargets(t *testing.T) {
62 t.Fatalf("target 0 as seen by the display %v", got)65 t.Fatalf("target 0 as seen by the display %v", got)
63 }66 }
64 }67 }
68+
69+// The exported HDR image carries PQ while Pixels remains the composed SDR
70+// image. A test-only transfer-src readback checks the actual shader output.
71+func TestHDRExportTarget(t *testing.T) {
72+ r, err := New(32, 16)
73+ if err != nil {
74+ t.Skipf("Vulkan unavailable: %v", err)
75+ }
76+ defer r.Close()
77+ r.SetHDR(203)
78+ // Only this GPU test requests transfer-src on an HDR target.
79+ r.hdrReadback = true
80+ if r.physical == 0 {
81+ t.Skip("no exportable GPU")
82+ }
83+ r.hdrMods = r.probeModifiers(r.physical, vk.FormatA2r10g10b10UnormPack32)
84+ if len(r.hdrMods) == 0 {
85+ t.Skip("no HDR modifier supporting transfer-src")
86+ }
87+ bufs, err := r.ExportTargets(1, r.hdrMods)
88+ if err != nil {
89+ t.Skipf("no HDR-exportable target: %v", err)
90+ }
91+ defer bufs[0].Planes[0].File.Close()
92+ if bufs[0].Format != fourccXR30 {
93+ t.Fatalf("format: %#x", bufs[0].Format)
94+ }
95+
96+ // A real second frame with an unchanged scene must retain the internal
97+ // image and use a partial-damage load from its actual prior layout.
98+ scene := ports.Scene{Seq: 1, Background: "#ffffff"}
99+ for _, tc := range []struct {
100+ bg string
101+ rgb [3]float64
102+ }{
103+ {"#ffffff", [3]float64{1, 1, 1}},
104+ {"#ff0000", [3]float64{1, 0, 0}},
105+ {"#0000ff", [3]float64{0, 0, 1}},
106+ {"#808080", [3]float64{128.0 / 255, 128.0 / 255, 128.0 / 255}},
107+ } {
108+ scene.Seq++
109+ scene.Background = tc.bg
110+ if err := render(r, scene, nil); err != nil {
111+ t.Fatal(err)
112+ }
113+ checkHDRPixel(t, r, tc.rgb)
114+ }
115+ before := r.redrawn
116+ if err := render(r, scene, nil); err != nil {
117+ t.Fatal(err)
118+ }
119+ if r.redrawn-before >= r.width*r.height {
120+ t.Fatalf("unchanged HDR frame redrew %d pixels", r.redrawn-before)
121+ }
122+ checkHDRPixel(t, r, [3]float64{128.0 / 255, 128.0 / 255, 128.0 / 255})
123+ if r.own.layout != vk.ImageLayoutTransferSrcOptimal {
124+ t.Fatalf("internal layout after HDR: %v", r.own.layout)
125+ }
126+ if got := r.Pixels().RGBAAt(0, 0); got.R != 128 || got.G != 128 || got.B != 128 {
127+ t.Fatalf("SDR readback: %v", got)
128+ }
129+}
130+
131+// Test-only target readback: production HDR images do not have transfer-src.
132+func checkHDRPixel(t *testing.T, r *Renderer, rgb [3]float64) {
133+ t.Helper()
134+ if err := r.waitFrame(r.submitted); err != nil {
135+ t.Fatal(err)
136+ }
137+ target := r.targets[0]
138+ err := r.oneShot(func(cmd vk.CommandBuffer) {
139+ d := r.dd
140+ 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}
141+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTopOfPipeBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b)
142+ region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
143+ d.CmdCopyImageToBuffer(cmd, target.image, vk.ImageLayoutTransferSrcOptimal, r.buffer, 1, &region)
144+ b.SrcAccessMask, b.DstAccessMask = vk.AccessTransferReadBit, 0
145+ b.OldLayout, b.NewLayout = vk.ImageLayoutTransferSrcOptimal, vk.ImageLayoutGeneral
146+ b.SrcQueueFamilyIndex, b.DstQueueFamilyIndex = r.family, vk.QueueFamilyForeignEXT
147+ d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b)
148+ })
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ v := *(*uint32)(unsafe.Pointer(r.mapped))
153+ red, green, blue := hdrPixel(rgb[0], rgb[1], rgb[2], 203)
154+ for i, want := range []float64{blue, green, red} {
155+ got := float64(v>>uint(i*10)&1023) / 1023
156+ if math.Abs(got-want) > 0.005 {
157+ t.Fatalf("rgb %v channel %d: PQ %.5f want %.5f", rgb, i, got, want)
158+ }
159+ }
160+}
M internal/app/drm.go +6 -1
@@ -81,8 +81,13 @@ func openDRM(ctx context.Context, cfg ports.Config) (*drmBackend, error) {
8181 // wantFromConfig turns output.<name> and render.* entries into connector choices.
8282 func wantFromConfig(cfg ports.Config) drm.Want {
8383 r := cfg.Render
84- want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR}
84+ want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, HDR: map[string]drm.HDRSettings{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR}
8585 for _, o := range cfg.Outputs {
86+ nits := o.SDRBrightness
87+ if nits == 0 {
88+ nits = ports.DefaultSDRBrightness
89+ }
90+ want.HDR[o.Name] = drm.HDRSettings{Enabled: o.HDR, SDRBrightness: nits}
8691 if o.Off {
8792 want.Disabled[o.Name] = true
8893 continue
@@ -81,8 +81,13 @@ func openDRM(ctx context.Context, cfg ports.Config) (*drmBackend, error) {
81 // wantFromConfig turns output.<name> and render.* entries into connector choices.81 // wantFromConfig turns output.<name> and render.* entries into connector choices.
82 func wantFromConfig(cfg ports.Config) drm.Want {82 func wantFromConfig(cfg ports.Config) drm.Want {
83 r := cfg.Render83 r := cfg.Render
84- want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR}84+ want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}, HDR: map[string]drm.HDRSettings{}, NoScanout: !r.DirectScanout, NoTearing: !r.Tearing, NoVRR: !r.VRR}
85 for _, o := range cfg.Outputs {85 for _, o := range cfg.Outputs {
86+ nits := o.SDRBrightness
87+ if nits == 0 {
88+ nits = ports.DefaultSDRBrightness
89+ }
90+ want.HDR[o.Name] = drm.HDRSettings{Enabled: o.HDR, SDRBrightness: nits}
86 if o.Off {91 if o.Off {
87 want.Disabled[o.Name] = true92 want.Disabled[o.Name] = true
88 continue93 continue
M internal/app/drm_test.go +3 -2
@@ -3,14 +3,15 @@ package app
33 import (
44 "testing"
55
6+ "github.com/bnema/neferwl/internal/adapters/drm"
67 "github.com/bnema/neferwl/internal/ports"
78 )
89
910 func TestWantFromConfig(t *testing.T) {
10- cfg := ports.Config{Outputs: []ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}}}
11+ cfg := ports.Config{Outputs: []ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true, HDR: true, SDRBrightness: 350}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}}}
1112 cfg.Render.DirectScanout, cfg.Render.VRR = true, true
1213 want := wantFromConfig(cfg)
13- if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 || want.NoScanout || !want.NoTearing || want.NoVRR {
14+ if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 || want.NoScanout || !want.NoTearing || want.NoVRR || want.HDR["eDP-1"] != (drm.HDRSettings{Enabled: true, SDRBrightness: 350}) || want.HDR["HDMI-A-1"].SDRBrightness != 203 {
1415 t.Fatalf("%+v", want)
1516 }
1617 }
@@ -3,14 +3,15 @@ package app
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/bnema/neferwl/internal/adapters/drm"
6 "github.com/bnema/neferwl/internal/ports"7 "github.com/bnema/neferwl/internal/ports"
7 )8 )
8 9
9 func TestWantFromConfig(t *testing.T) {10 func TestWantFromConfig(t *testing.T) {
10- cfg := ports.Config{Outputs: []ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}}}11+ cfg := ports.Config{Outputs: []ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true, HDR: true, SDRBrightness: 350}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}}}
11 cfg.Render.DirectScanout, cfg.Render.VRR = true, true12 cfg.Render.DirectScanout, cfg.Render.VRR = true, true
12 want := wantFromConfig(cfg)13 want := wantFromConfig(cfg)
13- if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 || want.NoScanout || !want.NoTearing || want.NoVRR {14+ if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 || want.NoScanout || !want.NoTearing || want.NoVRR || want.HDR["eDP-1"] != (drm.HDRSettings{Enabled: true, SDRBrightness: 350}) || want.HDR["HDMI-A-1"].SDRBrightness != 203 {
14 t.Fatalf("%+v", want)15 t.Fatalf("%+v", want)
15 }16 }
16 }17 }
M internal/mocks/ports/devices_mock.go +40 -0
@@ -388,6 +388,46 @@ func (_c *MockRenderer_Render_Call) RunAndReturn(run func(scene ports.Scene, win
388388 return _c
389389 }
390390
391+// SetHDR provides a mock function for the type MockRenderer
392+func (_mock *MockRenderer) SetHDR(sdrNits float64) {
393+ _mock.Called(sdrNits)
394+ return
395+}
396+
397+// MockRenderer_SetHDR_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetHDR'
398+type MockRenderer_SetHDR_Call struct {
399+ *mock.Call
400+}
401+
402+// SetHDR is a helper method to define mock.On call
403+// - sdrNits float64
404+func (_e *MockRenderer_Expecter) SetHDR(sdrNits any) *MockRenderer_SetHDR_Call {
405+ return &MockRenderer_SetHDR_Call{Call: _e.mock.On("SetHDR", sdrNits)}
406+}
407+
408+func (_c *MockRenderer_SetHDR_Call) Run(run func(sdrNits float64)) *MockRenderer_SetHDR_Call {
409+ _c.Call.Run(func(args mock.Arguments) {
410+ var arg0 float64
411+ if args[0] != nil {
412+ arg0 = args[0].(float64)
413+ }
414+ run(
415+ arg0,
416+ )
417+ })
418+ return _c
419+}
420+
421+func (_c *MockRenderer_SetHDR_Call) Return() *MockRenderer_SetHDR_Call {
422+ _c.Call.Return()
423+ return _c
424+}
425+
426+func (_c *MockRenderer_SetHDR_Call) RunAndReturn(run func(sdrNits float64)) *MockRenderer_SetHDR_Call {
427+ _c.Run(run)
428+ return _c
429+}
430+
391431 // UseTarget provides a mock function for the type MockRenderer
392432 func (_mock *MockRenderer) UseTarget(i int) {
393433 _mock.Called(i)
@@ -388,6 +388,46 @@ func (_c *MockRenderer_Render_Call) RunAndReturn(run func(scene ports.Scene, win
388 return _c388 return _c
389 }389 }
390 390
391+// SetHDR provides a mock function for the type MockRenderer
392+func (_mock *MockRenderer) SetHDR(sdrNits float64) {
393+ _mock.Called(sdrNits)
394+ return
395+}
396+
397+// MockRenderer_SetHDR_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetHDR'
398+type MockRenderer_SetHDR_Call struct {
399+ *mock.Call
400+}
401+
402+// SetHDR is a helper method to define mock.On call
403+// - sdrNits float64
404+func (_e *MockRenderer_Expecter) SetHDR(sdrNits any) *MockRenderer_SetHDR_Call {
405+ return &MockRenderer_SetHDR_Call{Call: _e.mock.On("SetHDR", sdrNits)}
406+}
407+
408+func (_c *MockRenderer_SetHDR_Call) Run(run func(sdrNits float64)) *MockRenderer_SetHDR_Call {
409+ _c.Call.Run(func(args mock.Arguments) {
410+ var arg0 float64
411+ if args[0] != nil {
412+ arg0 = args[0].(float64)
413+ }
414+ run(
415+ arg0,
416+ )
417+ })
418+ return _c
419+}
420+
421+func (_c *MockRenderer_SetHDR_Call) Return() *MockRenderer_SetHDR_Call {
422+ _c.Call.Return()
423+ return _c
424+}
425+
426+func (_c *MockRenderer_SetHDR_Call) RunAndReturn(run func(sdrNits float64)) *MockRenderer_SetHDR_Call {
427+ _c.Run(run)
428+ return _c
429+}
430+
391 // UseTarget provides a mock function for the type MockRenderer431 // UseTarget provides a mock function for the type MockRenderer
392 func (_mock *MockRenderer) UseTarget(i int) {432 func (_mock *MockRenderer) UseTarget(i int) {
393 _mock.Called(i)433 _mock.Called(i)
M internal/ports/config.go +12 -7
@@ -1,5 +1,8 @@
11 package ports
22
3+// DefaultSDRBrightness is the BT.2408 SDR reference white in HDR, in nits.
4+const DefaultSDRBrightness = 203
5+
36 // OutputConfig configures one connector. Name is the connector (e.g. "DP-2").
47 // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the
58 // monitor's preferred mode. Off disables the connector.
@@ -8,15 +11,17 @@ package ports
811 // Scale is the output scale (0 means 1); layout works in logical pixels,
912 // physical = logical × Scale.
1013 // Primary gets the focus and the pointer at startup, wherever it is placed.
11-// ScaleOnly marks an entry set by output.<name>.scale or .primary alone: it
14+// ScaleOnly marks an entry set only by output.<name> subkeys: it
1215 // does not select the connector.
1316 type OutputConfig struct {
14- Name string
15- Mode string
16- Off bool
17- Scale float64
18- Primary bool
19- ScaleOnly bool
17+ Name string
18+ Mode string
19+ Off bool
20+ Scale float64
21+ Primary bool
22+ HDR bool // opt-in to HDR10 on capable outputs
23+ SDRBrightness int // nits; default DefaultSDRBrightness
24+ ScaleOnly bool
2025 }
2126
2227 // Config is the parsed compositor configuration (see the config adapter for keys).
@@ -1,5 +1,8 @@
1 package ports1 package ports
2 2
3+// DefaultSDRBrightness is the BT.2408 SDR reference white in HDR, in nits.
4+const DefaultSDRBrightness = 203
5+
3 // OutputConfig configures one connector. Name is the connector (e.g. "DP-2").6 // OutputConfig configures one connector. Name is the connector (e.g. "DP-2").
4 // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the7 // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the
5 // monitor's preferred mode. Off disables the connector.8 // monitor's preferred mode. Off disables the connector.
@@ -8,15 +11,17 @@ package ports
8 // Scale is the output scale (0 means 1); layout works in logical pixels,11 // Scale is the output scale (0 means 1); layout works in logical pixels,
9 // physical = logical × Scale.12 // physical = logical × Scale.
10 // Primary gets the focus and the pointer at startup, wherever it is placed.13 // Primary gets the focus and the pointer at startup, wherever it is placed.
11-// ScaleOnly marks an entry set by output.<name>.scale or .primary alone: it14+// ScaleOnly marks an entry set only by output.<name> subkeys: it
12 // does not select the connector.15 // does not select the connector.
13 type OutputConfig struct {16 type OutputConfig struct {
14- Name string17+ Name string
15- Mode string18+ Mode string
16- Off bool19+ Off bool
17- Scale float6420+ Scale float64
18- Primary bool21+ Primary bool
19- ScaleOnly bool22+ HDR bool // opt-in to HDR10 on capable outputs
23+ SDRBrightness int // nits; default DefaultSDRBrightness
24+ ScaleOnly bool
20 }25 }
21 26
22 // Config is the parsed compositor configuration (see the config adapter for keys).27 // Config is the parsed compositor configuration (see the config adapter for keys).
M internal/ports/devices.go +6 -2
@@ -9,12 +9,16 @@ import (
99 // to its own image until ExportTargets gives it scanout images; then each
1010 // Render draws into the target chosen by UseTarget (ADR 014: no CPU pass).
1111 type Renderer interface {
12+ // SetHDR selects the HDR10 output transform (0 disables it).
13+ // The DRM output calls this before ExportTargets.
14+ SetHDR(sdrNits float64)
1215 // Render draws the scene. done is a sync file signalled when the GPU
1316 // finished the frame (nil: already finished); the caller closes it.
1417 Render(Scene, map[WindowID]SurfaceContent) (done *os.File, err error)
1518 // ExportTargets allocates n images of the renderer's size that the
16- // display can scan out, with one of the given XRGB8888 modifiers
17- // (none: any the device exports), and returns them as dmabufs.
19+ // display can scan out, with one of the given XRGB8888 (SDR) or
20+ // XRGB2101010 (HDR) modifiers (none: any the device exports),
21+ // and returns them as dmabufs.
1822 // n = 0 drops the targets.
1923 ExportTargets(n int, modifiers []uint64) ([]DMABuf, error)
2024 // UseTarget selects the exported image the next Render draws into.
@@ -9,12 +9,16 @@ import (
9 // to its own image until ExportTargets gives it scanout images; then each9 // to its own image until ExportTargets gives it scanout images; then each
10 // Render draws into the target chosen by UseTarget (ADR 014: no CPU pass).10 // Render draws into the target chosen by UseTarget (ADR 014: no CPU pass).
11 type Renderer interface {11 type Renderer interface {
12+ // SetHDR selects the HDR10 output transform (0 disables it).
13+ // The DRM output calls this before ExportTargets.
14+ SetHDR(sdrNits float64)
12 // Render draws the scene. done is a sync file signalled when the GPU15 // Render draws the scene. done is a sync file signalled when the GPU
13 // finished the frame (nil: already finished); the caller closes it.16 // finished the frame (nil: already finished); the caller closes it.
14 Render(Scene, map[WindowID]SurfaceContent) (done *os.File, err error)17 Render(Scene, map[WindowID]SurfaceContent) (done *os.File, err error)
15 // ExportTargets allocates n images of the renderer's size that the18 // ExportTargets allocates n images of the renderer's size that the
16- // display can scan out, with one of the given XRGB8888 modifiers19+ // display can scan out, with one of the given XRGB8888 (SDR) or
17- // (none: any the device exports), and returns them as dmabufs.20+ // XRGB2101010 (HDR) modifiers (none: any the device exports),
21+ // and returns them as dmabufs.
18 // n = 0 drops the targets.22 // n = 0 drops the targets.
19 ExportTargets(n int, modifiers []uint64) ([]DMABuf, error)23 ExportTargets(n int, modifiers []uint64) ([]DMABuf, error)
20 // UseTarget selects the exported image the next Render draws into.24 // UseTarget selects the exported image the next Render draws into.

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