detached 373cb80

feat(drm): scan out a fullscreen window's single opaque GPU subsurface

Wine (winewayland) draws the window in a wl_shm root and presents Vulkan in
one desync subsurface over the client area, so fullscreen games were always
composed (not_dmabuf). The candidate now takes that subsurface when it is
the only one, opaque, GPU-backed and at the geometry origin. Subsurfaces
carry their own tearing hint. make tty serves pprof on localhost:6060.
commit
373cb80d1dc60b982259fc5d81dcef135e2633c0
author
brice · · 2026-09-28 12:37 UTC
parent
ff30418
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +75 -2
M Makefile +3 -1
@@ -2,6 +2,8 @@
22
33 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
44 TTY_TIMEOUT ?= 0
5+# Go runtime profiles for tty runs; set PPROF= to disable.
6+PPROF ?= localhost:6060
57 RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/neferwl/runs
68
79 # Build a fresh binary with the git revision embedded.
@@ -10,7 +12,7 @@ bin:
1012
1113 # Run on the current TTY. Quit: Ctrl+Alt+Backspace (or the quit bind).
1214 tty: bin
13- ./bin/neferwl --backend=drm --debug=all --timeout=$(TTY_TIMEOUT); \
15+ ./bin/neferwl --backend=drm --debug=all --timeout=$(TTY_TIMEOUT) $(if $(PPROF),--pprof=$(PPROF)); \
1416 echo "exit $$? - log: $(RUNS)/latest.log"
1517
1618 # Summarise the last run: lifecycle, warnings and errors.
@@ -2,6 +2,8 @@
2 2
3 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.3 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
4 TTY_TIMEOUT ?= 04 TTY_TIMEOUT ?= 0
5+# Go runtime profiles for tty runs; set PPROF= to disable.
6+PPROF ?= localhost:6060
5 RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/neferwl/runs7 RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/neferwl/runs
6 8
7 # Build a fresh binary with the git revision embedded.9 # Build a fresh binary with the git revision embedded.
@@ -10,7 +12,7 @@ bin:
10 12
11 # Run on the current TTY. Quit: Ctrl+Alt+Backspace (or the quit bind).13 # Run on the current TTY. Quit: Ctrl+Alt+Backspace (or the quit bind).
12 tty: bin14 tty: bin
13- ./bin/neferwl --backend=drm --debug=all --timeout=$(TTY_TIMEOUT); \15+ ./bin/neferwl --backend=drm --debug=all --timeout=$(TTY_TIMEOUT) $(if $(PPROF),--pprof=$(PPROF)); \
14 echo "exit $$? - log: $(RUNS)/latest.log"16 echo "exit $$? - log: $(RUNS)/latest.log"
15 17
16 # Summarise the last run: lifecycle, warnings and errors.18 # Summarise the last run: lifecycle, warnings and errors.
M internal/adapters/drm/scanout.go +30 -0
@@ -84,6 +84,12 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
8484 }
8585 }
8686 c := surfaces[full.ID]
87+ if c.DMABuf == nil && len(c.Children) > 0 {
88+ var reason string
89+ if c, reason = clientSubsurface(c); reason != "" {
90+ return c, reason
91+ }
92+ }
8793 switch {
8894 case c.DMABuf == nil:
8995 return c, "not_dmabuf"
@@ -102,6 +108,30 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
102108 return c, ""
103109 }
104110
111+// clientSubsurface returns the content to scan out for a window whose
112+// root has no GPU buffer but one opaque subsurface over its geometry: Wine
113+// draws the frame in the root (wl_shm) and presents Vulkan in a subsurface
114+// on the client area. That subsurface hides the root entirely, so it is
115+// what the screen shows. The returned content keeps the window's ID and
116+// Seq for release and presentation.
117+func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) {
118+ if len(c.Children) != 1 {
119+ return c, "subsurfaces"
120+ }
121+ ch := c.Children[0]
122+ switch {
123+ case ch.DMABuf == nil:
124+ return c, "not_dmabuf"
125+ case !ch.Opaque:
126+ return c, "subsurface_translucent"
127+ case ch.X != c.Geometry.X || ch.Y != c.Geometry.Y:
128+ return c, "subsurface_offset"
129+ }
130+ out := ch.SurfaceContent
131+ out.ID, out.Seq, out.Geometry = c.ID, c.Seq, ports.Rect{}
132+ return out, ""
133+}
134+
105135 // scanoutFrame picks a fullscreen client buffer to flip directly. It
106136 // returns fb 0, with the reason logged on change, when the frame must be
107137 // composed.
@@ -84,6 +84,12 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
84 }84 }
85 }85 }
86 c := surfaces[full.ID]86 c := surfaces[full.ID]
87+ if c.DMABuf == nil && len(c.Children) > 0 {
88+ var reason string
89+ if c, reason = clientSubsurface(c); reason != "" {
90+ return c, reason
91+ }
92+ }
87 switch {93 switch {
88 case c.DMABuf == nil:94 case c.DMABuf == nil:
89 return c, "not_dmabuf"95 return c, "not_dmabuf"
@@ -102,6 +108,30 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
102 return c, ""108 return c, ""
103 }109 }
104 110
111+// clientSubsurface returns the content to scan out for a window whose
112+// root has no GPU buffer but one opaque subsurface over its geometry: Wine
113+// draws the frame in the root (wl_shm) and presents Vulkan in a subsurface
114+// on the client area. That subsurface hides the root entirely, so it is
115+// what the screen shows. The returned content keeps the window's ID and
116+// Seq for release and presentation.
117+func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) {
118+ if len(c.Children) != 1 {
119+ return c, "subsurfaces"
120+ }
121+ ch := c.Children[0]
122+ switch {
123+ case ch.DMABuf == nil:
124+ return c, "not_dmabuf"
125+ case !ch.Opaque:
126+ return c, "subsurface_translucent"
127+ case ch.X != c.Geometry.X || ch.Y != c.Geometry.Y:
128+ return c, "subsurface_offset"
129+ }
130+ out := ch.SurfaceContent
131+ out.ID, out.Seq, out.Geometry = c.ID, c.Seq, ports.Rect{}
132+ return out, ""
133+}
134+
105 // scanoutFrame picks a fullscreen client buffer to flip directly. It135 // scanoutFrame picks a fullscreen client buffer to flip directly. It
106 // returns fb 0, with the reason logged on change, when the frame must be136 // returns fb 0, with the reason logged on change, when the frame must be
107 // composed.137 // composed.
M internal/adapters/drm/scanout_test.go +40 -0
@@ -1,6 +1,7 @@
11 package drm
22
33 import (
4+ "os"
45 "testing"
56 "time"
67
@@ -42,6 +43,16 @@ func TestScanoutCandidate(t *testing.T) {
4243 {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"},
4344 {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "logical_mismatch"},
4445 {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""},
46+ {"wine client subsurface", nil, wineTree(nil), ""},
47+ {"client subsurface translucent", nil, wineTree(func(ch *ports.Subsurface) { ch.Opaque = false }), "subsurface_translucent"},
48+ {"client subsurface shm", nil, wineTree(func(ch *ports.Subsurface) { ch.DMABuf, ch.SHM = nil, &ports.SHMBuffer{} }), "not_dmabuf"},
49+ {"client subsurface offset", nil, wineTree(func(ch *ports.Subsurface) { ch.X = 4 }), "subsurface_offset"},
50+ {"client subsurface scaled", nil, wineTree(func(ch *ports.Subsurface) { ch.Width = 100 }), "size_mismatch"},
51+ {"client subsurface small", nil, wineTree(func(ch *ports.Subsurface) { ch.LogicalW = 90 }), "logical_mismatch"},
52+ {"two subsurfaces", nil, func(c *ports.SurfaceContent) {
53+ wineTree(nil)(c)
54+ c.Children = append(c.Children, c.Children[0])
55+ }, "subsurfaces"},
4556 } {
4657 s := scene
4758 s.Windows = append([]ports.SceneWindow(nil), scene.Windows...)
@@ -58,6 +69,35 @@ func TestScanoutCandidate(t *testing.T) {
5869 }
5970 }
6071
72+// wineTree turns the test content into Wine's layout: the root is a wl_shm
73+// frame and the game is one opaque GPU subsurface over the client area.
74+func wineTree(edit func(ch *ports.Subsurface)) func(c *ports.SurfaceContent) {
75+ return func(c *ports.SurfaceContent) {
76+ ch := ports.Subsurface{SurfaceContent: *c}
77+ ch.Opaque = true
78+ if edit != nil {
79+ edit(&ch)
80+ }
81+ c.DMABuf, c.SHM = nil, &ports.SHMBuffer{}
82+ c.Children = []ports.Subsurface{ch}
83+ }
84+}
85+
86+// Wine's game subsurface is scanned out in place of the window: the
87+// content keeps the window's ID and Seq and takes the subsurface buffer.
88+func TestScanoutCandidateClientSubsurface(t *testing.T) {
89+ scene := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true}}}
90+ game := &ports.DMABuf{ID: 9, Width: 200, Height: 100, Format: fourccXRGB, Planes: []ports.DMABufPlane{{}}}
91+ acquire := &os.File{}
92+ root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{},
93+ Geometry: ports.Rect{W: 100, H: 50},
94+ Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Seq: 3, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, Opaque: true, DMABuf: game, Async: true, Acquire: acquire}}}}
95+ c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100)
96+ if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) {
97+ t.Fatalf("candidate %+v, reason %q", c, reason)
98+ }
99+}
100+
61101 // VRR follows a visible fullscreen window covering the output, even
62102 // when other windows or layers force composition.
63103 func TestFullscreenShown(t *testing.T) {
@@ -1,6 +1,7 @@
1 package drm1 package drm
2 2
3 import (3 import (
4+ "os"
4 "testing"5 "testing"
5 "time"6 "time"
6 7
@@ -42,6 +43,16 @@ func TestScanoutCandidate(t *testing.T) {
42 {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"},43 {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"},
43 {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "logical_mismatch"},44 {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "logical_mismatch"},
44 {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""},45 {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""},
46+ {"wine client subsurface", nil, wineTree(nil), ""},
47+ {"client subsurface translucent", nil, wineTree(func(ch *ports.Subsurface) { ch.Opaque = false }), "subsurface_translucent"},
48+ {"client subsurface shm", nil, wineTree(func(ch *ports.Subsurface) { ch.DMABuf, ch.SHM = nil, &ports.SHMBuffer{} }), "not_dmabuf"},
49+ {"client subsurface offset", nil, wineTree(func(ch *ports.Subsurface) { ch.X = 4 }), "subsurface_offset"},
50+ {"client subsurface scaled", nil, wineTree(func(ch *ports.Subsurface) { ch.Width = 100 }), "size_mismatch"},
51+ {"client subsurface small", nil, wineTree(func(ch *ports.Subsurface) { ch.LogicalW = 90 }), "logical_mismatch"},
52+ {"two subsurfaces", nil, func(c *ports.SurfaceContent) {
53+ wineTree(nil)(c)
54+ c.Children = append(c.Children, c.Children[0])
55+ }, "subsurfaces"},
45 } {56 } {
46 s := scene57 s := scene
47 s.Windows = append([]ports.SceneWindow(nil), scene.Windows...)58 s.Windows = append([]ports.SceneWindow(nil), scene.Windows...)
@@ -58,6 +69,35 @@ func TestScanoutCandidate(t *testing.T) {
58 }69 }
59 }70 }
60 71
72+// wineTree turns the test content into Wine's layout: the root is a wl_shm
73+// frame and the game is one opaque GPU subsurface over the client area.
74+func wineTree(edit func(ch *ports.Subsurface)) func(c *ports.SurfaceContent) {
75+ return func(c *ports.SurfaceContent) {
76+ ch := ports.Subsurface{SurfaceContent: *c}
77+ ch.Opaque = true
78+ if edit != nil {
79+ edit(&ch)
80+ }
81+ c.DMABuf, c.SHM = nil, &ports.SHMBuffer{}
82+ c.Children = []ports.Subsurface{ch}
83+ }
84+}
85+
86+// Wine's game subsurface is scanned out in place of the window: the
87+// content keeps the window's ID and Seq and takes the subsurface buffer.
88+func TestScanoutCandidateClientSubsurface(t *testing.T) {
89+ scene := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true}}}
90+ game := &ports.DMABuf{ID: 9, Width: 200, Height: 100, Format: fourccXRGB, Planes: []ports.DMABufPlane{{}}}
91+ acquire := &os.File{}
92+ root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{},
93+ Geometry: ports.Rect{W: 100, H: 50},
94+ Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Seq: 3, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, Opaque: true, DMABuf: game, Async: true, Acquire: acquire}}}}
95+ c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100)
96+ if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) {
97+ t.Fatalf("candidate %+v, reason %q", c, reason)
98+ }
99+}
100+
61 // VRR follows a visible fullscreen window covering the output, even101 // VRR follows a visible fullscreen window covering the output, even
62 // when other windows or layers force composition.102 // when other windows or layers force composition.
63 func TestFullscreenShown(t *testing.T) {103 func TestFullscreenShown(t *testing.T) {
M internal/adapters/wayland/surface.go +2 -1
@@ -160,6 +160,8 @@ func (s *surface) contentWithColor() ports.SurfaceContent {
160160 c := s.content
161161 c.Surface, c.Version = s.identity, s.version
162162 c.Color = s.outputColor()
163+ // A subsurface asks for tearing itself: the output may scan it out.
164+ c.Async = s.async
163165 return c
164166 }
165167
@@ -179,7 +181,6 @@ func (s *surface) tree(id ports.WindowID) ports.SurfaceContent {
179181 if s.xdg != nil {
180182 c.Geometry = s.xdg.geometry
181183 }
182- c.Async = s.async
183184 return c
184185 }
185186
@@ -160,6 +160,8 @@ func (s *surface) contentWithColor() ports.SurfaceContent {
160 c := s.content160 c := s.content
161 c.Surface, c.Version = s.identity, s.version161 c.Surface, c.Version = s.identity, s.version
162 c.Color = s.outputColor()162 c.Color = s.outputColor()
163+ // A subsurface asks for tearing itself: the output may scan it out.
164+ c.Async = s.async
163 return c165 return c
164 }166 }
165 167
@@ -179,7 +181,6 @@ func (s *surface) tree(id ports.WindowID) ports.SurfaceContent {
179 if s.xdg != nil {181 if s.xdg != nil {
180 c.Geometry = s.xdg.geometry182 c.Geometry = s.xdg.geometry
181 }183 }
182- c.Async = s.async
183 return c184 return c
184 }185 }
185 186

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 23

Tags 10

↑↓ moveenter openesc close