ADR-018: The TFT frontend composes into one caller-owned full frame buffer

Date: 2026-08-07

Status: Accepted

Context

The classic T-Display panel is 135 by 240 pixels and consumes RGB565 over SPI. A frontend for it has to decide what it draws into before it decides what it draws, because that choice fixes the memory cost, what a host test can assert, and whether the fallback path and the pack path share one drawing discipline.

v0.5.0 puts the composition decisions on the development host. frontends/tft maps a snapshot to a role, resolves the role to a frame, scales and places it, and draws a procedural fallback when no asset is available. All of that is decision code, and ADR-016 already decided that the hosted gate covers it through the firmware-tft composition. A drawing target that only exists inside the SDK would put those decisions back out of reach.

The device has about 320 kilobytes of internal DRAM and no PSRAM on this variant, so a full frame is a real fraction of the budget rather than a rounding error. The pack, by contrast, is read-only bytes in flash and is read in place, so it costs image space rather than RAM.

Decision

The frontend composes into one full frame buffer of the panel geometry, and that buffer belongs to the caller.

PetFrontendTftSurface is a view of caller-supplied bytes with the panel extents. A pixel is RGB565 written high byte first, which is the order an ST7789 consumes, so a composed buffer transfers without a per-pixel conversion and the layout does not depend on the endianness of whatever built it. Every drawing operation clips against the extents, so a rectangle that overhangs an edge is bounded rather than refused and no operation can write outside the buffer it was given.

The cost is fixed and known. At 135 by 240 in RGB565 one frame is 64800 bytes, allocated once by the caller and reused for every iteration, and the frontend adds nothing per frame because it allocates nothing at all.

The buffer being caller-owned is what lets a host test hand the frontend an ordinary array, compose into it, and assert the resulting bytes. The same contract lets the ESP-IDF backend hand it DMA-capable storage without the frontend knowing what DMA is.

Consequences

The frontend performs no allocation, holds no static mutable state, and needs no platform header, so the whole composition path is a function of its inputs. A host test asserts composed pixels rather than a sequence of calls, which is what makes the release’s rendering decisions checkable before a device sees them.

One discipline serves both paths. The procedural fallback and a pack frame are drawn by the same operations into the same buffer, so a panel that has no assets and a panel that has them differ in what is drawn rather than in how it reaches the glass.

The memory is committed for the life of the run. 64800 bytes is roughly a fifth of the internal DRAM this board has, and it is spent whether or not the frame changed. The measured heap readings that PBI-112 collects are what tell a later release whether that was the right trade.

The transfer stays whole-frame. Composition covers the whole buffer and the backend sends the whole buffer, so the saving available to this release is skipping unchanged frames rather than sending smaller ones. PBI-115 owns that skip, and PBI-116 owns the transfer.

Alternatives considered

Stream rows or stripes into a small buffer and transfer each one as it is composed. Rejected because it saves memory the board has and costs the property the release is built on. A stripe composer has to know which part of the pet falls inside the stripe, so placement and clipping become stateful across calls and a host test asserts a sequence of partial writes instead of a frame. It also multiplies the SPI transactions for the same pixels. The saving is real and this release does not need it, so the decision is recorded rather than closed. A later panel that does not fit in DRAM reopens it.

Track a changed region and transfer only that rectangle. Rejected for this release because the frontend would have to hold what it drew last in order to know what changed, which makes a pure composition stateful in exchange for a saving nothing has shown is needed. A full 64800-byte frame costs about 13 milliseconds on the bus at 40 MHz, and the canonical animations change frames a few times a second. Skipping an unchanged frame captures most of that saving without holding a dirty region, and the soak measurements decide whether a later release wants more.

Hold two buffers and swap them. Rejected because it doubles the largest RAM consumer to remove tearing that a whole-frame transfer at this rate does not visibly produce, and because the second buffer would only earn its place if composition and transfer overlapped, which needs asynchronous transfer support this release does not take.

Draw through esp_lcd primitives instead of a buffer. Rejected because it puts every composition decision behind the SDK, so nothing but a device could assert them, and it would leave the fallback and the pack path drawing through different mechanisms.

Compose into an 8-bit palette buffer and convert while transferring. Rejected because it saves 32400 bytes at the price of a palette the canonical antialiased sprites do not fit, a conversion step on the transfer path, and a second pixel encoding in a release that already fixes one in the pack.

Let the frontend own the buffer rather than the caller. Rejected because the frontend would then allocate, or carry a fixed array sized for one panel. Allocating contradicts the release’s memory rules, and a fixed array would make the frontend’s storage a compile-time property of a board profile it deliberately does not read.

References