E-ink display pipeline

July 10, 2026 · View on GitHub

How the reMarkable Paper Pro Move drives its E Ink Gallery 3 (color) panel and how to drive it from a custom Linux installation.

Summary

The panel is E Ink Gallery 3 — a color e-paper display (1696×954, 264 DPI). The display pipeline is standard DRM/KMS with a reMarkable-specific frame format. The kernel drivers (imxdrm, imx93-parallel-disp-fmt, imx-lcdifv3-crtc, panel-rm-cumulus) are built-in and work correctly. The display requires a userspace driver — the kernel DRM side presents /dev/dri/card0 but raw scanout produces no visible output without the correct temporal frame sequences that encode the e-ink waveform.

The working approach is to reuse the vendor's libqsgepaper waveform engine via a Qt Quick application and the epaper QPA plugin. This is what einkbridge does. libqsgepaper handles both B&W (DU fast path) and full Gallery 3 color rendering automatically based on the content being displayed. See ../eink/src/einkbridge.cpp.

Hardware pipeline

LCDIFv3 → LDB (fsl,imx93-ldb-nxp) → remarkable,cumulus-panel (LVDS)
  • DRM device: /dev/dri/card0, connector `LVDS-1$ (\text{id} 36), \text{CRTC} \text{id} 34, \text{plane} \text{id} 32
  • \text{LVDS} \text{transport} \text{mode}: 365 \times 1700 @ 85 \text{Hz}, \text{pixel} \text{clock} 56 \text{MHz}
  • \text{Pixel} \text{format}: \text{RGB565} ($RG16`), 2 bytes/px, stride 730
  • Frame size: 365 × 1700 × 2 = 1,241,000 bytes

The cumulus controller converts scanned-out LVDS frames to e-ink drive signals. Waveform selection is not out-of-band — the frame sequence is the waveform.

Cumulus disambiguation. This panel is driven purely over LVDS — no MIPI-DSI, no FPGA bitstream, no EPDC/PXP. The BSP also ships rm-cumulus-bridge.c (an i2c + fpga-mgr + DSI driver), but that is for the bigger Paper Pro (ferrari), NOT chiappa — ignore it when reading vendor source.

Frame format

Each 365×1700 RGB565 frame requires a fixed control structure. Frames missing this structure are silently ignored by the controller.

RegionContent
Row 0Header: 0x2000 in cols 1–22, 0x3000 mid; constant
Col 00x0000
Cols 1–180x8000 — vertical sync stripe (constant)
Cols 19–220x0000 gap
Cols 23–570x1000 + per-row 0x5000 marker (fixed structure)
Cols 58–274, rows 208–1578Active image region
Cols 275–3640x0000 / 0x1000 (fixed)

Only the active image region (cols 58–274 × rows 208–1578) should be modified. All other bytes must be left byte-identical to a valid vendor frame. The active region maps approximately linearly to the visible screen area.

Pixel values (DU / fast B&W waveform)

These values appear in the active region when libqsgepaper is operating in the Direct Update (DU) waveform mode — a 2-level (black/white only) fast-path.

ValueMeaning
0x0000No-op
0x1000Hold (no-op in partial mode; white under a full-clear sequence)
0x1db6Black (darkest content value)
0x16db, 0x1492Full-screen flash / clear levels
0x8000Sync stripe value

colortable_*.bin is a 65536×8 LUT indexed by the 16-bit transport pixel value, corroborating these B&W mappings. In DU mode, any gray value (between 0x0000 and 0x1db6) is thresholded to one of the two levels and does not render as gray.

The panel is E Ink Gallery 3 (color e-paper). Full-color and grayscale rendering requires a multi-frame waveform sequence rather than the DU fast path. libqsgepaper selects the waveform automatically based on the rendering mode requested via EPScreenModeItem.

Color waveform tables live alongside the B&W tables in /usr/share/remarkable/:

File patternPurpose
GAL3_<lot>_*.einkPer-lot color waveform (libqsgepaper selects by panel serial)
ct33_fast.binGallery 3 color LUT — fast update (lower quality)
ct33_std.binGallery 3 color LUT — standard quality
ct33_best.binGallery 3 color LUT — best quality (slowest)
ct33_pen.binGallery 3 color LUT — optimized for pen/stylus input
colortable_*.binB&W/grayscale LUT (same quality tiers)

The ct33_*.bin files map RGB input values to EPD drive sequences for the Gallery 3 color particle set. Color rendering uses significantly more frames than DU and takes noticeably longer to settle on screen.

Update mechanism

The vendor application (xochitl) drives the display via DRM_IOCTL_MODE_ATOMIC at 85 Hz, page-flipping through a ring of framebuffers. Only the FB_ID plane property changes per commit — there is no out-of-band waveform or EPD property.

Full refresh vs. partial update differs only in the frame sequence content:

  • Full refresh: the sequence includes black flash frame(s) before the content frame ([black ×N] → [white ×N] → [content]), de-ghosting the panel
  • Partial update: content frame only, may leave ghosting

Each framebuffer in the sequence is held for approximately 140 ms (~12 atomic commits) before advancing. A single held frame will drive the panel slowly toward its target values over approximately 6 seconds (~510 scans).

Using the vendor waveform engine

libqsgepaper.so provides EPFramebuffer / EPFramebufferSwtcon, a software TCON with a phase-generator thread. It loads the panel's .eink waveform file, sets the EPD bias voltage, reads panel temperature, and drives /dev/dri/card0.

The correct entry point is the Qt Quick + epaper QPA path, not the lower-level setBuffers/swapBuffers API. The QPA plugin owns the buffer protocol and handles timing internally.

Launch:

QT_QUICK_BACKEND=epaper  QT_QPA_PLATFORM=epaper \
QT_PLUGIN_PATH=/usr/lib/plugins  QT_QPA_FONTDIR=/usr/share/fonts \
./einkbridge  scene.qml

The epaper platform reports screenGeometry 954×1696 and wires evdev touch and keyboard input. The EPScreenModeItem QML type exposes per-region refresh mode control (Pen / Mono / Animation / UI / Content).

Build

Cross-compile for aarch64-glibc:

aarch64-unknown-linux-gnu-g++ -std=c++20 \
  $(pkg-config --cflags Qt6Quick Qt6Qml Qt6Gui Qt6Core) \
  app.cpp -L <vendor-Qt6-lib-dir> \
  -lQt6Quick -lQt6Qml -lQt6Gui -lQt6Core \
  -Wl,--unresolved-symbols=ignore-in-shared-libs
patchelf --set-interpreter /lib/ld-linux-aarch64.so.1 app

Use a consistent Qt6 version for headers and libraries. See ../eink/scripts/build-bridge.sh.

Runtime constraints

  • Single instance: SWTCON takes an exclusive lock at /tmp/epframebuffer.lock. Two concurrent instances will conflict.
  • /tmp/epd.lock must exist (zero-byte, execute bit set) before launch.
  • /dev/dri/card0 must not be held open by any other process.
  • On musl-based distros, the entire Qt6 + libqsgepaper runtime must be bundled as a self-contained glibc environment (conventionally at /opt/eink/).

Display bridge architecture

All userspace display access must go through the vendor engine. The display bridge (einkbridge) provides:

  1. Direct rendering via Qt Quick QML scenes (einkbridge + bridge.qml)
  2. rm2fb IPC — shared memory at /dev/shm/swtfb$ (954 \times 1696 \text{RGB565}) \text{and} \text{Unix} \text{socket} \text{at} $/tmp/swtfb.ipc for rm2fb-compatible applications (KOReader etc.)
  3. VKMS bridge (vkms_bridge) — captures a Wayland compositor's output from the VKMS virtual DRM device (/dev/dri/card1) via its writeback connector (the compositor drives a DRM-leased plane) and forwards frames to the rm2fb layer. See the chiappa README's "VKMS bridge" section

Alternative: reimplementing from the .eink format

The .eink format is reMarkable's custom waveform format (not standard .wbf). colortable_*.bin is a 65536×8 per-value drive-curve LUT. A fully open implementation would decode these files and implement the phase-generator directly, using libqsgepaper as an oracle during development.

This panel's waveform lot: AAB0B7 (from devconfig serial_number_epd), VCOM −0.58 V. Waveform files are in ../firmware/waveforms/.

Capturing vendor frames (reverse-engineering method)

To decode what the vendor engine actually does, you must capture its frames — but xochitl's DRM framebuffers cannot be read via /proc/<pid>/mem (they're VM_PFNMAPEIO). The working method is an LD_PRELOAD shim inside xochitl that hooks drmModeSetCrtc / ioctl and traces/decodes the MODE_ATOMIC commits (the epshim variants) — this is how "the frame sequence is the waveform, no out-of-band trigger" was proven. Any fully-open engine reimplementation would redo this to validate against libqsgepaper.