LCD Display External Protocol
Wire protocol the LCD Display
device (config type display/lcd) uses to stream its composited frame data
to an external peripheral process — the bundled emma65-lcd-display binary,
or a replacement for it — over an attached
transport, when running the plain
emma65 CLI standalone. It’s unrelated to how the debugger renders the same
device in-process, which needs no wire protocol at all (same address space,
same process), and unrelated to the
Character Display External Protocol
and LED Matrix External Protocol — different
devices with different needs. See
Running the LCD Display Peripheral
for how to configure and launch emma65-lcd-display itself, and
LCD Display for the device’s
bus-facing register behavior — this page covers only what crosses the
transport.
Transport requirements
Exactly one connection, outbound only (device → peripheral) — like the LED
Matrix, and unlike the Character Display, this device has no input
capability at all. The transport send must be atomic — either the whole
buffer is written or none of it is — which in practice means only pipe: is
supported; any other transport spec is rejected for a display/lcd device
at configuration time.
Message framing
The header is sent exactly once, immediately when the transport is attached. Every subsequent message is a frame, sent whenever the device pushes a frame — i.e. after every register write that could change what’s rendered, with no periodic cadence at all, unlike the Character Display’s per-vsync or LED Matrix’s per-swap sends.
Unlike both of those protocols, a frame here is not a fixed size for the
life of the connection: Function Set’s F bit can switch the active font
between 5×8 and 5×10 dots at any time, changing every subsequent frame’s
pixel height. So each frame message carries its own width_px/height_px
fields rather than relying on the header alone to fix a size. There are
still no separate length prefixes or delimiters beyond those two fields: a
frame’s total size is always exactly 4 + width_px * height_px * 4 bytes,
which the receiver can compute as soon as it has read those two fields. This
is only safe because of the transport atomicity requirement above: a
transport that could deliver a partial frame would desync the stream
permanently, with no way to resynchronize.
A frame the transport can’t accept immediately (its outbound buffer still full of an earlier, not-yet-drained message) is not lost: the device keeps it and retries on every subsequent CPU cycle until it goes through. A later write that composites a newer frame before the retry succeeds replaces the pending one outright rather than queuing behind it, since only the current state is ever worth delivering. This guarantees the peripheral eventually catches up to whatever the device last rendered, even after a burst of writes outruns the peripheral’s read/render rate — unlike the Character Display’s or LED Matrix’s periodic cadence, which corrects itself on the next tick regardless, a permanently dropped frame here would otherwise leave the peripheral showing stale content indefinitely.
Header (sent once, on attach)
| Field | Type | Size (bytes) | Notes |
|---|---|---|---|
magic | ASCII | 4 | "E65L" — distinct from "E65D" (display) and "E65M" (LED matrix) |
version | u8 | 1 | 1 |
columns | u8 | 1 | configured character grid width |
rows | u8 | 1 | configured character grid height |
background | RGB24 | 3 | r, g, b — configuration-time-fixed |
foreground | RGB24 | 3 | r, g, b — configuration-time-fixed |
Total header size: 13 bytes. columns/rows are the character grid
dimensions, not pixel dimensions — a peripheral wanting pixel dimensions
ahead of the first frame can compute an upper bound (columns * 5 by
rows * 10) but must still read each frame’s own width_px/height_px to
render it, since the actual font in use isn’t known until the first frame
arrives. background/foreground are carried here, not derived from frame
pixel data, so a peripheral can replicate the debugger panel’s dot-matrix
cosmetics (see below) without having to reverse-engineer which composited
pixels are “on” vs. “off”.
Frame (sent whenever the device pushes a frame)
| Field | Type | Size (bytes) | Notes |
|---|---|---|---|
width_px | u16 LE | 2 | columns * 5 (fixed glyph cell width) |
height_px | u16 LE | 2 | rows * 8 or rows * 10, depending on the active font |
pixels | raw | width_px * height_px * 4 | RGBA, row-major, top row first, 4 bytes per pixel |
Total message size: 4 + width_px * height_px * 4 bytes. The pixel data
sent is always exactly what the device’s compositing produces for its
current DDRAM/CGRAM/CGROM/cursor/mode state — already fully composited
(background/foreground baked into each pixel, cursor drawn, blank when
display_on is false). There is no palette to separately transmit or
retain, unlike the Character Display or LED Matrix: this device’s only two
colors are the header’s fixed background/foreground.
Rendering cosmetics are the peripheral’s responsibility
The device-side compositing produces a flat one-RGBA-pixel-per-dot buffer
with no visual polish — no gaps, no rounded corners, no dim “off” state.
That cosmetic dot-matrix rendering (rounded dots, inter-dot and inter-cell
gaps, a dimly-visible off state rather than flat background) deliberately
lives independently in each renderer rather than in a shared library — this
protocol carries the same undecorated raw buffer the debugger panel receives
in-process, and a companion peripheral is expected to apply its own
equivalent cosmetic treatment using its own native drawing primitives (the
same split the LED Matrix’s protocol and emma65-led-matrix already use for
round-LED rendering). A peripheral can distinguish an “on”
dot from an “off” one the same way the debugger panel does: a pixel exactly
equal to the header’s background triple is “off”; anything else is “on”
(in practice, always exactly the header’s foreground triple).
Startup state and reconnection
There is no reconnection support — the design assumes a single spawned
child process tied to the device’s lifetime, mirroring the Character
Display’s and LED Matrix’s companion processes. A peripheral that attaches sees no frame
at all until the device’s next render-affecting register write — unlike the
debugger panel, which can fetch a cached last-delivered frame on mount,
there is no equivalent “replay the last frame” mechanism over this
protocol, so a freshly attached peripheral should render a blank grid (in
background) until its first frame arrives.
Non-goals
No protocol negotiation — a peripheral that doesn’t recognize a header’s
version should refuse to proceed rather than guess at a compatible
framing. No inbound direction (this device has no input capability at
all). No power/brightness/contrast messages (the HD44780 has no such
registers, and unlike the LED Matrix’s power/brightness messages, nothing in
this device’s spec calls for them).