From ede6e5b9f3b152364935f051771dbef96105a6a0 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Tue, 21 Jul 2026 10:27:17 -0400 Subject: [PATCH] Render X3 reader grayscale correctly (persistence + 4-level overlay) The reader draws a crisp BW page, then a 4-level grayscale antialiasing pass with inverted plane polarity (white = level 0 = both planes clear) and near-empty planes. The old model replaced the whole frame with those planes, wiping the page to an inverted muddle. Model it as e-ink actually behaves: keep a persistent surface, detect grayscale vs BW by DTM2's set-fraction (background is the white majority), and in grayscale treat level 0 as no-drive (keep pixel) so only the sparse antialiased edges repaint over the retained BW page. BW refreshes still repaint every pixel. Reader text now renders sharp. --- hw/display/xteink_x3_eink.c | 43 +++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/hw/display/xteink_x3_eink.c b/hw/display/xteink_x3_eink.c index 8a177e9159..fed136f56f 100644 --- a/hw/display/xteink_x3_eink.c +++ b/hw/display/xteink_x3_eink.c @@ -71,19 +71,54 @@ static void xteink_wasm_frame_updated(QemuConsole *console) #define BUSY_TIME_NS (5 * SCALE_MS) +/* 4-level grayscale drive shades for levels 1..3; level 0 = no drive (keep). */ +static const uint32_t xteink_x3_gray[4] = { + 0x00ffffff, 0x00aaaaaa, 0x00555555, 0x00000000, +}; + static void xteink_x3_eink_render(XteinkX3EinkState *s) { DisplaySurface *surface = qemu_console_surface(s->console); uint32_t *pixels = (uint32_t *)surface_data(surface); + /* Mode Detection - BW and grayscale LUTs use inverted plane polarity and + * differ only in LUT waveform bytes we don't model. BW pages drive white as + * DTM2 bit=1 (mostly-set plane); the reader's 4-level grayscale drives + * white as level 0 = (DTM2,DTM1)=(0,0) (mostly-clear plane). The background + * is the pixel majority and is white, so DTM2's set-fraction separates them. + * Ceiling: a genuinely mostly-black BW frame would flip; fine for text. + * Upgrade path: decode the loaded LUT directly. */ + unsigned dtm2_set = 0; + for (unsigned i = 0; i < XTEINK_X3_PLANE_SIZE; i++) { + dtm2_set += __builtin_popcount(s->new_plane[i]); + } + bool grayscale = dtm2_set * 2 < XTEINK_X3_PLANE_SIZE * 8; + + /* Persistence - E-ink retains undriven pixels. The reader's grayscale pass + * is a sparse antialiasing overlay (planes near-empty) on top of the crisp + * BW page from the prior full refresh; level 0 = no drive, so keep the + * existing pixel and only repaint driven (level>0) ones. BW refreshes drive + * every pixel, so they repaint in full. The surface is never cleared. */ for (unsigned y = 0; y < XTEINK_X3_HEIGHT; y++) { - const uint8_t *row = &s->new_plane[(XTEINK_X3_HEIGHT - 1 - y) * - XTEINK_X3_WIDTH_BYTES]; + unsigned row_off = (XTEINK_X3_HEIGHT - 1 - y) * XTEINK_X3_WIDTH_BYTES; + const uint8_t *msb = &s->new_plane[row_off]; + const uint8_t *lsb = &s->old_plane[row_off]; for (unsigned x = 0; x < XTEINK_X3_WIDTH; x++) { + uint8_t bit = 0x80 >> (x % 8); unsigned screen_x = XTEINK_X3_HEIGHT - 1 - y; unsigned screen_y = x; - pixels[screen_y * XTEINK_X3_HEIGHT + screen_x] = - (row[x / 8] & (0x80 >> (x % 8))) ? 0x00ffffff : 0; + if (grayscale) { + unsigned level = ((msb[x / 8] & bit) ? 2 : 0) | + ((lsb[x / 8] & bit) ? 1 : 0); + if (level == 0) { + continue; + } + pixels[screen_y * XTEINK_X3_HEIGHT + screen_x] = + xteink_x3_gray[level]; + } else { + pixels[screen_y * XTEINK_X3_HEIGHT + screen_x] = + (msb[x / 8] & bit) ? 0x00ffffff : 0; + } } }