detached f8fc70e

fix(drm): refuse scanout of cropped or below-root buffers

A viewport crop or a subsurface placed below the root cannot be shown by
the primary plane as composition shows it. A hint-only commit on a
subsurface now rebuilds the cached tree so its tearing hint reaches the
output.
commit
f8fc70e1be97372d63f0203ed86151b54e513250
author
brice · · 2026-09-28 12:49 UTC
parent
373cb80
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +67 -0
M internal/adapters/drm/scanout.go +6 -0
@@ -100,6 +100,9 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
100100 return c, "buffer_transform"
101101 case c.Width != w || c.Height != h:
102102 return c, "size_mismatch"
103+ case c.Source[2] != 0 && c.Source != [4]float32{0, 0, float32(c.Width), float32(c.Height)}:
104+ // A viewport crop: the plane would show the whole buffer.
105+ return c, "source_crop"
103106 case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:
104107 return c, "logical_mismatch"
105108 case c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0 || c.Geometry.W != c.LogicalW || c.Geometry.H != c.LogicalH):
@@ -124,6 +127,9 @@ func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) {
124127 return c, "not_dmabuf"
125128 case !ch.Opaque:
126129 return c, "subsurface_translucent"
130+ case ch.Below:
131+ // The root is drawn over it.
132+ return c, "subsurface_below"
127133 case ch.X != c.Geometry.X || ch.Y != c.Geometry.Y:
128134 return c, "subsurface_offset"
129135 }
@@ -100,6 +100,9 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo
100 return c, "buffer_transform"100 return c, "buffer_transform"
101 case c.Width != w || c.Height != h:101 case c.Width != w || c.Height != h:
102 return c, "size_mismatch"102 return c, "size_mismatch"
103+ case c.Source[2] != 0 && c.Source != [4]float32{0, 0, float32(c.Width), float32(c.Height)}:
104+ // A viewport crop: the plane would show the whole buffer.
105+ return c, "source_crop"
103 case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:106 case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H:
104 return c, "logical_mismatch"107 return c, "logical_mismatch"
105 case c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0 || c.Geometry.W != c.LogicalW || c.Geometry.H != c.LogicalH):108 case c.Geometry != (ports.Rect{}) && (c.Geometry.X != 0 || c.Geometry.Y != 0 || c.Geometry.W != c.LogicalW || c.Geometry.H != c.LogicalH):
@@ -124,6 +127,9 @@ func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) {
124 return c, "not_dmabuf"127 return c, "not_dmabuf"
125 case !ch.Opaque:128 case !ch.Opaque:
126 return c, "subsurface_translucent"129 return c, "subsurface_translucent"
130+ case ch.Below:
131+ // The root is drawn over it.
132+ return c, "subsurface_below"
127 case ch.X != c.Geometry.X || ch.Y != c.Geometry.Y:133 case ch.X != c.Geometry.X || ch.Y != c.Geometry.Y:
128 return c, "subsurface_offset"134 return c, "subsurface_offset"
129 }135 }
M internal/adapters/drm/scanout_test.go +4 -0
@@ -47,6 +47,10 @@ func TestScanoutCandidate(t *testing.T) {
4747 {"client subsurface translucent", nil, wineTree(func(ch *ports.Subsurface) { ch.Opaque = false }), "subsurface_translucent"},
4848 {"client subsurface shm", nil, wineTree(func(ch *ports.Subsurface) { ch.DMABuf, ch.SHM = nil, &ports.SHMBuffer{} }), "not_dmabuf"},
4949 {"client subsurface offset", nil, wineTree(func(ch *ports.Subsurface) { ch.X = 4 }), "subsurface_offset"},
50+ {"client subsurface below root", nil, wineTree(func(ch *ports.Subsurface) { ch.Below = true }), "subsurface_below"},
51+ {"client subsurface cropped", nil, wineTree(func(ch *ports.Subsurface) { ch.Source = [4]float32{0, 0, 100, 50} }), "source_crop"},
52+ {"client subsurface full source", nil, wineTree(func(ch *ports.Subsurface) { ch.Source = [4]float32{0, 0, 200, 100} }), ""},
53+ {"root cropped", nil, func(c *ports.SurfaceContent) { c.Source = [4]float32{10, 0, 190, 100} }, "source_crop"},
5054 {"client subsurface scaled", nil, wineTree(func(ch *ports.Subsurface) { ch.Width = 100 }), "size_mismatch"},
5155 {"client subsurface small", nil, wineTree(func(ch *ports.Subsurface) { ch.LogicalW = 90 }), "logical_mismatch"},
5256 {"two subsurfaces", nil, func(c *ports.SurfaceContent) {
@@ -47,6 +47,10 @@ func TestScanoutCandidate(t *testing.T) {
47 {"client subsurface translucent", nil, wineTree(func(ch *ports.Subsurface) { ch.Opaque = false }), "subsurface_translucent"},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"},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"},49 {"client subsurface offset", nil, wineTree(func(ch *ports.Subsurface) { ch.X = 4 }), "subsurface_offset"},
50+ {"client subsurface below root", nil, wineTree(func(ch *ports.Subsurface) { ch.Below = true }), "subsurface_below"},
51+ {"client subsurface cropped", nil, wineTree(func(ch *ports.Subsurface) { ch.Source = [4]float32{0, 0, 100, 50} }), "source_crop"},
52+ {"client subsurface full source", nil, wineTree(func(ch *ports.Subsurface) { ch.Source = [4]float32{0, 0, 200, 100} }), ""},
53+ {"root cropped", nil, func(c *ports.SurfaceContent) { c.Source = [4]float32{10, 0, 190, 100} }, "source_crop"},
50 {"client subsurface scaled", nil, wineTree(func(ch *ports.Subsurface) { ch.Width = 100 }), "size_mismatch"},54 {"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"},55 {"client subsurface small", nil, wineTree(func(ch *ports.Subsurface) { ch.LogicalW = 90 }), "logical_mismatch"},
52 {"two subsurfaces", nil, func(c *ports.SurfaceContent) {56 {"two subsurfaces", nil, func(c *ports.SurfaceContent) {
M internal/adapters/wayland/surface.go +4 -0
@@ -354,6 +354,10 @@ func (s *surface) applyCommit(u *update) {
354354 s.opaque = u.opaque
355355 }
356356 hinted := s.async != u.async || s.color != u.color || s.representation != u.representation
357+ if s.async != u.async {
358+ // Contents carry the hint of every surface in the tree.
359+ s.root().treeDirty = true
360+ }
357361 s.async = u.async
358362 s.color = u.color
359363 s.representation = u.representation
@@ -354,6 +354,10 @@ func (s *surface) applyCommit(u *update) {
354 s.opaque = u.opaque354 s.opaque = u.opaque
355 }355 }
356 hinted := s.async != u.async || s.color != u.color || s.representation != u.representation356 hinted := s.async != u.async || s.color != u.color || s.representation != u.representation
357+ if s.async != u.async {
358+ // Contents carry the hint of every surface in the tree.
359+ s.root().treeDirty = true
360+ }
357 s.async = u.async361 s.async = u.async
358 s.color = u.color362 s.color = u.color
359 s.representation = u.representation363 s.representation = u.representation
M internal/adapters/wayland/tearing_test.go +53 -0
@@ -25,6 +25,59 @@ func asyncContent(t *testing.T, contents <-chan ports.SurfaceContent, id ports.W
2525 }
2626 }
2727
28+// A subsurface carries its own hint in the window's content, even when a
29+// commit changes only the hint after the tree was cached (Wine presents
30+// the game in a subsurface that the output scans out).
31+func TestTearingHintOnSubsurface(t *testing.T) {
32+ s, events, _, contents, dir := contentServer(t)
33+ c := protocolClient(t, s, dir)
34+ w, root, _ := surfaceMapper(t, c, events)()
35+ comp := bindProtocol(t, c, "wl_compositor")
36+ subc := bindProtocol(t, c, "wl_subcompositor")
37+ child := c.AllocateID()
38+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, child)
39+ registerProtocol(t, c, child)
40+ sub := c.AllocateID()
41+ requestProtocol(t, c, subc, wayland.SubcompositorRequestGetSubsurface, sub, child, root)
42+ registerProtocol(t, c, sub)
43+ requestProtocol(t, c, child, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
44+ requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
45+ requestProtocol(t, c, root, wayland.SurfaceRequestCommit)
46+ if err := c.Roundtrip(); err != nil {
47+ t.Fatal(err)
48+ }
49+ childAsync := func(want bool) {
50+ t.Helper()
51+ deadline := time.After(2 * time.Second)
52+ for {
53+ select {
54+ case got := <-contents:
55+ if got.ID == w.ID && len(got.Children) == 1 && got.Children[0].Async == want {
56+ if got.Async {
57+ t.Fatal("child hint leaked to the root")
58+ }
59+ return
60+ }
61+ case <-deadline:
62+ t.Fatalf("no content with child async=%v", want)
63+ }
64+ }
65+ }
66+ childAsync(false)
67+
68+ manager := bindProtocol(t, c, "wp_tearing_control_manager_v1")
69+ control := c.AllocateID()
70+ registerProtocol(t, c, control)
71+ requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, control, child)
72+ requestProtocol(t, c, control, tearingcontrol.WpTearingControlV1RequestSetPresentationHint, uint32(tearingcontrol.WpTearingControlV1PresentationHintAsync))
73+ requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
74+ requestProtocol(t, c, root, wayland.SurfaceRequestCommit)
75+ if err := c.Roundtrip(); err != nil {
76+ t.Fatal(err)
77+ }
78+ childAsync(true)
79+}
80+
2881 func TestTearingHintAppliesOnCommit(t *testing.T) {
2982 s, events, _, contents, dir := contentServer(t)
3083 c := protocolClient(t, s, dir)
@@ -25,6 +25,59 @@ func asyncContent(t *testing.T, contents <-chan ports.SurfaceContent, id ports.W
25 }25 }
26 }26 }
27 27
28+// A subsurface carries its own hint in the window's content, even when a
29+// commit changes only the hint after the tree was cached (Wine presents
30+// the game in a subsurface that the output scans out).
31+func TestTearingHintOnSubsurface(t *testing.T) {
32+ s, events, _, contents, dir := contentServer(t)
33+ c := protocolClient(t, s, dir)
34+ w, root, _ := surfaceMapper(t, c, events)()
35+ comp := bindProtocol(t, c, "wl_compositor")
36+ subc := bindProtocol(t, c, "wl_subcompositor")
37+ child := c.AllocateID()
38+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, child)
39+ registerProtocol(t, c, child)
40+ sub := c.AllocateID()
41+ requestProtocol(t, c, subc, wayland.SubcompositorRequestGetSubsurface, sub, child, root)
42+ registerProtocol(t, c, sub)
43+ requestProtocol(t, c, child, wayland.SurfaceRequestAttach, shmBuffer(t, c), int32(0), int32(0))
44+ requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
45+ requestProtocol(t, c, root, wayland.SurfaceRequestCommit)
46+ if err := c.Roundtrip(); err != nil {
47+ t.Fatal(err)
48+ }
49+ childAsync := func(want bool) {
50+ t.Helper()
51+ deadline := time.After(2 * time.Second)
52+ for {
53+ select {
54+ case got := <-contents:
55+ if got.ID == w.ID && len(got.Children) == 1 && got.Children[0].Async == want {
56+ if got.Async {
57+ t.Fatal("child hint leaked to the root")
58+ }
59+ return
60+ }
61+ case <-deadline:
62+ t.Fatalf("no content with child async=%v", want)
63+ }
64+ }
65+ }
66+ childAsync(false)
67+
68+ manager := bindProtocol(t, c, "wp_tearing_control_manager_v1")
69+ control := c.AllocateID()
70+ registerProtocol(t, c, control)
71+ requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, control, child)
72+ requestProtocol(t, c, control, tearingcontrol.WpTearingControlV1RequestSetPresentationHint, uint32(tearingcontrol.WpTearingControlV1PresentationHintAsync))
73+ requestProtocol(t, c, child, wayland.SurfaceRequestCommit)
74+ requestProtocol(t, c, root, wayland.SurfaceRequestCommit)
75+ if err := c.Roundtrip(); err != nil {
76+ t.Fatal(err)
77+ }
78+ childAsync(true)
79+}
80+
28 func TestTearingHintAppliesOnCommit(t *testing.T) {81 func TestTearingHintAppliesOnCommit(t *testing.T) {
29 s, events, _, contents, dir := contentServer(t)82 s, events, _, contents, dir := contentServer(t)
30 c := protocolClient(t, s, dir)83 c := protocolClient(t, s, dir)

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