Compare commits

..
Author SHA1 Message Date
Uri Tauber fd00c185f2 feat: whole book page count 2026-07-15 17:58:45 +03:00
27 changed files with 407 additions and 489 deletions
-6
View File
@@ -25,9 +25,3 @@ lib/EpdFont/scripts/output/
# (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out. # (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out.
.claude/* .claude/*
!.claude/skills/ !.claude/skills/
/managed_components
/.dummy
/CMakeLists.txt
/dependencies.lock
/sdkconfig.default
/sdkconfig.defaults
+17 -44
View File
@@ -68,22 +68,6 @@ bool collectUniqueCodepoints(const char* text, uint32_t* codepoints, uint32_t& c
const char* asCStr(const std::string& s) { return s.c_str(); } const char* asCStr(const std::string& s) { return s.c_str(); }
const char* asCStr(const char* s) { return s; } const char* asCStr(const char* s) { return s; }
// Keep-if-fits buffer reuse: only reallocate when the needed size exceeds the
// current capacity. Freeing + reallocating slightly different sizes every page
// turn punches non-coalescing holes in the heap (the freed block rarely fits the
// next page's need), eroding the largest contiguous block all session. With
// reuse, capacities converge on the book's max page after a few turns and page
// turns stop touching the allocator. Only three small instantiations exist
// (interval/glyph/byte arrays), so template bloat is negligible.
template <typename T, typename CapT>
bool ensureArrayCapacity(T*& buf, CapT& capacity, const uint32_t needed) {
if (buf && capacity >= needed) return true;
delete[] buf;
buf = new (std::nothrow) T[needed > 0 ? needed : 1];
capacity = buf ? static_cast<CapT>(needed) : 0;
return buf != nullptr;
}
} // namespace } // namespace
SdCardFont::~SdCardFont() { freeAll(); } SdCardFont::~SdCardFont() { freeAll(); }
@@ -99,9 +83,6 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) {
s.miniBitmap = nullptr; s.miniBitmap = nullptr;
s.miniIntervalCount = 0; s.miniIntervalCount = 0;
s.miniGlyphCount = 0; s.miniGlyphCount = 0;
s.miniIntervalCapacity = 0;
s.miniGlyphCapacity = 0;
s.miniBitmapCapacity = 0;
freeStyleMiniKern(s); freeStyleMiniKern(s);
memset(&s.miniData, 0, sizeof(s.miniData)); memset(&s.miniData, 0, sizeof(s.miniData));
s.epdFont.data = &s.stubData; s.epdFont.data = &s.stubData;
@@ -128,9 +109,6 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) {
s.miniKernRightEntryCount = 0; s.miniKernRightEntryCount = 0;
s.miniKernLeftClassCount = 0; s.miniKernLeftClassCount = 0;
s.miniKernRightClassCount = 0; s.miniKernRightClassCount = 0;
s.miniKernLeftCapacity = 0;
s.miniKernRightCapacity = 0;
s.miniKernMatrixCapacity = 0;
} }
void SdCardFont::freeStyleAll(PerStyle& s) { void SdCardFont::freeStyleAll(PerStyle& s) {
@@ -333,13 +311,13 @@ bool SdCardFont::buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, ui
if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++; if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++;
} }
// Step 4: size the three mini buffers (reused across pages when they fit; the // Step 4: allocate the three mini buffers. The matrix is <1KB in practice
// per-page sizes vary by a few entries, which as free+realloc churn was punching // (<30 × <30 × 1 byte) so fragmentation is a non-issue.
// non-coalescing holes in the heap every page turn).
const uint32_t matrixBytes = static_cast<uint32_t>(numLeft) * numRight; const uint32_t matrixBytes = static_cast<uint32_t>(numLeft) * numRight;
if (!ensureArrayCapacity(s.miniKernLeftClasses, s.miniKernLeftCapacity, miniLeftCount) || s.miniKernLeftClasses = new (std::nothrow) EpdKernClassEntry[miniLeftCount];
!ensureArrayCapacity(s.miniKernRightClasses, s.miniKernRightCapacity, miniRightCount) || s.miniKernRightClasses = new (std::nothrow) EpdKernClassEntry[miniRightCount];
!ensureArrayCapacity(s.miniKernMatrix, s.miniKernMatrixCapacity, matrixBytes)) { s.miniKernMatrix = new (std::nothrow) int8_t[matrixBytes];
if (!s.miniKernLeftClasses || !s.miniKernRightClasses || !s.miniKernMatrix) {
LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u, LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u,
matrixBytes); matrixBytes);
freeStyleMiniKern(s); freeStyleMiniKern(s);
@@ -815,19 +793,12 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
return missed; return missed;
} }
// Build mini intervals from sorted codepoints. Reset counts and fall back to the // Build mini intervals from sorted codepoints
// stub until the rebuild completes, but KEEP the existing buffers (keep-if-fits freeStyleMiniData(s);
// reuse) — the free-and-realloc-per-page pattern here was a primary fragmenter.
s.miniIntervalCount = 0;
s.miniGlyphCount = 0;
s.miniKernLeftEntryCount = 0;
s.miniKernRightEntryCount = 0;
s.miniKernLeftClassCount = 0;
s.miniKernRightClassCount = 0;
memset(&s.miniData, 0, sizeof(s.miniData));
s.epdFont.data = &s.stubData;
if (!ensureArrayCapacity(s.miniIntervals, s.miniIntervalCapacity, validCount)) { uint32_t intervalCapacity = validCount;
s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity];
if (!s.miniIntervals) {
LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx); LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx);
delete[] mappings; delete[] mappings;
return static_cast<int>(cpCount); return static_cast<int>(cpCount);
@@ -845,14 +816,15 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
} }
} }
// Mini glyph array (reused across pages when it fits) // Allocate mini glyph array
if (!ensureArrayCapacity(s.miniGlyphs, s.miniGlyphCapacity, validCount)) { s.miniGlyphCount = validCount;
s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount];
if (!s.miniGlyphs) {
LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx); LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx);
delete[] mappings; delete[] mappings;
freeStyleMiniData(s); freeStyleMiniData(s);
return static_cast<int>(cpCount); return static_cast<int>(cpCount);
} }
s.miniGlyphCount = validCount;
// Build sorted read order for sequential I/O // Build sorted read order for sequential I/O
uint32_t* readOrder = new (std::nothrow) uint32_t[validCount]; uint32_t* readOrder = new (std::nothrow) uint32_t[validCount];
@@ -919,7 +891,8 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
totalBitmapSize += s.miniGlyphs[i].dataLength; totalBitmapSize += s.miniGlyphs[i].dataLength;
} }
if (!ensureArrayCapacity(s.miniBitmap, s.miniBitmapCapacity, totalBitmapSize)) { s.miniBitmap = new (std::nothrow) uint8_t[totalBitmapSize > 0 ? totalBitmapSize : 1];
if (!s.miniBitmap) {
LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx); LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx);
delete[] readOrder; delete[] readOrder;
delete[] mappings; delete[] mappings;
+1 -14
View File
@@ -168,22 +168,13 @@ class SdCardFont {
// Stub EpdFontData returned when not prewarmed // Stub EpdFontData returned when not prewarmed
EpdFontData stubData{}; EpdFontData stubData{};
// Mini EpdFontData built during prewarm. Buffers are kept-if-fits across pages // Mini EpdFontData built during prewarm
// (capacities below track allocated sizes): freeing and reallocating slightly
// different sizes on every page turn was a primary heap fragmenter — each page's
// freed hole rarely fit the next page's need, so maxAlloc eroded all session.
// After a few pages the capacities converge on the book's max and page turns
// stop allocating entirely. freeStyleMiniData() still releases everything (and
// zeroes capacities) for style eviction / font unload.
EpdFontData miniData{}; EpdFontData miniData{};
EpdUnicodeInterval* miniIntervals = nullptr; EpdUnicodeInterval* miniIntervals = nullptr;
EpdGlyph* miniGlyphs = nullptr; EpdGlyph* miniGlyphs = nullptr;
uint8_t* miniBitmap = nullptr; uint8_t* miniBitmap = nullptr;
uint32_t miniIntervalCount = 0; uint32_t miniIntervalCount = 0;
uint32_t miniGlyphCount = 0; uint32_t miniGlyphCount = 0;
uint32_t miniIntervalCapacity = 0;
uint32_t miniGlyphCapacity = 0;
uint32_t miniBitmapCapacity = 0;
// Per-page mini kern matrix (built by buildMiniKernMatrix on each full // Per-page mini kern matrix (built by buildMiniKernMatrix on each full
// prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints // prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints
@@ -198,10 +189,6 @@ class SdCardFont {
uint8_t miniKernLeftClassCount = 0; uint8_t miniKernLeftClassCount = 0;
uint8_t miniKernRightClassCount = 0; uint8_t miniKernRightClassCount = 0;
int8_t* miniKernMatrix = nullptr; int8_t* miniKernMatrix = nullptr;
// Kept-if-fits capacities, same rationale as the mini glyph buffers above.
uint16_t miniKernLeftCapacity = 0;
uint16_t miniKernRightCapacity = 0;
uint32_t miniKernMatrixCapacity = 0;
// The EpdFont whose data pointer we manage // The EpdFont whose data pointer we manage
EpdFont epdFont{&stubData}; EpdFont epdFont{&stubData};
+73
View File
@@ -0,0 +1,73 @@
#include "BookPages.h"
#include <algorithm>
#include <cmath>
#include <limits>
namespace {
// Coarse seed used only before any section has an exact or live count; replaced
// by the calibrated average as soon as one is available.
constexpr double DEFAULT_BYTES_PER_PAGE = 2000.0;
// A section's serialized pageCount is a uint16_t, so no estimate needs to exceed this.
constexpr int MAX_SECTION_PAGES = std::numeric_limits<uint16_t>::max();
int clampToInt(const uint64_t v) {
return v > static_cast<uint64_t>(std::numeric_limits<int>::max()) ? std::numeric_limits<int>::max()
: static_cast<int>(v);
}
} // namespace
BookPagePosition computeBookPagePosition(const BookPageEntry* entries, const int sectionCount, const int spineIndex,
const int pageInSection, const int liveSectionPages) {
BookPagePosition pos;
if (!entries || sectionCount <= 0) {
return pos;
}
// Calibrate bytes-per-page from every section with an exact count (empty
// known-0 chapters excluded), plus the live estimate for spineIndex when it
// has no exact count yet.
uint64_t knownBytes = 0;
uint64_t knownPages = 0;
for (int i = 0; i < sectionCount; ++i) {
if (entries[i].pages > 0) {
knownBytes += entries[i].bytes;
knownPages += static_cast<uint32_t>(entries[i].pages);
}
}
const bool useLive = spineIndex >= 0 && spineIndex < sectionCount && entries[spineIndex].pages < 0 &&
liveSectionPages > 0 && entries[spineIndex].bytes > 0;
if (useLive) {
knownBytes += entries[spineIndex].bytes;
knownPages += static_cast<uint32_t>(liveSectionPages);
}
const double bytesPerPage = (knownBytes > 0 && knownPages > 0)
? static_cast<double>(knownBytes) / static_cast<double>(knownPages)
: DEFAULT_BYTES_PER_PAGE;
uint64_t total = 0;
uint64_t before = 0;
bool exact = true;
for (int i = 0; i < sectionCount; ++i) {
uint32_t pages;
if (entries[i].pages >= 0) {
pages = static_cast<uint32_t>(entries[i].pages); // exact (0 = genuinely empty chapter)
} else if (i == spineIndex && liveSectionPages > 0) {
pages = static_cast<uint32_t>(std::min(liveSectionPages, MAX_SECTION_PAGES));
exact = false;
} else {
const double raw = std::floor(static_cast<double>(entries[i].bytes) / bytesPerPage + 0.5);
pages = static_cast<uint32_t>(std::clamp(raw, 1.0, static_cast<double>(MAX_SECTION_PAGES)));
exact = false;
}
total += pages;
if (i < spineIndex) {
before += pages;
}
}
pos.totalPages = clampToInt(total);
pos.currentPage = clampToInt(before + static_cast<uint64_t>(std::max(0, pageInSection)) + 1);
pos.isEstimate = !exact;
return pos;
}
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <cstdint>
// Whole-book page accounting for book-global "page X of Y".
//
// The finalized section cache files (sections/*.bin) are the single source of
// truth for exact per-section counts; the caller harvests them into an array of
// BookPageEntry (nothing is persisted separately). Sections without an exact
// count are estimated from their byte size, calibrated against the sections
// whose counts are known — so the total gets more accurate as more of the book
// is paginated, and becomes exact once every section is.
struct BookPageEntry {
uint32_t bytes = 0; // uncompressed XHTML size, for estimating unknown sections
int32_t pages = -1; // exact count from a finalized section cache; -1 = unknown (0 = empty chapter)
};
struct BookPagePosition {
int currentPage = 0;
int totalPages = 0;
bool isEstimate = true; // true until every section has an exact count
};
// Book-global position: currentPage = pages before spineIndex + pageInSection + 1.
// liveSectionPages is the in-progress build's estimate for spineIndex (see
// Section::estimatedTotalPages); it is used for that section when it has no exact
// count yet and folded into the bytes-per-page calibration. Pure function: no I/O.
BookPagePosition computeBookPagePosition(const BookPageEntry* entries, int sectionCount, int spineIndex,
int pageInSection, int liveSectionPages);
+38 -26
View File
@@ -39,8 +39,34 @@ constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) +
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
sizeof(uint32_t) + sizeof(uint32_t); sizeof(uint32_t) + sizeof(uint32_t);
// Read the render-parameter block of the header (everything between the version
// byte and pageCount), in writeSectionFileHeader order. The file cursor must sit
// just past the version byte.
Section::RenderParams readHeaderRenderParams(HalFile& f) {
Section::RenderParams p;
serialization::readPod(f, p.fontId);
serialization::readPod(f, p.lineCompression);
serialization::readPod(f, p.extraParagraphSpacing);
serialization::readPod(f, p.paragraphAlignment);
serialization::readPod(f, p.viewportWidth);
serialization::readPod(f, p.viewportHeight);
serialization::readPod(f, p.hyphenationEnabled);
serialization::readPod(f, p.embeddedStyle);
serialization::readPod(f, p.imageRendering);
serialization::readPod(f, p.focusReadingEnabled);
return p;
}
} // namespace } // namespace
bool Section::RenderParams::operator==(const RenderParams& o) const {
return fontId == o.fontId && lineCompression == o.lineCompression &&
extraParagraphSpacing == o.extraParagraphSpacing && paragraphAlignment == o.paragraphAlignment &&
viewportWidth == o.viewportWidth && viewportHeight == o.viewportHeight &&
hyphenationEnabled == o.hyphenationEnabled && embeddedStyle == o.embeddedStyle &&
imageRendering == o.imageRendering && focusReadingEnabled == o.focusReadingEnabled;
}
// Out-of-line so the unique_ptr<ChapterHtmlSlimParser> in BuildContext can be // Out-of-line so the unique_ptr<ChapterHtmlSlimParser> in BuildContext can be
// constructed/destroyed where the parser's full definition is visible. // constructed/destroyed where the parser's full definition is visible.
Section::Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer) Section::Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
@@ -133,31 +159,11 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
} }
filePartial = (version == SECTION_FILE_PARTIAL_VERSION); filePartial = (version == SECTION_FILE_PARTIAL_VERSION);
int fileFontId; const RenderParams fileParams = readHeaderRenderParams(file);
uint16_t fileViewportWidth, fileViewportHeight; const RenderParams params = {fontId, lineCompression, extraParagraphSpacing, paragraphAlignment,
float fileLineCompression; viewportWidth, viewportHeight, hyphenationEnabled, embeddedStyle,
bool fileExtraParagraphSpacing; imageRendering, focusReadingEnabled};
uint8_t fileParagraphAlignment; if (!(fileParams == params)) {
bool fileHyphenationEnabled;
bool fileEmbeddedStyle;
uint8_t fileImageRendering;
bool fileFocusReadingEnabled;
serialization::readPod(file, fileFontId);
serialization::readPod(file, fileLineCompression);
serialization::readPod(file, fileExtraParagraphSpacing);
serialization::readPod(file, fileParagraphAlignment);
serialization::readPod(file, fileViewportWidth);
serialization::readPod(file, fileViewportHeight);
serialization::readPod(file, fileHyphenationEnabled);
serialization::readPod(file, fileEmbeddedStyle);
serialization::readPod(file, fileImageRendering);
serialization::readPod(file, fileFocusReadingEnabled);
if (fontId != fileFontId || lineCompression != fileLineCompression ||
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle ||
imageRendering != fileImageRendering || focusReadingEnabled != fileFocusReadingEnabled) {
file.close(); file.close();
LOG_ERR("SCT", "Deserialization failed: Parameters do not match"); LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
clearCache(); clearCache();
@@ -754,7 +760,7 @@ std::string Section::getTextFromSectionFile() {
return fullText; return fullText;
} }
std::optional<uint16_t> Section::getCachedPageCount() const { std::optional<uint16_t> Section::getCachedPageCount(const RenderParams* mustMatch) const {
HalFile f; HalFile f;
if (!Storage.openFileForRead("SCT", filePath, f)) { if (!Storage.openFileForRead("SCT", filePath, f)) {
return std::nullopt; return std::nullopt;
@@ -774,6 +780,12 @@ std::optional<uint16_t> Section::getCachedPageCount() const {
return std::nullopt; return std::nullopt;
} }
// A file cached under other render settings paginates differently; its count is
// only stale-valid for rough mapping, so reject it when the caller needs a match.
if (mustMatch && !(readHeaderRenderParams(f) == *mustMatch)) {
return std::nullopt;
}
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t)); f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t));
uint16_t count; uint16_t count;
serialization::readPod(f, count); serialization::readPod(f, count);
+21 -1
View File
@@ -78,6 +78,23 @@ class Section {
std::unique_ptr<Page> loadPageDuringBuild(int page); std::unique_ptr<Page> loadPageDuringBuild(int page);
public: public:
// The render parameters that determine pagination, in section cache header order
// (see writeSectionFileHeader). A cached page count is only valid for one set.
struct RenderParams {
int fontId = 0;
float lineCompression = 0.0f;
bool extraParagraphSpacing = false;
uint8_t paragraphAlignment = 0;
uint16_t viewportWidth = 0;
uint16_t viewportHeight = 0;
bool hyphenationEnabled = false;
bool embeddedStyle = false;
uint8_t imageRendering = 0;
bool focusReadingEnabled = false;
bool operator==(const RenderParams& o) const;
};
uint16_t pageCount = 0; uint16_t pageCount = 0;
int currentPage = 0; int currentPage = 0;
@@ -144,7 +161,10 @@ class Section {
std::optional<uint16_t> findAnchorDuringBuild(const std::string& anchor) const; std::optional<uint16_t> findAnchorDuringBuild(const std::string& anchor) const;
// Get the page count from the section cache file without fully loading it. // Get the page count from the section cache file without fully loading it.
std::optional<uint16_t> getCachedPageCount() const; // Finalized files only (a partial's count is just a build watermark). When
// `mustMatch` is given, the header's render parameters must equal it, so a
// count cached under different settings is never trusted.
std::optional<uint16_t> getCachedPageCount(const RenderParams* mustMatch = nullptr) const;
// Look up the page number for a synthetic paragraph index from XPath p[N]. // Look up the page number for a synthetic paragraph index from XPath p[N].
std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const; std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const;
-14
View File
@@ -1451,20 +1451,6 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const
display.displayBuffer(refreshMode, fadingFix); display.displayBuffer(refreshMode, fadingFix);
} }
void GfxRenderer::displayBufferAsync(const HalDisplay::RefreshMode refreshMode) const {
// The async path has no turn-off-screen hook, which the sunlight fading fix
// relies on; keep those users on the blocking path.
if (fadingFix) {
display.displayBuffer(refreshMode, fadingFix);
return;
}
display.displayBufferAsync(refreshMode);
}
void GfxRenderer::waitRefreshComplete() const { display.waitRefreshComplete(); }
bool GfxRenderer::supportsAsyncRefresh() const { return !fadingFix && display.supportsAsyncRefresh(); }
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
const EpdFontFamily::Style style) const { const EpdFontFamily::Style style) const {
if (!text || maxWidth <= 0) return ""; if (!text || maxWidth <= 0) return "";
-11
View File
@@ -135,17 +135,6 @@ class GfxRenderer {
int getScreenWidth() const; int getScreenWidth() const;
int getScreenHeight() const; int getScreenHeight() const;
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
// Non-blocking refresh: starts the waveform and returns so CPU work (e.g.
// grayscale strip rendering) can overlap the panel's refresh time. The
// framebuffer must stay untouched until waitRefreshComplete(). Falls back to
// a blocking refresh when fadingFix is enabled or the panel lacks deferral
// support. See HalDisplay::displayBufferAsync for the baseline contract.
void displayBufferAsync(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
void waitRefreshComplete() const;
// True when displayBufferAsync() genuinely overlaps: panel defers and
// fadingFix isn't forcing the blocking path. Callers can skip overlap
// scaffolding (e.g. whole-plane grayscale buffers) when false.
bool supportsAsyncRefresh() const;
// EXPERIMENTAL: Windowed update - display only a rectangular region // EXPERIMENTAL: Windowed update - display only a rectangular region
// void displayWindow(int x, int y, int width, int height) const; // void displayWindow(int x, int y, int width, int height) const;
void invertScreen() const; void invertScreen() const;
+1 -1
View File
@@ -229,7 +229,7 @@ STR_OK_BUTTON: "OK"
STR_SLEEP_COVER_FILTER: "Sleep Screen Cover Filter" STR_SLEEP_COVER_FILTER: "Sleep Screen Cover Filter"
STR_FILTER_CONTRAST: "Contrast" STR_FILTER_CONTRAST: "Contrast"
STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar" STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar"
STR_CHAPTER_PAGE_COUNT: "Chapter Page Count" STR_CHAPTER_PAGE_COUNT: "Page Count"
STR_BOOK_PROGRESS_PERCENTAGE: "Book Progress Percentage" STR_BOOK_PROGRESS_PERCENTAGE: "Book Progress Percentage"
STR_PROGRESS_BAR: "Progress Bar" STR_PROGRESS_BAR: "Progress Bar"
STR_PROGRESS_BAR_THICKNESS: "Progress Bar Thickness" STR_PROGRESS_BAR_THICKNESS: "Progress Bar Thickness"
+4 -18
View File
@@ -22,21 +22,7 @@ constexpr char DEVICE_ID[] = "crosspoint-reader";
// footprint is smaller than mbedTLS's old ~48KB peak, but keep a conservative // footprint is smaller than mbedTLS's old ~48KB peak, but keep a conservative
// floor. Check both total free heap and largest contiguous block so fragmented // floor. Check both total free heap and largest contiguous block so fragmented
// heap does not fall through into a failed TLS allocation path. // heap does not fall through into a failed TLS allocation path.
// MEMFIX-PORT: TLS heap gate; portable constexpr uint32_t MIN_HEAP_FOR_TLS = 55000;
// Field data (July 2026): launching sync from a reader session lands at
// 51.9-58.2 KB free / 42-53 KB maxAlloc after WiFi comes up. wolfSSL handles
// allocation failure by returning MEMORY_E (no abort under -fno-exceptions),
// so an optimistic attempt degrades to the same clean "sync failed" as the
// gate — the gate only needs to keep out states where a doomed handshake
// would waste tens of seconds, not guarantee success.
//
// Free and largest-block have separate requirements: with SP ECC
// (WOLFSSL_HAVE_SP_ECC) the handshake's crypto uses fixed 256-bit arrays, so
// the largest single TLS allocation is the ~17 KB wolfSSL record buffer, not
// a run of fast-math bignums. A handshake was measured succeeding inside a
// 43 KB largest block; requiring 50 KB contiguous refused syncs that fit.
constexpr uint32_t MIN_FREE_FOR_TLS = 50000;
constexpr uint32_t MIN_BLOCK_FOR_TLS = 20000;
// Apply the shared KOSync auth headers after begin(). x-auth-* is the native // Apply the shared KOSync auth headers after begin(). x-auth-* is the native
// KOSync scheme; Basic auth is added for Calibre-Web-Automated compatibility. // KOSync scheme; Basic auth is added for Calibre-Web-Automated compatibility.
@@ -53,9 +39,9 @@ void applyAuthHeaders(freeink::SecureHttpClient& http) {
bool insufficientHeap() { bool insufficientHeap() {
const uint32_t freeHeap = ESP.getFreeHeap(); const uint32_t freeHeap = ESP.getFreeHeap();
const uint32_t maxAllocHeap = ESP.getMaxAllocHeap(); const uint32_t maxAllocHeap = ESP.getMaxAllocHeap();
if (freeHeap < MIN_FREE_FOR_TLS || maxAllocHeap < MIN_BLOCK_FOR_TLS) { if (freeHeap < MIN_HEAP_FOR_TLS || maxAllocHeap < MIN_HEAP_FOR_TLS) {
LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free (need %u), %u max alloc (need %u)", freeHeap, LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free, %u max alloc (need %u)", freeHeap,
MIN_FREE_FOR_TLS, maxAllocHeap, MIN_BLOCK_FOR_TLS); maxAllocHeap, MIN_HEAP_FOR_TLS);
return true; return true;
} }
return false; return false;
-12
View File
@@ -65,18 +65,6 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen)
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen); einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
} }
void HalDisplay::displayBufferAsync(HalDisplay::RefreshMode mode) {
if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) {
einkDisplay.requestResync(1);
}
einkDisplay.displayBufferAsyncNoShadow(convertRefreshMode(mode));
}
void HalDisplay::waitRefreshComplete() { einkDisplay.waitRefreshComplete(); }
bool HalDisplay::supportsAsyncRefresh() const { return einkDisplay.supportsAsyncRefresh(); }
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) { void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) { if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) {
einkDisplay.requestResync(1); einkDisplay.requestResync(1);
-11
View File
@@ -39,17 +39,6 @@ class HalDisplay {
bool fromProgmem = false) const; bool fromProgmem = false) const;
void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
// Non-blocking refresh (shadow-free): starts the panel waveform and returns
// while the panel refreshes on its own. The framebuffer must stay untouched
// until waitRefreshComplete(), and the caller must rebuild the differential
// baseline before the next differential update (the tiled grayscale cleanup
// does). Panels without deferral fall back to a blocking refresh.
void displayBufferAsync(RefreshMode mode = RefreshMode::FAST_REFRESH);
// Block until a pending deferred refresh completes (no-op when none is).
void waitRefreshComplete();
// True when displayBufferAsync() genuinely overlaps (panel driver defers);
// false where it falls back to a blocking refresh.
bool supportsAsyncRefresh() const;
void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
// Power management // Power management
+2 -53
View File
@@ -13,10 +13,7 @@ framework = arduino
monitor_speed = 115200 monitor_speed = 115200
upload_speed = 921600 upload_speed = 921600
check_tool = cppcheck check_tool = cppcheck
; missingInclude (project headers) is suppressed alongside missingIncludeSystem: on a check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
; fresh CI checkout cppcheck has no resolved include paths, so it reports every
; project header as missing (~400 information-level lines) and fails the job.
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=missingInclude --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
check_skip_packages = yes check_skip_packages = yes
board_upload.flash_size = 16MB board_upload.flash_size = 16MB
@@ -45,14 +42,7 @@ build_flags =
-DWOLFSSL_OPTIONS_H -DWOLFSSL_OPTIONS_H
-DWOLFSSL_CLIENT_EXAMPLE -DWOLFSSL_CLIENT_EXAMPLE
-DWOLFSSL_TLS13 -DWOLFSSL_TLS13
# MEMFIX-PORT: single-precision ECC (sp_c32.c). Without it every P-256 operation -DWOLFSSL_SP_RISCV32
# (TLS 1.3 key_share keygen, ECDHE, ECDSA cert verify) runs on fast-math bignums
# that WOLFSSL_SMALL_STACK heap-allocates at FP_MAX_BITS size -- tens of KB of
# temporaries, which OOMs (MP_MEM) at the ~50KB free heap a reading session
# leaves. SP uses fixed 256-bit arrays: a few KB, and several times faster.
# SP_SMALL trades the large precomputed point tables for smaller flash.
-DWOLFSSL_HAVE_SP_ECC
-DWOLFSSL_SP_SMALL
-DHAVE_TLS_EXTENSIONS -DHAVE_TLS_EXTENSIONS
-DHAVE_SUPPORTED_CURVES -DHAVE_SUPPORTED_CURVES
-DHAVE_HKDF -DHAVE_HKDF
@@ -73,47 +63,6 @@ board_build.flash_mode = dio
board_build.flash_size = 16MB board_build.flash_size = 16MB
board_build.partitions = partitions.csv board_build.partitions = partitions.csv
; MEMFIX-PORT: custom_sdkconfig heap reclamation (~32-37 KB). Rebuilds the
; Arduino core libs on first build (slower once, cached after; needs the CMake
; pin in platformio.local.ini on macOS).
;
; If an interrupted rebuild fails with "multiple definition of 'app_main'"
; (stale generated scaffold), clean it up with:
; rm -rf .dummy CMakeLists.txt sdkconfig.default sdkconfig.defaults .pio/build/default
; Do NOT use `git clean -fdX` — it deletes platformio.local.ini.
custom_sdkconfig =
; Task stack right-sizing from measured high-water marks (heap block map +
; per-task stack audit, July 2026): esp_timer used ~0.8 KB of 8 KB across
; every capture; the FreeRTOS timer service used ~0.5 KB
; of 4 KB. Neither runs TLS or app code. ~7 KB back to the heap.
CONFIG_ESP_TIMER_TASK_STACK_SIZE=4096
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=2560
; Move the WiFi stack's non-critical hot paths out of IRAM into flash.
; On the C3, IRAM and DRAM share one SRAM pool, so the ~25-30 KB this
; frees lands directly in the heap — paid for with lower WiFi throughput
; during transfers (occasional sync/OTA use, not streaming: acceptable).
; IRAM cost is static, so the heap gain applies even with WiFi off.
CONFIG_ESP_WIFI_IRAM_OPT=n
CONFIG_ESP_WIFI_RX_IRAM_OPT=n
; Keep the Arduino wrappers for the removed cloud components (below) out of
; the core source list; all other bundled libraries default to enabled.
CONFIG_ARDUINO_SELECTIVE_COMPILATION=y
CONFIG_ARDUINO_SELECTIVE_RainMaker=n
CONFIG_ARDUINO_SELECTIVE_Insights=n
; Drop unused cloud components from the core rebuild. esp_insights/rainmaker
; require embedded server certs the lib builder can't generate
; ("https_server.crt.S not found"); this firmware uses none of them.
custom_component_remove =
espressif/esp_insights
espressif/esp_rainmaker
espressif/esp_diagnostics
espressif/esp_diag_data_store
espressif/esp_schedule
espressif/esp_rcp_update
espressif/esp_secure_cert_mgr
espressif/cbor
extra_scripts = extra_scripts =
pre:scripts/patch_wolfssl.py pre:scripts/patch_wolfssl.py
pre:scripts/build_html.py pre:scripts/build_html.py
+1 -5
View File
@@ -12,12 +12,8 @@ OVERRIDES = f"""
#ifndef HAVE_FFDHE_2048 #ifndef HAVE_FFDHE_2048
#define HAVE_FFDHE_2048 #define HAVE_FFDHE_2048
#endif #endif
/* MEMFIX-PORT: 8192 handles up to RSA-4096 keys (the public-CA maximum,
ISRG Root X1 included) with half the per-bignum heap of 16384: with
WOLFSSL_SMALL_STACK each fast-math temp is FP_MAX_BITS/8 * 2 bytes on the
heap, and TLS cert verification allocates dozens at once. */
#undef FP_MAX_BITS #undef FP_MAX_BITS
#define FP_MAX_BITS 8192 #define FP_MAX_BITS 16384
""" """
+10 -1
View File
@@ -58,6 +58,14 @@ class CrossPointSettings {
HIDE_PROGRESS = 2, HIDE_PROGRESS = 2,
STATUS_BAR_PROGRESS_BAR_COUNT STATUS_BAR_PROGRESS_BAR_COUNT
}; };
// Values 0/1 keep the meaning of the old show/hide toggle (persisted under the
// legacy "statusBarChapterPageCount" key), so existing settings files load as-is.
enum STATUS_BAR_PAGE_COUNT {
HIDE_PAGE_COUNT = 0,
CHAPTER_PAGE_COUNT = 1,
BOOK_PAGE_COUNT = 2,
STATUS_BAR_PAGE_COUNT_COUNT
};
enum STATUS_BAR_PROGRESS_BAR_THICKNESS { enum STATUS_BAR_PROGRESS_BAR_THICKNESS {
PROGRESS_BAR_THIN = 0, PROGRESS_BAR_THIN = 0,
PROGRESS_BAR_NORMAL = 1, PROGRESS_BAR_NORMAL = 1,
@@ -189,7 +197,8 @@ class CrossPointSettings {
uint8_t sleepScreenCoverFilter = NO_FILTER; uint8_t sleepScreenCoverFilter = NO_FILTER;
// Status bar settings (statusBar retained for migration only) // Status bar settings (statusBar retained for migration only)
uint8_t statusBar = FULL; uint8_t statusBar = FULL;
uint8_t statusBarChapterPageCount = 1; // STATUS_BAR_PAGE_COUNT; persisted under the legacy "statusBarChapterPageCount" key.
uint8_t statusBarPageCount = CHAPTER_PAGE_COUNT;
uint8_t statusBarBookProgressPercentage = 1; uint8_t statusBarBookProgressPercentage = 1;
uint8_t statusBarProgressBar = HIDE_PROGRESS; uint8_t statusBarProgressBar = HIDE_PROGRESS;
uint8_t statusBarProgressBarThickness = PROGRESS_BAR_NORMAL; uint8_t statusBarProgressBarThickness = PROGRESS_BAR_NORMAL;
+6 -6
View File
@@ -21,35 +21,35 @@
void applyLegacyStatusBarSettings(CrossPointSettings& settings) { void applyLegacyStatusBarSettings(CrossPointSettings& settings) {
switch (static_cast<CrossPointSettings::STATUS_BAR_MODE>(settings.statusBar)) { switch (static_cast<CrossPointSettings::STATUS_BAR_MODE>(settings.statusBar)) {
case CrossPointSettings::NONE: case CrossPointSettings::NONE:
settings.statusBarChapterPageCount = 0; settings.statusBarPageCount = CrossPointSettings::HIDE_PAGE_COUNT;
settings.statusBarBookProgressPercentage = 0; settings.statusBarBookProgressPercentage = 0;
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
settings.statusBarTitle = CrossPointSettings::HIDE_TITLE; settings.statusBarTitle = CrossPointSettings::HIDE_TITLE;
settings.statusBarBattery = 0; settings.statusBarBattery = 0;
break; break;
case CrossPointSettings::NO_PROGRESS: case CrossPointSettings::NO_PROGRESS:
settings.statusBarChapterPageCount = 0; settings.statusBarPageCount = CrossPointSettings::HIDE_PAGE_COUNT;
settings.statusBarBookProgressPercentage = 0; settings.statusBarBookProgressPercentage = 0;
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
settings.statusBarBattery = 1; settings.statusBarBattery = 1;
break; break;
case CrossPointSettings::BOOK_PROGRESS_BAR: case CrossPointSettings::BOOK_PROGRESS_BAR:
settings.statusBarChapterPageCount = 1; settings.statusBarPageCount = CrossPointSettings::CHAPTER_PAGE_COUNT;
settings.statusBarBookProgressPercentage = 0; settings.statusBarBookProgressPercentage = 0;
settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS; settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS;
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
settings.statusBarBattery = 1; settings.statusBarBattery = 1;
break; break;
case CrossPointSettings::ONLY_BOOK_PROGRESS_BAR: case CrossPointSettings::ONLY_BOOK_PROGRESS_BAR:
settings.statusBarChapterPageCount = 1; settings.statusBarPageCount = CrossPointSettings::CHAPTER_PAGE_COUNT;
settings.statusBarBookProgressPercentage = 0; settings.statusBarBookProgressPercentage = 0;
settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS; settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS;
settings.statusBarTitle = CrossPointSettings::HIDE_TITLE; settings.statusBarTitle = CrossPointSettings::HIDE_TITLE;
settings.statusBarBattery = 0; settings.statusBarBattery = 0;
break; break;
case CrossPointSettings::CHAPTER_PROGRESS_BAR: case CrossPointSettings::CHAPTER_PROGRESS_BAR:
settings.statusBarChapterPageCount = 0; settings.statusBarPageCount = CrossPointSettings::HIDE_PAGE_COUNT;
settings.statusBarBookProgressPercentage = 1; settings.statusBarBookProgressPercentage = 1;
settings.statusBarProgressBar = CrossPointSettings::CHAPTER_PROGRESS; settings.statusBarProgressBar = CrossPointSettings::CHAPTER_PROGRESS;
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
@@ -57,7 +57,7 @@ void applyLegacyStatusBarSettings(CrossPointSettings& settings) {
break; break;
case CrossPointSettings::FULL: case CrossPointSettings::FULL:
default: default:
settings.statusBarChapterPageCount = 1; settings.statusBarPageCount = CrossPointSettings::CHAPTER_PAGE_COUNT;
settings.statusBarBookProgressPercentage = 1; settings.statusBarBookProgressPercentage = 1;
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
+4 -2
View File
@@ -233,8 +233,10 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
}, },
"koSendMetadata", StrId::STR_KOREADER_SYNC), "koSendMetadata", StrId::STR_KOREADER_SYNC),
// --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) ---
SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount, // Key kept from the old show/hide toggle so existing settings files load unchanged.
"statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR), SettingInfo::Enum(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarPageCount,
{StrId::STR_HIDE, StrId::STR_CHAPTER, StrId::STR_BOOK}, "statusBarChapterPageCount",
StrId::STR_CUSTOMISE_STATUS_BAR),
SettingInfo::Toggle(StrId::STR_BOOK_PROGRESS_PERCENTAGE, &CrossPointSettings::statusBarBookProgressPercentage, SettingInfo::Toggle(StrId::STR_BOOK_PROGRESS_PERCENTAGE, &CrossPointSettings::statusBarBookProgressPercentage,
"statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR), "statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR),
SettingInfo::Enum(StrId::STR_PROGRESS_BAR, &CrossPointSettings::statusBarProgressBar, SettingInfo::Enum(StrId::STR_PROGRESS_BAR, &CrossPointSettings::statusBarProgressBar,
+121 -160
View File
@@ -44,6 +44,22 @@ constexpr int PAGE_TURN_RATES[] = {1, 1, 3, 6, 12};
constexpr size_t initialBookmarkCacheCapacity = 16; constexpr size_t initialBookmarkCacheCapacity = 16;
constexpr float bookmarkProgressEpsilon = 0.0001f; constexpr float bookmarkProgressEpsilon = 0.0001f;
// The render parameters the reader paginates with, as passed to loadSectionFile /
// createSectionFile / startBuild below. Used to validate other sections' cached
// page counts and to detect when a settings change invalidates the harvest.
Section::RenderParams readerRenderParams(const uint16_t viewportWidth, const uint16_t viewportHeight) {
return {SETTINGS.getReaderFontId(),
SETTINGS.getReaderLineCompression(),
static_cast<bool>(SETTINGS.extraParagraphSpacing),
SETTINGS.paragraphAlignment,
viewportWidth,
viewportHeight,
static_cast<bool>(SETTINGS.hyphenationEnabled),
static_cast<bool>(SETTINGS.embeddedStyle),
SETTINGS.imageRendering,
static_cast<bool>(SETTINGS.focusReadingEnabled)};
}
int clampPercent(int percent) { int clampPercent(int percent) {
if (percent < 0) { if (percent < 0) {
return 0; return 0;
@@ -257,18 +273,6 @@ void EpubReaderActivity::openReaderMenu() {
}); });
} }
bool EpubReaderActivity::buildTickHeapGate() {
const size_t freeHeap = ESP.getFreeHeap();
const size_t maxBlock = ESP.getMaxAllocHeap();
// Below the floors: just wait. The tick is deferrable — page-turn transients
// free up between turns and the tick retries every loop pass. Track the
// paused state so skipLoopDelay() stops pinning the CPU at full speed while
// no build work is actually happening (the gate can stay closed for a long
// stretch if the retained build context itself holds the heap down).
buildHeapPaused = freeHeap < BACKGROUND_BUILD_MIN_FREE_HEAP || maxBlock < BACKGROUND_BUILD_MIN_MAX_ALLOC;
return !buildHeapPaused;
}
void EpubReaderActivity::loop() { void EpubReaderActivity::loop() {
if (!epub) { if (!epub) {
// Should never happen // Should never happen
@@ -276,40 +280,6 @@ void EpubReaderActivity::loop() {
return; return;
} }
// Idle glyph prewarm for the likely next page (currentPage + 1). The scan
// pass draws nothing (FCM scan mode suppresses pixels), so the displayed
// framebuffer is untouched; endScanAndPrewarm loads only glyphs not already
// cached. Debounced past rapid page-flipping, one attempt per position, and
// deferred while a render/build owns the CPU or the heap is at the render
// floor. Cross-chapter prewarm is deliberately out of scope (next spine's
// section isn't loaded).
constexpr unsigned long IDLE_PREWARM_DEBOUNCE_MS = 400;
if (section && !section->isBuilding() && !RenderLock::peek() && renderer.hasFrameBuffer() &&
lastRenderCompleteMs != 0 && millis() - lastRenderCompleteMs > IDLE_PREWARM_DEBOUNCE_MS &&
ESP.getFreeHeap() > RENDER_MIN_FREE_HEAP && ESP.getMaxAllocHeap() > BACKGROUND_BUILD_MIN_MAX_ALLOC &&
(idlePrewarmSpine != currentSpineIndex || idlePrewarmPage != section->currentPage)) {
RenderLock lock; // the page table must not change under the scan
// Re-check under the lock: peek() and acquisition are not atomic, so the render
// task may have reset/replaced the section or moved the page in between.
if (section && !section->isBuilding() &&
(idlePrewarmSpine != currentSpineIndex || idlePrewarmPage != section->currentPage)) {
idlePrewarmSpine = currentSpineIndex;
idlePrewarmPage = section->currentPage;
const int nextPage = section->currentPage + 1;
if (nextPage < static_cast<int>(section->pageCount)) {
if (const auto p = section->loadPage(nextPage)) {
if (auto* fcm = renderer.getFontCacheManager()) {
const auto t0 = millis();
auto scope = fcm->createPrewarmScope();
p->render(renderer, SETTINGS.getReaderFontId(), 0, 0); // scan only, no pixels
scope.endScanAndPrewarm();
LOG_DBG("ERS", "Idle prewarm: page %d in %lums", nextPage, millis() - t0);
}
}
}
}
}
// Lazily resume a partial's extension build once the reader nears its watermark. Far from // Lazily resume a partial's extension build once the reader nears its watermark. Far from
// it the rebuild is all cost (whole-chapter re-layout from page 0) and no benefit this // it the rebuild is all cost (whole-chapter re-layout from page 0) and no benefit this
// session, so reopening a partial deliberately does NOT start it (see the deferral in // session, so reopening a partial deliberately does NOT start it (see the deferral in
@@ -345,17 +315,14 @@ void EpubReaderActivity::loop() {
// "far enough ahead" and stall the build at 0 pages -- then the first turn past the // "far enough ahead" and stall the build at 0 pages -- then the first turn past the
// watermark re-parses the whole chapter synchronously. Keep ticking until it finalizes. // watermark re-parses the whole chapter synchronously. Keep ticking until it finalizes.
if (section && section->isBuilding() && !RenderLock::peek() && if (section && section->isBuilding() && !RenderLock::peek() &&
(section->isPartial() || static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) && (section->isPartial() || static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD)) {
buildTickHeapGate()) {
RenderLock lock; RenderLock lock;
// Re-check under the lock: render() (which also holds the RenderLock) may have finalized the // Re-check under the lock: render() (which also holds the RenderLock) may have finalized the
// build between the outer isBuilding() check and acquiring the lock here, in which case // build between the outer isBuilding() check and acquiring the lock here, in which case
// buildSomeMore() would fail and wrongly reset the section. The heap gate must be re-read // buildSomeMore() would fail and wrongly reset the section. cppcheck can't see the cross-task
// too: a render that won the lock race can expand retained glyph buffers, invalidating the // mutation, so it flags this as always true.
// pre-lock heap reading. cppcheck can't see the cross-task mutation, so it flags this as
// always true.
// cppcheck-suppress knownConditionTrueFalse // cppcheck-suppress knownConditionTrueFalse
if (section->isBuilding() && buildTickHeapGate()) { if (section->isBuilding()) {
if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) { if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) {
LOG_ERR("ERS", "Background section build failed"); LOG_ERR("ERS", "Background section build failed");
section.reset(); section.reset();
@@ -368,6 +335,25 @@ void EpubReaderActivity::loop() {
} }
} }
// Whole-book page counter: harvest one other section's cached page count per tick
// (a header-only peek), so the total converges to exact without visiting every
// chapter. Idle-priority — skipped whenever a render is pending or a build is
// running. No re-render is requested; the counter refreshes on the next page turn.
if (bookPages && bookPagesSweepIndex < epub->getSpineItemsCount() && !RenderLock::peek() &&
!(section && section->isBuilding())) {
RenderLock lock;
// Re-check under the lock: render() may have just reset/reallocated the table.
if (bookPages && bookPagesSweepIndex < epub->getSpineItemsCount()) {
const int index = bookPagesSweepIndex++;
if (bookPages[index].pages < 0) {
const Section peekSection(epub, index, renderer);
if (const auto count = peekSection.getCachedPageCount(&bookPagesParams)) {
bookPages[index].pages = *count;
}
}
}
}
// End-of-Book screen reached (currentSpineIndex == spine count) means the book is // End-of-Book screen reached (currentSpineIndex == spine count) means the book is
// finished. Two independent finished-book features key off this same condition. // finished. Two independent finished-book features key off this same condition.
const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount(); const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount();
@@ -1029,6 +1015,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
buildViewportWidth = viewportWidth; buildViewportWidth = viewportWidth;
buildViewportHeight = viewportHeight; buildViewportHeight = viewportHeight;
ensureBookPages(viewportWidth, viewportHeight);
if (!section) { if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex).href; const auto filepath = epub->getSpineItem(currentSpineIndex).href;
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex); LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
@@ -1271,6 +1259,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// a plain resume / unchanged pagination). If still building, this defers to loop() on completion. // a plain resume / unchanged pagination). If still building, this defers to loop() on completion.
applyDeferredReposition(); applyDeferredReposition();
recordCurrentSectionPages();
renderer.clearScreen(); renderer.clearScreen();
if (section->pageCount == 0) { if (section->pageCount == 0) {
@@ -1331,7 +1321,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
const auto start = millis(); const auto start = millis();
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
LOG_DBG("ERS", "Rendered page in %dms", millis() - start); LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
lastRenderCompleteMs = millis();
} }
// Only persist when the position actually changed. render() also runs on menu, // Only persist when the position actually changed. render() also runs on menu,
// bookmark and screenshot re-renders, and writeAtomic is several FAT ops for 6 bytes. // bookmark and screenshot re-renders, and writeAtomic is several FAT ops for 6 bytes.
@@ -1381,6 +1370,59 @@ bool EpubReaderActivity::applyDeferredReposition() {
return changed; return changed;
} }
void EpubReaderActivity::ensureBookPages(const uint16_t viewportWidth, const uint16_t viewportHeight) {
if (SETTINGS.statusBarPageCount != CrossPointSettings::STATUS_BAR_PAGE_COUNT::BOOK_PAGE_COUNT) {
bookPages.reset();
return;
}
const Section::RenderParams params = readerRenderParams(viewportWidth, viewportHeight);
if (bookPages && params == bookPagesParams) {
return;
}
// First render, or a render-params change (font/orientation/...): the harvested
// counts are for the old pagination, so start over. The section caches themselves
// are the persistence; there is nothing else to invalidate.
bookPagesParams = params;
bookPagesSweepIndex = 0;
bookPages.reset();
const int sectionCount = epub->getSpineItemsCount();
if (sectionCount <= 0) {
return;
}
// 8 bytes per spine item, held for the reading session; freed with the activity
// or when the feature is switched off. On OOM stays null (chapter-local counts).
bookPages = makeUniqueNoThrow<BookPageEntry[]>(sectionCount);
if (!bookPages) {
LOG_ERR("ERS", "OOM: book page table (%d sections)", sectionCount);
return;
}
size_t prev = 0;
for (int i = 0; i < sectionCount; ++i) {
const size_t cum = epub->getCumulativeSpineItemSize(i);
bookPages[i].bytes = static_cast<uint32_t>(cum >= prev ? cum - prev : 0);
prev = cum;
}
}
void EpubReaderActivity::recordCurrentSectionPages() {
// Only a finalized section's pageCount is the chapter total; a building or
// partial section's is just its current watermark.
if (!bookPages || !section || section->isBuilding() || section->isPartial()) {
return;
}
if (currentSpineIndex >= 0 && currentSpineIndex < epub->getSpineItemsCount()) {
bookPages[currentSpineIndex].pages = section->pageCount;
}
}
std::optional<BookPagePosition> EpubReaderActivity::bookPagePosition() const {
if (!bookPages || !section) {
return std::nullopt;
}
return computeBookPagePosition(bookPages.get(), epub->getSpineItemsCount(), currentSpineIndex, section->currentPage,
section->estimatedTotalPages());
}
bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) { bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
return EpubReaderUtils::saveProgress(*epub, spineIndex, currentPage, pageCount); return EpubReaderUtils::saveProgress(*epub, spineIndex, currentPage, pageCount);
} }
@@ -1401,13 +1443,6 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const bool pageHasImagesNeedingDecode = pageHasImages && page->hasImagesNeedingDecode(); const bool pageHasImagesNeedingDecode = pageHasImages && page->hasImagesNeedingDecode();
const bool needsTextGrayscale = SETTINGS.textAntiAliasing; const bool needsTextGrayscale = SETTINGS.textAntiAliasing;
const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages; const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages;
const bool tiledGrayscale = needsAnyGrayscale && renderer.supportsStripGrayscale();
// Whole-plane buffering only pays when the BW refresh genuinely runs async
// underneath it; on blocking panels (X3) it would just spend ~50 KB for the
// identical serial timing. Image pages take the blocking double-FAST path
// below (no async refresh is ever started), so they'd spend the buffers with
// nothing in flight to overlap.
const bool overlapRefresh = tiledGrayscale && renderer.supportsAsyncRefresh() && !pageHasImages;
auto renderGrayscalePass = [&]() { auto renderGrayscalePass = [&]() {
if (needsTextGrayscale) { if (needsTextGrayscale) {
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop); page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
@@ -1453,112 +1488,28 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
// regardless of residue. // regardless of residue.
pagesUntilFullRefresh = 1; pagesUntilFullRefresh = 1;
} else { } else {
// Async form: start the waveform and return so the grayscale plane rendering ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
// below overlaps the panel's refresh time instead of following it.
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh, overlapRefresh);
} }
const auto tDisplay = millis(); const auto tDisplay = millis();
// Tiled grayscale: render each plane band-by-band, leaving the BW // Tiled grayscale: render each plane band-by-band into a small scratch and
// framebuffer intact so no full-frame storeBwBuffer is needed; controller // stream straight to the controller, leaving the BW framebuffer intact so no
// RAM is re-synced from the live framebuffer afterward. The page is // full-frame storeBwBuffer is needed; controller RAM is re-synced from the
// re-rendered ceil(H/STRIP_ROWS) times per plane, but renderCharImpl culls // live framebuffer afterward. The page is re-rendered ceil(H/STRIP_ROWS) times
// out-of-band glyphs before decode so the cost stays close to one render. // per plane, but renderCharImpl culls out-of-band glyphs before decode so the
// Both text (drawPixel) and images (DirectPixelWriter) honor the active // cost stays close to one render. Both text (drawPixel) and images
// strip target. When the BW refresh above went out async, the plane // (DirectPixelWriter) honor the active strip target.
// rendering below overlaps the panel's refresh time; only the controller if (needsAnyGrayscale && renderer.supportsStripGrayscale()) {
// RAM writes wait for BUSY.
if (tiledGrayscale) {
constexpr int STRIP_ROWS = 80; constexpr int STRIP_ROWS = 80;
const int gh = renderer.getDisplayHeight(); const int gh = renderer.getDisplayHeight();
const int gwBytes = renderer.getDisplayWidthBytes(); const int gwBytes = renderer.getDisplayWidthBytes();
const size_t planeBytes = static_cast<size_t>(gwBytes) * gh;
// Render one plane band-by-band into a whole-plane buffer without touching
// the controller, so it can run while the refresh is still in flight.
auto renderPlaneToBuffer = [&](const bool lsbPlane, uint8_t* buf) {
renderer.setRenderMode(lsbPlane ? GfxRenderer::GRAYSCALE_LSB : GfxRenderer::GRAYSCALE_MSB);
for (int y = 0; y < gh; y += STRIP_ROWS) {
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
renderer.beginStripTarget(buf + static_cast<size_t>(y) * gwBytes, y, rows);
renderer.clearScreen(0x00);
renderGrayscalePass();
renderer.endStripTarget();
}
};
// Tiered on heap pressure: two plane buffers hide both plane renders
// inside the refresh wait; one hides the LSB render (its buffer is reused
// for MSB after streaming); none falls back to the strip-scratch flow with
// no overlap. Each buffer is only attempted when it leaves ~60 KB free so
// the pass never starves concurrent allocations: the next page re-render
// allocates through throwing std::string paths that abort() on OOM under
// -fno-exceptions, so a plane buffer that "fits" but eats the render
// headroom is worse than the strip fallback. Blocking panels skip the
// buffers entirely (nothing to overlap).
constexpr size_t PLANE_BUF_HEADROOM = 60000;
// Free-heap alone ignores fragmentation: taking the largest block for a
// plane can leave only slivers behind even when total headroom looks fine.
// Require the block to fit the plane with 16 KB contiguous to spare, which
// also keeps the advance-table batch scratch viable mid-render (same
// rationale as BACKGROUND_BUILD_MIN_MAX_ALLOC).
constexpr size_t PLANE_BUF_MAX_ALLOC_RESERVE = 16 * 1024;
const auto planeBufFits = [planeBytes] {
return ESP.getFreeHeap() >= planeBytes + PLANE_BUF_HEADROOM &&
ESP.getMaxAllocHeap() >= planeBytes + PLANE_BUF_MAX_ALLOC_RESERVE;
};
auto lsbPlaneBuf = (overlapRefresh && planeBufFits()) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
auto msbPlaneBuf = (lsbPlaneBuf && planeBufFits()) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
if (lsbPlaneBuf) {
renderPlaneToBuffer(true, lsbPlaneBuf.get());
if (msbPlaneBuf) renderPlaneToBuffer(false, msbPlaneBuf.get());
const auto tGrayRender = millis();
renderer.waitRefreshComplete();
const auto tWait = millis();
renderer.writeGrayscalePlaneStrip(true, lsbPlaneBuf.get(), 0, gh);
if (msbPlaneBuf) {
renderer.writeGrayscalePlaneStrip(false, msbPlaneBuf.get(), 0, gh);
} else {
renderPlaneToBuffer(false, lsbPlaneBuf.get());
renderer.writeGrayscalePlaneStrip(false, lsbPlaneBuf.get(), 0, gh);
}
const auto tGrayWrite = millis();
renderer.setRenderMode(GfxRenderer::BW);
renderer.displayGrayBuffer();
const auto tGrayDisplay = millis();
// BW framebuffer is intact; re-sync controller RAM for the next
// differential page turn directly from it.
renderer.cleanupGrayscaleWithFrameBuffer();
const auto tEnd = millis();
LOG_DBG("ERS",
"Page render (tiled async): prewarm=%lums bw_render=%lums display=%lums gray_render=%lums "
"wait=%lums gray_write=%lums gray_display=%lums cleanup=%lums total=%lums (planes buffered: %d)",
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayRender - tDisplay, tWait - tGrayRender,
tGrayWrite - tWait, tGrayDisplay - tGrayWrite, tEnd - tGrayDisplay, tEnd - t0, msbPlaneBuf ? 2 : 1);
} else {
// Per-strip scratch tier: blocking panels (X3) and the OOM fallback.
// The strip writes below need the panel idle, so wait out any pending
// async refresh first (no-op on blocking panels).
auto scratch = makeUniqueNoThrow<uint8_t[]>(static_cast<size_t>(gwBytes) * STRIP_ROWS); auto scratch = makeUniqueNoThrow<uint8_t[]>(static_cast<size_t>(gwBytes) * STRIP_ROWS);
renderer.waitRefreshComplete();
if (!scratch) { if (!scratch) {
LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS); LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS);
if (overlapRefresh) {
// The BW refresh ran the shadow-free async path, so controller RAM's
// differential baseline was never rebuilt. Even with AA skipped it must
// be re-synced from the intact BW framebuffer, or the next differential
// update diffs against stale contents.
renderer.cleanupGrayscaleWithFrameBuffer();
}
} else { } else {
// Bands may be streamed in any order: X4 windows each via setRamArea, // Bands may be streamed in any order: X4 windows each via setRamArea, X3
// X3 via PTL. // via PTL.
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
for (int y = 0; y < gh; y += STRIP_ROWS) { for (int y = 0; y < gh; y += STRIP_ROWS) {
const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS; const int rows = (gh - y < STRIP_ROWS) ? (gh - y) : STRIP_ROWS;
@@ -1598,7 +1549,6 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayLsb - tDisplay, tGrayMsb - tGrayLsb, tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayLsb - tDisplay, tGrayMsb - tGrayLsb,
tGrayDisplay - tGrayMsb, tCleanup - tGrayDisplay, tEnd - t0); tGrayDisplay - tGrayMsb, tCleanup - tGrayDisplay, tEnd - t0);
} }
}
} else { } else {
// Fallback path for a controller without strip support. grayscale rendering // Fallback path for a controller without strip support. grayscale rendering
// TODO: Only do this if font supports it // TODO: Only do this if font supports it
@@ -1654,10 +1604,21 @@ void EpubReaderActivity::renderStatusBar() const {
// Calculate progress in book. Use the estimated total while a giant spine is still building so // Calculate progress in book. Use the estimated total while a giant spine is still building so
// "page X of Y" and the progress bar don't read off the small build watermark. // "page X of Y" and the progress bar don't read off the small build watermark.
const int currentPage = section->currentPage + 1; const int currentPage = section->currentPage + 1;
const float pageCount = section->estimatedTotalPages(); const int pageCount = section->estimatedTotalPages();
const float sectionChapterProg = (pageCount > 0) ? (static_cast<float>(currentPage) / pageCount) : 0; const float sectionChapterProg = (pageCount > 0) ? (static_cast<float>(currentPage) / pageCount) : 0;
const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100; const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100;
// Page counter values: whole-book numbers when Page Count is set to Book (and the
// table is alive), otherwise chapter-local. The progress bar stays on bookProgress.
int counterPage = currentPage;
int counterTotal = pageCount;
bool counterIsEstimate = section->isBuilding();
if (const auto book = bookPagePosition()) {
counterPage = book->currentPage;
counterTotal = book->totalPages;
counterIsEstimate = book->isEstimate;
}
std::string title; std::string title;
int textYOffset = 0; int textYOffset = 0;
@@ -1685,8 +1646,8 @@ void EpubReaderActivity::renderStatusBar() const {
title = epub->getTitle(); title = epub->getTitle();
} }
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked, GUI.drawStatusBar(renderer, bookProgress, counterPage, counterTotal, title, 0, textYOffset, true,
section->isBuilding()); currentPageBookmarked, counterIsEstimate);
} }
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) { void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
+22 -38
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <Epub.h> #include <Epub.h>
#include <Epub/BookPages.h>
#include <Epub/FootnoteEntry.h> #include <Epub/FootnoteEntry.h>
#include <Epub/Section.h> #include <Epub/Section.h>
@@ -23,6 +24,17 @@ class EpubReaderActivity final : public Activity {
int pagesUntilFullRefresh = 0; int pagesUntilFullRefresh = 0;
int cachedSpineIndex = 0; int cachedSpineIndex = 0;
int cachedChapterTotalPageCount = 0; int cachedChapterTotalPageCount = 0;
// Whole-book page accounting ("page X of Y" across the whole book), active only
// when the status bar Page Count is set to Book. Exact counts are harvested from
// finalized section caches — nothing extra is persisted (see BookPages.h). Null
// while inactive or on OOM; every book-page path degrades to chapter-local counts.
// Shared between the render task and loop(): only touch under the RenderLock.
std::unique_ptr<BookPageEntry[]> bookPages;
// Render params the harvested counts are valid for; a change resets the harvest.
Section::RenderParams bookPagesParams;
// Next spine index for loop()'s background sweep that peeks other sections'
// cached counts (one per tick); >= spine count once the sweep is done.
int bookPagesSweepIndex = 0;
unsigned long lastPageTurnTime = 0UL; unsigned long lastPageTurnTime = 0UL;
unsigned long pageTurnDuration = 0UL; unsigned long pageTurnDuration = 0UL;
// Signals that the next render should reposition within the newly loaded section // Signals that the next render should reposition within the newly loaded section
@@ -41,13 +53,6 @@ class EpubReaderActivity final : public Activity {
bool showBookmarkMessage = false; bool showBookmarkMessage = false;
bool ignoreNextConfirmRelease = false; bool ignoreNextConfirmRelease = false;
bool currentPageBookmarked = false; bool currentPageBookmarked = false;
// Idle-time glyph prewarm: after a page settles, scan the LIKELY next page
// (scan mode draws nothing) and load its missing glyphs from SD during idle,
// so the next turn's in-render prewarm is a cache hit instead of ~100 ms of
// SD reads on the page-turn critical path. One attempt per position.
int idlePrewarmSpine = -1;
int idlePrewarmPage = -1;
unsigned long lastRenderCompleteMs = 0;
bool bookmarkRemoved = false; // true when last toggle removed (controls popup text) bool bookmarkRemoved = false; // true when last toggle removed (controls popup text)
std::vector<BookmarkEntry> cachedBookmarks; std::vector<BookmarkEntry> cachedBookmarks;
// Tracks whether this book is currently removed from Recent Books by the // Tracks whether this book is currently removed from Recent Books by the
@@ -93,33 +98,6 @@ class EpubReaderActivity final : public Activity {
// background build chunk never noticeably delays input or a pending render. // background build chunk never noticeably delays input or a pending render.
static constexpr int BUILD_PAGES_PER_CHUNK = 8; static constexpr int BUILD_PAGES_PER_CHUNK = 8;
static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2; static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2;
// MEMFIX-PORT: background-build heap floor; portable
// Skip background build ticks below this free-heap floor. The parse path grows
// word vectors of heap strings — throwing allocations that abort() on OOM under
// -fno-exceptions (field crash: bad_alloc in ParsedText::addWord during a
// background tick under heap pressure). The tick is deferrable work:
// page-turn transients free up between turns and the build resumes; the render
// path still builds the page it actually needs regardless of this floor.
static constexpr size_t BACKGROUND_BUILD_MIN_FREE_HEAP = 32 * 1024;
// Fragmentation floor for the same gate: a tick passed the free-heap floor at
// 34.7 KB free but the largest block was ~11 KB, and a parse allocation inside the
// tick aborted anyway. Free heap says how much memory exists; maxAlloc says whether
// any single allocation can actually have it. 16 KB also keeps the advance-table
// batch path (16 KB scratch) viable during builds.
static constexpr size_t BACKGROUND_BUILD_MIN_MAX_ALLOC = 16 * 1024;
// Gate for a background build tick: true when the heap can take parse allocations.
// Updates buildHeapPaused as a side effect.
bool buildTickHeapGate();
// True while the background build is gated on the heap floors. Lets skipLoopDelay()
// return the loop to normal delay/power-saving during the pause: isBuilding() stays
// true the whole time, and without this the loop would spin at full CPU speed doing
// no build work — indefinitely, if the build context itself keeps the heap low.
bool buildHeapPaused = false;
// Heap floor for optional render-adjacent work (idle prewarm). Page
// deserialization (TextBlock word vectors/strings) and glyph caching allocate
// through throwing paths that abort() on OOM; skip deferrable work below it.
static constexpr size_t RENDER_MIN_FREE_HEAP = 24 * 1024;
// How many pages to keep laid out ahead of the reader for a still-building section. A page // How many pages to keep laid out ahead of the reader for a still-building section. A page
// turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder // turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder
// -- a tiny buffer is enough. The background build stops once the watermark is this far // -- a tiny buffer is enough. The background build stops once the watermark is this far
@@ -147,6 +125,14 @@ class EpubReaderActivity final : public Activity {
// (used after a settings change re-paginates a chapter). Returns true if currentPage moved. // (used after a settings change re-paginates a chapter). Returns true if currentPage moved.
// No-op while the section is still building or when the pagination is unchanged (plain resume). // No-op while the section is still building or when the pagination is unchanged (plain resume).
bool applyDeferredReposition(); bool applyDeferredReposition();
// (Re)allocate and reset bookPages when the feature turns on or the render params
// change. Called from render() (under the RenderLock) where the viewport is known.
void ensureBookPages(uint16_t viewportWidth, uint16_t viewportHeight);
// Store the current section's exact page count once its pagination is final
// (no-op while building or partial). Caller must hold the RenderLock.
void recordCurrentSectionPages();
// Book-global position for the current page, or nullopt while inactive.
std::optional<BookPagePosition> bookPagePosition() const;
bool saveProgress(int spineIndex, int currentPage, int pageCount); bool saveProgress(int spineIndex, int currentPage, int pageCount);
// Jump to a percentage of the book (0-100), mapping it to spine and page. // Jump to a percentage of the book (0-100), mapping it to spine and page.
void jumpToPercent(int percent); void jumpToPercent(int percent);
@@ -177,10 +163,8 @@ class EpubReaderActivity final : public Activity {
// Full CPU speed + fast loop ticks while a section build runs: at the low-power // Full CPU speed + fast loop ticks while a section build runs: at the low-power
// frequency a giant chapter's background rebuild stretches from ~40s to many // frequency a giant chapter's background rebuild stretches from ~40s to many
// minutes, so the reader exits before it can finalize and the next open restarts // minutes, so the reader exits before it can finalize and the next open restarts
// it from page 0. Reverts to normal power behavior the moment the build finishes, // it from page 0. Reverts to normal power behavior the moment the build finishes.
// and while the build is heap-paused (no work is happening, so spinning at full bool skipLoopDelay() override { return section && section->isBuilding(); }
// speed would only burn battery; the paused gate still retries every loop pass).
bool skipLoopDelay() override { return section && section->isBuilding() && !buildHeapPaused; }
bool isReaderActivity() const override { return true; } bool isReaderActivity() const override { return true; }
ScreenshotInfo getScreenshotInfo() const override; ScreenshotInfo getScreenshotInfo() const override;
CrossPointPosition getCurrentPosition() const; CrossPointPosition getCurrentPosition() const;
+3 -12
View File
@@ -59,21 +59,12 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) {
return {prev, next, tiltPrev || tiltNext}; return {prev, next, tiltPrev || tiltNext};
} }
// One helper, blocking or deferred: the async form starts the refresh and inline void displayWithRefreshCycle(const GfxRenderer& renderer, int& pagesUntilFullRefresh) {
// returns so the caller can overlap CPU work with the panel's refresh time.
// Async callers must not touch the framebuffer until
// renderer.waitRefreshComplete() and must rebuild the differential baseline
// before the next page turn (the tiled grayscale cleanup does).
inline void displayWithRefreshCycle(const GfxRenderer& renderer, int& pagesUntilFullRefresh, bool async = false) {
const auto mode = (pagesUntilFullRefresh <= 1) ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH;
if (async) {
renderer.displayBufferAsync(mode);
} else {
renderer.displayBuffer(mode);
}
if (pagesUntilFullRefresh <= 1) { if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else { } else {
renderer.displayBuffer();
pagesUntilFullRefresh--; pagesUntilFullRefresh--;
} }
} }
@@ -64,6 +64,10 @@ std::string formatUtcOffset(uint8_t biasedQ) {
snprintf(buf, sizeof(buf), "UTC%c%d:%02d", neg ? '-' : '+', hours, mins); snprintf(buf, sizeof(buf), "UTC%c%d:%02d", neg ? '-' : '+', hours, mins);
return buf; return buf;
} }
// Order follows STATUS_BAR_PAGE_COUNT (hide=0, chapter=1, book=2).
constexpr int PAGE_COUNT_ITEMS = 3;
const StrId pageCountNames[PAGE_COUNT_ITEMS] = {StrId::STR_HIDE, StrId::STR_CHAPTER, StrId::STR_BOOK};
constexpr int PROGRESS_BAR_ITEMS = 3; constexpr int PROGRESS_BAR_ITEMS = 3;
const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE};
@@ -90,7 +94,11 @@ void StatusBarSettingsActivity::onEnter() {
selectedIndex = 0; selectedIndex = 0;
visibleItemCount = halClock.isAvailable() ? FULL_MENU_ITEMS : BASE_MENU_ITEMS; visibleItemCount = halClock.isAvailable() ? FULL_MENU_ITEMS : BASE_MENU_ITEMS;
// Clamp statusBarProgressBar and statusBarTitle in case of corrupt/migrated data // Clamp enum-valued settings in case of corrupt/migrated data
if (SETTINGS.statusBarPageCount >= PAGE_COUNT_ITEMS) {
SETTINGS.statusBarPageCount = CrossPointSettings::STATUS_BAR_PAGE_COUNT::CHAPTER_PAGE_COUNT;
}
if (SETTINGS.statusBarProgressBar >= PROGRESS_BAR_ITEMS) { if (SETTINGS.statusBarProgressBar >= PROGRESS_BAR_ITEMS) {
SETTINGS.statusBarProgressBar = CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS; SETTINGS.statusBarProgressBar = CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS;
} }
@@ -163,8 +171,12 @@ void StatusBarSettingsActivity::loop() {
void StatusBarSettingsActivity::handleSelection() { void StatusBarSettingsActivity::handleSelection() {
switch (selectedIndex) { switch (selectedIndex) {
case ITEM_CHAPTER_PAGE_COUNT: case ITEM_CHAPTER_PAGE_COUNT:
SETTINGS.statusBarChapterPageCount = (SETTINGS.statusBarChapterPageCount + 1) % 2; optionPopup.show(StrId::STR_CHAPTER_PAGE_COUNT, pageCountNames, PAGE_COUNT_ITEMS, SETTINGS.statusBarPageCount,
break; [this](int idx) {
SETTINGS.statusBarPageCount = idx;
SETTINGS.saveToFile();
});
return;
case ITEM_BOOK_PROGRESS_PERCENTAGE: case ITEM_BOOK_PROGRESS_PERCENTAGE:
SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2; SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2;
break; break;
@@ -236,7 +248,7 @@ void StatusBarSettingsActivity::render(RenderLock&&) {
[](int index) -> std::string { [](int index) -> std::string {
switch (index) { switch (index) {
case ITEM_CHAPTER_PAGE_COUNT: case ITEM_CHAPTER_PAGE_COUNT:
return SETTINGS.statusBarChapterPageCount ? tr(STR_SHOW) : tr(STR_HIDE); return I18N.get(pageCountNames[SETTINGS.statusBarPageCount]);
case ITEM_BOOK_PROGRESS_PERCENTAGE: case ITEM_BOOK_PROGRESS_PERCENTAGE:
return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE); return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE);
case ITEM_PROGRESS_BAR: case ITEM_PROGRESS_BAR:
+2 -1
View File
@@ -132,7 +132,8 @@ int UITheme::getStatusBarHeight() {
// Add status bar margin // Add status bar margin
const bool showStatusBar = const bool showStatusBar =
SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarPageCount != CrossPointSettings::STATUS_BAR_PAGE_COUNT::HIDE_PAGE_COUNT ||
SETTINGS.statusBarBookProgressPercentage ||
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery || SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery ||
SETTINGS.statusBarClock != CrossPointSettings::STATUS_BAR_CLOCK_MODE::STATUS_BAR_CLOCK_HIDE; SETTINGS.statusBarClock != CrossPointSettings::STATUS_BAR_CLOCK_MODE::STATUS_BAR_CLOCK_HIDE;
const bool showProgressBar = const bool showProgressBar =
+10 -6
View File
@@ -27,7 +27,8 @@ constexpr int bookmarkStatusIconGap = 4;
constexpr int bookmarkStatusIconTopCrop = 2; constexpr int bookmarkStatusIconTopCrop = 2;
bool statusBarTextLaneVisible() { bool statusBarTextLaneVisible() {
return SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage || return SETTINGS.statusBarPageCount != CrossPointSettings::STATUS_BAR_PAGE_COUNT::HIDE_PAGE_COUNT ||
SETTINGS.statusBarBookProgressPercentage ||
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery || SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery ||
(SETTINGS.statusBarClock && halClock.isAvailable()); (SETTINGS.statusBarClock && halClock.isAvailable());
} }
@@ -765,20 +766,23 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
int leftClusterWidth = 0; int leftClusterWidth = 0;
int rightClusterWidth = 0; int rightClusterWidth = 0;
if (SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount) { const bool showPageCount = SETTINGS.statusBarPageCount != CrossPointSettings::STATUS_BAR_PAGE_COUNT::HIDE_PAGE_COUNT;
if (SETTINGS.statusBarBookProgressPercentage || showPageCount) {
// Right aligned text for progress counter // Right aligned text for progress counter
char progressStr[32]; char progressStr[32];
// Prefix the page count with "~" while a still-building spine only yields an estimated total. // Mark the total with "~" while it is only an estimate (a still-building spine's
// watermark, or a whole-book total with not-yet-paginated chapters). The current
// page is always exact, so the marker sits on the total.
const char* estimatePrefix = pageCountEstimated ? "~" : ""; const char* estimatePrefix = pageCountEstimated ? "~" : "";
if (SETTINGS.statusBarBookProgressPercentage && SETTINGS.statusBarChapterPageCount) { if (SETTINGS.statusBarBookProgressPercentage && showPageCount) {
snprintf(progressStr, sizeof(progressStr), "%s%d/%d %.0f%%", estimatePrefix, currentPage, pageCount, snprintf(progressStr, sizeof(progressStr), "%d/%s%d %.0f%%", currentPage, estimatePrefix, pageCount,
bookProgress); bookProgress);
} else if (SETTINGS.statusBarBookProgressPercentage) { } else if (SETTINGS.statusBarBookProgressPercentage) {
snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress); snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress);
} else { } else {
snprintf(progressStr, sizeof(progressStr), "%s%d/%d", estimatePrefix, currentPage, pageCount); snprintf(progressStr, sizeof(progressStr), "%d/%s%d", currentPage, estimatePrefix, pageCount);
} }
int progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); int progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
-17
View File
@@ -200,14 +200,6 @@ void CrossPointWebServer::begin() {
udpActive = udp.begin(LOCAL_UDP_PORT); udpActive = udp.begin(LOCAL_UDP_PORT);
LOG_DBG("WEB", "Discovery UDP %s on port %d", udpActive ? "enabled" : "failed", LOCAL_UDP_PORT); LOG_DBG("WEB", "Discovery UDP %s on port %d", udpActive ? "enabled" : "failed", LOCAL_UDP_PORT);
// All request handlers run on the task that calls handleClient(). Register
// that task before any handler can call esp_task_wdt_reset().
const esp_err_t watchdogResult = esp_task_wdt_add(nullptr);
watchdogTaskRegistered = watchdogResult == ESP_OK;
if (!watchdogTaskRegistered) {
LOG_ERR("WEB", "Failed to register web server task with watchdog: %s", esp_err_to_name(watchdogResult));
}
running = true; running = true;
LOG_DBG("WEB", "Web server started on port %d", port); LOG_DBG("WEB", "Web server started on port %d", port);
@@ -237,10 +229,6 @@ void CrossPointWebServer::abortWsUpload(const char* tag) {
void CrossPointWebServer::stop() { void CrossPointWebServer::stop() {
if (!running || !server) { if (!running || !server) {
LOG_DBG("WEB", "stop() called but already stopped (running=%d, server=%p)", running, server.get()); LOG_DBG("WEB", "stop() called but already stopped (running=%d, server=%p)", running, server.get());
if (watchdogTaskRegistered) {
esp_task_wdt_delete(nullptr);
watchdogTaskRegistered = false;
}
return; return;
} }
@@ -281,11 +269,6 @@ void CrossPointWebServer::stop() {
LOG_DBG("WEB", "Web server stopped and deleted"); LOG_DBG("WEB", "Web server stopped and deleted");
LOG_DBG("WEB", "[MEM] Free heap after delete server: %d bytes", ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap after delete server: %d bytes", ESP.getFreeHeap());
if (watchdogTaskRegistered) {
esp_task_wdt_delete(nullptr);
watchdogTaskRegistered = false;
}
// Note: Static upload variables (uploadFileName, uploadPath, uploadError) are declared // Note: Static upload variables (uploadFileName, uploadPath, uploadError) are declared
// later in the file and will be cleared when they go out of scope or on next upload // later in the file and will be cleared when they go out of scope or on next upload
LOG_DBG("WEB", "[MEM] Free heap final: %d bytes", ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap final: %d bytes", ESP.getFreeHeap());
-1
View File
@@ -72,7 +72,6 @@ class CrossPointWebServer {
std::unique_ptr<WebServer> server = nullptr; std::unique_ptr<WebServer> server = nullptr;
std::unique_ptr<WebSocketsServer> wsServer = nullptr; std::unique_ptr<WebSocketsServer> wsServer = nullptr;
bool running = false; bool running = false;
bool watchdogTaskRegistered = false;
bool apMode = false; // true when running in AP mode, false for STA mode bool apMode = false; // true when running in AP mode, false for STA mode
uint16_t port = 80; uint16_t port = 80;
uint16_t wsPort = 81; // WebSocket port uint16_t wsPort = 81; // WebSocket port