From b2809fdafd596eef53620efc58e82b71f398677f Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Mon, 20 Apr 2026 00:14:54 +0200 Subject: [PATCH 01/57] feat: factory LUT grayscale rendering engine and perf optimizations Add GRAY2 absolute encoding for 4-shade e-ink rendering via factory LUTs. Introduce FactoryFast and FactoryQuality GrayscaleMode variants with X3 fallback to Differential. Add direct-pixel BMP rendering path, 2-bit XTC plane support in Xtc parser, and HALF_REFRESH display mode in HalDisplay. displayXtchPlanes uses Differential on X3 (factory LUT not calibrated). Skip fadingFix power-down on first BW render after factory LUT to avoid redundant power cycle (display already off after 0xC7 sequence). --- lib/Epub/Epub/blocks/ImageBlock.cpp | 4 +- lib/Epub/Epub/converters/DirectPixelWriter.h | 131 +++-- .../converters/JpegToFramebufferConverter.cpp | 16 +- .../converters/PngToFramebufferConverter.cpp | 4 +- lib/FsHelpers/FsHelpers.cpp | 2 + lib/FsHelpers/FsHelpers.h | 3 + lib/GfxRenderer/Bitmap.cpp | 45 +- lib/GfxRenderer/Bitmap.h | 10 + lib/GfxRenderer/BitmapHelpers.cpp | 30 +- lib/GfxRenderer/BitmapHelpers.h | 57 ++- lib/GfxRenderer/GfxRenderer.cpp | 459 ++++++++++++++++-- lib/GfxRenderer/GfxRenderer.h | 65 ++- lib/Xtc/Xtc.cpp | 416 +++++++++++----- lib/Xtc/Xtc.h | 2 + lib/Xtc/Xtc/XtcParser.cpp | 128 +++++ lib/Xtc/Xtc/XtcParser.h | 23 + lib/hal/HalDisplay.cpp | 4 +- lib/hal/HalDisplay.h | 2 +- open-x4-sdk | 2 +- 19 files changed, 1146 insertions(+), 257 deletions(-) diff --git a/lib/Epub/Epub/blocks/ImageBlock.cpp b/lib/Epub/Epub/blocks/ImageBlock.cpp index 1b71817a89..d234887a0f 100644 --- a/lib/Epub/Epub/blocks/ImageBlock.cpp +++ b/lib/Epub/Epub/blocks/ImageBlock.cpp @@ -74,13 +74,13 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, } const int destY = y + row; - pw.beginRow(destY); + pw.beginRow(destY, x); for (int col = 0; col < cachedWidth; col++) { const int byteIdx = col >> 2; // col / 4 const int bitShift = 6 - (col & 3) * 2; // MSB first within byte uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03; - pw.writePixel(x + col, pixelValue); + pw.writePixel(pixelValue); } } diff --git a/lib/Epub/Epub/converters/DirectPixelWriter.h b/lib/Epub/Epub/converters/DirectPixelWriter.h index bc66c2f78e..44bb56fd09 100644 --- a/lib/Epub/Epub/converters/DirectPixelWriter.h +++ b/lib/Epub/Epub/converters/DirectPixelWriter.h @@ -6,16 +6,26 @@ // Direct framebuffer writer that eliminates per-pixel overhead from the image // rendering hot path. Pre-computes orientation transform as linear coefficients -// and caches render-mode state so the inner loop is: one multiply, one add, -// one shift, and one AND per pixel — no branches, no method calls. +// and caches render-mode state so the inner loop is: two increments, one shift, +// one AND, and one or two bit-writes per pixel — no multiplies, no branches on +// mode or orientation, no method calls. // -// Caller is responsible for ensuring (outX, outY) are within screen bounds. -// ImageBlock::render() already validates this before entering the pixel loop, -// and the JPEG/PNG callbacks pre-clamp destination ranges to screen bounds. +// Usage: +// pw.init(renderer); +// for each row: pw.beginRow(logicalY); // resets running X/Y state to col 0 +// for each col: pw.writePixel(value); // advances running state, writes bit +// +// Caller must call writePixel() for every column in order (0, 1, 2, …) because +// the running state is advanced unconditionally on each call. Caller is also +// responsible for ensuring columns are within screen bounds before entering the +// loop; no bounds checking is performed here. struct DirectPixelWriter { uint8_t* fb; - GfxRenderer::RenderMode mode; - uint16_t displayWidthBytes; // Runtime framebuffer stride (X4: 100, X3: 99) + uint8_t* fb2; // Secondary framebuffer for MSB plane (null = two-pass / not active) + uint8_t writeFbMask; // Bit i set → write to fb when pixelValue==i (pre-computed from render mode) + uint8_t writeFb2Mask; // Bit i set → write to fb2 when pixelValue==i + bool fbClearBit; // true = clear bit (BW black); false = set bit (all gray modes) + uint16_t displayWidthBytes; // Runtime framebuffer stride // Orientation is collapsed into a linear transform: // phyX = phyXBase + x * phyXStepX + y * phyXStepY @@ -24,14 +34,44 @@ struct DirectPixelWriter { int phyXStepX, phyYStepX; // per logical-X step int phyXStepY, phyYStepY; // per logical-Y step - // Row-precomputed: the Y-dependent portion of the physical coords - int rowPhyXBase, rowPhyYBase; + // Pre-computed once in init(): physical-Y advance per logical-X step (in byte-index units). + int32_t byteIdxYStep; + + // Running state — reset by beginRow(), advanced by writePixel(). + int curPhyX; + int32_t curByteIdx; - void init(GfxRenderer& renderer) { + void init(const GfxRenderer& renderer) { fb = renderer.getFrameBuffer(); - mode = renderer.getRenderMode(); + fb2 = renderer.getSecondaryFrameBuffer(); displayWidthBytes = renderer.getDisplayWidthBytes(); + // Pre-compute write masks once so the inner loop has zero mode branches. + writeFbMask = 0; + writeFb2Mask = 0; + fbClearBit = false; + switch (renderer.getRenderMode()) { + case GfxRenderer::BW: + writeFbMask = 0x3; + fbClearBit = true; + break; + case GfxRenderer::GRAYSCALE_MSB: + writeFbMask = 0x6; + break; + case GfxRenderer::GRAYSCALE_LSB: + writeFbMask = 0x2; + break; + case GfxRenderer::GRAY2_LSB: + writeFbMask = 0x5; + if (fb2) writeFb2Mask = 0x3; + break; + case GfxRenderer::GRAY2_MSB: + writeFbMask = 0x3; + break; + default: + break; + } + const int phyW = renderer.getDisplayWidth(); const int phyH = renderer.getDisplayHeight(); @@ -82,52 +122,47 @@ struct DirectPixelWriter { phyYStepY = 1; break; } + + // Per-column advance in physical-Y expressed as a byte-index delta. + byteIdxYStep = static_cast(phyYStepX) * static_cast(displayWidthBytes); } // Call once per row before the column loop. - // Pre-computes the Y-dependent portion so writePixel() only needs the X part. - inline void beginRow(int logicalY) { - rowPhyXBase = phyXBase + logicalY * phyXStepY; - rowPhyYBase = phyYBase + logicalY * phyYStepY; + // startLogicalX is the X coordinate of the first writePixel() call for this row (default 0). + // Running state is initialised at startLogicalX so every subsequent writePixel() call + // advances to startLogicalX+1, startLogicalX+2, … with zero per-pixel multiplies. + inline void beginRow(int logicalY, int startLogicalX = 0) { + const int rowPhyXBase = phyXBase + logicalY * phyXStepY; + const int rowPhyYBase = phyYBase + logicalY * phyYStepY; + curPhyX = rowPhyXBase + startLogicalX * phyXStepX; + curByteIdx = + static_cast(rowPhyYBase + startLogicalX * phyYStepX) * static_cast(displayWidthBytes); } - // Write a single 2-bit dithered pixel value to the framebuffer. - // Must be called after beginRow() for the current row. + // Write a single 2-bit pixel value to the framebuffer and advance to the next column. + // Must be called after beginRow() for the current row, for every column in order. // No bounds checking — caller guarantees coordinates are valid. - inline void writePixel(int logicalX, uint8_t pixelValue) const { - // Determine whether to draw based on render mode - bool draw; - bool state; - switch (mode) { - case GfxRenderer::BW: - draw = (pixelValue < 3); - state = true; - break; - case GfxRenderer::GRAYSCALE_MSB: - draw = (pixelValue == 1 || pixelValue == 2); - state = false; - break; - case GfxRenderer::GRAYSCALE_LSB: - draw = (pixelValue == 1); - state = false; - break; - default: - return; - } - - if (!draw) return; - - const int phyX = rowPhyXBase + logicalX * phyXStepX; - const int phyY = rowPhyYBase + logicalX * phyYStepX; - - const uint16_t byteIndex = phyY * displayWidthBytes + (phyX >> 3); + // No mode switch — write masks are pre-computed in init() and stored as members. + inline void writePixel(uint8_t pixelValue) { + const int phyX = curPhyX; + const int32_t byteIdx = curByteIdx; + curPhyX += phyXStepX; + curByteIdx += byteIdxYStep; + + const bool doFb = (writeFbMask >> pixelValue) & 1; + const bool doFb2 = (writeFb2Mask >> pixelValue) & 1; + if (!doFb && !doFb2) return; + + const uint32_t bi = static_cast(byteIdx) + static_cast(phyX >> 3); const uint8_t bitMask = 1 << (7 - (phyX & 7)); - if (state) { - fb[byteIndex] &= ~bitMask; // Clear bit (draw black) - } else { - fb[byteIndex] |= bitMask; // Set bit (draw white) + if (doFb) { + if (fbClearBit) + fb[bi] &= ~bitMask; + else + fb[bi] |= bitMask; } + if (doFb2) fb2[bi] |= bitMask; } }; diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp index 4cf55ae3b5..f48d2fb4d5 100644 --- a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -168,7 +168,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { if (fineScaleFP == FP_ONE) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; - pw.beginRow(outY); + pw.beginRow(outY, cfgX + dstXStart); if (caching) cw.beginRow(outY, ctx->config->y); const uint8_t* row = &pixels[(dstY - blockY) * stride]; for (int dstX = dstXStart; dstX < dstXEnd; dstX++) { @@ -181,7 +181,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { dithered = gray / 85; if (dithered > 3) dithered = 3; } - pw.writePixel(outX, dithered); + pw.writePixel(dithered); if (caching) cw.writePixel(outX, dithered); } } @@ -202,7 +202,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; - pw.beginRow(outY); + pw.beginRow(outY, cfgX + dstXStart); if (caching) cw.beginRow(outY, ctx->config->y); const int32_t srcFyFP = dstY * invScaleFP; const int32_t fy = srcFyFP & FP_MASK; @@ -240,7 +240,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { dithered = gray / 85; if (dithered > 3) dithered = 3; } - pw.writePixel(outX, dithered); + pw.writePixel(dithered); if (caching) cw.writePixel(outX, dithered); } @@ -263,7 +263,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { dithered = gray / 85; if (dithered > 3) dithered = 3; } - pw.writePixel(outX, dithered); + pw.writePixel(dithered); if (caching) cw.writePixel(outX, dithered); } @@ -289,7 +289,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { dithered = gray / 85; if (dithered > 3) dithered = 3; } - pw.writePixel(outX, dithered); + pw.writePixel(dithered); if (caching) cw.writePixel(outX, dithered); } } @@ -299,7 +299,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { // === Nearest-neighbor (downscale: fineScale < 1.0) === for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; - pw.beginRow(outY); + pw.beginRow(outY, cfgX + dstXStart); if (caching) cw.beginRow(outY, ctx->config->y); const int32_t srcFyFP = dstY * invScaleFP; int ly = (srcFyFP >> FP_SHIFT) - blockY; @@ -322,7 +322,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { dithered = gray / 85; if (dithered > 3) dithered = 3; } - pw.writePixel(outX, dithered); + pw.writePixel(dithered); if (caching) cw.writePixel(outX, dithered); } } diff --git a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp index 0cc1616abc..ce0a4ab7f0 100644 --- a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -197,7 +197,7 @@ int pngDrawCallback(PNGDRAW* pDraw) { // Pre-compute orientation and render-mode state once per row DirectPixelWriter pw; pw.init(*ctx->renderer); - pw.beginRow(outY); + pw.beginRow(outY, outXBase); DirectCacheWriter cw; if (caching) { @@ -220,7 +220,7 @@ int pngDrawCallback(PNGDRAW* pDraw) { ditheredGray = gray / 85; if (ditheredGray > 3) ditheredGray = 3; } - pw.writePixel(outX, ditheredGray); + pw.writePixel(ditheredGray); if (caching) cw.writePixel(outX, ditheredGray); } diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp index 616b094b52..3f557f6ebd 100644 --- a/lib/FsHelpers/FsHelpers.cpp +++ b/lib/FsHelpers/FsHelpers.cpp @@ -66,6 +66,8 @@ bool hasPngExtension(std::string_view fileName) { return checkFileExtension(file bool hasBmpExtension(std::string_view fileName) { return checkFileExtension(fileName, ".bmp"); } +bool hasPxcExtension(std::string_view fileName) { return checkFileExtension(fileName, ".pxc"); } + bool hasGifExtension(std::string_view fileName) { return checkFileExtension(fileName, ".gif"); } bool hasEpubExtension(std::string_view fileName) { return checkFileExtension(fileName, ".epub"); } diff --git a/lib/FsHelpers/FsHelpers.h b/lib/FsHelpers/FsHelpers.h index f8af636a08..6880100b95 100644 --- a/lib/FsHelpers/FsHelpers.h +++ b/lib/FsHelpers/FsHelpers.h @@ -31,6 +31,9 @@ inline bool hasPngExtension(const String& fileName) { // Check for .bmp extension (case-insensitive) bool hasBmpExtension(std::string_view fileName); +// Check for .pxc extension (case-insensitive) +bool hasPxcExtension(std::string_view fileName); + // Check for .gif extension (case-insensitive) bool hasGifExtension(std::string_view fileName); inline bool hasGifExtension(const String& fileName) { diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 776e52f3b2..6231cfc30f 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -1,5 +1,7 @@ #include "Bitmap.h" +#include + #include #include @@ -19,6 +21,33 @@ Bitmap::~Bitmap() { delete atkinsonDitherer; delete fsDitherer; + + free(readChunkBuf); +} + +int Bitmap::bufferedRead(uint8_t* dst, int n) const { + if (!readChunkBuf) { + readChunkBuf = static_cast(malloc(READ_CHUNK_SIZE)); + if (!readChunkBuf) { + LOG_ERR("BMP", "bufferedRead: malloc failed (%d bytes)", READ_CHUNK_SIZE); + return 0; + } + } + + int done = 0; + while (done < n) { + if (readChunkPos >= readChunkFill) { + readChunkFill = file.read(readChunkBuf, READ_CHUNK_SIZE); + readChunkPos = 0; + if (readChunkFill <= 0) break; + } + const int avail = readChunkFill - readChunkPos; + const int take = (avail < n - done) ? avail : n - done; + memcpy(dst + done, readChunkBuf + readChunkPos, take); + readChunkPos += take; + done += take; + } + return done; } uint16_t Bitmap::readLE16(FsFile& f) { @@ -180,7 +209,7 @@ BmpReaderError Bitmap::parseHeaders() { // packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { // Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes' - if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; + if (bufferedRead(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; prevRowY += 1; @@ -193,16 +222,16 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { auto packPixel = [&](const uint8_t lum) { uint8_t color; if (atkinsonDitherer) { - color = atkinsonDitherer->processPixel(adjustPixel(lum), currentX); + color = atkinsonDitherer->processPixel(lum, currentX); } else if (fsDitherer) { - color = fsDitherer->processPixel(adjustPixel(lum), currentX); + color = fsDitherer->processPixel(lum, currentX); } else { if (nativePalette) { - // Palette matches native gray levels: direct mapping (still apply brightness/contrast/gamma) - color = static_cast(adjustPixel(lum) >> 6); + // Palette matches native gray levels: direct 2-bit mapping + color = static_cast(lum >> 6); } else { // Non-native palette with dithering disabled: simple quantization - color = quantize(adjustPixel(lum), currentX, prevRowY); + color = quantize(lum, currentX, prevRowY); } } currentOutByte |= (color << bitShift); @@ -287,6 +316,10 @@ BmpReaderError Bitmap::rewindToData() const { return BmpReaderError::SeekPixelDataFailed; } + // Invalidate chunk buffer — file position changed, buffered bytes are stale. + readChunkPos = 0; + readChunkFill = 0; + // Reset dithering when rewinding if (fsDitherer) fsDitherer->reset(); if (atkinsonDitherer) atkinsonDitherer->reset(); diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index bba4189f29..58abdb6faf 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -81,6 +81,10 @@ class Bitmap { static uint16_t readLE16(FsFile& f); static uint32_t readLE32(FsFile& f); + // Reads exactly n bytes from the internal chunk buffer, refilling from SD as needed. + // Returns bytes actually read (< n on EOF/error). + int bufferedRead(uint8_t* dst, int n) const; + FsFile& file; bool dithering = false; int width = 0; @@ -100,4 +104,10 @@ class Bitmap { mutable AtkinsonDitherer* atkinsonDitherer = nullptr; mutable FloydSteinbergDitherer* fsDitherer = nullptr; + + // SD read-ahead buffer: rows are served from here; refilled from SD in READ_CHUNK_SIZE chunks. + static constexpr int READ_CHUNK_SIZE = 4096; // 8 SD sectors per fill — enables CMD18 multi-block + mutable uint8_t* readChunkBuf = nullptr; + mutable int readChunkPos = 0; + mutable int readChunkFill = 0; }; diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp index dca059ec9e..11f93a6e9f 100644 --- a/lib/GfxRenderer/BitmapHelpers.cpp +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -5,11 +5,14 @@ #include "Bitmap.h" +// LUT-aware quantization profile: false = factory (default), true = differential +bool g_differentialQuantize = false; + // Brightness/Contrast adjustments: -constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments -constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50) +constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments +constexpr int BRIGHTNESS_BOOST = 0; // No boost — quality LUT already renders slightly lighter constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones) -constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast) +constexpr float CONTRAST_FACTOR = 1.2f; // Contrast boost for quality LUT (softer drive needs more contrast) constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering // Integer approximation of gamma correction (brightens midtones) @@ -52,18 +55,21 @@ int adjustPixel(int gray) { return gray; } -// Simple quantization without dithering - divide into 4 levels -// The thresholds are fine-tuned to the X4 display +// Simple quantization without dithering. +// Factory LUT (fast/quality): evenly-spaced thresholds — softer, linear drive. +// Differential LUT: calibrated thresholds from upstream — narrow darkGrey band, +// biased toward lighter levels to compensate for more aggressive drive. uint8_t quantizeSimple(int gray) { - if (gray < 45) { - return 0; - } else if (gray < 70) { - return 1; - } else if (gray < 140) { - return 2; - } else { + if (g_differentialQuantize) { + if (gray < 45) return 0; + if (gray < 70) return 1; + if (gray < 140) return 2; return 3; } + if (gray < 43) return 0; + if (gray < 128) return 1; + if (gray < 213) return 2; + return 3; } // Hash-based noise dithering - survives downsampling without moiré artifacts diff --git a/lib/GfxRenderer/BitmapHelpers.h b/lib/GfxRenderer/BitmapHelpers.h index d9d2d85544..e95a393360 100644 --- a/lib/GfxRenderer/BitmapHelpers.h +++ b/lib/GfxRenderer/BitmapHelpers.h @@ -11,6 +11,10 @@ uint8_t quantizeSimple(int gray); uint8_t quantize1bit(int gray, int x, int y); int adjustPixel(int gray); +// Set true when rendering for differential LUT to use calibrated thresholds. +// Set false (default) for factory LUT (fast/quality) — uses evenly-spaced thresholds. +extern bool g_differentialQuantize; + enum class BmpRowOrder { BottomUp, TopDown }; // Populates a 1-bit BMP header in the provided memory. @@ -128,35 +132,38 @@ class AtkinsonDitherer { if (adjusted > 255) adjusted = 255; // Quantize to 4 levels + // Differential LUT: calibrated thresholds matching upstream (narrow darkGrey band, + // biased toward light to compensate for more aggressive drive). + // Factory LUT (fast/quality): evenly-spaced thresholds — softer drive, linear response. uint8_t quantized; int quantizedValue; - if (false) { // original thresholds - if (adjusted < 43) { + if (g_differentialQuantize) { + if (adjusted < 45) { quantized = 0; quantizedValue = 0; - } else if (adjusted < 128) { + } else if (adjusted < 70) { quantized = 1; quantizedValue = 85; - } else if (adjusted < 213) { + } else if (adjusted < 140) { quantized = 2; quantizedValue = 170; } else { quantized = 3; quantizedValue = 255; } - } else { // fine-tuned to X4 eink display - if (adjusted < 30) { + } else { + if (adjusted < 43) { quantized = 0; - quantizedValue = 15; - } else if (adjusted < 50) { + quantizedValue = 0; + } else if (adjusted < 128) { quantized = 1; - quantizedValue = 30; - } else if (adjusted < 140) { + quantizedValue = 85; + } else if (adjusted < 213) { quantized = 2; - quantizedValue = 80; + quantizedValue = 170; } else { quantized = 3; - quantizedValue = 210; + quantizedValue = 255; } } @@ -231,36 +238,36 @@ class FloydSteinbergDitherer { if (adjusted < 0) adjusted = 0; if (adjusted > 255) adjusted = 255; - // Quantize to 4 levels (0, 85, 170, 255) + // Quantize to 4 levels — see Atkinson ditherer comment for threshold rationale uint8_t quantized; int quantizedValue; - if (false) { // original thresholds - if (adjusted < 43) { + if (g_differentialQuantize) { + if (adjusted < 45) { quantized = 0; quantizedValue = 0; - } else if (adjusted < 128) { + } else if (adjusted < 70) { quantized = 1; quantizedValue = 85; - } else if (adjusted < 213) { + } else if (adjusted < 140) { quantized = 2; quantizedValue = 170; } else { quantized = 3; quantizedValue = 255; } - } else { // fine-tuned to X4 eink display - if (adjusted < 30) { + } else { + if (adjusted < 43) { quantized = 0; - quantizedValue = 15; - } else if (adjusted < 50) { + quantizedValue = 0; + } else if (adjusted < 128) { quantized = 1; - quantizedValue = 30; - } else if (adjusted < 140) { + quantizedValue = 85; + } else if (adjusted < 213) { quantized = 2; - quantizedValue = 80; + quantizedValue = 170; } else { quantized = 3; - quantizedValue = 210; + quantizedValue = 255; } } diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index a343badccd..0e6cd684c0 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1,10 +1,12 @@ #include "GfxRenderer.h" +#include #include #include #include #include +#include "BitmapHelpers.h" #include "FontCacheManager.h" const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const { @@ -137,7 +139,15 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode // We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update renderer.drawPixel(screenX, screenY, false); } else if (renderMode == GfxRenderer::GRAYSCALE_LSB && bmpVal == 1) { - // Dark gray + // Differential LSB: mark dark gray pixels only + renderer.drawPixel(screenX, screenY, false); + } else if (renderMode == GfxRenderer::GRAY2_LSB && !(bmpVal & 1)) { + // Factory absolute LSB (BW RAM): set BW=1 for Black(0) and LightGrey(2) + // clearScreen(0x00) base; drawPixel(false) sets bit to 1 + renderer.drawPixel(screenX, screenY, false); + } else if (renderMode == GfxRenderer::GRAY2_MSB && bmpVal < 2) { + // Factory absolute MSB (RED RAM): set RED=1 for Black(0) and DarkGrey(1) + // clearScreen(0x00) base; drawPixel(false) sets bit to 1 renderer.drawPixel(screenX, screenY, false); } } @@ -160,7 +170,11 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode const uint8_t bit_index = 7 - (pixelPosition & 7); if ((byte >> bit_index) & 1) { - renderer.drawPixel(screenX, screenY, pixelState); + // In GRAY2 modes the framebuffer convention is inverted vs BW: clearScreen(0x00) is + // background and drawPixel(false) marks active pixels. BW-convention callers pass + // pixelState=true for "black" — invert here so 1-bit UI glyphs stay visible. + const bool gray2 = renderMode == GfxRenderer::GRAY2_LSB || renderMode == GfxRenderer::GRAY2_MSB; + renderer.drawPixel(screenX, screenY, gray2 ? !pixelState : pixelState); } } } @@ -179,7 +193,7 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { // Bounds checking against runtime panel dimensions if (phyX < 0 || phyX >= panelWidth || phyY < 0 || phyY >= panelHeight) { - LOG_ERR("GFX", "!! Outside range (%d, %d) -> (%d, %d)", x, y, phyX, phyY); + LOG_DBG("GFX", "!! Outside range (%d, %d) -> (%d, %d)", x, y, phyX, phyY); return; } @@ -189,11 +203,35 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { if (state) { frameBuffer[byteIndex] &= ~(1 << bitPosition); // Clear bit + // Single-pass: erasing a pixel must also clear the MSB plane so UI white fills (e.g. button + // hint backgrounds drawn on top of a full-screen image) fully erase image bits from both + // planes. Without this, image pixels remain in RED RAM and bleed through white areas. + if (renderMode == GRAY2_LSB && secondaryFrameBuffer != nullptr) { + secondaryFrameBuffer[byteIndex] &= ~(1 << bitPosition); + } } else { frameBuffer[byteIndex] |= 1 << bitPosition; // Set bit + // Single-pass: all set-bit draws in GRAY2_LSB mode (1-bit UI elements, text, icons) are + // treated as fully black and mirrored to the MSB plane so they don't render as light gray. + // Image pixels skip drawPixel and go through drawPixelToBuffer directly (see drawBitmap), + // so this path is only hit by 1-bit rendering (renderChar, drawIcon, etc.). + if (renderMode == GRAY2_LSB && secondaryFrameBuffer != nullptr) { + secondaryFrameBuffer[byteIndex] |= 1 << bitPosition; + } } } +// Writes a single pixel (always state=false / set bit) to an arbitrary buffer using the same +// orientation transform as drawPixel. Used by single-pass grayscale to write the MSB plane +// simultaneously with the LSB plane during a single renderFn call. +void GfxRenderer::drawPixelToBuffer(uint8_t* buf, const int x, const int y) const { + int phyX = 0, phyY = 0; + rotateCoordinates(orientation, x, y, &phyX, &phyY, panelWidth, panelHeight); + if (phyX < 0 || phyX >= panelWidth || phyY < 0 || phyY >= panelHeight) return; + const uint32_t byteIndex = static_cast(phyY) * panelWidthBytes + (phyX / 8); + buf[byteIndex] |= 1 << (7 - (phyX % 8)); +} + int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const { const auto fontIt = fontMap.find(fontId); if (fontIt == fontMap.end()) { @@ -275,19 +313,22 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const { if (fontCacheManager_ && fontCacheManager_->isScanning()) return; + // In GRAY2 modes the framebuffer convention is inverted vs BW: clearScreen(0x00) is background + // and drawPixel(false) marks active pixels. BW-convention callers pass state=true for "black". + const bool s = (renderMode == GRAY2_LSB || renderMode == GRAY2_MSB) ? !state : state; if (x1 == x2) { if (y2 < y1) { std::swap(y1, y2); } for (int y = y1; y <= y2; y++) { - drawPixel(x1, y, state); + drawPixel(x1, y, s); } } else if (y1 == y2) { if (x2 < x1) { std::swap(x1, x2); } for (int x = x1; x <= x2; x++) { - drawPixel(x, y1, state); + drawPixel(x, y1, s); } } else { // Bresenham's line algorithm — integer arithmetic only @@ -300,7 +341,7 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con int err = dx - dy; while (true) { - drawPixel(x1, y1, state); + drawPixel(x1, y1, s); if (x1 == x2 && y1 == y2) break; int e2 = 2 * err; if (e2 > -dy) { @@ -352,6 +393,8 @@ void GfxRenderer::drawArc(const int maxRadius, const int cx, const int cy, const const int outerRadiusSq = outerRadius * outerRadius; const int innerRadiusSq = innerRadius * innerRadius; + // Do NOT pre-invert state for GRAY2 here: fillRect→drawLine already handles the GRAY2 + // inversion. A pre-inversion here would double-invert (cancel out), rendering the wrong color. int xOuter = outerRadius; int xInner = innerRadius; @@ -454,22 +497,28 @@ void GfxRenderer::drawPixelDither(const int x, const int y) const template <> void GfxRenderer::drawPixelDither(const int x, const int y) const { - drawPixel(x, y, true); + const bool gray2 = renderMode == GRAY2_LSB || renderMode == GRAY2_MSB; + drawPixel(x, y, !gray2); } template <> void GfxRenderer::drawPixelDither(const int x, const int y) const { - drawPixel(x, y, false); + const bool gray2 = renderMode == GRAY2_LSB || renderMode == GRAY2_MSB; + drawPixel(x, y, gray2); } template <> void GfxRenderer::drawPixelDither(const int x, const int y) const { - drawPixel(x, y, x % 2 == 0 && y % 2 == 0); + const bool pix = x % 2 == 0 && y % 2 == 0; + const bool gray2 = renderMode == GRAY2_LSB || renderMode == GRAY2_MSB; + drawPixel(x, y, gray2 ? !pix : pix); } template <> void GfxRenderer::drawPixelDither(const int x, const int y) const { - drawPixel(x, y, (x + y) % 2 == 0); // TODO: maybe find a better pattern? + const bool pix = (x + y) % 2 == 0; // TODO: maybe find a better pattern? + const bool gray2 = renderMode == GRAY2_LSB || renderMode == GRAY2_MSB; + drawPixel(x, y, gray2 ? !pix : pix); } void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const { @@ -683,12 +732,88 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con return; } + // --- Pre-compute everything that is constant for the entire render --- + + // Orientation: collapse into 6 integer coefficients (same approach as DirectPixelWriter). + // phyX = phyXBase + screenY*phyXStepY + screenX*phyXStepX + // phyY = phyYBase + screenY*phyYStepY + screenX*phyYStepX + int phyXBase, phyYBase, phyXStepX, phyYStepX, phyXStepY, phyYStepY; + switch (orientation) { + case Portrait: + phyXBase = 0; + phyYBase = panelHeight - 1; + phyXStepX = 0; + phyYStepX = -1; + phyXStepY = 1; + phyYStepY = 0; + break; + case LandscapeClockwise: + phyXBase = panelWidth - 1; + phyYBase = panelHeight - 1; + phyXStepX = -1; + phyYStepX = 0; + phyXStepY = 0; + phyYStepY = -1; + break; + case PortraitInverted: + phyXBase = panelWidth - 1; + phyYBase = 0; + phyXStepX = 0; + phyYStepX = 1; + phyXStepY = -1; + phyYStepY = 0; + break; + case LandscapeCounterClockwise: + default: + phyXBase = 0; + phyYBase = 0; + phyXStepX = 1; + phyYStepX = 0; + phyXStepY = 0; + phyYStepY = 1; + break; + } + + // Per-val write masks (val is 2-bit: 0=black,1=darkGrey,2=lightGrey,3=white). + // Bit i of the mask is set when val==i should trigger a write. + // Evaluated once here; zero branch overhead inside the pixel loop. + uint8_t writeFbMask = 0; // which val values write to frameBuffer + uint8_t writeFb2Mask = 0; // which val values write to secondaryFrameBuffer (GRAY2_LSB single-pass only) + bool fbClearBit = false; // true = clear bit (BW black); false = set bit (all gray modes) + uint8_t* const fb2 = secondaryFrameBuffer; + + switch (renderMode) { + case BW: + writeFbMask = 0x3; + fbClearBit = true; + break; // val 0,1 (black+darkGrey) + case GRAYSCALE_MSB: + writeFbMask = 0x6; + break; // val 1,2 + case GRAYSCALE_LSB: + writeFbMask = 0x2; + break; // val 1 + case GRAY2_LSB: + writeFbMask = 0x5; // val 0,2 (LSB plane) + if (fb2) writeFb2Mask = 0x3; + break; // val 0,1 (MSB plane) + case GRAY2_MSB: + writeFbMask = 0x3; + break; // val 0,1 + default: + break; + } + + // Pre-computed for the unscaled incremental inner loop: stride through the physical Y axis per logical X step. + const int32_t byteIdxYStep = static_cast(phyYStepX) * static_cast(panelWidthBytes); + + // --- Outer row loop --- for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); bmpY++) { // The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative). // Screen's (0, 0) is the top-left corner. int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY); if (isScaled) { - screenY = std::floor(screenY * scale); + screenY = static_cast(std::floor(screenY * scale)); } screenY += y; // the offset should not be scaled if (screenY >= getScreenHeight()) { @@ -711,27 +836,63 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con continue; } - for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { - int screenX = bmpX - cropPixX; - if (isScaled) { - screenX = std::floor(screenX * scale); - } - screenX += x; // the offset should not be scaled - if (screenX >= getScreenWidth()) { - break; - } - if (screenX < 0) { - continue; - } - - const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; + // Pre-compute the Y-dependent portion of the physical coordinate transform once per row. + const int rowPhyXBase = phyXBase + screenY * phyXStepY; + const int rowPhyYBase = phyYBase + screenY * phyYStepY; - if (renderMode == BW && val < 3) { - drawPixel(screenX, screenY); - } else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) { - drawPixel(screenX, screenY, false); - } else if (renderMode == GRAYSCALE_LSB && val == 1) { - drawPixel(screenX, screenY, false); + if (isScaled) { + // Scaled path: float accumulator replaces per-column multiply. + // Integer coordinate multiplies remain but are rare (scaled images only). + float screenXF = static_cast(x); + for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++, screenXF += scale) { + const int screenX = static_cast(screenXF); + if (screenX >= getScreenWidth()) break; + if (screenX < 0) continue; + + const uint8_t val = (outputRow[bmpX >> 2] >> (6 - (bmpX & 3) * 2)) & 0x3; + const bool doFb = (writeFbMask >> val) & 1; + const bool doFb2 = (writeFb2Mask >> val) & 1; + if (!doFb && !doFb2) continue; + + const int phyX = rowPhyXBase + screenX * phyXStepX; + const int phyY = rowPhyYBase + screenX * phyYStepX; + const uint32_t byteIdx = static_cast(phyY) * panelWidthBytes + (phyX >> 3); + const uint8_t bitMask = 1 << (7 - (phyX & 7)); + if (doFb) { + if (fbClearBit) + frameBuffer[byteIdx] &= ~bitMask; + else + frameBuffer[byteIdx] |= bitMask; + } + if (doFb2) fb2[byteIdx] |= bitMask; + } + } else { + // Unscaled path: fully incremental — zero multiplies, zero float in the pixel loop. + // curPhyX and curByteIdxY start at screenX=x (when bmpX=cropPixX) and advance by + // phyXStepX / byteIdxYStep per column. The for-increment fires on every iteration + // including continue, so running state stays in sync with bmpX even for skipped pixels. + int curPhyX = rowPhyXBase + x * phyXStepX; + int32_t curByteIdx = static_cast(rowPhyYBase + x * phyYStepX) * static_cast(panelWidthBytes); + for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; + bmpX++, curPhyX += phyXStepX, curByteIdx += byteIdxYStep) { + const int screenX = bmpX - cropPixX + x; + if (screenX >= getScreenWidth()) break; + if (screenX < 0) continue; + + const uint8_t val = (outputRow[bmpX >> 2] >> (6 - (bmpX & 3) * 2)) & 0x3; + const bool doFb = (writeFbMask >> val) & 1; + const bool doFb2 = (writeFb2Mask >> val) & 1; + if (!doFb && !doFb2) continue; + + const uint32_t byteIdx = static_cast(curByteIdx) + static_cast(curPhyX >> 3); + const uint8_t bitMask = 1 << (7 - (curPhyX & 7)); + if (doFb) { + if (fbClearBit) + frameBuffer[byteIdx] &= ~bitMask; + else + frameBuffer[byteIdx] |= bitMask; + } + if (doFb2) fb2[byteIdx] |= bitMask; } } } @@ -796,10 +957,13 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, // Get 2-bit value (result of readNextRow quantization) const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; - // For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3) - // val < 3 means black pixel (draw it) + // For 1-bit source: val < 3 = black, val == 3 = white if (val < 3) { - drawPixel(screenX, screenY, true); + if (renderMode == GRAY2_LSB || renderMode == GRAY2_MSB) { + drawPixel(screenX, screenY, false); + } else { + drawPixel(screenX, screenY, true); + } } // White pixels (val == 3) are not drawn (leave background) } @@ -885,6 +1049,11 @@ void GfxRenderer::clearScreen(const uint8_t color) const { display.clearScreen(color); } +void GfxRenderer::setScreenshotHook(ScreenshotHook hook, void* ctx) { + screenshotHook = hook; + screenshotHookCtx = ctx; +} + void GfxRenderer::invertScreen() const { for (uint32_t i = 0; i < frameBufferSize; i++) { frameBuffer[i] = ~frameBuffer[i]; @@ -894,7 +1063,21 @@ void GfxRenderer::invertScreen() const { void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const { auto elapsed = millis() - start_ms; LOG_DBG("GFX", "Time = %lu ms from clearScreen to displayBuffer", elapsed); - display.displayBuffer(refreshMode, fadingFix); + // After a factory LUT render the display already powered down (0xC7 sequence). + // Requesting turnOffScreen=true here would immediately power on then off again, + // adding a full power cycle. Skip the power-down for this one transition. + const bool turnOff = (displayState == DisplayState::FactoryLut) ? false : fadingFix; + display.displayBuffer(refreshMode, turnOff); + displayState = DisplayState::BW; +} + +void GfxRenderer::displayGrayBuffer(const unsigned char* lut, const bool factoryMode) const { + display.displayGrayBuffer(fadingFix, lut, factoryMode); + if (factoryMode) { + displayState = DisplayState::FactoryLut; + } else { + displayState = DisplayState::BW; + } } std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, @@ -1172,7 +1355,211 @@ void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuff void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(frameBuffer); } -void GfxRenderer::displayGrayBuffer() const { display.displayGrayBuffer(fadingFix); } +void GfxRenderer::renderGrayscale(GrayscaleMode mode, void (*renderFn)(const GfxRenderer&, const void*), + const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*), + const void* preFlashCtx) { + if (mode == GrayscaleMode::FactoryFast || mode == GrayscaleMode::FactoryQuality) { + // Pre-flash to white so the factory LUT can drive particles reliably from any prior state. + // Without this, particles stranded at intermediate grays may not complete their transition: + // from a known-white state only downward transitions are needed, which both LUTs handle cleanly. + // + // HALF_REFRESH (CTRL1_BYPASS_RED) guarantees true white regardless of RED RAM sync state. + // FAST_REFRESH is differential against RED RAM — after any prior grayscale operation the RED RAM + // may be stale (e.g. chapter menu rendered while display shows gray), so pixels the controller + // believes are already white may physically be at gray or chapter-menu positions and won't be + // driven to white, corrupting the subsequent gray render. + clearScreen(); + if (preFlashOverlayFn) preFlashOverlayFn(*this, preFlashCtx); + displayBuffer(HalDisplay::HALF_REFRESH); + } + + const RenderMode lsbMode = (mode == GrayscaleMode::Differential) ? GRAYSCALE_LSB : GRAY2_LSB; + const RenderMode msbMode = (mode == GrayscaleMode::Differential) ? GRAYSCALE_MSB : GRAY2_MSB; + const bool factoryMode = (mode != GrayscaleMode::Differential); + const unsigned char* lut = (mode == GrayscaleMode::FactoryFast) ? lut_factory_fast + : (mode == GrayscaleMode::FactoryQuality) ? lut_factory_quality + : nullptr; + + g_differentialQuantize = (mode == GrayscaleMode::Differential); + + clearScreen(0x00); + setRenderMode(lsbMode); + renderFn(*this, ctx); + + // Save LSB plane for screenshot hook (needs both planes simultaneously). + uint8_t* lsbCopy = nullptr; + if (screenshotHook && factoryMode) { + lsbCopy = static_cast(malloc(frameBufferSize)); + if (lsbCopy) { + memcpy(lsbCopy, frameBuffer, frameBufferSize); + } else { + // Allocation failed — disarm the one-shot hook so it doesn't fire on a future render. + screenshotHook = nullptr; + screenshotHookCtx = nullptr; + } + } + copyGrayscaleLsbBuffers(); + + clearScreen(0x00); + setRenderMode(msbMode); + renderFn(*this, ctx); + copyGrayscaleMsbBuffers(); + + // Fire hook: LSB = lsbCopy, MSB = frameBuffer (still holds second-pass data). + if (screenshotHook && factoryMode && lsbCopy) { + screenshotHook(lsbCopy, frameBuffer, panelWidth, panelHeight, screenshotHookCtx); + screenshotHook = nullptr; + screenshotHookCtx = nullptr; + } + if (lsbCopy) { + free(lsbCopy); + lsbCopy = nullptr; + } + + g_differentialQuantize = false; + + displayGrayBuffer(lut, factoryMode); + setRenderMode(BW); +} + +void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn)(const GfxRenderer&, const void*), + const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*), + const void* preFlashCtx) { + if (mode == GrayscaleMode::FactoryFast || mode == GrayscaleMode::FactoryQuality) { + clearScreen(); + if (preFlashOverlayFn) preFlashOverlayFn(*this, preFlashCtx); + displayBuffer(HalDisplay::HALF_REFRESH); + } + + const RenderMode lsbMode = (mode == GrayscaleMode::Differential) ? GRAYSCALE_LSB : GRAY2_LSB; + const bool factoryMode = (mode != GrayscaleMode::Differential); + const unsigned char* lut = (mode == GrayscaleMode::FactoryFast) ? lut_factory_fast + : (mode == GrayscaleMode::FactoryQuality) ? lut_factory_quality + : nullptr; + + g_differentialQuantize = (mode == GrayscaleMode::Differential); + + // Allocate secondary buffer for the MSB plane. + auto* secBuf = static_cast(malloc(frameBufferSize)); + if (!secBuf) { + LOG_ERR("GFX", "renderGrayscaleSinglePass: malloc failed (%lu bytes), falling back to two-pass", + static_cast(frameBufferSize)); + // Disarm hook — the two-pass fallback does not capture both planes simultaneously. + screenshotHook = nullptr; + screenshotHookCtx = nullptr; + // Pre-flash already done; run two-pass directly without repeating it. + clearScreen(0x00); + setRenderMode(lsbMode); + renderFn(*this, ctx); + copyGrayscaleLsbBuffers(); + clearScreen(0x00); + setRenderMode(mode == GrayscaleMode::Differential ? GRAYSCALE_MSB : GRAY2_MSB); + renderFn(*this, ctx); + copyGrayscaleMsbBuffers(); + g_differentialQuantize = false; + displayGrayBuffer(lut, factoryMode); + setRenderMode(BW); + return; + } + memset(secBuf, 0x00, frameBufferSize); + secondaryFrameBuffer = secBuf; + + // Single pass: renderFn writes LSB plane to frameBuffer and MSB plane to secondaryFrameBuffer. + clearScreen(0x00); + setRenderMode(lsbMode); + renderFn(*this, ctx); + + // One-shot screenshot hook: fired while both planes are still in software, before either is + // pushed to the controller. frameBuffer = LSB plane, secBuf = MSB plane. + if (screenshotHook && factoryMode) { + screenshotHook(frameBuffer, secBuf, panelWidth, panelHeight, screenshotHookCtx); + screenshotHook = nullptr; + screenshotHookCtx = nullptr; + } + + // Push LSB plane (frameBuffer) → BW RAM. + copyGrayscaleLsbBuffers(); + + // Push MSB plane (secondaryFrameBuffer → frameBuffer → RED RAM). + memcpy(frameBuffer, secBuf, frameBufferSize); + copyGrayscaleMsbBuffers(); + + free(secBuf); + secondaryFrameBuffer = nullptr; + + g_differentialQuantize = false; + displayGrayBuffer(lut, factoryMode); + setRenderMode(BW); +} + +void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, const uint16_t pageWidth, + const uint16_t pageHeight) { + const size_t colBytes = (pageHeight + 7) / 8; + const uint16_t fbStride = panelWidthBytes; + + // Bounds check: each column c writes colBytes bytes at frameBuffer[c * fbStride]. + // Requires pageWidth <= panelHeight and colBytes <= panelWidthBytes. + if (pageWidth > static_cast(panelHeight) || colBytes > panelWidthBytes) { + LOG_ERR("GFX", "displayXtchPlanes: page %ux%u overflows framebuffer (%ux%u)", pageWidth, pageHeight, panelHeight, + panelWidth); + if (screenshotHook) { + screenshotHook = nullptr; + screenshotHookCtx = nullptr; + } + return; + } + + // Pass 1: plane1 (MSB) → BW RAM via copyGrayscaleLsbBuffers. + clearScreen(0x00); + for (uint16_t c = 0; c < pageWidth; c++) { + const uint8_t* srcCol = plane1 + static_cast(c) * colBytes; + uint8_t* dstRow = frameBuffer + static_cast(c) * fbStride; + for (uint16_t b = 0; b < colBytes; b++) { + dstRow[b] = srcCol[b]; + } + } + + copyGrayscaleLsbBuffers(); + + // Pass 2: plane2 (LSB) → RED RAM via copyGrayscaleMsbBuffers. + clearScreen(0x00); + for (uint16_t c = 0; c < pageWidth; c++) { + const uint8_t* srcCol = plane2 + static_cast(c) * colBytes; + uint8_t* dstRow = frameBuffer + static_cast(c) * fbStride; + for (uint16_t b = 0; b < colBytes; b++) { + dstRow[b] = srcCol[b]; + } + } + copyGrayscaleMsbBuffers(); + + // Fire hook: plane1 input IS already in framebuffer format (colBytes == fbStride for portrait + // pages), so pass it directly — no extra malloc needed. plane2 data is now in frameBuffer. + if (screenshotHook) { + screenshotHook(plane1, frameBuffer, panelWidth, panelHeight, screenshotHookCtx); + screenshotHook = nullptr; + screenshotHookCtx = nullptr; + } + + const bool isX3 = gpio.deviceIsX3(); + displayGrayBuffer(isX3 ? nullptr : lut_factory_quality, !isX3); + setRenderMode(BW); +} + +void GfxRenderer::displayXtcBwPage(const uint8_t* pageBuffer, const uint16_t pageWidth, const uint16_t pageHeight) { + const size_t srcRowBytes = (pageWidth + 7) / 8; + + // 1-bit content has no AA — render as plain BW and use the standard differential fast-refresh + // LUT (same as menus/EPUB). No factory LUT needed; avoids all GRAY2 convention complexity. + clearScreen(); + for (uint16_t y = 0; y < pageHeight; y++) { + for (uint16_t x = 0; x < pageWidth; x++) { + if (!((pageBuffer[y * srcRowBytes + x / 8] >> (7 - (x % 8))) & 1)) { + drawPixel(x, y, true); + } + } + } + displayBuffer(HalDisplay::FAST_REFRESH); +} void GfxRenderer::freeBwBufferChunks() { for (auto& bwBufferChunk : bwBufferChunks) { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e683e3122e..0f0ffd4a47 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -18,7 +18,13 @@ enum Color : uint8_t { Clear = 0x00, White = 0x01, LightGray = 0x05, DarkGray = class GfxRenderer { public: - enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; + enum RenderMode { + BW, // 1-bit black/white + GRAYSCALE_LSB, // Differential gray: mark pixels for LSB plane (clearScreen(0x00) + drawPixel(false)) + GRAYSCALE_MSB, // Differential gray: mark pixels for MSB plane (clearScreen(0x00) + drawPixel(false)) + GRAY2_LSB, // Factory absolute gray: encode BW RAM = bit0 (clearScreen(0x00) + drawPixel(false)) + GRAY2_MSB, // Factory absolute gray: encode RED RAM = bit1 (clearScreen(0x00) + drawPixel(false)) + }; // Logical screen orientation from the perspective of callers enum Orientation { @@ -28,6 +34,27 @@ class GfxRenderer { LandscapeCounterClockwise // 800x480 logical coordinates, native panel orientation }; + // Selects LUT, pixel-plane encoding, and pre-flash behavior for renderGrayscale(). + enum class GrayscaleMode { + FactoryFast, // Factory absolute 2-bit (lut_factory_fast); HALF_REFRESH pre-flash to white + FactoryQuality, // Factory absolute 2-bit (lut_factory_quality); HALF_REFRESH pre-flash to white + Differential, // Differential 2-bit overlay (no LUT); no pre-flash, requires prior BW state + }; + + // Display state — tracks whether the physical display was last updated via a factory LUT render. + // BW: frameBuffer mirrors the display (menus, EPUB reader). + // FactoryLut: display holds a grayscale image; frameBuffer has been reset to white by + // cleanupGrayscaleWithFrameBuffer() and no longer represents what is visually shown. + enum class DisplayState { BW, FactoryLut }; + + // One-shot hook fired in renderGrayscaleSinglePass after renderFn() writes both planes + // but before they are pushed to the controller. At that moment: + // lsbPlane = frameBuffer (LSB / BW RAM plane) + // msbPlane = secondaryFrameBuffer (MSB / RED RAM plane) + // Hook is cleared automatically after firing. + using ScreenshotHook = void (*)(const uint8_t* lsbPlane, const uint8_t* msbPlane, int physWidth, int physHeight, + void* ctx); + private: static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory @@ -36,12 +63,16 @@ class GfxRenderer { Orientation orientation; bool fadingFix; uint8_t* frameBuffer = nullptr; + uint8_t* secondaryFrameBuffer = nullptr; // MSB plane buffer for single-pass grayscale decode uint16_t panelWidth = HalDisplay::DISPLAY_WIDTH; uint16_t panelHeight = HalDisplay::DISPLAY_HEIGHT; uint16_t panelWidthBytes = HalDisplay::DISPLAY_WIDTH_BYTES; uint32_t frameBufferSize = HalDisplay::BUFFER_SIZE; std::vector bwBufferChunks; std::map fontMap; + mutable DisplayState displayState = DisplayState::BW; + ScreenshotHook screenshotHook = nullptr; + void* screenshotHookCtx = nullptr; // Mutable because drawText() is const but needs to delegate scan-mode // recording to the (non-const) FontCacheManager. Same pragmatic compromise @@ -55,6 +86,7 @@ class GfxRenderer { void drawPixelDither(int x, int y) const; template void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const; + void drawPixelToBuffer(uint8_t* buf, int x, int y) const; public: explicit GfxRenderer(HalDisplay& halDisplay) @@ -141,21 +173,50 @@ class GfxRenderer { EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; int getTextHeight(int fontId) const; + DisplayState getDisplayState() const { return displayState; } + void setScreenshotHook(ScreenshotHook hook, void* ctx); + // Grayscale functions void setRenderMode(const RenderMode mode) { this->renderMode = mode; } RenderMode getRenderMode() const { return renderMode; } void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; - void displayGrayBuffer() const; + void displayGrayBuffer(const unsigned char* lut = nullptr, bool factoryMode = false) const; bool storeBwBuffer(); // Returns true if buffer was stored successfully void restoreBwBuffer(); // Restore and free the stored buffer void cleanupGrayscaleWithFrameBuffer() const; + // Two-pass grayscale render. renderFn is called twice: once with the LSB render mode set + // (writes BW RAM plane), then with the MSB mode set (writes RED RAM plane). The method + // handles pre-flash (FactoryFast only), clearScreen, setRenderMode, buffer copies, + // displayGrayBuffer, and resets renderMode to BW on completion. + // storeBwBuffer / restoreBwBuffer remain the caller's responsibility. + // preFlashOverlayFn (optional): called after clearScreen() but before the pre-flash displayBuffer(), + // allowing callers to draw a loading indicator that appears during the pre-flash without an extra refresh. + void renderGrayscale(GrayscaleMode mode, void (*renderFn)(const GfxRenderer&, const void*), const void* ctx, + void (*preFlashOverlayFn)(const GfxRenderer&, const void*) = nullptr, + const void* preFlashCtx = nullptr); + // Single-pass variant: calls renderFn once in GRAY2_LSB mode while simultaneously writing + // the MSB plane to a secondary buffer. Cuts SD card reads from 2 to 1 for file-backed renders. + // Falls back to two-pass on secondary buffer allocation failure. + void renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn)(const GfxRenderer&, const void*), const void* ctx, + void (*preFlashOverlayFn)(const GfxRenderer&, const void*) = nullptr, + const void* preFlashCtx = nullptr); + + // Direct 2-bit XTCH plane blit using factory LUT. Caller supplies the two decoded bit planes + // (plane1 = BW RAM / LSB, plane2 = RED RAM / MSB) in column-major order matching XTCH encoding. + // Handles pre-flash, both RAM writes, factory LUT fire, and BW controller sync internally. + void displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, uint16_t pageWidth, uint16_t pageHeight); + + // 1-bit XTC page via the same grayscale LUT pipeline. Row-major pageBuffer (XTC: 0=black, 1=white). + // BW and RED RAM receive identical data since there are no intermediate gray levels. + void displayXtcBwPage(const uint8_t* pageBuffer, uint16_t pageWidth, uint16_t pageHeight); // Font helpers const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const; // Low level functions uint8_t* getFrameBuffer() const; + uint8_t* getSecondaryFrameBuffer() const { return secondaryFrameBuffer; } size_t getBufferSize() const; uint16_t getDisplayWidth() const { return panelWidth; } uint16_t getDisplayHeight() const { return panelHeight; } diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 5f0388d28f..2b49b78208 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -142,22 +142,131 @@ bool Xtc::generateCoverBmp() const { // Get bit depth const uint8_t bitDepth = parser->getBitDepth(); - // Allocate buffer for page data - // XTG (1-bit): Row-major, ((width+7)/8) * height bytes - // XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes - size_t bitmapSize; + // --- 2-bit XTCH/XTH: two-pass plane loading to stay within heap limits --- + // Each plane is ~48KB (fits MaxAlloc); both planes together (~96KB) do not. if (bitDepth == 2) { - bitmapSize = ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) * 2; - } else { - bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; + const size_t planeSize = (static_cast(pageInfo.width) * pageInfo.height + 7) / 8; + const size_t colBytes = (pageInfo.height + 7) / 8; + const uint32_t rowSize2 = ((static_cast(pageInfo.width) * 2 + 31) / 32) * 4; + const std::string tempPath = cachePath + "/cover_tmp.bmp"; + + // Pass 1: load plane1, write raw 2-bit rows to temp file (bit2=0, pixel = bit1<<1) + { + uint8_t* plane1 = static_cast(malloc(planeSize)); + if (!plane1) { + LOG_ERR("XTC", "Failed to alloc plane1 (%lu bytes)", planeSize); + return false; + } + if (const_cast(parser.get())->loadPageMsb(0, plane1, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane1 for cover"); + free(plane1); + return false; + } + FsFile tempFile; + if (!Storage.openFileForWrite("XTC", tempPath, tempFile)) { + LOG_ERR("XTC", "Failed to open temp cover file"); + free(plane1); + return false; + } + uint8_t rowBuf[256]; + for (uint16_t y = 0; y < pageInfo.height; y++) { + memset(rowBuf, 0, rowSize2); + for (uint16_t x = 0; x < pageInfo.width; x++) { + const size_t bo = (pageInfo.width - 1 - x) * colBytes + y / 8; + const uint8_t bit1 = (plane1[bo] >> (7 - (y % 8))) & 1; + rowBuf[x / 4] |= static_cast((bit1 << 1) << (6 - (x % 4) * 2)); + } + tempFile.write(rowBuf, rowSize2); + } + tempFile.close(); + free(plane1); + } + + // Pass 2: load plane2, combine with pass1, write final 2-bit BMP + { + uint8_t* plane2 = static_cast(malloc(planeSize)); + if (!plane2) { + LOG_ERR("XTC", "Failed to alloc plane2 (%lu bytes)", planeSize); + Storage.remove(tempPath.c_str()); + return false; + } + if (const_cast(parser.get())->loadPageLsb(0, plane2, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane2 for cover"); + free(plane2); + Storage.remove(tempPath.c_str()); + return false; + } + FsFile tempFile, coverFile; + if (!Storage.openFileForRead("XTC", tempPath, tempFile)) { + free(plane2); + Storage.remove(tempPath.c_str()); + return false; + } + if (!Storage.openFileForWrite("XTC", getCoverBmpPath(), coverFile)) { + tempFile.close(); + free(plane2); + Storage.remove(tempPath.c_str()); + return false; + } + // Write 2-bit BMP header + const uint32_t imageSize2 = rowSize2 * pageInfo.height; + const uint32_t fileSize2 = 14 + 40 + 16 + imageSize2; + coverFile.write('B'); + coverFile.write('M'); + coverFile.write(reinterpret_cast(&fileSize2), 4); + uint32_t rsv2 = 0; + coverFile.write(reinterpret_cast(&rsv2), 4); + uint32_t doff2 = 14 + 40 + 16; + coverFile.write(reinterpret_cast(&doff2), 4); + uint32_t dibSz2 = 40; + coverFile.write(reinterpret_cast(&dibSz2), 4); + int32_t ww2 = pageInfo.width; + coverFile.write(reinterpret_cast(&ww2), 4); + int32_t hh2 = -static_cast(pageInfo.height); + coverFile.write(reinterpret_cast(&hh2), 4); + uint16_t pl2 = 1; + coverFile.write(reinterpret_cast(&pl2), 2); + uint16_t bpp2 = 2; + coverFile.write(reinterpret_cast(&bpp2), 2); + uint32_t cmp2 = 0, ppm2 = 2835, cu2 = 4, ci2 = 4; + coverFile.write(reinterpret_cast(&cmp2), 4); + coverFile.write(reinterpret_cast(&imageSize2), 4); + coverFile.write(reinterpret_cast(&ppm2), 4); + coverFile.write(reinterpret_cast(&ppm2), 4); + coverFile.write(reinterpret_cast(&cu2), 4); + coverFile.write(reinterpret_cast(&ci2), 4); + static constexpr uint8_t pal2[16] = {0xFF, 0xFF, 0xFF, 0x00, 0xAA, 0xAA, 0xAA, 0x00, + 0x55, 0x55, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00}; + coverFile.write(pal2, 16); + // For each row: read pass1 row (bit1 only), OR in bit2, write to final + uint8_t rowBuf[256]; + for (uint16_t y = 0; y < pageInfo.height; y++) { + memset(rowBuf, 0, rowSize2); + tempFile.read(rowBuf, rowSize2); + for (uint16_t x = 0; x < pageInfo.width; x++) { + const size_t bo = (pageInfo.width - 1 - x) * colBytes + y / 8; + const uint8_t bit2 = (plane2[bo] >> (7 - (y % 8))) & 1; + rowBuf[x / 4] |= static_cast(bit2 << (6 - (x % 4) * 2)); + } + coverFile.write(rowBuf, rowSize2); + } + coverFile.close(); + tempFile.close(); + free(plane2); + Storage.remove(tempPath.c_str()); + } + LOG_DBG("XTC", "Generated 2-bit cover BMP: %s", getCoverBmpPath().c_str()); + return true; } + + // 1-bit (XTC/XTG) path + const size_t bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; uint8_t* pageBuffer = static_cast(malloc(bitmapSize)); if (!pageBuffer) { LOG_ERR("XTC", "Failed to allocate page buffer (%lu bytes)", bitmapSize); return false; } - // Load first page (cover) size_t bytesRead = const_cast(parser.get())->loadPage(0, pageBuffer, bitmapSize); if (bytesRead == 0) { LOG_ERR("XTC", "Failed to load cover page"); @@ -165,7 +274,6 @@ bool Xtc::generateCoverBmp() const { return false; } - // Create BMP file FsFile coverBmp; if (!Storage.openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) { LOG_DBG("XTC", "Failed to create cover BMP file"); @@ -173,29 +281,23 @@ bool Xtc::generateCoverBmp() const { return false; } - // Write 1-bit BMP header (top-down row order) BmpHeader bmpHeader; createBmpHeader(&bmpHeader, pageInfo.width, pageInfo.height, BmpRowOrder::TopDown); coverBmp.write(reinterpret_cast(&bmpHeader), sizeof(bmpHeader)); const uint32_t rowSize = ((pageInfo.width + 31) / 32) * 4; + const size_t srcRowSize = (pageInfo.width + 7) / 8; // Write bitmap data // BMP requires 4-byte row alignment - const size_t dstRowSize = (pageInfo.width + 7) / 8; // 1-bit destination row size + const size_t dstRowSize = (pageInfo.width + 7) / 8; if (bitDepth == 2) { - // XTH 2-bit mode: Two bit planes, column-major order - // - Columns scanned right to left (x = width-1 down to 0) - // - 8 vertical pixels per byte (MSB = topmost pixel in group) - // - First plane: Bit1, Second plane: Bit2 - // - Pixel value = (bit1 << 1) | bit2 const size_t planeSize = (static_cast(pageInfo.width) * pageInfo.height + 7) / 8; - const uint8_t* plane1 = pageBuffer; // Bit1 plane - const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane - const size_t colBytes = (pageInfo.height + 7) / 8; // Bytes per column + const uint8_t* plane1 = pageBuffer; + const uint8_t* plane2 = pageBuffer + planeSize; + const size_t colBytes = (pageInfo.height + 7) / 8; - // Allocate a row buffer for 1-bit output uint8_t* rowBuffer = static_cast(malloc(dstRowSize)); if (!rowBuffer) { free(pageBuffer); @@ -203,32 +305,27 @@ bool Xtc::generateCoverBmp() const { } for (uint16_t y = 0; y < pageInfo.height; y++) { - memset(rowBuffer, 0xFF, dstRowSize); // Start with all white + memset(rowBuffer, 0xFF, dstRowSize); for (uint16_t x = 0; x < pageInfo.width; x++) { - // Column-major, right to left: column index = (width - 1 - x) const size_t colIndex = pageInfo.width - 1 - x; const size_t byteInCol = y / 8; - const size_t bitInByte = 7 - (y % 8); // MSB = topmost pixel + const size_t bitInByte = 7 - (y % 8); const size_t byteOffset = colIndex * colBytes + byteInCol; const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; const uint8_t pixelValue = (bit1 << 1) | bit2; - // Threshold: 0=white (1); 1,2,3=black (0) if (pixelValue >= 1) { - // Set bit to 0 (black) in BMP format const size_t dstByte = x / 8; const size_t dstBit = 7 - (x % 8); rowBuffer[dstByte] &= ~(1 << dstBit); } } - // Write converted row coverBmp.write(rowBuffer, dstRowSize); - // Pad to 4-byte boundary uint8_t padding[4] = {0, 0, 0, 0}; size_t paddingSize = rowSize - dstRowSize; if (paddingSize > 0) { @@ -238,14 +335,9 @@ bool Xtc::generateCoverBmp() const { free(rowBuffer); } else { - // 1-bit source: write directly with proper padding - const size_t srcRowSize = (pageInfo.width + 7) / 8; - for (uint16_t y = 0; y < pageInfo.height; y++) { - // Write source row coverBmp.write(pageBuffer + y * srcRowSize, srcRowSize); - // Pad to 4-byte boundary uint8_t padding[4] = {0, 0, 0, 0}; size_t paddingSize = rowSize - srcRowSize; if (paddingSize > 0) { @@ -328,20 +420,162 @@ bool Xtc::generateThumbBmp(int height) const { LOG_DBG("XTC", "Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)", pageInfo.width, pageInfo.height, thumbWidth, thumbHeight, scale); - // Allocate buffer for page data - size_t bitmapSize; + // For 2-bit (XTCH): two-pass plane loading → 2-bit BMP output with 4-level grayscale palette. + // Full page (96KB) exceeds MaxAlloc; load each plane separately (~48KB). if (bitDepth == 2) { - bitmapSize = ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) * 2; - } else { - bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; + const size_t planeSize = (static_cast(pageInfo.width) * pageInfo.height + 7) / 8; + const size_t colBytes = (pageInfo.height + 7) / 8; + const uint32_t scaleInv_fp2 = static_cast(65536.0f / scale); + const size_t plane1BitsSize = (static_cast(thumbWidth) * thumbHeight + 7) / 8; + uint8_t* plane1Bits = static_cast(malloc(plane1BitsSize)); + if (!plane1Bits) { + LOG_ERR("XTC", "Failed to alloc plane1bits (%lu bytes)", plane1BitsSize); + return false; + } + memset(plane1Bits, 0, plane1BitsSize); + uint8_t* planeBuffer = static_cast(malloc(planeSize)); + if (!planeBuffer) { + LOG_ERR("XTC", "Failed to alloc plane buffer (%lu bytes)", planeSize); + free(plane1Bits); + return false; + } + // Pass 1: plane1 (bit1/MSB) majority vote per output pixel + if (const_cast(parser.get())->loadPageMsb(0, planeBuffer, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane1 for thumb"); + free(planeBuffer); + free(plane1Bits); + return false; + } + for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { + uint32_t srcYS = (static_cast(dstY) * scaleInv_fp2) >> 16; + uint32_t srcYE = (static_cast(dstY + 1) * scaleInv_fp2) >> 16; + if (srcYS >= pageInfo.height) srcYS = pageInfo.height - 1; + if (srcYE > pageInfo.height) srcYE = pageInfo.height; + if (srcYE <= srcYS) srcYE = srcYS + 1; + if (srcYE > pageInfo.height) srcYE = pageInfo.height; + for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { + uint32_t srcXS = (static_cast(dstX) * scaleInv_fp2) >> 16; + uint32_t srcXE = (static_cast(dstX + 1) * scaleInv_fp2) >> 16; + if (srcXS >= pageInfo.width) srcXS = pageInfo.width - 1; + if (srcXE > pageInfo.width) srcXE = pageInfo.width; + if (srcXE <= srcXS) srcXE = srcXS + 1; + if (srcXE > pageInfo.width) srcXE = pageInfo.width; + uint32_t darkCount = 0, total = 0; + for (uint32_t sy = srcYS; sy < srcYE; sy++) + for (uint32_t sx = srcXS; sx < srcXE; sx++) { + const size_t bo = (pageInfo.width - 1 - sx) * colBytes + sy / 8; + if (bo < planeSize) { + if ((planeBuffer[bo] >> (7 - (sy % 8))) & 1) darkCount++; + total++; + } + } + if (total > 0 && darkCount * 2 >= total) { + const size_t pi = static_cast(dstY) * thumbWidth + dstX; + plane1Bits[pi / 8] |= static_cast(1u << (7 - (pi % 8))); + } + } + } + // Pass 2: plane2 (bit2/LSB) + combine → write 2-bit BMP + if (const_cast(parser.get())->loadPageLsb(0, planeBuffer, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane2 for thumb"); + free(planeBuffer); + free(plane1Bits); + return false; + } + FsFile thumbBmp2; + if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp2)) { + free(planeBuffer); + free(plane1Bits); + return false; + } + const uint32_t rowSize2 = ((static_cast(thumbWidth) * 2 + 31) / 32) * 4; + const uint32_t imageSize2 = rowSize2 * thumbHeight; + const uint32_t fileSize2 = 14 + 40 + 16 + imageSize2; + thumbBmp2.write('B'); + thumbBmp2.write('M'); + thumbBmp2.write(reinterpret_cast(&fileSize2), 4); + uint32_t rsv2 = 0; + thumbBmp2.write(reinterpret_cast(&rsv2), 4); + uint32_t doff2 = 14 + 40 + 16; + thumbBmp2.write(reinterpret_cast(&doff2), 4); + uint32_t dibSz2 = 40; + thumbBmp2.write(reinterpret_cast(&dibSz2), 4); + int32_t ww2 = thumbWidth; + thumbBmp2.write(reinterpret_cast(&ww2), 4); + int32_t hh2 = -static_cast(thumbHeight); + thumbBmp2.write(reinterpret_cast(&hh2), 4); + uint16_t pl2 = 1; + thumbBmp2.write(reinterpret_cast(&pl2), 2); + uint16_t bpp2 = 2; + thumbBmp2.write(reinterpret_cast(&bpp2), 2); + uint32_t cmp2 = 0, imgSz2 = imageSize2, ppm2 = 2835, cu2 = 4, ci2 = 4; + thumbBmp2.write(reinterpret_cast(&cmp2), 4); + thumbBmp2.write(reinterpret_cast(&imgSz2), 4); + thumbBmp2.write(reinterpret_cast(&ppm2), 4); + thumbBmp2.write(reinterpret_cast(&ppm2), 4); + thumbBmp2.write(reinterpret_cast(&cu2), 4); + thumbBmp2.write(reinterpret_cast(&ci2), 4); + // Palette: 0=white, 1=lightGrey(170), 2=darkGrey(85), 3=black — matches XTC pixel value + static constexpr uint8_t pal2[16] = {0xFF, 0xFF, 0xFF, 0x00, 0xAA, 0xAA, 0xAA, 0x00, + 0x55, 0x55, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00}; + thumbBmp2.write(pal2, 16); + uint8_t* rowBuf2 = static_cast(malloc(rowSize2)); + if (!rowBuf2) { + free(planeBuffer); + free(plane1Bits); + thumbBmp2.close(); + Storage.remove(getThumbBmpPath(height).c_str()); + return false; + } + for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { + memset(rowBuf2, 0, rowSize2); + uint32_t srcYS = (static_cast(dstY) * scaleInv_fp2) >> 16; + uint32_t srcYE = (static_cast(dstY + 1) * scaleInv_fp2) >> 16; + if (srcYS >= pageInfo.height) srcYS = pageInfo.height - 1; + if (srcYE > pageInfo.height) srcYE = pageInfo.height; + if (srcYE <= srcYS) srcYE = srcYS + 1; + if (srcYE > pageInfo.height) srcYE = pageInfo.height; + for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { + uint32_t srcXS = (static_cast(dstX) * scaleInv_fp2) >> 16; + uint32_t srcXE = (static_cast(dstX + 1) * scaleInv_fp2) >> 16; + if (srcXS >= pageInfo.width) srcXS = pageInfo.width - 1; + if (srcXE > pageInfo.width) srcXE = pageInfo.width; + if (srcXE <= srcXS) srcXE = srcXS + 1; + if (srcXE > pageInfo.width) srcXE = pageInfo.width; + uint32_t darkCount = 0, total = 0; + for (uint32_t sy = srcYS; sy < srcYE; sy++) + for (uint32_t sx = srcXS; sx < srcXE; sx++) { + const size_t bo = (pageInfo.width - 1 - sx) * colBytes + sy / 8; + if (bo < planeSize) { + if ((planeBuffer[bo] >> (7 - (sy % 8))) & 1) darkCount++; + total++; + } + } + const size_t pi = static_cast(dstY) * thumbWidth + dstX; + const uint8_t bit1 = (plane1Bits[pi / 8] >> (7 - (pi % 8))) & 1; + const uint8_t bit2 = (total > 0 && darkCount * 2 >= total) ? 1 : 0; + const uint8_t twoBit = (bit1 << 1) | bit2; + const size_t bi2 = dstX / 4; + const int bs2 = 6 - static_cast(dstX % 4) * 2; + if (bi2 < rowSize2) rowBuf2[bi2] |= static_cast(twoBit << bs2); + } + thumbBmp2.write(rowBuf2, rowSize2); + } + free(rowBuf2); + free(planeBuffer); + free(plane1Bits); + thumbBmp2.close(); + LOG_DBG("XTC", "Generated 2-bit thumb BMP (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str()); + return true; } + + // 1-bit (XTC/XTG) path + const size_t bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; uint8_t* pageBuffer = static_cast(malloc(bitmapSize)); if (!pageBuffer) { LOG_ERR("XTC", "Failed to allocate page buffer (%lu bytes)", bitmapSize); return false; } - - // Load first page (cover) size_t bytesRead = const_cast(parser.get())->loadPage(0, pageBuffer, bitmapSize); if (bytesRead == 0) { LOG_ERR("XTC", "Failed to load cover page for thumb"); @@ -349,7 +583,6 @@ bool Xtc::generateThumbBmp(int height) const { return false; } - // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) FsFile thumbBmp; if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { LOG_DBG("XTC", "Failed to create thumb BMP file"); @@ -357,34 +590,22 @@ bool Xtc::generateThumbBmp(int height) const { return false; } - // Write 1-bit BMP header (top-down row order) BmpHeader bmpHeader; createBmpHeader(&bmpHeader, thumbWidth, thumbHeight, BmpRowOrder::TopDown); thumbBmp.write(reinterpret_cast(&bmpHeader), sizeof(bmpHeader)); const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; - - // Allocate row buffer for 1-bit output uint8_t* rowBuffer = static_cast(malloc(rowSize)); if (!rowBuffer) { free(pageBuffer); return false; } - // Fixed-point scale factor (16.16) - uint32_t scaleInv_fp = static_cast(65536.0f / scale); - - // Pre-calculate plane info for 2-bit mode - const size_t planeSize = (bitDepth == 2) ? ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) : 0; - const uint8_t* plane1 = (bitDepth == 2) ? pageBuffer : nullptr; - const uint8_t* plane2 = (bitDepth == 2) ? pageBuffer + planeSize : nullptr; - const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0; - const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0; + const uint32_t scaleInv_fp = static_cast(65536.0f / scale); + const size_t srcRowBytes = (pageInfo.width + 7) / 8; for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { - memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1) - - // Calculate source Y range with bounds checking + memset(rowBuffer, 0xFF, rowSize); uint32_t srcYStart = (static_cast(dstY) * scaleInv_fp) >> 16; uint32_t srcYEnd = (static_cast(dstY + 1) * scaleInv_fp) >> 16; if (srcYStart >= pageInfo.height) srcYStart = pageInfo.height - 1; @@ -393,7 +614,6 @@ bool Xtc::generateThumbBmp(int height) const { if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { - // Calculate source X range with bounds checking uint32_t srcXStart = (static_cast(dstX) * scaleInv_fp) >> 16; uint32_t srcXEnd = (static_cast(dstX + 1) * scaleInv_fp) >> 16; if (srcXStart >= pageInfo.width) srcXStart = pageInfo.width - 1; @@ -401,82 +621,38 @@ bool Xtc::generateThumbBmp(int height) const { if (srcXEnd <= srcXStart) srcXEnd = srcXStart + 1; if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; - // Area averaging: sum grayscale values (0-255 range) - uint32_t graySum = 0; - uint32_t totalCount = 0; - + uint32_t graySum = 0, totalCount = 0; for (uint32_t srcY = srcYStart; srcY < srcYEnd && srcY < pageInfo.height; srcY++) { for (uint32_t srcX = srcXStart; srcX < srcXEnd && srcX < pageInfo.width; srcX++) { - uint8_t grayValue = 255; // Default: white - - if (bitDepth == 2) { - // XTH 2-bit mode: pixel value 0-3 - // Bounds check for column index - if (srcX < pageInfo.width) { - const size_t colIndex = pageInfo.width - 1 - srcX; - const size_t byteInCol = srcY / 8; - const size_t bitInByte = 7 - (srcY % 8); - const size_t byteOffset = colIndex * colBytes + byteInCol; - // Bounds check for buffer access - if (byteOffset < planeSize) { - const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; - const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; - const uint8_t pixelValue = (bit1 << 1) | bit2; - // Convert 2-bit (0-3) to grayscale: 0=black, 3=white - // pixelValue: 0=white, 1=light gray, 2=dark gray, 3=black (XTC polarity) - grayValue = (3 - pixelValue) * 85; // 0->255, 1->170, 2->85, 3->0 - } - } - } else { - // 1-bit mode - const size_t byteIdx = srcY * srcRowBytes + srcX / 8; - const size_t bitIdx = 7 - (srcX % 8); - // Bounds check for buffer access - if (byteIdx < bitmapSize) { - const uint8_t pixelBit = (pageBuffer[byteIdx] >> bitIdx) & 1; - // XTC 1-bit polarity: 0=black, 1=white (same as BMP palette) - grayValue = pixelBit ? 255 : 0; - } + const size_t byteIdx = srcY * srcRowBytes + srcX / 8; + if (byteIdx < bitmapSize) { + graySum += ((pageBuffer[byteIdx] >> (7 - (srcX % 8))) & 1) ? 255 : 0; + totalCount++; } - - graySum += grayValue; - totalCount++; } } - // Calculate average grayscale and quantize to 1-bit with noise dithering uint8_t avgGray = (totalCount > 0) ? static_cast(graySum / totalCount) : 255; - - // Hash-based noise dithering for 1-bit output uint32_t hash = static_cast(dstX) * 374761393u + static_cast(dstY) * 668265263u; hash = (hash ^ (hash >> 13)) * 1274126177u; - const int threshold = static_cast(hash >> 24); // 0-255 - const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 - - // Quantize to 1-bit: 0=black, 1=white - uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0; - - // Pack 1-bit value into row buffer (MSB first, 8 pixels per byte) + const int threshold = static_cast(hash >> 24); + const int adjustedThreshold = 128 + ((threshold - 128) / 2); + const uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0; const size_t byteIndex = dstX / 8; const size_t bitOffset = 7 - (dstX % 8); - // Bounds check for row buffer access if (byteIndex < rowSize) { - if (oneBit) { - rowBuffer[byteIndex] |= (1 << bitOffset); // Set bit for white - } else { - rowBuffer[byteIndex] &= ~(1 << bitOffset); // Clear bit for black - } + if (oneBit) + rowBuffer[byteIndex] |= (1 << bitOffset); + else + rowBuffer[byteIndex] &= ~(1 << bitOffset); } } - - // Write row (already padded to 4-byte boundary by rowSize) thumbBmp.write(rowBuffer, rowSize); } free(rowBuffer); free(pageBuffer); - - LOG_DBG("XTC", "Generated thumb BMP (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str()); + LOG_DBG("XTC", "Generated 1-bit thumb BMP (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str()); return true; } @@ -515,6 +691,20 @@ size_t Xtc::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) con return const_cast(parser.get())->loadPage(pageIndex, buffer, bufferSize); } +size_t Xtc::loadPageMsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const { + if (!loaded || !parser) { + return 0; + } + return const_cast(parser.get())->loadPageMsb(pageIndex, buffer, bufferSize); +} + +size_t Xtc::loadPageLsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const { + if (!loaded || !parser) { + return 0; + } + return const_cast(parser.get())->loadPageLsb(pageIndex, buffer, bufferSize); +} + xtc::XtcError Xtc::loadPageStreaming(uint32_t pageIndex, std::function callback, size_t chunkSize) const { diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h index 3dbd160b4f..912f6ff162 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -82,6 +82,8 @@ class Xtc { * @return Number of bytes read */ size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const; + size_t loadPageMsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const; + size_t loadPageLsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) const; /** * Load page with streaming callback diff --git a/lib/Xtc/Xtc/XtcParser.cpp b/lib/Xtc/Xtc/XtcParser.cpp index e30f779642..48f5dcbcea 100644 --- a/lib/Xtc/Xtc/XtcParser.cpp +++ b/lib/Xtc/Xtc/XtcParser.cpp @@ -459,6 +459,134 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz return bytesRead; } +size_t XtcParser::loadPageMsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) { + if (m_bitDepth != 2) { + return loadPage(pageIndex, buffer, bufferSize); + } + + if (!m_isOpen) { + m_lastError = XtcError::FILE_NOT_FOUND; + return 0; + } + + if (pageIndex >= m_header.pageCount) { + m_lastError = XtcError::PAGE_OUT_OF_RANGE; + return 0; + } + + PageInfo page; + if (!readPageTableEntry(pageIndex, page)) { + m_lastError = XtcError::READ_ERROR; + return 0; + } + + if (!ensureFileOpen()) { + m_lastError = XtcError::FILE_NOT_FOUND; + return 0; + } + + if (!m_file.seek(page.offset)) { + LOG_DBG("XTC", "Failed to seek to page %u at offset %lu", pageIndex, page.offset); + m_lastError = XtcError::READ_ERROR; + return 0; + } + + XtgPageHeader pageHeader; + size_t headerRead = m_file.read(reinterpret_cast(&pageHeader), sizeof(XtgPageHeader)); + if (headerRead != sizeof(XtgPageHeader)) { + LOG_DBG("XTC", "Failed to read page header for page %u", pageIndex); + m_lastError = XtcError::READ_ERROR; + return 0; + } + + if (pageHeader.magic != XTH_MAGIC) { + LOG_DBG("XTC", "Invalid page magic for page %u: 0x%08X", pageIndex, pageHeader.magic); + m_lastError = XtcError::INVALID_MAGIC; + return 0; + } + + // Only plane1 (MSB): (width * height + 7) / 8 bytes + const size_t planeSize = (static_cast(pageHeader.width) * pageHeader.height + 7) / 8; + + if (bufferSize < planeSize) { + LOG_DBG("XTC", "Buffer too small for MSB plane: need %u, have %u", planeSize, bufferSize); + m_lastError = XtcError::MEMORY_ERROR; + return 0; + } + + size_t bytesRead = m_file.read(buffer, planeSize); + if (bytesRead != planeSize) { + LOG_DBG("XTC", "MSB plane read error: expected %u, got %u", planeSize, bytesRead); + m_lastError = XtcError::READ_ERROR; + return 0; + } + + m_lastError = XtcError::OK; + return bytesRead; +} + +size_t XtcParser::loadPageLsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) { + if (m_bitDepth != 2) return loadPage(pageIndex, buffer, bufferSize); + + if (!m_isOpen) { + m_lastError = XtcError::FILE_NOT_FOUND; + return 0; + } + if (pageIndex >= m_header.pageCount) { + m_lastError = XtcError::PAGE_OUT_OF_RANGE; + return 0; + } + + PageInfo page; + if (!readPageTableEntry(pageIndex, page)) { + m_lastError = XtcError::READ_ERROR; + return 0; + } + + if (!ensureFileOpen()) { + m_lastError = XtcError::FILE_NOT_FOUND; + return 0; + } + + if (!m_file.seek(page.offset)) { + m_lastError = XtcError::READ_ERROR; + return 0; + } + + XtgPageHeader pageHeader; + if (m_file.read(reinterpret_cast(&pageHeader), sizeof(XtgPageHeader)) != sizeof(XtgPageHeader)) { + m_lastError = XtcError::READ_ERROR; + return 0; + } + if (pageHeader.magic != XTH_MAGIC) { + LOG_DBG("XTC", "loadPageLsb: invalid magic for page %u: 0x%08X", pageIndex, pageHeader.magic); + m_lastError = XtcError::INVALID_MAGIC; + return 0; + } + + const size_t planeSize = (static_cast(pageHeader.width) * pageHeader.height + 7) / 8; + const uint32_t plane2Offset = page.offset + sizeof(XtgPageHeader) + static_cast(planeSize); + + if (!m_file.seek(plane2Offset)) { + m_lastError = XtcError::READ_ERROR; + return 0; + } + + if (bufferSize < planeSize) { + LOG_DBG("XTC", "Buffer too small for LSB plane: need %u, have %u", planeSize, bufferSize); + m_lastError = XtcError::MEMORY_ERROR; + return 0; + } + + size_t bytesRead = m_file.read(buffer, planeSize); + if (bytesRead != planeSize) { + m_lastError = XtcError::READ_ERROR; + return 0; + } + m_lastError = XtcError::OK; + return bytesRead; +} + XtcError XtcParser::loadPageStreaming(uint32_t pageIndex, std::function callback, size_t chunkSize) { diff --git a/lib/Xtc/Xtc/XtcParser.h b/lib/Xtc/Xtc/XtcParser.h index b688d79350..3901a72873 100644 --- a/lib/Xtc/Xtc/XtcParser.h +++ b/lib/Xtc/Xtc/XtcParser.h @@ -57,6 +57,29 @@ class XtcParser { */ size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize); + /** + * Load only plane1 (MSB) of a 2-bit page into buffer. + * For 1-bit pages behaves identically to loadPage. + * Requires only half the buffer of loadPage for 2-bit pages, fitting within heap limits. + * + * @param pageIndex Page index (0-based) + * @param buffer Output buffer (caller allocated, must hold planeSize bytes) + * @param bufferSize Buffer size + * @return Number of bytes read on success, 0 on failure + */ + size_t loadPageMsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize); + + /** + * Load only plane2 (LSB) of a 2-bit page into buffer. + * For 1-bit pages behaves identically to loadPage. + * + * @param pageIndex Page index (0-based) + * @param buffer Output buffer (caller allocated, must hold planeSize bytes) + * @param bufferSize Buffer size + * @return Number of bytes read on success, 0 on failure + */ + size_t loadPageLsb(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize); + /** * Streaming page load * Memory-efficient method that reads page data in chunks. diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index 665df5356b..4f1ec764f4 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -80,7 +80,9 @@ void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) { einkDisplay void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) { einkDisplay.cleanupGrayscaleBuffers(bwBuffer); } -void HalDisplay::displayGrayBuffer(bool turnOffScreen) { einkDisplay.displayGrayBuffer(turnOffScreen); } +void HalDisplay::displayGrayBuffer(bool turnOffScreen, const unsigned char* lut, bool factoryMode) { + einkDisplay.displayGrayBuffer(turnOffScreen, lut, factoryMode); +} uint16_t HalDisplay::getDisplayWidth() const { return einkDisplay.getDisplayWidth(); } diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index a0a7f92083..d12a5c5b8a 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -47,7 +47,7 @@ class HalDisplay { void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer); void cleanupGrayscaleBuffers(const uint8_t* bwBuffer); - void displayGrayBuffer(bool turnOffScreen = false); + void displayGrayBuffer(bool turnOffScreen = false, const unsigned char* lut = nullptr, bool factoryMode = false); // Runtime geometry passthrough uint16_t getDisplayWidth() const; diff --git a/open-x4-sdk b/open-x4-sdk index a64a3c29be..add90185ab 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit a64a3c29bebc59b2ccdfe15492cfc4b5e4c26360 +Subproject commit add90185ab77bb1704429b26493aa7e80a283e90 From 5e0976b0f60800cd65ba0eea1deedd787c093e9e Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Mon, 20 Apr 2026 00:15:02 +0200 Subject: [PATCH 02/57] feat: PXC/XTC viewers, sleep screen, and EPUB factory rendering Add PxcViewerActivity for PXC image display using factory LUT grayscale. Update BmpViewerActivity and SleepActivity with X3-aware grayscale mode. EPUB reader uses FactoryQuality on X4 for image pages, Differential on X3. XTC reader pre-flashes to white on entry; periodic FULL_REFRESH every 32 pages. Add UITheme metrics for PXC viewer layout. --- src/CrossPointSettings.h | 1 - src/activities/Activity.h | 5 + src/activities/ActivityManager.h | 2 + src/activities/boot_sleep/SleepActivity.cpp | 259 ++++++++++++++++--- src/activities/boot_sleep/SleepActivity.h | 8 + src/activities/home/FileBrowserActivity.cpp | 9 +- src/activities/reader/EpubReaderActivity.cpp | 113 ++++---- src/activities/reader/EpubReaderActivity.h | 5 + src/activities/reader/ReaderActivity.cpp | 9 + src/activities/reader/ReaderActivity.h | 2 + src/activities/reader/ReaderUtils.h | 1 + src/activities/reader/XtcReaderActivity.cpp | 218 +++++----------- src/activities/reader/XtcReaderActivity.h | 3 +- src/activities/util/BmpViewerActivity.cpp | 124 +++++++-- src/activities/util/BmpViewerActivity.h | 2 + src/activities/util/PxcViewerActivity.cpp | 209 +++++++++++++++ src/activities/util/PxcViewerActivity.h | 20 ++ src/components/UITheme.cpp | 2 +- 18 files changed, 733 insertions(+), 259 deletions(-) create mode 100644 src/activities/util/PxcViewerActivity.cpp create mode 100644 src/activities/util/PxcViewerActivity.h diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 7e50ac4fc0..f0db01551f 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -199,7 +199,6 @@ class CrossPointSettings { uint8_t showHiddenFiles = 0; // Image rendering mode in EPUB reader uint8_t imageRendering = IMAGES_DISPLAY; - ~CrossPointSettings() = default; // Get singleton instance diff --git a/src/activities/Activity.h b/src/activities/Activity.h index cc3fe44321..4a33f8a702 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -44,6 +44,11 @@ class Activity { virtual bool preventAutoSleep() { return false; } virtual bool isReaderActivity() const { return false; } + // Called when the screenshot combo is pressed and the display is in FactoryLut state. + // Activities that use renderGrayscaleSinglePass should override this to trigger a re-render, + // allowing the installed screenshot hook to capture both planes before they are pushed. + virtual void onScreenshotRequest() {} + // Start a new activity without destroying the current one // Note: requestUpdate() will be invoked automatically once resultHandler finishes void startActivityForResult(std::unique_ptr&& activity, ActivityResultHandler resultHandler); diff --git a/src/activities/ActivityManager.h b/src/activities/ActivityManager.h index bc975e919e..96a5482f62 100644 --- a/src/activities/ActivityManager.h +++ b/src/activities/ActivityManager.h @@ -96,6 +96,8 @@ class ActivityManager { // Note: if popActivity() on last activity on the stack, we will goHome() void popActivity(); + Activity* getCurrentActivity() const { return currentActivity.get(); } + bool preventAutoSleep() const; bool isReaderActivity() const; bool skipLoopDelay() const; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 06efdef7ac..1a2fdc211b 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -10,6 +11,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "Epub/converters/DirectPixelWriter.h" #include "activities/reader/ReaderUtils.h" #include "components/UITheme.h" #include "fontIds.h" @@ -18,13 +20,9 @@ void SleepActivity::onEnter() { Activity::onEnter(); - // Show popup with reader orientation only when going to sleep from reader if (APP_STATE.lastSleepFromReader) { ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); - GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); renderer.setOrientation(GfxRenderer::Orientation::Portrait); - } else { - GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); } switch (SETTINGS.sleepScreen) { @@ -72,14 +70,35 @@ void SleepActivity::renderCustomSleepScreen() const { continue; } - if (!FsHelpers::hasBmpExtension(filename)) { - LOG_DBG("SLP", "Skipping non-.bmp file name: %s", name); + const bool isBmp = FsHelpers::hasBmpExtension(filename); + const bool isPxc = FsHelpers::hasPxcExtension(filename); + if (!isBmp && !isPxc) { + LOG_DBG("SLP", "Skipping non-BMP/PXC file: %s", name); + file.close(); continue; } - Bitmap bitmap(file); - if (bitmap.parseHeaders() != BmpReaderError::Ok) { - LOG_DBG("SLP", "Skipping invalid BMP file: %s", name); - continue; + if (isBmp) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() != BmpReaderError::Ok) { + LOG_DBG("SLP", "Skipping invalid BMP file: %s", name); + file.close(); + continue; + } + } + if (isPxc) { + uint16_t w, h; + if (file.read(&w, 2) != 2 || file.read(&h, 2) != 2) { + LOG_DBG("SLP", "Skipping PXC with unreadable header: %s", name); + file.close(); + continue; + } + const int sw = renderer.getScreenWidth(); + const int sh = renderer.getScreenHeight(); + if (abs(w - sw) > 1 || abs(h - sh) > 1) { + LOG_DBG("SLP", "Skipping PXC size mismatch %dx%d (screen %dx%d): %s", w, h, sw, sh, name); + file.close(); + continue; + } } files.emplace_back(filename); } @@ -97,18 +116,37 @@ void SleepActivity::renderCustomSleepScreen() const { APP_STATE.pushRecentSleep(randomFileIndex); APP_STATE.saveToFile(); const auto filename = std::string(sleepDir) + "/" + files[randomFileIndex]; + LOG_DBG("SLP", "Randomly loading: %s/%s", sleepDir, files[randomFileIndex].c_str()); + delay(100); + if (FsHelpers::hasPxcExtension(files[randomFileIndex])) { + renderPxcSleepScreen(filename); + dir.close(); + return; + } FsFile file; if (Storage.openFileForRead("SLP", filename, file)) { - LOG_DBG("SLP", "Randomly loading: %s/%s", sleepDir, files[randomFileIndex].c_str()); - delay(100); Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { + if (bitmap.hasGreyscale() && + SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER) { + lastGrayscalePath = filename; + lastGrayscaleIsPxc = false; + } renderBitmapSleepScreen(bitmap); return; } } } } + if (dir) dir.close(); + + // Check root for sleep.pxc (preferred) or sleep.bmp + if (Storage.exists("/sleep.pxc")) { + LOG_DBG("SLP", "Loading: /sleep.pxc"); + renderPxcSleepScreen("/sleep.pxc"); + return; + } + // Look for sleep.bmp on the root of the sd card to determine if we should // render a custom sleep screen instead of the default. FsFile file; @@ -116,6 +154,11 @@ void SleepActivity::renderCustomSleepScreen() const { Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { LOG_DBG("SLP", "Loading: /sleep.bmp"); + if (bitmap.hasGreyscale() && + SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER) { + lastGrayscalePath = "/sleep.bmp"; + lastGrayscaleIsPxc = false; + } renderBitmapSleepScreen(bitmap); return; } @@ -138,7 +181,118 @@ void SleepActivity::renderDefaultSleepScreen() const { renderer.invertScreen(); } - renderer.displayBuffer(HalDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); +} + +void SleepActivity::renderPxcSleepScreen(const std::string& path) const { + FsFile file; + if (!Storage.openFileForRead("SLP", path, file)) { + LOG_ERR("SLP", "Cannot open PXC: %s", path.c_str()); + return renderDefaultSleepScreen(); + } + + uint16_t pxcWidth, pxcHeight; + if (file.read(&pxcWidth, 2) != 2 || file.read(&pxcHeight, 2) != 2) { + LOG_ERR("SLP", "PXC header read failed: %s", path.c_str()); + file.close(); + return renderDefaultSleepScreen(); + } + + const int screenWidth = renderer.getScreenWidth(); + const int screenHeight = renderer.getScreenHeight(); + if (abs(pxcWidth - screenWidth) > 1 || abs(pxcHeight - screenHeight) > 1) { + LOG_ERR("SLP", "PXC size %dx%d does not match screen %dx%d", pxcWidth, pxcHeight, screenWidth, screenHeight); + file.close(); + return renderDefaultSleepScreen(); + } + + const uint32_t dataOffset = file.position(); // right after the 4-byte header + const auto filter = SETTINGS.sleepScreenCoverFilter; + const int bytesPerRow = (pxcWidth + 3) / 4; + + if (filter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER) { + lastGrayscalePath = path; + lastGrayscaleIsPxc = true; + struct PxcCtx { + FsFile* file; + uint32_t dataOffset; + int width, height; + }; + PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight}; + + renderer.renderGrayscaleSinglePass( + gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, + [](const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + c->file->seek(c->dataOffset); + + const int bpr = (c->width + 3) / 4; + uint8_t* rowBuf = static_cast(malloc(bpr)); + if (!rowBuf) { + LOG_ERR("SLP", "malloc failed for rowBuf (%d bytes, %dx%d)", bpr, c->width, c->height); + return; + } + + DirectPixelWriter pw; + pw.init(r); + + for (int row = 0; row < c->height; row++) { + if (c->file->read(rowBuf, bpr) != bpr) break; + pw.beginRow(row); + for (int col = 0; col < c->width; col++) { + const uint8_t pv = (rowBuf[col >> 2] >> (6 - (col & 3) * 2)) & 0x03; + pw.writePixel(pv); + } + } + free(rowBuf); + }, + &ctx, + [](const GfxRenderer& r, const void*) { + constexpr int margin = 15; + const char* msg = tr(STR_ENTERING_SLEEP); + const int y = static_cast(r.getScreenHeight() * 0.075f); + const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); + const int w = textWidth + margin * 2; + const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; + const int x = (r.getScreenWidth() - w) / 2; + r.fillRect(x - 2, y - 2, w + 4, h + 4, true); + r.fillRect(x, y, w, h, false); + r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); + }, + nullptr); + } else { + // BLACK_AND_WHITE / INVERTED_BLACK_AND_WHITE: threshold PXC to 1-bit + // (pv 0=Black, 1=DarkGrey map to dark; 2=LightGrey, 3=White map to light) + renderer.clearScreen(); + if (!file.seek(dataOffset)) { + LOG_ERR("SLP", "PXC seek failed: %s", path.c_str()); + file.close(); + return renderDefaultSleepScreen(); + } + + uint8_t* rowBuf = static_cast(malloc(bytesPerRow)); + if (!rowBuf) { + LOG_ERR("SLP", "PXC malloc failed"); + file.close(); + return renderDefaultSleepScreen(); + } + + for (int row = 0; row < pxcHeight; row++) { + if (file.read(rowBuf, bytesPerRow) != bytesPerRow) break; + for (int col = 0; col < pxcWidth; col++) { + const uint8_t pv = (rowBuf[col >> 2] >> (6 - (col & 3) * 2)) & 0x03; + if (pv < 2) renderer.drawPixel(col, row, true); + } + } + free(rowBuf); + + if (filter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) { + renderer.invertScreen(); + } + renderer.displayBuffer(HalDisplay::FULL_REFRESH); + } + + file.close(); } void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { @@ -182,34 +336,44 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { } LOG_DBG("SLP", "drawing to %d x %d", x, y); - renderer.clearScreen(); const bool hasGreyscale = bitmap.hasGreyscale() && SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER; - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); - - if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) { - renderer.invertScreen(); - } - - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - if (hasGreyscale) { - bitmap.rewindToData(); - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); - renderer.copyGrayscaleLsbBuffers(); - - bitmap.rewindToData(); - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + struct BitmapGrayCtx { + const Bitmap* bitmap; + int x, y, maxWidth, maxHeight; + float cropX, cropY; + }; + BitmapGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, cropX, cropY}; + renderer.renderGrayscaleSinglePass( + gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, + [](const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, c->cropX, c->cropY); + }, + &grayCtx, + [](const GfxRenderer& r, const void*) { + constexpr int margin = 15; + const char* msg = tr(STR_ENTERING_SLEEP); + const int y = static_cast(r.getScreenHeight() * 0.075f); + const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); + const int w = textWidth + margin * 2; + const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; + const int x = (r.getScreenWidth() - w) / 2; + r.fillRect(x - 2, y - 2, w + 4, h + 4, true); + r.fillRect(x, y, w, h, false); + r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); + }, + nullptr); + } else { + renderer.clearScreen(); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); - renderer.copyGrayscaleMsbBuffers(); - - renderer.displayGrayBuffer(); - renderer.setRenderMode(GfxRenderer::BW); + if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) { + renderer.invertScreen(); + } + renderer.displayBuffer(HalDisplay::FULL_REFRESH); } } @@ -284,6 +448,11 @@ void SleepActivity::renderCoverSleepScreen() const { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str()); + if (bitmap.hasGreyscale() && + SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER) { + lastGrayscalePath = coverBmpPath; + lastGrayscaleIsPxc = false; + } renderBitmapSleepScreen(bitmap); return; } @@ -294,5 +463,23 @@ void SleepActivity::renderCoverSleepScreen() const { void SleepActivity::renderBlankSleepScreen() const { renderer.clearScreen(); - renderer.displayBuffer(HalDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); +} + +void SleepActivity::onScreenshotRequest() { + if (lastGrayscalePath.empty()) return; + if (lastGrayscaleIsPxc) { + renderPxcSleepScreen(lastGrayscalePath); + } else { + FsFile file; + if (Storage.openFileForRead("SLP", lastGrayscalePath.c_str(), file)) { + Bitmap bitmap(file, true); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + renderBitmapSleepScreen(bitmap); + } + file.close(); + } + } + renderer.clearScreen(); + renderer.cleanupGrayscaleWithFrameBuffer(); } diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 87df8ba19d..07bb76ea0d 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -1,4 +1,6 @@ #pragma once +#include + #include "../Activity.h" class Bitmap; @@ -8,11 +10,17 @@ class SleepActivity final : public Activity { explicit SleepActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) : Activity("Sleep", renderer, mappedInput) {} void onEnter() override; + void onScreenshotRequest() override; private: void renderDefaultSleepScreen() const; void renderCustomSleepScreen() const; void renderCoverSleepScreen() const; void renderBitmapSleepScreen(const Bitmap& bitmap) const; + void renderPxcSleepScreen(const std::string& path) const; void renderBlankSleepScreen() const; + + // Tracks the last factory-LUT render so onScreenshotRequest() can re-render the same image. + mutable std::string lastGrayscalePath; + mutable bool lastGrayscaleIsPxc = false; }; diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 1a4b455134..1c50520495 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -16,6 +16,11 @@ namespace { constexpr unsigned long GO_HOME_MS = 1000; + +bool isSupportedFile(std::string_view name) { + return FsHelpers::hasEpubExtension(name) || FsHelpers::hasXtcExtension(name) || FsHelpers::hasTxtExtension(name) || + FsHelpers::hasMarkdownExtension(name) || FsHelpers::hasBmpExtension(name) || FsHelpers::hasPxcExtension(name); +} } // namespace void sortFileList(std::vector& strs) { @@ -91,9 +96,7 @@ void FileBrowserActivity::loadFiles() { files.emplace_back(std::string(name) + "/"); } else { std::string_view filename{name}; - if (FsHelpers::hasEpubExtension(filename) || FsHelpers::hasXtcExtension(filename) || - FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename) || - FsHelpers::hasBmpExtension(filename)) { + if (isSupportedFile(filename)) { files.emplace_back(filename); } } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index bd700b761a..08b5780124 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -681,7 +681,12 @@ void EpubReaderActivity::render(RenderLock&& lock) { if (pendingScreenshot) { pendingScreenshot = false; - ScreenshotUtil::takeScreenshot(renderer); + if (lastPageWasFactoryGray) { + ScreenshotUtil::prepareFactoryLutScreenshot(renderer); + onScreenshotRequest(); + } else { + ScreenshotUtil::takeScreenshot(renderer); + } } } @@ -752,34 +757,20 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or LOG_DBG("ERS", "Heap: before=%lu after=%lu delta=%ld", heapBefore, heapAfter, (int32_t)heapAfter - (int32_t)heapBefore); - // Force special handling for pages with images when anti-aliasing is on - bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing; - page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); renderStatusBar(); fcm->logStats("bw_render"); const auto tBwRender = millis(); - if (imagePageWithAA) { - // Double FAST_REFRESH with selective image blanking (pablohc's technique): - // HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust. - // Instead, blank only the image area and do two fast refreshes. - // Step 1: Display page with image area blanked (text appears, image area white) - // Step 2: Re-render with images and display again (images appear clean) - int16_t imgX, imgY, imgW, imgH; - if (page->getImageBoundingBox(imgX, imgY, imgW, imgH)) { - renderer.fillRect(imgX + orientedMarginLeft, imgY + orientedMarginTop, imgW, imgH, false); - renderer.displayBuffer(HalDisplay::FAST_REFRESH); - - // Re-render page content to restore images into the blanked area - // Status bar is not re-rendered here to avoid reading stale dynamic values (e.g. battery %) - page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); - renderer.displayBuffer(HalDisplay::FAST_REFRESH); - } else { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - } - // Double FAST_REFRESH handles ghosting for image pages; don't count toward full refresh cadence + const bool isImagePage = page->hasImages(); + const bool useFactoryGray = SETTINGS.textAntiAliasing && isImagePage && !gpio.deviceIsX3(); + lastPageWasFactoryGray = useFactoryGray; + if (useFactoryGray) { + // Factory gray mode: skip BW display entirely — factory LUT drives pixels absolutely + lastFactoryMarginTop = orientedMarginTop; + lastFactoryMarginLeft = orientedMarginLeft; } else { + // Text-only AA or no AA: BW display with refresh cadence ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); } const auto tDisplay = millis(); @@ -789,37 +780,40 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or const auto tBwStore = millis(); // grayscale rendering - // TODO: Only do this if font supports it if (SETTINGS.textAntiAliasing) { - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); - page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); - renderer.copyGrayscaleLsbBuffers(); - const auto tGrayLsb = millis(); - - // Render and copy to MSB buffer - renderer.clearScreen(0x00); - renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); - page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); - renderer.copyGrayscaleMsbBuffers(); - const auto tGrayMsb = millis(); - - // display grayscale part - renderer.displayGrayBuffer(); - const auto tGrayDisplay = millis(); - renderer.setRenderMode(GfxRenderer::BW); - fcm->logStats("gray"); + struct PageRenderCtx { + Page* page; + int fontId, left, top; + const EpubReaderActivity* activity; + }; + PageRenderCtx grayCtx{page.get(), SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop, this}; + const auto grayFn = [](const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + c->page->render(const_cast(r), c->fontId, c->left, c->top); + c->activity->renderStatusBar(); + }; + + const auto tGrayStart = millis(); + const auto grayMode = + useFactoryGray ? GfxRenderer::GrayscaleMode::FactoryQuality : GfxRenderer::GrayscaleMode::Differential; + renderer.renderGrayscale(grayMode, grayFn, &grayCtx); + const auto tGrayEnd = millis(); + fcm->logStats(useFactoryGray ? "gray_factory_quality" : "gray"); - // restore the bw data renderer.restoreBwBuffer(); + if (useFactoryGray) { + // Factory LUT leaves RED RAM in gray-encoded state; sync controller to the + // restored BW framebuffer so subsequent BW page turns render cleanly. + renderer.cleanupGrayscaleWithFrameBuffer(); + } const auto tBwRestore = millis(); const auto tEnd = millis(); LOG_DBG("ERS", - "Page render: prewarm=%lums bw_render=%lums display=%lums bw_store=%lums " - "gray_lsb=%lums gray_msb=%lums gray_display=%lums bw_restore=%lums total=%lums", - tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, tGrayLsb - tBwStore, - tGrayMsb - tGrayLsb, tGrayDisplay - tGrayMsb, tBwRestore - tGrayDisplay, tEnd - t0); + "Page render (%s): prewarm=%lums bw_render=%lums display=%lums bw_store=%lums " + "gray=%lums bw_restore=%lums total=%lums", + useFactoryGray ? "factory" : "diff", tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, + tBwStore - tDisplay, tGrayEnd - tGrayStart, tBwRestore - tGrayEnd, tEnd - t0); } else { // restore the bw data renderer.restoreBwBuffer(); @@ -833,6 +827,31 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or } } +void EpubReaderActivity::onScreenshotRequest() { + if (!section || !lastPageWasFactoryGray) return; + + auto p = section->loadPageFromSectionFile(); + if (!p) return; + + struct PageRenderCtx { + Page* page; + int fontId, left, top; + const EpubReaderActivity* activity; + }; + PageRenderCtx grayCtx{p.get(), SETTINGS.getReaderFontId(), lastFactoryMarginLeft, lastFactoryMarginTop, this}; + const auto grayFn = [](const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + c->page->render(const_cast(r), c->fontId, c->left, c->top); + c->activity->renderStatusBar(); + }; + + renderer.renderGrayscale( + gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, grayFn, + &grayCtx); + renderer.clearScreen(); + renderer.cleanupGrayscaleWithFrameBuffer(); +} + void EpubReaderActivity::renderStatusBar() const { // Calculate progress in book const int currentPage = section->currentPage + 1; diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index d786ffed56..59a999c4f7 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -30,6 +30,10 @@ class EpubReaderActivity final : public Activity { bool pendingScreenshot = false; bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit bool automaticPageTurnActive = false; + // Context saved from the last factory-gray image page render, used by onScreenshotRequest(). + bool lastPageWasFactoryGray = false; + int lastFactoryMarginTop = 0; + int lastFactoryMarginLeft = 0; // Footnote support std::vector currentPageFootnotes; @@ -64,5 +68,6 @@ class EpubReaderActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&& lock) override; + void onScreenshotRequest() override; bool isReaderActivity() const override { return true; } }; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 989e6d2231..aa3c5d03fb 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -12,6 +12,7 @@ #include "XtcReaderActivity.h" #include "activities/util/BmpViewerActivity.h" #include "activities/util/FullScreenMessageActivity.h" +#include "activities/util/PxcViewerActivity.h" bool ReaderActivity::isXtcFile(const std::string& path) { return FsHelpers::hasXtcExtension(path); } @@ -22,6 +23,8 @@ bool ReaderActivity::isTxtFile(const std::string& path) { bool ReaderActivity::isBmpFile(const std::string& path) { return FsHelpers::hasBmpExtension(path); } +bool ReaderActivity::isPxcFile(const std::string& path) { return FsHelpers::hasPxcExtension(path); } + std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { if (!Storage.exists(path.c_str())) { LOG_ERR("READER", "File does not exist: %s", path.c_str()); @@ -83,6 +86,10 @@ void ReaderActivity::onGoToBmpViewer(const std::string& path) { activityManager.replaceActivity(std::make_unique(renderer, mappedInput, path)); } +void ReaderActivity::onGoToPxcViewer(const std::string& path) { + activityManager.replaceActivity(std::make_unique(renderer, mappedInput, path)); +} + void ReaderActivity::onGoToXtcReader(std::unique_ptr xtc) { const auto xtcPath = xtc->getPath(); currentBookPath = xtcPath; @@ -106,6 +113,8 @@ void ReaderActivity::onEnter() { currentBookPath = initialBookPath; if (isBmpFile(initialBookPath)) { onGoToBmpViewer(initialBookPath); + } else if (isPxcFile(initialBookPath)) { + onGoToPxcViewer(initialBookPath); } else if (isXtcFile(initialBookPath)) { auto xtc = loadXtc(initialBookPath); if (!xtc) { diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index f5c61a393d..e4cf6194b2 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -17,12 +17,14 @@ class ReaderActivity final : public Activity { static bool isXtcFile(const std::string& path); static bool isTxtFile(const std::string& path); static bool isBmpFile(const std::string& path); + static bool isPxcFile(const std::string& path); void goToLibrary(const std::string& fromBookPath = ""); void onGoToEpubReader(std::unique_ptr epub); void onGoToXtcReader(std::unique_ptr xtc); void onGoToTxtReader(std::unique_ptr txt); void onGoToBmpViewer(const std::string& path); + void onGoToPxcViewer(const std::string& path); void onGoBack(); diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 8694be080f..3c8d45c4a5 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -52,6 +52,7 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) { inline void displayWithRefreshCycle(const GfxRenderer& renderer, int& pagesUntilFullRefresh) { if (pagesUntilFullRefresh <= 1) { renderer.displayBuffer(HalDisplay::HALF_REFRESH); + renderer.cleanupGrayscaleWithFrameBuffer(); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { renderer.displayBuffer(); diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index d2b6f18ea7..a2f195f999 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -34,6 +34,10 @@ void XtcReaderActivity::onEnter() { xtc->setupCacheDir(); + // Pre-flash to white so the factory LUT can drive particles reliably from any prior state. + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + // Load saved progress loadProgress(); @@ -151,17 +155,64 @@ void XtcReaderActivity::renderPage() { const uint16_t pageHeight = xtc->getPageHeight(); const uint8_t bitDepth = xtc->getBitDepth(); - // Calculate buffer size for one page - // XTG (1-bit): Row-major, ((width+7)/8) * height bytes - // XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes - size_t pageBufferSize; if (bitDepth == 2) { - pageBufferSize = ((static_cast(pageWidth) * pageHeight + 7) / 8) * 2; - } else { - pageBufferSize = ((pageWidth + 7) / 8) * pageHeight; + // Load each XTCH plane separately to stay within heap limits. + // Combined size (~96KB) exceeds MaxAlloc; each plane (~48KB) fits. + const size_t planeSize = (static_cast(pageWidth) * pageHeight + 7) / 8; + + uint8_t* plane1 = static_cast(malloc(planeSize)); + if (!plane1) { + LOG_ERR("XTR", "Failed to allocate plane1 (%lu bytes)", planeSize); + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_MEMORY_ERROR), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + if (xtc->loadPageMsb(currentPage, plane1, planeSize) == 0) { + LOG_ERR("XTR", "Failed to load plane1 for page %lu", currentPage); + free(plane1); + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_PAGE_LOAD_ERROR), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + uint8_t* plane2 = static_cast(malloc(planeSize)); + if (!plane2) { + LOG_ERR("XTR", "Failed to allocate plane2 (%lu bytes)", planeSize); + free(plane1); + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_MEMORY_ERROR), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + if (xtc->loadPageLsb(currentPage, plane2, planeSize) == 0) { + LOG_ERR("XTR", "Failed to load plane2 for page %lu", currentPage); + free(plane1); + free(plane2); + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_PAGE_LOAD_ERROR), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + // Periodic FULL_REFRESH resets DC balance; every 32 pages. + if (++pagesSinceClean >= 32) { + pagesSinceClean = 0; + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); + } + + renderer.displayXtchPlanes(plane1, plane2, pageWidth, pageHeight); + free(plane1); + free(plane2); + + LOG_DBG("XTR", "Rendered page %lu/%lu (2-bit factory)", currentPage + 1, xtc->getPageCount()); + return; } - // Allocate page buffer + // 1-bit XTG path + const size_t pageBufferSize = ((pageWidth + 7) / 8) * pageHeight; uint8_t* pageBuffer = static_cast(malloc(pageBufferSize)); if (!pageBuffer) { LOG_ERR("XTR", "Failed to allocate page buffer (%lu bytes)", pageBufferSize); @@ -170,10 +221,7 @@ void XtcReaderActivity::renderPage() { renderer.displayBuffer(); return; } - - // Load page data - size_t bytesRead = xtc->loadPage(currentPage, pageBuffer, pageBufferSize); - if (bytesRead == 0) { + if (xtc->loadPage(currentPage, pageBuffer, pageBufferSize) == 0) { LOG_ERR("XTR", "Failed to load page %lu", currentPage); free(pageBuffer); renderer.clearScreen(); @@ -181,151 +229,13 @@ void XtcReaderActivity::renderPage() { renderer.displayBuffer(); return; } - - // Clear screen first - renderer.clearScreen(); - - // Copy page bitmap using GfxRenderer's drawPixel - // XTC/XTCH pages are pre-rendered with status bar included, so render full page - const uint16_t maxSrcY = pageHeight; - - if (bitDepth == 2) { - // XTH 2-bit mode: Two bit planes, column-major order - // - Columns scanned right to left (x = width-1 down to 0) - // - 8 vertical pixels per byte (MSB = topmost pixel in group) - // - First plane: Bit1, Second plane: Bit2 - // - Pixel value = (bit1 << 1) | bit2 - // - Grayscale: 0=White, 1=Dark Grey, 2=Light Grey, 3=Black - - const size_t planeSize = (static_cast(pageWidth) * pageHeight + 7) / 8; - const uint8_t* plane1 = pageBuffer; // Bit1 plane - const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane - const size_t colBytes = (pageHeight + 7) / 8; // Bytes per column (100 for 800 height) - - // Lambda to get pixel value at (x, y) - auto getPixelValue = [&](uint16_t x, uint16_t y) -> uint8_t { - const size_t colIndex = pageWidth - 1 - x; - const size_t byteInCol = y / 8; - const size_t bitInByte = 7 - (y % 8); - const size_t byteOffset = colIndex * colBytes + byteInCol; - const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; - const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; - return (bit1 << 1) | bit2; - }; - - // Optimized grayscale rendering without storeBwBuffer (saves 48KB peak memory) - // Flow: BW display → LSB/MSB passes → grayscale display → re-render BW for next frame - - // Count pixel distribution for debugging - uint32_t pixelCounts[4] = {0, 0, 0, 0}; - for (uint16_t y = 0; y < pageHeight; y++) { - for (uint16_t x = 0; x < pageWidth; x++) { - pixelCounts[getPixelValue(x, y)]++; - } - } - LOG_DBG("XTR", "Pixel distribution: White=%lu, DarkGrey=%lu, LightGrey=%lu, Black=%lu", pixelCounts[0], - pixelCounts[1], pixelCounts[2], pixelCounts[3]); - - // Pass 1: BW buffer - draw all non-white pixels as black - for (uint16_t y = 0; y < pageHeight; y++) { - for (uint16_t x = 0; x < pageWidth; x++) { - if (getPixelValue(x, y) >= 1) { - renderer.drawPixel(x, y, true); - } - } - } - - // Display BW with conditional refresh based on pagesUntilFullRefresh - if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); - } else { - renderer.displayBuffer(); - pagesUntilFullRefresh--; - } - - // Pass 2: LSB buffer - mark DARK gray only (XTH value 1) - // In LUT: 0 bit = apply gray effect, 1 bit = untouched - renderer.clearScreen(0x00); - for (uint16_t y = 0; y < pageHeight; y++) { - for (uint16_t x = 0; x < pageWidth; x++) { - if (getPixelValue(x, y) == 1) { // Dark grey only - renderer.drawPixel(x, y, false); - } - } - } - renderer.copyGrayscaleLsbBuffers(); - - // Pass 3: MSB buffer - mark LIGHT AND DARK gray (XTH value 1 or 2) - // In LUT: 0 bit = apply gray effect, 1 bit = untouched - renderer.clearScreen(0x00); - for (uint16_t y = 0; y < pageHeight; y++) { - for (uint16_t x = 0; x < pageWidth; x++) { - const uint8_t pv = getPixelValue(x, y); - if (pv == 1 || pv == 2) { // Dark grey or Light grey - renderer.drawPixel(x, y, false); - } - } - } - renderer.copyGrayscaleMsbBuffers(); - - // Display grayscale overlay - renderer.displayGrayBuffer(); - - // Pass 4: Re-render BW to framebuffer (restore for next frame, instead of restoreBwBuffer) - renderer.clearScreen(); - for (uint16_t y = 0; y < pageHeight; y++) { - for (uint16_t x = 0; x < pageWidth; x++) { - if (getPixelValue(x, y) >= 1) { - renderer.drawPixel(x, y, true); - } - } - } - - // Cleanup grayscale buffers with current frame buffer - renderer.cleanupGrayscaleWithFrameBuffer(); - - free(pageBuffer); - - LOG_DBG("XTR", "Rendered page %lu/%lu (2-bit grayscale)", currentPage + 1, xtc->getPageCount()); - return; - } else { - // 1-bit mode: 8 pixels per byte, MSB first - const size_t srcRowBytes = (pageWidth + 7) / 8; // 60 bytes for 480 width - - for (uint16_t srcY = 0; srcY < maxSrcY; srcY++) { - const size_t srcRowStart = srcY * srcRowBytes; - - for (uint16_t srcX = 0; srcX < pageWidth; srcX++) { - // Read source pixel (MSB first, bit 7 = leftmost pixel) - const size_t srcByte = srcRowStart + srcX / 8; - const size_t srcBit = 7 - (srcX % 8); - const bool isBlack = !((pageBuffer[srcByte] >> srcBit) & 1); // XTC: 0 = black, 1 = white - - if (isBlack) { - renderer.drawPixel(srcX, srcY, true); - } - } - } - } - // White pixels are already cleared by clearScreen() - + renderer.displayXtcBwPage(pageBuffer, pageWidth, pageHeight); free(pageBuffer); - - // XTC pages already have status bar pre-rendered, no need to add our own - - // Display with appropriate refresh - if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); - } else { - renderer.displayBuffer(); - pagesUntilFullRefresh--; - } - - LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth); + LOG_DBG("XTR", "Rendered page %lu/%lu (1-bit)", currentPage + 1, xtc->getPageCount()); } +void XtcReaderActivity::onScreenshotRequest() { renderPage(); } + void XtcReaderActivity::saveProgress() const { FsFile f; if (Storage.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) { diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index 18effaade5..5830a2b8d9 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -15,7 +15,7 @@ class XtcReaderActivity final : public Activity { std::shared_ptr xtc; uint32_t currentPage = 0; - int pagesUntilFullRefresh = 0; + uint32_t pagesSinceClean = 0; void renderPage(); void saveProgress() const; @@ -28,5 +28,6 @@ class XtcReaderActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; + void onScreenshotRequest() override; bool isReaderActivity() const override { return true; } }; diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index 37fa8fe19f..75b1722228 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -19,8 +20,6 @@ void BmpViewerActivity::onEnter() { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - Rect popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); - GUI.fillPopupProgress(renderer, popupRect, 20); // Initial 20% progress // 1. Open the file if (Storage.openFileForRead("BMP", filePath, file)) { Bitmap bitmap(file, true); @@ -48,20 +47,45 @@ void BmpViewerActivity::onEnter() { y = (pageHeight - bitmap.getHeight()) / 2; } - // 4. Prepare Rendering const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); - GUI.fillPopupProgress(renderer, popupRect, 50); - renderer.clearScreen(); - // Assuming drawBitmap defaults to 0,0 crop if omitted, or pass explicitly: drawBitmap(bitmap, x, y, pageWidth, - // pageHeight, 0, 0) - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0); - - // Draw UI hints on the base layer - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - // Single pass for non-grayscale images - - renderer.displayBuffer(HalDisplay::HALF_REFRESH); + if (bitmap.hasGreyscale()) { + struct BmpGrayCtx { + Bitmap* bitmap; + int x, y, maxWidth, maxHeight; + MappedInputManager::Labels labels; + }; + BmpGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, labels}; + renderer.renderGrayscaleSinglePass( + gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, + [](const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, 0, 0); + GUI.drawButtonHints(const_cast(r), c->labels.btn1, c->labels.btn2, c->labels.btn3, + c->labels.btn4); + }, + &grayCtx, + [](const GfxRenderer& r, const void*) { + constexpr int margin = 15; + const char* msg = tr(STR_LOADING_POPUP); + const int y = static_cast(r.getScreenHeight() * 0.075f); + const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); + const int w = textWidth + margin * 2; + const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; + const int x = (r.getScreenWidth() - w) / 2; + r.fillRect(x - 2, y - 2, w + 4, h + 4, true); + r.fillRect(x, y, w, h, false); + r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); + }, + nullptr); + renderer.clearScreen(); + renderer.cleanupGrayscaleWithFrameBuffer(); + } else { + renderer.clearScreen(); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); + } } else { // Handle file parsing error @@ -69,7 +93,7 @@ void BmpViewerActivity::onEnter() { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Invalid BMP File"); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(HalDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); } file.close(); @@ -79,8 +103,76 @@ void BmpViewerActivity::onEnter() { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Could not open file"); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(HalDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); + } +} + +void BmpViewerActivity::renderGrayscaleImage() { + FsFile file; + if (!Storage.openFileForRead("BMP", filePath, file)) return; + + Bitmap bitmap(file, true); + if (bitmap.parseHeaders() != BmpReaderError::Ok || !bitmap.hasGreyscale()) { + file.close(); + return; + } + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + int x, y; + if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) { + float ratio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + const float screenRatio = static_cast(pageWidth) / static_cast(pageHeight); + if (ratio > screenRatio) { + x = 0; + y = std::round((static_cast(pageHeight) - static_cast(pageWidth) / ratio) / 2); + } else { + x = std::round((static_cast(pageWidth) - static_cast(pageHeight) * ratio) / 2); + y = 0; + } + } else { + x = (pageWidth - bitmap.getWidth()) / 2; + y = (pageHeight - bitmap.getHeight()) / 2; } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + struct BmpGrayCtx { + Bitmap* bitmap; + int x, y, maxWidth, maxHeight; + MappedInputManager::Labels labels; + }; + BmpGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, labels}; + + renderer.renderGrayscaleSinglePass( + gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, + [](const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, 0, 0); + GUI.drawButtonHints(const_cast(r), c->labels.btn1, c->labels.btn2, c->labels.btn3, + c->labels.btn4); + }, + &grayCtx, + [](const GfxRenderer& r, const void*) { + constexpr int margin = 15; + const char* msg = tr(STR_LOADING_POPUP); + const int y = static_cast(r.getScreenHeight() * 0.075f); + const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); + const int w = textWidth + margin * 2; + const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; + const int x = (r.getScreenWidth() - w) / 2; + r.fillRect(x - 2, y - 2, w + 4, h + 4, true); + r.fillRect(x, y, w, h, false); + r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); + }, + nullptr); + + file.close(); +} + +void BmpViewerActivity::onScreenshotRequest() { + renderGrayscaleImage(); + renderer.clearScreen(); + renderer.cleanupGrayscaleWithFrameBuffer(); } void BmpViewerActivity::onExit() { diff --git a/src/activities/util/BmpViewerActivity.h b/src/activities/util/BmpViewerActivity.h index feac448e16..849a94fe0c 100644 --- a/src/activities/util/BmpViewerActivity.h +++ b/src/activities/util/BmpViewerActivity.h @@ -13,7 +13,9 @@ class BmpViewerActivity final : public Activity { void onEnter() override; void onExit() override; void loop() override; + void onScreenshotRequest() override; private: std::string filePath; + void renderGrayscaleImage(); }; \ No newline at end of file diff --git a/src/activities/util/PxcViewerActivity.cpp b/src/activities/util/PxcViewerActivity.cpp new file mode 100644 index 0000000000..bd08e38ae9 --- /dev/null +++ b/src/activities/util/PxcViewerActivity.cpp @@ -0,0 +1,209 @@ +#include "PxcViewerActivity.h" + +#include +#include +#include +#include + +#include "Epub/converters/DirectPixelWriter.h" +#include "components/UITheme.h" +#include "fontIds.h" + +PxcViewerActivity::PxcViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path) + : Activity("PxcViewer", renderer, mappedInput), filePath(std::move(path)) {} + +void PxcViewerActivity::onEnter() { + Activity::onEnter(); + + const int screenWidth = renderer.getScreenWidth(); + const int screenHeight = renderer.getScreenHeight(); + FsFile file; + if (!Storage.openFileForRead("PXC", filePath, file)) { + renderer.clearScreen(); + renderer.drawCenteredText(UI_10_FONT_ID, screenHeight / 2, "Could not open file"); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); + return; + } + + uint16_t pxcWidth, pxcHeight; + if (file.read(&pxcWidth, 2) != 2 || file.read(&pxcHeight, 2) != 2) { + LOG_ERR("PXC", "Header read failed: %s", filePath.c_str()); + file.close(); + renderer.clearScreen(); + renderer.drawCenteredText(UI_10_FONT_ID, screenHeight / 2, "Invalid PXC file"); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); + return; + } + + if (abs(pxcWidth - screenWidth) > 1 || abs(pxcHeight - screenHeight) > 1) { + LOG_ERR("PXC", "PXC size %dx%d does not match screen %dx%d", pxcWidth, pxcHeight, screenWidth, screenHeight); + file.close(); + renderer.clearScreen(); + renderer.drawCenteredText(UI_10_FONT_ID, screenHeight / 2, "PXC size mismatch"); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); + return; + } + + const uint32_t dataOffset = file.position(); + + struct PxcCtx { + FsFile* file; + uint32_t dataOffset; + int width, height; + MappedInputManager::Labels labels; + }; + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight, labels}; + + renderer.renderGrayscaleSinglePass( + gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, + [](const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + c->file->seek(c->dataOffset); + + const int bytesPerRow = (c->width + 3) / 4; + uint8_t* rowBuf = static_cast(malloc(bytesPerRow)); + if (!rowBuf) { + LOG_ERR("PXC", "malloc failed for rowBuf (%d bytes, %dx%d)", bytesPerRow, c->width, c->height); + return; + } + + DirectPixelWriter pw; + pw.init(r); + + for (int row = 0; row < c->height; row++) { + if (c->file->read(rowBuf, bytesPerRow) != bytesPerRow) break; + pw.beginRow(row); + for (int col = 0; col < c->width; col++) { + const uint8_t pv = (rowBuf[col >> 2] >> (6 - (col & 3) * 2)) & 0x03; + pw.writePixel(pv); + } + } + free(rowBuf); + + GUI.drawButtonHints(const_cast(r), c->labels.btn1, c->labels.btn2, c->labels.btn3, + c->labels.btn4); + }, + &ctx, + [](const GfxRenderer& r, const void*) { + constexpr int margin = 15; + const char* msg = tr(STR_LOADING_POPUP); + const int y = static_cast(r.getScreenHeight() * 0.075f); + const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); + const int w = textWidth + margin * 2; + const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; + const int x = (r.getScreenWidth() - w) / 2; + r.fillRect(x - 2, y - 2, w + 4, h + 4, true); + r.fillRect(x, y, w, h, false); + r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); + }, + nullptr); + + file.close(); + + // Sync BW framebuffer state after factory-gray render so onExit's HALF_REFRESH + // does a correct differential (controller BW state = white, not stale gray planes). + renderer.clearScreen(); + renderer.cleanupGrayscaleWithFrameBuffer(); +} + +void PxcViewerActivity::renderGrayscaleImage() { + FsFile file; + if (!Storage.openFileForRead("PXC", filePath, file)) return; + + uint16_t pxcWidth, pxcHeight; + if (file.read(&pxcWidth, 2) != 2 || file.read(&pxcHeight, 2) != 2) { + file.close(); + return; + } + + const int screenWidth = renderer.getScreenWidth(); + const int screenHeight = renderer.getScreenHeight(); + if (abs(pxcWidth - screenWidth) > 1 || abs(pxcHeight - screenHeight) > 1) { + file.close(); + return; + } + + const uint32_t dataOffset = file.position(); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + struct PxcCtx { + FsFile* file; + uint32_t dataOffset; + int width, height; + MappedInputManager::Labels labels; + }; + PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight, labels}; + + renderer.renderGrayscaleSinglePass( + gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, + [](const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + c->file->seek(c->dataOffset); + + const int bytesPerRow = (c->width + 3) / 4; + uint8_t* rowBuf = static_cast(malloc(bytesPerRow)); + if (!rowBuf) { + LOG_ERR("PXC", "malloc failed for rowBuf (%d bytes, %dx%d)", bytesPerRow, c->width, c->height); + return; + } + + DirectPixelWriter pw; + pw.init(r); + + for (int row = 0; row < c->height; row++) { + if (c->file->read(rowBuf, bytesPerRow) != bytesPerRow) break; + pw.beginRow(row); + for (int col = 0; col < c->width; col++) { + const uint8_t pv = (rowBuf[col >> 2] >> (6 - (col & 3) * 2)) & 0x03; + pw.writePixel(pv); + } + } + free(rowBuf); + + GUI.drawButtonHints(const_cast(r), c->labels.btn1, c->labels.btn2, c->labels.btn3, + c->labels.btn4); + }, + &ctx, + [](const GfxRenderer& r, const void*) { + constexpr int margin = 15; + const char* msg = tr(STR_LOADING_POPUP); + const int y = static_cast(r.getScreenHeight() * 0.075f); + const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); + const int w = textWidth + margin * 2; + const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; + const int x = (r.getScreenWidth() - w) / 2; + r.fillRect(x - 2, y - 2, w + 4, h + 4, true); + r.fillRect(x, y, w, h, false); + r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); + }, + nullptr); + + file.close(); +} + +void PxcViewerActivity::onScreenshotRequest() { + renderGrayscaleImage(); + renderer.clearScreen(); + renderer.cleanupGrayscaleWithFrameBuffer(); +} + +void PxcViewerActivity::onExit() { + Activity::onExit(); + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); +} + +void PxcViewerActivity::loop() { + Activity::loop(); + + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + activityManager.goToFileBrowser(filePath); + return; + } +} diff --git a/src/activities/util/PxcViewerActivity.h b/src/activities/util/PxcViewerActivity.h new file mode 100644 index 0000000000..ffa0f5c18d --- /dev/null +++ b/src/activities/util/PxcViewerActivity.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +#include "../Activity.h" +#include "MappedInputManager.h" + +class PxcViewerActivity final : public Activity { + public: + PxcViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string filePath); + + void onEnter() override; + void onExit() override; + void loop() override; + void onScreenshotRequest() override; + + private: + std::string filePath; + void renderGrayscaleImage(); +}; diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 425981a217..36c1c8cd49 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -84,7 +84,7 @@ UIIcon UITheme::getFileIcon(const std::string& filename) { if (FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename)) { return Text; } - if (FsHelpers::hasBmpExtension(filename)) { + if (FsHelpers::hasBmpExtension(filename) || FsHelpers::hasPxcExtension(filename)) { return Image; } return File; From 53e300c80a5d96b6b63cfe56bf9dbe8691c3c510 Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Mon, 20 Apr 2026 00:15:07 +0200 Subject: [PATCH 03/57] feat: grayscale screenshot capture for factory LUT renders Add ScreenshotUtil to detect factory LUT display state and re-render the current page via renderGrayscale for accurate screenshot output. X3 falls back to Differential mode for all grayscale paths. --- src/main.cpp | 13 ++- src/util/ScreenshotUtil.cpp | 173 ++++++++++++++++++++++++++++++++++++ src/util/ScreenshotUtil.h | 11 +++ 3 files changed, 196 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index a3929d069c..c7efb24024 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -357,7 +357,18 @@ void loop() { screenshotButtonsReleased = false; { RenderLock lock; - ScreenshotUtil::takeScreenshot(renderer); + if (renderer.getDisplayState() == GfxRenderer::DisplayState::FactoryLut) { + // Display shows a grayscale image held only as physical particle positions. + // frameBuffer has been reset to white — do NOT use the BW screenshot path. + // Only arm the hook when an activity exists to call onScreenshotRequest(). + Activity* activity = activityManager.getCurrentActivity(); + if (activity) { + ScreenshotUtil::prepareFactoryLutScreenshot(renderer); + activity->onScreenshotRequest(); + } + } else { + ScreenshotUtil::takeScreenshot(renderer); + } } } return; diff --git a/src/util/ScreenshotUtil.cpp b/src/util/ScreenshotUtil.cpp index a152488d73..b5ff30d491 100644 --- a/src/util/ScreenshotUtil.cpp +++ b/src/util/ScreenshotUtil.cpp @@ -6,10 +6,16 @@ #include #include +#include #include #include "Bitmap.h" // Required for BmpHeader struct definition +// Static storage for the pending grayscale filename. Lifetime: set by prepareFactoryLutScreenshot, +// consumed (read) inside grayscaleHookCallback when the hook fires. The buffer is never freed — +// it's a fixed-size static so it persists for the lifetime of the firmware. +static char s_pendingFilename[64]; + void ScreenshotUtil::takeScreenshot(GfxRenderer& renderer) { const uint8_t* fb = renderer.getFrameBuffer(); if (fb) { @@ -118,3 +124,170 @@ bool ScreenshotUtil::saveFramebufferAsBmp(const char* filename, const uint8_t* f return true; } + +// --------------------------------------------------------------------------- +// Grayscale (factory LUT) screenshot — hook-based two-plane capture +// --------------------------------------------------------------------------- + +void ScreenshotUtil::prepareFactoryLutScreenshot(GfxRenderer& renderer) { + snprintf(s_pendingFilename, sizeof(s_pendingFilename), "/screenshots/screenshot-%lu.bmp", millis()); + renderer.setScreenshotHook(grayscaleHookCallback, s_pendingFilename); +} + +void ScreenshotUtil::grayscaleHookCallback(const uint8_t* lsbPlane, const uint8_t* msbPlane, int physWidth, + int physHeight, void* ctx) { + const char* filename = static_cast(ctx); + if (saveGrayscaleBmp(filename, lsbPlane, msbPlane, physWidth, physHeight)) { + LOG_DBG("SCR", "Grayscale screenshot saved: %s", filename); + } else { + LOG_ERR("SCR", "Failed to save grayscale screenshot"); + } +} + +bool ScreenshotUtil::saveGrayscaleBmp(const char* filename, const uint8_t* lsbPlane, const uint8_t* msbPlane, + int physWidth, int physHeight) { + // Logical output after 90° CCW rotation (same orientation as BW screenshots): + // outWidth = physHeight (BMP columns = physical rows) + // outHeight = physWidth (BMP rows = physical columns) + const int outWidth = physHeight; + const int outHeight = physWidth; + + // Ensure /screenshots directory exists. + if (!Storage.exists("/screenshots")) { + if (!Storage.mkdir("/screenshots")) { + return false; + } + } + + FsFile file; + if (!Storage.openFileForWrite("SCR", filename, file)) { + LOG_ERR("SCR", "Failed to open grayscale screenshot file"); + return false; + } + + // --- BMP file header (14 bytes) + DIB header (40 bytes) --- + // 8-bit palette BMP: pixel data offset = 14 + 40 + 256*4 = 1078 bytes. + const uint32_t rowSizePadded = (static_cast(outWidth) + 3u) & ~3u; + const uint32_t imageSize = rowSizePadded * static_cast(outHeight); + const uint32_t fileSize = 1078u + imageSize; + +#pragma pack(push, 1) + struct Bmp8Header { + // File header + uint16_t bfType; + uint32_t bfSize; + uint16_t bfReserved1; + uint16_t bfReserved2; + uint32_t bfOffBits; + // DIB header (BITMAPINFOHEADER) + uint32_t biSize; + int32_t biWidth; + int32_t biHeight; + uint16_t biPlanes; + uint16_t biBitCount; + uint32_t biCompression; + uint32_t biSizeImage; + int32_t biXPelsPerMeter; + int32_t biYPelsPerMeter; + uint32_t biClrUsed; + uint32_t biClrImportant; + }; +#pragma pack(pop) + + Bmp8Header hdr; + memset(&hdr, 0, sizeof(hdr)); + hdr.bfType = 0x4D42u; + hdr.bfSize = fileSize; + hdr.bfReserved1 = 0u; + hdr.bfReserved2 = 0u; + hdr.bfOffBits = 1078u; + hdr.biSize = 40u; + hdr.biWidth = outWidth; + hdr.biHeight = outHeight; // positive = bottom-up row order + hdr.biPlanes = 1u; + hdr.biBitCount = 8u; + hdr.biCompression = 0u; // BI_RGB (uncompressed) + hdr.biSizeImage = imageSize; + hdr.biXPelsPerMeter = 2835; + hdr.biYPelsPerMeter = 2835; + hdr.biClrUsed = 256u; + hdr.biClrImportant = 0u; + + bool write_error = false; + if (file.write(reinterpret_cast(&hdr), sizeof(hdr)) != sizeof(hdr)) { + write_error = true; + } + + if (!write_error) { + // Palette: 256 entries × 4 bytes = 1024 bytes. + // GRAY2_LSB encoding: index = lsb | (msb << 1) + // 0 → White (0xFF) lsb=0, msb=0 + // 1 → LightGrey lsb=1, msb=0 + // 2 → DarkGrey lsb=0, msb=1 + // 3 → Black (0x00) lsb=1, msb=1 + static constexpr uint8_t kPalette[16] = { + 0xFF, 0xFF, 0xFF, 0x00, // index 0: White + 0xAA, 0xAA, 0xAA, 0x00, // index 1: LightGrey + 0x55, 0x55, 0x55, 0x00, // index 2: DarkGrey + 0x00, 0x00, 0x00, 0x00, // index 3: Black + }; + if (file.write(kPalette, sizeof(kPalette)) != sizeof(kPalette)) { + write_error = true; + } + if (!write_error) { + // Write 252 zero entries to complete the 256-entry palette table. + uint8_t zeros[32] = {}; + uint32_t remaining = 252u * 4u; + while (remaining > 0u && !write_error) { + const uint32_t chunk = (remaining > sizeof(zeros)) ? static_cast(sizeof(zeros)) : remaining; + if (file.write(zeros, chunk) != chunk) write_error = true; + remaining -= chunk; + } + } + } + + if (write_error) { + file.close(); + Storage.remove(filename); + return false; + } + + // --- Pixel data: one byte per pixel (palette index 0–3), bottom-to-top rows --- + // Row buffer: outWidth bytes padded to 4-byte boundary (> 256 bytes; heap-allocated). + uint8_t* rowBuf = static_cast(malloc(rowSizePadded)); + if (!rowBuf) { + LOG_ERR("SCR", "saveGrayscaleBmp: malloc failed for row buffer (%u bytes)", rowSizePadded); + file.close(); + Storage.remove(filename); + return false; + } + + // physWidth pixels per physical row; each byte holds 8 pixels (1-bit planes). + const int bytesPerPhysRow = physWidth / 8; + + for (int outY = 0; outY < outHeight && !write_error; outY++) { + memset(rowBuf, 0, rowSizePadded); + for (int outX = 0; outX < outWidth; outX++) { + // 90° CCW rotation (same transform as BW saveFramebufferAsBmp): + // BMP rows are bottom-to-top, so outY=0 is the bottom of the logical image. + const int srcX = physWidth - 1 - outY; + const int srcY = physHeight - 1 - outX; + const int byteIdx = srcY * bytesPerPhysRow + (srcX / 8); + const int bitPos = 7 - (srcX % 8); + const uint8_t lsb = (lsbPlane[byteIdx] >> bitPos) & 1u; + const uint8_t msb = (msbPlane[byteIdx] >> bitPos) & 1u; + rowBuf[outX] = lsb | static_cast(msb << 1); + } + if (file.write(rowBuf, rowSizePadded) != rowSizePadded) write_error = true; + } + + free(rowBuf); + file.close(); + + if (write_error) { + Storage.remove(filename); + return false; + } + + return true; +} diff --git a/src/util/ScreenshotUtil.h b/src/util/ScreenshotUtil.h index 96d459e616..f866a6ecb6 100644 --- a/src/util/ScreenshotUtil.h +++ b/src/util/ScreenshotUtil.h @@ -5,4 +5,15 @@ class ScreenshotUtil { public: static void takeScreenshot(GfxRenderer& renderer); static bool saveFramebufferAsBmp(const char* filename, const uint8_t* framebuffer, int width, int height); + + // Called when displayState == FactoryLut. Installs a one-shot hook on the renderer; + // the caller must then call currentActivity->onScreenshotRequest() to trigger the re-render + // that fires the hook and captures both grayscale planes. + static void prepareFactoryLutScreenshot(GfxRenderer& renderer); + + private: + static void grayscaleHookCallback(const uint8_t* lsbPlane, const uint8_t* msbPlane, int physWidth, int physHeight, + void* ctx); + static bool saveGrayscaleBmp(const char* filename, const uint8_t* lsbPlane, const uint8_t* msbPlane, int physWidth, + int physHeight); }; From 29e8d75a2724c4771223382b7fa9b4010827385a Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Thu, 23 Apr 2026 00:34:30 +0200 Subject: [PATCH 04/57] fix: remove contrast boost from grayscale image quantization --- lib/GfxRenderer/BitmapHelpers.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp index 11f93a6e9f..9da450f057 100644 --- a/lib/GfxRenderer/BitmapHelpers.cpp +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -12,7 +12,7 @@ bool g_differentialQuantize = false; constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments constexpr int BRIGHTNESS_BOOST = 0; // No boost — quality LUT already renders slightly lighter constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones) -constexpr float CONTRAST_FACTOR = 1.2f; // Contrast boost for quality LUT (softer drive needs more contrast) +constexpr float CONTRAST_FACTOR = 1.0f; // No contrast adjustment constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering // Integer approximation of gamma correction (brightens midtones) From a405dd0116126a02a696dd56ba2ff33b84058141 Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Thu, 23 Apr 2026 00:42:13 +0200 Subject: [PATCH 05/57] fix: scope contrast boost to 1-bit thumbnails only, not 2-bit image quantization --- lib/GfxRenderer/BitmapHelpers.cpp | 2 +- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp index 9da450f057..f737fb92ae 100644 --- a/lib/GfxRenderer/BitmapHelpers.cpp +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -12,7 +12,7 @@ bool g_differentialQuantize = false; constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments constexpr int BRIGHTNESS_BOOST = 0; // No boost — quality LUT already renders slightly lighter constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones) -constexpr float CONTRAST_FACTOR = 1.0f; // No contrast adjustment +constexpr float CONTRAST_FACTOR = 1.2f; // Contrast boost for 1-bit thumbnails (applied inside Atkinson1BitDitherer) constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering // Integer approximation of gamma correction (brightens midtones) diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 0dd7872753..44bd504673 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -245,14 +245,13 @@ static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY) if (ctx->atkinson1BitDitherer) ctx->atkinson1BitDitherer->nextRow(); } else { for (int x = 0; x < ctx->outWidth; x++) { - const uint8_t gray = adjustPixel(srcRow[x]); uint8_t twoBit; if (ctx->atkinsonDitherer) { - twoBit = ctx->atkinsonDitherer->processPixel(gray, x); + twoBit = ctx->atkinsonDitherer->processPixel(srcRow[x], x); } else if (ctx->fsDitherer) { - twoBit = ctx->fsDitherer->processPixel(gray, x); + twoBit = ctx->fsDitherer->processPixel(srcRow[x], x); } else { - twoBit = quantize(gray, x, outY); + twoBit = quantize(srcRow[x], x, outY); } ctx->bmpRow[(x * 2) / 8] |= (twoBit << (6 - ((x * 2) % 8))); } @@ -284,7 +283,7 @@ static void flushScaledRow(BmpConvertCtx* ctx) { if (ctx->atkinson1BitDitherer) ctx->atkinson1BitDitherer->nextRow(); } else { for (int x = 0; x < ctx->outWidth; x++) { - const uint8_t gray = adjustPixel((ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0); + const uint8_t gray = (ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0; uint8_t twoBit; if (ctx->atkinsonDitherer) { twoBit = ctx->atkinsonDitherer->processPixel(gray, x); From a65c4bd750f59b60816a698d55c2154179c1288c Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Thu, 23 Apr 2026 00:43:30 +0200 Subject: [PATCH 06/57] chore: clang-format --- lib/GfxRenderer/BitmapHelpers.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp index f737fb92ae..96a9135c13 100644 --- a/lib/GfxRenderer/BitmapHelpers.cpp +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -9,10 +9,10 @@ bool g_differentialQuantize = false; // Brightness/Contrast adjustments: -constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments -constexpr int BRIGHTNESS_BOOST = 0; // No boost — quality LUT already renders slightly lighter -constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones) -constexpr float CONTRAST_FACTOR = 1.2f; // Contrast boost for 1-bit thumbnails (applied inside Atkinson1BitDitherer) +constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments +constexpr int BRIGHTNESS_BOOST = 0; // No boost — quality LUT already renders slightly lighter +constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones) +constexpr float CONTRAST_FACTOR = 1.2f; // Contrast boost for 1-bit thumbnails (applied inside Atkinson1BitDitherer) constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering // Integer approximation of gamma correction (brightens midtones) From 1d135f4ac48b787f4f0ceed9af7eb997a22b256e Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Thu, 23 Apr 2026 22:52:37 +0200 Subject: [PATCH 07/57] chore: point open-x4-sdk to community-sdk PR#33 (factory LUT grayscale) --- open-x4-sdk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-x4-sdk b/open-x4-sdk index add90185ab..7dd61fafa8 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit add90185ab77bb1704429b26493aa7e80a283e90 +Subproject commit 7dd61fafa87a44898baa660f343399a6a68b8c3d From edea5a4a439794068bfbbe13ce3b65543fbb6e39 Mon Sep 17 00:00:00 2001 From: pablohc Date: Thu, 23 Apr 2026 13:00:26 +0200 Subject: [PATCH 08/57] Use HALF_REFRESH instead of FULL_REFRESH for Blank and Logo sleep screen modes --- src/activities/boot_sleep/SleepActivity.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 1a2fdc211b..35f51f9181 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -181,7 +181,7 @@ void SleepActivity::renderDefaultSleepScreen() const { renderer.invertScreen(); } - renderer.displayBuffer(HalDisplay::FULL_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); } void SleepActivity::renderPxcSleepScreen(const std::string& path) const { @@ -463,7 +463,7 @@ void SleepActivity::renderCoverSleepScreen() const { void SleepActivity::renderBlankSleepScreen() const { renderer.clearScreen(); - renderer.displayBuffer(HalDisplay::FULL_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); } void SleepActivity::onScreenshotRequest() { From 548c84be7fbb52611847d357016db444c2ffa15a Mon Sep 17 00:00:00 2001 From: pablohc Date: Thu, 23 Apr 2026 15:28:49 +0200 Subject: [PATCH 09/57] Use direct XTCH plane rendering for 2-bit sleep screen covers --- src/activities/boot_sleep/SleepActivity.cpp | 35 ++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 35f51f9181..e7dc4262fb 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -397,13 +397,46 @@ void SleepActivity::renderCoverSleepScreen() const { // Check if the current book is XTC, TXT, or EPUB if (FsHelpers::hasXtcExtension(APP_STATE.openEpubPath)) { - // Handle XTC file Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint"); if (!lastXtc.load()) { LOG_ERR("SLP", "Failed to load last XTC"); return (this->*renderNoCoverSleepScreen)(); } + if (lastXtc.getBitDepth() == 2) { + const size_t planeSize = (static_cast(lastXtc.getPageWidth()) * lastXtc.getPageHeight() + 7) / 8; + uint8_t* plane1 = static_cast(malloc(planeSize)); + if (!plane1) { + LOG_ERR("SLP", "Failed to alloc plane1 for direct XTCH render (%lu bytes)", static_cast(planeSize)); + return (this->*renderNoCoverSleepScreen)(); + } + uint8_t* plane2 = static_cast(malloc(planeSize)); + if (!plane2) { + LOG_ERR("SLP", "Failed to alloc plane2 for direct XTCH render (%lu bytes)", static_cast(planeSize)); + free(plane1); + return (this->*renderNoCoverSleepScreen)(); + } + + if (lastXtc.loadPageMsb(0, plane1, planeSize) == 0) { + LOG_ERR("SLP", "Failed to load XTCH plane1 for sleep cover"); + free(plane1); + free(plane2); + return (this->*renderNoCoverSleepScreen)(); + } + if (lastXtc.loadPageLsb(0, plane2, planeSize) == 0) { + LOG_ERR("SLP", "Failed to load XTCH plane2 for sleep cover"); + free(plane1); + free(plane2); + return (this->*renderNoCoverSleepScreen)(); + } + + LOG_DBG("SLP", "Direct XTCH plane render: %ux%u", lastXtc.getPageWidth(), lastXtc.getPageHeight()); + renderer.displayXtchPlanes(plane1, plane2, lastXtc.getPageWidth(), lastXtc.getPageHeight()); + free(plane1); + free(plane2); + return; + } + if (!lastXtc.generateCoverBmp()) { LOG_ERR("SLP", "Failed to generate XTC cover bmp"); return (this->*renderNoCoverSleepScreen)(); From 228ac9440656c705e1a2eeb3057a172883e39cbf Mon Sep 17 00:00:00 2001 From: pablohc Date: Thu, 23 Apr 2026 15:32:36 +0200 Subject: [PATCH 10/57] Use direct XTC page rendering for 1-bit sleep screen covers --- src/activities/boot_sleep/SleepActivity.cpp | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index e7dc4262fb..125dfedec6 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -403,6 +403,7 @@ void SleepActivity::renderCoverSleepScreen() const { return (this->*renderNoCoverSleepScreen)(); } +<<<<<<< HEAD if (lastXtc.getBitDepth() == 2) { const size_t planeSize = (static_cast(lastXtc.getPageWidth()) * lastXtc.getPageHeight() + 7) / 8; uint8_t* plane1 = static_cast(malloc(planeSize)); @@ -437,6 +438,27 @@ void SleepActivity::renderCoverSleepScreen() const { return; } + if (lastXtc.getBitDepth() == 1) { + const size_t bufferSize = (static_cast(lastXtc.getPageWidth() + 7) / 8) * lastXtc.getPageHeight(); + uint8_t* pageBuffer = static_cast(malloc(bufferSize)); + if (!pageBuffer) { + LOG_ERR("SLP", "Failed to alloc page buffer for direct XTC render (%lu bytes)", static_cast(bufferSize)); + return (this->*renderNoCoverSleepScreen)(); + } + if (lastXtc.loadPage(0, pageBuffer, bufferSize) == 0) { + LOG_ERR("SLP", "Failed to load XTC page for sleep cover"); + free(pageBuffer); + return (this->*renderNoCoverSleepScreen)(); + } + LOG_DBG("SLP", "Direct XTC page render: %ux%u", lastXtc.getPageWidth(), lastXtc.getPageHeight()); + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + renderer.displayXtcBwPage(pageBuffer, lastXtc.getPageWidth(), lastXtc.getPageHeight()); + free(pageBuffer); + return; + } + } + if (!lastXtc.generateCoverBmp()) { LOG_ERR("SLP", "Failed to generate XTC cover bmp"); return (this->*renderNoCoverSleepScreen)(); From c791f093526c9a469f5bb8ce31758d1a679889d9 Mon Sep 17 00:00:00 2001 From: pablohc Date: Thu, 23 Apr 2026 22:02:06 +0200 Subject: [PATCH 11/57] fix: add HALF_REFRESH pre-conditioning for XTCH 2-bit sleep cover from Home When entering sleep from Home (not Reader), the display particle state is in BW mode from FAST_REFRESH renders. Without pre-conditioning, the subsequent factory LUT render produces a dirty appearance. Added a clearScreen + HALF_REFRESH before displayXtchPlanes to reset particles to a known state. --- src/activities/boot_sleep/SleepActivity.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 125dfedec6..387e7fe78c 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -432,6 +432,10 @@ void SleepActivity::renderCoverSleepScreen() const { } LOG_DBG("SLP", "Direct XTCH plane render: %ux%u", lastXtc.getPageWidth(), lastXtc.getPageHeight()); + if (!APP_STATE.lastSleepFromReader) { + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + } renderer.displayXtchPlanes(plane1, plane2, lastXtc.getPageWidth(), lastXtc.getPageHeight()); free(plane1); free(plane2); From bb69393159ac16cd8c282f2158328f28b553f189 Mon Sep 17 00:00:00 2001 From: pablohc Date: Thu, 23 Apr 2026 22:17:05 +0200 Subject: [PATCH 12/57] fix: restore 'Entering Sleep' overlay in sleep screen --- src/activities/boot_sleep/SleepActivity.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 387e7fe78c..f034ff33a1 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -19,6 +19,7 @@ void SleepActivity::onEnter() { Activity::onEnter(); + GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); if (APP_STATE.lastSleepFromReader) { ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); @@ -408,12 +409,14 @@ void SleepActivity::renderCoverSleepScreen() const { const size_t planeSize = (static_cast(lastXtc.getPageWidth()) * lastXtc.getPageHeight() + 7) / 8; uint8_t* plane1 = static_cast(malloc(planeSize)); if (!plane1) { - LOG_ERR("SLP", "Failed to alloc plane1 for direct XTCH render (%lu bytes)", static_cast(planeSize)); + LOG_ERR("SLP", "Failed to alloc plane1 for direct XTCH render (%lu bytes)", + static_cast(planeSize)); return (this->*renderNoCoverSleepScreen)(); } uint8_t* plane2 = static_cast(malloc(planeSize)); if (!plane2) { - LOG_ERR("SLP", "Failed to alloc plane2 for direct XTCH render (%lu bytes)", static_cast(planeSize)); + LOG_ERR("SLP", "Failed to alloc plane2 for direct XTCH render (%lu bytes)", + static_cast(planeSize)); free(plane1); return (this->*renderNoCoverSleepScreen)(); } @@ -446,7 +449,8 @@ void SleepActivity::renderCoverSleepScreen() const { const size_t bufferSize = (static_cast(lastXtc.getPageWidth() + 7) / 8) * lastXtc.getPageHeight(); uint8_t* pageBuffer = static_cast(malloc(bufferSize)); if (!pageBuffer) { - LOG_ERR("SLP", "Failed to alloc page buffer for direct XTC render (%lu bytes)", static_cast(bufferSize)); + LOG_ERR("SLP", "Failed to alloc page buffer for direct XTC render (%lu bytes)", + static_cast(bufferSize)); return (this->*renderNoCoverSleepScreen)(); } if (lastXtc.loadPage(0, pageBuffer, bufferSize) == 0) { From ee67faa622235d3d619574e9d9e745266a44acf4 Mon Sep 17 00:00:00 2001 From: pablohc Date: Thu, 23 Apr 2026 20:18:57 +0200 Subject: [PATCH 13/57] fix: Atkinson 1-bit dithering for XTCH 2-bit home cover thumbnails Replace the 2-bit BMP thumbnail generation for XTCH files with 1-bit BMP using Atkinson dithering (matching EPUB thumbnail quality). Key changes: - Load both XTCH bitplanes separately for area-averaged downsampling - Apply Atkinson 1-bit dithering (3 error rows, 6 neighbors, 6/8 error) - Apply 1.2x contrast boost for better tonal separation - Inverted bitplane luminance to match display polarity - Falls back to 2-bit BMP on memory allocation failure --- lib/Xtc/Xtc.cpp | 138 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 2b49b78208..cc2ba87beb 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -426,6 +426,144 @@ bool Xtc::generateThumbBmp(int height) const { const size_t planeSize = (static_cast(pageInfo.width) * pageInfo.height + 7) / 8; const size_t colBytes = (pageInfo.height + 7) / 8; const uint32_t scaleInv_fp2 = static_cast(65536.0f / scale); + + { + uint8_t* plane1Buf = static_cast(malloc(planeSize)); + if (!plane1Buf) { + LOG_ERR("XTC", "Failed to alloc plane1 for thumb dither (%lu bytes)", static_cast(planeSize)); + return false; + } + if (const_cast(parser.get())->loadPageMsb(0, plane1Buf, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane1 for thumb"); + free(plane1Buf); + return false; + } + + uint8_t* plane2Buf = static_cast(malloc(planeSize)); + if (!plane2Buf) { + LOG_ERR("XTC", "Failed to alloc plane2 for thumb dither (%lu bytes), falling back to 2-bit BMP", + static_cast(planeSize)); + free(plane1Buf); + goto fallback_2bit_thumb; + } + if (const_cast(parser.get())->loadPageLsb(0, plane2Buf, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane2 for thumb, falling back to 2-bit BMP"); + free(plane1Buf); + free(plane2Buf); + goto fallback_2bit_thumb; + } + + int16_t* errRow0 = static_cast(malloc((thumbWidth + 4) * sizeof(int16_t))); + int16_t* errRow1 = static_cast(malloc((thumbWidth + 4) * sizeof(int16_t))); + int16_t* errRow2 = static_cast(malloc((thumbWidth + 4) * sizeof(int16_t))); + if (!errRow0 || !errRow1 || !errRow2) { + LOG_ERR("XTC", "Failed to alloc dither buffers, falling back to 2-bit BMP"); + free(plane1Buf); + free(plane2Buf); + free(errRow0); + free(errRow1); + free(errRow2); + goto fallback_2bit_thumb; + } + memset(errRow0, 0, (thumbWidth + 4) * sizeof(int16_t)); + memset(errRow1, 0, (thumbWidth + 4) * sizeof(int16_t)); + memset(errRow2, 0, (thumbWidth + 4) * sizeof(int16_t)); + + FsFile thumbBmp; + if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { + free(plane1Buf); + free(plane2Buf); + free(errRow0); + free(errRow1); + free(errRow2); + return false; + } + + BmpHeader bmpHeader; + createBmpHeader(&bmpHeader, thumbWidth, thumbHeight, BmpRowOrder::TopDown); + thumbBmp.write(reinterpret_cast(&bmpHeader), sizeof(bmpHeader)); + + const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; + uint8_t* rowBuf = static_cast(malloc(rowSize)); + if (!rowBuf) { + free(plane1Buf); + free(plane2Buf); + free(errRow0); + free(errRow1); + free(errRow2); + thumbBmp.close(); + return false; + } + + for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { + memset(rowBuf, 0xFF, rowSize); + uint32_t srcYS = (static_cast(dstY) * scaleInv_fp2) >> 16; + uint32_t srcYE = (static_cast(dstY + 1) * scaleInv_fp2) >> 16; + if (srcYS >= pageInfo.height) srcYS = pageInfo.height - 1; + if (srcYE > pageInfo.height) srcYE = pageInfo.height; + if (srcYE <= srcYS) srcYE = srcYS + 1; + if (srcYE > pageInfo.height) srcYE = pageInfo.height; + for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { + uint32_t srcXS = (static_cast(dstX) * scaleInv_fp2) >> 16; + uint32_t srcXE = (static_cast(dstX + 1) * scaleInv_fp2) >> 16; + if (srcXS >= pageInfo.width) srcXS = pageInfo.width - 1; + if (srcXE > pageInfo.width) srcXE = pageInfo.width; + if (srcXE <= srcXS) srcXE = srcXS + 1; + if (srcXE > pageInfo.width) srcXE = pageInfo.width; + int lumSum = 0, total = 0; + for (uint32_t sy = srcYS; sy < srcYE; sy++) + for (uint32_t sx = srcXS; sx < srcXE; sx++) { + const size_t bo = (pageInfo.width - 1 - sx) * colBytes + sy / 8; + if (bo < planeSize) { + const uint8_t b1 = (plane1Buf[bo] >> (7 - (sy % 8))) & 1; + const uint8_t b2 = (plane2Buf[bo] >> (7 - (sy % 8))) & 1; + lumSum += (1 - b1) * 85 + (1 - b2) * 170; + total++; + } + } + const int avgLum = (total > 0) ? (lumSum * 255 / total) / 255 : 255; + int adjusted = avgLum; + adjusted = ((adjusted - 128) * 120) / 100 + 128; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + adjusted += errRow0[dstX + 2]; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + const bool dark = adjusted < 128; + const int quantizedValue = dark ? 0 : 255; + const int error = (adjusted - quantizedValue) >> 3; + errRow0[dstX + 3] += error; + errRow0[dstX + 4] += error; + errRow1[dstX + 1] += error; + errRow1[dstX + 2] += error; + errRow1[dstX + 3] += error; + errRow2[dstX + 2] += error; + if (dark) { + const size_t bi = dstX / 8; + if (bi < rowSize) rowBuf[bi] &= ~(1 << (7 - (dstX % 8))); + } + } + thumbBmp.write(rowBuf, rowSize); + int16_t* tmp = errRow0; + errRow0 = errRow1; + errRow1 = errRow2; + errRow2 = tmp; + memset(errRow2, 0, (thumbWidth + 4) * sizeof(int16_t)); + } + + free(rowBuf); + free(plane1Buf); + free(plane2Buf); + free(errRow0); + free(errRow1); + free(errRow2); + thumbBmp.close(); + LOG_DBG("XTC", "Generated 1-bit thumb BMP with dithering (%dx%d): %s", thumbWidth, thumbHeight, + getThumbBmpPath(height).c_str()); + return true; + } + + fallback_2bit_thumb: const size_t plane1BitsSize = (static_cast(thumbWidth) * thumbHeight + 7) / 8; uint8_t* plane1Bits = static_cast(malloc(plane1BitsSize)); if (!plane1Bits) { From 2bab76cb0dee7cc616b0981fc5a8eada78901938 Mon Sep 17 00:00:00 2001 From: pablohc Date: Thu, 23 Apr 2026 20:33:39 +0200 Subject: [PATCH 14/57] fix: Atkinson 1-bit dithering for XTC 1-bit home cover thumbnails Replace hash-based noise dithering with Atkinson 1-bit dithering to match EPUB thumbnail quality. Key changes: - Atkinson 1-bit dithering (3 error rows, 6 neighbors, 6/8 error) - 1.2x contrast boost for better edge definition - Same BMP convention as XTCH path (memset 0xFF + clear bit for dark) --- lib/Xtc/Xtc.cpp | 58 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index cc2ba87beb..4f6261ea4c 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -739,6 +739,21 @@ bool Xtc::generateThumbBmp(int height) const { return false; } + int16_t* errRow0 = static_cast(malloc((thumbWidth + 4) * sizeof(int16_t))); + int16_t* errRow1 = static_cast(malloc((thumbWidth + 4) * sizeof(int16_t))); + int16_t* errRow2 = static_cast(malloc((thumbWidth + 4) * sizeof(int16_t))); + if (!errRow0 || !errRow1 || !errRow2) { + free(pageBuffer); + free(rowBuffer); + free(errRow0); + free(errRow1); + free(errRow2); + return false; + } + memset(errRow0, 0, (thumbWidth + 4) * sizeof(int16_t)); + memset(errRow1, 0, (thumbWidth + 4) * sizeof(int16_t)); + memset(errRow2, 0, (thumbWidth + 4) * sizeof(int16_t)); + const uint32_t scaleInv_fp = static_cast(65536.0f / scale); const size_t srcRowBytes = (pageInfo.width + 7) / 8; @@ -770,27 +785,42 @@ bool Xtc::generateThumbBmp(int height) const { } } - uint8_t avgGray = (totalCount > 0) ? static_cast(graySum / totalCount) : 255; - uint32_t hash = static_cast(dstX) * 374761393u + static_cast(dstY) * 668265263u; - hash = (hash ^ (hash >> 13)) * 1274126177u; - const int threshold = static_cast(hash >> 24); - const int adjustedThreshold = 128 + ((threshold - 128) / 2); - const uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0; - const size_t byteIndex = dstX / 8; - const size_t bitOffset = 7 - (dstX % 8); - if (byteIndex < rowSize) { - if (oneBit) - rowBuffer[byteIndex] |= (1 << bitOffset); - else - rowBuffer[byteIndex] &= ~(1 << bitOffset); + int adjusted = (totalCount > 0) ? static_cast(graySum * 255 / totalCount) / 255 : 255; + adjusted = ((adjusted - 128) * 120) / 100 + 128; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + adjusted += errRow0[dstX + 2]; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + const bool dark = adjusted < 128; + const int quantizedValue = dark ? 0 : 255; + const int error = (adjusted - quantizedValue) >> 3; + errRow0[dstX + 3] += error; + errRow0[dstX + 4] += error; + errRow1[dstX + 1] += error; + errRow1[dstX + 2] += error; + errRow1[dstX + 3] += error; + errRow2[dstX + 2] += error; + if (dark) { + const size_t bi = dstX / 8; + if (bi < rowSize) rowBuffer[bi] &= ~(1 << (7 - (dstX % 8))); } } thumbBmp.write(rowBuffer, rowSize); + int16_t* tmp = errRow0; + errRow0 = errRow1; + errRow1 = errRow2; + errRow2 = tmp; + memset(errRow2, 0, (thumbWidth + 4) * sizeof(int16_t)); } free(rowBuffer); free(pageBuffer); - LOG_DBG("XTC", "Generated 1-bit thumb BMP (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str()); + free(errRow0); + free(errRow1); + free(errRow2); + LOG_DBG("XTC", "Generated 1-bit thumb BMP with Atkinson dithering (%dx%d): %s", thumbWidth, thumbHeight, + getThumbBmpPath(height).c_str()); return true; } From 565c77f1d99c3695dda7576628d105e8701b1ab7 Mon Sep 17 00:00:00 2001 From: pablohc Date: Thu, 23 Apr 2026 20:55:35 +0200 Subject: [PATCH 15/57] refactor: use Atkinson1BitDitherer and computeSrcRange helper Eliminate ~80 lines of duplicated code: - Replace inline Atkinson dithering with Atkinson1BitDitherer class (same class used by EPUB thumbnail generation) - Extract computeSrcRange() helper for downscale coordinate calc (was duplicated 4 times across XTCH/XTC paths) --- lib/Xtc/Xtc.cpp | 137 ++++++++++-------------------------------------- 1 file changed, 27 insertions(+), 110 deletions(-) diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 4f6261ea4c..5593b25fda 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -11,6 +11,16 @@ #include #include +static inline void computeSrcRange(uint32_t dstCoord, uint32_t scaleInv, uint32_t maxSrc, + uint32_t& srcStart, uint32_t& srcEnd) { + srcStart = (static_cast(dstCoord) * scaleInv) >> 16; + srcEnd = (static_cast(dstCoord + 1) * scaleInv) >> 16; + if (srcStart >= maxSrc) srcStart = maxSrc - 1; + if (srcEnd > maxSrc) srcEnd = maxSrc; + if (srcEnd <= srcStart) srcEnd = srcStart + 1; + if (srcEnd > maxSrc) srcEnd = maxSrc; +} + bool Xtc::load() { LOG_DBG("XTC", "Loading XTC: %s", filepath.c_str()); @@ -453,29 +463,12 @@ bool Xtc::generateThumbBmp(int height) const { goto fallback_2bit_thumb; } - int16_t* errRow0 = static_cast(malloc((thumbWidth + 4) * sizeof(int16_t))); - int16_t* errRow1 = static_cast(malloc((thumbWidth + 4) * sizeof(int16_t))); - int16_t* errRow2 = static_cast(malloc((thumbWidth + 4) * sizeof(int16_t))); - if (!errRow0 || !errRow1 || !errRow2) { - LOG_ERR("XTC", "Failed to alloc dither buffers, falling back to 2-bit BMP"); - free(plane1Buf); - free(plane2Buf); - free(errRow0); - free(errRow1); - free(errRow2); - goto fallback_2bit_thumb; - } - memset(errRow0, 0, (thumbWidth + 4) * sizeof(int16_t)); - memset(errRow1, 0, (thumbWidth + 4) * sizeof(int16_t)); - memset(errRow2, 0, (thumbWidth + 4) * sizeof(int16_t)); + Atkinson1BitDitherer ditherer(thumbWidth); FsFile thumbBmp; if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { free(plane1Buf); free(plane2Buf); - free(errRow0); - free(errRow1); - free(errRow2); return false; } @@ -488,28 +481,17 @@ bool Xtc::generateThumbBmp(int height) const { if (!rowBuf) { free(plane1Buf); free(plane2Buf); - free(errRow0); - free(errRow1); - free(errRow2); thumbBmp.close(); return false; } for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { memset(rowBuf, 0xFF, rowSize); - uint32_t srcYS = (static_cast(dstY) * scaleInv_fp2) >> 16; - uint32_t srcYE = (static_cast(dstY + 1) * scaleInv_fp2) >> 16; - if (srcYS >= pageInfo.height) srcYS = pageInfo.height - 1; - if (srcYE > pageInfo.height) srcYE = pageInfo.height; - if (srcYE <= srcYS) srcYE = srcYS + 1; - if (srcYE > pageInfo.height) srcYE = pageInfo.height; + uint32_t srcYS, srcYE; + computeSrcRange(dstY, scaleInv_fp2, pageInfo.height, srcYS, srcYE); for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { - uint32_t srcXS = (static_cast(dstX) * scaleInv_fp2) >> 16; - uint32_t srcXE = (static_cast(dstX + 1) * scaleInv_fp2) >> 16; - if (srcXS >= pageInfo.width) srcXS = pageInfo.width - 1; - if (srcXE > pageInfo.width) srcXE = pageInfo.width; - if (srcXE <= srcXS) srcXE = srcXS + 1; - if (srcXE > pageInfo.width) srcXE = pageInfo.width; + uint32_t srcXS, srcXE; + computeSrcRange(dstX, scaleInv_fp2, pageInfo.width, srcXS, srcXE); int lumSum = 0, total = 0; for (uint32_t sy = srcYS; sy < srcYE; sy++) for (uint32_t sx = srcXS; sx < srcXE; sx++) { @@ -522,41 +504,19 @@ bool Xtc::generateThumbBmp(int height) const { } } const int avgLum = (total > 0) ? (lumSum * 255 / total) / 255 : 255; - int adjusted = avgLum; - adjusted = ((adjusted - 128) * 120) / 100 + 128; - if (adjusted < 0) adjusted = 0; - if (adjusted > 255) adjusted = 255; - adjusted += errRow0[dstX + 2]; - if (adjusted < 0) adjusted = 0; - if (adjusted > 255) adjusted = 255; - const bool dark = adjusted < 128; - const int quantizedValue = dark ? 0 : 255; - const int error = (adjusted - quantizedValue) >> 3; - errRow0[dstX + 3] += error; - errRow0[dstX + 4] += error; - errRow1[dstX + 1] += error; - errRow1[dstX + 2] += error; - errRow1[dstX + 3] += error; - errRow2[dstX + 2] += error; - if (dark) { + const uint8_t bit = ditherer.processPixel(avgLum, dstX); + if (!bit) { const size_t bi = dstX / 8; if (bi < rowSize) rowBuf[bi] &= ~(1 << (7 - (dstX % 8))); } } thumbBmp.write(rowBuf, rowSize); - int16_t* tmp = errRow0; - errRow0 = errRow1; - errRow1 = errRow2; - errRow2 = tmp; - memset(errRow2, 0, (thumbWidth + 4) * sizeof(int16_t)); + ditherer.nextRow(); } free(rowBuf); free(plane1Buf); free(plane2Buf); - free(errRow0); - free(errRow1); - free(errRow2); thumbBmp.close(); LOG_DBG("XTC", "Generated 1-bit thumb BMP with dithering (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str()); @@ -739,40 +699,18 @@ bool Xtc::generateThumbBmp(int height) const { return false; } - int16_t* errRow0 = static_cast(malloc((thumbWidth + 4) * sizeof(int16_t))); - int16_t* errRow1 = static_cast(malloc((thumbWidth + 4) * sizeof(int16_t))); - int16_t* errRow2 = static_cast(malloc((thumbWidth + 4) * sizeof(int16_t))); - if (!errRow0 || !errRow1 || !errRow2) { - free(pageBuffer); - free(rowBuffer); - free(errRow0); - free(errRow1); - free(errRow2); - return false; - } - memset(errRow0, 0, (thumbWidth + 4) * sizeof(int16_t)); - memset(errRow1, 0, (thumbWidth + 4) * sizeof(int16_t)); - memset(errRow2, 0, (thumbWidth + 4) * sizeof(int16_t)); - + Atkinson1BitDitherer ditherer(thumbWidth); const uint32_t scaleInv_fp = static_cast(65536.0f / scale); const size_t srcRowBytes = (pageInfo.width + 7) / 8; for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { memset(rowBuffer, 0xFF, rowSize); - uint32_t srcYStart = (static_cast(dstY) * scaleInv_fp) >> 16; - uint32_t srcYEnd = (static_cast(dstY + 1) * scaleInv_fp) >> 16; - if (srcYStart >= pageInfo.height) srcYStart = pageInfo.height - 1; - if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; - if (srcYEnd <= srcYStart) srcYEnd = srcYStart + 1; - if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; + uint32_t srcYStart, srcYEnd; + computeSrcRange(dstY, scaleInv_fp, pageInfo.height, srcYStart, srcYEnd); for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { - uint32_t srcXStart = (static_cast(dstX) * scaleInv_fp) >> 16; - uint32_t srcXEnd = (static_cast(dstX + 1) * scaleInv_fp) >> 16; - if (srcXStart >= pageInfo.width) srcXStart = pageInfo.width - 1; - if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; - if (srcXEnd <= srcXStart) srcXEnd = srcXStart + 1; - if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; + uint32_t srcXStart, srcXEnd; + computeSrcRange(dstX, scaleInv_fp, pageInfo.width, srcXStart, srcXEnd); uint32_t graySum = 0, totalCount = 0; for (uint32_t srcY = srcYStart; srcY < srcYEnd && srcY < pageInfo.height; srcY++) { @@ -785,40 +723,19 @@ bool Xtc::generateThumbBmp(int height) const { } } - int adjusted = (totalCount > 0) ? static_cast(graySum * 255 / totalCount) / 255 : 255; - adjusted = ((adjusted - 128) * 120) / 100 + 128; - if (adjusted < 0) adjusted = 0; - if (adjusted > 255) adjusted = 255; - adjusted += errRow0[dstX + 2]; - if (adjusted < 0) adjusted = 0; - if (adjusted > 255) adjusted = 255; - const bool dark = adjusted < 128; - const int quantizedValue = dark ? 0 : 255; - const int error = (adjusted - quantizedValue) >> 3; - errRow0[dstX + 3] += error; - errRow0[dstX + 4] += error; - errRow1[dstX + 1] += error; - errRow1[dstX + 2] += error; - errRow1[dstX + 3] += error; - errRow2[dstX + 2] += error; - if (dark) { + const int avgGray = (totalCount > 0) ? static_cast(graySum * 255 / totalCount) / 255 : 255; + const uint8_t bit = ditherer.processPixel(avgGray, dstX); + if (!bit) { const size_t bi = dstX / 8; if (bi < rowSize) rowBuffer[bi] &= ~(1 << (7 - (dstX % 8))); } } thumbBmp.write(rowBuffer, rowSize); - int16_t* tmp = errRow0; - errRow0 = errRow1; - errRow1 = errRow2; - errRow2 = tmp; - memset(errRow2, 0, (thumbWidth + 4) * sizeof(int16_t)); + ditherer.nextRow(); } free(rowBuffer); free(pageBuffer); - free(errRow0); - free(errRow1); - free(errRow2); LOG_DBG("XTC", "Generated 1-bit thumb BMP with Atkinson dithering (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str()); return true; From 50bb37ea6b2653fb3357d266cb4504d60d740ae1 Mon Sep 17 00:00:00 2001 From: pablohc Date: Thu, 23 Apr 2026 22:21:58 +0200 Subject: [PATCH 16/57] style: apply clang-format to Xtc.cpp --- lib/Xtc/Xtc.cpp | 148 ++++++++++++++++++++++++------------------------ 1 file changed, 74 insertions(+), 74 deletions(-) diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 5593b25fda..e1cda14cda 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -11,8 +11,8 @@ #include #include -static inline void computeSrcRange(uint32_t dstCoord, uint32_t scaleInv, uint32_t maxSrc, - uint32_t& srcStart, uint32_t& srcEnd) { +static inline void computeSrcRange(uint32_t dstCoord, uint32_t scaleInv, uint32_t maxSrc, uint32_t& srcStart, + uint32_t& srcEnd) { srcStart = (static_cast(dstCoord) * scaleInv) >> 16; srcEnd = (static_cast(dstCoord + 1) * scaleInv) >> 16; if (srcStart >= maxSrc) srcStart = maxSrc - 1; @@ -438,89 +438,89 @@ bool Xtc::generateThumbBmp(int height) const { const uint32_t scaleInv_fp2 = static_cast(65536.0f / scale); { - uint8_t* plane1Buf = static_cast(malloc(planeSize)); - if (!plane1Buf) { - LOG_ERR("XTC", "Failed to alloc plane1 for thumb dither (%lu bytes)", static_cast(planeSize)); - return false; - } - if (const_cast(parser.get())->loadPageMsb(0, plane1Buf, planeSize) == 0) { - LOG_ERR("XTC", "Failed to load plane1 for thumb"); - free(plane1Buf); - return false; - } + uint8_t* plane1Buf = static_cast(malloc(planeSize)); + if (!plane1Buf) { + LOG_ERR("XTC", "Failed to alloc plane1 for thumb dither (%lu bytes)", static_cast(planeSize)); + return false; + } + if (const_cast(parser.get())->loadPageMsb(0, plane1Buf, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane1 for thumb"); + free(plane1Buf); + return false; + } - uint8_t* plane2Buf = static_cast(malloc(planeSize)); - if (!plane2Buf) { - LOG_ERR("XTC", "Failed to alloc plane2 for thumb dither (%lu bytes), falling back to 2-bit BMP", - static_cast(planeSize)); - free(plane1Buf); - goto fallback_2bit_thumb; - } - if (const_cast(parser.get())->loadPageLsb(0, plane2Buf, planeSize) == 0) { - LOG_ERR("XTC", "Failed to load plane2 for thumb, falling back to 2-bit BMP"); - free(plane1Buf); - free(plane2Buf); - goto fallback_2bit_thumb; - } + uint8_t* plane2Buf = static_cast(malloc(planeSize)); + if (!plane2Buf) { + LOG_ERR("XTC", "Failed to alloc plane2 for thumb dither (%lu bytes), falling back to 2-bit BMP", + static_cast(planeSize)); + free(plane1Buf); + goto fallback_2bit_thumb; + } + if (const_cast(parser.get())->loadPageLsb(0, plane2Buf, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane2 for thumb, falling back to 2-bit BMP"); + free(plane1Buf); + free(plane2Buf); + goto fallback_2bit_thumb; + } - Atkinson1BitDitherer ditherer(thumbWidth); + Atkinson1BitDitherer ditherer(thumbWidth); - FsFile thumbBmp; - if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { - free(plane1Buf); - free(plane2Buf); - return false; - } + FsFile thumbBmp; + if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { + free(plane1Buf); + free(plane2Buf); + return false; + } - BmpHeader bmpHeader; - createBmpHeader(&bmpHeader, thumbWidth, thumbHeight, BmpRowOrder::TopDown); - thumbBmp.write(reinterpret_cast(&bmpHeader), sizeof(bmpHeader)); + BmpHeader bmpHeader; + createBmpHeader(&bmpHeader, thumbWidth, thumbHeight, BmpRowOrder::TopDown); + thumbBmp.write(reinterpret_cast(&bmpHeader), sizeof(bmpHeader)); - const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; - uint8_t* rowBuf = static_cast(malloc(rowSize)); - if (!rowBuf) { - free(plane1Buf); - free(plane2Buf); - thumbBmp.close(); - return false; - } + const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; + uint8_t* rowBuf = static_cast(malloc(rowSize)); + if (!rowBuf) { + free(plane1Buf); + free(plane2Buf); + thumbBmp.close(); + return false; + } - for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { - memset(rowBuf, 0xFF, rowSize); - uint32_t srcYS, srcYE; - computeSrcRange(dstY, scaleInv_fp2, pageInfo.height, srcYS, srcYE); - for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { - uint32_t srcXS, srcXE; - computeSrcRange(dstX, scaleInv_fp2, pageInfo.width, srcXS, srcXE); - int lumSum = 0, total = 0; - for (uint32_t sy = srcYS; sy < srcYE; sy++) - for (uint32_t sx = srcXS; sx < srcXE; sx++) { - const size_t bo = (pageInfo.width - 1 - sx) * colBytes + sy / 8; - if (bo < planeSize) { - const uint8_t b1 = (plane1Buf[bo] >> (7 - (sy % 8))) & 1; - const uint8_t b2 = (plane2Buf[bo] >> (7 - (sy % 8))) & 1; - lumSum += (1 - b1) * 85 + (1 - b2) * 170; - total++; + for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { + memset(rowBuf, 0xFF, rowSize); + uint32_t srcYS, srcYE; + computeSrcRange(dstY, scaleInv_fp2, pageInfo.height, srcYS, srcYE); + for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { + uint32_t srcXS, srcXE; + computeSrcRange(dstX, scaleInv_fp2, pageInfo.width, srcXS, srcXE); + int lumSum = 0, total = 0; + for (uint32_t sy = srcYS; sy < srcYE; sy++) + for (uint32_t sx = srcXS; sx < srcXE; sx++) { + const size_t bo = (pageInfo.width - 1 - sx) * colBytes + sy / 8; + if (bo < planeSize) { + const uint8_t b1 = (plane1Buf[bo] >> (7 - (sy % 8))) & 1; + const uint8_t b2 = (plane2Buf[bo] >> (7 - (sy % 8))) & 1; + lumSum += (1 - b1) * 85 + (1 - b2) * 170; + total++; + } } + const int avgLum = (total > 0) ? (lumSum * 255 / total) / 255 : 255; + const uint8_t bit = ditherer.processPixel(avgLum, dstX); + if (!bit) { + const size_t bi = dstX / 8; + if (bi < rowSize) rowBuf[bi] &= ~(1 << (7 - (dstX % 8))); } - const int avgLum = (total > 0) ? (lumSum * 255 / total) / 255 : 255; - const uint8_t bit = ditherer.processPixel(avgLum, dstX); - if (!bit) { - const size_t bi = dstX / 8; - if (bi < rowSize) rowBuf[bi] &= ~(1 << (7 - (dstX % 8))); } + thumbBmp.write(rowBuf, rowSize); + ditherer.nextRow(); } - thumbBmp.write(rowBuf, rowSize); - ditherer.nextRow(); - } - free(rowBuf); - free(plane1Buf); - free(plane2Buf); - thumbBmp.close(); - LOG_DBG("XTC", "Generated 1-bit thumb BMP with dithering (%dx%d): %s", thumbWidth, thumbHeight, - getThumbBmpPath(height).c_str()); - return true; + free(rowBuf); + free(plane1Buf); + free(plane2Buf); + thumbBmp.close(); + LOG_DBG("XTC", "Generated 1-bit thumb BMP with dithering (%dx%d): %s", thumbWidth, thumbHeight, + getThumbBmpPath(height).c_str()); + return true; } fallback_2bit_thumb: From 9f3bb618322ade741924cedf09f5f7dd3e753574 Mon Sep 17 00:00:00 2001 From: pablohc Date: Fri, 24 Apr 2026 00:57:59 +0200 Subject: [PATCH 17/57] fix: resolve leftover conflict markers in SleepActivity.cpp --- src/activities/boot_sleep/SleepActivity.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index f034ff33a1..a441a7a2df 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -404,7 +404,6 @@ void SleepActivity::renderCoverSleepScreen() const { return (this->*renderNoCoverSleepScreen)(); } -<<<<<<< HEAD if (lastXtc.getBitDepth() == 2) { const size_t planeSize = (static_cast(lastXtc.getPageWidth()) * lastXtc.getPageHeight() + 7) / 8; uint8_t* plane1 = static_cast(malloc(planeSize)); @@ -465,7 +464,6 @@ void SleepActivity::renderCoverSleepScreen() const { free(pageBuffer); return; } - } if (!lastXtc.generateCoverBmp()) { LOG_ERR("SLP", "Failed to generate XTC cover bmp"); From b8fa515bcc97eae4c87ba48be2d2b25537695538 Mon Sep 17 00:00:00 2001 From: pablohc Date: Fri, 24 Apr 2026 11:40:59 +0200 Subject: [PATCH 18/57] fix: unify XTCH 2-bit thumb generation to sequential dithered 1-bit BMP After reader sessions, heap fragmentation reduces MaxAlloc to ~65KB, preventing the primary path from allocating both planes simultaneously (96KB). The fallback produced a raw 2-bit BMP with no dithering, resulting in poor visual quality on the Home Screen. Unify to a single sequential path: load plane1 (48KB) -> majority-vote -> free, load plane2 (48KB) -> combine -> Atkinson dithering -> 1-bit BMP. Peak memory ~52KB, always within MaxAlloc constraints. This matches the same approach used by XTC 1-bit thumbnails, ensuring consistent quality across all themes and memory states. --- lib/Xtc/Xtc.cpp | 175 ++++++++++-------------------------------------- 1 file changed, 37 insertions(+), 138 deletions(-) diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index e1cda14cda..5c27cf28f7 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -430,113 +430,28 @@ bool Xtc::generateThumbBmp(int height) const { LOG_DBG("XTC", "Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)", pageInfo.width, pageInfo.height, thumbWidth, thumbHeight, scale); - // For 2-bit (XTCH): two-pass plane loading → 2-bit BMP output with 4-level grayscale palette. - // Full page (96KB) exceeds MaxAlloc; load each plane separately (~48KB). + // For 2-bit (XTCH): sequential plane loading → Atkinson dithered 1-bit BMP. + // Loads each plane separately (~48KB) to stay within memory constraints after reader sessions. if (bitDepth == 2) { const size_t planeSize = (static_cast(pageInfo.width) * pageInfo.height + 7) / 8; const size_t colBytes = (pageInfo.height + 7) / 8; const uint32_t scaleInv_fp2 = static_cast(65536.0f / scale); - { - uint8_t* plane1Buf = static_cast(malloc(planeSize)); - if (!plane1Buf) { - LOG_ERR("XTC", "Failed to alloc plane1 for thumb dither (%lu bytes)", static_cast(planeSize)); - return false; - } - if (const_cast(parser.get())->loadPageMsb(0, plane1Buf, planeSize) == 0) { - LOG_ERR("XTC", "Failed to load plane1 for thumb"); - free(plane1Buf); - return false; - } - - uint8_t* plane2Buf = static_cast(malloc(planeSize)); - if (!plane2Buf) { - LOG_ERR("XTC", "Failed to alloc plane2 for thumb dither (%lu bytes), falling back to 2-bit BMP", - static_cast(planeSize)); - free(plane1Buf); - goto fallback_2bit_thumb; - } - if (const_cast(parser.get())->loadPageLsb(0, plane2Buf, planeSize) == 0) { - LOG_ERR("XTC", "Failed to load plane2 for thumb, falling back to 2-bit BMP"); - free(plane1Buf); - free(plane2Buf); - goto fallback_2bit_thumb; - } - - Atkinson1BitDitherer ditherer(thumbWidth); - - FsFile thumbBmp; - if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { - free(plane1Buf); - free(plane2Buf); - return false; - } - - BmpHeader bmpHeader; - createBmpHeader(&bmpHeader, thumbWidth, thumbHeight, BmpRowOrder::TopDown); - thumbBmp.write(reinterpret_cast(&bmpHeader), sizeof(bmpHeader)); - - const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; - uint8_t* rowBuf = static_cast(malloc(rowSize)); - if (!rowBuf) { - free(plane1Buf); - free(plane2Buf); - thumbBmp.close(); - return false; - } - - for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { - memset(rowBuf, 0xFF, rowSize); - uint32_t srcYS, srcYE; - computeSrcRange(dstY, scaleInv_fp2, pageInfo.height, srcYS, srcYE); - for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { - uint32_t srcXS, srcXE; - computeSrcRange(dstX, scaleInv_fp2, pageInfo.width, srcXS, srcXE); - int lumSum = 0, total = 0; - for (uint32_t sy = srcYS; sy < srcYE; sy++) - for (uint32_t sx = srcXS; sx < srcXE; sx++) { - const size_t bo = (pageInfo.width - 1 - sx) * colBytes + sy / 8; - if (bo < planeSize) { - const uint8_t b1 = (plane1Buf[bo] >> (7 - (sy % 8))) & 1; - const uint8_t b2 = (plane2Buf[bo] >> (7 - (sy % 8))) & 1; - lumSum += (1 - b1) * 85 + (1 - b2) * 170; - total++; - } - } - const int avgLum = (total > 0) ? (lumSum * 255 / total) / 255 : 255; - const uint8_t bit = ditherer.processPixel(avgLum, dstX); - if (!bit) { - const size_t bi = dstX / 8; - if (bi < rowSize) rowBuf[bi] &= ~(1 << (7 - (dstX % 8))); - } - } - thumbBmp.write(rowBuf, rowSize); - ditherer.nextRow(); - } - - free(rowBuf); - free(plane1Buf); - free(plane2Buf); - thumbBmp.close(); - LOG_DBG("XTC", "Generated 1-bit thumb BMP with dithering (%dx%d): %s", thumbWidth, thumbHeight, - getThumbBmpPath(height).c_str()); - return true; - } - - fallback_2bit_thumb: const size_t plane1BitsSize = (static_cast(thumbWidth) * thumbHeight + 7) / 8; uint8_t* plane1Bits = static_cast(malloc(plane1BitsSize)); if (!plane1Bits) { - LOG_ERR("XTC", "Failed to alloc plane1bits (%lu bytes)", plane1BitsSize); + LOG_ERR("XTC", "Failed to alloc plane1bits (%lu bytes)", static_cast(plane1BitsSize)); return false; } memset(plane1Bits, 0, plane1BitsSize); + uint8_t* planeBuffer = static_cast(malloc(planeSize)); if (!planeBuffer) { - LOG_ERR("XTC", "Failed to alloc plane buffer (%lu bytes)", planeSize); + LOG_ERR("XTC", "Failed to alloc plane buffer (%lu bytes)", static_cast(planeSize)); free(plane1Bits); return false; } + // Pass 1: plane1 (bit1/MSB) majority vote per output pixel if (const_cast(parser.get())->loadPageMsb(0, planeBuffer, planeSize) == 0) { LOG_ERR("XTC", "Failed to load plane1 for thumb"); @@ -573,60 +488,39 @@ bool Xtc::generateThumbBmp(int height) const { } } } - // Pass 2: plane2 (bit2/LSB) + combine → write 2-bit BMP + + // Pass 2: plane2 (bit2/LSB) + combine → Atkinson dithering → 1-bit BMP if (const_cast(parser.get())->loadPageLsb(0, planeBuffer, planeSize) == 0) { LOG_ERR("XTC", "Failed to load plane2 for thumb"); free(planeBuffer); free(plane1Bits); return false; } - FsFile thumbBmp2; - if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp2)) { + + Atkinson1BitDitherer ditherer(thumbWidth); + + FsFile thumbBmp; + if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { free(planeBuffer); free(plane1Bits); return false; } - const uint32_t rowSize2 = ((static_cast(thumbWidth) * 2 + 31) / 32) * 4; - const uint32_t imageSize2 = rowSize2 * thumbHeight; - const uint32_t fileSize2 = 14 + 40 + 16 + imageSize2; - thumbBmp2.write('B'); - thumbBmp2.write('M'); - thumbBmp2.write(reinterpret_cast(&fileSize2), 4); - uint32_t rsv2 = 0; - thumbBmp2.write(reinterpret_cast(&rsv2), 4); - uint32_t doff2 = 14 + 40 + 16; - thumbBmp2.write(reinterpret_cast(&doff2), 4); - uint32_t dibSz2 = 40; - thumbBmp2.write(reinterpret_cast(&dibSz2), 4); - int32_t ww2 = thumbWidth; - thumbBmp2.write(reinterpret_cast(&ww2), 4); - int32_t hh2 = -static_cast(thumbHeight); - thumbBmp2.write(reinterpret_cast(&hh2), 4); - uint16_t pl2 = 1; - thumbBmp2.write(reinterpret_cast(&pl2), 2); - uint16_t bpp2 = 2; - thumbBmp2.write(reinterpret_cast(&bpp2), 2); - uint32_t cmp2 = 0, imgSz2 = imageSize2, ppm2 = 2835, cu2 = 4, ci2 = 4; - thumbBmp2.write(reinterpret_cast(&cmp2), 4); - thumbBmp2.write(reinterpret_cast(&imgSz2), 4); - thumbBmp2.write(reinterpret_cast(&ppm2), 4); - thumbBmp2.write(reinterpret_cast(&ppm2), 4); - thumbBmp2.write(reinterpret_cast(&cu2), 4); - thumbBmp2.write(reinterpret_cast(&ci2), 4); - // Palette: 0=white, 1=lightGrey(170), 2=darkGrey(85), 3=black — matches XTC pixel value - static constexpr uint8_t pal2[16] = {0xFF, 0xFF, 0xFF, 0x00, 0xAA, 0xAA, 0xAA, 0x00, - 0x55, 0x55, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00}; - thumbBmp2.write(pal2, 16); - uint8_t* rowBuf2 = static_cast(malloc(rowSize2)); - if (!rowBuf2) { + + BmpHeader bmpHeader; + createBmpHeader(&bmpHeader, thumbWidth, thumbHeight, BmpRowOrder::TopDown); + thumbBmp.write(reinterpret_cast(&bmpHeader), sizeof(bmpHeader)); + + const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; + uint8_t* rowBuf = static_cast(malloc(rowSize)); + if (!rowBuf) { free(planeBuffer); free(plane1Bits); - thumbBmp2.close(); - Storage.remove(getThumbBmpPath(height).c_str()); + thumbBmp.close(); return false; } + for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { - memset(rowBuf2, 0, rowSize2); + memset(rowBuf, 0xFF, rowSize); uint32_t srcYS = (static_cast(dstY) * scaleInv_fp2) >> 16; uint32_t srcYE = (static_cast(dstY + 1) * scaleInv_fp2) >> 16; if (srcYS >= pageInfo.height) srcYS = pageInfo.height - 1; @@ -652,18 +546,23 @@ bool Xtc::generateThumbBmp(int height) const { const size_t pi = static_cast(dstY) * thumbWidth + dstX; const uint8_t bit1 = (plane1Bits[pi / 8] >> (7 - (pi % 8))) & 1; const uint8_t bit2 = (total > 0 && darkCount * 2 >= total) ? 1 : 0; - const uint8_t twoBit = (bit1 << 1) | bit2; - const size_t bi2 = dstX / 4; - const int bs2 = 6 - static_cast(dstX % 4) * 2; - if (bi2 < rowSize2) rowBuf2[bi2] |= static_cast(twoBit << bs2); + const uint8_t lum = (1 - bit1) * 85 + (1 - bit2) * 170; + const uint8_t bit = ditherer.processPixel(lum, dstX); + if (!bit) { + const size_t bi = dstX / 8; + if (bi < rowSize) rowBuf[bi] &= ~(1 << (7 - (dstX % 8))); + } } - thumbBmp2.write(rowBuf2, rowSize2); + thumbBmp.write(rowBuf, rowSize); + ditherer.nextRow(); } - free(rowBuf2); + + free(rowBuf); free(planeBuffer); free(plane1Bits); - thumbBmp2.close(); - LOG_DBG("XTC", "Generated 2-bit thumb BMP (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str()); + thumbBmp.close(); + LOG_DBG("XTC", "Generated 1-bit thumb BMP with dithering (%dx%d): %s", thumbWidth, thumbHeight, + getThumbBmpPath(height).c_str()); return true; } From f9be6d91f94b68fd3b6d3d774ebc9cf299d43073 Mon Sep 17 00:00:00 2001 From: pablohc Date: Fri, 24 Apr 2026 13:24:45 +0200 Subject: [PATCH 19/57] fix: use area-averaged strip processing for XTCH 2-bit thumb generation --- lib/Xtc/Xtc.cpp | 183 +++++++++++++++++++++++++----------------------- 1 file changed, 95 insertions(+), 88 deletions(-) diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 5c27cf28f7..f17c3c8a5d 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -430,79 +430,35 @@ bool Xtc::generateThumbBmp(int height) const { LOG_DBG("XTC", "Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)", pageInfo.width, pageInfo.height, thumbWidth, thumbHeight, scale); - // For 2-bit (XTCH): sequential plane loading → Atkinson dithered 1-bit BMP. - // Loads each plane separately (~48KB) to stay within memory constraints after reader sessions. + // For 2-bit (XTCH): strip-based sequential plane loading → area-averaged Atkinson dithered 1-bit BMP. + // Loads each plane separately (~48KB) in strips to stay within memory constraints after reader sessions. + // Uses area-averaging (same formula as the 1-bit path) for accurate luminance computation. if (bitDepth == 2) { const size_t planeSize = (static_cast(pageInfo.width) * pageInfo.height + 7) / 8; const size_t colBytes = (pageInfo.height + 7) / 8; const uint32_t scaleInv_fp2 = static_cast(65536.0f / scale); - const size_t plane1BitsSize = (static_cast(thumbWidth) * thumbHeight + 7) / 8; - uint8_t* plane1Bits = static_cast(malloc(plane1BitsSize)); - if (!plane1Bits) { - LOG_ERR("XTC", "Failed to alloc plane1bits (%lu bytes)", static_cast(plane1BitsSize)); - return false; - } - memset(plane1Bits, 0, plane1BitsSize); - uint8_t* planeBuffer = static_cast(malloc(planeSize)); if (!planeBuffer) { LOG_ERR("XTC", "Failed to alloc plane buffer (%lu bytes)", static_cast(planeSize)); - free(plane1Bits); return false; } - // Pass 1: plane1 (bit1/MSB) majority vote per output pixel - if (const_cast(parser.get())->loadPageMsb(0, planeBuffer, planeSize) == 0) { - LOG_ERR("XTC", "Failed to load plane1 for thumb"); - free(planeBuffer); - free(plane1Bits); - return false; - } - for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { - uint32_t srcYS = (static_cast(dstY) * scaleInv_fp2) >> 16; - uint32_t srcYE = (static_cast(dstY + 1) * scaleInv_fp2) >> 16; - if (srcYS >= pageInfo.height) srcYS = pageInfo.height - 1; - if (srcYE > pageInfo.height) srcYE = pageInfo.height; - if (srcYE <= srcYS) srcYE = srcYS + 1; - if (srcYE > pageInfo.height) srcYE = pageInfo.height; - for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { - uint32_t srcXS = (static_cast(dstX) * scaleInv_fp2) >> 16; - uint32_t srcXE = (static_cast(dstX + 1) * scaleInv_fp2) >> 16; - if (srcXS >= pageInfo.width) srcXS = pageInfo.width - 1; - if (srcXE > pageInfo.width) srcXE = pageInfo.width; - if (srcXE <= srcXS) srcXE = srcXS + 1; - if (srcXE > pageInfo.width) srcXE = pageInfo.width; - uint32_t darkCount = 0, total = 0; - for (uint32_t sy = srcYS; sy < srcYE; sy++) - for (uint32_t sx = srcXS; sx < srcXE; sx++) { - const size_t bo = (pageInfo.width - 1 - sx) * colBytes + sy / 8; - if (bo < planeSize) { - if ((planeBuffer[bo] >> (7 - (sy % 8))) & 1) darkCount++; - total++; - } - } - if (total > 0 && darkCount * 2 >= total) { - const size_t pi = static_cast(dstY) * thumbWidth + dstX; - plane1Bits[pi / 8] |= static_cast(1u << (7 - (pi % 8))); - } - } - } + const size_t stripBudget = (static_cast(thumbWidth) * thumbHeight + 7) / 8; + const int stripRows = std::min(static_cast(thumbHeight), static_cast(stripBudget / thumbWidth)); - // Pass 2: plane2 (bit2/LSB) + combine → Atkinson dithering → 1-bit BMP - if (const_cast(parser.get())->loadPageLsb(0, planeBuffer, planeSize) == 0) { - LOG_ERR("XTC", "Failed to load plane2 for thumb"); + uint8_t* darkCount1Buf = static_cast(malloc(static_cast(stripRows) * thumbWidth)); + if (!darkCount1Buf) { + LOG_ERR("XTC", "Failed to alloc darkCount1 buffer (%lu bytes)", + static_cast(static_cast(stripRows) * thumbWidth)); free(planeBuffer); - free(plane1Bits); return false; } - Atkinson1BitDitherer ditherer(thumbWidth); - FsFile thumbBmp; if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { + free(darkCount1Buf); free(planeBuffer); - free(plane1Bits); return false; } @@ -513,53 +469,104 @@ bool Xtc::generateThumbBmp(int height) const { const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; uint8_t* rowBuf = static_cast(malloc(rowSize)); if (!rowBuf) { + free(darkCount1Buf); free(planeBuffer); - free(plane1Bits); thumbBmp.close(); return false; } - for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { - memset(rowBuf, 0xFF, rowSize); - uint32_t srcYS = (static_cast(dstY) * scaleInv_fp2) >> 16; - uint32_t srcYE = (static_cast(dstY + 1) * scaleInv_fp2) >> 16; - if (srcYS >= pageInfo.height) srcYS = pageInfo.height - 1; - if (srcYE > pageInfo.height) srcYE = pageInfo.height; - if (srcYE <= srcYS) srcYE = srcYS + 1; - if (srcYE > pageInfo.height) srcYE = pageInfo.height; - for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { - uint32_t srcXS = (static_cast(dstX) * scaleInv_fp2) >> 16; - uint32_t srcXE = (static_cast(dstX + 1) * scaleInv_fp2) >> 16; - if (srcXS >= pageInfo.width) srcXS = pageInfo.width - 1; - if (srcXE > pageInfo.width) srcXE = pageInfo.width; - if (srcXE <= srcXS) srcXE = srcXS + 1; - if (srcXE > pageInfo.width) srcXE = pageInfo.width; - uint32_t darkCount = 0, total = 0; - for (uint32_t sy = srcYS; sy < srcYE; sy++) - for (uint32_t sx = srcXS; sx < srcXE; sx++) { - const size_t bo = (pageInfo.width - 1 - sx) * colBytes + sy / 8; - if (bo < planeSize) { - if ((planeBuffer[bo] >> (7 - (sy % 8))) & 1) darkCount++; - total++; + Atkinson1BitDitherer ditherer(thumbWidth); + + for (int stripStart = 0; stripStart < static_cast(thumbHeight); stripStart += stripRows) { + const int curRows = std::min(stripRows, static_cast(thumbHeight) - stripStart); + + // Pass 1: plane1 (bit1/MSB) — count dark pixels per output pixel + if (const_cast(parser.get())->loadPageMsb(0, planeBuffer, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane1 for thumb"); + free(rowBuf); + free(darkCount1Buf); + free(planeBuffer); + thumbBmp.close(); + return false; + } + for (int dy = 0; dy < curRows; dy++) { + const uint16_t dstY = static_cast(stripStart + dy); + for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { + uint32_t srcYS = (static_cast(dstY) * scaleInv_fp2) >> 16; + uint32_t srcYE = (static_cast(dstY + 1) * scaleInv_fp2) >> 16; + if (srcYS >= pageInfo.height) srcYS = pageInfo.height - 1; + if (srcYE > pageInfo.height) srcYE = pageInfo.height; + if (srcYE <= srcYS) srcYE = srcYS + 1; + if (srcYE > pageInfo.height) srcYE = pageInfo.height; + uint32_t srcXS = (static_cast(dstX) * scaleInv_fp2) >> 16; + uint32_t srcXE = (static_cast(dstX + 1) * scaleInv_fp2) >> 16; + if (srcXS >= pageInfo.width) srcXS = pageInfo.width - 1; + if (srcXE > pageInfo.width) srcXE = pageInfo.width; + if (srcXE <= srcXS) srcXE = srcXS + 1; + if (srcXE > pageInfo.width) srcXE = pageInfo.width; + uint32_t darkCount = 0; + for (uint32_t sy = srcYS; sy < srcYE; sy++) + for (uint32_t sx = srcXS; sx < srcXE; sx++) { + const size_t bo = (pageInfo.width - 1 - sx) * colBytes + sy / 8; + if (bo < planeSize) { + if ((planeBuffer[bo] >> (7 - (sy % 8))) & 1) darkCount++; + } + } + darkCount1Buf[static_cast(dy) * thumbWidth + dstX] = static_cast(darkCount); + } + } + + // Pass 2: plane2 (bit2/LSB) — combine with darkCount1 → area-average → dither → write BMP rows + if (const_cast(parser.get())->loadPageLsb(0, planeBuffer, planeSize) == 0) { + LOG_ERR("XTC", "Failed to load plane2 for thumb"); + free(rowBuf); + free(darkCount1Buf); + free(planeBuffer); + thumbBmp.close(); + return false; + } + for (int dy = 0; dy < curRows; dy++) { + memset(rowBuf, 0xFF, rowSize); + const uint16_t dstY = static_cast(stripStart + dy); + for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { + uint32_t srcYS = (static_cast(dstY) * scaleInv_fp2) >> 16; + uint32_t srcYE = (static_cast(dstY + 1) * scaleInv_fp2) >> 16; + if (srcYS >= pageInfo.height) srcYS = pageInfo.height - 1; + if (srcYE > pageInfo.height) srcYE = pageInfo.height; + if (srcYE <= srcYS) srcYE = srcYS + 1; + if (srcYE > pageInfo.height) srcYE = pageInfo.height; + uint32_t srcXS = (static_cast(dstX) * scaleInv_fp2) >> 16; + uint32_t srcXE = (static_cast(dstX + 1) * scaleInv_fp2) >> 16; + if (srcXS >= pageInfo.width) srcXS = pageInfo.width - 1; + if (srcXE > pageInfo.width) srcXE = pageInfo.width; + if (srcXE <= srcXS) srcXE = srcXS + 1; + if (srcXE > pageInfo.width) srcXE = pageInfo.width; + uint32_t darkCount = 0, totalCount = 0; + for (uint32_t sy = srcYS; sy < srcYE; sy++) + for (uint32_t sx = srcXS; sx < srcXE; sx++) { + const size_t bo = (pageInfo.width - 1 - sx) * colBytes + sy / 8; + if (bo < planeSize) { + if ((planeBuffer[bo] >> (7 - (sy % 8))) & 1) darkCount++; + totalCount++; + } } + const uint32_t dark1 = darkCount1Buf[static_cast(dy) * thumbWidth + dstX]; + const int lumSum = static_cast((totalCount - dark1) * 85 + (totalCount - darkCount) * 170); + const int avgLum = (totalCount > 0) ? (lumSum * 255 / totalCount) / 255 : 255; + const uint8_t bit = ditherer.processPixel(avgLum, dstX); + if (!bit) { + const size_t bi = dstX / 8; + if (bi < rowSize) rowBuf[bi] &= ~(1 << (7 - (dstX % 8))); } - const size_t pi = static_cast(dstY) * thumbWidth + dstX; - const uint8_t bit1 = (plane1Bits[pi / 8] >> (7 - (pi % 8))) & 1; - const uint8_t bit2 = (total > 0 && darkCount * 2 >= total) ? 1 : 0; - const uint8_t lum = (1 - bit1) * 85 + (1 - bit2) * 170; - const uint8_t bit = ditherer.processPixel(lum, dstX); - if (!bit) { - const size_t bi = dstX / 8; - if (bi < rowSize) rowBuf[bi] &= ~(1 << (7 - (dstX % 8))); } + thumbBmp.write(rowBuf, rowSize); + ditherer.nextRow(); } - thumbBmp.write(rowBuf, rowSize); - ditherer.nextRow(); } free(rowBuf); + free(darkCount1Buf); free(planeBuffer); - free(plane1Bits); thumbBmp.close(); LOG_DBG("XTC", "Generated 1-bit thumb BMP with dithering (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str()); From 56f5e90b9cc6ed813bf06ebc80e511fd6c9142a2 Mon Sep 17 00:00:00 2001 From: pablohc Date: Fri, 24 Apr 2026 13:36:35 +0200 Subject: [PATCH 20/57] fix: validate I/O return values in 2-bit cover BMP generation --- lib/Xtc/Xtc.cpp | 77 +++++++++++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index f17c3c8a5d..36dc36d9d1 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -186,7 +186,13 @@ bool Xtc::generateCoverBmp() const { const uint8_t bit1 = (plane1[bo] >> (7 - (y % 8))) & 1; rowBuf[x / 4] |= static_cast((bit1 << 1) << (6 - (x % 4) * 2)); } - tempFile.write(rowBuf, rowSize2); + if (tempFile.write(rowBuf, rowSize2) != rowSize2) { + LOG_ERR("XTC", "Failed to write temp cover row %u", y); + tempFile.close(); + free(plane1); + Storage.remove(tempPath.c_str()); + return false; + } } tempFile.close(); free(plane1); @@ -221,44 +227,59 @@ bool Xtc::generateCoverBmp() const { // Write 2-bit BMP header const uint32_t imageSize2 = rowSize2 * pageInfo.height; const uint32_t fileSize2 = 14 + 40 + 16 + imageSize2; - coverFile.write('B'); - coverFile.write('M'); - coverFile.write(reinterpret_cast(&fileSize2), 4); - uint32_t rsv2 = 0; - coverFile.write(reinterpret_cast(&rsv2), 4); - uint32_t doff2 = 14 + 40 + 16; - coverFile.write(reinterpret_cast(&doff2), 4); - uint32_t dibSz2 = 40; - coverFile.write(reinterpret_cast(&dibSz2), 4); + static constexpr uint8_t bmpHeader2[70] = { + 'B', 'M', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 4, 0, 0, 0, 4, 0, 0, 0, + 0xFF, 0xFF, 0xFF, 0x00, 0xAA, 0xAA, 0xAA, 0x00, + 0x55, 0x55, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00}; + uint8_t hdr[70]; + memcpy(hdr, bmpHeader2, 70); + memcpy(hdr + 2, &fileSize2, 4); + const uint32_t doff2 = 14 + 40 + 16; + memcpy(hdr + 10, &doff2, 4); int32_t ww2 = pageInfo.width; - coverFile.write(reinterpret_cast(&ww2), 4); + memcpy(hdr + 18, &ww2, 4); int32_t hh2 = -static_cast(pageInfo.height); - coverFile.write(reinterpret_cast(&hh2), 4); - uint16_t pl2 = 1; - coverFile.write(reinterpret_cast(&pl2), 2); - uint16_t bpp2 = 2; - coverFile.write(reinterpret_cast(&bpp2), 2); - uint32_t cmp2 = 0, ppm2 = 2835, cu2 = 4, ci2 = 4; - coverFile.write(reinterpret_cast(&cmp2), 4); - coverFile.write(reinterpret_cast(&imageSize2), 4); - coverFile.write(reinterpret_cast(&ppm2), 4); - coverFile.write(reinterpret_cast(&ppm2), 4); - coverFile.write(reinterpret_cast(&cu2), 4); - coverFile.write(reinterpret_cast(&ci2), 4); - static constexpr uint8_t pal2[16] = {0xFF, 0xFF, 0xFF, 0x00, 0xAA, 0xAA, 0xAA, 0x00, - 0x55, 0x55, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00}; - coverFile.write(pal2, 16); + memcpy(hdr + 22, &hh2, 4); + memcpy(hdr + 34, &imageSize2, 4); + const uint32_t ppm2 = 2835; + memcpy(hdr + 38, &ppm2, 4); + memcpy(hdr + 42, &ppm2, 4); + if (coverFile.write(hdr, 70) != 70) { + LOG_ERR("XTC", "Failed to write 2-bit BMP header"); + coverFile.close(); + tempFile.close(); + free(plane2); + Storage.remove(tempPath.c_str()); + return false; + } // For each row: read pass1 row (bit1 only), OR in bit2, write to final uint8_t rowBuf[256]; for (uint16_t y = 0; y < pageInfo.height; y++) { memset(rowBuf, 0, rowSize2); - tempFile.read(rowBuf, rowSize2); + if (tempFile.read(rowBuf, rowSize2) != rowSize2) { + LOG_ERR("XTC", "Failed to read temp cover row %u", y); + coverFile.close(); + tempFile.close(); + free(plane2); + Storage.remove(tempPath.c_str()); + return false; + } for (uint16_t x = 0; x < pageInfo.width; x++) { const size_t bo = (pageInfo.width - 1 - x) * colBytes + y / 8; const uint8_t bit2 = (plane2[bo] >> (7 - (y % 8))) & 1; rowBuf[x / 4] |= static_cast(bit2 << (6 - (x % 4) * 2)); } - coverFile.write(rowBuf, rowSize2); + if (coverFile.write(rowBuf, rowSize2) != rowSize2) { + LOG_ERR("XTC", "Failed to write cover row %u", y); + coverFile.close(); + tempFile.close(); + free(plane2); + Storage.remove(tempPath.c_str()); + return false; + } } coverFile.close(); tempFile.close(); From 7fa2b3d741e8196ee95b8b50cc4c2e1180a77365 Mon Sep 17 00:00:00 2001 From: pablohc Date: Fri, 24 Apr 2026 13:36:54 +0200 Subject: [PATCH 21/57] fix: reject oversized PXC files to prevent out-of-bounds write --- lib/Xtc/Xtc.cpp | 10 ++++------ src/activities/util/PxcViewerActivity.cpp | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 36dc36d9d1..b6196854f5 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -228,12 +228,10 @@ bool Xtc::generateCoverBmp() const { const uint32_t imageSize2 = rowSize2 * pageInfo.height; const uint32_t fileSize2 = 14 + 40 + 16 + imageSize2; static constexpr uint8_t bmpHeader2[70] = { - 'B', 'M', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 4, 0, 0, 0, 4, 0, 0, 0, - 0xFF, 0xFF, 0xFF, 0x00, 0xAA, 0xAA, 0xAA, 0x00, - 0x55, 0x55, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00}; + 'B', 'M', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 40, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 2, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 4, 0, 0, + 0xFF, 0xFF, 0xFF, 0x00, 0xAA, 0xAA, 0xAA, 0x00, 0x55, 0x55, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t hdr[70]; memcpy(hdr, bmpHeader2, 70); memcpy(hdr + 2, &fileSize2, 4); diff --git a/src/activities/util/PxcViewerActivity.cpp b/src/activities/util/PxcViewerActivity.cpp index bd08e38ae9..45ef139efa 100644 --- a/src/activities/util/PxcViewerActivity.cpp +++ b/src/activities/util/PxcViewerActivity.cpp @@ -39,7 +39,7 @@ void PxcViewerActivity::onEnter() { return; } - if (abs(pxcWidth - screenWidth) > 1 || abs(pxcHeight - screenHeight) > 1) { + if (pxcWidth > screenWidth || pxcHeight > screenHeight) { LOG_ERR("PXC", "PXC size %dx%d does not match screen %dx%d", pxcWidth, pxcHeight, screenWidth, screenHeight); file.close(); renderer.clearScreen(); @@ -125,7 +125,7 @@ void PxcViewerActivity::renderGrayscaleImage() { const int screenWidth = renderer.getScreenWidth(); const int screenHeight = renderer.getScreenHeight(); - if (abs(pxcWidth - screenWidth) > 1 || abs(pxcHeight - screenHeight) > 1) { + if (pxcWidth > screenWidth || pxcHeight > screenHeight) { file.close(); return; } From 3c06bc0a955c955bc91ddc346598edacb6754ef4 Mon Sep 17 00:00:00 2001 From: pablohc Date: Fri, 24 Apr 2026 13:58:58 +0200 Subject: [PATCH 22/57] fix: correct BMP header array size from 70 to 74 bytes --- lib/Xtc/Xtc.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index b6196854f5..32e47b41d7 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -227,13 +227,13 @@ bool Xtc::generateCoverBmp() const { // Write 2-bit BMP header const uint32_t imageSize2 = rowSize2 * pageInfo.height; const uint32_t fileSize2 = 14 + 40 + 16 + imageSize2; - static constexpr uint8_t bmpHeader2[70] = { - 'B', 'M', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 40, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 2, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 4, 0, 0, + static constexpr uint8_t bmpHeader2[74] = { + 'B', 'M', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 40, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 2, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 4, 0, 0, 0xFF, 0xFF, 0xFF, 0x00, 0xAA, 0xAA, 0xAA, 0x00, 0x55, 0x55, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00}; - uint8_t hdr[70]; - memcpy(hdr, bmpHeader2, 70); + uint8_t hdr[74]; + memcpy(hdr, bmpHeader2, 74); memcpy(hdr + 2, &fileSize2, 4); const uint32_t doff2 = 14 + 40 + 16; memcpy(hdr + 10, &doff2, 4); @@ -245,7 +245,7 @@ bool Xtc::generateCoverBmp() const { const uint32_t ppm2 = 2835; memcpy(hdr + 38, &ppm2, 4); memcpy(hdr + 42, &ppm2, 4); - if (coverFile.write(hdr, 70) != 70) { + if (coverFile.write(hdr, 74) != 74) { LOG_ERR("XTC", "Failed to write 2-bit BMP header"); coverFile.close(); tempFile.close(); From c47074b7d683ff5f710c95972ea6974cd02cfd90 Mon Sep 17 00:00:00 2001 From: pablohc Date: Fri, 24 Apr 2026 15:56:55 +0200 Subject: [PATCH 23/57] fix: remove truncated thumbnail on failure in 2-bit thumb generation --- lib/Xtc/Xtc.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 32e47b41d7..b942807ed8 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -491,6 +491,7 @@ bool Xtc::generateThumbBmp(int height) const { free(darkCount1Buf); free(planeBuffer); thumbBmp.close(); + Storage.remove(getThumbBmpPath(height).c_str()); return false; } @@ -506,6 +507,7 @@ bool Xtc::generateThumbBmp(int height) const { free(darkCount1Buf); free(planeBuffer); thumbBmp.close(); + Storage.remove(getThumbBmpPath(height).c_str()); return false; } for (int dy = 0; dy < curRows; dy++) { @@ -542,6 +544,7 @@ bool Xtc::generateThumbBmp(int height) const { free(darkCount1Buf); free(planeBuffer); thumbBmp.close(); + Storage.remove(getThumbBmpPath(height).c_str()); return false; } for (int dy = 0; dy < curRows; dy++) { From 1233a12591a4c42d40efc188ff3736ca429584a0 Mon Sep 17 00:00:00 2001 From: pablohc Date: Fri, 24 Apr 2026 16:33:33 +0200 Subject: [PATCH 24/57] refactor: replace duplicated clamp-and-scale with computeSrcRange in 2-bit thumb path --- lib/Xtc/Xtc.cpp | 30 ++++++------------------------ 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index b942807ed8..1313bfe0b8 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -513,18 +513,9 @@ bool Xtc::generateThumbBmp(int height) const { for (int dy = 0; dy < curRows; dy++) { const uint16_t dstY = static_cast(stripStart + dy); for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { - uint32_t srcYS = (static_cast(dstY) * scaleInv_fp2) >> 16; - uint32_t srcYE = (static_cast(dstY + 1) * scaleInv_fp2) >> 16; - if (srcYS >= pageInfo.height) srcYS = pageInfo.height - 1; - if (srcYE > pageInfo.height) srcYE = pageInfo.height; - if (srcYE <= srcYS) srcYE = srcYS + 1; - if (srcYE > pageInfo.height) srcYE = pageInfo.height; - uint32_t srcXS = (static_cast(dstX) * scaleInv_fp2) >> 16; - uint32_t srcXE = (static_cast(dstX + 1) * scaleInv_fp2) >> 16; - if (srcXS >= pageInfo.width) srcXS = pageInfo.width - 1; - if (srcXE > pageInfo.width) srcXE = pageInfo.width; - if (srcXE <= srcXS) srcXE = srcXS + 1; - if (srcXE > pageInfo.width) srcXE = pageInfo.width; + uint32_t srcYS, srcYE, srcXS, srcXE; + computeSrcRange(dstY, scaleInv_fp2, pageInfo.height, srcYS, srcYE); + computeSrcRange(dstX, scaleInv_fp2, pageInfo.width, srcXS, srcXE); uint32_t darkCount = 0; for (uint32_t sy = srcYS; sy < srcYE; sy++) for (uint32_t sx = srcXS; sx < srcXE; sx++) { @@ -551,18 +542,9 @@ bool Xtc::generateThumbBmp(int height) const { memset(rowBuf, 0xFF, rowSize); const uint16_t dstY = static_cast(stripStart + dy); for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { - uint32_t srcYS = (static_cast(dstY) * scaleInv_fp2) >> 16; - uint32_t srcYE = (static_cast(dstY + 1) * scaleInv_fp2) >> 16; - if (srcYS >= pageInfo.height) srcYS = pageInfo.height - 1; - if (srcYE > pageInfo.height) srcYE = pageInfo.height; - if (srcYE <= srcYS) srcYE = srcYS + 1; - if (srcYE > pageInfo.height) srcYE = pageInfo.height; - uint32_t srcXS = (static_cast(dstX) * scaleInv_fp2) >> 16; - uint32_t srcXE = (static_cast(dstX + 1) * scaleInv_fp2) >> 16; - if (srcXS >= pageInfo.width) srcXS = pageInfo.width - 1; - if (srcXE > pageInfo.width) srcXE = pageInfo.width; - if (srcXE <= srcXS) srcXE = srcXS + 1; - if (srcXE > pageInfo.width) srcXE = pageInfo.width; + uint32_t srcYS, srcYE, srcXS, srcXE; + computeSrcRange(dstY, scaleInv_fp2, pageInfo.height, srcYS, srcYE); + computeSrcRange(dstX, scaleInv_fp2, pageInfo.width, srcXS, srcXE); uint32_t darkCount = 0, totalCount = 0; for (uint32_t sy = srcYS; sy < srcYE; sy++) for (uint32_t sx = srcXS; sx < srcXE; sx++) { From 16fec5ceed24b2606e27c668eff8301dbae35040 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Mon, 27 Apr 2026 03:14:37 -0400 Subject: [PATCH 25/57] Remove X3 device-specific grayscale mode handling Standardize all grayscale rendering to use FactoryQuality mode instead of conditionally using Differential mode for X3 devices. Also inverts the useFactoryGray logic in EpubReaderActivity to enable factory grayscale for X3 devices on image pages. --- lib/GfxRenderer/GfxRenderer.cpp | 3 +-- src/activities/boot_sleep/SleepActivity.cpp | 4 ++-- src/activities/reader/EpubReaderActivity.cpp | 6 ++---- src/activities/util/BmpViewerActivity.cpp | 4 ++-- src/activities/util/PxcViewerActivity.cpp | 4 ++-- 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 0e6cd684c0..094ffc0767 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1540,8 +1540,7 @@ void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2 screenshotHookCtx = nullptr; } - const bool isX3 = gpio.deviceIsX3(); - displayGrayBuffer(isX3 ? nullptr : lut_factory_quality, !isX3); + displayGrayBuffer(lut_factory_quality, true); setRenderMode(BW); } diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index a441a7a2df..b36d563dc7 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -222,7 +222,7 @@ void SleepActivity::renderPxcSleepScreen(const std::string& path) const { PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight}; renderer.renderGrayscaleSinglePass( - gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, + GfxRenderer::GrayscaleMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { const auto* c = static_cast(raw); c->file->seek(c->dataOffset); @@ -349,7 +349,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { }; BitmapGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, cropX, cropY}; renderer.renderGrayscaleSinglePass( - gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, + GfxRenderer::GrayscaleMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { const auto* c = static_cast(raw); r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, c->cropX, c->cropY); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 08b5780124..e48df9900e 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -763,7 +763,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or const auto tBwRender = millis(); const bool isImagePage = page->hasImages(); - const bool useFactoryGray = SETTINGS.textAntiAliasing && isImagePage && !gpio.deviceIsX3(); + const bool useFactoryGray = SETTINGS.textAntiAliasing && (isImagePage || gpio.deviceIsX3()); lastPageWasFactoryGray = useFactoryGray; if (useFactoryGray) { // Factory gray mode: skip BW display entirely — factory LUT drives pixels absolutely @@ -845,9 +845,7 @@ void EpubReaderActivity::onScreenshotRequest() { c->activity->renderStatusBar(); }; - renderer.renderGrayscale( - gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, grayFn, - &grayCtx); + renderer.renderGrayscale(GfxRenderer::GrayscaleMode::FactoryQuality, grayFn, &grayCtx); renderer.clearScreen(); renderer.cleanupGrayscaleWithFrameBuffer(); } diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index 75b1722228..89777dcb00 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -57,7 +57,7 @@ void BmpViewerActivity::onEnter() { }; BmpGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, labels}; renderer.renderGrayscaleSinglePass( - gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, + GfxRenderer::GrayscaleMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { const auto* c = static_cast(raw); r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, 0, 0); @@ -144,7 +144,7 @@ void BmpViewerActivity::renderGrayscaleImage() { BmpGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, labels}; renderer.renderGrayscaleSinglePass( - gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, + GfxRenderer::GrayscaleMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { const auto* c = static_cast(raw); r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, 0, 0); diff --git a/src/activities/util/PxcViewerActivity.cpp b/src/activities/util/PxcViewerActivity.cpp index 45ef139efa..d8eeb9e95b 100644 --- a/src/activities/util/PxcViewerActivity.cpp +++ b/src/activities/util/PxcViewerActivity.cpp @@ -62,7 +62,7 @@ void PxcViewerActivity::onEnter() { PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight, labels}; renderer.renderGrayscaleSinglePass( - gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, + GfxRenderer::GrayscaleMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { const auto* c = static_cast(raw); c->file->seek(c->dataOffset); @@ -141,7 +141,7 @@ void PxcViewerActivity::renderGrayscaleImage() { PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight, labels}; renderer.renderGrayscaleSinglePass( - gpio.deviceIsX3() ? GfxRenderer::GrayscaleMode::Differential : GfxRenderer::GrayscaleMode::FactoryQuality, + GfxRenderer::GrayscaleMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { const auto* c = static_cast(raw); c->file->seek(c->dataOffset); From f0a032f8dc4897ac3539ea274af3e58c227d5d73 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Mon, 27 Apr 2026 03:32:03 -0400 Subject: [PATCH 26/57] Disable factory gray mode for X3 devices Remove X3-specific factory gray rendering path and associated pre-flash logic. Factory gray mode now only applies to image pages when text anti-aliasing is enabled, regardless of device type. --- lib/GfxRenderer/GfxRenderer.cpp | 13 ------------- src/activities/reader/EpubReaderActivity.cpp | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 094ffc0767..05f595666d 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1359,15 +1359,6 @@ void GfxRenderer::renderGrayscale(GrayscaleMode mode, void (*renderFn)(const Gfx const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*), const void* preFlashCtx) { if (mode == GrayscaleMode::FactoryFast || mode == GrayscaleMode::FactoryQuality) { - // Pre-flash to white so the factory LUT can drive particles reliably from any prior state. - // Without this, particles stranded at intermediate grays may not complete their transition: - // from a known-white state only downward transitions are needed, which both LUTs handle cleanly. - // - // HALF_REFRESH (CTRL1_BYPASS_RED) guarantees true white regardless of RED RAM sync state. - // FAST_REFRESH is differential against RED RAM — after any prior grayscale operation the RED RAM - // may be stale (e.g. chapter menu rendered while display shows gray), so pixels the controller - // believes are already white may physically be at gray or chapter-menu positions and won't be - // driven to white, corrupting the subsequent gray render. clearScreen(); if (preFlashOverlayFn) preFlashOverlayFn(*this, preFlashCtx); displayBuffer(HalDisplay::HALF_REFRESH); @@ -1386,7 +1377,6 @@ void GfxRenderer::renderGrayscale(GrayscaleMode mode, void (*renderFn)(const Gfx setRenderMode(lsbMode); renderFn(*this, ctx); - // Save LSB plane for screenshot hook (needs both planes simultaneously). uint8_t* lsbCopy = nullptr; if (screenshotHook && factoryMode) { lsbCopy = static_cast(malloc(frameBufferSize)); @@ -1439,15 +1429,12 @@ void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn) g_differentialQuantize = (mode == GrayscaleMode::Differential); - // Allocate secondary buffer for the MSB plane. auto* secBuf = static_cast(malloc(frameBufferSize)); if (!secBuf) { LOG_ERR("GFX", "renderGrayscaleSinglePass: malloc failed (%lu bytes), falling back to two-pass", static_cast(frameBufferSize)); - // Disarm hook — the two-pass fallback does not capture both planes simultaneously. screenshotHook = nullptr; screenshotHookCtx = nullptr; - // Pre-flash already done; run two-pass directly without repeating it. clearScreen(0x00); setRenderMode(lsbMode); renderFn(*this, ctx); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index e48df9900e..852dc02c62 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -763,7 +763,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or const auto tBwRender = millis(); const bool isImagePage = page->hasImages(); - const bool useFactoryGray = SETTINGS.textAntiAliasing && (isImagePage || gpio.deviceIsX3()); + const bool useFactoryGray = SETTINGS.textAntiAliasing && isImagePage; lastPageWasFactoryGray = useFactoryGray; if (useFactoryGray) { // Factory gray mode: skip BW display entirely — factory LUT drives pixels absolutely From 763d06b32163bcffcad6be3d7d8367e6a9dc035d Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Mon, 27 Apr 2026 04:59:12 -0400 Subject: [PATCH 27/57] Remove outdated display mode comments Remove obsolete comments about factory gray mode and text-only antialiasing display handling that are no longer relevant to the current implementation. --- src/activities/reader/EpubReaderActivity.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 852dc02c62..0f85f1e6d4 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -766,11 +766,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or const bool useFactoryGray = SETTINGS.textAntiAliasing && isImagePage; lastPageWasFactoryGray = useFactoryGray; if (useFactoryGray) { - // Factory gray mode: skip BW display entirely — factory LUT drives pixels absolutely lastFactoryMarginTop = orientedMarginTop; lastFactoryMarginLeft = orientedMarginLeft; } else { - // Text-only AA or no AA: BW display with refresh cadence ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); } const auto tDisplay = millis(); From 8ac04d0ec31778eb845db9ab6fbd2f0de152f568 Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Mon, 27 Apr 2026 15:48:24 +0200 Subject: [PATCH 28/57] fix: gate 1-bit XTC sleep pre-flash on lastSleepFromReader The 1-bit XTC sleep path unconditionally cleared the screen and issued a HALF_REFRESH before calling displayXtcBwPage, which itself runs clearScreen + FAST_REFRESH. Match the 2-bit branch by skipping the pre-flash when the previous activity was the reader, avoiding the redundant refresh. Also dedupes a few render callbacks while in the area: - Extract the 'Entering sleep' overlay lambda into drawEnteringSleepOverlay. - Promote PageRenderCtx + grayFn to a private nested struct and static renderPageCallback shared by renderContents and onScreenshotRequest. - Consolidate PxcCtx and the rendering/overlay callbacks into file-scope statics behind a renderPxcToFramebuffer helper used by both onEnter and renderGrayscaleImage. --- src/activities/boot_sleep/SleepActivity.cpp | 51 +++--- src/activities/reader/EpubReaderActivity.cpp | 31 +--- src/activities/reader/EpubReaderActivity.h | 7 + src/activities/util/PxcViewerActivity.cpp | 163 +++++++------------ src/activities/util/PxcViewerActivity.h | 4 + 5 files changed, 98 insertions(+), 158 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index b36d563dc7..31287ab4c4 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -17,6 +17,21 @@ #include "fontIds.h" #include "images/Logo120.h" +namespace { +void drawEnteringSleepOverlay(const GfxRenderer& r, const void*) { + constexpr int margin = 15; + const char* msg = tr(STR_ENTERING_SLEEP); + const int y = static_cast(r.getScreenHeight() * 0.075f); + const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); + const int w = textWidth + margin * 2; + const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; + const int x = (r.getScreenWidth() - w) / 2; + r.fillRect(x - 2, y - 2, w + 4, h + 4, true); + r.fillRect(x, y, w, h, false); + r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); +} +} // namespace + void SleepActivity::onEnter() { Activity::onEnter(); GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); @@ -247,20 +262,7 @@ void SleepActivity::renderPxcSleepScreen(const std::string& path) const { } free(rowBuf); }, - &ctx, - [](const GfxRenderer& r, const void*) { - constexpr int margin = 15; - const char* msg = tr(STR_ENTERING_SLEEP); - const int y = static_cast(r.getScreenHeight() * 0.075f); - const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); - const int w = textWidth + margin * 2; - const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; - const int x = (r.getScreenWidth() - w) / 2; - r.fillRect(x - 2, y - 2, w + 4, h + 4, true); - r.fillRect(x, y, w, h, false); - r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); - }, - nullptr); + &ctx, &drawEnteringSleepOverlay, nullptr); } else { // BLACK_AND_WHITE / INVERTED_BLACK_AND_WHITE: threshold PXC to 1-bit // (pv 0=Black, 1=DarkGrey map to dark; 2=LightGrey, 3=White map to light) @@ -354,20 +356,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { const auto* c = static_cast(raw); r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, c->cropX, c->cropY); }, - &grayCtx, - [](const GfxRenderer& r, const void*) { - constexpr int margin = 15; - const char* msg = tr(STR_ENTERING_SLEEP); - const int y = static_cast(r.getScreenHeight() * 0.075f); - const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); - const int w = textWidth + margin * 2; - const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; - const int x = (r.getScreenWidth() - w) / 2; - r.fillRect(x - 2, y - 2, w + 4, h + 4, true); - r.fillRect(x, y, w, h, false); - r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); - }, - nullptr); + &grayCtx, &drawEnteringSleepOverlay, nullptr); } else { renderer.clearScreen(); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); @@ -458,8 +447,10 @@ void SleepActivity::renderCoverSleepScreen() const { return (this->*renderNoCoverSleepScreen)(); } LOG_DBG("SLP", "Direct XTC page render: %ux%u", lastXtc.getPageWidth(), lastXtc.getPageHeight()); - renderer.clearScreen(); - renderer.displayBuffer(HalDisplay::HALF_REFRESH); + if (!APP_STATE.lastSleepFromReader) { + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + } renderer.displayXtcBwPage(pageBuffer, lastXtc.getPageWidth(), lastXtc.getPageHeight()); free(pageBuffer); return; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 0f85f1e6d4..434761066b 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -45,6 +45,12 @@ int clampPercent(int percent) { } // namespace +void EpubReaderActivity::renderPageCallback(const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + c->page->render(const_cast(r), c->fontId, c->left, c->top); + c->activity->renderStatusBar(); +} + void EpubReaderActivity::onEnter() { Activity::onEnter(); @@ -779,22 +785,12 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or // grayscale rendering if (SETTINGS.textAntiAliasing) { - struct PageRenderCtx { - Page* page; - int fontId, left, top; - const EpubReaderActivity* activity; - }; PageRenderCtx grayCtx{page.get(), SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop, this}; - const auto grayFn = [](const GfxRenderer& r, const void* raw) { - const auto* c = static_cast(raw); - c->page->render(const_cast(r), c->fontId, c->left, c->top); - c->activity->renderStatusBar(); - }; const auto tGrayStart = millis(); const auto grayMode = useFactoryGray ? GfxRenderer::GrayscaleMode::FactoryQuality : GfxRenderer::GrayscaleMode::Differential; - renderer.renderGrayscale(grayMode, grayFn, &grayCtx); + renderer.renderGrayscale(grayMode, &renderPageCallback, &grayCtx); const auto tGrayEnd = millis(); fcm->logStats(useFactoryGray ? "gray_factory_quality" : "gray"); @@ -831,19 +827,8 @@ void EpubReaderActivity::onScreenshotRequest() { auto p = section->loadPageFromSectionFile(); if (!p) return; - struct PageRenderCtx { - Page* page; - int fontId, left, top; - const EpubReaderActivity* activity; - }; PageRenderCtx grayCtx{p.get(), SETTINGS.getReaderFontId(), lastFactoryMarginLeft, lastFactoryMarginTop, this}; - const auto grayFn = [](const GfxRenderer& r, const void* raw) { - const auto* c = static_cast(raw); - c->page->render(const_cast(r), c->fontId, c->left, c->top); - c->activity->renderStatusBar(); - }; - - renderer.renderGrayscale(GfxRenderer::GrayscaleMode::FactoryQuality, grayFn, &grayCtx); + renderer.renderGrayscale(GfxRenderer::GrayscaleMode::FactoryQuality, &renderPageCallback, &grayCtx); renderer.clearScreen(); renderer.cleanupGrayscaleWithFrameBuffer(); } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 59a999c4f7..72dac076a3 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -45,6 +45,13 @@ class EpubReaderActivity final : public Activity { SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {}; int footnoteDepth = 0; + struct PageRenderCtx { + Page* page; + int fontId, left, top; + const EpubReaderActivity* activity; + }; + static void renderPageCallback(const GfxRenderer& r, const void* raw); + void renderContents(std::unique_ptr page, int orientedMarginTop, int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft); void renderStatusBar() const; diff --git a/src/activities/util/PxcViewerActivity.cpp b/src/activities/util/PxcViewerActivity.cpp index d8eeb9e95b..ccec33f7d7 100644 --- a/src/activities/util/PxcViewerActivity.cpp +++ b/src/activities/util/PxcViewerActivity.cpp @@ -9,9 +9,65 @@ #include "components/UITheme.h" #include "fontIds.h" +namespace { +struct PxcCtx { + FsFile* file; + uint32_t dataOffset; + int width, height; + MappedInputManager::Labels labels; +}; + +void pxcRenderCallback(const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + c->file->seek(c->dataOffset); + + const int bytesPerRow = (c->width + 3) / 4; + uint8_t* rowBuf = static_cast(malloc(bytesPerRow)); + if (!rowBuf) { + LOG_ERR("PXC", "malloc failed for rowBuf (%d bytes, %dx%d)", bytesPerRow, c->width, c->height); + return; + } + + DirectPixelWriter pw; + pw.init(r); + + for (int row = 0; row < c->height; row++) { + if (c->file->read(rowBuf, bytesPerRow) != bytesPerRow) break; + pw.beginRow(row); + for (int col = 0; col < c->width; col++) { + const uint8_t pv = (rowBuf[col >> 2] >> (6 - (col & 3) * 2)) & 0x03; + pw.writePixel(pv); + } + } + free(rowBuf); + + GUI.drawButtonHints(const_cast(r), c->labels.btn1, c->labels.btn2, c->labels.btn3, c->labels.btn4); +} + +void pxcLoadingOverlay(const GfxRenderer& r, const void*) { + constexpr int margin = 15; + const char* msg = tr(STR_LOADING_POPUP); + const int y = static_cast(r.getScreenHeight() * 0.075f); + const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); + const int w = textWidth + margin * 2; + const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; + const int x = (r.getScreenWidth() - w) / 2; + r.fillRect(x - 2, y - 2, w + 4, h + 4, true); + r.fillRect(x, y, w, h, false); + r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); +} +} // namespace + PxcViewerActivity::PxcViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path) : Activity("PxcViewer", renderer, mappedInput), filePath(std::move(path)) {} +void PxcViewerActivity::renderPxcToFramebuffer(FsFile& file, uint16_t width, uint16_t height, uint32_t dataOffset) { + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + PxcCtx ctx{&file, dataOffset, width, height, labels}; + renderer.renderGrayscaleSinglePass(GfxRenderer::GrayscaleMode::FactoryQuality, &pxcRenderCallback, &ctx, + &pxcLoadingOverlay, nullptr); +} + void PxcViewerActivity::onEnter() { Activity::onEnter(); @@ -51,59 +107,7 @@ void PxcViewerActivity::onEnter() { } const uint32_t dataOffset = file.position(); - - struct PxcCtx { - FsFile* file; - uint32_t dataOffset; - int width, height; - MappedInputManager::Labels labels; - }; - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); - PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight, labels}; - - renderer.renderGrayscaleSinglePass( - GfxRenderer::GrayscaleMode::FactoryQuality, - [](const GfxRenderer& r, const void* raw) { - const auto* c = static_cast(raw); - c->file->seek(c->dataOffset); - - const int bytesPerRow = (c->width + 3) / 4; - uint8_t* rowBuf = static_cast(malloc(bytesPerRow)); - if (!rowBuf) { - LOG_ERR("PXC", "malloc failed for rowBuf (%d bytes, %dx%d)", bytesPerRow, c->width, c->height); - return; - } - - DirectPixelWriter pw; - pw.init(r); - - for (int row = 0; row < c->height; row++) { - if (c->file->read(rowBuf, bytesPerRow) != bytesPerRow) break; - pw.beginRow(row); - for (int col = 0; col < c->width; col++) { - const uint8_t pv = (rowBuf[col >> 2] >> (6 - (col & 3) * 2)) & 0x03; - pw.writePixel(pv); - } - } - free(rowBuf); - - GUI.drawButtonHints(const_cast(r), c->labels.btn1, c->labels.btn2, c->labels.btn3, - c->labels.btn4); - }, - &ctx, - [](const GfxRenderer& r, const void*) { - constexpr int margin = 15; - const char* msg = tr(STR_LOADING_POPUP); - const int y = static_cast(r.getScreenHeight() * 0.075f); - const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); - const int w = textWidth + margin * 2; - const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; - const int x = (r.getScreenWidth() - w) / 2; - r.fillRect(x - 2, y - 2, w + 4, h + 4, true); - r.fillRect(x, y, w, h, false); - r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); - }, - nullptr); + renderPxcToFramebuffer(file, pxcWidth, pxcHeight, dataOffset); file.close(); @@ -131,58 +135,7 @@ void PxcViewerActivity::renderGrayscaleImage() { } const uint32_t dataOffset = file.position(); - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); - struct PxcCtx { - FsFile* file; - uint32_t dataOffset; - int width, height; - MappedInputManager::Labels labels; - }; - PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight, labels}; - - renderer.renderGrayscaleSinglePass( - GfxRenderer::GrayscaleMode::FactoryQuality, - [](const GfxRenderer& r, const void* raw) { - const auto* c = static_cast(raw); - c->file->seek(c->dataOffset); - - const int bytesPerRow = (c->width + 3) / 4; - uint8_t* rowBuf = static_cast(malloc(bytesPerRow)); - if (!rowBuf) { - LOG_ERR("PXC", "malloc failed for rowBuf (%d bytes, %dx%d)", bytesPerRow, c->width, c->height); - return; - } - - DirectPixelWriter pw; - pw.init(r); - - for (int row = 0; row < c->height; row++) { - if (c->file->read(rowBuf, bytesPerRow) != bytesPerRow) break; - pw.beginRow(row); - for (int col = 0; col < c->width; col++) { - const uint8_t pv = (rowBuf[col >> 2] >> (6 - (col & 3) * 2)) & 0x03; - pw.writePixel(pv); - } - } - free(rowBuf); - - GUI.drawButtonHints(const_cast(r), c->labels.btn1, c->labels.btn2, c->labels.btn3, - c->labels.btn4); - }, - &ctx, - [](const GfxRenderer& r, const void*) { - constexpr int margin = 15; - const char* msg = tr(STR_LOADING_POPUP); - const int y = static_cast(r.getScreenHeight() * 0.075f); - const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); - const int w = textWidth + margin * 2; - const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; - const int x = (r.getScreenWidth() - w) / 2; - r.fillRect(x - 2, y - 2, w + 4, h + 4, true); - r.fillRect(x, y, w, h, false); - r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); - }, - nullptr); + renderPxcToFramebuffer(file, pxcWidth, pxcHeight, dataOffset); file.close(); } diff --git a/src/activities/util/PxcViewerActivity.h b/src/activities/util/PxcViewerActivity.h index ffa0f5c18d..377a2936b8 100644 --- a/src/activities/util/PxcViewerActivity.h +++ b/src/activities/util/PxcViewerActivity.h @@ -1,5 +1,8 @@ #pragma once +#include + +#include #include #include "../Activity.h" @@ -17,4 +20,5 @@ class PxcViewerActivity final : public Activity { private: std::string filePath; void renderGrayscaleImage(); + void renderPxcToFramebuffer(FsFile& file, uint16_t width, uint16_t height, uint32_t dataOffset); }; From adb7ab244fe6edd4c5d223b0e2e927cdc0f9c5b6 Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Mon, 27 Apr 2026 16:07:14 +0200 Subject: [PATCH 29/57] fix: tighten PXC validation and screenshot BW restore - Reject PXC files whose dimensions don't exactly match the runtime screen size. The previous abs(w-sw) > 1 tolerance allowed a one-pixel oversize, which would cause out-of-bounds writes in DirectPixelWriter (no bounds checking on writePixel). Strict equality is correct per device since renderer.getScreenWidth/Height are device-aware (X4 800x480 vs X3 792x528). - Make renderPxcSleepScreen return bool so the /sleep.pxc path can fall through to /sleep.bmp when the PXC is missing/invalid, instead of short-circuiting straight to the default sleep screen. Other callers (random wallpaper picker, screenshot replay) explicitly fall back to renderDefaultSleepScreen on failure to preserve prior behaviour. - In onScreenshotRequest, store the BW page before the factory-gray render and restore it before cleanupGrayscaleWithFrameBuffer, so the controller's BW state is synced to the actual page rather than a cleared framebuffer. Mirrors the pattern in the normal render path. --- src/activities/boot_sleep/SleepActivity.cpp | 30 ++++++++++++-------- src/activities/boot_sleep/SleepActivity.h | 2 +- src/activities/reader/EpubReaderActivity.cpp | 6 +++- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 31287ab4c4..71fcfcdfaa 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -110,7 +110,7 @@ void SleepActivity::renderCustomSleepScreen() const { } const int sw = renderer.getScreenWidth(); const int sh = renderer.getScreenHeight(); - if (abs(w - sw) > 1 || abs(h - sh) > 1) { + if (w != sw || h != sh) { LOG_DBG("SLP", "Skipping PXC size mismatch %dx%d (screen %dx%d): %s", w, h, sw, sh, name); file.close(); continue; @@ -135,7 +135,9 @@ void SleepActivity::renderCustomSleepScreen() const { LOG_DBG("SLP", "Randomly loading: %s/%s", sleepDir, files[randomFileIndex].c_str()); delay(100); if (FsHelpers::hasPxcExtension(files[randomFileIndex])) { - renderPxcSleepScreen(filename); + if (!renderPxcSleepScreen(filename)) { + renderDefaultSleepScreen(); + } dir.close(); return; } @@ -159,8 +161,9 @@ void SleepActivity::renderCustomSleepScreen() const { // Check root for sleep.pxc (preferred) or sleep.bmp if (Storage.exists("/sleep.pxc")) { LOG_DBG("SLP", "Loading: /sleep.pxc"); - renderPxcSleepScreen("/sleep.pxc"); - return; + if (renderPxcSleepScreen("/sleep.pxc")) { + return; + } } // Look for sleep.bmp on the root of the sd card to determine if we should @@ -200,26 +203,26 @@ void SleepActivity::renderDefaultSleepScreen() const { renderer.displayBuffer(HalDisplay::HALF_REFRESH); } -void SleepActivity::renderPxcSleepScreen(const std::string& path) const { +bool SleepActivity::renderPxcSleepScreen(const std::string& path) const { FsFile file; if (!Storage.openFileForRead("SLP", path, file)) { LOG_ERR("SLP", "Cannot open PXC: %s", path.c_str()); - return renderDefaultSleepScreen(); + return false; } uint16_t pxcWidth, pxcHeight; if (file.read(&pxcWidth, 2) != 2 || file.read(&pxcHeight, 2) != 2) { LOG_ERR("SLP", "PXC header read failed: %s", path.c_str()); file.close(); - return renderDefaultSleepScreen(); + return false; } const int screenWidth = renderer.getScreenWidth(); const int screenHeight = renderer.getScreenHeight(); - if (abs(pxcWidth - screenWidth) > 1 || abs(pxcHeight - screenHeight) > 1) { + if (pxcWidth != screenWidth || pxcHeight != screenHeight) { LOG_ERR("SLP", "PXC size %dx%d does not match screen %dx%d", pxcWidth, pxcHeight, screenWidth, screenHeight); file.close(); - return renderDefaultSleepScreen(); + return false; } const uint32_t dataOffset = file.position(); // right after the 4-byte header @@ -270,14 +273,14 @@ void SleepActivity::renderPxcSleepScreen(const std::string& path) const { if (!file.seek(dataOffset)) { LOG_ERR("SLP", "PXC seek failed: %s", path.c_str()); file.close(); - return renderDefaultSleepScreen(); + return false; } uint8_t* rowBuf = static_cast(malloc(bytesPerRow)); if (!rowBuf) { LOG_ERR("SLP", "PXC malloc failed"); file.close(); - return renderDefaultSleepScreen(); + return false; } for (int row = 0; row < pxcHeight; row++) { @@ -296,6 +299,7 @@ void SleepActivity::renderPxcSleepScreen(const std::string& path) const { } file.close(); + return true; } void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { @@ -521,7 +525,9 @@ void SleepActivity::renderBlankSleepScreen() const { void SleepActivity::onScreenshotRequest() { if (lastGrayscalePath.empty()) return; if (lastGrayscaleIsPxc) { - renderPxcSleepScreen(lastGrayscalePath); + if (!renderPxcSleepScreen(lastGrayscalePath)) { + renderDefaultSleepScreen(); + } } else { FsFile file; if (Storage.openFileForRead("SLP", lastGrayscalePath.c_str(), file)) { diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 07bb76ea0d..2952009341 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -17,7 +17,7 @@ class SleepActivity final : public Activity { void renderCustomSleepScreen() const; void renderCoverSleepScreen() const; void renderBitmapSleepScreen(const Bitmap& bitmap) const; - void renderPxcSleepScreen(const std::string& path) const; + bool renderPxcSleepScreen(const std::string& path) const; void renderBlankSleepScreen() const; // Tracks the last factory-LUT render so onScreenshotRequest() can re-render the same image. diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 434761066b..1ca25ecb5a 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -827,9 +827,13 @@ void EpubReaderActivity::onScreenshotRequest() { auto p = section->loadPageFromSectionFile(); if (!p) return; + // Preserve the BW page across the gray render so cleanupGrayscaleWithFrameBuffer + // syncs the controller to the actual page, not a cleared framebuffer. + if (!renderer.storeBwBuffer()) return; + PageRenderCtx grayCtx{p.get(), SETTINGS.getReaderFontId(), lastFactoryMarginLeft, lastFactoryMarginTop, this}; renderer.renderGrayscale(GfxRenderer::GrayscaleMode::FactoryQuality, &renderPageCallback, &grayCtx); - renderer.clearScreen(); + renderer.restoreBwBuffer(); renderer.cleanupGrayscaleWithFrameBuffer(); } From 2b831ecf84dc79512abe3c04c1f28e7a67b62342 Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Mon, 27 Apr 2026 16:34:59 +0200 Subject: [PATCH 30/57] docs: explain why sleep screenshot leaves controller desynced The clearScreen + cleanupGrayscaleWithFrameBuffer in onScreenshotRequest intentionally puts the controller into a 'BW = white' state. This is the correct precondition for the HALF_REFRESH pre-flash done by the next SleepActivity render (triggered by goToSleep on the way into deep sleep) and the panel resets across deep sleep, so no preservation of the displayed image is needed here. --- src/activities/boot_sleep/SleepActivity.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 71fcfcdfaa..dff3be525a 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -538,6 +538,7 @@ void SleepActivity::onScreenshotRequest() { file.close(); } } + // Device enters deep sleep next; on wake the new activity will full-refresh anyway. renderer.clearScreen(); renderer.cleanupGrayscaleWithFrameBuffer(); } From 265fb12bb9dc15f71aeade9463385d75eb20404f Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Tue, 28 Apr 2026 02:35:11 +0200 Subject: [PATCH 31/57] fix: correct Bayer dither thresholds for 4-level palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thresholds were 64/128/192, evenly partitioning [0,255] but mismatched to the actual output palette {0, 85, 170, 255}. The white cutoff at 192 (vs the correct midpoint 213) snapped grays in 192–212 — and via the ±40 dither offset, sources as low as ~157 — to pure white, blowing out highlights in EPUB JPEG/PNG renders. Use the palette midpoints 43/128/213 to match the calibration already used by the Atkinson and Floyd-Steinberg ditherers. --- lib/Epub/Epub/converters/DitherUtils.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/Epub/Epub/converters/DitherUtils.h b/lib/Epub/Epub/converters/DitherUtils.h index ec63a76840..e40115ff39 100644 --- a/lib/Epub/Epub/converters/DitherUtils.h +++ b/lib/Epub/Epub/converters/DitherUtils.h @@ -20,8 +20,9 @@ inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) { if (adjusted < 0) adjusted = 0; if (adjusted > 255) adjusted = 255; - if (adjusted < 64) return 0; + // Midpoint thresholds for output palette {0, 85, 170, 255}. + if (adjusted < 43) return 0; if (adjusted < 128) return 1; - if (adjusted < 192) return 2; + if (adjusted < 213) return 2; return 3; } From 1d72e2399f7318839b9c9d25ce469acdfcbf99ca Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Tue, 28 Apr 2026 18:38:17 +0200 Subject: [PATCH 32/57] fix: raise EPUB image dither thresholds for factory LUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EPUB image pages render on the factory LUT, where palette levels are physically lighter than on the differential LUT used upstream. With the symmetric midpoint thresholds {43, 128, 213} mid-light tones (sRGB 128–213) blew out toward white. Raise T12 to 170 and T23 to 235 so mid-bright pixels land in palette 1 (drive 85) and only true highlights come out pure white. Brightness is now roughly equivalent to upstream's differential rendering. --- lib/Epub/Epub/converters/DitherUtils.h | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub/converters/DitherUtils.h b/lib/Epub/Epub/converters/DitherUtils.h index e40115ff39..47886fcdbb 100644 --- a/lib/Epub/Epub/converters/DitherUtils.h +++ b/lib/Epub/Epub/converters/DitherUtils.h @@ -20,9 +20,18 @@ inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) { if (adjusted < 0) adjusted = 0; if (adjusted > 255) adjusted = 255; - // Midpoint thresholds for output palette {0, 85, 170, 255}. + // Output palette {0, 85, 170, 255}. EPUB image pages render on the factory + // LUT (EpubReaderActivity: useFactoryGray), where palette levels are + // physically lighter than on the differential LUT used upstream. To get + // brightness roughly equivalent to upstream's differential rendering, we + // raise T12 and T23 to push more pixels into the darker palette indices: + // T01 = 43 — calibrated shadow boundary (linear midpoint, unchanged) + // T12 = 170 — widens palette 1 band so mid-bright pixels (sRGB 128–170) + // render as gray 85 instead of gray 170 + // T23 = 235 — narrows the white band so only true highlights come out + // pure white; mid-light tones stay at gray 170 if (adjusted < 43) return 0; - if (adjusted < 128) return 1; - if (adjusted < 213) return 2; + if (adjusted < 170) return 1; + if (adjusted < 235) return 2; return 3; } From 5c14403aed418c1f67bfe3c32c137ce9e5a4ee8c Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Tue, 28 Apr 2026 20:22:30 +0200 Subject: [PATCH 33/57] Revert "fix: raise EPUB image dither thresholds for factory LUT" This reverts commit 1d72e2399f7318839b9c9d25ce469acdfcbf99ca. --- lib/Epub/Epub/converters/DitherUtils.h | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/lib/Epub/Epub/converters/DitherUtils.h b/lib/Epub/Epub/converters/DitherUtils.h index 47886fcdbb..e40115ff39 100644 --- a/lib/Epub/Epub/converters/DitherUtils.h +++ b/lib/Epub/Epub/converters/DitherUtils.h @@ -20,18 +20,9 @@ inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) { if (adjusted < 0) adjusted = 0; if (adjusted > 255) adjusted = 255; - // Output palette {0, 85, 170, 255}. EPUB image pages render on the factory - // LUT (EpubReaderActivity: useFactoryGray), where palette levels are - // physically lighter than on the differential LUT used upstream. To get - // brightness roughly equivalent to upstream's differential rendering, we - // raise T12 and T23 to push more pixels into the darker palette indices: - // T01 = 43 — calibrated shadow boundary (linear midpoint, unchanged) - // T12 = 170 — widens palette 1 band so mid-bright pixels (sRGB 128–170) - // render as gray 85 instead of gray 170 - // T23 = 235 — narrows the white band so only true highlights come out - // pure white; mid-light tones stay at gray 170 + // Midpoint thresholds for output palette {0, 85, 170, 255}. if (adjusted < 43) return 0; - if (adjusted < 170) return 1; - if (adjusted < 235) return 2; + if (adjusted < 128) return 1; + if (adjusted < 213) return 2; return 3; } From 423c086512323d2ee6863750fd31eb71b603a5ef Mon Sep 17 00:00:00 2001 From: Patryk Radtke Date: Tue, 28 Apr 2026 20:50:56 +0200 Subject: [PATCH 34/57] fix: adjust dither thresholds and add soft-shoulder for factory LUT --- lib/Epub/Epub/converters/DitherUtils.h | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/Epub/Epub/converters/DitherUtils.h b/lib/Epub/Epub/converters/DitherUtils.h index e40115ff39..cbdcf4fa51 100644 --- a/lib/Epub/Epub/converters/DitherUtils.h +++ b/lib/Epub/Epub/converters/DitherUtils.h @@ -13,16 +13,30 @@ inline const uint8_t bayer4x4[4][4] = { // Apply Bayer dithering and quantize to 4 levels (0-3) // Stateless - works correctly with any pixel processing order inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) { + // Soft-shoulder darkening for factory LUT: EPUB image pages render on the + // factory LUT, where palette levels are physically lighter than on the + // differential LUT. Apply a -12 offset to mid-bright pixels onward to bring + // highlights/midtones back down without crushing deep shadow detail. + // Ramp the offset from 0 to 12 across gray [0, 64], flat -12 above 64. + int g = gray; + int offset = (g < 64) ? g * 12 / 64 : 12; + g -= offset; + int bayer = bayer4x4[y & 3][x & 3]; int dither = (bayer - 8) * 5; // Scale to +/-40 (half of quantization step 85) - int adjusted = gray + dither; + int adjusted = g + dither; if (adjusted < 0) adjusted = 0; if (adjusted > 255) adjusted = 255; - // Midpoint thresholds for output palette {0, 85, 170, 255}. - if (adjusted < 43) return 0; - if (adjusted < 128) return 1; - if (adjusted < 213) return 2; + // T12 raised from 128 to 150 so mid-bright source pixels (sRGB 150–170) + // land in the palette 1 / palette 2 dither zone, producing ~50% perceived + // reflectance via 57/43 mixing — the perceptual mid-gray that factory LUT + // can't reach with palette 2 alone (~70% reflectance). + // T23 raised from 192 to 210 to keep mid-bright pixels (sRGB 180–210) from + // blowing out to pure white after the soft-shoulder offset is applied. + if (adjusted < 64) return 0; + if (adjusted < 150) return 1; + if (adjusted < 210) return 2; return 3; } From e2ffc4b183af1818f710cd043b118b580e46ec94 Mon Sep 17 00:00:00 2001 From: pablohc Date: Tue, 5 May 2026 22:30:04 +0200 Subject: [PATCH 35/57] fix: reduce Bayer dither soft-shoulder and rebalance thresholds for factory LUT Reduce darkening offset from -12 to -8 and set thresholds to {48, 133, 218} to preserve shadow/midtone detail while compensating factory LUT brightness. Previous values (-12, {64, 150, 210}) caused image darkening artifacts and visual glitches on screen suspend from EPUB image pages. --- lib/Epub/Epub/converters/DitherUtils.h | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/Epub/Epub/converters/DitherUtils.h b/lib/Epub/Epub/converters/DitherUtils.h index cbdcf4fa51..f8614047da 100644 --- a/lib/Epub/Epub/converters/DitherUtils.h +++ b/lib/Epub/Epub/converters/DitherUtils.h @@ -15,11 +15,11 @@ inline const uint8_t bayer4x4[4][4] = { inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) { // Soft-shoulder darkening for factory LUT: EPUB image pages render on the // factory LUT, where palette levels are physically lighter than on the - // differential LUT. Apply a -12 offset to mid-bright pixels onward to bring + // differential LUT. Apply a -8 offset to mid-bright pixels onward to bring // highlights/midtones back down without crushing deep shadow detail. - // Ramp the offset from 0 to 12 across gray [0, 64], flat -12 above 64. + // Ramp the offset from 0 to 8 across gray [0, 64], flat -8 above 64. int g = gray; - int offset = (g < 64) ? g * 12 / 64 : 12; + int offset = (g < 64) ? g * 8 / 64 : 8; g -= offset; int bayer = bayer4x4[y & 3][x & 3]; @@ -29,14 +29,14 @@ inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) { if (adjusted < 0) adjusted = 0; if (adjusted > 255) adjusted = 255; - // T12 raised from 128 to 150 so mid-bright source pixels (sRGB 150–170) - // land in the palette 1 / palette 2 dither zone, producing ~50% perceived - // reflectance via 57/43 mixing — the perceptual mid-gray that factory LUT - // can't reach with palette 2 alone (~70% reflectance). - // T23 raised from 192 to 210 to keep mid-bright pixels (sRGB 180–210) from - // blowing out to pure white after the soft-shoulder offset is applied. - if (adjusted < 64) return 0; - if (adjusted < 150) return 1; - if (adjusted < 210) return 2; + // T01=48: slightly above original 43, keeps shadow detail while allowing + // near-black pixels to lift to dark gray. + // T12=133: raised from 128 to push more mid-bright source pixels into the + // palette 1 / palette 2 dither zone for perceptual mid-gray. + // T23=218: raised from 192 to expand light-gray range, preserving + // highlight detail on the brighter factory LUT. + if (adjusted < 48) return 0; + if (adjusted < 133) return 1; + if (adjusted < 218) return 2; return 3; } From f6a6ddb0f476146ac56b112d3c029e2a23d75123 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Fri, 8 May 2026 18:34:26 +0200 Subject: [PATCH 36/57] fix: PXC sleep screens always use factory LUT grayscale - Remove BW/Inverted filter path for PXC (bypassed factory LUT) - PXC now always uses FactoryQuality grayscale mode - DirectPixelWriter handles orientation transforms automatically - Filter settings (BLACK_AND_WHITE/INVERTED) ignored for PXC - Consistent with PxcViewerActivity behavior - Maintains PR 1614 intent (factory LUT + PXC support) --- src/activities/boot_sleep/SleepActivity.cpp | 101 +++++++------------- 1 file changed, 34 insertions(+), 67 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 144190de24..f84b8e28a1 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -242,77 +242,44 @@ bool SleepActivity::renderPxcSleepScreen(const std::string& path) const { } const uint32_t dataOffset = file.position(); // right after the 4-byte header - const auto filter = SETTINGS.sleepScreenCoverFilter; - const int bytesPerRow = (pxcWidth + 3) / 4; - - if (filter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER) { - lastGrayscalePath = path; - lastGrayscaleIsPxc = true; - struct PxcCtx { - FsFile* file; - uint32_t dataOffset; - int width, height; - }; - PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight}; - renderer.renderGrayscaleSinglePass( - GfxRenderer::GrayscaleMode::FactoryQuality, - [](const GfxRenderer& r, const void* raw) { - const auto* c = static_cast(raw); - c->file->seek(c->dataOffset); - - const int bpr = (c->width + 3) / 4; - uint8_t* rowBuf = static_cast(malloc(bpr)); - if (!rowBuf) { - LOG_ERR("SLP", "malloc failed for rowBuf (%d bytes, %dx%d)", bpr, c->width, c->height); - return; - } + // PXC is always 2-bit grayscale - always use factory LUT + lastGrayscalePath = path; + lastGrayscaleIsPxc = true; + struct PxcCtx { + FsFile* file; + uint32_t dataOffset; + int width, height; + }; + PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight}; + + renderer.renderGrayscaleSinglePass( + GfxRenderer::GrayscaleMode::FactoryQuality, + [](const GfxRenderer& r, const void* raw) { + const auto* c = static_cast(raw); + c->file->seek(c->dataOffset); + + const int bpr = (c->width + 3) / 4; + uint8_t* rowBuf = static_cast(malloc(bpr)); + if (!rowBuf) { + LOG_ERR("SLP", "malloc failed for rowBuf (%d bytes, %dx%d)", bpr, c->width, c->height); + return; + } - DirectPixelWriter pw; - pw.init(r); + DirectPixelWriter pw; + pw.init(r); - for (int row = 0; row < c->height; row++) { - if (c->file->read(rowBuf, bpr) != bpr) break; - pw.beginRow(row); - for (int col = 0; col < c->width; col++) { - const uint8_t pv = (rowBuf[col >> 2] >> (6 - (col & 3) * 2)) & 0x03; - pw.writePixel(pv); - } + for (int row = 0; row < c->height; row++) { + if (c->file->read(rowBuf, bpr) != bpr) break; + pw.beginRow(row); + for (int col = 0; col < c->width; col++) { + const uint8_t pv = (rowBuf[col >> 2] >> (6 - (col & 3) * 2)) & 0x03; + pw.writePixel(pv); } - free(rowBuf); - }, - &ctx, &drawEnteringSleepOverlay, nullptr); - } else { - // BLACK_AND_WHITE / INVERTED_BLACK_AND_WHITE: threshold PXC to 1-bit - // (pv 0=Black, 1=DarkGrey map to dark; 2=LightGrey, 3=White map to light) - renderer.clearScreen(); - if (!file.seek(dataOffset)) { - LOG_ERR("SLP", "PXC seek failed: %s", path.c_str()); - file.close(); - return false; - } - - uint8_t* rowBuf = static_cast(malloc(bytesPerRow)); - if (!rowBuf) { - LOG_ERR("SLP", "PXC malloc failed"); - file.close(); - return false; - } - - for (int row = 0; row < pxcHeight; row++) { - if (file.read(rowBuf, bytesPerRow) != bytesPerRow) break; - for (int col = 0; col < pxcWidth; col++) { - const uint8_t pv = (rowBuf[col >> 2] >> (6 - (col & 3) * 2)) & 0x03; - if (pv < 2) renderer.drawPixel(col, row, true); - } - } - free(rowBuf); - - if (filter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) { - renderer.invertScreen(); - } - renderer.displayBuffer(HalDisplay::FULL_REFRESH); - } + } + free(rowBuf); + }, + &ctx, &drawEnteringSleepOverlay, nullptr); file.close(); return true; From c0bea867de1d0612682277d914f57148c52df712 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Fri, 8 May 2026 19:34:24 +0200 Subject: [PATCH 37/57] chore: update SDK to fix X3 factory fast mode fallback --- open-x4-sdk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-x4-sdk b/open-x4-sdk index 9ea2f02f56..7df6023954 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit 9ea2f02f56477d927a1bacd03821a70fc41829c7 +Subproject commit 7df6023954dae1b10db480551a4ef7e673f5326e From 52ddb1f2cf15f493d913c83b34b9ae1065029e4c Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Fri, 8 May 2026 19:45:59 +0200 Subject: [PATCH 38/57] chore: update SDK to fix grayscaleRevert logic bug --- open-x4-sdk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-x4-sdk b/open-x4-sdk index 7df6023954..e9dbce287c 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit 7df6023954dae1b10db480551a4ef7e673f5326e +Subproject commit e9dbce287c6618220b184cb2849ddc438c5b9fdb From f4960a3a90e4d85ca3ffbc843272fc31b4f1f4b6 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Fri, 8 May 2026 22:19:42 +0200 Subject: [PATCH 39/57] fix: resolve PR1614 xtc status bar merge --- lib/GfxRenderer/GfxRenderer.cpp | 13 ++- lib/GfxRenderer/GfxRenderer.h | 8 +- lib/I18n/translations/english.yaml | 3 + src/CrossPointSettings.h | 7 ++ src/SettingsList.h | 3 + src/activities/home/FileBrowserActivity.cpp | 15 +-- src/activities/reader/XtcReaderActivity.cpp | 96 ++++++++++++++++++- src/activities/reader/XtcReaderActivity.h | 15 +++ .../settings/StatusBarSettingsActivity.cpp | 17 +++- src/main.cpp | 12 ++- 10 files changed, 173 insertions(+), 16 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 940df8b61c..4cb0236044 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1509,7 +1509,7 @@ void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn) } void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, const uint16_t pageWidth, - const uint16_t pageHeight) { + const uint16_t pageHeight, RenderHook overlayFn, const void* overlayCtx) { const size_t colBytes = (pageHeight + 7) / 8; const uint16_t fbStride = panelWidthBytes; @@ -1534,6 +1534,8 @@ void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2 dstRow[b] = srcCol[b]; } } + setRenderMode(GRAY2_LSB); + if (overlayFn) overlayFn(*this, overlayCtx); copyGrayscaleLsbBuffers(); @@ -1546,6 +1548,8 @@ void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2 dstRow[b] = srcCol[b]; } } + setRenderMode(GRAY2_MSB); + if (overlayFn) overlayFn(*this, overlayCtx); copyGrayscaleMsbBuffers(); // Fire hook: plane1 input IS already in framebuffer format (colBytes == fbStride for portrait @@ -1560,7 +1564,9 @@ void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2 setRenderMode(BW); } -void GfxRenderer::displayXtcBwPage(const uint8_t* pageBuffer, const uint16_t pageWidth, const uint16_t pageHeight) { +void GfxRenderer::displayXtcBwPage(const uint8_t* pageBuffer, const uint16_t pageWidth, const uint16_t pageHeight, + const HalDisplay::RefreshMode refreshMode, RenderHook overlayFn, + const void* overlayCtx) { const size_t srcRowBytes = (pageWidth + 7) / 8; // 1-bit content has no AA — render as plain BW and use the standard differential fast-refresh @@ -1573,7 +1579,8 @@ void GfxRenderer::displayXtcBwPage(const uint8_t* pageBuffer, const uint16_t pag } } } - displayBuffer(HalDisplay::FAST_REFRESH); + if (overlayFn) overlayFn(*this, overlayCtx); + displayBuffer(refreshMode); } void GfxRenderer::freeBwBufferChunks() { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 3b00ab9654..fc6aa0ea5d 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -54,6 +54,7 @@ class GfxRenderer { // Hook is cleared automatically after firing. using ScreenshotHook = void (*)(const uint8_t* lsbPlane, const uint8_t* msbPlane, int physWidth, int physHeight, void* ctx); + using RenderHook = void (*)(const GfxRenderer&, const void*); private: static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory @@ -206,11 +207,14 @@ class GfxRenderer { // Direct 2-bit XTCH plane blit using factory LUT. Caller supplies the two decoded bit planes // (plane1 = BW RAM / LSB, plane2 = RED RAM / MSB) in column-major order matching XTCH encoding. // Handles pre-flash, both RAM writes, factory LUT fire, and BW controller sync internally. - void displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, uint16_t pageWidth, uint16_t pageHeight); + void displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, uint16_t pageWidth, uint16_t pageHeight, + RenderHook overlayFn = nullptr, const void* overlayCtx = nullptr); // 1-bit XTC page via the same grayscale LUT pipeline. Row-major pageBuffer (XTC: 0=black, 1=white). // BW and RED RAM receive identical data since there are no intermediate gray levels. - void displayXtcBwPage(const uint8_t* pageBuffer, uint16_t pageWidth, uint16_t pageHeight); + void displayXtcBwPage(const uint8_t* pageBuffer, uint16_t pageWidth, uint16_t pageHeight, + HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH, RenderHook overlayFn = nullptr, + const void* overlayCtx = nullptr); // Font helpers const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const; diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index b662389992..bcc1a20b19 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -229,6 +229,9 @@ STR_EXAMPLE_BOOK: "Book Title" STR_PREVIEW: "Preview" STR_TITLE: "Title" STR_BATTERY: "Battery" +STR_XTC_STATUS_BAR: "XTC Status Bar" +STR_BOTTOM: "Bottom" +STR_TOP: "Top" STR_UI_THEME: "UI Theme" STR_THEME_CLASSIC: "Classic" STR_THEME_LYRA: "Lyra" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 01d7cc5618..cf5c695df0 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -57,6 +57,12 @@ class CrossPointSettings { STATUS_BAR_PROGRESS_BAR_THICKNESS_COUNT }; enum STATUS_BAR_TITLE { BOOK_TITLE = 0, CHAPTER_TITLE = 1, HIDE_TITLE = 2, STATUS_BAR_TITLE_COUNT }; + enum XTC_STATUS_BAR_MODE { + XTC_STATUS_BAR_HIDE = 0, + XTC_STATUS_BAR_BOTTOM = 1, + XTC_STATUS_BAR_TOP = 2, + XTC_STATUS_BAR_MODE_COUNT + }; enum ORIENTATION { PORTRAIT = 0, // 480x800 logical coordinates (current default) @@ -161,6 +167,7 @@ class CrossPointSettings { uint8_t statusBarProgressBarThickness = PROGRESS_BAR_NORMAL; uint8_t statusBarTitle = CHAPTER_TITLE; uint8_t statusBarBattery = 1; + uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE; // Text rendering settings uint8_t extraParagraphSpacing = 1; uint8_t textAntiAliasing = 1; diff --git a/src/SettingsList.h b/src/SettingsList.h index bbe9a77458..cf2d02ed8b 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -131,6 +131,9 @@ inline const std::vector& getSettingsList() { StrId::STR_CUSTOMISE_STATUS_BAR), SettingInfo::Toggle(StrId::STR_BATTERY, &CrossPointSettings::statusBarBattery, "statusBarBattery", StrId::STR_CUSTOMISE_STATUS_BAR), + SettingInfo::Enum(StrId::STR_XTC_STATUS_BAR, &CrossPointSettings::xtcStatusBarMode, + {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}, "xtcStatusBarMode", + StrId::STR_CUSTOMISE_STATUS_BAR), }; // Only show tilt page turn setting when the QMI8658 IMU is present (X3) if (halTiltSensor.isAvailable()) { diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 66a94469ac..5737fdca82 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -142,17 +142,20 @@ void FileBrowserActivity::loop() { return; } - if (mode == Mode::Books && mappedInput.getHeldTime() >= GO_HOME_MS && !isDirectory) { - // --- LONG PRESS ACTION: DELETE FILE --- + if (mode == Mode::Books && mappedInput.getHeldTime() >= GO_HOME_MS) { + // --- LONG PRESS ACTION: DELETE FILE OR DIRECTORY --- std::string cleanBasePath = basepath; if (cleanBasePath.back() != '/') cleanBasePath += "/"; const std::string fullPath = cleanBasePath + entry; - auto handler = [this, fullPath](const ActivityResult& res) { + auto handler = [this, fullPath, isDirectory](const ActivityResult& res) { if (!res.isCancelled) { LOG_DBG("FileBrowser", "Attempting to delete: %s", fullPath.c_str()); - clearFileMetadata(fullPath); - if (Storage.remove(fullPath.c_str())) { + if (!isDirectory) { + clearFileMetadata(fullPath); + } + const bool deleted = isDirectory ? Storage.removeDir(fullPath.c_str()) : Storage.remove(fullPath.c_str()); + if (deleted) { LOG_DBG("FileBrowser", "Deleted successfully"); loadFiles(); if (files.empty()) { @@ -164,7 +167,7 @@ void FileBrowserActivity::loop() { requestUpdate(true); } else { - LOG_ERR("FileBrowser", "Failed to delete file: %s", fullPath.c_str()); + LOG_ERR("FileBrowser", "Failed to delete: %s", fullPath.c_str()); } } else { LOG_DBG("FileBrowser", "Delete cancelled by user"); diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 5779fb7897..dc4486f9ae 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -12,6 +12,8 @@ #include #include +#include + #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" @@ -135,6 +137,86 @@ void XtcReaderActivity::render(RenderLock&&) { saveProgress(); } +void XtcReaderActivity::renderStatusBarOverlayCallback(const GfxRenderer&, const void* raw) { + const auto* activity = static_cast(raw); + activity->renderConfiguredStatusBarOverlay(); +} + +XtcReaderActivity::StatusBarInfo XtcReaderActivity::getStatusBarInfo() const { + const int bookPageCount = static_cast(xtc->getPageCount()); + const int bookPage = static_cast(currentPage) + 1; + std::string title = + SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE ? xtc->getTitle() : ""; + + if (!xtc->hasChapters()) { + return StatusBarInfo{bookPage, bookPageCount, std::move(title)}; + } + + const auto& chapters = xtc->getChapters(); + const auto chapterIt = std::find_if(chapters.begin(), chapters.end(), [this](const xtc::ChapterInfo& chapter) { + return currentPage >= chapter.startPage && currentPage <= chapter.endPage; + }); + + if (chapterIt == chapters.end() || chapterIt->endPage < chapterIt->startPage) { + return StatusBarInfo{bookPage, bookPageCount, std::move(title)}; + } + + if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) { + title = chapterIt->name.empty() ? tr(STR_UNNAMED) : chapterIt->name; + } + + return StatusBarInfo{static_cast(currentPage - chapterIt->startPage) + 1, + static_cast(chapterIt->endPage - chapterIt->startPage) + 1, std::move(title)}; +} + +void XtcReaderActivity::renderStatusBarOverlay(const StatusBarOverlayPosition position) const { + const bool drawBottom = SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_BOTTOM && + position == StatusBarOverlayPosition::Bottom; + const bool drawTop = SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP && + position == StatusBarOverlayPosition::Top; + if (!drawBottom && !drawTop) { + return; + } + + const int statusBarHeight = UITheme::getInstance().getStatusBarHeight(); + if (statusBarHeight <= 0) { + return; + } + + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + + int clearY; + int paddingBottom = 0; + if (position == StatusBarOverlayPosition::Bottom) { + clearY = renderer.getScreenHeight() - orientedMarginBottom - statusBarHeight - 4; + if (clearY < 0) { + clearY = 0; + } + } else { + clearY = orientedMarginTop; + paddingBottom = renderer.getScreenHeight() - statusBarHeight - orientedMarginBottom - orientedMarginTop - 4; + } + const int clearHeight = position == StatusBarOverlayPosition::Bottom + ? renderer.getScreenHeight() - orientedMarginBottom - clearY + : statusBarHeight + 4; + if (clearHeight > 0) { + renderer.fillRect(0, clearY, renderer.getScreenWidth(), clearHeight, false); + } + + const int pageCount = static_cast(xtc->getPageCount()); + const int displayPage = static_cast(currentPage) + 1; + const float progress = pageCount > 0 ? (static_cast(displayPage) * 100.0f) / pageCount : 0.0f; + const auto pageInfo = getStatusBarInfo(); + GUI.drawStatusBar(renderer, progress, pageInfo.currentPage, pageInfo.pageCount, pageInfo.title, paddingBottom); +} + +void XtcReaderActivity::renderConfiguredStatusBarOverlay() const { + renderStatusBarOverlay(StatusBarOverlayPosition::Top); + renderStatusBarOverlay(StatusBarOverlayPosition::Bottom); +} + void XtcReaderActivity::renderPage() { const uint16_t pageWidth = xtc->getPageWidth(); const uint16_t pageHeight = xtc->getPageHeight(); @@ -188,7 +270,8 @@ void XtcReaderActivity::renderPage() { renderer.displayBuffer(HalDisplay::FULL_REFRESH); } - renderer.displayXtchPlanes(plane1, plane2, pageWidth, pageHeight); + renderer.displayXtchPlanes(plane1, plane2, pageWidth, pageHeight, &XtcReaderActivity::renderStatusBarOverlayCallback, + this); free(plane1); free(plane2); @@ -215,8 +298,17 @@ void XtcReaderActivity::renderPage() { renderer.displayBuffer(); return; } - renderer.displayXtcBwPage(pageBuffer, pageWidth, pageHeight); + const bool doFullRefresh = pagesUntilFullRefresh <= 1; + renderer.displayXtcBwPage(pageBuffer, pageWidth, pageHeight, + doFullRefresh ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH, + &XtcReaderActivity::renderStatusBarOverlayCallback, this); free(pageBuffer); + if (doFullRefresh) { + renderer.cleanupGrayscaleWithFrameBuffer(); + pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); + } else { + pagesUntilFullRefresh--; + } LOG_DBG("XTR", "Rendered page %lu/%lu (1-bit)", currentPage + 1, xtc->getPageCount()); } diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index 07d21b4d3f..af2da44fd1 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -9,15 +9,30 @@ #include +#include +#include + #include "activities/Activity.h" class XtcReaderActivity final : public Activity { std::shared_ptr xtc; uint32_t currentPage = 0; + int pagesUntilFullRefresh = 0; uint32_t pagesSinceClean = 0; + enum class StatusBarOverlayPosition { Bottom, Top }; + struct StatusBarInfo { + int currentPage; + int pageCount; + std::string title; + }; + + static void renderStatusBarOverlayCallback(const GfxRenderer& renderer, const void* raw); void renderPage(); + void renderStatusBarOverlay(StatusBarOverlayPosition position) const; + void renderConfiguredStatusBarOverlay() const; + StatusBarInfo getStatusBarInfo() const; void saveProgress() const; void loadProgress(); diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index 6ff0b34d37..f62ce4d4b6 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -11,13 +11,14 @@ #include "fontIds.h" namespace { -constexpr int MENU_ITEMS = 6; +constexpr int MENU_ITEMS = 7; const StrId menuNames[MENU_ITEMS] = {StrId::STR_CHAPTER_PAGE_COUNT, StrId::STR_BOOK_PROGRESS_PERCENTAGE, StrId::STR_PROGRESS_BAR, StrId::STR_PROGRESS_BAR_THICKNESS, StrId::STR_TITLE, - StrId::STR_BATTERY}; + StrId::STR_BATTERY, + StrId::STR_XTC_STATUS_BAR}; constexpr int PROGRESS_BAR_ITEMS = 3; const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; @@ -28,6 +29,9 @@ const StrId progressBarThicknessNames[PROGRESS_BAR_THICKNESS_ITEMS] = { constexpr int TITLE_ITEMS = 3; const StrId titleNames[TITLE_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; +constexpr int XTC_STATUS_BAR_ITEMS = 3; +const StrId xtcStatusBarNames[XTC_STATUS_BAR_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}; + const int widthMargin = 10; const int verticalPreviewPadding = 50; const int verticalPreviewTextPadding = 40; @@ -51,6 +55,10 @@ void StatusBarSettingsActivity::onEnter() { SETTINGS.statusBarTitle = CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE; } + if (SETTINGS.xtcStatusBarMode >= XTC_STATUS_BAR_ITEMS) { + SETTINGS.xtcStatusBarMode = CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_HIDE; + } + requestUpdate(); } @@ -110,6 +118,9 @@ void StatusBarSettingsActivity::handleSelection() { } else if (selectedIndex == 5) { // Show Battery SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2; + } else if (selectedIndex == 6) { + // XTC Status Bar + SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS; } SETTINGS.saveToFile(); } @@ -143,6 +154,8 @@ void StatusBarSettingsActivity::render(RenderLock&&) { return I18N.get(titleNames[SETTINGS.statusBarTitle]); } else if (index == 5) { return SETTINGS.statusBarBattery ? tr(STR_SHOW) : tr(STR_HIDE); + } else if (index == 6) { + return I18N.get(xtcStatusBarNames[SETTINGS.xtcStatusBarMode]); } else { return tr(STR_HIDE); } diff --git a/src/main.cpp b/src/main.cpp index d6f5533d44..6c540a0433 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -381,7 +381,9 @@ void loop() { } static bool screenshotButtonsReleased = true; + static bool screenshotComboActive = false; if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.isPressed(HalGPIO::BTN_DOWN)) { + screenshotComboActive = true; if (screenshotButtonsReleased) { screenshotButtonsReleased = false; { @@ -401,8 +403,16 @@ void loop() { } } return; - } else { + } + if (screenshotComboActive) { + if (gpio.isPressed(HalGPIO::BTN_POWER)) return; + if (gpio.wasReleased(HalGPIO::BTN_POWER)) { + screenshotButtonsReleased = true; + screenshotComboActive = false; + return; + } screenshotButtonsReleased = true; + screenshotComboActive = false; } const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs(); From 7ee4f6fe4f2d654228ed43bd32d0919587739030 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Fri, 8 May 2026 22:38:10 +0200 Subject: [PATCH 40/57] chore: apply clang-format fix for xtc reader --- src/activities/reader/XtcReaderActivity.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 8fe68c0b3a..254195f739 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -268,8 +268,8 @@ void XtcReaderActivity::renderPage() { renderer.displayBuffer(HalDisplay::FULL_REFRESH); } - renderer.displayXtchPlanes(plane1, plane2, pageWidth, pageHeight, &XtcReaderActivity::renderStatusBarOverlayCallback, - this); + renderer.displayXtchPlanes(plane1, plane2, pageWidth, pageHeight, + &XtcReaderActivity::renderStatusBarOverlayCallback, this); free(plane1); free(plane2); From 9d03cae1106b4781aadd61d019fa69eddbe4c9d6 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Sat, 9 May 2026 18:46:05 +0200 Subject: [PATCH 41/57] fix: declare gray cleanup intent + drop redundant RED RAM rebase Two coupled changes for the new SDK contract: - After displayGrayBuffer in GfxRenderer::renderGrayscale and renderGrayscaleSinglePass (both the malloc'd path and the two-pass fallback), call display.clearGrayscaleModeFlag(). Differential mode rendering was leaving inGrayscaleMode=true, which (post-SDK fix) triggered an automatic ~700ms revert refresh on the next BW page turn. The renderer already manages cleanup via cleanupGrayscaleBuffers inside restoreBwBuffer, so the SDK auto-revert is unwanted overhead. - Drop the redundant cleanupGrayscaleWithFrameBuffer call after restoreBwBuffer in EpubReaderActivity (page render path and onScreenshotRequest). restoreBwBuffer already calls cleanupGrayscaleBuffers(frameBuffer), so the explicit second call wrote RED RAM with identical data twice per page. RED RAM is now rebased exactly once per gray-to-BW transition. Bumps SDK submodule to ccfd37b (community-sdk PR #4 head): the clearGrayscaleModeFlag() accessor and idempotent grayscaleRevert contract this code depends on. --- lib/GfxRenderer/GfxRenderer.cpp | 8 ++++++++ lib/hal/HalDisplay.h | 5 +++++ open-x4-sdk | 2 +- src/activities/reader/EpubReaderActivity.cpp | 14 ++++++-------- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index bc858f0ac9..f12b48ff42 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1496,6 +1496,10 @@ void GfxRenderer::renderGrayscale(GrayscaleMode mode, void (*renderFn)(const Gfx g_differentialQuantize = false; displayGrayBuffer(lut, factoryMode); + // Suppress the SDK's automatic grayscaleRevert on the next BW page turn. + // Caller is responsible for cleanup (restoreBwBuffer rebases RED RAM and + // the next FAST_REFRESH drives pixels back to clean BW). + display.clearGrayscaleModeFlag(); setRenderMode(BW); } @@ -1532,6 +1536,8 @@ void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn) copyGrayscaleMsbBuffers(); g_differentialQuantize = false; displayGrayBuffer(lut, factoryMode); + // See note in renderGrayscale(). + display.clearGrayscaleModeFlag(); setRenderMode(BW); return; } @@ -1563,6 +1569,8 @@ void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn) g_differentialQuantize = false; displayGrayBuffer(lut, factoryMode); + // See note in renderGrayscale(). + display.clearGrayscaleModeFlag(); setRenderMode(BW); } diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index d12a5c5b8a..b39d6b10e6 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -49,6 +49,11 @@ class HalDisplay { void displayGrayBuffer(bool turnOffScreen = false, const unsigned char* lut = nullptr, bool factoryMode = false); + // Tell the SDK that grayscale state has been cleaned up by the consumer + // (RAM banks rebased + a follow-up FAST_REFRESH will handle pixel cleanup), + // so the next displayBuffer() should not run grayscaleRevert(). + void clearGrayscaleModeFlag() { einkDisplay.clearGrayscaleModeFlag(); } + // Runtime geometry passthrough uint16_t getDisplayWidth() const; uint16_t getDisplayHeight() const; diff --git a/open-x4-sdk b/open-x4-sdk index e9dbce287c..ccfd37be5d 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit e9dbce287c6618220b184cb2849ddc438c5b9fdb +Subproject commit ccfd37be5d0ee1ce8eef1c389db3941b1c1b61b3 diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 54a9fdf257..d020bcce25 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -819,12 +819,10 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or const auto tGrayEnd = millis(); fcm->logStats(useFactoryGray ? "gray_factory_quality" : "gray"); + // restoreBwBuffer() copies the saved BW frame back AND rebases RED RAM + // via cleanupGrayscaleBuffers, which is sufficient cleanup for both + // differential and factory paths — no extra RED RAM write needed. renderer.restoreBwBuffer(); - if (useFactoryGray) { - // Factory LUT leaves RED RAM in gray-encoded state; sync controller to the - // restored BW framebuffer so subsequent BW page turns render cleanly. - renderer.cleanupGrayscaleWithFrameBuffer(); - } const auto tBwRestore = millis(); const auto tEnd = millis(); @@ -852,14 +850,14 @@ void EpubReaderActivity::onScreenshotRequest() { auto p = section->loadPageFromSectionFile(); if (!p) return; - // Preserve the BW page across the gray render so cleanupGrayscaleWithFrameBuffer - // syncs the controller to the actual page, not a cleared framebuffer. + // Preserve the BW page across the gray render so restoreBwBuffer's + // cleanupGrayscaleBuffers call rebases RED RAM to the actual page, not a + // cleared framebuffer. if (!renderer.storeBwBuffer()) return; PageRenderCtx grayCtx{p.get(), SETTINGS.getReaderFontId(), lastFactoryMarginLeft, lastFactoryMarginTop, this}; renderer.renderGrayscale(GfxRenderer::GrayscaleMode::FactoryQuality, &renderPageCallback, &grayCtx); renderer.restoreBwBuffer(); - renderer.cleanupGrayscaleWithFrameBuffer(); } void EpubReaderActivity::renderStatusBar() const { From 368b83bbd88c875ce2a1046422cc8ea2ca1b5abd Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Sat, 9 May 2026 22:36:09 +0200 Subject: [PATCH 42/57] feat: PXC viewer sibling navigation and set-sleep-cover Mirrors the BMP viewer feature set for .pxc files: Up/Down navigates siblings in the current folder, Confirm copies the current image to /sleep.pxc and switches sleepScreen to CUSTOM. --- src/activities/util/PxcViewerActivity.cpp | 110 +++++++++++++++++++++- src/activities/util/PxcViewerActivity.h | 6 ++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/activities/util/PxcViewerActivity.cpp b/src/activities/util/PxcViewerActivity.cpp index ccec33f7d7..d397b0cb88 100644 --- a/src/activities/util/PxcViewerActivity.cpp +++ b/src/activities/util/PxcViewerActivity.cpp @@ -1,10 +1,12 @@ #include "PxcViewerActivity.h" +#include #include #include #include #include +#include "CrossPointSettings.h" #include "Epub/converters/DirectPixelWriter.h" #include "components/UITheme.h" #include "fontIds.h" @@ -61,8 +63,49 @@ void pxcLoadingOverlay(const GfxRenderer& r, const void*) { PxcViewerActivity::PxcViewerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string path) : Activity("PxcViewer", renderer, mappedInput), filePath(std::move(path)) {} +void PxcViewerActivity::loadSiblingImages() { + siblingImages.clear(); + currentImageIndex = -1; + + if (filePath.empty()) return; + + std::string dirPath = FsHelpers::extractFolderPath(filePath); + size_t lastSlash = filePath.find_last_of('/'); + std::string fileName = (lastSlash != std::string::npos) ? filePath.substr(lastSlash + 1) : filePath; + + auto dir = Storage.open(dirPath.c_str()); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + return; + } + + char name[500]; + for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { + if (!file.isDirectory()) { + file.getName(name, sizeof(name)); + if (name[0] != '.') { + std::string fname(name); + if (FsHelpers::hasPxcExtension(fname)) { + siblingImages.push_back(fname); + } + } + } + file.close(); + } + dir.close(); + + FsHelpers::sortFileList(siblingImages); + + for (size_t i = 0; i < siblingImages.size(); ++i) { + if (siblingImages[i] == fileName) { + currentImageIndex = static_cast(i); + break; + } + } +} + void PxcViewerActivity::renderPxcToFramebuffer(FsFile& file, uint16_t width, uint16_t height, uint32_t dataOffset) { - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), "", ""); PxcCtx ctx{&file, dataOffset, width, height, labels}; renderer.renderGrayscaleSinglePass(GfxRenderer::GrayscaleMode::FactoryQuality, &pxcRenderCallback, &ctx, &pxcLoadingOverlay, nullptr); @@ -71,6 +114,10 @@ void PxcViewerActivity::renderPxcToFramebuffer(FsFile& file, uint16_t width, uin void PxcViewerActivity::onEnter() { Activity::onEnter(); + if (siblingImages.empty() && !filePath.empty()) { + loadSiblingImages(); + } + const int screenWidth = renderer.getScreenWidth(); const int screenHeight = renderer.getScreenHeight(); FsFile file; @@ -152,6 +199,39 @@ void PxcViewerActivity::onExit() { renderer.displayBuffer(HalDisplay::HALF_REFRESH); } +void PxcViewerActivity::doSetSleepCover() { + GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + + bool success = false; + FsFile inFile, outFile; + if (Storage.openFileForRead("PXC", filePath, inFile)) { + if (Storage.openFileForWrite("PXC", "/sleep.pxc", outFile)) { + char buffer[2048]; + int bytesRead; + success = true; + while ((bytesRead = inFile.read(buffer, sizeof(buffer))) > 0) { + if (outFile.write(buffer, bytesRead) != bytesRead) { + success = false; + break; + } + } + outFile.close(); + } + inFile.close(); + } + + if (success) { + SETTINGS.sleepScreen = CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM; + SETTINGS.saveToFile(); + GUI.drawPopup(renderer, tr(STR_DONE)); + } else { + GUI.drawPopup(renderer, tr(STR_FAILED_LOWER)); + } + + delay(1000); + onEnter(); +} + void PxcViewerActivity::loop() { Activity::loop(); @@ -159,4 +239,32 @@ void PxcViewerActivity::loop() { activityManager.goToFileBrowser(filePath); return; } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + doSetSleepCover(); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + if (siblingImages.size() > 1 && currentImageIndex > 0) { + currentImageIndex--; + std::string dirPath = FsHelpers::extractFolderPath(filePath); + if (dirPath.back() != '/') dirPath += "/"; + filePath = dirPath + siblingImages[currentImageIndex]; + onEnter(); + } + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + if (siblingImages.size() > 1 && currentImageIndex != -1 && + currentImageIndex < static_cast(siblingImages.size()) - 1) { + currentImageIndex++; + std::string dirPath = FsHelpers::extractFolderPath(filePath); + if (dirPath.back() != '/') dirPath += "/"; + filePath = dirPath + siblingImages[currentImageIndex]; + onEnter(); + } + return; + } } diff --git a/src/activities/util/PxcViewerActivity.h b/src/activities/util/PxcViewerActivity.h index 377a2936b8..c10357ab39 100644 --- a/src/activities/util/PxcViewerActivity.h +++ b/src/activities/util/PxcViewerActivity.h @@ -4,6 +4,7 @@ #include #include +#include #include "../Activity.h" #include "MappedInputManager.h" @@ -19,6 +20,11 @@ class PxcViewerActivity final : public Activity { private: std::string filePath; + std::vector siblingImages; + int currentImageIndex = -1; + + void loadSiblingImages(); + void doSetSleepCover(); void renderGrayscaleImage(); void renderPxcToFramebuffer(FsFile& file, uint16_t width, uint16_t height, uint32_t dataOffset); }; From 783b3c0c3376ee46f46a0031dc6435bd4bf0cb27 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Tue, 12 May 2026 22:45:02 +0200 Subject: [PATCH 43/57] add 2-bit XTC render quality setting (Speed/Quality) Exposes the SSD1677 factory LUT trade-off for XTC reading: Speed uses lut_factory_fast (default), Quality uses lut_factory_quality. Selectable under Settings -> Reader as "2-bit XTC". Stored as xtcRenderQuality in the JSON settings file. displayXtchPlanes() gains an optional GrayscaleMode parameter (default FactoryFast) and selects the LUT at the displayGrayBuffer call. XtcReaderActivity reads SETTINGS.xtcRenderQuality at the call site to pick FactoryFast or FactoryQuality. --- lib/GfxRenderer/GfxRenderer.cpp | 6 ++++-- lib/GfxRenderer/GfxRenderer.h | 4 +++- lib/I18n/translations/english.yaml | 3 +++ src/CrossPointSettings.h | 2 ++ src/SettingsList.h | 2 ++ src/activities/reader/XtcReaderActivity.cpp | 5 ++++- 6 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index f12b48ff42..17b5fad25a 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1575,7 +1575,8 @@ void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn) } void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, const uint16_t pageWidth, - const uint16_t pageHeight, RenderHook overlayFn, const void* overlayCtx) { + const uint16_t pageHeight, RenderHook overlayFn, const void* overlayCtx, + GrayscaleMode mode) { const size_t colBytes = (pageHeight + 7) / 8; const uint16_t fbStride = panelWidthBytes; @@ -1626,7 +1627,8 @@ void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2 screenshotHookCtx = nullptr; } - displayGrayBuffer(lut_factory_quality, true); + const unsigned char* lut = (mode == GrayscaleMode::FactoryQuality) ? lut_factory_quality : lut_factory_fast; + displayGrayBuffer(lut, true); setRenderMode(BW); } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e14f61d28c..7820b8c01c 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -229,8 +229,10 @@ class GfxRenderer { // Direct 2-bit XTCH plane blit using factory LUT. Caller supplies the two decoded bit planes // (plane1 = BW RAM / LSB, plane2 = RED RAM / MSB) in column-major order matching XTCH encoding. // Handles pre-flash, both RAM writes, factory LUT fire, and BW controller sync internally. + // mode selects the factory LUT: FactoryFast (default) or FactoryQuality; Differential is invalid here. void displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, uint16_t pageWidth, uint16_t pageHeight, - RenderHook overlayFn = nullptr, const void* overlayCtx = nullptr); + RenderHook overlayFn = nullptr, const void* overlayCtx = nullptr, + GrayscaleMode mode = GrayscaleMode::FactoryFast); // 1-bit XTC page via the same grayscale LUT pipeline. Row-major pageBuffer (XTC: 0=black, 1=white). // BW and RED RAM receive identical data since there are no intermediate gray levels. diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 969af61cb5..4e235886ab 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -230,6 +230,9 @@ STR_PREVIEW: "Preview" STR_TITLE: "Title" STR_BATTERY: "Battery" STR_XTC_STATUS_BAR: "XTC Status Bar" +STR_XTC_RENDER_QUALITY: "2-bit XTC" +STR_SPEED: "Speed" +STR_QUALITY: "Quality" STR_BOTTOM: "Bottom" STR_TOP: "Top" STR_UI_THEME: "UI Theme" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index bd6a850209..7751c66672 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -63,6 +63,7 @@ class CrossPointSettings { XTC_STATUS_BAR_TOP = 2, XTC_STATUS_BAR_MODE_COUNT }; + enum XTC_RENDER_QUALITY { XTC_RENDER_FAST = 0, XTC_RENDER_QUALITY_HIGH = 1, XTC_RENDER_QUALITY_COUNT }; enum ORIENTATION { PORTRAIT = 0, // 480x800 logical coordinates (current default) @@ -169,6 +170,7 @@ class CrossPointSettings { uint8_t statusBarTitle = CHAPTER_TITLE; uint8_t statusBarBattery = 1; uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE; + uint8_t xtcRenderQuality = XTC_RENDER_FAST; // Text rendering settings uint8_t extraParagraphSpacing = 1; uint8_t textAntiAliasing = 1; diff --git a/src/SettingsList.h b/src/SettingsList.h index 9af7101eaf..69d0c8d993 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -157,6 +157,8 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering, {StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS}, "imageRendering", StrId::STR_CAT_READER), + SettingInfo::Enum(StrId::STR_XTC_RENDER_QUALITY, &CrossPointSettings::xtcRenderQuality, + {StrId::STR_SPEED, StrId::STR_QUALITY}, "xtcRenderQuality", StrId::STR_CAT_READER), // --- Controls --- SettingInfo::Enum(StrId::STR_SIDE_BTN_LAYOUT, &CrossPointSettings::sideButtonLayout, {StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV}, "sideButtonLayout", StrId::STR_CAT_CONTROLS), diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 254195f739..92649a581a 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -268,8 +268,11 @@ void XtcReaderActivity::renderPage() { renderer.displayBuffer(HalDisplay::FULL_REFRESH); } + const auto xtcGrayMode = SETTINGS.xtcRenderQuality == CrossPointSettings::XTC_RENDER_QUALITY_HIGH + ? GfxRenderer::GrayscaleMode::FactoryQuality + : GfxRenderer::GrayscaleMode::FactoryFast; renderer.displayXtchPlanes(plane1, plane2, pageWidth, pageHeight, - &XtcReaderActivity::renderStatusBarOverlayCallback, this); + &XtcReaderActivity::renderStatusBarOverlayCallback, this, xtcGrayMode); free(plane1); free(plane2); From 4bc9dd2eb1633ee436607d9f639c4d486c7a1b40 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Tue, 12 May 2026 22:45:33 +0200 Subject: [PATCH 44/57] add prev/next labels to BMP/PXC viewers + Left/Right bindings Ports upstream #1852 prev/next arrow labels onto BmpViewer and mirrors the same behavior on PxcViewer for parity with the recently-added sibling navigation. Both viewers now show '<' / '>' next to BACK and SET_SLEEP_COVER when adjacent siblings exist, and accept Left/Right alongside Up/Down for sibling navigation. PxcViewer's renderPxcToFramebuffer() gains hasPrevious/hasNext bools so the interactive onEnter path receives arrow labels while the screenshot path passes false/false (matches BMP screenshot behavior). --- src/activities/util/BmpViewerActivity.cpp | 13 ++++++++++--- src/activities/util/PxcViewerActivity.cpp | 19 +++++++++++++------ src/activities/util/PxcViewerActivity.h | 3 ++- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index c131c490d1..fb062fdd6f 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -97,7 +97,12 @@ void BmpViewerActivity::onEnter() { y = (pageHeight - bitmap.getHeight()) / 2; } - const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), "", ""); + bool hasPrevious = (siblingImages.size() > 1 && currentImageIndex > 0); + bool hasNext = (siblingImages.size() > 1 && currentImageIndex != -1 && + currentImageIndex < static_cast(siblingImages.size()) - 1); + + const auto labels = + mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), (hasPrevious ? "<" : ""), (hasNext ? ">" : "")); if (bitmap.hasGreyscale()) { struct BmpGrayCtx { @@ -279,7 +284,8 @@ void BmpViewerActivity::loop() { return; } - if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + if (mappedInput.wasReleased(MappedInputManager::Button::Left) || + mappedInput.wasReleased(MappedInputManager::Button::Up)) { if (siblingImages.size() > 1 && currentImageIndex > 0) { currentImageIndex--; std::string dirPath = FsHelpers::extractFolderPath(filePath); @@ -290,7 +296,8 @@ void BmpViewerActivity::loop() { return; } - if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + if (mappedInput.wasReleased(MappedInputManager::Button::Right) || + mappedInput.wasReleased(MappedInputManager::Button::Down)) { if (siblingImages.size() > 1 && currentImageIndex != -1 && currentImageIndex < static_cast(siblingImages.size()) - 1) { currentImageIndex++; diff --git a/src/activities/util/PxcViewerActivity.cpp b/src/activities/util/PxcViewerActivity.cpp index d397b0cb88..1d60a502f8 100644 --- a/src/activities/util/PxcViewerActivity.cpp +++ b/src/activities/util/PxcViewerActivity.cpp @@ -104,8 +104,10 @@ void PxcViewerActivity::loadSiblingImages() { } } -void PxcViewerActivity::renderPxcToFramebuffer(FsFile& file, uint16_t width, uint16_t height, uint32_t dataOffset) { - const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), "", ""); +void PxcViewerActivity::renderPxcToFramebuffer(FsFile& file, uint16_t width, uint16_t height, uint32_t dataOffset, + bool hasPrevious, bool hasNext) { + const auto labels = + mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), (hasPrevious ? "<" : ""), (hasNext ? ">" : "")); PxcCtx ctx{&file, dataOffset, width, height, labels}; renderer.renderGrayscaleSinglePass(GfxRenderer::GrayscaleMode::FactoryQuality, &pxcRenderCallback, &ctx, &pxcLoadingOverlay, nullptr); @@ -154,7 +156,10 @@ void PxcViewerActivity::onEnter() { } const uint32_t dataOffset = file.position(); - renderPxcToFramebuffer(file, pxcWidth, pxcHeight, dataOffset); + const bool hasPrevious = (siblingImages.size() > 1 && currentImageIndex > 0); + const bool hasNext = (siblingImages.size() > 1 && currentImageIndex != -1 && + currentImageIndex < static_cast(siblingImages.size()) - 1); + renderPxcToFramebuffer(file, pxcWidth, pxcHeight, dataOffset, hasPrevious, hasNext); file.close(); @@ -182,7 +187,7 @@ void PxcViewerActivity::renderGrayscaleImage() { } const uint32_t dataOffset = file.position(); - renderPxcToFramebuffer(file, pxcWidth, pxcHeight, dataOffset); + renderPxcToFramebuffer(file, pxcWidth, pxcHeight, dataOffset, false, false); file.close(); } @@ -245,7 +250,8 @@ void PxcViewerActivity::loop() { return; } - if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + if (mappedInput.wasReleased(MappedInputManager::Button::Left) || + mappedInput.wasReleased(MappedInputManager::Button::Up)) { if (siblingImages.size() > 1 && currentImageIndex > 0) { currentImageIndex--; std::string dirPath = FsHelpers::extractFolderPath(filePath); @@ -256,7 +262,8 @@ void PxcViewerActivity::loop() { return; } - if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + if (mappedInput.wasReleased(MappedInputManager::Button::Right) || + mappedInput.wasReleased(MappedInputManager::Button::Down)) { if (siblingImages.size() > 1 && currentImageIndex != -1 && currentImageIndex < static_cast(siblingImages.size()) - 1) { currentImageIndex++; diff --git a/src/activities/util/PxcViewerActivity.h b/src/activities/util/PxcViewerActivity.h index c10357ab39..5df19fc509 100644 --- a/src/activities/util/PxcViewerActivity.h +++ b/src/activities/util/PxcViewerActivity.h @@ -26,5 +26,6 @@ class PxcViewerActivity final : public Activity { void loadSiblingImages(); void doSetSleepCover(); void renderGrayscaleImage(); - void renderPxcToFramebuffer(FsFile& file, uint16_t width, uint16_t height, uint32_t dataOffset); + void renderPxcToFramebuffer(FsFile& file, uint16_t width, uint16_t height, uint32_t dataOffset, bool hasPrevious, + bool hasNext); }; From f22edb4a343d77ae922fe88531d36871f4f33362 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Wed, 13 May 2026 13:48:38 +0200 Subject: [PATCH 45/57] fix: clean sleep and XTC transitions --- src/activities/boot_sleep/SleepActivity.cpp | 21 ++------------------- src/activities/reader/XtcReaderActivity.cpp | 10 ++++++++-- src/activities/reader/XtcReaderActivity.h | 1 + 3 files changed, 11 insertions(+), 21 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index f84b8e28a1..c273ffc5d5 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -13,28 +13,11 @@ #include "CrossPointState.h" #include "Epub/converters/DirectPixelWriter.h" #include "activities/reader/ReaderUtils.h" -#include "components/UITheme.h" #include "fontIds.h" #include "images/Logo120.h" -namespace { -void drawEnteringSleepOverlay(const GfxRenderer& r, const void*) { - constexpr int margin = 15; - const char* msg = tr(STR_ENTERING_SLEEP); - const int y = static_cast(r.getScreenHeight() * 0.075f); - const int textWidth = r.getTextWidth(UI_12_FONT_ID, msg, EpdFontFamily::BOLD); - const int w = textWidth + margin * 2; - const int h = r.getLineHeight(UI_12_FONT_ID) + margin * 2; - const int x = (r.getScreenWidth() - w) / 2; - r.fillRect(x - 2, y - 2, w + 4, h + 4, true); - r.fillRect(x, y, w, h, false); - r.drawText(UI_12_FONT_ID, x + margin, y + margin - 2, msg, true, EpdFontFamily::BOLD); -} -} // namespace - void SleepActivity::onEnter() { Activity::onEnter(); - GUI.drawPopup(renderer, tr(STR_ENTERING_SLEEP)); if (APP_STATE.lastSleepFromReader) { ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); @@ -279,7 +262,7 @@ bool SleepActivity::renderPxcSleepScreen(const std::string& path) const { } free(rowBuf); }, - &ctx, &drawEnteringSleepOverlay, nullptr); + &ctx); file.close(); return true; @@ -343,7 +326,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { const auto* c = static_cast(raw); r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, c->cropX, c->cropY); }, - &grayCtx, &drawEnteringSleepOverlay, nullptr); + &grayCtx); } else { renderer.clearScreen(); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 92649a581a..ac8d1d3da9 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -65,6 +65,7 @@ void XtcReaderActivity::loop() { [this](const ActivityResult& result) { if (!result.isCancelled) { currentPage = std::get(result.data).page; + halfRefreshBeforeNextPage = xtc && xtc->getBitDepth() == 2; } }); } @@ -261,8 +262,13 @@ void XtcReaderActivity::renderPage() { return; } - // Periodic FULL_REFRESH resets DC balance; every 32 pages. - if (++pagesSinceClean >= 32) { + if (halfRefreshBeforeNextPage) { + halfRefreshBeforeNextPage = false; + pagesSinceClean = 0; + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + } else if (++pagesSinceClean >= 32) { + // Periodic FULL_REFRESH resets DC balance; every 32 pages. pagesSinceClean = 0; renderer.clearScreen(); renderer.displayBuffer(HalDisplay::FULL_REFRESH); diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index af2da44fd1..734d4065e0 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -20,6 +20,7 @@ class XtcReaderActivity final : public Activity { uint32_t currentPage = 0; int pagesUntilFullRefresh = 0; uint32_t pagesSinceClean = 0; + bool halfRefreshBeforeNextPage = false; enum class StatusBarOverlayPosition { Bottom, Top }; struct StatusBarInfo { From 53a86f5929d371b8eb3096ff309bf20680a3bee4 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Wed, 13 May 2026 18:40:03 +0200 Subject: [PATCH 46/57] chore: point SDK to combined grayscale fixes --- open-x4-sdk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-x4-sdk b/open-x4-sdk index ccfd37be5d..b1c1f01e4c 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit ccfd37be5d0ee1ce8eef1c389db3941b1c1b61b3 +Subproject commit b1c1f01e4c151152d87e4b64d7fbfd958115eecc From f687b036dcfda56dd6f66549c2e418e863297b83 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Wed, 13 May 2026 19:59:58 +0200 Subject: [PATCH 47/57] fix: clean factory grayscale transitions --- lib/GfxRenderer/GfxRenderer.cpp | 16 +++++++++------- lib/hal/HalDisplay.cpp | 2 +- lib/hal/HalDisplay.h | 2 +- open-x4-sdk | 2 +- src/main.cpp | 4 +++- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 5f2b930af7..dd21bef310 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1136,11 +1136,13 @@ void GfxRenderer::invertScreen() const { void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const { auto elapsed = millis() - start_ms; LOG_DBG("GFX", "Time = %lu ms from clearScreen to displayBuffer", elapsed); - // After a factory LUT render the display already powered down (0xC7 sequence). - // Requesting turnOffScreen=true here would immediately power on then off again, - // adding a full power cycle. Skip the power-down for this one transition. - const bool turnOff = (displayState == DisplayState::FactoryLut) ? false : fadingFix; - display.displayBuffer(refreshMode, turnOff); + // After a factory LUT render, RED RAM still contains the grayscale MSB plane. + // Promote the first normal FAST refresh to HALF so both RAM banks are rebased + // before differential updates resume. + const bool afterFactoryLut = displayState == DisplayState::FactoryLut; + const auto effectiveRefreshMode = + afterFactoryLut && refreshMode == HalDisplay::FAST_REFRESH ? HalDisplay::HALF_REFRESH : refreshMode; + display.displayBuffer(effectiveRefreshMode, fadingFix); displayState = DisplayState::BW; } @@ -1512,8 +1514,8 @@ void GfxRenderer::renderGrayscale(GrayscaleMode mode, void (*renderFn)(const Gfx displayGrayBuffer(lut, factoryMode); // Suppress the SDK's automatic grayscaleRevert on the next BW page turn. - // Caller is responsible for cleanup (restoreBwBuffer rebases RED RAM and - // the next FAST_REFRESH drives pixels back to clean BW). + // Caller is responsible for cleanup: restoreBwBuffer rebases RED RAM, and + // displayBuffer promotes the first post-factory FAST refresh to HALF. display.clearGrayscaleModeFlag(); setRenderMode(BW); } diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index 4f1ec764f4..ff479e3cfb 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -66,7 +66,7 @@ void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen); } -void HalDisplay::deepSleep() { einkDisplay.deepSleep(); } +void HalDisplay::deepSleep(const bool powerDownDisplay) { einkDisplay.deepSleep(powerDownDisplay); } uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); } diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index b39d6b10e6..9d4f45d320 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -37,7 +37,7 @@ class HalDisplay { void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); // Power management - void deepSleep(); + void deepSleep(bool powerDownDisplay = true); // Access to frame buffer uint8_t* getFrameBuffer() const; diff --git a/open-x4-sdk b/open-x4-sdk index b1c1f01e4c..9fe173e2cd 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit b1c1f01e4c151152d87e4b64d7fbfd958115eecc +Subproject commit 9fe173e2cd5d646341f15c6419caa4b0cc92a243 diff --git a/src/main.cpp b/src/main.cpp index 4fe44a0035..83382738b3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -191,7 +191,9 @@ void enterDeepSleep() { activityManager.goToSleep(); halTiltSensor.deepSleep(); - display.deepSleep(); + const bool preserveFactoryLutSleepScreen = + !gpio.deviceIsX3() && renderer.getDisplayState() == GfxRenderer::DisplayState::FactoryLut; + display.deepSleep(!preserveFactoryLutSleepScreen); LOG_DBG("MAIN", "Entering deep sleep"); powerManager.startDeepSleep(gpio); From 30baf8fb48b707b140459a0b717109cc61f8b83a Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Wed, 13 May 2026 20:13:45 +0200 Subject: [PATCH 48/57] fix: full preflash grayscale sleep screens --- lib/GfxRenderer/GfxRenderer.cpp | 13 ++++++++++--- lib/GfxRenderer/GfxRenderer.h | 12 ++++++++---- src/activities/boot_sleep/SleepActivity.cpp | 11 ++++------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index dd21bef310..6536cc818b 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1522,11 +1522,12 @@ void GfxRenderer::renderGrayscale(GrayscaleMode mode, void (*renderFn)(const Gfx void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn)(const GfxRenderer&, const void*), const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*), - const void* preFlashCtx) { + const void* preFlashCtx, + const HalDisplay::RefreshMode preFlashRefreshMode) { if (mode == GrayscaleMode::FactoryFast || mode == GrayscaleMode::FactoryQuality) { clearScreen(); if (preFlashOverlayFn) preFlashOverlayFn(*this, preFlashCtx); - displayBuffer(HalDisplay::HALF_REFRESH); + displayBuffer(preFlashRefreshMode); } const RenderMode lsbMode = (mode == GrayscaleMode::Differential) ? GRAYSCALE_LSB : GRAY2_LSB; @@ -1593,7 +1594,8 @@ void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn) void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, const uint16_t pageWidth, const uint16_t pageHeight, RenderHook overlayFn, const void* overlayCtx, - GrayscaleMode mode) { + GrayscaleMode mode, const bool preFlash, + const HalDisplay::RefreshMode preFlashRefreshMode) { const size_t colBytes = (pageHeight + 7) / 8; const uint16_t fbStride = panelWidthBytes; @@ -1609,6 +1611,11 @@ void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2 return; } + if (preFlash) { + clearScreen(); + displayBuffer(preFlashRefreshMode); + } + // Pass 1: plane1 (MSB) → BW RAM via copyGrayscaleLsbBuffers. clearScreen(0x00); for (uint16_t c = 0; c < pageWidth; c++) { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 7820b8c01c..29ee7257cb 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -221,18 +221,22 @@ class GfxRenderer { const void* preFlashCtx = nullptr); // Single-pass variant: calls renderFn once in GRAY2_LSB mode while simultaneously writing // the MSB plane to a secondary buffer. Cuts SD card reads from 2 to 1 for file-backed renders. - // Falls back to two-pass on secondary buffer allocation failure. + // Falls back to two-pass on secondary buffer allocation failure. Factory modes pre-flash to + // white with HALF_REFRESH by default; sleep screens can request FULL_REFRESH for a stronger + // temperature-aware conditioning pass before the final static image. void renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn)(const GfxRenderer&, const void*), const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*) = nullptr, - const void* preFlashCtx = nullptr); + const void* preFlashCtx = nullptr, + HalDisplay::RefreshMode preFlashRefreshMode = HalDisplay::HALF_REFRESH); // Direct 2-bit XTCH plane blit using factory LUT. Caller supplies the two decoded bit planes // (plane1 = BW RAM / LSB, plane2 = RED RAM / MSB) in column-major order matching XTCH encoding. - // Handles pre-flash, both RAM writes, factory LUT fire, and BW controller sync internally. + // Handles optional pre-flash, both RAM writes, factory LUT fire, and BW controller sync internally. // mode selects the factory LUT: FactoryFast (default) or FactoryQuality; Differential is invalid here. void displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, uint16_t pageWidth, uint16_t pageHeight, RenderHook overlayFn = nullptr, const void* overlayCtx = nullptr, - GrayscaleMode mode = GrayscaleMode::FactoryFast); + GrayscaleMode mode = GrayscaleMode::FactoryFast, bool preFlash = false, + HalDisplay::RefreshMode preFlashRefreshMode = HalDisplay::HALF_REFRESH); // 1-bit XTC page via the same grayscale LUT pipeline. Row-major pageBuffer (XTC: 0=black, 1=white). // BW and RED RAM receive identical data since there are no intermediate gray levels. diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index c273ffc5d5..7594d06c85 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -262,7 +262,7 @@ bool SleepActivity::renderPxcSleepScreen(const std::string& path) const { } free(rowBuf); }, - &ctx); + &ctx, nullptr, nullptr, HalDisplay::FULL_REFRESH); file.close(); return true; @@ -326,7 +326,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { const auto* c = static_cast(raw); r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, c->cropX, c->cropY); }, - &grayCtx); + &grayCtx, nullptr, nullptr, HalDisplay::FULL_REFRESH); } else { renderer.clearScreen(); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); @@ -393,11 +393,8 @@ void SleepActivity::renderCoverSleepScreen() const { } LOG_DBG("SLP", "Direct XTCH plane render: %ux%u", lastXtc.getPageWidth(), lastXtc.getPageHeight()); - if (!APP_STATE.lastSleepFromReader) { - renderer.clearScreen(); - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - } - renderer.displayXtchPlanes(plane1, plane2, lastXtc.getPageWidth(), lastXtc.getPageHeight()); + renderer.displayXtchPlanes(plane1, plane2, lastXtc.getPageWidth(), lastXtc.getPageHeight(), nullptr, nullptr, + GfxRenderer::GrayscaleMode::FactoryQuality, true, HalDisplay::FULL_REFRESH); free(plane1); free(plane2); return; From c0a6a2a02372ca1da9c70d186515122dd79219f4 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Thu, 14 May 2026 11:41:29 +0200 Subject: [PATCH 49/57] fix: use SDK factory gray sleep handling --- open-x4-sdk | 2 +- src/main.cpp | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/open-x4-sdk b/open-x4-sdk index 9fe173e2cd..af33a42633 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit 9fe173e2cd5d646341f15c6419caa4b0cc92a243 +Subproject commit af33a42633404b113405a4be8bc6b56fdc3dfbea diff --git a/src/main.cpp b/src/main.cpp index 83382738b3..4fe44a0035 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -191,9 +191,7 @@ void enterDeepSleep() { activityManager.goToSleep(); halTiltSensor.deepSleep(); - const bool preserveFactoryLutSleepScreen = - !gpio.deviceIsX3() && renderer.getDisplayState() == GfxRenderer::DisplayState::FactoryLut; - display.deepSleep(!preserveFactoryLutSleepScreen); + display.deepSleep(); LOG_DBG("MAIN", "Entering deep sleep"); powerManager.startDeepSleep(gpio); From 38bcba435a708f2a4afe4ef7d530ea8703135160 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Fri, 15 May 2026 22:12:47 +0200 Subject: [PATCH 50/57] fix: stabilize factory gray sleep screens --- lib/GfxRenderer/GfxRenderer.cpp | 24 +++++++++------- lib/GfxRenderer/GfxRenderer.h | 10 ++++--- open-x4-sdk | 2 +- src/activities/boot_sleep/SleepActivity.cpp | 31 +++++++++++++++++++-- 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 6536cc818b..d1f7340863 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1522,12 +1522,14 @@ void GfxRenderer::renderGrayscale(GrayscaleMode mode, void (*renderFn)(const Gfx void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn)(const GfxRenderer&, const void*), const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*), - const void* preFlashCtx, - const HalDisplay::RefreshMode preFlashRefreshMode) { - if (mode == GrayscaleMode::FactoryFast || mode == GrayscaleMode::FactoryQuality) { - clearScreen(); - if (preFlashOverlayFn) preFlashOverlayFn(*this, preFlashCtx); - displayBuffer(preFlashRefreshMode); + const void* preFlashCtx, const HalDisplay::RefreshMode preFlashRefreshMode, + const uint8_t preFlashPasses) { + if ((mode == GrayscaleMode::FactoryFast || mode == GrayscaleMode::FactoryQuality) && preFlashPasses > 0) { + for (uint8_t pass = 0; pass < preFlashPasses; pass++) { + clearScreen(); + if (pass == 0 && preFlashOverlayFn) preFlashOverlayFn(*this, preFlashCtx); + displayBuffer(preFlashRefreshMode); + } } const RenderMode lsbMode = (mode == GrayscaleMode::Differential) ? GRAYSCALE_LSB : GRAY2_LSB; @@ -1595,7 +1597,7 @@ void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn) void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, const uint16_t pageWidth, const uint16_t pageHeight, RenderHook overlayFn, const void* overlayCtx, GrayscaleMode mode, const bool preFlash, - const HalDisplay::RefreshMode preFlashRefreshMode) { + const HalDisplay::RefreshMode preFlashRefreshMode, const uint8_t preFlashPasses) { const size_t colBytes = (pageHeight + 7) / 8; const uint16_t fbStride = panelWidthBytes; @@ -1611,9 +1613,11 @@ void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2 return; } - if (preFlash) { - clearScreen(); - displayBuffer(preFlashRefreshMode); + if (preFlash && preFlashPasses > 0) { + for (uint8_t pass = 0; pass < preFlashPasses; pass++) { + clearScreen(); + displayBuffer(preFlashRefreshMode); + } } // Pass 1: plane1 (MSB) → BW RAM via copyGrayscaleLsbBuffers. diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 29ee7257cb..9ba80751f6 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -222,12 +222,13 @@ class GfxRenderer { // Single-pass variant: calls renderFn once in GRAY2_LSB mode while simultaneously writing // the MSB plane to a secondary buffer. Cuts SD card reads from 2 to 1 for file-backed renders. // Falls back to two-pass on secondary buffer allocation failure. Factory modes pre-flash to - // white with HALF_REFRESH by default; sleep screens can request FULL_REFRESH for a stronger - // temperature-aware conditioning pass before the final static image. + // white with HALF_REFRESH by default. preFlashPasses=0 skips the internal pre-flash so + // callers can run a custom conditioning sequence first. void renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn)(const GfxRenderer&, const void*), const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*) = nullptr, const void* preFlashCtx = nullptr, - HalDisplay::RefreshMode preFlashRefreshMode = HalDisplay::HALF_REFRESH); + HalDisplay::RefreshMode preFlashRefreshMode = HalDisplay::HALF_REFRESH, + uint8_t preFlashPasses = 1); // Direct 2-bit XTCH plane blit using factory LUT. Caller supplies the two decoded bit planes // (plane1 = BW RAM / LSB, plane2 = RED RAM / MSB) in column-major order matching XTCH encoding. @@ -236,7 +237,8 @@ class GfxRenderer { void displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, uint16_t pageWidth, uint16_t pageHeight, RenderHook overlayFn = nullptr, const void* overlayCtx = nullptr, GrayscaleMode mode = GrayscaleMode::FactoryFast, bool preFlash = false, - HalDisplay::RefreshMode preFlashRefreshMode = HalDisplay::HALF_REFRESH); + HalDisplay::RefreshMode preFlashRefreshMode = HalDisplay::HALF_REFRESH, + uint8_t preFlashPasses = 1); // 1-bit XTC page via the same grayscale LUT pipeline. Row-major pageBuffer (XTC: 0=black, 1=white). // BW and RED RAM receive identical data since there are no intermediate gray levels. diff --git a/open-x4-sdk b/open-x4-sdk index af33a42633..d3275d2d96 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit af33a42633404b113405a4be8bc6b56fdc3dfbea +Subproject commit d3275d2d961531161bf08bb9c1d6f78708688aa7 diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 7594d06c85..0fcf6222ba 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -16,6 +16,27 @@ #include "fontIds.h" #include "images/Logo120.h" +namespace { +constexpr uint8_t SLEEP_FACTORY_INTERNAL_PREFLASH_PASSES = 0; + +struct FactorySleepPreconditionPass { + uint8_t color; + HalDisplay::RefreshMode refreshMode; +}; + +constexpr FactorySleepPreconditionPass FACTORY_SLEEP_PRECONDITION[] = { + {0x00, HalDisplay::FULL_REFRESH}, + {0xFF, HalDisplay::FULL_REFRESH}, +}; + +void runFactorySleepPrecondition(const GfxRenderer& renderer) { + for (const auto& pass : FACTORY_SLEEP_PRECONDITION) { + renderer.clearScreen(pass.color); + renderer.displayBuffer(pass.refreshMode); + } +} +} + void SleepActivity::onEnter() { Activity::onEnter(); @@ -236,6 +257,7 @@ bool SleepActivity::renderPxcSleepScreen(const std::string& path) const { }; PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight}; + runFactorySleepPrecondition(renderer); renderer.renderGrayscaleSinglePass( GfxRenderer::GrayscaleMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { @@ -262,7 +284,7 @@ bool SleepActivity::renderPxcSleepScreen(const std::string& path) const { } free(rowBuf); }, - &ctx, nullptr, nullptr, HalDisplay::FULL_REFRESH); + &ctx, nullptr, nullptr, HalDisplay::FULL_REFRESH, SLEEP_FACTORY_INTERNAL_PREFLASH_PASSES); file.close(); return true; @@ -320,13 +342,14 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { float cropX, cropY; }; BitmapGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, cropX, cropY}; + runFactorySleepPrecondition(renderer); renderer.renderGrayscaleSinglePass( GfxRenderer::GrayscaleMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { const auto* c = static_cast(raw); r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, c->cropX, c->cropY); }, - &grayCtx, nullptr, nullptr, HalDisplay::FULL_REFRESH); + &grayCtx, nullptr, nullptr, HalDisplay::FULL_REFRESH, SLEEP_FACTORY_INTERNAL_PREFLASH_PASSES); } else { renderer.clearScreen(); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); @@ -393,8 +416,10 @@ void SleepActivity::renderCoverSleepScreen() const { } LOG_DBG("SLP", "Direct XTCH plane render: %ux%u", lastXtc.getPageWidth(), lastXtc.getPageHeight()); + runFactorySleepPrecondition(renderer); renderer.displayXtchPlanes(plane1, plane2, lastXtc.getPageWidth(), lastXtc.getPageHeight(), nullptr, nullptr, - GfxRenderer::GrayscaleMode::FactoryQuality, true, HalDisplay::FULL_REFRESH); + GfxRenderer::GrayscaleMode::FactoryQuality, false, HalDisplay::FULL_REFRESH, + SLEEP_FACTORY_INTERNAL_PREFLASH_PASSES); free(plane1); free(plane2); return; From 9266211fcd4b5eeb77e0c972052282d6d16ab077 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Sat, 16 May 2026 23:16:30 +0200 Subject: [PATCH 51/57] refactor: GrayscaleDriveMode + stock-V5.5.9 factory experiments Refactor: - Rename GfxRenderer::GrayscaleMode -> GrayscaleDriveMode to separate caller intent (which LUT, fast vs quality vs differential) from low-level panel plumbing (RenderMode values, lut pointer, factoryMode flag, g_differentialQuantize). - Add file-private GrayscaleDriveSpec + resolveGrayscaleDrive() in GfxRenderer.cpp. Replaces four duplicated ternary clusters across renderGrayscale, renderGrayscaleSinglePass (main + malloc-failed fallback), and displayXtchPlanes. - Tidy SleepActivity displayXtchPlanes call: drop two inert args (preFlash is false, so passes count and refresh mode were unreachable). Add a one-line comment pointing to the external precondition. Stock-firmware experiments (PXC sleep ghost investigation, see docs/v559-disassembly-findings.md): - renderGrayscaleSinglePass factory path now uses the split SDK API (displayGrayBufferFactorySetup + RAM writes + displayGrayBufferFactoryActivate) to match stock's SPI order: LUT load -> Border Waveform -> RAM writes -> CTRL1/CTRL2/MASTER_ACTIVATION. - Precondition (runFactorySleepPrecondition) commented out in all three SleepActivity render paths (PXC, BMP grayscale, XTCH planes) to test whether the precondition's post-FULL_REFRESH RED RAM sync was contributing. - Bump open-x4-sdk pointer to pick up the matching SDK experiments (FACTORY_GRAY CTRL2 = 0xCC, Border Waveform per render, BOOSTER and BORDER init bytes matching stock, deepSleep factory branch early-return, factory LUT voltages set to stock's byte-exact 0x00/0x00/0x01/0x22/0x22 sequence). --- lib/GfxRenderer/GfxRenderer.cpp | 101 ++++++++++++------- lib/GfxRenderer/GfxRenderer.h | 19 ++-- lib/hal/HalDisplay.cpp | 6 ++ lib/hal/HalDisplay.h | 3 + open-x4-sdk | 2 +- src/activities/boot_sleep/SleepActivity.cpp | 18 ++-- src/activities/reader/EpubReaderActivity.cpp | 6 +- src/activities/reader/XtcReaderActivity.cpp | 4 +- src/activities/util/BmpViewerActivity.cpp | 4 +- src/activities/util/PxcViewerActivity.cpp | 2 +- 10 files changed, 102 insertions(+), 63 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index d1f7340863..58cfe6dad8 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -23,6 +23,28 @@ const char* resolveVisualText(const char* text, std::string& visualBuffer, int p uint8_t resolveSdCardStyle(const SdCardFont& font, const EpdFontFamily::Style style) { return font.resolveStyle(static_cast(style)); } + +// Bundles every panel-level value derived from a caller-facing GrayscaleDriveMode, +// so the three grayscale entry points share one mapping instead of repeating ternaries. +struct GrayscaleDriveSpec { + GfxRenderer::RenderMode lsbMode; + GfxRenderer::RenderMode msbMode; + const unsigned char* lut; // nullptr for Differential + bool factoryMode; + bool differentialQuantize; +}; + +GrayscaleDriveSpec resolveGrayscaleDrive(GfxRenderer::GrayscaleDriveMode mode) { + switch (mode) { + case GfxRenderer::GrayscaleDriveMode::Differential: + return {GfxRenderer::GRAYSCALE_LSB, GfxRenderer::GRAYSCALE_MSB, nullptr, false, true}; + case GfxRenderer::GrayscaleDriveMode::FactoryFast: + return {GfxRenderer::GRAY2_LSB, GfxRenderer::GRAY2_MSB, lut_factory_fast, true, false}; + case GfxRenderer::GrayscaleDriveMode::FactoryQuality: + return {GfxRenderer::GRAY2_LSB, GfxRenderer::GRAY2_MSB, lut_factory_quality, true, false}; + } + return {GfxRenderer::GRAYSCALE_LSB, GfxRenderer::GRAYSCALE_MSB, nullptr, false, true}; +} } // namespace const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const { @@ -1459,30 +1481,25 @@ void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuff void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(frameBuffer); } -void GfxRenderer::renderGrayscale(GrayscaleMode mode, void (*renderFn)(const GfxRenderer&, const void*), +void GfxRenderer::renderGrayscale(GrayscaleDriveMode mode, void (*renderFn)(const GfxRenderer&, const void*), const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*), const void* preFlashCtx) { - if (mode == GrayscaleMode::FactoryFast || mode == GrayscaleMode::FactoryQuality) { + const auto spec = resolveGrayscaleDrive(mode); + + if (spec.factoryMode) { clearScreen(); if (preFlashOverlayFn) preFlashOverlayFn(*this, preFlashCtx); displayBuffer(HalDisplay::HALF_REFRESH); } - const RenderMode lsbMode = (mode == GrayscaleMode::Differential) ? GRAYSCALE_LSB : GRAY2_LSB; - const RenderMode msbMode = (mode == GrayscaleMode::Differential) ? GRAYSCALE_MSB : GRAY2_MSB; - const bool factoryMode = (mode != GrayscaleMode::Differential); - const unsigned char* lut = (mode == GrayscaleMode::FactoryFast) ? lut_factory_fast - : (mode == GrayscaleMode::FactoryQuality) ? lut_factory_quality - : nullptr; - - g_differentialQuantize = (mode == GrayscaleMode::Differential); + g_differentialQuantize = spec.differentialQuantize; clearScreen(0x00); - setRenderMode(lsbMode); + setRenderMode(spec.lsbMode); renderFn(*this, ctx); uint8_t* lsbCopy = nullptr; - if (screenshotHook && factoryMode) { + if (screenshotHook && spec.factoryMode) { lsbCopy = static_cast(malloc(frameBufferSize)); if (lsbCopy) { memcpy(lsbCopy, frameBuffer, frameBufferSize); @@ -1495,12 +1512,12 @@ void GfxRenderer::renderGrayscale(GrayscaleMode mode, void (*renderFn)(const Gfx copyGrayscaleLsbBuffers(); clearScreen(0x00); - setRenderMode(msbMode); + setRenderMode(spec.msbMode); renderFn(*this, ctx); copyGrayscaleMsbBuffers(); // Fire hook: LSB = lsbCopy, MSB = frameBuffer (still holds second-pass data). - if (screenshotHook && factoryMode && lsbCopy) { + if (screenshotHook && spec.factoryMode && lsbCopy) { screenshotHook(lsbCopy, frameBuffer, panelWidth, panelHeight, screenshotHookCtx); screenshotHook = nullptr; screenshotHookCtx = nullptr; @@ -1512,7 +1529,7 @@ void GfxRenderer::renderGrayscale(GrayscaleMode mode, void (*renderFn)(const Gfx g_differentialQuantize = false; - displayGrayBuffer(lut, factoryMode); + displayGrayBuffer(spec.lut, spec.factoryMode); // Suppress the SDK's automatic grayscaleRevert on the next BW page turn. // Caller is responsible for cleanup: restoreBwBuffer rebases RED RAM, and // displayBuffer promotes the first post-factory FAST refresh to HALF. @@ -1520,11 +1537,13 @@ void GfxRenderer::renderGrayscale(GrayscaleMode mode, void (*renderFn)(const Gfx setRenderMode(BW); } -void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn)(const GfxRenderer&, const void*), +void GfxRenderer::renderGrayscaleSinglePass(GrayscaleDriveMode mode, void (*renderFn)(const GfxRenderer&, const void*), const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*), const void* preFlashCtx, const HalDisplay::RefreshMode preFlashRefreshMode, const uint8_t preFlashPasses) { - if ((mode == GrayscaleMode::FactoryFast || mode == GrayscaleMode::FactoryQuality) && preFlashPasses > 0) { + const auto spec = resolveGrayscaleDrive(mode); + + if (spec.factoryMode && preFlashPasses > 0) { for (uint8_t pass = 0; pass < preFlashPasses; pass++) { clearScreen(); if (pass == 0 && preFlashOverlayFn) preFlashOverlayFn(*this, preFlashCtx); @@ -1532,13 +1551,7 @@ void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn) } } - const RenderMode lsbMode = (mode == GrayscaleMode::Differential) ? GRAYSCALE_LSB : GRAY2_LSB; - const bool factoryMode = (mode != GrayscaleMode::Differential); - const unsigned char* lut = (mode == GrayscaleMode::FactoryFast) ? lut_factory_fast - : (mode == GrayscaleMode::FactoryQuality) ? lut_factory_quality - : nullptr; - - g_differentialQuantize = (mode == GrayscaleMode::Differential); + g_differentialQuantize = spec.differentialQuantize; auto* secBuf = static_cast(malloc(frameBufferSize)); if (!secBuf) { @@ -1547,15 +1560,15 @@ void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn) screenshotHook = nullptr; screenshotHookCtx = nullptr; clearScreen(0x00); - setRenderMode(lsbMode); + setRenderMode(spec.lsbMode); renderFn(*this, ctx); copyGrayscaleLsbBuffers(); clearScreen(0x00); - setRenderMode(mode == GrayscaleMode::Differential ? GRAYSCALE_MSB : GRAY2_MSB); + setRenderMode(spec.msbMode); renderFn(*this, ctx); copyGrayscaleMsbBuffers(); g_differentialQuantize = false; - displayGrayBuffer(lut, factoryMode); + displayGrayBuffer(spec.lut, spec.factoryMode); // See note in renderGrayscale(). display.clearGrayscaleModeFlag(); setRenderMode(BW); @@ -1566,21 +1579,36 @@ void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn) // Single pass: renderFn writes LSB plane to frameBuffer and MSB plane to secondaryFrameBuffer. clearScreen(0x00); - setRenderMode(lsbMode); + setRenderMode(spec.lsbMode); renderFn(*this, ctx); // One-shot screenshot hook: fired while both planes are still in software, before either is // pushed to the controller. frameBuffer = LSB plane, secBuf = MSB plane. - if (screenshotHook && factoryMode) { + if (screenshotHook && spec.factoryMode) { screenshotHook(frameBuffer, secBuf, panelWidth, panelHeight, screenshotHookCtx); screenshotHook = nullptr; screenshotHookCtx = nullptr; } - // Push LSB plane (frameBuffer) → BW RAM. - copyGrayscaleLsbBuffers(); + // EXPERIMENT: match stock V5.5.9 SPI order: LUT load → RAM writes → activate. + // For factory mode, use the split SDK API so RAM data is written AFTER setCustomLUT + // and Border Waveform, BEFORE the MASTER_ACTIVATION. See docs/v559-disassembly-findings.md. + if (spec.factoryMode) { + display.displayGrayBufferFactorySetup(spec.lut); + copyGrayscaleLsbBuffers(); + memcpy(frameBuffer, secBuf, frameBufferSize); + copyGrayscaleMsbBuffers(); + free(secBuf); + secondaryFrameBuffer = nullptr; + g_differentialQuantize = false; + display.displayGrayBufferFactoryActivate(); + display.clearGrayscaleModeFlag(); + setRenderMode(BW); + return; + } - // Push MSB plane (secondaryFrameBuffer → frameBuffer → RED RAM). + // Differential path: original order (RAM writes then combined displayGrayBuffer). + copyGrayscaleLsbBuffers(); memcpy(frameBuffer, secBuf, frameBufferSize); copyGrayscaleMsbBuffers(); @@ -1588,15 +1616,14 @@ void GfxRenderer::renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn) secondaryFrameBuffer = nullptr; g_differentialQuantize = false; - displayGrayBuffer(lut, factoryMode); - // See note in renderGrayscale(). + displayGrayBuffer(spec.lut, spec.factoryMode); display.clearGrayscaleModeFlag(); setRenderMode(BW); } void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, const uint16_t pageWidth, const uint16_t pageHeight, RenderHook overlayFn, const void* overlayCtx, - GrayscaleMode mode, const bool preFlash, + GrayscaleDriveMode mode, const bool preFlash, const HalDisplay::RefreshMode preFlashRefreshMode, const uint8_t preFlashPasses) { const size_t colBytes = (pageHeight + 7) / 8; const uint16_t fbStride = panelWidthBytes; @@ -1655,8 +1682,8 @@ void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2 screenshotHookCtx = nullptr; } - const unsigned char* lut = (mode == GrayscaleMode::FactoryQuality) ? lut_factory_quality : lut_factory_fast; - displayGrayBuffer(lut, true); + const auto spec = resolveGrayscaleDrive(mode); + displayGrayBuffer(spec.lut, true); setRenderMode(BW); } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 9ba80751f6..01d480bfb4 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -35,11 +35,12 @@ class GfxRenderer { LandscapeCounterClockwise // 800x480 logical coordinates, native panel orientation }; - // Selects LUT, pixel-plane encoding, and pre-flash behavior for renderGrayscale(). - enum class GrayscaleMode { - FactoryFast, // Factory absolute 2-bit (lut_factory_fast); HALF_REFRESH pre-flash to white - FactoryQuality, // Factory absolute 2-bit (lut_factory_quality); HALF_REFRESH pre-flash to white - Differential, // Differential 2-bit overlay (no LUT); no pre-flash, requires prior BW state + // Selects LUT and pixel-plane encoding for renderGrayscale() / renderGrayscaleSinglePass() / + // displayXtchPlanes(). Pre-flash policy is per-call, not part of the drive mode. + enum class GrayscaleDriveMode { + FactoryFast, // Factory absolute 2-bit (lut_factory_fast) + FactoryQuality, // Factory absolute 2-bit (lut_factory_quality) + Differential, // Differential 2-bit overlay (no LUT); requires prior BW state }; // Display state — tracks whether the physical display was last updated via a factory LUT render. @@ -216,7 +217,7 @@ class GfxRenderer { // storeBwBuffer / restoreBwBuffer remain the caller's responsibility. // preFlashOverlayFn (optional): called after clearScreen() but before the pre-flash displayBuffer(), // allowing callers to draw a loading indicator that appears during the pre-flash without an extra refresh. - void renderGrayscale(GrayscaleMode mode, void (*renderFn)(const GfxRenderer&, const void*), const void* ctx, + void renderGrayscale(GrayscaleDriveMode mode, void (*renderFn)(const GfxRenderer&, const void*), const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*) = nullptr, const void* preFlashCtx = nullptr); // Single-pass variant: calls renderFn once in GRAY2_LSB mode while simultaneously writing @@ -224,8 +225,8 @@ class GfxRenderer { // Falls back to two-pass on secondary buffer allocation failure. Factory modes pre-flash to // white with HALF_REFRESH by default. preFlashPasses=0 skips the internal pre-flash so // callers can run a custom conditioning sequence first. - void renderGrayscaleSinglePass(GrayscaleMode mode, void (*renderFn)(const GfxRenderer&, const void*), const void* ctx, - void (*preFlashOverlayFn)(const GfxRenderer&, const void*) = nullptr, + void renderGrayscaleSinglePass(GrayscaleDriveMode mode, void (*renderFn)(const GfxRenderer&, const void*), + const void* ctx, void (*preFlashOverlayFn)(const GfxRenderer&, const void*) = nullptr, const void* preFlashCtx = nullptr, HalDisplay::RefreshMode preFlashRefreshMode = HalDisplay::HALF_REFRESH, uint8_t preFlashPasses = 1); @@ -236,7 +237,7 @@ class GfxRenderer { // mode selects the factory LUT: FactoryFast (default) or FactoryQuality; Differential is invalid here. void displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2, uint16_t pageWidth, uint16_t pageHeight, RenderHook overlayFn = nullptr, const void* overlayCtx = nullptr, - GrayscaleMode mode = GrayscaleMode::FactoryFast, bool preFlash = false, + GrayscaleDriveMode mode = GrayscaleDriveMode::FactoryFast, bool preFlash = false, HalDisplay::RefreshMode preFlashRefreshMode = HalDisplay::HALF_REFRESH, uint8_t preFlashPasses = 1); diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index ff479e3cfb..1f6979de7f 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -84,6 +84,12 @@ void HalDisplay::displayGrayBuffer(bool turnOffScreen, const unsigned char* lut, einkDisplay.displayGrayBuffer(turnOffScreen, lut, factoryMode); } +void HalDisplay::displayGrayBufferFactorySetup(const unsigned char* lut) { + einkDisplay.displayGrayBufferFactorySetup(lut); +} + +void HalDisplay::displayGrayBufferFactoryActivate() { einkDisplay.displayGrayBufferFactoryActivate(); } + uint16_t HalDisplay::getDisplayWidth() const { return einkDisplay.getDisplayWidth(); } uint16_t HalDisplay::getDisplayHeight() const { return einkDisplay.getDisplayHeight(); } diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index 9d4f45d320..fc88189dd0 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -48,6 +48,9 @@ class HalDisplay { void cleanupGrayscaleBuffers(const uint8_t* bwBuffer); void displayGrayBuffer(bool turnOffScreen = false, const unsigned char* lut = nullptr, bool factoryMode = false); + // Two-phase factory grayscale render — see EInkDisplay.h. + void displayGrayBufferFactorySetup(const unsigned char* lut); + void displayGrayBufferFactoryActivate(); // Tell the SDK that grayscale state has been cleaned up by the consumer // (RAM banks rebased + a follow-up FAST_REFRESH will handle pixel cleanup), diff --git a/open-x4-sdk b/open-x4-sdk index d3275d2d96..e59ef3a1a5 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit d3275d2d961531161bf08bb9c1d6f78708688aa7 +Subproject commit e59ef3a1a5be6d3beaa93d48995f6896e76e5fd8 diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 0fcf6222ba..fe889700e4 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -35,7 +35,7 @@ void runFactorySleepPrecondition(const GfxRenderer& renderer) { renderer.displayBuffer(pass.refreshMode); } } -} +} // namespace void SleepActivity::onEnter() { Activity::onEnter(); @@ -257,9 +257,10 @@ bool SleepActivity::renderPxcSleepScreen(const std::string& path) const { }; PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight}; - runFactorySleepPrecondition(renderer); + // EXPERIMENT: precondition disabled to test ghost hypothesis. See docs/v559-disassembly-findings.md. + // runFactorySleepPrecondition(renderer); renderer.renderGrayscaleSinglePass( - GfxRenderer::GrayscaleMode::FactoryQuality, + GfxRenderer::GrayscaleDriveMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { const auto* c = static_cast(raw); c->file->seek(c->dataOffset); @@ -342,9 +343,10 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { float cropX, cropY; }; BitmapGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, cropX, cropY}; - runFactorySleepPrecondition(renderer); + // EXPERIMENT: precondition disabled to test ghost hypothesis. + // runFactorySleepPrecondition(renderer); renderer.renderGrayscaleSinglePass( - GfxRenderer::GrayscaleMode::FactoryQuality, + GfxRenderer::GrayscaleDriveMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { const auto* c = static_cast(raw); r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, c->cropX, c->cropY); @@ -416,10 +418,10 @@ void SleepActivity::renderCoverSleepScreen() const { } LOG_DBG("SLP", "Direct XTCH plane render: %ux%u", lastXtc.getPageWidth(), lastXtc.getPageHeight()); - runFactorySleepPrecondition(renderer); + // EXPERIMENT: precondition disabled to test ghost hypothesis. + // runFactorySleepPrecondition(renderer); renderer.displayXtchPlanes(plane1, plane2, lastXtc.getPageWidth(), lastXtc.getPageHeight(), nullptr, nullptr, - GfxRenderer::GrayscaleMode::FactoryQuality, false, HalDisplay::FULL_REFRESH, - SLEEP_FACTORY_INTERNAL_PREFLASH_PASSES); + GfxRenderer::GrayscaleDriveMode::FactoryQuality, false); free(plane1); free(plane2); return; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 3349e701fa..d010270527 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -813,8 +813,8 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or PageRenderCtx grayCtx{page.get(), SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop, this}; const auto tGrayStart = millis(); - const auto grayMode = - useFactoryGray ? GfxRenderer::GrayscaleMode::FactoryQuality : GfxRenderer::GrayscaleMode::Differential; + const auto grayMode = useFactoryGray ? GfxRenderer::GrayscaleDriveMode::FactoryQuality + : GfxRenderer::GrayscaleDriveMode::Differential; renderer.renderGrayscale(grayMode, &renderPageCallback, &grayCtx); const auto tGrayEnd = millis(); fcm->logStats(useFactoryGray ? "gray_factory_quality" : "gray"); @@ -856,7 +856,7 @@ void EpubReaderActivity::onScreenshotRequest() { if (!renderer.storeBwBuffer()) return; PageRenderCtx grayCtx{p.get(), SETTINGS.getReaderFontId(), lastFactoryMarginLeft, lastFactoryMarginTop, this}; - renderer.renderGrayscale(GfxRenderer::GrayscaleMode::FactoryQuality, &renderPageCallback, &grayCtx); + renderer.renderGrayscale(GfxRenderer::GrayscaleDriveMode::FactoryQuality, &renderPageCallback, &grayCtx); renderer.restoreBwBuffer(); } diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index ac8d1d3da9..0c0a4e1331 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -275,8 +275,8 @@ void XtcReaderActivity::renderPage() { } const auto xtcGrayMode = SETTINGS.xtcRenderQuality == CrossPointSettings::XTC_RENDER_QUALITY_HIGH - ? GfxRenderer::GrayscaleMode::FactoryQuality - : GfxRenderer::GrayscaleMode::FactoryFast; + ? GfxRenderer::GrayscaleDriveMode::FactoryQuality + : GfxRenderer::GrayscaleDriveMode::FactoryFast; renderer.displayXtchPlanes(plane1, plane2, pageWidth, pageHeight, &XtcReaderActivity::renderStatusBarOverlayCallback, this, xtcGrayMode); free(plane1); diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index 8726183a76..29d37d98b6 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -113,7 +113,7 @@ void BmpViewerActivity::onEnter() { }; BmpGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, labels}; renderer.renderGrayscaleSinglePass( - GfxRenderer::GrayscaleMode::FactoryQuality, + GfxRenderer::GrayscaleDriveMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { const auto* c = static_cast(raw); r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, 0, 0); @@ -201,7 +201,7 @@ void BmpViewerActivity::renderGrayscaleImage() { BmpGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, labels}; renderer.renderGrayscaleSinglePass( - GfxRenderer::GrayscaleMode::FactoryQuality, + GfxRenderer::GrayscaleDriveMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { const auto* c = static_cast(raw); r.drawBitmap(*c->bitmap, c->x, c->y, c->maxWidth, c->maxHeight, 0, 0); diff --git a/src/activities/util/PxcViewerActivity.cpp b/src/activities/util/PxcViewerActivity.cpp index 1d60a502f8..e3ba8cf340 100644 --- a/src/activities/util/PxcViewerActivity.cpp +++ b/src/activities/util/PxcViewerActivity.cpp @@ -109,7 +109,7 @@ void PxcViewerActivity::renderPxcToFramebuffer(FsFile& file, uint16_t width, uin const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SET_SLEEP_COVER), (hasPrevious ? "<" : ""), (hasNext ? ">" : "")); PxcCtx ctx{&file, dataOffset, width, height, labels}; - renderer.renderGrayscaleSinglePass(GfxRenderer::GrayscaleMode::FactoryQuality, &pxcRenderCallback, &ctx, + renderer.renderGrayscaleSinglePass(GfxRenderer::GrayscaleDriveMode::FactoryQuality, &pxcRenderCallback, &ctx, &pxcLoadingOverlay, nullptr); } From 400b0bc660d6f17fbfc244eec81ec9f12b47272d Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Sun, 17 May 2026 13:15:49 +0200 Subject: [PATCH 52/57] chore: bump open-x4-sdk to 4e949dc (VCOM restore in factory Activate) Picks up the fix for displayGrayBufferFactoryActivate not restoring VCOM after a non-default-VCOM LUT load. See SDK commit 4e949dc. --- open-x4-sdk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-x4-sdk b/open-x4-sdk index e59ef3a1a5..4e949dc5d5 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit e59ef3a1a5be6d3beaa93d48995f6896e76e5fd8 +Subproject commit 4e949dc5d5f8ad4d57bb5c8ff13e1e1c36db559b From 40bd133240cfcde736c43c09aff983b51178ad61 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Sun, 17 May 2026 13:23:25 +0200 Subject: [PATCH 53/57] chore: bump open-x4-sdk to 7c8afd0 (revert VCOM restore for stock-match) Picks up the revert of the VCOM restore in displayGrayBufferFactoryActivate. Preserves byte-exact stock V5.5.9 SPI sequence for the PXC sleep ghost experiment. See SDK commit 7c8afd0. --- open-x4-sdk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-x4-sdk b/open-x4-sdk index 4e949dc5d5..7c8afd08c9 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit 4e949dc5d5f8ad4d57bb5c8ff13e1e1c36db559b +Subproject commit 7c8afd08c9e4dbacaaca04e05a9c6f12cae3b454 From ba725495efad4cdef500a0b6e4d6c6a9ddc77c8b Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Sun, 17 May 2026 13:50:45 +0200 Subject: [PATCH 54/57] feat: re-enable precondition with stock-match (CTRL2=0xF7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-enable runFactorySleepPrecondition in all three SleepActivity render paths (PXC, BMP grayscale, XTCH planes). The disable was an experiment that didn't move the ghost; restoring it to test against stock's actual pre-LUT sequence. Switch the precondition itself from renderer.clearScreen + renderer.displayBuffer(FULL_REFRESH) to renderer.displayBufferPrecondition() — a new HAL/SDK method that fires CTRL2 = 0xF7 (full power cycle, matches stock V5.5.9) and skips the SINGLE_BUFFER_MODE post-RED-sync. Wrappers added to HalDisplay and GfxRenderer. Bump open-x4-sdk pointer to 4982527 which contains the new EInkDisplay::displayBufferPrecondition implementation. See docs/v559-disassembly-findings.md for the full byte-match rationale. --- lib/GfxRenderer/GfxRenderer.cpp | 5 ++++ lib/GfxRenderer/GfxRenderer.h | 4 +++ lib/hal/HalDisplay.cpp | 2 ++ lib/hal/HalDisplay.h | 2 ++ open-x4-sdk | 2 +- src/activities/boot_sleep/SleepActivity.cpp | 29 ++++++++------------- 6 files changed, 25 insertions(+), 19 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 58cfe6dad8..70b29eb923 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1168,6 +1168,11 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const displayState = DisplayState::BW; } +void GfxRenderer::displayBufferPrecondition(uint8_t color) const { + display.displayBufferPrecondition(color); + displayState = DisplayState::BW; +} + void GfxRenderer::displayGrayBuffer(const unsigned char* lut, const bool factoryMode) const { display.displayGrayBuffer(fadingFix, lut, factoryMode); if (factoryMode) { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 01d480bfb4..c455deb440 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -140,6 +140,10 @@ class GfxRenderer { int getScreenWidth() const; int getScreenHeight() const; void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; + // Stock-V5.5.9 byte-match precondition flash (black or white). Fires CTRL2=0xF7 + // (full power-cycle, rails off after) and skips SINGLE_BUFFER_MODE post-RED-sync. + // See docs/v559-disassembly-findings.md. + void displayBufferPrecondition(uint8_t color) const; // EXPERIMENTAL: Windowed update - display only a rectangular region // void displayWindow(int x, int y, int width, int height) const; void invertScreen() const; diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index 1f6979de7f..d176c3de02 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -90,6 +90,8 @@ void HalDisplay::displayGrayBufferFactorySetup(const unsigned char* lut) { void HalDisplay::displayGrayBufferFactoryActivate() { einkDisplay.displayGrayBufferFactoryActivate(); } +void HalDisplay::displayBufferPrecondition(uint8_t color) { einkDisplay.displayBufferPrecondition(color); } + uint16_t HalDisplay::getDisplayWidth() const { return einkDisplay.getDisplayWidth(); } uint16_t HalDisplay::getDisplayHeight() const { return einkDisplay.getDisplayHeight(); } diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index fc88189dd0..fc8cee8c29 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -51,6 +51,8 @@ class HalDisplay { // Two-phase factory grayscale render — see EInkDisplay.h. void displayGrayBufferFactorySetup(const unsigned char* lut); void displayGrayBufferFactoryActivate(); + // Stock-V5.5.9 byte-match precondition (black/white full power-cycle flash). + void displayBufferPrecondition(uint8_t color); // Tell the SDK that grayscale state has been cleaned up by the consumer // (RAM banks rebased + a follow-up FAST_REFRESH will handle pixel cleanup), diff --git a/open-x4-sdk b/open-x4-sdk index 7c8afd08c9..4982527a3c 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit 7c8afd08c9e4dbacaaca04e05a9c6f12cae3b454 +Subproject commit 4982527a3c88a06e36ca7ce084fdfca6ef79bea5 diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index fe889700e4..877eaf1e5c 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -19,20 +19,16 @@ namespace { constexpr uint8_t SLEEP_FACTORY_INTERNAL_PREFLASH_PASSES = 0; -struct FactorySleepPreconditionPass { - uint8_t color; - HalDisplay::RefreshMode refreshMode; -}; - -constexpr FactorySleepPreconditionPass FACTORY_SLEEP_PRECONDITION[] = { - {0x00, HalDisplay::FULL_REFRESH}, - {0xFF, HalDisplay::FULL_REFRESH}, -}; +// Stock V5.5.9 byte-match: black flash then white flash, each via the new +// EInkDisplay::displayBufferPrecondition() path which fires CTRL2=0xF7 (full +// power cycle) and skips the SINGLE_BUFFER_MODE post-RED-sync. Matches stock's +// precondition function at firmware addr 0x42010096 — see +// docs/v559-disassembly-findings.md. +constexpr uint8_t FACTORY_SLEEP_PRECONDITION_COLORS[] = {0x00, 0xFF}; void runFactorySleepPrecondition(const GfxRenderer& renderer) { - for (const auto& pass : FACTORY_SLEEP_PRECONDITION) { - renderer.clearScreen(pass.color); - renderer.displayBuffer(pass.refreshMode); + for (const uint8_t color : FACTORY_SLEEP_PRECONDITION_COLORS) { + renderer.displayBufferPrecondition(color); } } } // namespace @@ -257,8 +253,7 @@ bool SleepActivity::renderPxcSleepScreen(const std::string& path) const { }; PxcCtx ctx{&file, dataOffset, pxcWidth, pxcHeight}; - // EXPERIMENT: precondition disabled to test ghost hypothesis. See docs/v559-disassembly-findings.md. - // runFactorySleepPrecondition(renderer); + runFactorySleepPrecondition(renderer); renderer.renderGrayscaleSinglePass( GfxRenderer::GrayscaleDriveMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { @@ -343,8 +338,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { float cropX, cropY; }; BitmapGrayCtx grayCtx{&bitmap, x, y, pageWidth, pageHeight, cropX, cropY}; - // EXPERIMENT: precondition disabled to test ghost hypothesis. - // runFactorySleepPrecondition(renderer); + runFactorySleepPrecondition(renderer); renderer.renderGrayscaleSinglePass( GfxRenderer::GrayscaleDriveMode::FactoryQuality, [](const GfxRenderer& r, const void* raw) { @@ -418,8 +412,7 @@ void SleepActivity::renderCoverSleepScreen() const { } LOG_DBG("SLP", "Direct XTCH plane render: %ux%u", lastXtc.getPageWidth(), lastXtc.getPageHeight()); - // EXPERIMENT: precondition disabled to test ghost hypothesis. - // runFactorySleepPrecondition(renderer); + runFactorySleepPrecondition(renderer); renderer.displayXtchPlanes(plane1, plane2, lastXtc.getPageWidth(), lastXtc.getPageHeight(), nullptr, nullptr, GfxRenderer::GrayscaleDriveMode::FactoryQuality, false); free(plane1); From f024256c62a207ec58be9fc8a9db3d364a4b8c2b Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Sun, 17 May 2026 20:55:40 +0200 Subject: [PATCH 55/57] chore: bump open-x4-sdk to b4b9d39 (revert byte-exact LUT experiment) The stock byte-exact lut_factory_quality swap caused all-white renders on device. Reverted in SDK; bumping pointer to match. The "OTP-preserve for 0x00 voltages" hypothesis from Difference #8 is falsified by this empirical result. See docs/v559-disassembly-findings.md. --- open-x4-sdk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-x4-sdk b/open-x4-sdk index 4982527a3c..b4b9d39190 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit 4982527a3c88a06e36ca7ce084fdfca6ef79bea5 +Subproject commit b4b9d391908f5afdcc74db267cc371f1fb9e2991 From 57b56c6cb8e25325b4bb5116b2029cd6932461d0 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Sun, 17 May 2026 21:48:49 +0200 Subject: [PATCH 56/57] feat: extend stock-match LUT-before-RAM order to all factory paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the split SDK API (displayGrayBufferFactorySetup + displayGrayBufferFactoryActivate, matching stock V5.5.9's LUT-load-before-RAM-writes SPI order — Difference #4) was applied only to renderGrayscaleSinglePass. PxcViewer and BmpViewer use that singlepass path so they got the patch implicitly, but the other factory render callers did not: - renderGrayscale (two-pass) → used by EpubReaderActivity for inline grayscale images - displayXtchPlanes → used by XtcReaderActivity and SleepActivity's XTCH cover path Refactor both to use the split SDK API for factory mode: - renderGrayscale: insert displayGrayBufferFactorySetup before the first copyGrayscaleLsbBuffers, replace the trailing displayGrayBuffer with displayGrayBufferFactoryActivate. Differential mode keeps the combined displayGrayBuffer (no change). - displayXtchPlanes: resolve drive spec early, call Setup before the plane1 RAM write, call Activate after the plane2 RAM write. Net result: all 6 factory render call sites (PXC viewer, BMP viewer, EPUB inline images, XTC reader, sleep XTCH path, sleep PXC/BMP path) now follow the stock SPI order. See docs/v559-disassembly-findings.md Difference #4. --- lib/GfxRenderer/GfxRenderer.cpp | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 70b29eb923..dfd4a339e9 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1514,6 +1514,13 @@ void GfxRenderer::renderGrayscale(GrayscaleDriveMode mode, void (*renderFn)(cons screenshotHookCtx = nullptr; } } + + // Match stock SPI order for factory mode: setCustomLUT + Border BEFORE the + // RAM writes. Differential path skips Setup (handled by displayGrayBuffer at + // the end). See docs/v559-disassembly-findings.md Difference #4. + if (spec.factoryMode) { + display.displayGrayBufferFactorySetup(spec.lut); + } copyGrayscaleLsbBuffers(); clearScreen(0x00); @@ -1534,7 +1541,14 @@ void GfxRenderer::renderGrayscale(GrayscaleDriveMode mode, void (*renderFn)(cons g_differentialQuantize = false; - displayGrayBuffer(spec.lut, spec.factoryMode); + // Factory mode: differential path keeps the original combined call. Factory + // path uses the split SDK API so the LUT load happens BEFORE RAM writes — + // matches stock V5.5.9 SPI order. Same pattern as renderGrayscaleSinglePass. + if (spec.factoryMode) { + display.displayGrayBufferFactoryActivate(); + } else { + displayGrayBuffer(spec.lut, spec.factoryMode); + } // Suppress the SDK's automatic grayscaleRevert on the next BW page turn. // Caller is responsible for cleanup: restoreBwBuffer rebases RED RAM, and // displayBuffer promotes the first post-factory FAST refresh to HALF. @@ -1652,6 +1666,12 @@ void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2 } } + // Match stock V5.5.9 SPI order: setCustomLUT + Border BEFORE RAM writes. + // Resolve drive spec early so we can use Setup/Activate. See + // docs/v559-disassembly-findings.md Difference #4. + const auto spec = resolveGrayscaleDrive(mode); + display.displayGrayBufferFactorySetup(spec.lut); + // Pass 1: plane1 (MSB) → BW RAM via copyGrayscaleLsbBuffers. clearScreen(0x00); for (uint16_t c = 0; c < pageWidth; c++) { @@ -1687,8 +1707,7 @@ void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2 screenshotHookCtx = nullptr; } - const auto spec = resolveGrayscaleDrive(mode); - displayGrayBuffer(spec.lut, true); + display.displayGrayBufferFactoryActivate(); setRenderMode(BW); } From 0bd54cca7494b15740158e06d7145273fa589aa1 Mon Sep 17 00:00:00 2001 From: pablohc Date: Mon, 18 May 2026 12:12:23 +0200 Subject: [PATCH 57/57] fix: set displayState=FactoryLut after factory activate in all render paths --- lib/GfxRenderer/GfxRenderer.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index dfd4a339e9..32ea4c0759 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1546,6 +1546,7 @@ void GfxRenderer::renderGrayscale(GrayscaleDriveMode mode, void (*renderFn)(cons // matches stock V5.5.9 SPI order. Same pattern as renderGrayscaleSinglePass. if (spec.factoryMode) { display.displayGrayBufferFactoryActivate(); + displayState = DisplayState::FactoryLut; } else { displayGrayBuffer(spec.lut, spec.factoryMode); } @@ -1621,6 +1622,7 @@ void GfxRenderer::renderGrayscaleSinglePass(GrayscaleDriveMode mode, void (*rend secondaryFrameBuffer = nullptr; g_differentialQuantize = false; display.displayGrayBufferFactoryActivate(); + displayState = DisplayState::FactoryLut; display.clearGrayscaleModeFlag(); setRenderMode(BW); return; @@ -1708,6 +1710,7 @@ void GfxRenderer::displayXtchPlanes(const uint8_t* plane1, const uint8_t* plane2 } display.displayGrayBufferFactoryActivate(); + displayState = DisplayState::FactoryLut; setRenderMode(BW); }