Protocol

This document is the source of truth for the wire protocol between veiland-core (the host) and a plugin process. The Rust types in veiland-protocol are an implementation of this spec; other languages may implement it directly. If the Rust code and this document disagree, the document wins and the code is a bug.

1. Non-goals

2. Transport

3. Encoding primitives

TypeEncoding
u81 byte
u162 bytes, little-endian
u324 bytes, little-endian
i324 bytes, little-endian, two's complement
u648 bytes, little-endian
i648 bytes, little-endian, two's complement
stru16 length in bytes, then that many UTF-8 bytes. Per-field max declared at the field site.
enumu16 tag (little-endian), then the variant's payload (may be empty)

Strings are validated as UTF-8 on receipt. Invalid UTF-8 is a protocol error.

4. Frame layout

Each recvmsg returns one frame:

+--------+-----------------+
| u16    | variant payload |
| tag    | (tag-dependent) |
+--------+-----------------+

Tags are namespaced by direction. The host knows it is reading client messages and decodes against the ClientMessage tag table; the plugin knows it is reading server messages and decodes against the ServerMessage tag table. Sending a server-direction tag in the client direction (or vice versa) is a protocol error.

5. Protocol version and host capabilities

Before any tagged message, the two sides negotiate a protocol version and the host advertises its capabilities, in sequence:

1. client → server:  u32 client_version
2. server → client:  u32 server_version       (only if it accepts client_version)
3. server → client:  u32 host_capabilities    (immediately after server_version)

For v1, both version values are 1. If the host does not recognize the client version, it closes the socket without replying. The plugin, having already sent its version, sees the socket close before reading a server version and infers a version mismatch. If the plugin reads a server version it does not recognize, it closes the socket.

Timing is host policy: the host MAY apply a deadline to the handshake and to the first ClientMessage (the reference host uses 2 seconds) and treat expiry as a failed spawn — the plugin is killed and its layer stays empty. A plugin SHOULD therefore complete the handshake and send Hello before any expensive setup of its own (GPU init, asset decode); the reference SDK's Connection::connect does this ordering naturally.

The handshake is not a ClientMessage/ServerMessage — it is raw little-endian u32s on each side, sent before the tagged-message stream begins. This keeps version mismatch outside the variant-decoding path: a future codec that changes how tags are encoded can still negotiate cleanly with the existing peer.

5.1 Host capabilities

host_capabilities is a bitfield declaring which optional protocol features the host supports. The plugin reads it after server_version and uses it to decide which path to take for features whose support is host-dependent.

BitNameMeaning
0HOST_CAP_FENCE_FDHost accepts a sync-fence fd attached as a second SCM_RIGHTS fd on Buffer messages, and will wait on it before sampling.
1–31reservedMUST be zero. A plugin that sees any reserved bit set MUST treat the handshake as a protocol violation and close the socket.

The reserved-MUST-be-zero rule is forward-compatibility hygiene: future capability bits are added by introducing new bit positions, never by redefining bits the spec currently marks reserved. A plugin built against this spec can therefore trust that bits it doesn't understand mean the host is doing something the plugin wasn't designed for, and fail closed rather than guess.

A host MUST NOT set a capability bit it does not implement. A plugin SHOULD NOT use a capability without checking the corresponding bit; doing so risks a protocol violation if the host has it disabled (e.g. HOST_CAP_FENCE_FD off and the plugin attaches a fence fd anyway — §6.2 makes that a violation, though the reference host does not yet enforce it; see §13).

How a host decides its capability bits is host-policy and out of scope for this spec. For HOST_CAP_FENCE_FD the natural rule is "set iff the host's EGL display exposes EGL_ANDROID_native_fence_sync."

6. Client messages (plugin → host)

6.1 Hello — tag 0x0001

Sent exactly once, immediately after the version handshake. Sending any other message before Hello is a protocol error.

str   plugin_name        (max 64 bytes)
str   plugin_version     (max 32 bytes)

6.2 Buffer — tag 0x0002

Carries one dmabuf fd via SCM_RIGHTS, optionally followed by a sync-fence fd when both sides are on the fast path (see "Sync fence" below).

u32   id            (plugin-assigned; referenced in later BufferReleased and BufferDestroy)
u32   width         (1..=8192)
u32   height        (1..=8192)
u32   format        (DRM FourCC; accepted iff host's GL can import it)
u64   modifier      (DRM modifier; accepted iff host's GL can import it)
u32   stride        (must be >= width)
u32   offset        (typically 0; nonzero for sub-allocation)

Id semantics. The id is plugin-assigned and scoped to this connection (the host's bookkeeping is per-socket; another plugin's identical id is unrelated and never seen). A plugin MAY re-send the same id after receiving the corresponding BufferReleased, either with the same fd (signaling "buffer contents have changed, re-sample") or with a different fd (replacing the buffer). Sending a Buffer with an id the host is currently using (i.e. not yet released) is a protocol violation. (Not yet enforced by the reference host — see §13; it is single-buffer and replaces the texture on each Buffer regardless of id, so reuse is currently tolerated.)

Choosing ids (non-normative). The id is opaque to the host. Plugins MAY use any u32 scheme; the simplest is id = 0 for a single-buffer plugin, or small sequential ids for a buffer pool. The host does not require sequential, dense, or stable ids — only that the plugin uses the same id for the same buffer when re-sending it.

Validation, host side. Validation happens in two layers:

Any failure at either layer: log, close the plugin's socket, treat as plugin death. The lock surface falls back to its clear color in the affected region.

Sync fence (optional second fd). When the host advertised HOST_CAP_FENCE_FD in the handshake (§5.1), the plugin MAY attach a second SCM_RIGHTS fd to its Buffer messages. The two fds appear in the cmsg in this order, matched by arrival order:

  1. Dmabuf fd (always present) — references the GPU buffer the host imports as an EGLImage.
  2. Fence fd (present iff fast-path) — a dma-fence fd, produced by the plugin via eglDupNativeFenceFDANDROID after the render commands targeting the dmabuf were flushed. The host imports it as an EGL sync object and waits on it before sampling the dmabuf — without that wait, the host might sample a half-rendered frame.

The plugin chooses fast or slow path once at startup, after reading the handshake, based on HOST_CAP_FENCE_FD AND its own EGL display's support for EGL_ANDROID_native_fence_sync. Both must be true for the fast path. The choice is fixed for the connection's lifetime — plugins do not switch paths per frame.

The host's fd-count expectation is determined by what it advertised plus what it observes the plugin doing on the first Buffer:

A plugin that flip-flops between 1-fd and 2-fd across messages is a protocol violation. The path is a connection-level decision, not a per-message one. (The lock-in and the "2 fds with HOST_CAP_FENCE_FD off" rejection are not yet enforced by the reference host — see §13; it accepts 1 or 2 fds on every Buffer and does not consult the negotiated capability after the handshake.)

Modifier INVALID (u64::MAX) is a special DRM sentinel meaning "unknown / unspecified" — what gbm_bo_create returns when called without explicit modifier negotiation. It's a legitimate value plugins may send; whether the host can import it is again EGL's decision.

6.3 BufferDestroy — tag 0x0003

Plugin tells the host it will no longer reuse this buffer id. Host may release any cached EGLImage/GL resources keyed on the id.

u32   id

v1 implementation note. With v1's single-buffer model the plugin has no reason to send BufferDestroy before shutdown — at shutdown, socket close already prompts the host to free everything. BufferDestroy is on the wire from v1 so that future buffer-pool plugins and any reference plugins that grow a pool can use it without a protocol bump.

7. Server messages (host → plugin)

7.1 Configure — tag 0x0001

Sent at least once before the first FrameDone. May be sent again at any time (e.g. on output resize, time tick coarsely).

i32   region_x
i32   region_y
u32   region_w           (1..=8192)
u32   region_h           (1..=8192)
u32   scale_120          (output scale in 120ths; 1..=9999)
i64   time_unix_seconds
i32   time_tz_offset_seconds
str   output_name        (max 64 bytes)

scale_120 carries the output scale in 120ths, matching wp_fractional_scale_v1's encoding: 120 = 1×, 180 = 1.5×, 240 = 2×. Convert to a float multiplier with scale_120 as f32 / 120.0. Values outside 1..=9999 are rejected at decode.

The region dimensions (region_w, region_h) are already in physical pixels; plugins do not multiply them by scale_120. scale_120 is for converting plugin-internal logical sizes (font sizes, shadow radii, asset selection) into physical pixels. The host sources it from wp_fractional_scale_v1.preferred_scale where the compositor advertises that protocol, and falls back to the integer wl_output.scale × 120 otherwise; both paths clamp on the host side so the encoder never produces an out-of-range Configure.

A re-Configure may arrive at any time with a different scale_120 (e.g. the user changes their monitor's scale factor in the compositor settings). The plugin should latch the new value and use it on the next FrameDone — there is no separate "scale-changed" message and no requirement to re-render immediately on receipt. The particle plugins (veiland-particles, veiland-sakura, veiland-snow, ...) are the reference shape for this: scale_120 is stored on plugin state at every Configure and each render multiplies logical-pixel config values (radius_px, drift) by the current scale factor. (A text plugin can instead size by fraction of the physical surface and never read scale_120; position is a surface fraction and is deliberately not scaled. Both conventions are described in docs/plugin-api.md §HiDPI.)

output_name is the xdg_output.name string for the output this plugin instance is rendering for (e.g. "DP-1", "HDMI-A-1", "eDP-1"). Each [[plugin]] entry in the user's config is instantiated once per matching output (see docs/config.md §5); each instance is its own process and each gets its own Configure with the corresponding output_name.

Plugins that don't care about per-output behaviour ignore the field. Plugins that do care (a wallpaper rendering different images per monitor, a clock showing a different timezone per monitor) use output_name as the key for per-instance state — it is the only protocol-level signal that distinguishes "the DP-1 instance of plugin X" from "the HDMI-A-1 instance of plugin X."

The 64-byte cap matches Hello.plugin_name's cap. Real Wayland output names are short (typically 4–10 bytes); 64 is wildly generous and exists to bound the decoder's allocation. Empty output_name is accepted on the wire — it represents the rare case where the compositor has not yet delivered an xdg_output.name event for this output (transient, expected to resolve within one roundtrip).

7.2 FrameDone — tag 0x0002

Cue to render the next frame. Empty payload. Modelled on Wayland's frame callbacks: the host throttles rendering by withholding FrameDone.

7.3 BufferReleased — tag 0x0003

u32   id

Host is done sampling the buffer with this id; plugin may reuse it.

The host MUST send BufferReleased after it finishes sampling each buffer, on both sync paths. Plugins use it to gate the next render — overwriting the dmabuf before the release arrives races the host's GPU read. The host uses a host-side egress fence to know when sampling is complete.

An earlier revision of this spec let the host omit BufferReleased on the slow path ("plugins MUST tolerate not receiving it"). That permission is withdrawn: the reference host has always sent the release on both paths and the reference SDK has always required it, so no conforming implementation relied on the omission.

Future buffer-pool plugins will track release per-id so the pool's free-list reflects host-side completion. The current single-buffer plugin uses BufferReleased purely as a wait point, not as an id-keyed structure.

7.4 Shutdown — tag 0x0004

Empty payload. Plugin SHOULD exit cleanly within a short grace period (implementation-defined — the reference host allows a few hundred milliseconds). After the grace period the host will SIGTERM the plugin.

8. Handshake and lifecycle

 1. Host spawns plugin (socketpair + exec, fd 3, VEILAND_PLUGIN_SOCKET=3,
    optionally VEILAND_PLUGIN_CONFIG=<json> from [plugin.config]; see §2).
 2. Plugin sends u32 client_version = 1.
 3. Host sends u32 server_version = 1.
 4. Host sends u32 host_capabilities (bitfield; see §5.1).
 5. Plugin sends Hello.
 6. Host sends Configure.
 7. Host sends FrameDone.
 8. Plugin renders, sends Buffer (with dmabuf fd, plus a fence fd if
    fast-path; see §6.2).
 9. Host waits on the fence (if present), imports the dmabuf, and
    immediately sends FrameDone — the next-frame cue goes out on
    Buffer receipt, before compositing.
10. On its next repaint the host composites, swaps, waits for its
    egress sync, then sends BufferReleased (§7.3).
11. Repeat from 8.

Note the steady-state order: FrameDone for the next frame is sent when a Buffer arrives, before the host composites it; the BufferReleased for that buffer follows after the composite and the egress sync. A plugin must not assume either message arrives first — the reference FramePacer renders only once it holds both the frame cue and the release, whichever order they land in.

The list above is the success path. If any step fails (socket close, version mismatch, validation failure), the lifecycle ends and the surviving side cleans up. Either side may send Shutdown (host) or close the socket (plugin) at any time. Socket EOF on either side means the peer is gone; the other side cleans up.

9. Error handling

The host treats any protocol violation as plugin death, including but not limited to:

On violation: log the plugin name and the violation, close the socket. What the host does after that (draw a fallback, leave the region as-is, etc.) is host policy and out of scope for the protocol. Never panic. Never assert on plugin input. This rule is load-bearing for the threat model — see CLAUDE.md §"Threat model".

Plugins SHOULD treat any protocol violation from the host as a fatal error and exit. Hosts are trusted; a buggy host is a host bug worth surfacing.

10. Forward compatibility

11. Design decisions (resolved)

12. Rendering conventions

These describe how plugins should produce pixel data so the host's compositor blends multiple plugins correctly. They are not wire-format constraints — the wire bytes are unchanged — but the host assumes them.

12.1 Premultiplied alpha

Plugins emit pixels with premultiplied alpha: the RGB channels store the colour already scaled by alpha. A red pixel at 50% opacity is (0.5, 0.0, 0.0, 0.5), not (1.0, 0.0, 0.0, 0.5).

The host's compositor uses glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA), the correct blend function for premultiplied alpha. Plugins that emit straight alpha (RGB not scaled by alpha) will appear too bright where their regions overlap lower-z plugins.

This convention was chosen because veiland-text (the glyph atlas renderer used by the core's password field) emits premultiplied alpha — glyph coverage composites correctly only once under ONE / ONE_MINUS_SRC_ALPHA. Straight alpha with that blend function double-applied coverage and produced a halo around text edges. Premultiplying in the fragment shader is one extra multiply per pixel and the failure mode (washed-out blending) is visible immediately.

13. Known deviations (reference host)

The reference host (veiland-core) is intentionally more lenient than this spec in three places. Each is called out inline above; they are collected here as the backlog. All three are lenient — the host accepts input the spec says to reject — so none can crash the locker, leak, or affect the unlock decision, and the reference SDK never triggers any of them (it picks one fd-path at connect time, is single-buffer, and sends only tiny messages). Enforcing them touches the untrusted IPC hot path, so it is deferred rather than rushed. Until it lands, a plugin author MUST NOT rely on the host closing the socket in these cases.