detached 275b19c

feat(logging): add wayland-damage and render-damage debug categories

commit
275b19c94ef43ea5c1f96c2d5dd2baef8cca35e2
author
brice · · 2026-10-13 15:31 UTC
parent
e4693e6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +186 -11
M docs/performance.md +1 -1
@@ -52,7 +52,7 @@ GODEBUG=gctrace=1 neferwl --backend=drm
5252
5353 `make tty` serves pprof on `localhost:6060` (`make tty PPROF=` turns it off). It also serves `/debug/pprof/profile` and `/debug/pprof/trace`.
5454
55-Logs are in `$XDG_STATE_HOME/neferwl/runs/<backend>/` (default `~/.local/state`), with `latest.log` pointing at the newest run. Each backend keeps 20 runs. `--debug=all` leaves out the noisy categories `drm-flip`, `input-motion` and `input-keys`; name them to turn them on.
55+Logs are in `$XDG_STATE_HOME/neferwl/runs/<backend>/` (default `~/.local/state`), with `latest.log` pointing at the newest run. Each backend keeps 20 runs. `--debug=all` leaves out the noisy categories `drm-flip`, `input-motion`, `input-keys`, `wayland-damage` (one `client damage` entry per fresh client commit: window, buffer size, rect count, `full_buffer`, first 4 rects) and `render-damage` (one `frame damage` entry per rendered frame: `redrawn`, `draws`, `partial`); name them to turn them on.
5656
5757 ### Frame stats
5858
@@ -52,7 +52,7 @@ GODEBUG=gctrace=1 neferwl --backend=drm
52 52
53 `make tty` serves pprof on `localhost:6060` (`make tty PPROF=` turns it off). It also serves `/debug/pprof/profile` and `/debug/pprof/trace`.53 `make tty` serves pprof on `localhost:6060` (`make tty PPROF=` turns it off). It also serves `/debug/pprof/profile` and `/debug/pprof/trace`.
54 54
55-Logs are in `$XDG_STATE_HOME/neferwl/runs/<backend>/` (default `~/.local/state`), with `latest.log` pointing at the newest run. Each backend keeps 20 runs. `--debug=all` leaves out the noisy categories `drm-flip`, `input-motion` and `input-keys`; name them to turn them on.55+Logs are in `$XDG_STATE_HOME/neferwl/runs/<backend>/` (default `~/.local/state`), with `latest.log` pointing at the newest run. Each backend keeps 20 runs. `--debug=all` leaves out the noisy categories `drm-flip`, `input-motion`, `input-keys`, `wayland-damage` (one `client damage` entry per fresh client commit: window, buffer size, rect count, `full_buffer`, first 4 rects) and `render-damage` (one `frame damage` entry per rendered frame: `redrawn`, `draws`, `partial`); name them to turn them on.
56 56
57 ### Frame stats57 ### Frame stats
58 58
M internal/adapters/logging/logging.go +7 -2
@@ -28,8 +28,13 @@ var components = map[string]bool{
2828 // categories are debug switches narrower than a component, only on when
2929 // named: "all" leaves them off. input-motion logs every pointer motion,
3030 // input-keys every key (typed text), drm-flip every frame and commit
31-// completion with its timing. Naming one turns on debug for its component.
32-var categories = map[string]string{"input-motion": "input", "input-keys": "input", "drm-flip": "drm"}
31+// completion with its timing, wayland-damage every fresh client commit with
32+// its damage rects, render-damage every rendered frame with its redrawn
33+// area. Naming one turns on debug for its component.
34+var categories = map[string]string{
35+ "input-motion": "input", "input-keys": "input", "drm-flip": "drm",
36+ "wayland-damage": "wayland", "render-damage": "render",
37+}
3338
3439 type debugKey struct{}
3540 type levelKey struct{}
@@ -28,8 +28,13 @@ var components = map[string]bool{
28 // categories are debug switches narrower than a component, only on when28 // categories are debug switches narrower than a component, only on when
29 // named: "all" leaves them off. input-motion logs every pointer motion,29 // named: "all" leaves them off. input-motion logs every pointer motion,
30 // input-keys every key (typed text), drm-flip every frame and commit30 // input-keys every key (typed text), drm-flip every frame and commit
31-// completion with its timing. Naming one turns on debug for its component.31+// completion with its timing, wayland-damage every fresh client commit with
32-var categories = map[string]string{"input-motion": "input", "input-keys": "input", "drm-flip": "drm"}32+// its damage rects, render-damage every rendered frame with its redrawn
33+// area. Naming one turns on debug for its component.
34+var categories = map[string]string{
35+ "input-motion": "input", "input-keys": "input", "drm-flip": "drm",
36+ "wayland-damage": "wayland", "render-damage": "render",
37+}
33 38
34 type debugKey struct{}39 type debugKey struct{}
35 type levelKey struct{}40 type levelKey struct{}
M internal/adapters/logging/logging_test.go +4 -1
@@ -168,8 +168,11 @@ func TestLogWriteNeverBlocksOnSlowDisk(t *testing.T) {
168168
169169 // Narrow categories are on only when named, never through "all".
170170 func TestEnabledCategories(t *testing.T) {
171- for _, category := range []string{"input-motion", "input-keys", "drm-flip"} {
171+ for _, category := range []string{"input-motion", "input-keys", "drm-flip", "wayland-damage", "render-damage"} {
172172 component := categories[category]
173+ if category == "wayland-damage" && component != "wayland" || category == "render-damage" && component != "render" {
174+ t.Errorf("%s belongs to %q", category, component)
175+ }
173176 for _, tc := range []struct {
174177 debug string
175178 want bool
@@ -168,8 +168,11 @@ func TestLogWriteNeverBlocksOnSlowDisk(t *testing.T) {
168 168
169 // Narrow categories are on only when named, never through "all".169 // Narrow categories are on only when named, never through "all".
170 func TestEnabledCategories(t *testing.T) {170 func TestEnabledCategories(t *testing.T) {
171- for _, category := range []string{"input-motion", "input-keys", "drm-flip"} {171+ for _, category := range []string{"input-motion", "input-keys", "drm-flip", "wayland-damage", "render-damage"} {
172 component := categories[category]172 component := categories[category]
173+ if category == "wayland-damage" && component != "wayland" || category == "render-damage" && component != "render" {
174+ t.Errorf("%s belongs to %q", category, component)
175+ }
173 for _, tc := range []struct {176 for _, tc := range []struct {
174 debug string177 debug string
175 want bool178 want bool
M internal/adapters/vulkan/frame.go +7 -0
@@ -199,6 +199,13 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
199199 } else {
200200 r.redrawn += sw * sh
201201 }
202+ if r.traceDamage {
203+ redrawn := sw * sh
204+ if partial {
205+ redrawn = dmg.area.Dx() * dmg.area.Dy()
206+ }
207+ r.log.Debug().Int("redrawn", redrawn).Int("draws", len(ds)).Bool("partial", partial).Msg("frame damage")
208+ }
202209 r.orient(ds, s.Transform)
203210 d := r.dd
204211 cmd := slot.cmd
@@ -199,6 +199,13 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
199 } else {199 } else {
200 r.redrawn += sw * sh200 r.redrawn += sw * sh
201 }201 }
202+ if r.traceDamage {
203+ redrawn := sw * sh
204+ if partial {
205+ redrawn = dmg.area.Dx() * dmg.area.Dy()
206+ }
207+ r.log.Debug().Int("redrawn", redrawn).Int("draws", len(ds)).Bool("partial", partial).Msg("frame damage")
208+ }
202 r.orient(ds, s.Transform)209 r.orient(ds, s.Transform)
203 d := r.dd210 d := r.dd
204 cmd := slot.cmd211 cmd := slot.cmd
M internal/adapters/vulkan/renderer.go +6 -0
@@ -41,6 +41,8 @@ type Renderer struct {
4141 modPlanes map[uint64]uint32
4242 // log reports the chosen target modifier; the zero value discards.
4343 log zerowrap.Logger
44+ // traceDamage logs every frame's redrawn area (--debug=render-damage).
45+ traceDamage bool
4446 // hdrReadback makes exported HDR targets transfer sources, for GPU tests
4547 // and headless --screenshot-raw (SetHDRReadback); never DRM.
4648 hdrReadback bool
@@ -359,6 +361,10 @@ func (r *Renderer) TakeRedrawn() int {
359361 // events. Not on the port; the zero logger discards.
360362 func (r *Renderer) SetLogger(l zerowrap.Logger) { r.log = l }
361363
364+// SetTraceDamage logs the redrawn pixels, draw count and partial flag of
365+// every frame at debug level (--debug=render-damage). Off by default.
366+func (r *Renderer) SetTraceDamage(on bool) { r.traceDamage = on }
367+
362368 // QueuePriority is the global priority the queue got: realtime, high or
363369 // default.
364370 func (r *Renderer) QueuePriority() string { return r.queuePriority }
@@ -41,6 +41,8 @@ type Renderer struct {
41 modPlanes map[uint64]uint3241 modPlanes map[uint64]uint32
42 // log reports the chosen target modifier; the zero value discards.42 // log reports the chosen target modifier; the zero value discards.
43 log zerowrap.Logger43 log zerowrap.Logger
44+ // traceDamage logs every frame's redrawn area (--debug=render-damage).
45+ traceDamage bool
44 // hdrReadback makes exported HDR targets transfer sources, for GPU tests46 // hdrReadback makes exported HDR targets transfer sources, for GPU tests
45 // and headless --screenshot-raw (SetHDRReadback); never DRM.47 // and headless --screenshot-raw (SetHDRReadback); never DRM.
46 hdrReadback bool48 hdrReadback bool
@@ -359,6 +361,10 @@ func (r *Renderer) TakeRedrawn() int {
359 // events. Not on the port; the zero logger discards.361 // events. Not on the port; the zero logger discards.
360 func (r *Renderer) SetLogger(l zerowrap.Logger) { r.log = l }362 func (r *Renderer) SetLogger(l zerowrap.Logger) { r.log = l }
361 363
364+// SetTraceDamage logs the redrawn pixels, draw count and partial flag of
365+// every frame at debug level (--debug=render-damage). Off by default.
366+func (r *Renderer) SetTraceDamage(on bool) { r.traceDamage = on }
367+
362 // QueuePriority is the global priority the queue got: realtime, high or368 // QueuePriority is the global priority the queue got: realtime, high or
363 // default.369 // default.
364 func (r *Renderer) QueuePriority() string { return r.queuePriority }370 func (r *Renderer) QueuePriority() string { return r.queuePriority }
A internal/adapters/wayland/damage_log_test.go +105 -0
@@ -0,0 +1,105 @@
1+package wayland
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/go-wayland-bindings/server/wayland"
9+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
10+ "github.com/bnema/zerowrap"
11+ "golang.org/x/sys/unix"
12+)
13+
14+// --debug=wayland-damage logs one "client damage" entry per fresh commit
15+// with the damage in buffer pixels; without it nothing is logged.
16+func TestClientDamageLog(t *testing.T) {
17+ for _, on := range []bool{true, false} {
18+ var out lockedBuffer
19+ log := zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &out}).WithField("component", "wayland")
20+ s, events, _, contents, dir := contentServerWith(t, Options{LogDamage: on}, log)
21+ c := protocolClient(t, s, dir)
22+ comp := bindVersion(t, c, "wl_compositor", 4)
23+ wm := bindProtocol(t, c, "xdg_wm_base")
24+ shm := bindProtocol(t, c, "wl_shm")
25+ registerProtocol(t, c, shm)
26+ surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
27+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
28+ registerProtocol(t, c, surf)
29+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
30+ serials := make(chan uint32, 8)
31+ xp := &configureProxy{serial: serials}
32+ xp.SetID(xdg)
33+ registerWireProxy(c, xp)
34+ registerProtocol(t, c, top)
35+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
36+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
37+ if err := c.Roundtrip(); err != nil {
38+ t.Fatal(err)
39+ }
40+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, <-serials)
41+ fd, err := unix.MemfdCreate("damagelog", 0)
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ defer unix.Close(fd)
46+ if err := unix.Ftruncate(fd, 16*16*4); err != nil {
47+ t.Fatal(err)
48+ }
49+ pool, buf := c.AllocateID(), c.AllocateID()
50+ registerProtocol(t, c, pool)
51+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16*16*4)); err != nil {
52+ t.Fatal(err)
53+ }
54+ registerProtocol(t, c, buf)
55+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(16), int32(16), int32(64), uint32(0))
56+ commit := func(rects ...[4]int32) {
57+ t.Helper()
58+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
59+ for _, r := range rects {
60+ requestProtocol(t, c, surf, wayland.SurfaceRequestDamageBuffer, r[0], r[1], r[2], r[3])
61+ }
62+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
63+ if err := c.Roundtrip(); err != nil {
64+ t.Fatal(err)
65+ }
66+ select {
67+ case <-contents:
68+ case <-time.After(2 * time.Second):
69+ t.Fatal("no content")
70+ }
71+ }
72+ // First buffer: a new size, full. Then a small rect, then one
73+ // covering the whole buffer.
74+ commit([4]int32{0, 0, 4, 4})
75+ mapped(t, events, 2*time.Second)
76+ commit([4]int32{2, 3, 4, 5})
77+ commit([4]int32{0, 0, 16, 16})
78+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")
79+ var damage []string
80+ for _, l := range lines {
81+ if strings.Contains(l, `"client damage"`) {
82+ damage = append(damage, l)
83+ }
84+ }
85+ if !on {
86+ if len(damage) != 0 {
87+ t.Fatalf("logged with LogDamage off: %q", damage)
88+ }
89+ continue
90+ }
91+ if len(damage) != 3 {
92+ t.Fatalf("%d client damage entries, want 3: %q", len(damage), damage)
93+ }
94+ for i, want := range []string{`"full_buffer":true`, `"full_buffer":false`, `"full_buffer":true`} {
95+ if !strings.Contains(damage[i], want) {
96+ t.Errorf("entry %d lacks %s: %s", i, want, damage[i])
97+ }
98+ }
99+ for _, want := range []string{`"component":"wayland"`, `"bw":16`, `"bh":16`, `"rects":1`, `"window":`, `"first_rects":[{"x":2,"y":3,"w":4,"h":5}]`} {
100+ if !strings.Contains(damage[1], want) {
101+ t.Errorf("entry 1 lacks %s: %s", want, damage[1])
102+ }
103+ }
104+ }
105+}
@@ -0,0 +1,105 @@
1+package wayland
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/go-wayland-bindings/server/wayland"
9+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
10+ "github.com/bnema/zerowrap"
11+ "golang.org/x/sys/unix"
12+)
13+
14+// --debug=wayland-damage logs one "client damage" entry per fresh commit
15+// with the damage in buffer pixels; without it nothing is logged.
16+func TestClientDamageLog(t *testing.T) {
17+ for _, on := range []bool{true, false} {
18+ var out lockedBuffer
19+ log := zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &out}).WithField("component", "wayland")
20+ s, events, _, contents, dir := contentServerWith(t, Options{LogDamage: on}, log)
21+ c := protocolClient(t, s, dir)
22+ comp := bindVersion(t, c, "wl_compositor", 4)
23+ wm := bindProtocol(t, c, "xdg_wm_base")
24+ shm := bindProtocol(t, c, "wl_shm")
25+ registerProtocol(t, c, shm)
26+ surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
27+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
28+ registerProtocol(t, c, surf)
29+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
30+ serials := make(chan uint32, 8)
31+ xp := &configureProxy{serial: serials}
32+ xp.SetID(xdg)
33+ registerWireProxy(c, xp)
34+ registerProtocol(t, c, top)
35+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
36+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
37+ if err := c.Roundtrip(); err != nil {
38+ t.Fatal(err)
39+ }
40+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, <-serials)
41+ fd, err := unix.MemfdCreate("damagelog", 0)
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ defer unix.Close(fd)
46+ if err := unix.Ftruncate(fd, 16*16*4); err != nil {
47+ t.Fatal(err)
48+ }
49+ pool, buf := c.AllocateID(), c.AllocateID()
50+ registerProtocol(t, c, pool)
51+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(16*16*4)); err != nil {
52+ t.Fatal(err)
53+ }
54+ registerProtocol(t, c, buf)
55+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buf, int32(0), int32(16), int32(16), int32(64), uint32(0))
56+ commit := func(rects ...[4]int32) {
57+ t.Helper()
58+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buf, int32(0), int32(0))
59+ for _, r := range rects {
60+ requestProtocol(t, c, surf, wayland.SurfaceRequestDamageBuffer, r[0], r[1], r[2], r[3])
61+ }
62+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
63+ if err := c.Roundtrip(); err != nil {
64+ t.Fatal(err)
65+ }
66+ select {
67+ case <-contents:
68+ case <-time.After(2 * time.Second):
69+ t.Fatal("no content")
70+ }
71+ }
72+ // First buffer: a new size, full. Then a small rect, then one
73+ // covering the whole buffer.
74+ commit([4]int32{0, 0, 4, 4})
75+ mapped(t, events, 2*time.Second)
76+ commit([4]int32{2, 3, 4, 5})
77+ commit([4]int32{0, 0, 16, 16})
78+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")
79+ var damage []string
80+ for _, l := range lines {
81+ if strings.Contains(l, `"client damage"`) {
82+ damage = append(damage, l)
83+ }
84+ }
85+ if !on {
86+ if len(damage) != 0 {
87+ t.Fatalf("logged with LogDamage off: %q", damage)
88+ }
89+ continue
90+ }
91+ if len(damage) != 3 {
92+ t.Fatalf("%d client damage entries, want 3: %q", len(damage), damage)
93+ }
94+ for i, want := range []string{`"full_buffer":true`, `"full_buffer":false`, `"full_buffer":true`} {
95+ if !strings.Contains(damage[i], want) {
96+ t.Errorf("entry %d lacks %s: %s", i, want, damage[i])
97+ }
98+ }
99+ for _, want := range []string{`"component":"wayland"`, `"bw":16`, `"bh":16`, `"rects":1`, `"window":`, `"first_rects":[{"x":2,"y":3,"w":4,"h":5}]`} {
100+ if !strings.Contains(damage[1], want) {
101+ t.Errorf("entry 1 lacks %s: %s", want, damage[1])
102+ }
103+ }
104+ }
105+}
M internal/adapters/wayland/scale_test.go +9 -1
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/neferwl/internal/adapters/logging"
1414 "github.com/bnema/neferwl/internal/ports"
1515 "github.com/bnema/wlturbo"
16+ "github.com/bnema/zerowrap"
1617 "golang.org/x/sys/unix"
1718 )
1819
@@ -52,12 +53,19 @@ func (p *surfaceTransformProxy) Dispatch(e *wlturbo.Event) {
5253 }
5354
5455 func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) {
56+ t.Helper()
57+ return contentServerWith(t, Options{}, logging.For(context.Background(), "wayland"))
58+}
59+
60+// contentServerWith is contentServer with extra options and a logger.
61+func contentServerWith(t *testing.T, opts Options, log zerowrap.Logger) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) {
5562 t.Helper()
5663 dir := t.TempDir()
5764 events := make(chan ports.ClientEvent, 16)
5865 commands := make(chan ports.ClientCommand, 16)
5966 contents := make(chan ports.SurfaceContent, 16)
60- s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Contents: contents}, logging.For(context.Background(), "wayland"))
67+ opts.RuntimeDir, opts.Outputs = dir, testOutputs
68+ s, err := New(opts, Channels{Events: events, Commands: commands, Contents: contents}, log)
6169 if err != nil {
6270 t.Fatal(err)
6371 }
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/neferwl/internal/adapters/logging"13 "github.com/bnema/neferwl/internal/adapters/logging"
14 "github.com/bnema/neferwl/internal/ports"14 "github.com/bnema/neferwl/internal/ports"
15 "github.com/bnema/wlturbo"15 "github.com/bnema/wlturbo"
16+ "github.com/bnema/zerowrap"
16 "golang.org/x/sys/unix"17 "golang.org/x/sys/unix"
17 )18 )
18 19
@@ -52,12 +53,19 @@ func (p *surfaceTransformProxy) Dispatch(e *wlturbo.Event) {
52 }53 }
53 54
54 func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) {55 func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) {
56+ t.Helper()
57+ return contentServerWith(t, Options{}, logging.For(context.Background(), "wayland"))
58+}
59+
60+// contentServerWith is contentServer with extra options and a logger.
61+func contentServerWith(t *testing.T, opts Options, log zerowrap.Logger) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, chan ports.SurfaceContent, string) {
55 t.Helper()62 t.Helper()
56 dir := t.TempDir()63 dir := t.TempDir()
57 events := make(chan ports.ClientEvent, 16)64 events := make(chan ports.ClientEvent, 16)
58 commands := make(chan ports.ClientCommand, 16)65 commands := make(chan ports.ClientCommand, 16)
59 contents := make(chan ports.SurfaceContent, 16)66 contents := make(chan ports.SurfaceContent, 16)
60- s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Contents: contents}, logging.For(context.Background(), "wayland"))67+ opts.RuntimeDir, opts.Outputs = dir, testOutputs
68+ s, err := New(opts, Channels{Events: events, Commands: commands, Contents: contents}, log)
61 if err != nil {69 if err != nil {
62 t.Fatal(err)70 t.Fatal(err)
63 }71 }
M internal/adapters/wayland/server.go +5 -0
@@ -48,6 +48,9 @@ type Options struct {
4848 // nil lets every client capture, unless the session is protected. The
4949 // caller keeps it current (captureallow.Store.Run).
5050 CaptureAllow *captureallow.Store
51+ // LogDamage logs the damage of every fresh client commit at debug level
52+ // (--debug=wayland-damage).
53+ LogDamage bool
5154 // syncDev replaces the render node's syncobj interface (tests).
5255 syncDev syncobjDevice
5356 // peer replaces the peer executable lookup (tests).
@@ -144,6 +147,7 @@ type Server struct {
144147 name string
145148 cleanup func()
146149 log zerowrap.Logger
150+ logDamage bool
147151 channels Channels
148152 // awaiting holds frame callbacks by output name, due at its next
149153 // frame (frameDue, or its page flip).
@@ -321,6 +325,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
321325 name: name,
322326 cleanup: cleanup,
323327 log: log,
328+ logDamage: opts.LogDamage,
324329 channels: ch,
325330 surfaces: make(map[*server.Resource]*surface),
326331 buffers: make(map[*server.Resource]clientBuffer),
@@ -48,6 +48,9 @@ type Options struct {
48 // nil lets every client capture, unless the session is protected. The48 // nil lets every client capture, unless the session is protected. The
49 // caller keeps it current (captureallow.Store.Run).49 // caller keeps it current (captureallow.Store.Run).
50 CaptureAllow *captureallow.Store50 CaptureAllow *captureallow.Store
51+ // LogDamage logs the damage of every fresh client commit at debug level
52+ // (--debug=wayland-damage).
53+ LogDamage bool
51 // syncDev replaces the render node's syncobj interface (tests).54 // syncDev replaces the render node's syncobj interface (tests).
52 syncDev syncobjDevice55 syncDev syncobjDevice
53 // peer replaces the peer executable lookup (tests).56 // peer replaces the peer executable lookup (tests).
@@ -144,6 +147,7 @@ type Server struct {
144 name string147 name string
145 cleanup func()148 cleanup func()
146 log zerowrap.Logger149 log zerowrap.Logger
150+ logDamage bool
147 channels Channels151 channels Channels
148 // awaiting holds frame callbacks by output name, due at its next152 // awaiting holds frame callbacks by output name, due at its next
149 // frame (frameDue, or its page flip).153 // frame (frameDue, or its page flip).
@@ -321,6 +325,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
321 name: name,325 name: name,
322 cleanup: cleanup,326 cleanup: cleanup,
323 log: log,327 log: log,
328+ logDamage: opts.LogDamage,
324 channels: ch,329 channels: ch,
325 surfaces: make(map[*server.Resource]*surface),330 surfaces: make(map[*server.Resource]*surface),
326 buffers: make(map[*server.Resource]clientBuffer),331 buffers: make(map[*server.Resource]clientBuffer),
M internal/adapters/wayland/surface.go +32 -0
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/go-wayland-bindings/server/wlrlayershell"
1414 "github.com/bnema/go-wayland-bindings/server/xdgshell"
1515 "github.com/bnema/neferwl/internal/ports"
16+ "github.com/rs/zerolog"
1617 )
1718
1819 type roleKind uint8
@@ -588,6 +589,37 @@ func (s *surface) DamageBuffer(_ *wayland.Surface, x, y, w, h int32) {
588589 // A new size, too many rects or none at all with a new buffer (clients
589590 // that do not report damage) is a full change.
590591 func (s *surface) commitDamage(u *update, fresh, resized bool, bw, bh int) {
592+ s.mapDamage(u, fresh, resized, bw, bh)
593+ if fresh && s.server.logDamage {
594+ s.traceDamage(bw, bh)
595+ }
596+}
597+
598+// traceDamage logs the damage commitDamage just computed
599+// (--debug=wayland-damage): full_buffer is set when the commit is a full
600+// change or one rect covers the whole buffer. Rects are already clipped to
601+// the buffer, so covering means equal to it.
602+func (s *surface) traceDamage(bw, bh int) {
603+ d := s.committed
604+ full := d.full
605+ for _, r := range d.rects {
606+ if r.X <= 0 && r.Y <= 0 && r.X+r.W >= bw && r.Y+r.H >= bh {
607+ full = true
608+ }
609+ }
610+ arr := zerolog.Arr()
611+ for _, r := range d.rects[:min(len(d.rects), maxLoggedDamageRects)] {
612+ arr.Dict(zerolog.Dict().Int("x", r.X).Int("y", r.Y).Int("w", r.W).Int("h", r.H))
613+ }
614+ s.server.log.Debug().Uint64("window", uint64(s.root().windowID())).Int("bw", bw).Int("bh", bh).
615+ Int("rects", len(d.rects)).Bool("full_buffer", full).Array("first_rects", arr).Msg("client damage")
616+}
617+
618+// maxLoggedDamageRects bounds the rects one "client damage" entry lists.
619+const maxLoggedDamageRects = 4
620+
621+// mapDamage sets s.committed from the commit's requested damage.
622+func (s *surface) mapDamage(u *update, fresh, resized bool, bw, bh int) {
591623 surf, buf := u.damage, u.bufDamage
592624 if !fresh {
593625 s.committed = damage{}
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/go-wayland-bindings/server/wlrlayershell"13 "github.com/bnema/go-wayland-bindings/server/wlrlayershell"
14 "github.com/bnema/go-wayland-bindings/server/xdgshell"14 "github.com/bnema/go-wayland-bindings/server/xdgshell"
15 "github.com/bnema/neferwl/internal/ports"15 "github.com/bnema/neferwl/internal/ports"
16+ "github.com/rs/zerolog"
16 )17 )
17 18
18 type roleKind uint819 type roleKind uint8
@@ -588,6 +589,37 @@ func (s *surface) DamageBuffer(_ *wayland.Surface, x, y, w, h int32) {
588 // A new size, too many rects or none at all with a new buffer (clients589 // A new size, too many rects or none at all with a new buffer (clients
589 // that do not report damage) is a full change.590 // that do not report damage) is a full change.
590 func (s *surface) commitDamage(u *update, fresh, resized bool, bw, bh int) {591 func (s *surface) commitDamage(u *update, fresh, resized bool, bw, bh int) {
592+ s.mapDamage(u, fresh, resized, bw, bh)
593+ if fresh && s.server.logDamage {
594+ s.traceDamage(bw, bh)
595+ }
596+}
597+
598+// traceDamage logs the damage commitDamage just computed
599+// (--debug=wayland-damage): full_buffer is set when the commit is a full
600+// change or one rect covers the whole buffer. Rects are already clipped to
601+// the buffer, so covering means equal to it.
602+func (s *surface) traceDamage(bw, bh int) {
603+ d := s.committed
604+ full := d.full
605+ for _, r := range d.rects {
606+ if r.X <= 0 && r.Y <= 0 && r.X+r.W >= bw && r.Y+r.H >= bh {
607+ full = true
608+ }
609+ }
610+ arr := zerolog.Arr()
611+ for _, r := range d.rects[:min(len(d.rects), maxLoggedDamageRects)] {
612+ arr.Dict(zerolog.Dict().Int("x", r.X).Int("y", r.Y).Int("w", r.W).Int("h", r.H))
613+ }
614+ s.server.log.Debug().Uint64("window", uint64(s.root().windowID())).Int("bw", bw).Int("bh", bh).
615+ Int("rects", len(d.rects)).Bool("full_buffer", full).Array("first_rects", arr).Msg("client damage")
616+}
617+
618+// maxLoggedDamageRects bounds the rects one "client damage" entry lists.
619+const maxLoggedDamageRects = 4
620+
621+// mapDamage sets s.committed from the commit's requested damage.
622+func (s *surface) mapDamage(u *update, fresh, resized bool, bw, bh int) {
591 surf, buf := u.damage, u.bufDamage623 surf, buf := u.damage, u.bufDamage
592 if !fresh {624 if !fresh {
593 s.committed = damage{}625 s.committed = damage{}
M internal/app/headless_test.go +1 -1
@@ -15,7 +15,7 @@ import (
1515 // and a headless output falls back to SDR. Skips without Vulkan or without a
1616 // device that exports dmabufs (lavapipe in CI).
1717 func TestVirtualRendererExportsHDRTargets(t *testing.T) {
18- r, err := newVulkanRenderer(logging.For(t.Context(), "render"), true, false)(64, 16)
18+ r, err := newVulkanRenderer(logging.For(t.Context(), "render"), true, false, false)(64, 16)
1919 if err != nil {
2020 t.Skipf("Vulkan unavailable: %v", err)
2121 }
@@ -15,7 +15,7 @@ import (
15 // and a headless output falls back to SDR. Skips without Vulkan or without a15 // and a headless output falls back to SDR. Skips without Vulkan or without a
16 // device that exports dmabufs (lavapipe in CI).16 // device that exports dmabufs (lavapipe in CI).
17 func TestVirtualRendererExportsHDRTargets(t *testing.T) {17 func TestVirtualRendererExportsHDRTargets(t *testing.T) {
18- r, err := newVulkanRenderer(logging.For(t.Context(), "render"), true, false)(64, 16)18+ r, err := newVulkanRenderer(logging.For(t.Context(), "render"), true, false, false)(64, 16)
19 if err != nil {19 if err != nil {
20 t.Skipf("Vulkan unavailable: %v", err)20 t.Skipf("Vulkan unavailable: %v", err)
21 }21 }
M internal/app/session.go +8 -4
@@ -123,6 +123,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
123123 Keymap: s.km.String(),
124124 RepeatRate: opts.Config.Keyboard.RepeatRate,
125125 RepeatDelay: opts.Config.Keyboard.RepeatDelay,
126+ LogDamage: logging.Enabled(ctx, "wayland-damage"),
126127 }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil {
127128 return s, err
128129 }
@@ -145,10 +146,11 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
145146 s.path = config.DefaultPath()
146147 }
147148 renderLog := logging.For(ctx, "render")
149+ traceDamage := logging.Enabled(ctx, "render-damage")
148150 if s.hw != nil {
149- s.newRenderer = newVulkanRenderer(renderLog, false, false)
151+ s.newRenderer = newVulkanRenderer(renderLog, false, false, traceDamage)
150152 } else {
151- s.newRenderer = newVulkanRenderer(renderLog, true, opts.ScreenshotRaw)
153+ s.newRenderer = newVulkanRenderer(renderLog, true, opts.ScreenshotRaw, traceDamage)
152154 }
153155 return s, nil
154156 }
@@ -182,14 +184,16 @@ func (s *session) close() {
182184 // outputs have no display to list modifiers, so a headless renderer is told
183185 // it drives one: without it a real GPU refuses the HDR targets and the
184186 // output falls back to SDR. readback makes the HDR targets readable
185-// (HDRPixels). These settings are Vulkan-only and not on ports.Renderer.
186-func newVulkanRenderer(log zerowrap.Logger, virtual, readback bool) func(w, h int) (ports.Renderer, error) {
187+// (HDRPixels). traceDamage logs every frame's redrawn area. These settings
188+// are Vulkan-only and not on ports.Renderer.
189+func newVulkanRenderer(log zerowrap.Logger, virtual, readback, traceDamage bool) func(w, h int) (ports.Renderer, error) {
187190 return func(w, h int) (ports.Renderer, error) {
188191 r, err := vulkan.New(w, h)
189192 if err != nil {
190193 return nil, err
191194 }
192195 r.SetLogger(log)
196+ r.SetTraceDamage(traceDamage)
193197 log.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
194198 r.SetVirtualOutput(virtual)
195199 if readback {
@@ -123,6 +123,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
123 Keymap: s.km.String(),123 Keymap: s.km.String(),
124 RepeatRate: opts.Config.Keyboard.RepeatRate,124 RepeatRate: opts.Config.Keyboard.RepeatRate,
125 RepeatDelay: opts.Config.Keyboard.RepeatDelay,125 RepeatDelay: opts.Config.Keyboard.RepeatDelay,
126+ LogDamage: logging.Enabled(ctx, "wayland-damage"),
126 }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil {127 }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil {
127 return s, err128 return s, err
128 }129 }
@@ -145,10 +146,11 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
145 s.path = config.DefaultPath()146 s.path = config.DefaultPath()
146 }147 }
147 renderLog := logging.For(ctx, "render")148 renderLog := logging.For(ctx, "render")
149+ traceDamage := logging.Enabled(ctx, "render-damage")
148 if s.hw != nil {150 if s.hw != nil {
149- s.newRenderer = newVulkanRenderer(renderLog, false, false)151+ s.newRenderer = newVulkanRenderer(renderLog, false, false, traceDamage)
150 } else {152 } else {
151- s.newRenderer = newVulkanRenderer(renderLog, true, opts.ScreenshotRaw)153+ s.newRenderer = newVulkanRenderer(renderLog, true, opts.ScreenshotRaw, traceDamage)
152 }154 }
153 return s, nil155 return s, nil
154 }156 }
@@ -182,14 +184,16 @@ func (s *session) close() {
182 // outputs have no display to list modifiers, so a headless renderer is told184 // outputs have no display to list modifiers, so a headless renderer is told
183 // it drives one: without it a real GPU refuses the HDR targets and the185 // it drives one: without it a real GPU refuses the HDR targets and the
184 // output falls back to SDR. readback makes the HDR targets readable186 // output falls back to SDR. readback makes the HDR targets readable
185-// (HDRPixels). These settings are Vulkan-only and not on ports.Renderer.187+// (HDRPixels). traceDamage logs every frame's redrawn area. These settings
186-func newVulkanRenderer(log zerowrap.Logger, virtual, readback bool) func(w, h int) (ports.Renderer, error) {188+// are Vulkan-only and not on ports.Renderer.
189+func newVulkanRenderer(log zerowrap.Logger, virtual, readback, traceDamage bool) func(w, h int) (ports.Renderer, error) {
187 return func(w, h int) (ports.Renderer, error) {190 return func(w, h int) (ports.Renderer, error) {
188 r, err := vulkan.New(w, h)191 r, err := vulkan.New(w, h)
189 if err != nil {192 if err != nil {
190 return nil, err193 return nil, err
191 }194 }
192 r.SetLogger(log)195 r.SetLogger(log)
196+ r.SetTraceDamage(traceDamage)
193 log.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")197 log.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
194 r.SetVirtualOutput(virtual)198 r.SetVirtualOutput(virtual)
195 if readback {199 if readback {
M internal/cli/run.go +1 -1
@@ -39,7 +39,7 @@ func (f *runFlags) register(fs *flag.FlagSet) {
3939 fs.BoolVar(&f.headlessHDR, "headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
4040 fs.BoolVar(&f.screenshotRaw, "screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output")
4141 fs.DurationVar(&f.timeout, "timeout", 0, "duration before exit (0 disables timeout)")
42- fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)")
42+ fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion, input-keys, wayland-damage and render-damage, which log every flip, pointer motion, key, client commit or frame)")
4343 fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)")
4444 fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
4545 fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
@@ -39,7 +39,7 @@ func (f *runFlags) register(fs *flag.FlagSet) {
39 fs.BoolVar(&f.headlessHDR, "headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")39 fs.BoolVar(&f.headlessHDR, "headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
40 fs.BoolVar(&f.screenshotRaw, "screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output")40 fs.BoolVar(&f.screenshotRaw, "screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output")
41 fs.DurationVar(&f.timeout, "timeout", 0, "duration before exit (0 disables timeout)")41 fs.DurationVar(&f.timeout, "timeout", 0, "duration before exit (0 disables timeout)")
42- fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)")42+ fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion, input-keys, wayland-damage and render-damage, which log every flip, pointer motion, key, client commit or frame)")
43 fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)")43 fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)")
44 fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")44 fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
45 fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")45 fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close