Wayland scaffold — a real xdg-shell window
Findings from ./examples/scaffold/app.d, the evolved form of the minimal registry bootstrap ./example/app.d: it completes the whole no-buffer-no-window handshake and opens an actual xdg_toplevel window titled wsi-scaffold, with wl_shm double-buffered ARGB software rendering, frame-callback-driven redraw, and a programmatic maximize/unmaximize resize storm. It is the baseline every per-feature Wayland demo (examples/fXX-*) copies, and the source of the F01/F02 numbers below. Verified Tier A: weston --backend=headless, exit 0, 120 frames presented.
Last reviewed: June 10, 2026
| Measurement | Value |
|---|---|
| Concepts to first pixel | 11 protocol object types (10 to the first commit, +1 — wl_callback — to confirm it) |
LOC (app.d) | 463 lines (346 excluding comments/blanks); instrument.d and the c.c shim excluded per F01 |
init_start → first_commit | ≈ 2.2 ms (of which ≈ 1.3 ms is painting the first 640×480 gradient) |
init_start → first_pixel_presented | ≈ 12.4 ms — dominated by waiting for the compositor's first 60 Hz wl_surface.frame tick |
| Round-trips before first pixel | 2 blocking waits: wl_display_roundtrip (registry) + the configure event after the initial commit |
Concepts to pixel
Distinct protocol object types touched before the first confirmed pixel, in first-touch order (spec links go to the rendered protocol XML on wayland.app — the core protocol and xdg-shell):
wl_display— the connectionwl_registry— global discoverywl_callback— first used internally bywl_display_roundtrip(wl_display.sync), later as the frame callback that confirms presentationwl_compositor— bound v4 (needed forwl_surface.damage_buffer)wl_shm— software-buffer factoryxdg_wm_base— the window-role factory fromxdg-shellwl_surface— the pixel rectanglexdg_surface— the desktop-window adapterxdg_toplevel— the window role (title, app-id, maximize)wl_shm_pool— wraps thememfdmappingwl_buffer— the committable pixels
wl_seat is bound when advertised, but headless weston advertises no seat at all (see surprises), so the Tier-A count stays 11. Two non-protocol OS objects ride along: a memfd_create(2) file descriptor and its mmap(2) mapping. For comparison, the minimal ./example/app.d touches 3 (wl_display, wl_registry, wl_callback) — a visible window costs almost 4× the object vocabulary of the bootstrap.
Init step sequence (instrument log)
From the demo's instrument.d stream (microseconds since init_start; format defined in F01 § Instrumentation), recorded under weston --backend=headless with WSI_AUTO_EXIT=1:
0 scaffold-wayland init_start
56 scaffold-wayland step name=wl_display_connect
86 scaffold-wayland step name=wl_display_get_registry
244 scaffold-wayland step name=wl_registry_bind iface=wl_compositor version=5
355 scaffold-wayland step name=wl_registry_bind iface=wl_shm version=2
406 scaffold-wayland step name=wl_registry_bind iface=xdg_wm_base version=5
435 scaffold-wayland step name=wl_display_roundtrip
458 scaffold-wayland step name=wl_compositor_create_surface
480 scaffold-wayland step name=xdg_wm_base_get_xdg_surface
505 scaffold-wayland step name=xdg_surface_get_toplevel
527 scaffold-wayland step name=xdg_toplevel_set_title
548 scaffold-wayland step name=xdg_toplevel_set_app_id
568 scaffold-wayland window_created
584 scaffold-wayland step name=wl_surface_commit
759 scaffold-wayland xdg_toplevel_configure size=0x0 maximized=0
783 scaffold-wayland configure serial=1 size=640x480
810 scaffold-wayland step name=xdg_surface_ack_configure
828 scaffold-wayland first_configure
854 scaffold-wayland step name=memfd_create+mmap
884 scaffold-wayland step name=wl_shm_create_pool
910 scaffold-wayland step name=wl_shm_pool_create_buffer
935 scaffold-wayland buffer_alloc size=640x480 bytes=1228800
2241 scaffold-wayland first_commit size=640x480
12364 scaffold-wayland frame_callback t=475658634
12386 scaffold-wayland first_pixel_presentedReadings:
- Everything client-side is cheap. Connect through
window_createdis ~0.6 ms total; the only real costs are the two server waits —wl_display_roundtripfor the registry and the wait for the firstconfigureafter the no-buffer commit (~175 µs on a local socket) — and the final 10 ms of simply waiting for the compositor's next vsync tick to fire the frame callback. "First pixel" on Wayland is gated by the frame clock, not by init work. xdg_toplevel.configuresuggested0x0("you pick"), so the demo chose its 640×480 default — the size a Wayland client "decides" is itself part of the negotiation.- The gap
buffer_alloc→first_commit(~1.3 ms) is painting the 640×480 gradient (307200 pixels), not protocol.
Configure → ack → commit, proven
The WAYLAND_DEBUG=1 wire trace (also stderr, so it interleaves with the instrument lines) around the first configure shows the ordering F02 demands — ack_configure carrying the right serial before the buffer commit, and the committed buffer sized exactly to the acked configure:
[ 272970.453] {Default Queue} -> wl_surface#3.commit()
584 scaffold-wayland step name=wl_surface_commit
[ 272970.628] {Default Queue} xdg_toplevel#8.configure(0, 0, array[0])
[ 272970.653] {Default Queue} xdg_surface#7.configure(1)
783 scaffold-wayland configure serial=1 size=640x480
[ 272970.679] {Default Queue} -> xdg_surface#7.ack_configure(1)
[ 272970.753] {Default Queue} -> wl_shm#5.create_pool(new id wl_shm_pool#9, fd 5, 1228800)
[ 272970.778] {Default Queue} -> wl_shm_pool#9.create_buffer(new id wl_buffer#10, 0, 640, 480, 2560, 0)
[ 272972.079] {Default Queue} -> wl_surface#3.attach(wl_buffer#10, 0, 0)
[ 272972.091] {Default Queue} -> wl_surface#3.damage_buffer(0, 0, 640, 480)
[ 272972.103] {Default Queue} -> wl_surface#3.frame(new id wl_callback#11)
[ 272972.110] {Default Queue} -> wl_surface#3.commit()The auto-exit resize storm (set_maximized at frame 30) exercises the same contract at a new size — note the new serial, the immediate ack, and the freshly allocated 1024×608 buffer committed right after:
[ 273465.568] {Default Queue} -> xdg_toplevel#8.set_maximized()
[ 273466.219] {Default Queue} xdg_toplevel#8.configure(1024, 608, array[4])
496354 scaffold-wayland xdg_toplevel_configure size=1024x608 maximized=1
[ 273466.254] {Default Queue} xdg_surface#7.configure(2)
[ 273466.292] {Default Queue} -> xdg_surface#7.ack_configure(2)
496449 scaffold-wayland resize size=1024x608 scale=1
[ 273466.431] {Default Queue} -> wl_shm_pool#9.create_buffer(new id wl_buffer#12, 0, 1024, 608, 4096, 0)
[ 273468.962] {Default Queue} -> wl_surface#3.attach(wl_buffer#12, 0, 0)
[ 273468.985] {Default Queue} -> wl_surface#3.damage_buffer(0, 0, 1024, 608)
[ 273468.992] {Default Queue} -> wl_surface#3.commit()render() asserts buffer.size == acked size on every commit, so a violation would abort the demo rather than silently stretch. Buffer lifetime is wl_buffer.release-driven: a buffer is busy from commit until its release event, and stale-sized buffers are destroyed lazily, only when picked for reuse (never while the compositor holds them). Teardown destroys children before parents (buffers → frame callback → xdg_toplevel → xdg_surface → wl_surface → xdg_wm_base → globals → registry) and disconnects; the demo exits 0 after 120 frames / 122 commits (the two extras are the immediate post-resize commits).
What surprised us
- Headless weston advertises no
wl_seat. With no input backend there is simply no seat global —wl_seatbinding is written but unexercised in Tier-A runs, and any input-feature demo (F06+) must treat "no seat" as a skippable host capability, not an error. - Weston releases
wl_shmbuffers almost immediately (it copies the pixels at repaint), so steady-state rendering reuses a single buffer and the second one is only ever touched during resize transitions, when the first is still held. Double buffering is therefore insurance, not throughput — but it is what makes the resize path race-free. - Maximized on headless weston is 1024×608, not 1024×640: the default output is 1024×640 and
weston-desktop-shell's panel eats 32 px. Unmaximize suggests0x0— the client is again "free" to pick, so a robust client must remember its own floating size. - The D/ImportC walls were all build-system, not protocol (details in the gotchas list below):
static inlinerequest helpers, dub's*.d-only source glob, the hyphenated generated filename, and Nix's-D_FORTIFY_SOURCEeach broke the build before the first protocol message was ever sent.
What the scaffold adds over the minimal example
./example/app.d stops — deliberately — at the registry: 77 lines, three object types, no window, because the xdg-shell glue beyond that point is scanner-generated. The scaffold pays exactly that cost and crosses the line. Concretely it adds:
| Capability | Minimal ./example/app.d | Scaffold ./examples/scaffold/app.d |
|---|---|---|
| Protocol glue | Core ABI only (hand-expanded static inline) | wayland-scanner-generated xdg-shell glue, regenerated at every build (generate.sh) |
| Registry | Enumerates globals | Binds wl_compositor, wl_shm, xdg_wm_base, wl_seat (when present) |
| Window | None (impossible by construction) | wl_surface → xdg_surface → xdg_toplevel, title wsi-scaffold |
| Pixels | None | Double-buffered memfd + mmap + wl_shm ARGB8888 gradient |
| Lifecycle | One wl_display_roundtrip | configure/ack/commit negotiation, frame-callback redraw loop, xdg_wm_base.ping → pong |
| Resize | n/a | maximize/unmaximize storm; per-configure buffer realloc; size assertion (F02) |
| Instrumentation | printf only | instrument.d — the canonical <monotonic_us> <DEMO> <EVENT_KIND> logger all demos copy |
| Exit | After the roundtrip | WSI_AUTO_EXIT=1 → ~120 frames then clean teardown; otherwise runs until xdg_toplevel.close |
Implementation gotchas the feature demos inherit (all encoded in ./examples/scaffold/c.c and the package config):
- ImportC cannot call
static inline— and all scanner-generated request helpers arestatic inline. Hand-marshalling viawl_proxy_marshal_flags(the minimal example's trick) does not scale to the ~25 helpers a window needs; the shim re-exports each as a realwsi_*function instead, which also keeps listener types exact (no function-pointer casts in D). - dub's source glob only picks up
*.d. A.cshim that is merely imported compiles declaration-only — every wrapper becomes an undefined reference at link time. The shim must be listed explicitly (sourceFiles "c.c"). xdg-shell-protocol.ccannot be a dub source: ldc rejects the hyphenated filename as a module name ("modulexdg-shell-protocolhas non-identifier characters in filename"). The shim#includes it textually (one translation unit) andexcludedSourceFileskeeps dub away from it. Both generated files are gitignored and rebuilt bygenerate.shviapreGenerateCommands, locating the XML throughpkg-config --variable=pkgdatadir wayland-protocols.- Nix's cc wrapper injects
-D_FORTIFY_SOURCE, and glibc's fortifiedbits/unistd.huses__builtin_dynamic_object_size, which ImportC does not implement — the shim must#undef _FORTIFY_SOURCEbefore its first glibc include.
Sources
- Protocol — the core Wayland protocol (
wl_display,wl_registry,wl_compositor,wl_shm,wl_surface,wl_buffer,wl_callback,wl_seat) andxdg-shell(xdg_wm_base,xdg_surface,xdg_toplevel; the initial-commit/configure/ack contract quoted in the survey); the glue is generated from thewayland-protocolsXML (stable/xdg-shell/xdg-shell.xml). - Shared-memory buffers — the Wayland Book's shared-memory chapter (
memfd_create+wl_shmpool pattern the scaffold follows). - Specs implemented — F01 first pixel and F02 resize; conventions in the features index.
- Code —
./examples/scaffold/app.d,./examples/scaffold/instrument.d,./examples/scaffold/c.c; the predecessor./example/app.dand its survey./index.md.