mirror of
https://github.com/crosspoint-reader/crosspoint-reader.git
synced 2026-04-29 10:26:52 -07:00
fix: boot looping when opening large XTC files (#1648)
Opening XTC files with a high page count (e.g. *The Magic Mountain* at 4,187 pages) causes an immediate `abort()` crash and reboot loop. The device becomes unusable until the book is removed from the SD card. **Crash log:** ``` abort() was called at 0x4214a5fb on core 0 ``` ### Root cause During `XtcParser::open()`, the parser calls `m_pageTable.resize(pageCount)` to load the entire page table into RAM. Each `PageInfo` entry is 16 bytes, so: - 4,187 pages x 16 bytes = **66,992 bytes (~65KB)** as a single contiguous heap allocation On the ESP32-C3 with ~380KB total RAM (no PSRAM), this allocation fails after firmware, fonts, and the activity system are already loaded. Because the firmware is compiled with `-fno-exceptions`, the failed `new` inside `std::vector::resize()` calls `abort()` instead of throwing. This affects any XTC file with roughly 3,000+ pages, depending on heap state at the time of loading. ## Solution Replace the bulk page table allocation with on-demand reads from the SD card. Instead of loading all page table entries into a vector at file open, we now: 1. Read only the **first** page table entry at open time (to get default page dimensions) 2. Read a **single** 16-byte entry from the SD card each time a page is loaded This reduces page table memory usage from `pageCount * 16` bytes to **zero bytes**, regardless of how many pages the file contains. ### Changes | File | What changed | |------|-------------| | `XtcParser.h` | Removed `std::vector<PageInfo> m_pageTable`. Added `readPageTableEntry()` for on-demand reads. | | `XtcParser.cpp` | Replaced `readPageTable()` with `readFirstPageInfo()`. Updated `getPageInfo()`, `loadPage()`, and `loadPageStreaming()` to seek and read individual entries from the file. | ## Trade-offs ### Performance Each page turn now requires one additional SD card seek + 16-byte read to look up the page table entry before reading the page data itself. - SD card sequential read latency: ~0.1-0.5ms for a 16-byte read - E-ink full display refresh: ~1,000-2,000ms I personally can't see any performance difference while reading and the trade off of not boot looping seems to make this well worth it. ### Memory | Metric | Before | After | |--------|--------|-------| | Page table RAM (4,187 pages) | ~65KB | 0 bytes | | Page table RAM (1,000 pages) | ~16KB | 0 bytes | | Page table RAM (max 65,535 pages) | ~1MB (impossible) | 0 bytes |
This commit is contained in:
+1
-1
@@ -103,7 +103,7 @@ bool Xtc::hasChapters() const {
|
||||
return parser->hasChapters();
|
||||
}
|
||||
|
||||
const std::vector<xtc::ChapterInfo>& Xtc::getChapters() const {
|
||||
const std::vector<xtc::ChapterInfo>& Xtc::getChapters() {
|
||||
static const std::vector<xtc::ChapterInfo> kEmpty;
|
||||
if (!loaded || !parser) {
|
||||
return kEmpty;
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ class Xtc {
|
||||
std::string getTitle() const;
|
||||
std::string getAuthor() const;
|
||||
bool hasChapters() const;
|
||||
const std::vector<xtc::ChapterInfo>& getChapters() const;
|
||||
const std::vector<xtc::ChapterInfo>& getChapters();
|
||||
|
||||
// Cover image support (for sleep screen)
|
||||
std::string getCoverBmpPath() const;
|
||||
|
||||
+131
-56
@@ -21,6 +21,7 @@ XtcParser::XtcParser()
|
||||
m_defaultHeight(DISPLAY_HEIGHT),
|
||||
m_bitDepth(1),
|
||||
m_hasChapters(false),
|
||||
m_chaptersLoaded(false),
|
||||
m_lastError(XtcError::OK) {
|
||||
memset(&m_header, 0, sizeof(m_header));
|
||||
}
|
||||
@@ -33,6 +34,8 @@ XtcError XtcParser::open(const char* filepath) {
|
||||
close();
|
||||
}
|
||||
|
||||
m_filepath = filepath;
|
||||
|
||||
// Open file
|
||||
if (!Storage.openFileForRead("XTC", filepath, m_file)) {
|
||||
m_lastError = XtcError::FILE_NOT_FOUND;
|
||||
@@ -64,25 +67,29 @@ XtcError XtcParser::open(const char* filepath) {
|
||||
m_file.close();
|
||||
return m_lastError;
|
||||
}
|
||||
// Trim excess capacity from metadata strings
|
||||
m_title.shrink_to_fit();
|
||||
m_author.shrink_to_fit();
|
||||
}
|
||||
|
||||
// Read page table
|
||||
m_lastError = readPageTable();
|
||||
// Read first page info for default dimensions (no bulk page table allocation)
|
||||
m_lastError = readFirstPageInfo();
|
||||
if (m_lastError != XtcError::OK) {
|
||||
LOG_DBG("XTC", "Failed to read page table: %s", errorToString(m_lastError));
|
||||
LOG_DBG("XTC", "Failed to read first page info: %s", errorToString(m_lastError));
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
m_file.close();
|
||||
return m_lastError;
|
||||
}
|
||||
|
||||
// Read chapters if present
|
||||
m_lastError = readChapters();
|
||||
if (m_lastError != XtcError::OK) {
|
||||
LOG_DBG("XTC", "Failed to read chapters: %s", errorToString(m_lastError));
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
m_file.close();
|
||||
return m_lastError;
|
||||
}
|
||||
// Defer chapter parsing until actually needed (lazy load).
|
||||
// Chapter strings can use significant heap; keeping them out of memory
|
||||
// during rendering leaves more room for the page bitmap buffer.
|
||||
m_hasChapters = (m_header.hasChapters == 1);
|
||||
m_chaptersLoaded = false;
|
||||
|
||||
// Close the source file to free its internal SdFat buffers.
|
||||
// It will be reopened on-demand for page table lookups and bitmap reads.
|
||||
m_file.close();
|
||||
|
||||
m_isOpen = true;
|
||||
LOG_DBG("XTC", "Opened file: %s (%u pages, %dx%d)", filepath, m_header.pageCount, m_defaultWidth, m_defaultHeight);
|
||||
@@ -90,18 +97,29 @@ XtcError XtcParser::open(const char* filepath) {
|
||||
}
|
||||
|
||||
void XtcParser::close() {
|
||||
if (m_isOpen) {
|
||||
// Explicit close() required: member variable persists beyond function scope
|
||||
m_file.close();
|
||||
m_isOpen = false;
|
||||
}
|
||||
m_pageTable.clear();
|
||||
closeFile();
|
||||
m_isOpen = false;
|
||||
m_chaptersLoaded = false;
|
||||
m_chapters.clear();
|
||||
m_title.clear();
|
||||
m_author.clear();
|
||||
m_hasChapters = false;
|
||||
memset(&m_header, 0, sizeof(m_header));
|
||||
}
|
||||
|
||||
bool XtcParser::ensureFileOpen() {
|
||||
if (m_file.isOpen()) {
|
||||
return true;
|
||||
}
|
||||
return Storage.openFileForRead("XTC", m_filepath.c_str(), m_file);
|
||||
}
|
||||
|
||||
void XtcParser::closeFile() {
|
||||
if (m_file.isOpen()) {
|
||||
m_file.close();
|
||||
}
|
||||
}
|
||||
|
||||
XtcError XtcParser::readHeader() {
|
||||
// Read first 56 bytes of header
|
||||
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&m_header), sizeof(XtcHeader));
|
||||
@@ -169,50 +187,81 @@ XtcError XtcParser::readAuthor() {
|
||||
return XtcError::OK;
|
||||
}
|
||||
|
||||
XtcError XtcParser::readPageTable() {
|
||||
XtcError XtcParser::readFirstPageInfo() {
|
||||
if (m_header.pageTableOffset == 0) {
|
||||
LOG_DBG("XTC", "Page table offset is 0, cannot read");
|
||||
return XtcError::CORRUPTED_HEADER;
|
||||
}
|
||||
|
||||
// Seek to page table
|
||||
// Verify the file is large enough to contain the full page table
|
||||
const uint64_t fileSize = m_file.size();
|
||||
const uint64_t pageTableSize = static_cast<uint64_t>(m_header.pageCount) * sizeof(PageTableEntry);
|
||||
if (m_header.pageTableOffset < sizeof(XtcHeader) || m_header.pageTableOffset > fileSize ||
|
||||
pageTableSize > fileSize - m_header.pageTableOffset) {
|
||||
LOG_DBG("XTC", "Page table exceeds file bounds");
|
||||
return XtcError::CORRUPTED_HEADER;
|
||||
}
|
||||
|
||||
// Read only the first entry to get default page dimensions
|
||||
// All other entries are read on-demand via readPageTableEntry()
|
||||
// This avoids allocating pageCount * 16 bytes (e.g. 65KB for 4000+ pages)
|
||||
PageTableEntry entry;
|
||||
if (!m_file.seek(m_header.pageTableOffset)) {
|
||||
LOG_DBG("XTC", "Failed to seek to page table at %llu", m_header.pageTableOffset);
|
||||
return XtcError::READ_ERROR;
|
||||
}
|
||||
|
||||
m_pageTable.resize(m_header.pageCount);
|
||||
|
||||
// Read page table entries
|
||||
for (uint16_t i = 0; i < m_header.pageCount; i++) {
|
||||
PageTableEntry entry;
|
||||
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry));
|
||||
if (bytesRead != sizeof(PageTableEntry)) {
|
||||
LOG_DBG("XTC", "Failed to read page table entry %u", i);
|
||||
return XtcError::READ_ERROR;
|
||||
}
|
||||
|
||||
m_pageTable[i].offset = static_cast<uint32_t>(entry.dataOffset);
|
||||
m_pageTable[i].size = entry.dataSize;
|
||||
m_pageTable[i].width = entry.width;
|
||||
m_pageTable[i].height = entry.height;
|
||||
m_pageTable[i].bitDepth = m_bitDepth;
|
||||
|
||||
// Update default dimensions from first page
|
||||
if (i == 0) {
|
||||
m_defaultWidth = entry.width;
|
||||
m_defaultHeight = entry.height;
|
||||
}
|
||||
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry));
|
||||
if (bytesRead != sizeof(PageTableEntry)) {
|
||||
LOG_DBG("XTC", "Failed to read first page table entry");
|
||||
return XtcError::READ_ERROR;
|
||||
}
|
||||
|
||||
LOG_DBG("XTC", "Read %u page table entries", m_header.pageCount);
|
||||
m_defaultWidth = entry.width;
|
||||
m_defaultHeight = entry.height;
|
||||
|
||||
LOG_DBG("XTC", "Page table validated: %u pages, default %dx%d", m_header.pageCount, m_defaultWidth, m_defaultHeight);
|
||||
return XtcError::OK;
|
||||
}
|
||||
|
||||
bool XtcParser::readPageTableEntry(uint32_t pageIndex, PageInfo& info) {
|
||||
if (pageIndex >= m_header.pageCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ensureFileOpen()) {
|
||||
LOG_DBG("XTC", "Failed to reopen file for page table read");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Seek to the specific page table entry on the SD card
|
||||
const uint64_t entryOffset = m_header.pageTableOffset + static_cast<uint64_t>(pageIndex) * sizeof(PageTableEntry);
|
||||
if (!m_file.seek(entryOffset)) {
|
||||
LOG_DBG("XTC", "Failed to seek to page table entry %lu at %llu", pageIndex, entryOffset);
|
||||
return false;
|
||||
}
|
||||
|
||||
PageTableEntry entry;
|
||||
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry));
|
||||
if (bytesRead != sizeof(PageTableEntry)) {
|
||||
LOG_DBG("XTC", "Failed to read page table entry %lu", pageIndex);
|
||||
return false;
|
||||
}
|
||||
|
||||
info.offset = static_cast<uint32_t>(entry.dataOffset);
|
||||
info.size = entry.dataSize;
|
||||
info.width = entry.width;
|
||||
info.height = entry.height;
|
||||
info.bitDepth = m_bitDepth;
|
||||
return true;
|
||||
}
|
||||
|
||||
XtcError XtcParser::readChapters() {
|
||||
m_hasChapters = false;
|
||||
m_chapters.clear();
|
||||
|
||||
if (!ensureFileOpen()) {
|
||||
return XtcError::READ_ERROR;
|
||||
}
|
||||
|
||||
uint8_t hasChaptersFlag = 0;
|
||||
if (!m_file.seek(0x0B)) {
|
||||
return XtcError::READ_ERROR;
|
||||
@@ -242,13 +291,12 @@ XtcError XtcParser::readChapters() {
|
||||
return XtcError::OK;
|
||||
}
|
||||
|
||||
uint64_t maxOffset = 0;
|
||||
if (m_header.pageTableOffset > chapterOffset) {
|
||||
// Clamp maxOffset to fileSize so bogus header values can't inflate chapterCount
|
||||
uint64_t maxOffset = fileSize;
|
||||
if (m_header.pageTableOffset > chapterOffset && m_header.pageTableOffset <= fileSize) {
|
||||
maxOffset = m_header.pageTableOffset;
|
||||
} else if (m_header.dataOffset > chapterOffset) {
|
||||
} else if (m_header.dataOffset > chapterOffset && m_header.dataOffset <= fileSize) {
|
||||
maxOffset = m_header.dataOffset;
|
||||
} else {
|
||||
maxOffset = fileSize;
|
||||
}
|
||||
|
||||
if (maxOffset <= chapterOffset) {
|
||||
@@ -266,6 +314,7 @@ XtcError XtcParser::readChapters() {
|
||||
return XtcError::READ_ERROR;
|
||||
}
|
||||
|
||||
m_chapters.reserve(chapterCount);
|
||||
std::vector<uint8_t> chapterBuf(chapterSize);
|
||||
for (size_t i = 0; i < chapterCount; i++) {
|
||||
if (m_file.read(chapterBuf.data(), chapterSize) != chapterSize) {
|
||||
@@ -315,14 +364,24 @@ XtcError XtcParser::readChapters() {
|
||||
return XtcError::OK;
|
||||
}
|
||||
|
||||
bool XtcParser::getPageInfo(uint32_t pageIndex, PageInfo& info) const {
|
||||
if (pageIndex >= m_pageTable.size()) {
|
||||
return false;
|
||||
const std::vector<ChapterInfo>& XtcParser::getChapters() {
|
||||
// Lazy load chapters on first access
|
||||
if (!m_chaptersLoaded && m_hasChapters) {
|
||||
const XtcError err = readChapters();
|
||||
if (err != XtcError::OK) {
|
||||
LOG_ERR("XTC", "Failed to lazy-load chapters: %s", errorToString(err));
|
||||
m_hasChapters = false;
|
||||
m_chapters.clear();
|
||||
}
|
||||
m_chaptersLoaded = true;
|
||||
// Close file after chapter read to free buffers for rendering
|
||||
closeFile();
|
||||
}
|
||||
info = m_pageTable[pageIndex];
|
||||
return true;
|
||||
return m_chapters;
|
||||
}
|
||||
|
||||
bool XtcParser::getPageInfo(uint32_t pageIndex, PageInfo& info) { return readPageTableEntry(pageIndex, info); }
|
||||
|
||||
size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) {
|
||||
if (!m_isOpen) {
|
||||
m_lastError = XtcError::FILE_NOT_FOUND;
|
||||
@@ -334,7 +393,16 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
|
||||
return 0;
|
||||
}
|
||||
|
||||
const PageInfo& page = m_pageTable[pageIndex];
|
||||
PageInfo page;
|
||||
if (!readPageTableEntry(pageIndex, page)) {
|
||||
m_lastError = XtcError::READ_ERROR;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!ensureFileOpen()) {
|
||||
m_lastError = XtcError::FILE_NOT_FOUND;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Seek to page data
|
||||
if (!m_file.seek(page.offset)) {
|
||||
@@ -402,7 +470,14 @@ XtcError XtcParser::loadPageStreaming(uint32_t pageIndex,
|
||||
return XtcError::PAGE_OUT_OF_RANGE;
|
||||
}
|
||||
|
||||
const PageInfo& page = m_pageTable[pageIndex];
|
||||
PageInfo page;
|
||||
if (!readPageTableEntry(pageIndex, page)) {
|
||||
return XtcError::READ_ERROR;
|
||||
}
|
||||
|
||||
if (!ensureFileOpen()) {
|
||||
return XtcError::FILE_NOT_FOUND;
|
||||
}
|
||||
|
||||
// Seek to page data
|
||||
if (!m_file.seek(page.offset)) {
|
||||
|
||||
+13
-4
@@ -23,6 +23,9 @@ namespace xtc {
|
||||
*
|
||||
* Reads XTC files from SD card and extracts page data.
|
||||
* Designed for ESP32-C3's limited RAM (~380KB) using streaming.
|
||||
*
|
||||
* The source file is kept closed between reads to free heap for rendering.
|
||||
* It is reopened on-demand for page table lookups and bitmap data reads.
|
||||
*/
|
||||
class XtcParser {
|
||||
public:
|
||||
@@ -42,7 +45,7 @@ class XtcParser {
|
||||
uint8_t getBitDepth() const { return m_bitDepth; } // 1 = XTC/XTG, 2 = XTCH/XTH
|
||||
|
||||
// Page information
|
||||
bool getPageInfo(uint32_t pageIndex, PageInfo& info) const;
|
||||
bool getPageInfo(uint32_t pageIndex, PageInfo& info);
|
||||
|
||||
/**
|
||||
* Load page bitmap (raw 1-bit data, skipping XTG header)
|
||||
@@ -72,7 +75,7 @@ class XtcParser {
|
||||
std::string getAuthor() const { return m_author; }
|
||||
|
||||
bool hasChapters() const { return m_hasChapters; }
|
||||
const std::vector<ChapterInfo>& getChapters() const { return m_chapters; }
|
||||
const std::vector<ChapterInfo>& getChapters();
|
||||
|
||||
// Validation
|
||||
static bool isValidXtcFile(const char* filepath);
|
||||
@@ -82,9 +85,9 @@ class XtcParser {
|
||||
|
||||
private:
|
||||
FsFile m_file;
|
||||
std::string m_filepath;
|
||||
bool m_isOpen;
|
||||
XtcHeader m_header;
|
||||
std::vector<PageInfo> m_pageTable;
|
||||
std::vector<ChapterInfo> m_chapters;
|
||||
std::string m_title;
|
||||
std::string m_author;
|
||||
@@ -92,14 +95,20 @@ class XtcParser {
|
||||
uint16_t m_defaultHeight;
|
||||
uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit)
|
||||
bool m_hasChapters;
|
||||
bool m_chaptersLoaded;
|
||||
XtcError m_lastError;
|
||||
|
||||
// Internal helper functions
|
||||
XtcError readHeader();
|
||||
XtcError readPageTable();
|
||||
XtcError readFirstPageInfo();
|
||||
XtcError readTitle();
|
||||
XtcError readAuthor();
|
||||
XtcError readChapters();
|
||||
bool readPageTableEntry(uint32_t pageIndex, PageInfo& info);
|
||||
|
||||
// File handle management — reopen on demand, close after use
|
||||
bool ensureFileOpen();
|
||||
void closeFile();
|
||||
};
|
||||
|
||||
} // namespace xtc
|
||||
|
||||
Reference in New Issue
Block a user