detached 760a012

#24 fix(drm): allocate output images with GBM and import them into Vulkan

state
merged · 3c13b3d · merge commit
branch
fix/gbm-scanout-images → main
opened
bnema ·
updated

Problem

On the NVIDIA proprietary driver, the screen shows heavy corruption: horizontal bands, repeated fragments and large black or grey areas. KMS accepts the output framebuffers (modifier 0x300000000606014, block-linear) and nothing crashes.

NeferWL allocated its two output images with Vulkan and exported them to KMS. NVIDIA does not scan these images out correctly. gamescope hits the same bug with the same modifier (ValveSoftware/gamescope#2309). wlroots, Hyprland/Aquamarine, niri/Smithay and gamescope's fix all allocate scanout buffers with GBM instead.

Fix

  • GBM allocation: output images are allocated with GBM (SCANOUT | RENDERING) on the KMS card. The modifier list is the plane's IN_FORMATS intersected with the modifiers the renderer can import. libgbm is loaded with purego, so the build stays CGO_ENABLED=0.
  • Vulkan import: the images are imported into Vulkan with explicit modifier layouts. Rendering is still zero-copy.
  • Fallback (deprecated, neferwl#23): for each image kind (driver, single-plane, linear), the renderer's Vulkan export is used when GBM is missing or fails. The zero-copy output line is then a warning with "alloc":"vulkan".
  • Old nvidia-drm: before 560.35.3, IN_FENCE_FD makes commits fail (NVIDIA/open-gpu-kernel-modules#622). On these versions the render fence is waited on the CPU, and a client buffer whose fence is still pending is composed rather than scanned out.

Verified

  • make check, make race, make mocks-check and staticcheck ./... pass.
  • The GPU test (GBM allocation, Vulkan import, render, pixel readback) passes on AMD RX 9070 (GBM picks 0x200000000082305) and on NVIDIA RTX 3070 Ti, driver 615.78.08, render node only (GBM picks 0x300000000606013).
  • AMD, real KMS: two outputs (5120×2160@165, 3840×2160@120) run on "alloc":"gbm" with the same modifier as before, a clean display, and workspace and VT switching.
  • NVIDIA, real KMS: a user confirmed the display works on nvidia-drm (DP-1 3440×1440, "alloc":"gbm", modifier 0x300000000606014).

Not verified

  • The IN_FENCE_FD gate on an nvidia-drm older than 560.35.3.
  • HDR (XRGB2101010) with GBM images.

Follow-ups

  • neferwl#22: the hardware cursor plane is refused on nvidia-drm.
  • neferwl#23: remove the deprecated Vulkan-export fallback.
CommitSubjectAuthorDateSignatureStatus
760a012refactor(drm): mark the Vulkan-export output image fallback deprecatedbriceGPG Verified
f0c31c4refactor(drm): simplify GBM image lifecycle and harden the fence gatebriceGPG Verified
53269c8fix(drm): allocate output images with GBM and import them into VulkanbriceGPG Verified

Checks 1

47 files · +2460 -588
1/1
M .github/workflows/release.yml +8 -3
M .goreleaser.yaml +12 -19
M .mockery.yml +10 -0
M README.md +4 -3
M docs/dev/architecture.md +9 -1
M docs/dev/headless.md +5 -9
M docs/media/demo.mp4 +0 -0 binary file: no text diff
M docs/media/demo.png +0 -0 binary file: no text diff
A internal/adapters/drm/alloc.go +50 -0
A internal/adapters/drm/alloc_test.go +234 -0
M internal/adapters/drm/amdgpu_rules_test.go +1 -0
M internal/adapters/drm/card.go +4 -1
M internal/adapters/drm/commit_test.go +1 -0
A internal/adapters/drm/fencegate.go +214 -0
A internal/adapters/drm/fencegate_test.go +283 -0
M internal/adapters/drm/flip_stats.go +3 -0
A internal/adapters/drm/gbm/gbm.go +276 -0
A internal/adapters/drm/gbm/gbm_test.go +88 -0
M internal/adapters/drm/hdr_test.go +1 -0
A internal/adapters/drm/image_allocator_mock_test.go +158 -0
A internal/adapters/drm/image_buffer_mock_test.go +132 -0
M internal/adapters/drm/images.go +168 -37
M internal/adapters/drm/output.go +43 -13
M internal/adapters/drm/overlay.go +2 -4
M internal/adapters/drm/protection_retry_test.go +1 -0
M internal/adapters/drm/scanout.go +6 -0
@@ -231,6 +231,9 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por
231231 mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline)
232232 }
233233 }
234+ if reason == "" && !o.clientFenceReady(c.Acquire) {
235+ reason = "fence_pending"
236+ }
234237 if reason == "" {
235238 fb, reason = o.scanoutFB(c.DMABuf, time.Now())
236239 if reason == "" {
@@ -287,6 +290,9 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S
287290 reason = "color_refused"
288291 }
289292 }
293+ if reason == "" && !o.clientFenceReady(c.Acquire) {
294+ reason = "fence_pending"
295+ }
290296 if reason == "" {
291297 var fb uint32
292298 if fb, reason = o.overlayFB(c.DMABuf, time.Now()); reason == "" {
@@ -231,6 +231,9 @@ func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]por
231 mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline)231 mode, reason = planeColor(c.Color, c.DMABuf.Format, c.Opaque, o.hdr.on, o.primary.pipeline)
232 }232 }
233 }233 }
234+ if reason == "" && !o.clientFenceReady(c.Acquire) {
235+ reason = "fence_pending"
236+ }
234 if reason == "" {237 if reason == "" {
235 fb, reason = o.scanoutFB(c.DMABuf, time.Now())238 fb, reason = o.scanoutFB(c.DMABuf, time.Now())
236 if reason == "" {239 if reason == "" {
@@ -287,6 +290,9 @@ func (o *Output) overlayFrame(s ports.Scene, surfaces map[ports.WindowID]ports.S
287 reason = "color_refused"290 reason = "color_refused"
288 }291 }
289 }292 }
293+ if reason == "" && !o.clientFenceReady(c.Acquire) {
294+ reason = "fence_pending"
295+ }
290 if reason == "" {296 if reason == "" {
291 var fb uint32297 var fb uint32
292 if fb, reason = o.overlayFB(c.DMABuf, time.Now()); reason == "" {298 if fb, reason = o.overlayFB(c.DMABuf, time.Now()); reason == "" {
M internal/adapters/drm/security_recovery_test.go +1 -0
@@ -16,6 +16,7 @@ import (
1616 // signals. Recovery revokes earlier proof before a fresh blocking black proof.
1717 func TestSecurityStuckRecoveryInvalidatesBeforeFreshProof(t *testing.T) {
1818 o, k, commits, mu := testOutputMu(t)
19+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
1920 o.cursor, o.tearing = nil, false
2021 o.frame.stuckAfter = 40 * time.Millisecond
2122 o.flipped = make(chan flipEvent) // the event is intentionally lost
@@ -16,6 +16,7 @@ import (
16 // signals. Recovery revokes earlier proof before a fresh blocking black proof.16 // signals. Recovery revokes earlier proof before a fresh blocking black proof.
17 func TestSecurityStuckRecoveryInvalidatesBeforeFreshProof(t *testing.T) {17 func TestSecurityStuckRecoveryInvalidatesBeforeFreshProof(t *testing.T) {
18 o, k, commits, mu := testOutputMu(t)18 o, k, commits, mu := testOutputMu(t)
19+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
19 o.cursor, o.tearing = nil, false20 o.cursor, o.tearing = nil, false
20 o.frame.stuckAfter = 40 * time.Millisecond21 o.frame.stuckAfter = 40 * time.Millisecond
21 o.flipped = make(chan flipEvent) // the event is intentionally lost22 o.flipped = make(chan flipEvent) // the event is intentionally lost
M internal/adapters/drm/security_run_test.go +1 -0
@@ -19,6 +19,7 @@ func TestSecurityRunWakePendingResumeAndOff(t *testing.T) {
1919
2020 func securityRunWakePendingResumeAndOff(t *testing.T) {
2121 o, k, commits, mu := testOutputMu(t)
22+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
2223 o.cursor, o.tearing = nil, false
2324 var state atomic.Uint64
2425 securityGate(t, o, &state)
@@ -19,6 +19,7 @@ func TestSecurityRunWakePendingResumeAndOff(t *testing.T) {
19 19
20 func securityRunWakePendingResumeAndOff(t *testing.T) {20 func securityRunWakePendingResumeAndOff(t *testing.T) {
21 o, k, commits, mu := testOutputMu(t)21 o, k, commits, mu := testOutputMu(t)
22+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
22 o.cursor, o.tearing = nil, false23 o.cursor, o.tearing = nil, false
23 var state atomic.Uint6424 var state atomic.Uint64
24 securityGate(t, o, &state)25 securityGate(t, o, &state)
M internal/adapters/drm/security_startup_test.go +1 -0
@@ -15,6 +15,7 @@ import (
1515
1616 func TestSecurityProtectedStartupBlackProofAndCaptureRejection(t *testing.T) {
1717 o, k, commits, mu := testOutputMu(t)
18+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
1819 o.cursor = nil
1920 var state atomic.Uint64
2021 state.Store(3)
@@ -15,6 +15,7 @@ import (
15 15
16 func TestSecurityProtectedStartupBlackProofAndCaptureRejection(t *testing.T) {16 func TestSecurityProtectedStartupBlackProofAndCaptureRejection(t *testing.T) {
17 o, k, commits, mu := testOutputMu(t)17 o, k, commits, mu := testOutputMu(t)
18+ o.fbs = [2]uint32{} // the first setup of a run: no image yet
18 o.cursor = nil19 o.cursor = nil
19 var state atomic.Uint6420 var state atomic.Uint64
20 state.Store(3)21 state.Store(3)
M internal/adapters/drm/vrr.go +3 -2
@@ -24,10 +24,11 @@ func (o *Output) probeAsync(r ports.Renderer) {
2424 req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(o.fbs[o.back]))
2525 if done != nil {
2626 defer done.Close()
27- req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(done.Fd()))
27+ o.fenceProp(req, o.primary, done)
2828 }
2929 err = o.k.commit(req, atomicTestOnly|flipAsyncFlag, 0)
30- o.asyncFence = err == nil && done != nil
30+ // With CPU waits no fence is in a commit, async or not.
31+ o.asyncFence = err == nil && (done != nil || o.cpuFences)
3132 o.log.Info().Err(err).Str("connector", o.conn.name).Bool("async_fence", o.asyncFence).Msg("tearing probe")
3233 }
3334
@@ -24,10 +24,11 @@ func (o *Output) probeAsync(r ports.Renderer) {
24 req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(o.fbs[o.back]))24 req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(o.fbs[o.back]))
25 if done != nil {25 if done != nil {
26 defer done.Close()26 defer done.Close()
27- req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(done.Fd()))27+ o.fenceProp(req, o.primary, done)
28 }28 }
29 err = o.k.commit(req, atomicTestOnly|flipAsyncFlag, 0)29 err = o.k.commit(req, atomicTestOnly|flipAsyncFlag, 0)
30- o.asyncFence = err == nil && done != nil30+ // With CPU waits no fence is in a commit, async or not.
31+ o.asyncFence = err == nil && (done != nil || o.cpuFences)
31 o.log.Info().Err(err).Str("connector", o.conn.name).Bool("async_fence", o.asyncFence).Msg("tearing probe")32 o.log.Info().Err(err).Str("connector", o.conn.name).Bool("async_fence", o.asyncFence).Msg("tearing probe")
32 }33 }
33 34
M internal/adapters/headless/output.go +4 -53
@@ -8,7 +8,6 @@ import (
88 "image/png"
99 "os"
1010 "path/filepath"
11- "sync"
1211 "time"
1312
1413 "golang.org/x/sys/unix"
@@ -584,8 +583,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
584583 if !written {
585584 continue
586585 }
587- // latest.png is the frame just written, linked rather than encoded again.
588- written, err = linkPNGSecure(filepath.Join(opts.ScreenshotDir, name), filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security)
586+ written, err = writePNGSecureResult(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security)
589587 if err != nil {
590588 return fmt.Errorf("latest screenshot: %w", err)
591589 }
@@ -642,31 +640,9 @@ func writePNGSecure(path string, img image.Image, security ports.SessionSecurity
642640 return err
643641 }
644642
645-// screenshotPNG encodes the PNGs written by the headless output (frames, the
646-// latest.png fallback, raw HDR latest-pq.png): encoding is by far the largest
647-// cost of a headless frame, so it trades file size for speed. Its buffers are
648-// reused between files.
649-var screenshotPNG = png.Encoder{CompressionLevel: png.BestSpeed, BufferPool: new(pngBuffers)}
650-
651-// pngBuffers is a png.EncoderBufferPool.
652-type pngBuffers struct{ pool sync.Pool }
653-
654-func (p *pngBuffers) Get() *png.EncoderBuffer {
655- b, _ := p.pool.Get().(*png.EncoderBuffer)
656- return b
657-}
658-
659-func (p *pngBuffers) Put(b *png.EncoderBuffer) { p.pool.Put(b) }
660-
661-// gated reports whether a screenshot must not be published: the session is
662-// protected, or its security state is no longer the one the frame was drawn in.
663-func gated(security ports.SessionSecurity, state ports.SecurityState) bool {
664- return security != nil && (state.Protected || security.Snapshot() != state)
665-}
666-
667643 // writePNGSecureResult distinguishes a security skip from a published PNG.
668644 func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) {
669- if gated(security, state) {
645+ if security != nil && (state.Protected || security.Snapshot() != state) {
670646 return false, nil
671647 }
672648 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
@@ -674,14 +650,14 @@ func writePNGSecureResult(path string, img image.Image, security ports.SessionSe
674650 return false, err
675651 }
676652 defer os.Remove(f.Name())
677- if err := screenshotPNG.Encode(f, img); err != nil {
653+ if err := png.Encode(f, img); err != nil {
678654 _ = f.Close()
679655 return false, err
680656 }
681657 if err := f.Close(); err != nil {
682658 return false, err
683659 }
684- if gated(security, state) {
660+ if security != nil && security.Snapshot() != state {
685661 return false, nil
686662 }
687663 if err := os.Rename(f.Name(), path); err != nil {
@@ -690,31 +666,6 @@ func writePNGSecureResult(path string, img image.Image, security ports.SessionSe
690666 return true, nil
691667 }
692668
693-// linkPNGSecure publishes the PNG already written at src as dst without
694-// encoding it again: a hard link under a temporary name, renamed over dst, so
695-// readers see the old or the new frame, never a partial one. Frame files are
696-// never modified after they are published, which makes the shared file safe.
697-// It gates on security like writePNGSecureResult, and falls back to encoding
698-// img where the file system cannot link.
699-func linkPNGSecure(src, dst string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) {
700- if gated(security, state) {
701- return false, nil
702- }
703- tmp := dst + ".tmp"
704- _ = os.Remove(tmp)
705- if err := os.Link(src, tmp); err != nil {
706- return writePNGSecureResult(dst, img, security, state)
707- }
708- defer os.Remove(tmp)
709- if gated(security, state) {
710- return false, nil
711- }
712- if err := os.Rename(tmp, dst); err != nil {
713- return false, err
714- }
715- return true, nil
716-}
717-
718669 // report queues what the output read, with flip for a drawn frame, and
719670 // pushes it to the sink.
720671 func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
@@ -8,7 +8,6 @@ import (
8 "image/png"8 "image/png"
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11- "sync"
12 "time"11 "time"
13 12
14 "golang.org/x/sys/unix"13 "golang.org/x/sys/unix"
@@ -584,8 +583,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
584 if !written {583 if !written {
585 continue584 continue
586 }585 }
587- // latest.png is the frame just written, linked rather than encoded again.586+ written, err = writePNGSecureResult(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security)
588- written, err = linkPNGSecure(filepath.Join(opts.ScreenshotDir, name), filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security)
589 if err != nil {587 if err != nil {
590 return fmt.Errorf("latest screenshot: %w", err)588 return fmt.Errorf("latest screenshot: %w", err)
591 }589 }
@@ -642,31 +640,9 @@ func writePNGSecure(path string, img image.Image, security ports.SessionSecurity
642 return err640 return err
643 }641 }
644 642
645-// screenshotPNG encodes the PNGs written by the headless output (frames, the
646-// latest.png fallback, raw HDR latest-pq.png): encoding is by far the largest
647-// cost of a headless frame, so it trades file size for speed. Its buffers are
648-// reused between files.
649-var screenshotPNG = png.Encoder{CompressionLevel: png.BestSpeed, BufferPool: new(pngBuffers)}
650-
651-// pngBuffers is a png.EncoderBufferPool.
652-type pngBuffers struct{ pool sync.Pool }
653-
654-func (p *pngBuffers) Get() *png.EncoderBuffer {
655- b, _ := p.pool.Get().(*png.EncoderBuffer)
656- return b
657-}
658-
659-func (p *pngBuffers) Put(b *png.EncoderBuffer) { p.pool.Put(b) }
660-
661-// gated reports whether a screenshot must not be published: the session is
662-// protected, or its security state is no longer the one the frame was drawn in.
663-func gated(security ports.SessionSecurity, state ports.SecurityState) bool {
664- return security != nil && (state.Protected || security.Snapshot() != state)
665-}
666-
667 // writePNGSecureResult distinguishes a security skip from a published PNG.643 // writePNGSecureResult distinguishes a security skip from a published PNG.
668 func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) {644 func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) {
669- if gated(security, state) {645+ if security != nil && (state.Protected || security.Snapshot() != state) {
670 return false, nil646 return false, nil
671 }647 }
672 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")648 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
@@ -674,14 +650,14 @@ func writePNGSecureResult(path string, img image.Image, security ports.SessionSe
674 return false, err650 return false, err
675 }651 }
676 defer os.Remove(f.Name())652 defer os.Remove(f.Name())
677- if err := screenshotPNG.Encode(f, img); err != nil {653+ if err := png.Encode(f, img); err != nil {
678 _ = f.Close()654 _ = f.Close()
679 return false, err655 return false, err
680 }656 }
681 if err := f.Close(); err != nil {657 if err := f.Close(); err != nil {
682 return false, err658 return false, err
683 }659 }
684- if gated(security, state) {660+ if security != nil && security.Snapshot() != state {
685 return false, nil661 return false, nil
686 }662 }
687 if err := os.Rename(f.Name(), path); err != nil {663 if err := os.Rename(f.Name(), path); err != nil {
@@ -690,31 +666,6 @@ func writePNGSecureResult(path string, img image.Image, security ports.SessionSe
690 return true, nil666 return true, nil
691 }667 }
692 668
693-// linkPNGSecure publishes the PNG already written at src as dst without
694-// encoding it again: a hard link under a temporary name, renamed over dst, so
695-// readers see the old or the new frame, never a partial one. Frame files are
696-// never modified after they are published, which makes the shared file safe.
697-// It gates on security like writePNGSecureResult, and falls back to encoding
698-// img where the file system cannot link.
699-func linkPNGSecure(src, dst string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) {
700- if gated(security, state) {
701- return false, nil
702- }
703- tmp := dst + ".tmp"
704- _ = os.Remove(tmp)
705- if err := os.Link(src, tmp); err != nil {
706- return writePNGSecureResult(dst, img, security, state)
707- }
708- defer os.Remove(tmp)
709- if gated(security, state) {
710- return false, nil
711- }
712- if err := os.Rename(tmp, dst); err != nil {
713- return false, err
714- }
715- return true, nil
716-}
717-
718 // report queues what the output read, with flip for a drawn frame, and669 // report queues what the output read, with flip for a drawn frame, and
719 // pushes it to the sink.670 // pushes it to the sink.
720 func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {671 func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
M internal/adapters/headless/output_test.go +0 -171
@@ -14,7 +14,6 @@ import (
1414 "strconv"
1515 "strings"
1616 "sync"
17- "syscall"
1817 "testing"
1918 "time"
2019
@@ -270,176 +269,6 @@ func TestRunTrimsRendererOnTick(t *testing.T) {
270269 }
271270 }
272271
273-// latest.png is the frame file linked, not encoded again: the same pixels,
274-// published atomically, and nothing left behind.
275-func TestLinkPNGSecure(t *testing.T) {
276- dir := t.TempDir()
277- img := image.NewRGBA(image.Rect(0, 0, 3, 2))
278- for i := range img.Pix {
279- img.Pix[i] = byte(i * 7)
280- }
281- frame, latest := filepath.Join(dir, "frame-000001.png"), filepath.Join(dir, "latest.png")
282- if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
283- t.Fatal(err)
284- }
285- // An older latest.png is replaced.
286- if err := os.WriteFile(latest, []byte("old"), 0o600); err != nil {
287- t.Fatal(err)
288- }
289- if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err != nil || !written {
290- t.Fatalf("link = %v, %v", written, err)
291- }
292- a, err := os.ReadFile(frame)
293- if err != nil {
294- t.Fatal(err)
295- }
296- b, err := os.ReadFile(latest)
297- if err != nil {
298- t.Fatal(err)
299- }
300- if !bytes.Equal(a, b) {
301- t.Fatal("latest.png differs from the frame")
302- }
303- fi, err := os.Stat(frame)
304- if err != nil {
305- t.Fatal(err)
306- }
307- li, err := os.Stat(latest)
308- if err != nil {
309- t.Fatal(err)
310- }
311- if !os.SameFile(fi, li) {
312- t.Fatal("latest.png is a copy, not a hard link to the frame")
313- }
314- got, err := png.Decode(bytes.NewReader(b))
315- if err != nil {
316- t.Fatal(err)
317- }
318- for y := range 2 {
319- for x := range 3 {
320- if g, w := color.RGBAModel.Convert(got.At(x, y)), img.RGBAAt(x, y); g != color.Color(w) {
321- t.Errorf("(%d,%d) = %v, want %v", x, y, g, w)
322- }
323- }
324- }
325- entries, err := os.ReadDir(dir)
326- if err != nil {
327- t.Fatal(err)
328- }
329- if len(entries) != 2 {
330- t.Fatalf("leftover files: %v", entries)
331- }
332-}
333-
334-// A frame that crossed a security change is not published as latest.png, a
335-// failed rename leaves no temporary file, and a source that cannot be linked
336-// is encoded instead.
337-func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) {
338- img := image.NewRGBA(image.Rect(0, 0, 1, 1))
339- good := ports.SecurityState{Generation: 1}
340- // frameIn writes the frame to link and returns its directory and path.
341- frameIn := func(t *testing.T) (string, string) {
342- t.Helper()
343- dir := t.TempDir()
344- frame := filepath.Join(dir, "frame-000001.png")
345- if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
346- t.Fatal(err)
347- }
348- return dir, frame
349- }
350- // onlyFrame fails unless the frame is all the directory holds: no
351- // latest.png and no latest.png.tmp.
352- onlyFrame := func(t *testing.T, dir string) {
353- t.Helper()
354- if entries, _ := os.ReadDir(dir); len(entries) != 1 {
355- t.Fatalf("latest.png or a temp file delivered: %v", entries)
356- }
357- }
358- t.Run("protected state", func(t *testing.T) {
359- dir, frame := frameIn(t)
360- // A protected state is refused without reading the live snapshot.
361- gate := portsmocks.NewMockSessionSecurity(t)
362- if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, ports.SecurityState{Generation: 1, Protected: true}); err != nil || written {
363- t.Fatalf("link = %v, %v", written, err)
364- }
365- onlyFrame(t, dir)
366- })
367- t.Run("protected snapshot", func(t *testing.T) {
368- dir, frame := frameIn(t)
369- gate := portsmocks.NewMockSessionSecurity(t)
370- gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
371- if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
372- t.Fatalf("link = %v, %v", written, err)
373- }
374- onlyFrame(t, dir)
375- })
376- t.Run("changed after the link", func(t *testing.T) {
377- dir, frame := frameIn(t)
378- gate := portsmocks.NewMockSessionSecurity(t)
379- gate.EXPECT().Snapshot().Return(good).Once()
380- gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 2}).Once()
381- if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
382- t.Fatalf("link = %v, %v", written, err)
383- }
384- onlyFrame(t, dir)
385- })
386- t.Run("rename failure", func(t *testing.T) {
387- dir, frame := frameIn(t)
388- // A non-empty directory cannot be replaced by a file.
389- latest := filepath.Join(dir, "latest.png")
390- if err := os.MkdirAll(filepath.Join(latest, "keep"), 0o700); err != nil {
391- t.Fatal(err)
392- }
393- if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err == nil || written {
394- t.Fatalf("link = %v, %v; want an error", written, err)
395- }
396- if _, err := os.Stat(latest + ".tmp"); !os.IsNotExist(err) {
397- t.Fatalf("temporary link left behind: %v", err)
398- }
399- if _, err := os.Stat(filepath.Join(latest, "keep")); err != nil {
400- t.Fatalf("existing directory touched: %v", err)
401- }
402- })
403- t.Run("no source file", func(t *testing.T) {
404- dir := t.TempDir()
405- latest := filepath.Join(dir, "latest.png")
406- if written, err := linkPNGSecure(filepath.Join(dir, "missing.png"), latest, img, nil, ports.SecurityState{}); err != nil || !written {
407- t.Fatalf("link = %v, %v", written, err)
408- }
409- fi, err := os.Stat(latest)
410- if err != nil {
411- t.Fatal(err)
412- }
413- if !fi.Mode().IsRegular() {
414- t.Fatalf("latest.png is %v", fi.Mode())
415- }
416- f, err := os.Open(latest)
417- if err != nil {
418- t.Fatal(err)
419- }
420- defer f.Close()
421- if _, err := png.Decode(f); err != nil {
422- t.Fatal(err)
423- }
424- // Encoded on its own: nothing else in the directory shares it.
425- if entries, _ := os.ReadDir(dir); len(entries) != 1 {
426- t.Fatalf("unexpected files: %v", entries)
427- }
428- if n, ok := linkCount(fi); ok && n != 1 {
429- t.Fatalf("latest.png has %d links, want 1", n)
430- }
431- })
432-}
433-
434-// linkCount returns the hard link count of fi where the platform reports it.
435-func linkCount(fi os.FileInfo) (uint64, bool) {
436- st, ok := fi.Sys().(*syscall.Stat_t)
437- if !ok {
438- return 0, false
439- }
440- return uint64(st.Nlink), true
441-}
442-
443272 func TestWritePNGAtomic(t *testing.T) {
444273 dir := t.TempDir()
445274 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
@@ -14,7 +14,6 @@ import (
14 "strconv"14 "strconv"
15 "strings"15 "strings"
16 "sync"16 "sync"
17- "syscall"
18 "testing"17 "testing"
19 "time"18 "time"
20 19
@@ -270,176 +269,6 @@ func TestRunTrimsRendererOnTick(t *testing.T) {
270 }269 }
271 }270 }
272 271
273-// latest.png is the frame file linked, not encoded again: the same pixels,
274-// published atomically, and nothing left behind.
275-func TestLinkPNGSecure(t *testing.T) {
276- dir := t.TempDir()
277- img := image.NewRGBA(image.Rect(0, 0, 3, 2))
278- for i := range img.Pix {
279- img.Pix[i] = byte(i * 7)
280- }
281- frame, latest := filepath.Join(dir, "frame-000001.png"), filepath.Join(dir, "latest.png")
282- if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
283- t.Fatal(err)
284- }
285- // An older latest.png is replaced.
286- if err := os.WriteFile(latest, []byte("old"), 0o600); err != nil {
287- t.Fatal(err)
288- }
289- if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err != nil || !written {
290- t.Fatalf("link = %v, %v", written, err)
291- }
292- a, err := os.ReadFile(frame)
293- if err != nil {
294- t.Fatal(err)
295- }
296- b, err := os.ReadFile(latest)
297- if err != nil {
298- t.Fatal(err)
299- }
300- if !bytes.Equal(a, b) {
301- t.Fatal("latest.png differs from the frame")
302- }
303- fi, err := os.Stat(frame)
304- if err != nil {
305- t.Fatal(err)
306- }
307- li, err := os.Stat(latest)
308- if err != nil {
309- t.Fatal(err)
310- }
311- if !os.SameFile(fi, li) {
312- t.Fatal("latest.png is a copy, not a hard link to the frame")
313- }
314- got, err := png.Decode(bytes.NewReader(b))
315- if err != nil {
316- t.Fatal(err)
317- }
318- for y := range 2 {
319- for x := range 3 {
320- if g, w := color.RGBAModel.Convert(got.At(x, y)), img.RGBAAt(x, y); g != color.Color(w) {
321- t.Errorf("(%d,%d) = %v, want %v", x, y, g, w)
322- }
323- }
324- }
325- entries, err := os.ReadDir(dir)
326- if err != nil {
327- t.Fatal(err)
328- }
329- if len(entries) != 2 {
330- t.Fatalf("leftover files: %v", entries)
331- }
332-}
333-
334-// A frame that crossed a security change is not published as latest.png, a
335-// failed rename leaves no temporary file, and a source that cannot be linked
336-// is encoded instead.
337-func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) {
338- img := image.NewRGBA(image.Rect(0, 0, 1, 1))
339- good := ports.SecurityState{Generation: 1}
340- // frameIn writes the frame to link and returns its directory and path.
341- frameIn := func(t *testing.T) (string, string) {
342- t.Helper()
343- dir := t.TempDir()
344- frame := filepath.Join(dir, "frame-000001.png")
345- if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
346- t.Fatal(err)
347- }
348- return dir, frame
349- }
350- // onlyFrame fails unless the frame is all the directory holds: no
351- // latest.png and no latest.png.tmp.
352- onlyFrame := func(t *testing.T, dir string) {
353- t.Helper()
354- if entries, _ := os.ReadDir(dir); len(entries) != 1 {
355- t.Fatalf("latest.png or a temp file delivered: %v", entries)
356- }
357- }
358- t.Run("protected state", func(t *testing.T) {
359- dir, frame := frameIn(t)
360- // A protected state is refused without reading the live snapshot.
361- gate := portsmocks.NewMockSessionSecurity(t)
362- if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, ports.SecurityState{Generation: 1, Protected: true}); err != nil || written {
363- t.Fatalf("link = %v, %v", written, err)
364- }
365- onlyFrame(t, dir)
366- })
367- t.Run("protected snapshot", func(t *testing.T) {
368- dir, frame := frameIn(t)
369- gate := portsmocks.NewMockSessionSecurity(t)
370- gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
371- if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
372- t.Fatalf("link = %v, %v", written, err)
373- }
374- onlyFrame(t, dir)
375- })
376- t.Run("changed after the link", func(t *testing.T) {
377- dir, frame := frameIn(t)
378- gate := portsmocks.NewMockSessionSecurity(t)
379- gate.EXPECT().Snapshot().Return(good).Once()
380- gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 2}).Once()
381- if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
382- t.Fatalf("link = %v, %v", written, err)
383- }
384- onlyFrame(t, dir)
385- })
386- t.Run("rename failure", func(t *testing.T) {
387- dir, frame := frameIn(t)
388- // A non-empty directory cannot be replaced by a file.
389- latest := filepath.Join(dir, "latest.png")
390- if err := os.MkdirAll(filepath.Join(latest, "keep"), 0o700); err != nil {
391- t.Fatal(err)
392- }
393- if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err == nil || written {
394- t.Fatalf("link = %v, %v; want an error", written, err)
395- }
396- if _, err := os.Stat(latest + ".tmp"); !os.IsNotExist(err) {
397- t.Fatalf("temporary link left behind: %v", err)
398- }
399- if _, err := os.Stat(filepath.Join(latest, "keep")); err != nil {
400- t.Fatalf("existing directory touched: %v", err)
401- }
402- })
403- t.Run("no source file", func(t *testing.T) {
404- dir := t.TempDir()
405- latest := filepath.Join(dir, "latest.png")
406- if written, err := linkPNGSecure(filepath.Join(dir, "missing.png"), latest, img, nil, ports.SecurityState{}); err != nil || !written {
407- t.Fatalf("link = %v, %v", written, err)
408- }
409- fi, err := os.Stat(latest)
410- if err != nil {
411- t.Fatal(err)
412- }
413- if !fi.Mode().IsRegular() {
414- t.Fatalf("latest.png is %v", fi.Mode())
415- }
416- f, err := os.Open(latest)
417- if err != nil {
418- t.Fatal(err)
419- }
420- defer f.Close()
421- if _, err := png.Decode(f); err != nil {
422- t.Fatal(err)
423- }
424- // Encoded on its own: nothing else in the directory shares it.
425- if entries, _ := os.ReadDir(dir); len(entries) != 1 {
426- t.Fatalf("unexpected files: %v", entries)
427- }
428- if n, ok := linkCount(fi); ok && n != 1 {
429- t.Fatalf("latest.png has %d links, want 1", n)
430- }
431- })
432-}
433-
434-// linkCount returns the hard link count of fi where the platform reports it.
435-func linkCount(fi os.FileInfo) (uint64, bool) {
436- st, ok := fi.Sys().(*syscall.Stat_t)
437- if !ok {
438- return 0, false
439- }
440- return uint64(st.Nlink), true
441-}
442-
443 func TestWritePNGAtomic(t *testing.T) {272 func TestWritePNGAtomic(t *testing.T) {
444 dir := t.TempDir()273 dir := t.TempDir()
445 img := image.NewRGBA(image.Rect(0, 0, 1, 1))274 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
M internal/adapters/vulkan/renderer.go +3 -0
@@ -34,6 +34,9 @@ type Renderer struct {
3434 current int
3535 renderMods []uint64
3636 hdrMods []uint64
37+ // importMods caches the modifiers a target of a format can be imported
38+ // with (probeImportModifiers), by what the answer depends on.
39+ importMods map[importKey][]uint64
3740 hdrNits float64
3841 hdr hdrPass
3942 hdrError error
@@ -34,6 +34,9 @@ type Renderer struct {
34 current int34 current int
35 renderMods []uint6435 renderMods []uint64
36 hdrMods []uint6436 hdrMods []uint64
37+ // importMods caches the modifiers a target of a format can be imported
38+ // with (probeImportModifiers), by what the answer depends on.
39+ importMods map[importKey][]uint64
37 hdrNits float6440 hdrNits float64
38 hdr hdrPass41 hdr hdrPass
39 hdrError error42 hdrError error
M internal/adapters/vulkan/targets.go +208 -8
@@ -10,6 +10,7 @@ import (
1010
1111 "github.com/bnema/neferwl/internal/ports"
1212 vk "github.com/bnema/purego-vulkan/vulkan"
13+ "golang.org/x/sys/unix"
1314 )
1415
1516 // Render targets (ADR 014). A DRM output scans out images the renderer
@@ -99,6 +100,35 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64, singlePlane bool) ([
99100 return out, nil
100101 }
101102
103+// ImportTargets makes the images the display allocated (GBM dmabufs, one
104+// memory object for all planes) the render targets, replacing the previous
105+// ones; no buffers only drops them. The caller keeps its files: the
106+// renderer imports duplicates. Each buffer must have the renderer's size and
107+// target format and a modifier from TargetModifiers. A refusal (the driver
108+// rejecting the explicit layout) returns an error and leaves no targets.
109+func (r *Renderer) ImportTargets(bufs []ports.DMABuf) error {
110+ r.dropTargets()
111+ if len(bufs) == 0 {
112+ return nil
113+ }
114+ if r.hdrNits > 0 && r.hdr.pipeline == 0 {
115+ return fmt.Errorf("HDR transform unavailable: %w", r.hdrError)
116+ }
117+ if !r.dd.HasGetMemoryFdPropertiesKHR() || !r.dd.HasGetImageDrmFormatModifierPropertiesEXT() || len(r.dmabuf.Formats) == 0 {
118+ return errors.New("device cannot import dmabufs")
119+ }
120+ for i := range bufs {
121+ t, err := r.importTarget(&bufs[i])
122+ if err != nil {
123+ r.dropTargets()
124+ return err
125+ }
126+ r.targets = append(r.targets, t)
127+ }
128+ r.current = 0
129+ return nil
130+}
131+
102132 // SetVirtualOutput marks the output as having no display (headless): an
103133 // empty display modifier list then accepts every exportable HDR modifier,
104134 // as it does for SDR. DRM never sets it. Call before ExportTargets.
@@ -124,11 +154,19 @@ func (r *Renderer) target() *target {
124154 // except for a virtual output (no display) or a test readback. singlePlane
125155 // drops the modifiers with more than one memory plane.
126156 func (r *Renderer) exportModifiers(display []uint64, singlePlane bool) []uint64 {
127- var out []uint64
128157 available := r.renderMods
129158 if r.hdrNits > 0 {
130159 available = r.hdrMods
131160 }
161+ return r.intersectModifiers(available, display, singlePlane)
162+}
163+
164+// intersectModifiers keeps the available modifiers the display lists (an
165+// empty display list accepts all of them, for SDR and for HDR only on a
166+// virtual output or a test readback), without the multi-plane ones when
167+// singlePlane.
168+func (r *Renderer) intersectModifiers(available, display []uint64, singlePlane bool) []uint64 {
169+ var out []uint64
132170 for _, m := range available {
133171 if singlePlane && r.modPlanes[m] > 1 {
134172 continue
@@ -140,6 +178,27 @@ func (r *Renderer) exportModifiers(display []uint64, singlePlane bool) []uint64
140178 return out
141179 }
142180
181+// targetFormat is the Vulkan format and fourcc of the targets: XRGB8888, or
182+// XRGB2101010 in HDR.
183+func (r *Renderer) targetFormat() (vk.Format, uint32) {
184+ if r.hdrNits > 0 {
185+ return vk.FormatA2r10g10b10UnormPack32, fourccXR30
186+ }
187+ return vk.FormatB8g8r8a8Unorm, fourccXRGB
188+}
189+
190+// TargetModifiers lists the modifiers an image allocated by the display
191+// (GBM) can have to be imported as a target: those the device renders into
192+// and imports for the target format, that the display lists (empty: any),
193+// without multi-plane ones when singlePlane. Call after SetHDR.
194+func (r *Renderer) TargetModifiers(display []uint64, singlePlane bool) []uint64 {
195+ if r.physical == 0 || r.dd == nil || !r.dd.HasGetMemoryFdPropertiesKHR() || !r.dd.HasGetImageDrmFormatModifierPropertiesEXT() {
196+ return nil
197+ }
198+ format, _ := r.targetFormat()
199+ return r.intersectModifiers(r.probeImportModifiers(r.physical, format), display, singlePlane)
200+}
201+
143202 // probeRenderModifiers lists exportable SDR and HDR targets.
144203 func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {
145204 r.renderMods = r.probeModifiers(physical, vk.FormatB8g8r8a8Unorm)
@@ -152,6 +211,34 @@ func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {
152211 // records their plane counts in modPlanes. HDR targets need transfer-src only when
153212 // hdrReadback is set (GPU tests, headless --screenshot-raw; never DRM).
154213 func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 {
214+ return r.probeTargetModifiers(physical, format, vk.ExternalMemoryFeatureExportableBit)
215+}
216+
217+// importKey is what the importable modifiers of a target depend on: its
218+// format and whether test readback adds the transfer-source requirement.
219+type importKey struct {
220+ format vk.Format
221+ readback bool
222+}
223+
224+// probeImportModifiers lists the modifiers of format a target can be
225+// imported with: probeModifiers with the importable bit, for images the
226+// display allocator made. The answer is probed once per key: it does not
227+// change while the device lives.
228+func (r *Renderer) probeImportModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 {
229+ key := importKey{format, r.hdrReadback}
230+ if mods, ok := r.importMods[key]; ok {
231+ return mods
232+ }
233+ mods := r.probeTargetModifiers(physical, format, vk.ExternalMemoryFeatureImportableBit)
234+ if r.importMods == nil {
235+ r.importMods = make(map[importKey][]uint64)
236+ }
237+ r.importMods[key] = mods
238+ return mods
239+}
240+
241+func (r *Renderer) probeTargetModifiers(physical vk.PhysicalDevice, format vk.Format, feature vk.ExternalMemoryFeatureFlags) []uint64 {
155242 list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}
156243 fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)}
157244 r.id.GetPhysicalDeviceFormatProperties2(physical, format, &fp)
@@ -169,7 +256,7 @@ func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format)
169256 }
170257 // SDR targets are composed and read back; HDR targets are color
171258 // attachments, and transfer sources only with hdrReadback.
172- result, planes := filterModifiers(mods[:list.DrmFormatModifierCount], need, func(m uint64) bool { return r.exportable(physical, format, m) })
259+ result, planes := filterModifiers(mods[:list.DrmFormatModifierCount], need, func(m uint64) bool { return r.externalTarget(physical, format, m, feature) })
173260 if r.modPlanes == nil {
174261 r.modPlanes = make(map[uint64]uint32)
175262 }
@@ -216,7 +303,10 @@ func filterModifiers(mods []vk.DrmFormatModifierPropertiesEXT, need vk.FormatFea
216303 return result, planes
217304 }
218305
219-func (r *Renderer) exportable(physical vk.PhysicalDevice, format vk.Format, modifier uint64) bool {
306+// externalTarget asks whether a target image of format and modifier with
307+// the target usage supports the external memory feature (exportable: the
308+// renderer allocates it; importable: the display allocator does).
309+func (r *Renderer) externalTarget(physical vk.PhysicalDevice, format vk.Format, modifier uint64, feature vk.ExternalMemoryFeatureFlags) bool {
220310 mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive}
221311 ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
222312 info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Format: format, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: exportUsage(format, r.hdrReadback)}
@@ -225,7 +315,7 @@ func (r *Renderer) exportable(physical vk.PhysicalDevice, format vk.Format, modi
225315 if r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) != vk.Success {
226316 return false
227317 }
228- return extOut.ExternalMemoryProperties.ExternalMemoryFeatures&vk.ExternalMemoryFeatureExportableBit != 0
318+ return extOut.ExternalMemoryProperties.ExternalMemoryFeatures&feature != 0
229319 }
230320
231321 // VK_FORMAT_FEATURE_TRANSFER_SRC_BIT (Vulkan 1.1), missing from the
@@ -248,10 +338,7 @@ func exportUsage(format vk.Format, readback bool) vk.ImageUsageFlags {
248338 func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {
249339 d := r.dd
250340 t := &target{exported: true, layout: vk.ImageLayoutUndefined}
251- format, fourcc := vk.Format(vk.FormatB8g8r8a8Unorm), uint32(fourccXRGB)
252- if r.hdrNits > 0 {
253- format, fourcc = vk.FormatA2r10g10b10UnormPack32, fourccXR30
254- }
341+ format, fourcc := r.targetFormat()
255342 ok := false
256343 defer func() {
257344 if !ok {
@@ -354,3 +441,116 @@ func (r *Renderer) dropTargets() {
354441 // New targets hold no HDR frame until the next Render.
355442 r.targets, r.current, r.lastHDRTarget = nil, 0, -1
356443 }
444+
445+// What ImportTargets refuses an image for, before asking the driver.
446+var (
447+ errImageGeometry = errors.New("image size or format does not match the target")
448+ errImageModifier = errors.New("image modifier is not an importable target modifier")
449+ errImagePlanes = errors.New("image plane count does not match its modifier")
450+ errImageDMABuf = errors.New("image planes are not in one dmabuf")
451+)
452+
453+// importTarget makes a target of an image allocated outside the renderer.
454+// The target is shared with the display like an exported one: frames
455+// acquire it from and release it to the foreign queue family.
456+func (r *Renderer) importTarget(b *ports.DMABuf) (*target, error) {
457+ d := r.dd
458+ format, fourcc := r.targetFormat()
459+ n := len(b.Planes)
460+ want, known := r.modPlanes[b.Modifier]
461+ switch {
462+ case b.Format != fourcc || b.Width != r.width || b.Height != r.height:
463+ return nil, fmt.Errorf("%w: image %dx%d format %#x, target %dx%d format %#x", errImageGeometry, b.Width, b.Height, b.Format, r.width, r.height, fourcc)
464+ case !known:
465+ return nil, fmt.Errorf("%w: %#x is not a target modifier of the device", errImageModifier, b.Modifier)
466+ case n < 1 || n > maxModifierPlanes || want != uint32(n):
467+ return nil, fmt.Errorf("%w: modifier %#x: image has %d planes, device expects %d", errImagePlanes, b.Modifier, n, want)
468+ case !r.externalTarget(r.physical, format, b.Modifier, vk.ExternalMemoryFeatureImportableBit):
469+ return nil, fmt.Errorf("%w: %#x cannot be imported as a target", errImageModifier, b.Modifier)
470+ }
471+ if err := sameDMABuf(b.Planes); err != nil {
472+ return nil, err
473+ }
474+ fd, err := dupFile(b.Planes[0].File)
475+ if err != nil {
476+ return nil, err
477+ }
478+ // Vulkan owns the fd it imports on success: hand it its own duplicate.
479+ // Closed here on every path but the successful import.
480+ defer func() {
481+ if fd >= 0 {
482+ _ = unix.Close(fd)
483+ }
484+ }()
485+ t := &target{exported: true, layout: vk.ImageLayoutUndefined}
486+ ok := false
487+ defer func() {
488+ if !ok {
489+ r.freeTarget(t)
490+ }
491+ }()
492+ var layouts [maxModifierPlanes]vk.SubresourceLayout
493+ for i, p := range b.Planes {
494+ layouts[i] = vk.SubresourceLayout{Offset: vk.DeviceSize(p.Offset), RowPitch: vk.DeviceSize(p.Stride)}
495+ }
496+ explicit := vk.ImageDrmFormatModifierExplicitCreateInfoEXT{SType: vk.StructureTypeImageDRMFormatModifierExplicitCreateInfoEXT, DrmFormatModifier: b.Modifier, DrmFormatModifierPlaneCount: uint32(n), PlaneLayouts: &layouts[0]}
497+ external := vk.ExternalMemoryImageCreateInfo{SType: vk.StructureTypeExternalMemoryImageCreateInfo, Next: unsafe.Pointer(&explicit), HandleTypes: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
498+ 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}
499+ if err := checked("vkCreateImage(imported target)", d.CreateImage(r.device, &ii, nil, &t.image)); err != nil {
500+ return nil, err
501+ }
502+ fdProps := vk.MemoryFdPropertiesKHR{SType: vk.StructureTypeMemoryFDPropertiesKHR}
503+ if err := checked("vkGetMemoryFdPropertiesKHR", d.GetMemoryFdPropertiesKHR(r.device, vk.ExternalMemoryHandleTypeDMABUFBitEXT, int32(fd), &fdProps)); err != nil {
504+ return nil, err
505+ }
506+ var req vk.MemoryRequirements
507+ d.GetImageMemoryRequirements(r.device, t.image, &req)
508+ bits := req.MemoryTypeBits & fdProps.MemoryTypeBits
509+ kind, err := r.findMemoryType(bits, vk.MemoryPropertyDeviceLocalBit)
510+ if err != nil {
511+ if kind, err = r.findMemoryType(bits, 0); err != nil {
512+ return nil, err
513+ }
514+ }
515+ dedicated := vk.MemoryDedicatedAllocateInfo{SType: vk.StructureTypeMemoryDedicatedAllocateInfo, Image: t.image}
516+ imp := vk.ImportMemoryFdInfoKHR{SType: vk.StructureTypeImportMemoryFDInfoKHR, Next: unsafe.Pointer(&dedicated), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT, Fd: int32(fd)}
517+ alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, Next: unsafe.Pointer(&imp), AllocationSize: req.Size, MemoryTypeIndex: kind}
518+ if err := checked("vkAllocateMemory(imported target)", d.AllocateMemory(r.device, &alloc, nil, &t.memory)); err != nil {
519+ return nil, err
520+ }
521+ fd = -1 // Vulkan owns it now.
522+ if err := checked("vkBindImageMemory(imported target)", d.BindImageMemory(r.device, t.image, t.memory, 0)); err != nil {
523+ return nil, err
524+ }
525+ if t.view, err = r.imageView(t.image, format); err != nil {
526+ return nil, err
527+ }
528+ r.log.Debug().Uint64("modifier", b.Modifier).Int("planes", n).Msg("render target imported")
529+ ok = true
530+ return t, nil
531+}
532+
533+// sameDMABuf fails unless every plane file is the same dmabuf (same device
534+// and inode): the target is imported from the first plane's memory only, so
535+// planes in other buffers would read the wrong memory.
536+func sameDMABuf(planes []ports.DMABufPlane) error {
537+ var first unix.Stat_t
538+ for i, p := range planes {
539+ fd, err := dupFile(p.File)
540+ if err != nil {
541+ return fmt.Errorf("plane %d: %w", i, err)
542+ }
543+ var st unix.Stat_t
544+ err = unix.Fstat(fd, &st)
545+ _ = unix.Close(fd)
546+ if err != nil {
547+ return fmt.Errorf("stat plane %d: %w", i, err)
548+ }
549+ if i == 0 {
550+ first = st
551+ } else if st.Dev != first.Dev || st.Ino != first.Ino {
552+ return fmt.Errorf("%w: plane %d is not in the dmabuf of plane 0", errImageDMABuf, i)
553+ }
554+ }
555+ return nil
556+}
@@ -10,6 +10,7 @@ import (
10 10
11 "github.com/bnema/neferwl/internal/ports"11 "github.com/bnema/neferwl/internal/ports"
12 vk "github.com/bnema/purego-vulkan/vulkan"12 vk "github.com/bnema/purego-vulkan/vulkan"
13+ "golang.org/x/sys/unix"
13 )14 )
14 15
15 // Render targets (ADR 014). A DRM output scans out images the renderer16 // Render targets (ADR 014). A DRM output scans out images the renderer
@@ -99,6 +100,35 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64, singlePlane bool) ([
99 return out, nil100 return out, nil
100 }101 }
101 102
103+// ImportTargets makes the images the display allocated (GBM dmabufs, one
104+// memory object for all planes) the render targets, replacing the previous
105+// ones; no buffers only drops them. The caller keeps its files: the
106+// renderer imports duplicates. Each buffer must have the renderer's size and
107+// target format and a modifier from TargetModifiers. A refusal (the driver
108+// rejecting the explicit layout) returns an error and leaves no targets.
109+func (r *Renderer) ImportTargets(bufs []ports.DMABuf) error {
110+ r.dropTargets()
111+ if len(bufs) == 0 {
112+ return nil
113+ }
114+ if r.hdrNits > 0 && r.hdr.pipeline == 0 {
115+ return fmt.Errorf("HDR transform unavailable: %w", r.hdrError)
116+ }
117+ if !r.dd.HasGetMemoryFdPropertiesKHR() || !r.dd.HasGetImageDrmFormatModifierPropertiesEXT() || len(r.dmabuf.Formats) == 0 {
118+ return errors.New("device cannot import dmabufs")
119+ }
120+ for i := range bufs {
121+ t, err := r.importTarget(&bufs[i])
122+ if err != nil {
123+ r.dropTargets()
124+ return err
125+ }
126+ r.targets = append(r.targets, t)
127+ }
128+ r.current = 0
129+ return nil
130+}
131+
102 // SetVirtualOutput marks the output as having no display (headless): an132 // SetVirtualOutput marks the output as having no display (headless): an
103 // empty display modifier list then accepts every exportable HDR modifier,133 // empty display modifier list then accepts every exportable HDR modifier,
104 // as it does for SDR. DRM never sets it. Call before ExportTargets.134 // as it does for SDR. DRM never sets it. Call before ExportTargets.
@@ -124,11 +154,19 @@ func (r *Renderer) target() *target {
124 // except for a virtual output (no display) or a test readback. singlePlane154 // except for a virtual output (no display) or a test readback. singlePlane
125 // drops the modifiers with more than one memory plane.155 // drops the modifiers with more than one memory plane.
126 func (r *Renderer) exportModifiers(display []uint64, singlePlane bool) []uint64 {156 func (r *Renderer) exportModifiers(display []uint64, singlePlane bool) []uint64 {
127- var out []uint64
128 available := r.renderMods157 available := r.renderMods
129 if r.hdrNits > 0 {158 if r.hdrNits > 0 {
130 available = r.hdrMods159 available = r.hdrMods
131 }160 }
161+ return r.intersectModifiers(available, display, singlePlane)
162+}
163+
164+// intersectModifiers keeps the available modifiers the display lists (an
165+// empty display list accepts all of them, for SDR and for HDR only on a
166+// virtual output or a test readback), without the multi-plane ones when
167+// singlePlane.
168+func (r *Renderer) intersectModifiers(available, display []uint64, singlePlane bool) []uint64 {
169+ var out []uint64
132 for _, m := range available {170 for _, m := range available {
133 if singlePlane && r.modPlanes[m] > 1 {171 if singlePlane && r.modPlanes[m] > 1 {
134 continue172 continue
@@ -140,6 +178,27 @@ func (r *Renderer) exportModifiers(display []uint64, singlePlane bool) []uint64
140 return out178 return out
141 }179 }
142 180
181+// targetFormat is the Vulkan format and fourcc of the targets: XRGB8888, or
182+// XRGB2101010 in HDR.
183+func (r *Renderer) targetFormat() (vk.Format, uint32) {
184+ if r.hdrNits > 0 {
185+ return vk.FormatA2r10g10b10UnormPack32, fourccXR30
186+ }
187+ return vk.FormatB8g8r8a8Unorm, fourccXRGB
188+}
189+
190+// TargetModifiers lists the modifiers an image allocated by the display
191+// (GBM) can have to be imported as a target: those the device renders into
192+// and imports for the target format, that the display lists (empty: any),
193+// without multi-plane ones when singlePlane. Call after SetHDR.
194+func (r *Renderer) TargetModifiers(display []uint64, singlePlane bool) []uint64 {
195+ if r.physical == 0 || r.dd == nil || !r.dd.HasGetMemoryFdPropertiesKHR() || !r.dd.HasGetImageDrmFormatModifierPropertiesEXT() {
196+ return nil
197+ }
198+ format, _ := r.targetFormat()
199+ return r.intersectModifiers(r.probeImportModifiers(r.physical, format), display, singlePlane)
200+}
201+
143 // probeRenderModifiers lists exportable SDR and HDR targets.202 // probeRenderModifiers lists exportable SDR and HDR targets.
144 func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {203 func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {
145 r.renderMods = r.probeModifiers(physical, vk.FormatB8g8r8a8Unorm)204 r.renderMods = r.probeModifiers(physical, vk.FormatB8g8r8a8Unorm)
@@ -152,6 +211,34 @@ func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {
152 // records their plane counts in modPlanes. HDR targets need transfer-src only when211 // records their plane counts in modPlanes. HDR targets need transfer-src only when
153 // hdrReadback is set (GPU tests, headless --screenshot-raw; never DRM).212 // hdrReadback is set (GPU tests, headless --screenshot-raw; never DRM).
154 func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 {213 func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 {
214+ return r.probeTargetModifiers(physical, format, vk.ExternalMemoryFeatureExportableBit)
215+}
216+
217+// importKey is what the importable modifiers of a target depend on: its
218+// format and whether test readback adds the transfer-source requirement.
219+type importKey struct {
220+ format vk.Format
221+ readback bool
222+}
223+
224+// probeImportModifiers lists the modifiers of format a target can be
225+// imported with: probeModifiers with the importable bit, for images the
226+// display allocator made. The answer is probed once per key: it does not
227+// change while the device lives.
228+func (r *Renderer) probeImportModifiers(physical vk.PhysicalDevice, format vk.Format) []uint64 {
229+ key := importKey{format, r.hdrReadback}
230+ if mods, ok := r.importMods[key]; ok {
231+ return mods
232+ }
233+ mods := r.probeTargetModifiers(physical, format, vk.ExternalMemoryFeatureImportableBit)
234+ if r.importMods == nil {
235+ r.importMods = make(map[importKey][]uint64)
236+ }
237+ r.importMods[key] = mods
238+ return mods
239+}
240+
241+func (r *Renderer) probeTargetModifiers(physical vk.PhysicalDevice, format vk.Format, feature vk.ExternalMemoryFeatureFlags) []uint64 {
155 list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}242 list := vk.DrmFormatModifierPropertiesListEXT{SType: vk.StructureTypeDRMFormatModifierPropertiesListEXT}
156 fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)}243 fp := vk.FormatProperties2{SType: vk.StructureTypeFormatProperties2, Next: unsafe.Pointer(&list)}
157 r.id.GetPhysicalDeviceFormatProperties2(physical, format, &fp)244 r.id.GetPhysicalDeviceFormatProperties2(physical, format, &fp)
@@ -169,7 +256,7 @@ func (r *Renderer) probeModifiers(physical vk.PhysicalDevice, format vk.Format)
169 }256 }
170 // SDR targets are composed and read back; HDR targets are color257 // SDR targets are composed and read back; HDR targets are color
171 // attachments, and transfer sources only with hdrReadback.258 // attachments, and transfer sources only with hdrReadback.
172- result, planes := filterModifiers(mods[:list.DrmFormatModifierCount], need, func(m uint64) bool { return r.exportable(physical, format, m) })259+ result, planes := filterModifiers(mods[:list.DrmFormatModifierCount], need, func(m uint64) bool { return r.externalTarget(physical, format, m, feature) })
173 if r.modPlanes == nil {260 if r.modPlanes == nil {
174 r.modPlanes = make(map[uint64]uint32)261 r.modPlanes = make(map[uint64]uint32)
175 }262 }
@@ -216,7 +303,10 @@ func filterModifiers(mods []vk.DrmFormatModifierPropertiesEXT, need vk.FormatFea
216 return result, planes303 return result, planes
217 }304 }
218 305
219-func (r *Renderer) exportable(physical vk.PhysicalDevice, format vk.Format, modifier uint64) bool {306+// externalTarget asks whether a target image of format and modifier with
307+// the target usage supports the external memory feature (exportable: the
308+// renderer allocates it; importable: the display allocator does).
309+func (r *Renderer) externalTarget(physical vk.PhysicalDevice, format vk.Format, modifier uint64, feature vk.ExternalMemoryFeatureFlags) bool {
220 mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive}310 mod := vk.PhysicalDeviceImageDrmFormatModifierInfoEXT{SType: vk.StructureTypePhysicalDeviceImageDRMFormatModifierInfoEXT, DrmFormatModifier: modifier, SharingMode: vk.SharingModeExclusive}
221 ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT}311 ext := vk.PhysicalDeviceExternalImageFormatInfo{SType: vk.StructureTypePhysicalDeviceExternalImageFormatInfo, Next: unsafe.Pointer(&mod), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
222 info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Format: format, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: exportUsage(format, r.hdrReadback)}312 info := vk.PhysicalDeviceImageFormatInfo2{SType: vk.StructureTypePhysicalDeviceImageFormatInfo2, Next: unsafe.Pointer(&ext), Format: format, Type: vk.ImageType2d, Tiling: vk.ImageTilingDRMFormatModifierEXT, Usage: exportUsage(format, r.hdrReadback)}
@@ -225,7 +315,7 @@ func (r *Renderer) exportable(physical vk.PhysicalDevice, format vk.Format, modi
225 if r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) != vk.Success {315 if r.id.GetPhysicalDeviceImageFormatProperties2(physical, &info, &out) != vk.Success {
226 return false316 return false
227 }317 }
228- return extOut.ExternalMemoryProperties.ExternalMemoryFeatures&vk.ExternalMemoryFeatureExportableBit != 0318+ return extOut.ExternalMemoryProperties.ExternalMemoryFeatures&feature != 0
229 }319 }
230 320
231 // VK_FORMAT_FEATURE_TRANSFER_SRC_BIT (Vulkan 1.1), missing from the321 // VK_FORMAT_FEATURE_TRANSFER_SRC_BIT (Vulkan 1.1), missing from the
@@ -248,10 +338,7 @@ func exportUsage(format vk.Format, readback bool) vk.ImageUsageFlags {
248 func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {338 func (r *Renderer) exportTarget(mods []uint64) (*target, ports.DMABuf, error) {
249 d := r.dd339 d := r.dd
250 t := &target{exported: true, layout: vk.ImageLayoutUndefined}340 t := &target{exported: true, layout: vk.ImageLayoutUndefined}
251- format, fourcc := vk.Format(vk.FormatB8g8r8a8Unorm), uint32(fourccXRGB)341+ format, fourcc := r.targetFormat()
252- if r.hdrNits > 0 {
253- format, fourcc = vk.FormatA2r10g10b10UnormPack32, fourccXR30
254- }
255 ok := false342 ok := false
256 defer func() {343 defer func() {
257 if !ok {344 if !ok {
@@ -354,3 +441,116 @@ func (r *Renderer) dropTargets() {
354 // New targets hold no HDR frame until the next Render.441 // New targets hold no HDR frame until the next Render.
355 r.targets, r.current, r.lastHDRTarget = nil, 0, -1442 r.targets, r.current, r.lastHDRTarget = nil, 0, -1
356 }443 }
444+
445+// What ImportTargets refuses an image for, before asking the driver.
446+var (
447+ errImageGeometry = errors.New("image size or format does not match the target")
448+ errImageModifier = errors.New("image modifier is not an importable target modifier")
449+ errImagePlanes = errors.New("image plane count does not match its modifier")
450+ errImageDMABuf = errors.New("image planes are not in one dmabuf")
451+)
452+
453+// importTarget makes a target of an image allocated outside the renderer.
454+// The target is shared with the display like an exported one: frames
455+// acquire it from and release it to the foreign queue family.
456+func (r *Renderer) importTarget(b *ports.DMABuf) (*target, error) {
457+ d := r.dd
458+ format, fourcc := r.targetFormat()
459+ n := len(b.Planes)
460+ want, known := r.modPlanes[b.Modifier]
461+ switch {
462+ case b.Format != fourcc || b.Width != r.width || b.Height != r.height:
463+ return nil, fmt.Errorf("%w: image %dx%d format %#x, target %dx%d format %#x", errImageGeometry, b.Width, b.Height, b.Format, r.width, r.height, fourcc)
464+ case !known:
465+ return nil, fmt.Errorf("%w: %#x is not a target modifier of the device", errImageModifier, b.Modifier)
466+ case n < 1 || n > maxModifierPlanes || want != uint32(n):
467+ return nil, fmt.Errorf("%w: modifier %#x: image has %d planes, device expects %d", errImagePlanes, b.Modifier, n, want)
468+ case !r.externalTarget(r.physical, format, b.Modifier, vk.ExternalMemoryFeatureImportableBit):
469+ return nil, fmt.Errorf("%w: %#x cannot be imported as a target", errImageModifier, b.Modifier)
470+ }
471+ if err := sameDMABuf(b.Planes); err != nil {
472+ return nil, err
473+ }
474+ fd, err := dupFile(b.Planes[0].File)
475+ if err != nil {
476+ return nil, err
477+ }
478+ // Vulkan owns the fd it imports on success: hand it its own duplicate.
479+ // Closed here on every path but the successful import.
480+ defer func() {
481+ if fd >= 0 {
482+ _ = unix.Close(fd)
483+ }
484+ }()
485+ t := &target{exported: true, layout: vk.ImageLayoutUndefined}
486+ ok := false
487+ defer func() {
488+ if !ok {
489+ r.freeTarget(t)
490+ }
491+ }()
492+ var layouts [maxModifierPlanes]vk.SubresourceLayout
493+ for i, p := range b.Planes {
494+ layouts[i] = vk.SubresourceLayout{Offset: vk.DeviceSize(p.Offset), RowPitch: vk.DeviceSize(p.Stride)}
495+ }
496+ explicit := vk.ImageDrmFormatModifierExplicitCreateInfoEXT{SType: vk.StructureTypeImageDRMFormatModifierExplicitCreateInfoEXT, DrmFormatModifier: b.Modifier, DrmFormatModifierPlaneCount: uint32(n), PlaneLayouts: &layouts[0]}
497+ external := vk.ExternalMemoryImageCreateInfo{SType: vk.StructureTypeExternalMemoryImageCreateInfo, Next: unsafe.Pointer(&explicit), HandleTypes: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
498+ 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}
499+ if err := checked("vkCreateImage(imported target)", d.CreateImage(r.device, &ii, nil, &t.image)); err != nil {
500+ return nil, err
501+ }
502+ fdProps := vk.MemoryFdPropertiesKHR{SType: vk.StructureTypeMemoryFDPropertiesKHR}
503+ if err := checked("vkGetMemoryFdPropertiesKHR", d.GetMemoryFdPropertiesKHR(r.device, vk.ExternalMemoryHandleTypeDMABUFBitEXT, int32(fd), &fdProps)); err != nil {
504+ return nil, err
505+ }
506+ var req vk.MemoryRequirements
507+ d.GetImageMemoryRequirements(r.device, t.image, &req)
508+ bits := req.MemoryTypeBits & fdProps.MemoryTypeBits
509+ kind, err := r.findMemoryType(bits, vk.MemoryPropertyDeviceLocalBit)
510+ if err != nil {
511+ if kind, err = r.findMemoryType(bits, 0); err != nil {
512+ return nil, err
513+ }
514+ }
515+ dedicated := vk.MemoryDedicatedAllocateInfo{SType: vk.StructureTypeMemoryDedicatedAllocateInfo, Image: t.image}
516+ imp := vk.ImportMemoryFdInfoKHR{SType: vk.StructureTypeImportMemoryFDInfoKHR, Next: unsafe.Pointer(&dedicated), HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT, Fd: int32(fd)}
517+ alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, Next: unsafe.Pointer(&imp), AllocationSize: req.Size, MemoryTypeIndex: kind}
518+ if err := checked("vkAllocateMemory(imported target)", d.AllocateMemory(r.device, &alloc, nil, &t.memory)); err != nil {
519+ return nil, err
520+ }
521+ fd = -1 // Vulkan owns it now.
522+ if err := checked("vkBindImageMemory(imported target)", d.BindImageMemory(r.device, t.image, t.memory, 0)); err != nil {
523+ return nil, err
524+ }
525+ if t.view, err = r.imageView(t.image, format); err != nil {
526+ return nil, err
527+ }
528+ r.log.Debug().Uint64("modifier", b.Modifier).Int("planes", n).Msg("render target imported")
529+ ok = true
530+ return t, nil
531+}
532+
533+// sameDMABuf fails unless every plane file is the same dmabuf (same device
534+// and inode): the target is imported from the first plane's memory only, so
535+// planes in other buffers would read the wrong memory.
536+func sameDMABuf(planes []ports.DMABufPlane) error {
537+ var first unix.Stat_t
538+ for i, p := range planes {
539+ fd, err := dupFile(p.File)
540+ if err != nil {
541+ return fmt.Errorf("plane %d: %w", i, err)
542+ }
543+ var st unix.Stat_t
544+ err = unix.Fstat(fd, &st)
545+ _ = unix.Close(fd)
546+ if err != nil {
547+ return fmt.Errorf("stat plane %d: %w", i, err)
548+ }
549+ if i == 0 {
550+ first = st
551+ } else if st.Dev != first.Dev || st.Ino != first.Ino {
552+ return fmt.Errorf("%w: plane %d is not in the dmabuf of plane 0", errImageDMABuf, i)
553+ }
554+ }
555+ return nil
556+}
M internal/adapters/vulkan/targets_test.go +172 -0
@@ -1,8 +1,10 @@
11 package vulkan
22
33 import (
4+ "errors"
45 "image/color"
56 "math"
7+ "os"
68 "slices"
79 "testing"
810
@@ -367,3 +369,173 @@ func TestProbedModifiersHaveNoDCCRetile(t *testing.T) {
367369 }
368370 }
369371 }
372+
373+// pipeFile is a file for plane tests: a distinct inode per call.
374+func pipeFile(t *testing.T) *os.File {
375+ t.Helper()
376+ f, w, err := os.Pipe()
377+ if err != nil {
378+ t.Fatal(err)
379+ }
380+ w.Close()
381+ t.Cleanup(func() { f.Close() })
382+ return f
383+}
384+
385+// Planes must be in one dmabuf: the same file or duplicates of it are, two
386+// files are not. No device needed.
387+func TestSameDMABuf(t *testing.T) {
388+ one, other := pipeFile(t), pipeFile(t)
389+ for _, tc := range []struct {
390+ name string
391+ planes []ports.DMABufPlane
392+ ok bool
393+ }{
394+ {"one plane", []ports.DMABufPlane{{File: one}}, true},
395+ {"same file twice", []ports.DMABufPlane{{File: one}, {File: one}}, true},
396+ {"two files", []ports.DMABufPlane{{File: one}, {File: other}}, false},
397+ {"third plane elsewhere", []ports.DMABufPlane{{File: one}, {File: one}, {File: other}}, false},
398+ {"no file", []ports.DMABufPlane{{File: one}, {}}, false},
399+ } {
400+ err := sameDMABuf(tc.planes)
401+ if (err == nil) != tc.ok {
402+ t.Errorf("%s: %v", tc.name, err)
403+ }
404+ if err != nil && tc.name != "no file" && !errors.Is(err, errImageDMABuf) {
405+ t.Errorf("%s: %v is not errImageDMABuf", tc.name, err)
406+ }
407+ }
408+}
409+
410+// TargetModifiers keeps the modifiers the display lists and drops
411+// multi-plane ones for singlePlane.
412+func TestTargetModifiersFiltering(t *testing.T) {
413+ r, err := New(64, 32)
414+ if err != nil {
415+ t.Skipf("Vulkan unavailable: %v", err)
416+ }
417+ defer r.Close()
418+ mods := r.TargetModifiers(nil, false)
419+ if len(mods) == 0 {
420+ t.Skip("no importable target modifier")
421+ }
422+ if got := r.TargetModifiers([]uint64{0x1234567}, false); len(got) != 0 {
423+ t.Errorf("modifiers outside the display list: %#x", got)
424+ }
425+ if got := r.TargetModifiers(mods[:1], false); len(got) != 1 || got[0] != mods[0] {
426+ t.Errorf("display list %#x gave %#x", mods[:1], got)
427+ }
428+ for _, m := range r.TargetModifiers(nil, true) {
429+ if r.modPlanes[m] > 1 {
430+ t.Errorf("single-plane list holds multi-plane modifier %#x", m)
431+ }
432+ }
433+ // The answer is probed once and kept.
434+ if len(r.importMods) == 0 {
435+ t.Error("import modifiers not cached")
436+ }
437+ again := r.TargetModifiers(nil, false)
438+ if !slices.Equal(mods, again) {
439+ t.Errorf("cached list differs: %#x vs %#x", mods, again)
440+ }
441+}
442+
443+// ImportTargets refuses an image that does not fit the renderer before
444+// asking the driver, says why, and leaves no targets: frames go back to the
445+// renderer's own image.
446+func TestImportTargetsRefusesMismatchedImages(t *testing.T) {
447+ r, err := New(64, 32)
448+ if err != nil {
449+ t.Skipf("Vulkan unavailable: %v", err)
450+ }
451+ defer r.Close()
452+ var single uint64
453+ found := false
454+ for _, m := range r.TargetModifiers(nil, true) {
455+ single, found = m, true
456+ break
457+ }
458+ if !found {
459+ t.Skip("no importable one-plane target modifier")
460+ }
461+ good := func() ports.DMABuf {
462+ return ports.DMABuf{Width: 64, Height: 32, Format: fourccXRGB, Modifier: single, Planes: []ports.DMABufPlane{{File: pipeFile(t)}}}
463+ }
464+ for _, tc := range []struct {
465+ name string
466+ mutate func(*ports.DMABuf)
467+ want error
468+ }{
469+ {"width", func(b *ports.DMABuf) { b.Width = 65 }, errImageGeometry},
470+ {"height", func(b *ports.DMABuf) { b.Height = 31 }, errImageGeometry},
471+ {"format", func(b *ports.DMABuf) { b.Format = fourccXR30 }, errImageGeometry},
472+ {"unknown modifier", func(b *ports.DMABuf) { b.Modifier = 0x1234567 }, errImageModifier},
473+ {"no planes", func(b *ports.DMABuf) { b.Planes = nil }, errImagePlanes},
474+ {"too many planes", func(b *ports.DMABuf) {
475+ b.Planes = append(b.Planes, ports.DMABufPlane{File: pipeFile(t)})
476+ }, errImagePlanes},
477+ } {
478+ t.Run(tc.name, func(t *testing.T) {
479+ b := good()
480+ tc.mutate(&b)
481+ err := r.ImportTargets([]ports.DMABuf{b})
482+ if !errors.Is(err, tc.want) {
483+ t.Fatalf("error %v, want %v", err, tc.want)
484+ }
485+ if len(r.targets) != 0 {
486+ t.Fatalf("%d targets left", len(r.targets))
487+ }
488+ })
489+ }
490+ t.Run("second image refused drops the first", func(t *testing.T) {
491+ // A real dmabuf for the first image: the renderer's own export.
492+ exported, err := r.ExportTargets(1, []uint64{single}, true)
493+ if err != nil {
494+ t.Skipf("no exportable target to reuse: %v", err)
495+ }
496+ defer closeBufs(exported)
497+ bad := good()
498+ bad.Width = 1
499+ err = r.ImportTargets([]ports.DMABuf{exported[0], bad})
500+ if !errors.Is(err, errImageGeometry) {
501+ t.Fatalf("error %v, want the second image's geometry refusal", err)
502+ }
503+ if len(r.targets) != 0 {
504+ t.Fatalf("%d targets left", len(r.targets))
505+ }
506+ })
507+ t.Run("dropping", func(t *testing.T) {
508+ if err := r.ImportTargets(nil); err != nil || len(r.targets) != 0 {
509+ t.Fatalf("%v, %d targets", err, len(r.targets))
510+ }
511+ })
512+ t.Run("planes in different dmabufs", func(t *testing.T) {
513+ multi := uint64(0)
514+ for _, m := range r.TargetModifiers(nil, false) {
515+ if r.modPlanes[m] > 1 {
516+ multi = m
517+ break
518+ }
519+ }
520+ if multi == 0 {
521+ t.Skip("no multi-plane importable modifier on this device: the check is covered by TestSameDMABuf")
522+ }
523+ b := good()
524+ b.Modifier = multi
525+ b.Planes = nil
526+ for range r.modPlanes[multi] {
527+ b.Planes = append(b.Planes, ports.DMABufPlane{File: pipeFile(t)})
528+ }
529+ if err := r.ImportTargets([]ports.DMABuf{b}); !errors.Is(err, errImageDMABuf) {
530+ t.Fatalf("error %v", err)
531+ }
532+ })
533+}
534+
535+func closeBufs(bufs []ports.DMABuf) {
536+ for _, b := range bufs {
537+ for _, p := range b.Planes {
538+ p.File.Close()
539+ }
540+ }
541+}
@@ -1,8 +1,10 @@
1 package vulkan1 package vulkan
2 2
3 import (3 import (
4+ "errors"
4 "image/color"5 "image/color"
5 "math"6 "math"
7+ "os"
6 "slices"8 "slices"
7 "testing"9 "testing"
8 10
@@ -367,3 +369,173 @@ func TestProbedModifiersHaveNoDCCRetile(t *testing.T) {
367 }369 }
368 }370 }
369 }371 }
372+
373+// pipeFile is a file for plane tests: a distinct inode per call.
374+func pipeFile(t *testing.T) *os.File {
375+ t.Helper()
376+ f, w, err := os.Pipe()
377+ if err != nil {
378+ t.Fatal(err)
379+ }
380+ w.Close()
381+ t.Cleanup(func() { f.Close() })
382+ return f
383+}
384+
385+// Planes must be in one dmabuf: the same file or duplicates of it are, two
386+// files are not. No device needed.
387+func TestSameDMABuf(t *testing.T) {
388+ one, other := pipeFile(t), pipeFile(t)
389+ for _, tc := range []struct {
390+ name string
391+ planes []ports.DMABufPlane
392+ ok bool
393+ }{
394+ {"one plane", []ports.DMABufPlane{{File: one}}, true},
395+ {"same file twice", []ports.DMABufPlane{{File: one}, {File: one}}, true},
396+ {"two files", []ports.DMABufPlane{{File: one}, {File: other}}, false},
397+ {"third plane elsewhere", []ports.DMABufPlane{{File: one}, {File: one}, {File: other}}, false},
398+ {"no file", []ports.DMABufPlane{{File: one}, {}}, false},
399+ } {
400+ err := sameDMABuf(tc.planes)
401+ if (err == nil) != tc.ok {
402+ t.Errorf("%s: %v", tc.name, err)
403+ }
404+ if err != nil && tc.name != "no file" && !errors.Is(err, errImageDMABuf) {
405+ t.Errorf("%s: %v is not errImageDMABuf", tc.name, err)
406+ }
407+ }
408+}
409+
410+// TargetModifiers keeps the modifiers the display lists and drops
411+// multi-plane ones for singlePlane.
412+func TestTargetModifiersFiltering(t *testing.T) {
413+ r, err := New(64, 32)
414+ if err != nil {
415+ t.Skipf("Vulkan unavailable: %v", err)
416+ }
417+ defer r.Close()
418+ mods := r.TargetModifiers(nil, false)
419+ if len(mods) == 0 {
420+ t.Skip("no importable target modifier")
421+ }
422+ if got := r.TargetModifiers([]uint64{0x1234567}, false); len(got) != 0 {
423+ t.Errorf("modifiers outside the display list: %#x", got)
424+ }
425+ if got := r.TargetModifiers(mods[:1], false); len(got) != 1 || got[0] != mods[0] {
426+ t.Errorf("display list %#x gave %#x", mods[:1], got)
427+ }
428+ for _, m := range r.TargetModifiers(nil, true) {
429+ if r.modPlanes[m] > 1 {
430+ t.Errorf("single-plane list holds multi-plane modifier %#x", m)
431+ }
432+ }
433+ // The answer is probed once and kept.
434+ if len(r.importMods) == 0 {
435+ t.Error("import modifiers not cached")
436+ }
437+ again := r.TargetModifiers(nil, false)
438+ if !slices.Equal(mods, again) {
439+ t.Errorf("cached list differs: %#x vs %#x", mods, again)
440+ }
441+}
442+
443+// ImportTargets refuses an image that does not fit the renderer before
444+// asking the driver, says why, and leaves no targets: frames go back to the
445+// renderer's own image.
446+func TestImportTargetsRefusesMismatchedImages(t *testing.T) {
447+ r, err := New(64, 32)
448+ if err != nil {
449+ t.Skipf("Vulkan unavailable: %v", err)
450+ }
451+ defer r.Close()
452+ var single uint64
453+ found := false
454+ for _, m := range r.TargetModifiers(nil, true) {
455+ single, found = m, true
456+ break
457+ }
458+ if !found {
459+ t.Skip("no importable one-plane target modifier")
460+ }
461+ good := func() ports.DMABuf {
462+ return ports.DMABuf{Width: 64, Height: 32, Format: fourccXRGB, Modifier: single, Planes: []ports.DMABufPlane{{File: pipeFile(t)}}}
463+ }
464+ for _, tc := range []struct {
465+ name string
466+ mutate func(*ports.DMABuf)
467+ want error
468+ }{
469+ {"width", func(b *ports.DMABuf) { b.Width = 65 }, errImageGeometry},
470+ {"height", func(b *ports.DMABuf) { b.Height = 31 }, errImageGeometry},
471+ {"format", func(b *ports.DMABuf) { b.Format = fourccXR30 }, errImageGeometry},
472+ {"unknown modifier", func(b *ports.DMABuf) { b.Modifier = 0x1234567 }, errImageModifier},
473+ {"no planes", func(b *ports.DMABuf) { b.Planes = nil }, errImagePlanes},
474+ {"too many planes", func(b *ports.DMABuf) {
475+ b.Planes = append(b.Planes, ports.DMABufPlane{File: pipeFile(t)})
476+ }, errImagePlanes},
477+ } {
478+ t.Run(tc.name, func(t *testing.T) {
479+ b := good()
480+ tc.mutate(&b)
481+ err := r.ImportTargets([]ports.DMABuf{b})
482+ if !errors.Is(err, tc.want) {
483+ t.Fatalf("error %v, want %v", err, tc.want)
484+ }
485+ if len(r.targets) != 0 {
486+ t.Fatalf("%d targets left", len(r.targets))
487+ }
488+ })
489+ }
490+ t.Run("second image refused drops the first", func(t *testing.T) {
491+ // A real dmabuf for the first image: the renderer's own export.
492+ exported, err := r.ExportTargets(1, []uint64{single}, true)
493+ if err != nil {
494+ t.Skipf("no exportable target to reuse: %v", err)
495+ }
496+ defer closeBufs(exported)
497+ bad := good()
498+ bad.Width = 1
499+ err = r.ImportTargets([]ports.DMABuf{exported[0], bad})
500+ if !errors.Is(err, errImageGeometry) {
501+ t.Fatalf("error %v, want the second image's geometry refusal", err)
502+ }
503+ if len(r.targets) != 0 {
504+ t.Fatalf("%d targets left", len(r.targets))
505+ }
506+ })
507+ t.Run("dropping", func(t *testing.T) {
508+ if err := r.ImportTargets(nil); err != nil || len(r.targets) != 0 {
509+ t.Fatalf("%v, %d targets", err, len(r.targets))
510+ }
511+ })
512+ t.Run("planes in different dmabufs", func(t *testing.T) {
513+ multi := uint64(0)
514+ for _, m := range r.TargetModifiers(nil, false) {
515+ if r.modPlanes[m] > 1 {
516+ multi = m
517+ break
518+ }
519+ }
520+ if multi == 0 {
521+ t.Skip("no multi-plane importable modifier on this device: the check is covered by TestSameDMABuf")
522+ }
523+ b := good()
524+ b.Modifier = multi
525+ b.Planes = nil
526+ for range r.modPlanes[multi] {
527+ b.Planes = append(b.Planes, ports.DMABufPlane{File: pipeFile(t)})
528+ }
529+ if err := r.ImportTargets([]ports.DMABuf{b}); !errors.Is(err, errImageDMABuf) {
530+ t.Fatalf("error %v", err)
531+ }
532+ })
533+}
534+
535+func closeBufs(bufs []ports.DMABuf) {
536+ for _, b := range bufs {
537+ for _, p := range b.Planes {
538+ p.File.Close()
539+ }
540+ }
541+}
A internal/app/gbm_targets_test.go +151 -0
@@ -0,0 +1,151 @@
1+package app
2+
3+import (
4+ "image/color"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/neferwl/internal/adapters/drm/gbm"
10+ "github.com/bnema/neferwl/internal/adapters/vulkan"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// fourccXRGB8888 is DRM_FORMAT_XRGB8888.
16+const fourccXRGB8888 = 'X' | 'R'<<8 | '2'<<16 | '4'<<24
17+
18+// renderNodeFor opens the render node of the GPU the renderer runs on.
19+func renderNodeFor(t *testing.T, dev uint64) *os.File {
20+ t.Helper()
21+ nodes, _ := filepath.Glob("/dev/dri/renderD*")
22+ for _, n := range nodes {
23+ var st unix.Stat_t
24+ if unix.Stat(n, &st) != nil || st.Rdev != dev {
25+ continue
26+ }
27+ f, err := os.OpenFile(n, os.O_RDWR|unix.O_CLOEXEC, 0)
28+ if err != nil {
29+ t.Skipf("cannot open %s: %v", n, err)
30+ }
31+ return f
32+ }
33+ t.Skipf("no render node for device %#x", dev)
34+ return nil
35+}
36+
37+// Images allocated by GBM are imported as the renderer's targets (the path
38+// the DRM output takes) and frames drawn into them read back. Skips without
39+// a GPU, libgbm or an openable render node.
40+func TestGBMImagesAreRenderTargets(t *testing.T) {
41+ const w, h = 128, 64
42+ r, err := vulkan.New(w, h)
43+ if err != nil {
44+ t.Skipf("Vulkan unavailable: %v", err)
45+ }
46+ defer r.Close()
47+ sup := r.DMABuf()
48+ if sup.Device == 0 || len(sup.Formats) == 0 {
49+ t.Skip("device does not import dmabufs")
50+ }
51+ mods := r.TargetModifiers(nil, false)
52+ if len(mods) == 0 {
53+ t.Skip("no importable target modifier")
54+ }
55+ t.Logf("renderer target modifiers: %#x", mods)
56+ node := renderNodeFor(t, sup.Device)
57+ defer node.Close()
58+ dev, err := gbm.Open(int(node.Fd()))
59+ if err != nil {
60+ t.Skipf("GBM unavailable: %v", err)
61+ }
62+ defer dev.Close()
63+
64+ // A render node cannot always allocate scanout buffers.
65+ var bufs [2]*gbm.Buffer
66+ for _, usage := range []gbm.Usage{gbm.UseScanout | gbm.UseRendering, gbm.UseRendering} {
67+ var cerr error
68+ for i := range bufs {
69+ if bufs[i], cerr = dev.Create(w, h, fourccXRGB8888, mods, usage); cerr != nil {
70+ break
71+ }
72+ }
73+ if cerr == nil {
74+ t.Logf("GBM usage %#x", usage)
75+ break
76+ }
77+ t.Logf("GBM usage %#x: %v", usage, cerr)
78+ for i := range bufs {
79+ bufs[i].Close()
80+ bufs[i] = nil
81+ }
82+ }
83+ if bufs[0] == nil || bufs[1] == nil {
84+ t.Skip("GBM allocated no image with the renderer's modifiers")
85+ }
86+ defer bufs[0].Close()
87+ defer bufs[1].Close()
88+
89+ var dmas []ports.DMABuf
90+ for _, b := range bufs {
91+ d, err := b.DMABuf()
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ dmas = append(dmas, d)
96+ }
97+ defer func() {
98+ for _, d := range dmas {
99+ for _, p := range d.Planes {
100+ p.File.Close()
101+ }
102+ }
103+ }()
104+ t.Logf("GBM picked modifier %#x, %d plane(s), stride %d", dmas[0].Modifier, len(dmas[0].Planes), dmas[0].Planes[0].Stride)
105+ if err := r.ImportTargets(dmas); err != nil {
106+ t.Fatalf("ImportTargets: %v", err)
107+ }
108+ t.Log("Vulkan import of the GBM images succeeded")
109+
110+ // Target 0: red background with a blue window; target 1: green.
111+ scenes := []ports.Scene{
112+ {Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 32, Y: 16, W: 64, H: 32}}}},
113+ {Background: "#00ff00"},
114+ }
115+ contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Width: 1, Height: 1, LogicalW: 64, LogicalH: 32, Opaque: true, Solid: &ports.SolidColor{B: 1, A: 1}}}
116+ type px struct {
117+ x, y int
118+ want color.RGBA
119+ }
120+ checks := [][]px{
121+ {{4, 4, color.RGBA{255, 0, 0, 255}}, {w - 4, h - 4, color.RGBA{255, 0, 0, 255}}, {64, 32, color.RGBA{0, 0, 255, 255}}, {36, 20, color.RGBA{0, 0, 255, 255}}},
122+ {{4, 4, color.RGBA{0, 255, 0, 255}}, {64, 32, color.RGBA{0, 255, 0, 255}}},
123+ }
124+ // Twice around, so each image is drawn into again after the other one.
125+ for round := range 2 {
126+ for i := range scenes {
127+ r.UseTarget(i)
128+ s := scenes[i]
129+ s.Seq = uint64(round*2 + i + 1)
130+ done, err := r.Render(s, contents)
131+ if err != nil {
132+ t.Fatalf("render %d: %v", i, err)
133+ }
134+ if done != nil {
135+ done.Close()
136+ }
137+ img := r.Pixels()
138+ if img == nil {
139+ t.Fatalf("target %d: no pixels", i)
140+ }
141+ for _, c := range checks[i] {
142+ if got := img.At(c.x, c.y); got != c.want {
143+ t.Errorf("round %d target %d pixel (%d,%d) = %v, want %v", round, i, c.x, c.y, got, c.want)
144+ }
145+ }
146+ }
147+ }
148+ if err := r.ImportTargets(nil); err != nil {
149+ t.Fatal(err)
150+ }
151+}
@@ -0,0 +1,151 @@
1+package app
2+
3+import (
4+ "image/color"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/neferwl/internal/adapters/drm/gbm"
10+ "github.com/bnema/neferwl/internal/adapters/vulkan"
11+ "github.com/bnema/neferwl/internal/ports"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// fourccXRGB8888 is DRM_FORMAT_XRGB8888.
16+const fourccXRGB8888 = 'X' | 'R'<<8 | '2'<<16 | '4'<<24
17+
18+// renderNodeFor opens the render node of the GPU the renderer runs on.
19+func renderNodeFor(t *testing.T, dev uint64) *os.File {
20+ t.Helper()
21+ nodes, _ := filepath.Glob("/dev/dri/renderD*")
22+ for _, n := range nodes {
23+ var st unix.Stat_t
24+ if unix.Stat(n, &st) != nil || st.Rdev != dev {
25+ continue
26+ }
27+ f, err := os.OpenFile(n, os.O_RDWR|unix.O_CLOEXEC, 0)
28+ if err != nil {
29+ t.Skipf("cannot open %s: %v", n, err)
30+ }
31+ return f
32+ }
33+ t.Skipf("no render node for device %#x", dev)
34+ return nil
35+}
36+
37+// Images allocated by GBM are imported as the renderer's targets (the path
38+// the DRM output takes) and frames drawn into them read back. Skips without
39+// a GPU, libgbm or an openable render node.
40+func TestGBMImagesAreRenderTargets(t *testing.T) {
41+ const w, h = 128, 64
42+ r, err := vulkan.New(w, h)
43+ if err != nil {
44+ t.Skipf("Vulkan unavailable: %v", err)
45+ }
46+ defer r.Close()
47+ sup := r.DMABuf()
48+ if sup.Device == 0 || len(sup.Formats) == 0 {
49+ t.Skip("device does not import dmabufs")
50+ }
51+ mods := r.TargetModifiers(nil, false)
52+ if len(mods) == 0 {
53+ t.Skip("no importable target modifier")
54+ }
55+ t.Logf("renderer target modifiers: %#x", mods)
56+ node := renderNodeFor(t, sup.Device)
57+ defer node.Close()
58+ dev, err := gbm.Open(int(node.Fd()))
59+ if err != nil {
60+ t.Skipf("GBM unavailable: %v", err)
61+ }
62+ defer dev.Close()
63+
64+ // A render node cannot always allocate scanout buffers.
65+ var bufs [2]*gbm.Buffer
66+ for _, usage := range []gbm.Usage{gbm.UseScanout | gbm.UseRendering, gbm.UseRendering} {
67+ var cerr error
68+ for i := range bufs {
69+ if bufs[i], cerr = dev.Create(w, h, fourccXRGB8888, mods, usage); cerr != nil {
70+ break
71+ }
72+ }
73+ if cerr == nil {
74+ t.Logf("GBM usage %#x", usage)
75+ break
76+ }
77+ t.Logf("GBM usage %#x: %v", usage, cerr)
78+ for i := range bufs {
79+ bufs[i].Close()
80+ bufs[i] = nil
81+ }
82+ }
83+ if bufs[0] == nil || bufs[1] == nil {
84+ t.Skip("GBM allocated no image with the renderer's modifiers")
85+ }
86+ defer bufs[0].Close()
87+ defer bufs[1].Close()
88+
89+ var dmas []ports.DMABuf
90+ for _, b := range bufs {
91+ d, err := b.DMABuf()
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ dmas = append(dmas, d)
96+ }
97+ defer func() {
98+ for _, d := range dmas {
99+ for _, p := range d.Planes {
100+ p.File.Close()
101+ }
102+ }
103+ }()
104+ t.Logf("GBM picked modifier %#x, %d plane(s), stride %d", dmas[0].Modifier, len(dmas[0].Planes), dmas[0].Planes[0].Stride)
105+ if err := r.ImportTargets(dmas); err != nil {
106+ t.Fatalf("ImportTargets: %v", err)
107+ }
108+ t.Log("Vulkan import of the GBM images succeeded")
109+
110+ // Target 0: red background with a blue window; target 1: green.
111+ scenes := []ports.Scene{
112+ {Background: "#ff0000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 32, Y: 16, W: 64, H: 32}}}},
113+ {Background: "#00ff00"},
114+ }
115+ contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Width: 1, Height: 1, LogicalW: 64, LogicalH: 32, Opaque: true, Solid: &ports.SolidColor{B: 1, A: 1}}}
116+ type px struct {
117+ x, y int
118+ want color.RGBA
119+ }
120+ checks := [][]px{
121+ {{4, 4, color.RGBA{255, 0, 0, 255}}, {w - 4, h - 4, color.RGBA{255, 0, 0, 255}}, {64, 32, color.RGBA{0, 0, 255, 255}}, {36, 20, color.RGBA{0, 0, 255, 255}}},
122+ {{4, 4, color.RGBA{0, 255, 0, 255}}, {64, 32, color.RGBA{0, 255, 0, 255}}},
123+ }
124+ // Twice around, so each image is drawn into again after the other one.
125+ for round := range 2 {
126+ for i := range scenes {
127+ r.UseTarget(i)
128+ s := scenes[i]
129+ s.Seq = uint64(round*2 + i + 1)
130+ done, err := r.Render(s, contents)
131+ if err != nil {
132+ t.Fatalf("render %d: %v", i, err)
133+ }
134+ if done != nil {
135+ done.Close()
136+ }
137+ img := r.Pixels()
138+ if img == nil {
139+ t.Fatalf("target %d: no pixels", i)
140+ }
141+ for _, c := range checks[i] {
142+ if got := img.At(c.x, c.y); got != c.want {
143+ t.Errorf("round %d target %d pixel (%d,%d) = %v, want %v", round, i, c.x, c.y, got, c.want)
144+ }
145+ }
146+ }
147+ }
148+ if err := r.ImportTargets(nil); err != nil {
149+ t.Fatal(err)
150+ }
151+}
M internal/mocks/ports/devices_mock.go +110 -0
@@ -504,6 +504,57 @@ func (_c *MockRenderer_HDRPixels_Call) RunAndReturn(run func() *image.RGBA64) *M
504504 return _c
505505 }
506506
507+// ImportTargets provides a mock function for the type MockRenderer
508+func (_mock *MockRenderer) ImportTargets(bufs []ports.DMABuf) error {
509+ ret := _mock.Called(bufs)
510+
511+ if len(ret) == 0 {
512+ panic("no return value specified for ImportTargets")
513+ }
514+
515+ var r0 error
516+ if returnFunc, ok := ret.Get(0).(func([]ports.DMABuf) error); ok {
517+ r0 = returnFunc(bufs)
518+ } else {
519+ r0 = ret.Error(0)
520+ }
521+ return r0
522+}
523+
524+// MockRenderer_ImportTargets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ImportTargets'
525+type MockRenderer_ImportTargets_Call struct {
526+ *mock.Call
527+}
528+
529+// ImportTargets is a helper method to define mock.On call
530+// - bufs []ports.DMABuf
531+func (_e *MockRenderer_Expecter) ImportTargets(bufs any) *MockRenderer_ImportTargets_Call {
532+ return &MockRenderer_ImportTargets_Call{Call: _e.mock.On("ImportTargets", bufs)}
533+}
534+
535+func (_c *MockRenderer_ImportTargets_Call) Run(run func(bufs []ports.DMABuf)) *MockRenderer_ImportTargets_Call {
536+ _c.Call.Run(func(args mock.Arguments) {
537+ var arg0 []ports.DMABuf
538+ if args[0] != nil {
539+ arg0 = args[0].([]ports.DMABuf)
540+ }
541+ run(
542+ arg0,
543+ )
544+ })
545+ return _c
546+}
547+
548+func (_c *MockRenderer_ImportTargets_Call) Return(err error) *MockRenderer_ImportTargets_Call {
549+ _c.Call.Return(err)
550+ return _c
551+}
552+
553+func (_c *MockRenderer_ImportTargets_Call) RunAndReturn(run func(bufs []ports.DMABuf) error) *MockRenderer_ImportTargets_Call {
554+ _c.Call.Return(run)
555+ return _c
556+}
557+
507558 // Pixels provides a mock function for the type MockRenderer
508559 func (_mock *MockRenderer) Pixels() *image.RGBA {
509560 ret := _mock.Called()
@@ -702,6 +753,65 @@ func (_c *MockRenderer_TakeRedrawn_Call) RunAndReturn(run func() int) *MockRende
702753 return _c
703754 }
704755
756+// TargetModifiers provides a mock function for the type MockRenderer
757+func (_mock *MockRenderer) TargetModifiers(display []uint64, singlePlane bool) []uint64 {
758+ ret := _mock.Called(display, singlePlane)
759+
760+ if len(ret) == 0 {
761+ panic("no return value specified for TargetModifiers")
762+ }
763+
764+ var r0 []uint64
765+ if returnFunc, ok := ret.Get(0).(func([]uint64, bool) []uint64); ok {
766+ r0 = returnFunc(display, singlePlane)
767+ } else {
768+ if ret.Get(0) != nil {
769+ r0 = ret.Get(0).([]uint64)
770+ }
771+ }
772+ return r0
773+}
774+
775+// MockRenderer_TargetModifiers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TargetModifiers'
776+type MockRenderer_TargetModifiers_Call struct {
777+ *mock.Call
778+}
779+
780+// TargetModifiers is a helper method to define mock.On call
781+// - display []uint64
782+// - singlePlane bool
783+func (_e *MockRenderer_Expecter) TargetModifiers(display any, singlePlane any) *MockRenderer_TargetModifiers_Call {
784+ return &MockRenderer_TargetModifiers_Call{Call: _e.mock.On("TargetModifiers", display, singlePlane)}
785+}
786+
787+func (_c *MockRenderer_TargetModifiers_Call) Run(run func(display []uint64, singlePlane bool)) *MockRenderer_TargetModifiers_Call {
788+ _c.Call.Run(func(args mock.Arguments) {
789+ var arg0 []uint64
790+ if args[0] != nil {
791+ arg0 = args[0].([]uint64)
792+ }
793+ var arg1 bool
794+ if args[1] != nil {
795+ arg1 = args[1].(bool)
796+ }
797+ run(
798+ arg0,
799+ arg1,
800+ )
801+ })
802+ return _c
803+}
804+
805+func (_c *MockRenderer_TargetModifiers_Call) Return(uint64s []uint64) *MockRenderer_TargetModifiers_Call {
806+ _c.Call.Return(uint64s)
807+ return _c
808+}
809+
810+func (_c *MockRenderer_TargetModifiers_Call) RunAndReturn(run func(display []uint64, singlePlane bool) []uint64) *MockRenderer_TargetModifiers_Call {
811+ _c.Call.Return(run)
812+ return _c
813+}
814+
705815 // Trim provides a mock function for the type MockRenderer
706816 func (_mock *MockRenderer) Trim(now time.Time) error {
707817 ret := _mock.Called(now)
@@ -504,6 +504,57 @@ func (_c *MockRenderer_HDRPixels_Call) RunAndReturn(run func() *image.RGBA64) *M
504 return _c504 return _c
505 }505 }
506 506
507+// ImportTargets provides a mock function for the type MockRenderer
508+func (_mock *MockRenderer) ImportTargets(bufs []ports.DMABuf) error {
509+ ret := _mock.Called(bufs)
510+
511+ if len(ret) == 0 {
512+ panic("no return value specified for ImportTargets")
513+ }
514+
515+ var r0 error
516+ if returnFunc, ok := ret.Get(0).(func([]ports.DMABuf) error); ok {
517+ r0 = returnFunc(bufs)
518+ } else {
519+ r0 = ret.Error(0)
520+ }
521+ return r0
522+}
523+
524+// MockRenderer_ImportTargets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ImportTargets'
525+type MockRenderer_ImportTargets_Call struct {
526+ *mock.Call
527+}
528+
529+// ImportTargets is a helper method to define mock.On call
530+// - bufs []ports.DMABuf
531+func (_e *MockRenderer_Expecter) ImportTargets(bufs any) *MockRenderer_ImportTargets_Call {
532+ return &MockRenderer_ImportTargets_Call{Call: _e.mock.On("ImportTargets", bufs)}
533+}
534+
535+func (_c *MockRenderer_ImportTargets_Call) Run(run func(bufs []ports.DMABuf)) *MockRenderer_ImportTargets_Call {
536+ _c.Call.Run(func(args mock.Arguments) {
537+ var arg0 []ports.DMABuf
538+ if args[0] != nil {
539+ arg0 = args[0].([]ports.DMABuf)
540+ }
541+ run(
542+ arg0,
543+ )
544+ })
545+ return _c
546+}
547+
548+func (_c *MockRenderer_ImportTargets_Call) Return(err error) *MockRenderer_ImportTargets_Call {
549+ _c.Call.Return(err)
550+ return _c
551+}
552+
553+func (_c *MockRenderer_ImportTargets_Call) RunAndReturn(run func(bufs []ports.DMABuf) error) *MockRenderer_ImportTargets_Call {
554+ _c.Call.Return(run)
555+ return _c
556+}
557+
507 // Pixels provides a mock function for the type MockRenderer558 // Pixels provides a mock function for the type MockRenderer
508 func (_mock *MockRenderer) Pixels() *image.RGBA {559 func (_mock *MockRenderer) Pixels() *image.RGBA {
509 ret := _mock.Called()560 ret := _mock.Called()
@@ -702,6 +753,65 @@ func (_c *MockRenderer_TakeRedrawn_Call) RunAndReturn(run func() int) *MockRende
702 return _c753 return _c
703 }754 }
704 755
756+// TargetModifiers provides a mock function for the type MockRenderer
757+func (_mock *MockRenderer) TargetModifiers(display []uint64, singlePlane bool) []uint64 {
758+ ret := _mock.Called(display, singlePlane)
759+
760+ if len(ret) == 0 {
761+ panic("no return value specified for TargetModifiers")
762+ }
763+
764+ var r0 []uint64
765+ if returnFunc, ok := ret.Get(0).(func([]uint64, bool) []uint64); ok {
766+ r0 = returnFunc(display, singlePlane)
767+ } else {
768+ if ret.Get(0) != nil {
769+ r0 = ret.Get(0).([]uint64)
770+ }
771+ }
772+ return r0
773+}
774+
775+// MockRenderer_TargetModifiers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TargetModifiers'
776+type MockRenderer_TargetModifiers_Call struct {
777+ *mock.Call
778+}
779+
780+// TargetModifiers is a helper method to define mock.On call
781+// - display []uint64
782+// - singlePlane bool
783+func (_e *MockRenderer_Expecter) TargetModifiers(display any, singlePlane any) *MockRenderer_TargetModifiers_Call {
784+ return &MockRenderer_TargetModifiers_Call{Call: _e.mock.On("TargetModifiers", display, singlePlane)}
785+}
786+
787+func (_c *MockRenderer_TargetModifiers_Call) Run(run func(display []uint64, singlePlane bool)) *MockRenderer_TargetModifiers_Call {
788+ _c.Call.Run(func(args mock.Arguments) {
789+ var arg0 []uint64
790+ if args[0] != nil {
791+ arg0 = args[0].([]uint64)
792+ }
793+ var arg1 bool
794+ if args[1] != nil {
795+ arg1 = args[1].(bool)
796+ }
797+ run(
798+ arg0,
799+ arg1,
800+ )
801+ })
802+ return _c
803+}
804+
805+func (_c *MockRenderer_TargetModifiers_Call) Return(uint64s []uint64) *MockRenderer_TargetModifiers_Call {
806+ _c.Call.Return(uint64s)
807+ return _c
808+}
809+
810+func (_c *MockRenderer_TargetModifiers_Call) RunAndReturn(run func(display []uint64, singlePlane bool) []uint64) *MockRenderer_TargetModifiers_Call {
811+ _c.Call.Return(run)
812+ return _c
813+}
814+
705 // Trim provides a mock function for the type MockRenderer815 // Trim provides a mock function for the type MockRenderer
706 func (_mock *MockRenderer) Trim(now time.Time) error {816 func (_mock *MockRenderer) Trim(now time.Time) error {
707 ret := _mock.Called(now)817 ret := _mock.Called(now)
M internal/ports/devices.go +20 -3
@@ -36,11 +36,12 @@ type CaptureFrame interface {
3636 }
3737
3838 // Renderer draws scenes for an output. One goroutine owns it. Frames go
39-// to its own image until ExportTargets gives it scanout images; then each
40-// Render draws into the target chosen by UseTarget (ADR 014: no CPU pass).
39+// to its own image until ExportTargets or ImportTargets gives it scanout
40+// images; then each Render draws into the target chosen by UseTarget
41+// (ADR 014: no CPU pass).
4142 type Renderer interface {
4243 // SetHDR selects the HDR10 output transform (0 disables it).
43- // The DRM output calls this before ExportTargets.
44+ // The DRM output calls this before ExportTargets or ImportTargets.
4445 SetHDR(sdrNits float64)
4546 // Render draws the scene. done is a sync file signalled when the GPU
4647 // finished the frame (nil: already finished); the caller closes it.
@@ -53,6 +54,22 @@ type Renderer interface {
5354 // restricts the choice to one-plane modifiers.
5455 // n = 0 drops the targets.
5556 ExportTargets(n int, modifiers []uint64, singlePlane bool) ([]DMABuf, error)
57+ // TargetModifiers lists the modifiers an image allocated outside the
58+ // renderer (by the display's allocator) can have to be imported as a
59+ // target: those the device renders into and imports for the XRGB8888
60+ // (SDR) or XRGB2101010 (HDR) format, that the display lists (empty: any),
61+ // without multi-plane ones when singlePlane. Empty when the device
62+ // cannot import dmabufs.
63+ TargetModifiers(display []uint64, singlePlane bool) []uint64
64+ // ImportTargets makes images allocated outside the renderer its
65+ // targets, replacing the previous ones (no buffers: drops them). Each
66+ // is one memory object of the renderer's size and target format, with
67+ // a modifier from TargetModifiers: every plane file is the same
68+ // dmabuf (the planes differ by offset), and disjoint planes are
69+ // refused. The caller keeps its files and must keep the memory alive
70+ // while the targets are in use. An error leaves the renderer without
71+ // targets.
72+ ImportTargets(bufs []DMABuf) error
5673 // UseTarget selects the exported image the next Render draws into.
5774 UseTarget(i int)
5875 // CursorBuffers allocates two linear ARGB8888 images of size×size the
@@ -36,11 +36,12 @@ type CaptureFrame interface {
36 }36 }
37 37
38 // Renderer draws scenes for an output. One goroutine owns it. Frames go38 // Renderer draws scenes for an output. One goroutine owns it. Frames go
39-// to its own image until ExportTargets gives it scanout images; then each39+// to its own image until ExportTargets or ImportTargets gives it scanout
40-// Render draws into the target chosen by UseTarget (ADR 014: no CPU pass).40+// images; then each Render draws into the target chosen by UseTarget
41+// (ADR 014: no CPU pass).
41 type Renderer interface {42 type Renderer interface {
42 // SetHDR selects the HDR10 output transform (0 disables it).43 // SetHDR selects the HDR10 output transform (0 disables it).
43- // The DRM output calls this before ExportTargets.44+ // The DRM output calls this before ExportTargets or ImportTargets.
44 SetHDR(sdrNits float64)45 SetHDR(sdrNits float64)
45 // Render draws the scene. done is a sync file signalled when the GPU46 // Render draws the scene. done is a sync file signalled when the GPU
46 // finished the frame (nil: already finished); the caller closes it.47 // finished the frame (nil: already finished); the caller closes it.
@@ -53,6 +54,22 @@ type Renderer interface {
53 // restricts the choice to one-plane modifiers.54 // restricts the choice to one-plane modifiers.
54 // n = 0 drops the targets.55 // n = 0 drops the targets.
55 ExportTargets(n int, modifiers []uint64, singlePlane bool) ([]DMABuf, error)56 ExportTargets(n int, modifiers []uint64, singlePlane bool) ([]DMABuf, error)
57+ // TargetModifiers lists the modifiers an image allocated outside the
58+ // renderer (by the display's allocator) can have to be imported as a
59+ // target: those the device renders into and imports for the XRGB8888
60+ // (SDR) or XRGB2101010 (HDR) format, that the display lists (empty: any),
61+ // without multi-plane ones when singlePlane. Empty when the device
62+ // cannot import dmabufs.
63+ TargetModifiers(display []uint64, singlePlane bool) []uint64
64+ // ImportTargets makes images allocated outside the renderer its
65+ // targets, replacing the previous ones (no buffers: drops them). Each
66+ // is one memory object of the renderer's size and target format, with
67+ // a modifier from TargetModifiers: every plane file is the same
68+ // dmabuf (the planes differ by offset), and disjoint planes are
69+ // refused. The caller keeps its files and must keep the memory alive
70+ // while the targets are in use. An error leaves the renderer without
71+ // targets.
72+ ImportTargets(bufs []DMABuf) error
56 // UseTarget selects the exported image the next Render draws into.73 // UseTarget selects the exported image the next Render draws into.
57 UseTarget(i int)74 UseTarget(i int)
58 // CursorBuffers allocates two linear ARGB8888 images of size×size the75 // CursorBuffers allocates two linear ARGB8888 images of size×size the
M packaging/aur/neferwl-git/PKGBUILD +2 -2
@@ -6,7 +6,7 @@ pkgver=0.1.0.r78.ge0b15ca
66 pkgrel=1
77 pkgdesc='A Wayland compositor that spends its frames on your apps, not on itself (git version)'
88 arch=('x86_64' 'aarch64')
9-url='https://git.bnema.dev/neferwl'
9+url='https://github.com/bnema/neferwl'
1010 license=('GPL-3.0-only')
1111 # Loaded at runtime (purego, no cgo): not seen by makepkg's library scan.
1212 depends=('glibc' 'libinput' 'libxkbcommon' 'seatd' 'systemd-libs' 'vulkan-icd-loader'
@@ -22,7 +22,7 @@ optdepends=('xwayland-satellite>=0.7: X11 apps'
2222 provides=('neferwl')
2323 conflicts=('neferwl' 'neferwl-bin')
2424 install=neferwl.install
25-source=('git+https://git.bnema.dev/neferwl.git')
25+source=('git+https://github.com/bnema/neferwl.git')
2626 sha256sums=('SKIP')
2727
2828 pkgver() {
@@ -6,7 +6,7 @@ pkgver=0.1.0.r78.ge0b15ca
6 pkgrel=16 pkgrel=1
7 pkgdesc='A Wayland compositor that spends its frames on your apps, not on itself (git version)'7 pkgdesc='A Wayland compositor that spends its frames on your apps, not on itself (git version)'
8 arch=('x86_64' 'aarch64')8 arch=('x86_64' 'aarch64')
9-url='https://git.bnema.dev/neferwl'9+url='https://github.com/bnema/neferwl'
10 license=('GPL-3.0-only')10 license=('GPL-3.0-only')
11 # Loaded at runtime (purego, no cgo): not seen by makepkg's library scan.11 # Loaded at runtime (purego, no cgo): not seen by makepkg's library scan.
12 depends=('glibc' 'libinput' 'libxkbcommon' 'seatd' 'systemd-libs' 'vulkan-icd-loader'12 depends=('glibc' 'libinput' 'libxkbcommon' 'seatd' 'systemd-libs' 'vulkan-icd-loader'
@@ -22,7 +22,7 @@ optdepends=('xwayland-satellite>=0.7: X11 apps'
22 provides=('neferwl')22 provides=('neferwl')
23 conflicts=('neferwl' 'neferwl-bin')23 conflicts=('neferwl' 'neferwl-bin')
24 install=neferwl.install24 install=neferwl.install
25-source=('git+https://git.bnema.dev/neferwl.git')25+source=('git+https://github.com/bnema/neferwl.git')
26 sha256sums=('SKIP')26 sha256sums=('SKIP')
27 27
28 pkgver() {28 pkgver() {
M tools/demo/Dockerfile +15 -24
@@ -4,32 +4,23 @@ RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
44 pacman -Sy --noconfirm archlinux-keyring \
55 && pacman -Syu --noconfirm --needed \
66 firefox foot btop fastfetch eza mousepad \
7- ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-radeon \
8- tesseract tesseract-data-eng at-spi2-core python
7+ ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-swrast
98 # Arch's btop carries file capabilities that rootless Docker cannot grant.
109 RUN install -m 0755 /usr/bin/btop /usr/local/bin/btop
11-# Mousepad warns about running as root, so it runs as demo (uid 1000).
12-RUN useradd -m -u 1000 demo && mkdir -p /root/firefox /root/.config/btop /run/wl && chmod 0755 /run/wl
13-COPY firefox/user.js /root/firefox/user.js
14-COPY firefox/userChrome.css /root/firefox/chrome/userChrome.css
15-COPY foot.ini /root/.config/foot/foot.ini
16-COPY btop.conf /root/.config/btop/btop.conf
17-COPY btop-vesper.theme /root/.config/btop/themes/vesper.theme
18-COPY demo-entry demo-hint-label /usr/local/bin/
19-# One config for both users: the compositor (root) reads it, Mousepad (demo) edits it.
10+RUN useradd -m -u 1000 demo \
11+ && mkdir -p /home/demo/firefox /home/demo/.config/btop /run/wl \
12+ && chmod 0700 /run/wl
13+COPY firefox/user.js /home/demo/firefox/user.js
14+COPY firefox/userChrome.css /home/demo/firefox/chrome/userChrome.css
15+COPY foot.ini /home/demo/.config/foot/foot.ini
16+COPY btop.conf /home/demo/.config/btop/btop.conf
17+COPY btop-vesper.theme /home/demo/.config/btop/themes/vesper.theme
18+COPY site /home/demo/site
2019 COPY config /home/demo/config
21-COPY mousepad.keyfile /home/demo/.config/glib-2.0/settings/keyfile
22-RUN chown -R demo:demo /home/demo
23-ENV HOME=/root XDG_RUNTIME_DIR=/run/wl WAYLAND_DISPLAY=wayland-0 \
20+RUN chown -R demo:demo /home/demo /run/wl
21+ENV HOME=/home/demo XDG_RUNTIME_DIR=/run/wl WAYLAND_DISPLAY=wayland-0 \
2422 GTK_THEME=Adwaita:dark ADW_DEBUG_COLOR_SCHEME=prefer-dark \
25- DBUS_SESSION_BUS_ADDRESS=unix:path=/run/wl/bus \
2623 MOZ_ENABLE_WAYLAND=1 LIBGL_ALWAYS_SOFTWARE=1 COLORTERM=truecolor
27-# LIBGL_ALWAYS_SOFTWARE applies to the apps' OpenGL, not to the compositor (Vulkan).
28-WORKDIR /root
29-# Everything runs as the container's root, which rootless Docker maps to the
30-# host user, so the bind-mounted work directory stays owned by the host user.
31-# The compositor (bind-mounted at /usr/local/bin/neferwl) and the apps share one
32-# session bus, which AT-SPI needs. The compositor renders on the host GPU through
33-# /dev/dri: vulkan-radeon is its Mesa driver here (swap in vulkan-intel for an
34-# Intel GPU). Software Vulkan (lvp) cannot export the sync files hint scans need.
35-ENTRYPOINT ["/usr/local/bin/demo-entry"]
24+WORKDIR /home/demo
25+# The recorder grants demo access to only the disposable headless socket.
26+CMD ["sleep", "infinity"]
@@ -4,32 +4,23 @@ RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
4 pacman -Sy --noconfirm archlinux-keyring \4 pacman -Sy --noconfirm archlinux-keyring \
5 && pacman -Syu --noconfirm --needed \5 && pacman -Syu --noconfirm --needed \
6 firefox foot btop fastfetch eza mousepad \6 firefox foot btop fastfetch eza mousepad \
7- ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-radeon \7+ ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-swrast
8- tesseract tesseract-data-eng at-spi2-core python
9 # Arch's btop carries file capabilities that rootless Docker cannot grant.8 # Arch's btop carries file capabilities that rootless Docker cannot grant.
10 RUN install -m 0755 /usr/bin/btop /usr/local/bin/btop9 RUN install -m 0755 /usr/bin/btop /usr/local/bin/btop
11-# Mousepad warns about running as root, so it runs as demo (uid 1000).10+RUN useradd -m -u 1000 demo \
12-RUN useradd -m -u 1000 demo && mkdir -p /root/firefox /root/.config/btop /run/wl && chmod 0755 /run/wl11+ && mkdir -p /home/demo/firefox /home/demo/.config/btop /run/wl \
13-COPY firefox/user.js /root/firefox/user.js12+ && chmod 0700 /run/wl
14-COPY firefox/userChrome.css /root/firefox/chrome/userChrome.css13+COPY firefox/user.js /home/demo/firefox/user.js
15-COPY foot.ini /root/.config/foot/foot.ini14+COPY firefox/userChrome.css /home/demo/firefox/chrome/userChrome.css
16-COPY btop.conf /root/.config/btop/btop.conf15+COPY foot.ini /home/demo/.config/foot/foot.ini
17-COPY btop-vesper.theme /root/.config/btop/themes/vesper.theme16+COPY btop.conf /home/demo/.config/btop/btop.conf
18-COPY demo-entry demo-hint-label /usr/local/bin/17+COPY btop-vesper.theme /home/demo/.config/btop/themes/vesper.theme
19-# One config for both users: the compositor (root) reads it, Mousepad (demo) edits it.18+COPY site /home/demo/site
20 COPY config /home/demo/config19 COPY config /home/demo/config
21-COPY mousepad.keyfile /home/demo/.config/glib-2.0/settings/keyfile20+RUN chown -R demo:demo /home/demo /run/wl
22-RUN chown -R demo:demo /home/demo21+ENV HOME=/home/demo XDG_RUNTIME_DIR=/run/wl WAYLAND_DISPLAY=wayland-0 \
23-ENV HOME=/root XDG_RUNTIME_DIR=/run/wl WAYLAND_DISPLAY=wayland-0 \
24 GTK_THEME=Adwaita:dark ADW_DEBUG_COLOR_SCHEME=prefer-dark \22 GTK_THEME=Adwaita:dark ADW_DEBUG_COLOR_SCHEME=prefer-dark \
25- DBUS_SESSION_BUS_ADDRESS=unix:path=/run/wl/bus \
26 MOZ_ENABLE_WAYLAND=1 LIBGL_ALWAYS_SOFTWARE=1 COLORTERM=truecolor23 MOZ_ENABLE_WAYLAND=1 LIBGL_ALWAYS_SOFTWARE=1 COLORTERM=truecolor
27-# LIBGL_ALWAYS_SOFTWARE applies to the apps' OpenGL, not to the compositor (Vulkan).24+WORKDIR /home/demo
28-WORKDIR /root25+# The recorder grants demo access to only the disposable headless socket.
29-# Everything runs as the container's root, which rootless Docker maps to the26+CMD ["sleep", "infinity"]
30-# host user, so the bind-mounted work directory stays owned by the host user.
31-# The compositor (bind-mounted at /usr/local/bin/neferwl) and the apps share one
32-# session bus, which AT-SPI needs. The compositor renders on the host GPU through
33-# /dev/dri: vulkan-radeon is its Mesa driver here (swap in vulkan-intel for an
34-# Intel GPU). Software Vulkan (lvp) cannot export the sync files hint scans need.
35-ENTRYPOINT ["/usr/local/bin/demo-entry"]
M tools/demo/config +0 -5
@@ -11,17 +11,12 @@ terminal.auto-open = off
1111 stash.spawn-terminal = off
1212 stash.capture = off
1313 keyboard.layout = us
14-hint.enable = on
15-hint.targets = links
1614
1715 # Columns fill a band; extra windows flow below.
1816 # Super+Up / Down travel between bands.
1917 # Super+Shift+s sets a window aside.
2018 # Super+s brings your stash into view.
2119 # Super+o opens the overview.
22-# Super+Shift+f makes a window fullscreen.
23-# Super+g labels the links; type one to click it.
24-bind.cmd+g = hint
2520 bind.cmd+code:3 = focus-workspace 2
2621 bind.cmd+code:2 = focus-workspace 1
2722
@@ -11,17 +11,12 @@ terminal.auto-open = off
11 stash.spawn-terminal = off11 stash.spawn-terminal = off
12 stash.capture = off12 stash.capture = off
13 keyboard.layout = us13 keyboard.layout = us
14-hint.enable = on
15-hint.targets = links
16 14
17 # Columns fill a band; extra windows flow below.15 # Columns fill a band; extra windows flow below.
18 # Super+Up / Down travel between bands.16 # Super+Up / Down travel between bands.
19 # Super+Shift+s sets a window aside.17 # Super+Shift+s sets a window aside.
20 # Super+s brings your stash into view.18 # Super+s brings your stash into view.
21 # Super+o opens the overview.19 # Super+o opens the overview.
22-# Super+Shift+f makes a window fullscreen.
23-# Super+g labels the links; type one to click it.
24-bind.cmd+g = hint
25 bind.cmd+code:3 = focus-workspace 220 bind.cmd+code:3 = focus-workspace 2
26 bind.cmd+code:2 = focus-workspace 121 bind.cmd+code:2 = focus-workspace 1
27 22
D tools/demo/demo-entry +0 -9
@@ -1,9 +0,0 @@
1-#!/bin/sh
2-# Starts the session bus shared by the compositor and the apps, turns
3-# accessibility on before any app starts, then runs the compositor as the
4-# container's main process: the container exits with it.
5-set -eu
6-dbus-daemon --session --fork --address="$DBUS_SESSION_BUS_ADDRESS"
7-dbus-send --session --print-reply --dest=org.a11y.Bus /org/a11y/bus \
8- org.freedesktop.DBus.Properties.Set string:org.a11y.Status string:IsEnabled variant:boolean:true >/dev/null
9-exec /usr/local/bin/neferwl "$@"
@@ -1,9 +0,0 @@
1-#!/bin/sh
2-# Starts the session bus shared by the compositor and the apps, turns
3-# accessibility on before any app starts, then runs the compositor as the
4-# container's main process: the container exits with it.
5-set -eu
6-dbus-daemon --session --fork --address="$DBUS_SESSION_BUS_ADDRESS"
7-dbus-send --session --print-reply --dest=org.a11y.Bus /org/a11y/bus \
8- org.freedesktop.DBus.Properties.Set string:org.a11y.Status string:IsEnabled variant:boolean:true >/dev/null
9-exec /usr/local/bin/neferwl "$@"
D tools/demo/demo-hint-label +0 -76
@@ -1,76 +0,0 @@
1-#!/usr/bin/env python3
2-"""Work out the hint label of a link, to type it the way a user reads it.
3-
4- demo-hint-label ocr
5- Reads the frame on screen now (the compositor's latest screenshot) with
6- tesseract and keeps the words and their boxes in /tmp/hint-words.json.
7- Run it while the page is still, before the hint bind, so that the
8- lookup after the bind needs no OCR.
9-
10- demo-hint-label label <since-epoch> <word>...
11- Waits for the hint dump (NEFERWL_HINT_DEBUG=1) of the scan started after
12- <since-epoch>, finds the link target that shows all the words and prints
13- its label. Labels follow internal/core/hint.go: the targets whose centre
14- is on the output, sorted by top then left edge, one letter each from the
15- home-row alphabet, two letters past 26 targets.
16-"""
17-import csv, glob, io, json, os, re, shutil, subprocess, sys, time
18-
19-ALPHABET = "fjdkslaghrueiwoqptyvbcnxmz"
20-STATE = os.environ.get("XDG_STATE_HOME", "/work/state")
21-DUMPS = os.path.join(STATE, "neferwl", "hints")
22-SHOT = "/work/shots/latest.png"
23-WORDS = "/tmp/hint-words.json"
24-
25-
26-def ocr():
27- shutil.copyfile(SHOT, "/tmp/hint-ocr.png")
28- out = subprocess.run(["tesseract", "/tmp/hint-ocr.png", "stdout", "--psm", "11", "tsv"],
29- capture_output=True, text=True, check=True).stdout
30- words = []
31- for row in csv.DictReader(io.StringIO(out), delimiter="\t", quoting=csv.QUOTE_NONE):
32- text = re.sub(r"\W", "", row["text"] or "").lower()
33- if text and float(row["conf"]) > 30:
34- x, y, w, h = (int(row[k]) for k in ("left", "top", "width", "height"))
35- words.append([text, x + w / 2, y + h / 2])
36- with open(WORDS, "w") as f:
37- json.dump(words, f)
38-
39-
40-def newest_dump(since):
41- deadline = time.time() + 15
42- while time.time() < deadline:
43- for path in sorted(glob.glob(os.path.join(DUMPS, "*.json")), reverse=True):
44- if os.path.getmtime(path) >= since:
45- return path
46- time.sleep(0.05)
47- sys.exit("no hint scan dump after the bind")
48-
49-
50-def label(since, wanted):
51- with open(newest_dump(since)) as f:
52- meta = json.load(f)
53- with open(WORDS) as f:
54- words = json.load(f)
55- # Core keeps the targets whose centre is on the output, then sorts them.
56- width, height = meta["width"], meta["height"]
57- targets = [t for t in meta["targets"]
58- if 0 <= (t["rect"][0] + t["rect"][2]) / 2 < width and 0 <= (t["rect"][1] + t["rect"][3]) / 2 < height]
59- targets.sort(key=lambda t: (t["rect"][1], t["rect"][0]))
60- for i, t in enumerate(targets):
61- x0, y0, x1, y1 = t["rect"]
62- inside = {w for w, cx, cy in words if x0 <= cx < x1 and y0 <= cy < y1}
63- if t["kind"] == "link" and set(wanted) <= inside:
64- n = len(ALPHABET)
65- print("link %d of %d shows %s" % (i + 1, len(targets), " ".join(wanted)), file=sys.stderr)
66- print(ALPHABET[i] if len(targets) <= n else ALPHABET[i // n] + ALPHABET[i % n])
67- return
68- sys.exit("no link shows %s (%d targets, %d words read)" % (" ".join(wanted), len(targets), len(words)))
69-
70-
71-if sys.argv[1:2] == ["ocr"]:
72- ocr()
73-elif sys.argv[1:2] == ["label"] and len(sys.argv) > 3:
74- label(float(sys.argv[2]), [w.lower() for w in sys.argv[3:]])
75-else:
76- sys.exit(__doc__)
@@ -1,76 +0,0 @@
1-#!/usr/bin/env python3
2-"""Work out the hint label of a link, to type it the way a user reads it.
3-
4- demo-hint-label ocr
5- Reads the frame on screen now (the compositor's latest screenshot) with
6- tesseract and keeps the words and their boxes in /tmp/hint-words.json.
7- Run it while the page is still, before the hint bind, so that the
8- lookup after the bind needs no OCR.
9-
10- demo-hint-label label <since-epoch> <word>...
11- Waits for the hint dump (NEFERWL_HINT_DEBUG=1) of the scan started after
12- <since-epoch>, finds the link target that shows all the words and prints
13- its label. Labels follow internal/core/hint.go: the targets whose centre
14- is on the output, sorted by top then left edge, one letter each from the
15- home-row alphabet, two letters past 26 targets.
16-"""
17-import csv, glob, io, json, os, re, shutil, subprocess, sys, time
18-
19-ALPHABET = "fjdkslaghrueiwoqptyvbcnxmz"
20-STATE = os.environ.get("XDG_STATE_HOME", "/work/state")
21-DUMPS = os.path.join(STATE, "neferwl", "hints")
22-SHOT = "/work/shots/latest.png"
23-WORDS = "/tmp/hint-words.json"
24-
25-
26-def ocr():
27- shutil.copyfile(SHOT, "/tmp/hint-ocr.png")
28- out = subprocess.run(["tesseract", "/tmp/hint-ocr.png", "stdout", "--psm", "11", "tsv"],
29- capture_output=True, text=True, check=True).stdout
30- words = []
31- for row in csv.DictReader(io.StringIO(out), delimiter="\t", quoting=csv.QUOTE_NONE):
32- text = re.sub(r"\W", "", row["text"] or "").lower()
33- if text and float(row["conf"]) > 30:
34- x, y, w, h = (int(row[k]) for k in ("left", "top", "width", "height"))
35- words.append([text, x + w / 2, y + h / 2])
36- with open(WORDS, "w") as f:
37- json.dump(words, f)
38-
39-
40-def newest_dump(since):
41- deadline = time.time() + 15
42- while time.time() < deadline:
43- for path in sorted(glob.glob(os.path.join(DUMPS, "*.json")), reverse=True):
44- if os.path.getmtime(path) >= since:
45- return path
46- time.sleep(0.05)
47- sys.exit("no hint scan dump after the bind")
48-
49-
50-def label(since, wanted):
51- with open(newest_dump(since)) as f:
52- meta = json.load(f)
53- with open(WORDS) as f:
54- words = json.load(f)
55- # Core keeps the targets whose centre is on the output, then sorts them.
56- width, height = meta["width"], meta["height"]
57- targets = [t for t in meta["targets"]
58- if 0 <= (t["rect"][0] + t["rect"][2]) / 2 < width and 0 <= (t["rect"][1] + t["rect"][3]) / 2 < height]
59- targets.sort(key=lambda t: (t["rect"][1], t["rect"][0]))
60- for i, t in enumerate(targets):
61- x0, y0, x1, y1 = t["rect"]
62- inside = {w for w, cx, cy in words if x0 <= cx < x1 and y0 <= cy < y1}
63- if t["kind"] == "link" and set(wanted) <= inside:
64- n = len(ALPHABET)
65- print("link %d of %d shows %s" % (i + 1, len(targets), " ".join(wanted)), file=sys.stderr)
66- print(ALPHABET[i] if len(targets) <= n else ALPHABET[i // n] + ALPHABET[i % n])
67- return
68- sys.exit("no link shows %s (%d targets, %d words read)" % (" ".join(wanted), len(targets), len(words)))
69-
70-
71-if sys.argv[1:2] == ["ocr"]:
72- ocr()
73-elif sys.argv[1:2] == ["label"] and len(sys.argv) > 3:
74- label(float(sys.argv[2]), [w.lower() for w in sys.argv[3:]])
75-else:
76- sys.exit(__doc__)
M tools/demo/input.txt +1 -32
@@ -1,17 +1,8 @@
11 move 1918 1078
2-sleep 5s
3-# Unseen warm-up while Firefox sits beside the terminal: a hint bind cancelled
4-# at once still runs its scan, without drawing labels. It connects to the
5-# accessibility bus early; more of them below (each right after a key press, in
6-# an existing pause) let Firefox expose its page content by the end.
7-key Super+g
8-key Escape
9-sleep 7s
2+sleep 12s
103 key Super+Up
114 sleep 1500ms
125 key Super+Down
13-key Super+g
14-key Escape
156 sleep 1500ms
167 key Super+Shift+s
178 sleep 500ms
@@ -20,8 +11,6 @@ sleep 1500ms
2011 key Super+2
2112 key Super+Shift+1
2213 key Super+1
23-key Super+g
24-key Escape
2514 sleep 3s
2615 key Super+Shift+s
2716 sleep 500ms
@@ -29,12 +18,8 @@ key Super+s
2918 sleep 1500ms
3019 key Super+s
3120 key Super+Left
32-key Super+g
33-key Escape
3421 sleep 2s
3522 key Super+Right
36-key Super+g
37-key Escape
3823 sleep 2s
3924 key Super+Left
4025 sleep 1s
@@ -55,20 +40,4 @@ sleep 1500ms
5540 key Up
5641 sleep 1s
5742 key Return
58-sleep 700ms
59-# Back on workspace 1: focus Firefox and fullscreen it. Once the page is still,
60-# read it, then hint mode starts and the label of the Wikipedia result
61-# ("Wayland (protocol) - Wikipedia") is typed.
62-key Super+Right
63-sleep 600ms
64-key Super+Shift+f
65-sleep 900ms
66-hint-read
67-sleep 200ms
68-key Super+g
69-sleep 1500ms
70-hint-click wikipedia protocol
71-# Firefox loads the page; the compositor writes no frame while it is still, so
72-# a pointer move marks the end of the hold.
7343 sleep 4s
74-move 1500 900
@@ -1,17 +1,8 @@
1 move 1918 10781 move 1918 1078
2-sleep 5s2+sleep 12s
3-# Unseen warm-up while Firefox sits beside the terminal: a hint bind cancelled
4-# at once still runs its scan, without drawing labels. It connects to the
5-# accessibility bus early; more of them below (each right after a key press, in
6-# an existing pause) let Firefox expose its page content by the end.
7-key Super+g
8-key Escape
9-sleep 7s
10 key Super+Up3 key Super+Up
11 sleep 1500ms4 sleep 1500ms
12 key Super+Down5 key Super+Down
13-key Super+g
14-key Escape
15 sleep 1500ms6 sleep 1500ms
16 key Super+Shift+s7 key Super+Shift+s
17 sleep 500ms8 sleep 500ms
@@ -20,8 +11,6 @@ sleep 1500ms
20 key Super+211 key Super+2
21 key Super+Shift+112 key Super+Shift+1
22 key Super+113 key Super+1
23-key Super+g
24-key Escape
25 sleep 3s14 sleep 3s
26 key Super+Shift+s15 key Super+Shift+s
27 sleep 500ms16 sleep 500ms
@@ -29,12 +18,8 @@ key Super+s
29 sleep 1500ms18 sleep 1500ms
30 key Super+s19 key Super+s
31 key Super+Left20 key Super+Left
32-key Super+g
33-key Escape
34 sleep 2s21 sleep 2s
35 key Super+Right22 key Super+Right
36-key Super+g
37-key Escape
38 sleep 2s23 sleep 2s
39 key Super+Left24 key Super+Left
40 sleep 1s25 sleep 1s
@@ -55,20 +40,4 @@ sleep 1500ms
55 key Up40 key Up
56 sleep 1s41 sleep 1s
57 key Return42 key Return
58-sleep 700ms
59-# Back on workspace 1: focus Firefox and fullscreen it. Once the page is still,
60-# read it, then hint mode starts and the label of the Wikipedia result
61-# ("Wayland (protocol) - Wikipedia") is typed.
62-key Super+Right
63-sleep 600ms
64-key Super+Shift+f
65-sleep 900ms
66-hint-read
67-sleep 200ms
68-key Super+g
69-sleep 1500ms
70-hint-click wikipedia protocol
71-# Firefox loads the page; the compositor writes no frame while it is still, so
72-# a pointer move marks the end of the hold.
73 sleep 4s43 sleep 4s
74-move 1500 900
D tools/demo/mousepad.keyfile +0 -6
@@ -1,6 +0,0 @@
1-[org/xfce/mousepad/preferences/view]
2-use-default-monospace-font=false
3-font-name='JetBrainsMono Nerd Font 15'
4-
5-[org/xfce/mousepad/preferences/window]
6-menubar-visible=false
@@ -1,6 +0,0 @@
1-[org/xfce/mousepad/preferences/view]
2-use-default-monospace-font=false
3-font-name='JetBrainsMono Nerd Font 15'
4-
5-[org/xfce/mousepad/preferences/window]
6-menubar-visible=false
M tools/demo/record.sh +42 -107
@@ -1,13 +1,5 @@
11 #!/usr/bin/env bash
2-# Requires Go 1.27, rootless Docker, bash, ffmpeg, network access and an AMD GPU.
3-# Run from any directory. The image holds the compositor's runtime (tesseract,
4-# AT-SPI, Mesa's radeon Vulkan driver) and the apps; the host builds the binary.
5-# The compositor renders on the host GPU through /dev/dri, and hint scans need
6-# the sync files only a hardware driver exports. The image only carries
7-# vulkan-radeon, so another GPU needs its Mesa driver in tools/demo/Dockerfile
8-# and the vendor check below changed.
9-# A recording only succeeds if the compositor stops on the quit key that ends
10-# tools/demo/input.txt: hitting --timeout first means a truncated video.
2+# Requires Go 1.27, rootless Docker, bash and ffmpeg. Run from any directory.
113 # KEEP_CAPTURE=1 preserves temporary captures on success; failures always clean up.
124 set -euo pipefail
135 root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
@@ -16,17 +8,8 @@ for command in docker go ffmpeg; do
168 command -v "$command" >/dev/null || { echo "Missing required command: $command" >&2; exit 1; }
179 done
1810 docker info >/dev/null 2>&1 || { echo 'Docker daemon unavailable; start rootless Docker first.' >&2; exit 1; }
19-amd=''
20-for vendor in /sys/class/drm/renderD*/device/vendor; do
21- if [[ -r $vendor && $(<"$vendor") == 0x1002 ]]; then amd=yes; fi
22-done
23-[[ -n $amd ]] || {
24- echo 'No AMD GPU render node (PCI vendor 0x1002) found: the demo image only carries the radeon Vulkan driver.' >&2
25- echo 'See the header of this script to use another GPU.' >&2
26- exit 1
27-}
2811 work=$(mktemp -d "${TMPDIR:-/tmp}/neferwl-demo.XXXXXX")
29-launcher='' feeder='' success=''
12+compositor='' launcher='' feeder='' success=''
3013 container=neferwl-demo-$$
3114 cleanup() {
3215 local status=$?
@@ -36,8 +19,9 @@ cleanup() {
3619 for pid in "$launcher" "$feeder"; do
3720 if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi
3821 done
22+ if [[ -n $compositor ]]; then kill "$compositor" 2>/dev/null || true; fi
3923 docker rm -f "$container" >/dev/null 2>&1 || true
40- for pid in "$launcher" "$feeder"; do
24+ for pid in "$launcher" "$feeder" "$compositor"; do
4125 if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi
4226 done
4327 exec 3>&-
@@ -50,129 +34,80 @@ trap cleanup EXIT
5034 trap 'exit 130' INT
5135 trap 'exit 143' TERM
5236 printf 'Capture directory: %s\n' "$work"
53-mkdir -p "$work/shots" "$work/state" docs/media
37+mkdir -p "$work/shots" docs/media
5438 docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo
5539 CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl
56-log=$work/state/neferwl/runs/headless/latest.log
40+state=$work/state
41+log=$state/neferwl/runs/headless/latest.log
5742 mkfifo "$work/input"
5843 # Hold both ends open so the FIFO cannot block compositor startup or produce
59-# premature EOF. No input is fed until the first app is ready.
44+# premature EOF. No input is fed until Docker and the first app are ready.
6045 exec 3<>"$work/input"
61-# The compositor is the container's main process, so the container ends with
62-# it. Root in a rootless container is the host user: the bind-mounted work
63-# directory (binary, FIFO, frames, log, hint dumps) stays owned by the host user.
64-# The GPU render node gives the compositor the sync files that hint scans need.
65-docker run -d --name "$container" --hostname neferwl-demo --device /dev/dri \
66- -e XDG_STATE_HOME=/work/state -e NEFERWL_HINT_DEBUG=1 \
67- -v "$work:/work" -v "$work/neferwl:/usr/local/bin/neferwl:ro" neferwl-demo \
68- --backend=headless --size 1920x1080 --config /home/demo/config --no-xwayland \
69- --screenshot /work/shots --input /work/input --timeout 75s >/dev/null
70-display=''
71-deadline=$((SECONDS + 30))
72-while ((SECONDS < deadline)); do
73- [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null) == true ]] ||
74- { echo "Compositor exited before readiness; see $log" >&2; docker logs "$container" >&2 || true; exit 1; }
46+env -u NEFERWL_STATE -u NEFERWL_SLOT -u WAYLAND_DISPLAY XDG_STATE_HOME="$state" "$work/neferwl" \
47+ --backend=headless --size 1920x1080 --config tools/demo/config \
48+ --no-xwayland --screenshot "$work/shots" --input - --timeout 75s <"$work/input" &
49+compositor=$!
50+socket=''
51+for ((attempt = 0; attempt < 150; attempt++)); do
52+ kill -0 "$compositor" 2>/dev/null || { echo "Compositor exited before readiness; see $log" >&2; exit 1; }
7553 if [[ -f $log ]]; then
7654 display=$(sed -n '/"message":"listening"/s/.*"WAYLAND_DISPLAY":"\([^" ]*\)".*/\1/p' "$log" | tail -1)
77- [[ -n $display ]] && break
55+ if [[ -n $display && -S $XDG_RUNTIME_DIR/${display##*/} ]]; then
56+ socket=$XDG_RUNTIME_DIR/${display##*/}
57+ break
58+ fi
7859 fi
7960 sleep 0.1
8061 done
81-[[ -n $display ]] || { echo "Timed out after 30s waiting for the compositor to listen; see $log" >&2; exit 1; }
82-# Mousepad runs as another user (see launch_apps) and needs the socket.
83-docker exec "$container" chmod 666 "/run/wl/${display##*/}"
84-app() { docker exec -d -e "WAYLAND_DISPLAY=${display##*/}" "$container" "$@"; }
62+[[ -n $socket ]] || { echo "Timed out after 15s waiting for compositor socket; see $log" >&2; exit 1; }
63+# Container root maps to the host uid under rootless Docker. Grant demo access
64+# to this disposable socket only; Firefox rejects root with a demo-owned HOME.
65+docker run -d --name "$container" --hostname neferwl-demo --network none \
66+ -v "$socket:/run/wl/wayland-0" neferwl-demo
67+docker exec "$container" chmod 666 /run/wl/wayland-0
8568 at() { local delay=$(($1 - SECONDS)); if ((delay > 0)); then sleep "$delay"; fi; }
8669 launch_apps() {
8770 # This shell timeline shares t=0 with input.txt. Only keys live in input.txt;
88- # app launches cannot be invoked by compositor input scripts.
71+ # Docker launches cannot be invoked by compositor input scripts.
8972 at 3
90- app firefox --no-remote --profile /root/firefox 'https://duckduckgo.com/?q=wayland+compositor'
73+ docker exec -d --user demo "$container" dbus-run-session -- firefox --no-remote \
74+ --profile /home/demo/firefox file:///home/demo/site/index.html
9175 # btop's first per-core sample is bogus in this container. Let it collect
9276 # real deltas on workspace 2; input.txt brings it into view at t=17.
9377 at 5
94- app foot --app-id demo-monitor --title 'Monitor / btop' /usr/local/bin/btop
78+ docker exec -d --user demo "$container" foot --app-id demo-monitor \
79+ --title 'Monitor / btop' /usr/local/bin/btop
9580 at 9
96- # Mousepad runs as demo (it warns about root), with a file-backed
97- # GSettings and no session bus (it needs the root-owned bus otherwise).
98- app setpriv --reuid=1000 --regid=1000 --clear-groups env HOME=/home/demo \
99- GSETTINGS_BACKEND=keyfile DBUS_SESSION_BUS_ADDRESS=disabled: \
100- mousepad --disable-server /home/demo/config
81+ docker exec -d --user demo "$container" dbus-run-session -- bash -c \
82+ 'gsettings set org.xfce.mousepad.preferences.view use-default-monospace-font false; gsettings set org.xfce.mousepad.preferences.view font-name "JetBrainsMono Nerd Font 15"; gsettings set org.xfce.mousepad.preferences.window menubar-visible false; mousepad /home/demo/config'
10183 at 30
102- app foot --title 'Fonts / workspace 2' bash -c \
84+ docker exec -d --user demo "$container" foot --title 'Fonts / workspace 2' bash -c \
10385 'eza --tree --level=2 --icons --color=always /usr/share/fonts | head -30; exec sleep infinity'
10486 }
105-feed() {
106- # Forwards input.txt to the compositor in real time. `sleep` lines are
107- # served here (not forwarded), so host-side steps can sit between two
108- # compositor steps:
109- # hint-read reads the still page with OCR, in the background
110- # hint-click <word>... types the label of the link that shows all the
111- # words, from the dump of the scan the last
112- # `hint` bind started (demo-hint-label reproduces
113- # the labelling of internal/core/hint.go)
114- local line cmd d mark label reader=''
115- while IFS= read -r line || [[ -n $line ]]; do
116- cmd=${line%% *}
117- case $cmd in
118- '' | '#'*) ;;
119- sleep)
120- d=${line#sleep }
121- if [[ $d == *ms ]]; then d=$(printf '%d.%03d' $((${d%ms} / 1000)) $((${d%ms} % 1000))); fi
122- sleep "$d" ;;
123- hint-read)
124- docker exec "$container" demo-hint-label ocr &
125- reader=$! ;;
126- hint-click)
127- [[ -n $reader ]] && wait "$reader"
128- # shellcheck disable=SC2086
129- label=$(docker exec "$container" demo-hint-label label "$mark" ${line#hint-click }) ||
130- { echo "No hint label for: ${line#hint-click }" >&2; docker kill "$container" >/dev/null; return 1; }
131- echo "Hint label for ${line#hint-click }: $label" >&2
132- printf 'type %s\n' "$label" ;;
133- *)
134- [[ $line == 'key Super+g' ]] && mark=$(date +%s.%N)
135- printf '%s\n' "$line" ;;
136- esac
137- done <tools/demo/input.txt
138- printf 'key Ctrl+Alt+BackSpace\n'
139-}
140-app foot --title 'System / NeferWL' bash -c \
141- 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity'
87+docker exec -d --user demo "$container" foot --title 'System / NeferWL' bash -c \
88+ 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity'
14289 # The first app is launched before t=0. FIFO input and shell launches now
14390 # share this epoch, independent of compositor/image/container startup time.
14491 SECONDS=0
14592 set -m
146-feed >&3 &
93+{ cat tools/demo/input.txt; printf '\nkey Ctrl+Alt+BackSpace\n'; } >&3 &
14794 feeder=$!
14895 launch_apps &
14996 launcher=$!
15097 set +m
151-# The compositor ends with the quit key at the end of input.txt. Hitting
152-# --timeout first (a stalled timeline) leaves a truncated recording: that fails.
153-status=$(docker wait "$container")
154-if grep -q '"reason":"timeout"' "$log"; then
155- echo 'The compositor hit --timeout before the quit key: the recording is truncated.' >&2
156- exit 1
98+# --timeout exits nonzero; accept only its explicit timeout log entry.
99+if ! wait "$compositor"; then
100+ grep -q '"reason":"timeout"' "$log" || exit 1
157101 fi
158-[[ $status == 0 ]] || { echo "Compositor failed (exit $status); see $log" >&2; docker logs "$container" >&2 || true; exit 1; }
159-wait "$feeder" || { echo 'Scripted input failed.' >&2; exit 1; }
102+compositor=
103+wait "$feeder"
160104 feeder=
161105 wait "$launcher"
162106 launcher=
163-# Firefox's links come from the accessibility tree; OCR (tesseract) must still load.
164-if grep -qE 'OCR unavailable|English OCR data missing' "$log"; then echo 'tesseract is missing in the image.' >&2; exit 1; fi
165-if grep -q 'capture needs sync file export' "$log"; then
166- echo 'Hint scans failed: the compositor is not on a hardware Vulkan driver (radeon); see the header of this script.' >&2
167- exit 1
168-fi
169-grep -qE '"atspi_targets":[1-9][0-9]*,.*"message":"hint scan"' "$log" ||
170- { echo 'No hint scan found accessibility targets:' >&2; grep -E '"component":"(hints|atspi)"' "$log" | cut -c1-300 >&2; exit 1; }
171-grep '"message":"hint scan"' "$log" | sed -E 's/.*"atspi_targets":([0-9]+).*"ocr_links":([0-9]+).*"targets":([0-9]+).*/hint scan: \1 accessibility + \2 OCR links = \3 targets/' | tail -3
172107 ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \
173108 -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \
174109 -c:v libx264 -crf 23 -movflags +faststart docs/media/demo.mp4 -y
175-ffmpeg -hide_banner -loglevel warning -ss 8 -i docs/media/demo.mp4 \
110+ffmpeg -hide_banner -loglevel warning -ss 7 -i docs/media/demo.mp4 \
176111 -frames:v 1 -update 1 docs/media/demo.png -y
177112 success=yes
178113 if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi
@@ -1,13 +1,5 @@
1 #!/usr/bin/env bash1 #!/usr/bin/env bash
2-# Requires Go 1.27, rootless Docker, bash, ffmpeg, network access and an AMD GPU.2+# Requires Go 1.27, rootless Docker, bash and ffmpeg. Run from any directory.
3-# Run from any directory. The image holds the compositor's runtime (tesseract,
4-# AT-SPI, Mesa's radeon Vulkan driver) and the apps; the host builds the binary.
5-# The compositor renders on the host GPU through /dev/dri, and hint scans need
6-# the sync files only a hardware driver exports. The image only carries
7-# vulkan-radeon, so another GPU needs its Mesa driver in tools/demo/Dockerfile
8-# and the vendor check below changed.
9-# A recording only succeeds if the compositor stops on the quit key that ends
10-# tools/demo/input.txt: hitting --timeout first means a truncated video.
11 # KEEP_CAPTURE=1 preserves temporary captures on success; failures always clean up.3 # KEEP_CAPTURE=1 preserves temporary captures on success; failures always clean up.
12 set -euo pipefail4 set -euo pipefail
13 root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)5 root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
@@ -16,17 +8,8 @@ for command in docker go ffmpeg; do
16 command -v "$command" >/dev/null || { echo "Missing required command: $command" >&2; exit 1; }8 command -v "$command" >/dev/null || { echo "Missing required command: $command" >&2; exit 1; }
17 done9 done
18 docker info >/dev/null 2>&1 || { echo 'Docker daemon unavailable; start rootless Docker first.' >&2; exit 1; }10 docker info >/dev/null 2>&1 || { echo 'Docker daemon unavailable; start rootless Docker first.' >&2; exit 1; }
19-amd=''
20-for vendor in /sys/class/drm/renderD*/device/vendor; do
21- if [[ -r $vendor && $(<"$vendor") == 0x1002 ]]; then amd=yes; fi
22-done
23-[[ -n $amd ]] || {
24- echo 'No AMD GPU render node (PCI vendor 0x1002) found: the demo image only carries the radeon Vulkan driver.' >&2
25- echo 'See the header of this script to use another GPU.' >&2
26- exit 1
27-}
28 work=$(mktemp -d "${TMPDIR:-/tmp}/neferwl-demo.XXXXXX")11 work=$(mktemp -d "${TMPDIR:-/tmp}/neferwl-demo.XXXXXX")
29-launcher='' feeder='' success=''12+compositor='' launcher='' feeder='' success=''
30 container=neferwl-demo-$$13 container=neferwl-demo-$$
31 cleanup() {14 cleanup() {
32 local status=$?15 local status=$?
@@ -36,8 +19,9 @@ cleanup() {
36 for pid in "$launcher" "$feeder"; do19 for pid in "$launcher" "$feeder"; do
37 if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi20 if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi
38 done21 done
22+ if [[ -n $compositor ]]; then kill "$compositor" 2>/dev/null || true; fi
39 docker rm -f "$container" >/dev/null 2>&1 || true23 docker rm -f "$container" >/dev/null 2>&1 || true
40- for pid in "$launcher" "$feeder"; do24+ for pid in "$launcher" "$feeder" "$compositor"; do
41 if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi25 if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi
42 done26 done
43 exec 3>&-27 exec 3>&-
@@ -50,129 +34,80 @@ trap cleanup EXIT
50 trap 'exit 130' INT34 trap 'exit 130' INT
51 trap 'exit 143' TERM35 trap 'exit 143' TERM
52 printf 'Capture directory: %s\n' "$work"36 printf 'Capture directory: %s\n' "$work"
53-mkdir -p "$work/shots" "$work/state" docs/media37+mkdir -p "$work/shots" docs/media
54 docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo38 docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo
55 CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl39 CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl
56-log=$work/state/neferwl/runs/headless/latest.log40+state=$work/state
41+log=$state/neferwl/runs/headless/latest.log
57 mkfifo "$work/input"42 mkfifo "$work/input"
58 # Hold both ends open so the FIFO cannot block compositor startup or produce43 # Hold both ends open so the FIFO cannot block compositor startup or produce
59-# premature EOF. No input is fed until the first app is ready.44+# premature EOF. No input is fed until Docker and the first app are ready.
60 exec 3<>"$work/input"45 exec 3<>"$work/input"
61-# The compositor is the container's main process, so the container ends with46+env -u NEFERWL_STATE -u NEFERWL_SLOT -u WAYLAND_DISPLAY XDG_STATE_HOME="$state" "$work/neferwl" \
62-# it. Root in a rootless container is the host user: the bind-mounted work47+ --backend=headless --size 1920x1080 --config tools/demo/config \
63-# directory (binary, FIFO, frames, log, hint dumps) stays owned by the host user.48+ --no-xwayland --screenshot "$work/shots" --input - --timeout 75s <"$work/input" &
64-# The GPU render node gives the compositor the sync files that hint scans need.49+compositor=$!
65-docker run -d --name "$container" --hostname neferwl-demo --device /dev/dri \50+socket=''
66- -e XDG_STATE_HOME=/work/state -e NEFERWL_HINT_DEBUG=1 \51+for ((attempt = 0; attempt < 150; attempt++)); do
67- -v "$work:/work" -v "$work/neferwl:/usr/local/bin/neferwl:ro" neferwl-demo \52+ kill -0 "$compositor" 2>/dev/null || { echo "Compositor exited before readiness; see $log" >&2; exit 1; }
68- --backend=headless --size 1920x1080 --config /home/demo/config --no-xwayland \
69- --screenshot /work/shots --input /work/input --timeout 75s >/dev/null
70-display=''
71-deadline=$((SECONDS + 30))
72-while ((SECONDS < deadline)); do
73- [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null) == true ]] ||
74- { echo "Compositor exited before readiness; see $log" >&2; docker logs "$container" >&2 || true; exit 1; }
75 if [[ -f $log ]]; then53 if [[ -f $log ]]; then
76 display=$(sed -n '/"message":"listening"/s/.*"WAYLAND_DISPLAY":"\([^" ]*\)".*/\1/p' "$log" | tail -1)54 display=$(sed -n '/"message":"listening"/s/.*"WAYLAND_DISPLAY":"\([^" ]*\)".*/\1/p' "$log" | tail -1)
77- [[ -n $display ]] && break55+ if [[ -n $display && -S $XDG_RUNTIME_DIR/${display##*/} ]]; then
56+ socket=$XDG_RUNTIME_DIR/${display##*/}
57+ break
58+ fi
78 fi59 fi
79 sleep 0.160 sleep 0.1
80 done61 done
81-[[ -n $display ]] || { echo "Timed out after 30s waiting for the compositor to listen; see $log" >&2; exit 1; }62+[[ -n $socket ]] || { echo "Timed out after 15s waiting for compositor socket; see $log" >&2; exit 1; }
82-# Mousepad runs as another user (see launch_apps) and needs the socket.63+# Container root maps to the host uid under rootless Docker. Grant demo access
83-docker exec "$container" chmod 666 "/run/wl/${display##*/}"64+# to this disposable socket only; Firefox rejects root with a demo-owned HOME.
84-app() { docker exec -d -e "WAYLAND_DISPLAY=${display##*/}" "$container" "$@"; }65+docker run -d --name "$container" --hostname neferwl-demo --network none \
66+ -v "$socket:/run/wl/wayland-0" neferwl-demo
67+docker exec "$container" chmod 666 /run/wl/wayland-0
85 at() { local delay=$(($1 - SECONDS)); if ((delay > 0)); then sleep "$delay"; fi; }68 at() { local delay=$(($1 - SECONDS)); if ((delay > 0)); then sleep "$delay"; fi; }
86 launch_apps() {69 launch_apps() {
87 # This shell timeline shares t=0 with input.txt. Only keys live in input.txt;70 # This shell timeline shares t=0 with input.txt. Only keys live in input.txt;
88- # app launches cannot be invoked by compositor input scripts.71+ # Docker launches cannot be invoked by compositor input scripts.
89 at 372 at 3
90- app firefox --no-remote --profile /root/firefox 'https://duckduckgo.com/?q=wayland+compositor'73+ docker exec -d --user demo "$container" dbus-run-session -- firefox --no-remote \
74+ --profile /home/demo/firefox file:///home/demo/site/index.html
91 # btop's first per-core sample is bogus in this container. Let it collect75 # btop's first per-core sample is bogus in this container. Let it collect
92 # real deltas on workspace 2; input.txt brings it into view at t=17.76 # real deltas on workspace 2; input.txt brings it into view at t=17.
93 at 577 at 5
94- app foot --app-id demo-monitor --title 'Monitor / btop' /usr/local/bin/btop78+ docker exec -d --user demo "$container" foot --app-id demo-monitor \
79+ --title 'Monitor / btop' /usr/local/bin/btop
95 at 980 at 9
96- # Mousepad runs as demo (it warns about root), with a file-backed81+ docker exec -d --user demo "$container" dbus-run-session -- bash -c \
97- # GSettings and no session bus (it needs the root-owned bus otherwise).82+ 'gsettings set org.xfce.mousepad.preferences.view use-default-monospace-font false; gsettings set org.xfce.mousepad.preferences.view font-name "JetBrainsMono Nerd Font 15"; gsettings set org.xfce.mousepad.preferences.window menubar-visible false; mousepad /home/demo/config'
98- app setpriv --reuid=1000 --regid=1000 --clear-groups env HOME=/home/demo \
99- GSETTINGS_BACKEND=keyfile DBUS_SESSION_BUS_ADDRESS=disabled: \
100- mousepad --disable-server /home/demo/config
101 at 3083 at 30
102- app foot --title 'Fonts / workspace 2' bash -c \84+ docker exec -d --user demo "$container" foot --title 'Fonts / workspace 2' bash -c \
103 'eza --tree --level=2 --icons --color=always /usr/share/fonts | head -30; exec sleep infinity'85 'eza --tree --level=2 --icons --color=always /usr/share/fonts | head -30; exec sleep infinity'
104 }86 }
105-feed() {87+docker exec -d --user demo "$container" foot --title 'System / NeferWL' bash -c \
106- # Forwards input.txt to the compositor in real time. `sleep` lines are88+ 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity'
107- # served here (not forwarded), so host-side steps can sit between two
108- # compositor steps:
109- # hint-read reads the still page with OCR, in the background
110- # hint-click <word>... types the label of the link that shows all the
111- # words, from the dump of the scan the last
112- # `hint` bind started (demo-hint-label reproduces
113- # the labelling of internal/core/hint.go)
114- local line cmd d mark label reader=''
115- while IFS= read -r line || [[ -n $line ]]; do
116- cmd=${line%% *}
117- case $cmd in
118- '' | '#'*) ;;
119- sleep)
120- d=${line#sleep }
121- if [[ $d == *ms ]]; then d=$(printf '%d.%03d' $((${d%ms} / 1000)) $((${d%ms} % 1000))); fi
122- sleep "$d" ;;
123- hint-read)
124- docker exec "$container" demo-hint-label ocr &
125- reader=$! ;;
126- hint-click)
127- [[ -n $reader ]] && wait "$reader"
128- # shellcheck disable=SC2086
129- label=$(docker exec "$container" demo-hint-label label "$mark" ${line#hint-click }) ||
130- { echo "No hint label for: ${line#hint-click }" >&2; docker kill "$container" >/dev/null; return 1; }
131- echo "Hint label for ${line#hint-click }: $label" >&2
132- printf 'type %s\n' "$label" ;;
133- *)
134- [[ $line == 'key Super+g' ]] && mark=$(date +%s.%N)
135- printf '%s\n' "$line" ;;
136- esac
137- done <tools/demo/input.txt
138- printf 'key Ctrl+Alt+BackSpace\n'
139-}
140-app foot --title 'System / NeferWL' bash -c \
141- 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity'
142 # The first app is launched before t=0. FIFO input and shell launches now89 # The first app is launched before t=0. FIFO input and shell launches now
143 # share this epoch, independent of compositor/image/container startup time.90 # share this epoch, independent of compositor/image/container startup time.
144 SECONDS=091 SECONDS=0
145 set -m92 set -m
146-feed >&3 &93+{ cat tools/demo/input.txt; printf '\nkey Ctrl+Alt+BackSpace\n'; } >&3 &
147 feeder=$!94 feeder=$!
148 launch_apps &95 launch_apps &
149 launcher=$!96 launcher=$!
150 set +m97 set +m
151-# The compositor ends with the quit key at the end of input.txt. Hitting98+# --timeout exits nonzero; accept only its explicit timeout log entry.
152-# --timeout first (a stalled timeline) leaves a truncated recording: that fails.99+if ! wait "$compositor"; then
153-status=$(docker wait "$container")100+ grep -q '"reason":"timeout"' "$log" || exit 1
154-if grep -q '"reason":"timeout"' "$log"; then
155- echo 'The compositor hit --timeout before the quit key: the recording is truncated.' >&2
156- exit 1
157 fi101 fi
158-[[ $status == 0 ]] || { echo "Compositor failed (exit $status); see $log" >&2; docker logs "$container" >&2 || true; exit 1; }102+compositor=
159-wait "$feeder" || { echo 'Scripted input failed.' >&2; exit 1; }103+wait "$feeder"
160 feeder=104 feeder=
161 wait "$launcher"105 wait "$launcher"
162 launcher=106 launcher=
163-# Firefox's links come from the accessibility tree; OCR (tesseract) must still load.
164-if grep -qE 'OCR unavailable|English OCR data missing' "$log"; then echo 'tesseract is missing in the image.' >&2; exit 1; fi
165-if grep -q 'capture needs sync file export' "$log"; then
166- echo 'Hint scans failed: the compositor is not on a hardware Vulkan driver (radeon); see the header of this script.' >&2
167- exit 1
168-fi
169-grep -qE '"atspi_targets":[1-9][0-9]*,.*"message":"hint scan"' "$log" ||
170- { echo 'No hint scan found accessibility targets:' >&2; grep -E '"component":"(hints|atspi)"' "$log" | cut -c1-300 >&2; exit 1; }
171-grep '"message":"hint scan"' "$log" | sed -E 's/.*"atspi_targets":([0-9]+).*"ocr_links":([0-9]+).*"targets":([0-9]+).*/hint scan: \1 accessibility + \2 OCR links = \3 targets/' | tail -3
172 ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \107 ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \
173 -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \108 -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \
174 -c:v libx264 -crf 23 -movflags +faststart docs/media/demo.mp4 -y109 -c:v libx264 -crf 23 -movflags +faststart docs/media/demo.mp4 -y
175-ffmpeg -hide_banner -loglevel warning -ss 8 -i docs/media/demo.mp4 \110+ffmpeg -hide_banner -loglevel warning -ss 7 -i docs/media/demo.mp4 \
176 -frames:v 1 -update 1 docs/media/demo.png -y111 -frames:v 1 -update 1 docs/media/demo.png -y
177 success=yes112 success=yes
178 if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi113 if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi
A tools/demo/site/index.html +13 -0
@@ -0,0 +1,13 @@
1+<!doctype html>
2+<html lang="en">
3+<meta charset="utf-8">
4+<meta name="color-scheme" content="dark">
5+<title>NeferWL — Space to think</title>
6+<style>
7+*{box-sizing:border-box}body{margin:0;background:#101010;color:#ffffff;font:18px/1.6 sans-serif}main{max-width:960px;margin:auto;padding:60px 38px}header{color:#ffc799;letter-spacing:.2em;font:700 18px monospace}small{color:#ffc799}h1{font-size:54px;line-height:1.12;letter-spacing:-2px;margin:40px 0 24px}p{color:#a0a0a0}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:36px 0}.card{padding:18px;background:#161616;border:1px solid #282828;border-radius:12px}.card b{color:#ffc799}.card p{font-size:15px;margin-bottom:0}pre{background:#161616;border-left:3px solid #ffc799;border-radius:6px;padding:24px;font:16px/1.8 monospace;color:#ffc799}footer{color:#a0a0a0;font:14px monospace}
8+</style>
9+<main><header>◈ NEFERWL</header><h1>Your desktop.<br>A little more focused.</h1><p>A Wayland compositor with room for your work.<br>Tile naturally, set things aside, see the whole picture.</p><div class="cards"><section class="card"><b>01 / Performance</b><p>Small, responsive.<br>Built in Go. Made to stay out of your way.</p></section><section class="card"><b>02 / Stash</b><p>Keep tools close.<br>Set aside a window. Bring it back in a keystroke.</p></section><section class="card"><b>03 / Overview</b><p>Find your focus.<br>Every workspace and stashed window, at a glance.</p></section></div><pre># A desktop that flows with you
10+layout.max-columns = 2
11+layout.overflow = cascade
12+animations = on</pre><footer>SUPER + ↑ / ↓ &nbsp; bands &nbsp; · &nbsp; SUPER + S &nbsp; stash &nbsp; · &nbsp; SUPER + O &nbsp; overview</footer></main>
13+</html>
@@ -0,0 +1,13 @@
1+<!doctype html>
2+<html lang="en">
3+<meta charset="utf-8">
4+<meta name="color-scheme" content="dark">
5+<title>NeferWL — Space to think</title>
6+<style>
7+*{box-sizing:border-box}body{margin:0;background:#101010;color:#ffffff;font:18px/1.6 sans-serif}main{max-width:960px;margin:auto;padding:60px 38px}header{color:#ffc799;letter-spacing:.2em;font:700 18px monospace}small{color:#ffc799}h1{font-size:54px;line-height:1.12;letter-spacing:-2px;margin:40px 0 24px}p{color:#a0a0a0}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:36px 0}.card{padding:18px;background:#161616;border:1px solid #282828;border-radius:12px}.card b{color:#ffc799}.card p{font-size:15px;margin-bottom:0}pre{background:#161616;border-left:3px solid #ffc799;border-radius:6px;padding:24px;font:16px/1.8 monospace;color:#ffc799}footer{color:#a0a0a0;font:14px monospace}
8+</style>
9+<main><header>◈ NEFERWL</header><h1>Your desktop.<br>A little more focused.</h1><p>A Wayland compositor with room for your work.<br>Tile naturally, set things aside, see the whole picture.</p><div class="cards"><section class="card"><b>01 / Performance</b><p>Small, responsive.<br>Built in Go. Made to stay out of your way.</p></section><section class="card"><b>02 / Stash</b><p>Keep tools close.<br>Set aside a window. Bring it back in a keystroke.</p></section><section class="card"><b>03 / Overview</b><p>Find your focus.<br>Every workspace and stashed window, at a glance.</p></section></div><pre># A desktop that flows with you
10+layout.max-columns = 2
11+layout.overflow = cascade
12+animations = on</pre><footer>SUPER + ↑ / ↓ &nbsp; bands &nbsp; · &nbsp; SUPER + S &nbsp; stash &nbsp; · &nbsp; SUPER + O &nbsp; overview</footer></main>
13+</html>

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close