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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 100 | 100 | return c, "buffer_transform" | |
| 101 | 101 | case c.Width != w || c.Height != h: | |
| 102 | 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 | 106 | case c.LogicalW != full.Rect.W || c.LogicalH != full.Rect.H: | |
| 104 | 107 | return c, "logical_mismatch" | |
| 105 | 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 | 127 | return c, "not_dmabuf" | |
| 125 | 128 | case !ch.Opaque: | |
| 126 | 129 | return c, "subsurface_translucent" | |
| 130 | + | case ch.Below: | |
| 131 | + | // The root is drawn over it. | |
| 132 | + | return c, "subsurface_below" | |
| 127 | 133 | case ch.X != c.Geometry.X || ch.Y != c.Geometry.Y: | |
| 128 | 134 | return c, "subsurface_offset" | |
| 129 | 135 | } | |
| @@ -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) { | |||
| 47 | 47 | {"client subsurface translucent", nil, wineTree(func(ch *ports.Subsurface) { ch.Opaque = false }), "subsurface_translucent"}, | |
| 48 | 48 | {"client subsurface shm", nil, wineTree(func(ch *ports.Subsurface) { ch.DMABuf, ch.SHM = nil, &ports.SHMBuffer{} }), "not_dmabuf"}, | |
| 49 | 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 | 54 | {"client subsurface scaled", nil, wineTree(func(ch *ports.Subsurface) { ch.Width = 100 }), "size_mismatch"}, | |
| 51 | 55 | {"client subsurface small", nil, wineTree(func(ch *ports.Subsurface) { ch.LogicalW = 90 }), "logical_mismatch"}, | |
| 52 | 56 | {"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) { | |||
| 354 | 354 | s.opaque = u.opaque | |
| 355 | 355 | } | |
| 356 | 356 | 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 | 361 | s.async = u.async | |
| 358 | 362 | s.color = u.color | |
| 359 | 363 | s.representation = u.representation | |
| @@ -354,6 +354,10 @@ func (s *surface) applyCommit(u *update) { | |||||
| 354 | s.opaque = u.opaque | 354 | s.opaque = u.opaque | ||
| 355 | } | 355 | } | ||
| 356 | hinted := s.async != u.async || s.color != u.color || s.representation != u.representation | 356 | 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.async | 361 | s.async = u.async | ||
| 358 | s.color = u.color | 362 | s.color = u.color | ||
| 359 | s.representation = u.representation | 363 | 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 | |||
| 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 | 81 | func TestTearingHintAppliesOnCommit(t *testing.T) { | |
| 29 | 82 | s, events, _, contents, dir := contentServer(t) | |
| 30 | 83 | 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) | ||