Compare commits

..
Author SHA1 Message Date
Justin Mitchell 27c4be8a34 Implement chunked allocation for glyph bitmaps
Replace single contiguous allocation with fixed-size chunks to handle heap fragmentation. Glyph bitmaps are now stored in 4KB chunks (up to 24 chunks per style/page) to allow allocation when memory is fragmented. Each glyph bitmap fits entirely within one chunk and is addressed via virtual offset.
2026-08-05 18:42:44 -04:00
Uri Tauber fecb9eb3a4 fix: trim curly quotes and dashes in dictionary lookup (#2877) 2026-08-05 15:37:41 -04:00
Jesse Vincent 53232eefab fix: recognize watchdog-triggered resets as panics (#2830) 2026-08-05 22:02:41 +03:00
winst0niuss cc96e3c538 fix: size translated-label buffers for UTF-8, not ASCII (#2850) 2026-08-05 21:51:37 +03:00
12 changed files with 159 additions and 95 deletions

No files matched your search

+7 -21
View File
@@ -7,13 +7,6 @@ on:
jobs:
build-release:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- pio_env: gh_release
artifact_name: firmware-x4_x3
- pio_env: sticky
artifact_name: firmware-sticky
steps:
- uses: actions/checkout@v6
with:
@@ -40,22 +33,15 @@ jobs:
restore-keys: pio-${{ runner.os }}-
- name: Build CrossPoint
env:
CROSSPOINT_RELEASE_BUILD: "1"
run: pio run -e ${{ matrix.pio_env }} -j1
- name: Name Release Firmware
run: |
cp ".pio/build/${{ matrix.pio_env }}/firmware.bin" \
".pio/build/${{ matrix.pio_env }}/${{ matrix.artifact_name }}.bin"
run: pio run -e gh_release
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
name: CrossPoint-${{ github.ref_name }}
path: |
.pio/build/${{ matrix.pio_env }}/bootloader.bin
.pio/build/${{ matrix.pio_env }}/${{ matrix.artifact_name }}.bin
.pio/build/${{ matrix.pio_env }}/firmware.elf
.pio/build/${{ matrix.pio_env }}/firmware.map
.pio/build/${{ matrix.pio_env }}/partitions.bin
.pio/build/gh_release/bootloader.bin
.pio/build/gh_release/firmware.bin
.pio/build/gh_release/firmware.elf
.pio/build/gh_release/firmware.map
.pio/build/gh_release/partitions.bin
+9 -20
View File
@@ -7,13 +7,6 @@ jobs:
build-release-candidate:
if: startsWith(github.ref_name, 'release/')
runs-on: ubuntu-latest
strategy:
matrix:
include:
- pio_env: gh_release_rc
artifact_name: firmware-x4_x3
- pio_env: sticky
artifact_name: firmware-sticky
steps:
- uses: actions/checkout@v6
with:
@@ -39,27 +32,23 @@ jobs:
key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }}
restore-keys: pio-${{ runner.os }}-
- name: Extract short SHA
- name: Extract env
run: |
echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
echo "BRANCH_SUFFIX=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
- name: Build CrossPoint Release Candidate
env:
CROSSPOINT_RC_HASH: ${{ env.SHORT_SHA }}
run: pio run -e ${{ matrix.pio_env }} -j1
- name: Name Release Candidate Firmware
run: |
cp ".pio/build/${{ matrix.pio_env }}/firmware.bin" \
".pio/build/${{ matrix.pio_env }}/${{ matrix.artifact_name }}-rc+${{ env.SHORT_SHA }}.bin"
run: pio run -e gh_release_rc
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}-rc+${{ env.SHORT_SHA }}
name: CrossPoint-RC-${{ env.BRANCH_SUFFIX }}
path: |
.pio/build/${{ matrix.pio_env }}/bootloader.bin
.pio/build/${{ matrix.pio_env }}/${{ matrix.artifact_name }}-rc+${{ env.SHORT_SHA }}.bin
.pio/build/${{ matrix.pio_env }}/firmware.elf
.pio/build/${{ matrix.pio_env }}/firmware.map
.pio/build/${{ matrix.pio_env }}/partitions.bin
.pio/build/gh_release_rc/bootloader.bin
.pio/build/gh_release_rc/firmware.bin
.pio/build/gh_release_rc/firmware.elf
.pio/build/gh_release_rc/firmware.map
.pio/build/gh_release_rc/partitions.bin
+74 -19
View File
@@ -99,8 +99,11 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) {
s.miniIntervals = nullptr;
delete[] s.miniGlyphs;
s.miniGlyphs = nullptr;
delete[] s.miniBitmap;
s.miniBitmap = nullptr;
for (auto& chunk : s.miniBitmapChunks) {
delete[] chunk;
chunk = nullptr;
}
s.miniBitmapChunkCount = 0;
s.miniIntervalCount = 0;
s.miniGlyphCount = 0;
s.miniIntervalCapacity = 0;
@@ -1010,30 +1013,66 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
totalBitmapSize += s.miniGlyphs[i].dataLength;
}
if (!ensureArrayCapacity(s.miniBitmap, s.miniBitmapCapacity, totalBitmapSize)) {
LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx);
delete[] readOrder;
delete[] mappings;
freeStyleMiniData(s);
return static_cast<int>(cpCount);
}
s.miniBitmapUsed = totalBitmapSize; // underuse-hysteresis signal for resetStyleMiniData
// Read bitmap data sorted by file offset
std::sort(readOrder, readOrder + validCount,
[&](uint32_t a, uint32_t b) { return s.miniGlyphs[a].dataOffset < s.miniGlyphs[b].dataOffset; });
uint32_t miniBitmapOffset = 0;
// Assemble the bitmap arena from on-demand 4 KB chunks (see MINI_BM_CHUNK_*).
// `span` is the running virtual offset into the arena; each glyph is placed
// wholly within one chunk, skipping past a boundary rather than straddling it.
// glyph.dataOffset becomes the virtual offset, decoded at render time by
// miniGlyphBitmap(). Chunks are allocated only as span reaches them, so a
// sparse page never allocates the full ceiling.
uint32_t span = 0;
uint32_t lastBitmapEnd = UINT32_MAX;
for (uint32_t i = 0; i < validCount; i++) {
uint32_t mapIdx = readOrder[i];
EpdGlyph& glyph = s.miniGlyphs[mapIdx];
const uint32_t len = glyph.dataLength;
if (glyph.dataLength == 0) {
glyph.dataOffset = miniBitmapOffset;
if (len == 0) {
glyph.dataOffset = span;
continue;
}
if (len > MINI_BM_CHUNK_SIZE) {
// A single glyph larger than one chunk cannot be placed. Real fonts never
// hit this (a 4 KB 2bpp glyph would be enormous); bail rather than corrupt.
LOG_ERR("SDCF", "Prewarm: glyph %u B exceeds chunk %u B (style %u)", len, MINI_BM_CHUNK_SIZE, styleIdx);
file.close();
delete[] readOrder;
delete[] mappings;
freeStyleMiniData(s);
return static_cast<int>(cpCount);
}
// Skip to the next chunk boundary if this glyph would straddle one.
const uint32_t within = span & (MINI_BM_CHUNK_SIZE - 1);
if (within != 0 && within + len > MINI_BM_CHUNK_SIZE) {
span += MINI_BM_CHUNK_SIZE - within;
}
const uint32_t chunkIdx = span >> MINI_BM_CHUNK_SHIFT;
if (chunkIdx >= MINI_BM_MAX_CHUNKS) {
LOG_ERR("SDCF", "Prewarm: mini bitmap needs > %u chunks (style %u)", MINI_BM_MAX_CHUNKS, styleIdx);
file.close();
delete[] readOrder;
delete[] mappings;
freeStyleMiniData(s);
return static_cast<int>(cpCount);
}
if (!s.miniBitmapChunks[chunkIdx]) {
s.miniBitmapChunks[chunkIdx] = new (std::nothrow) uint8_t[MINI_BM_CHUNK_SIZE];
if (!s.miniBitmapChunks[chunkIdx]) {
LOG_ERR("SDCF", "Failed to allocate mini bitmap chunk %u (style %u)", chunkIdx, styleIdx);
file.close();
delete[] readOrder;
delete[] mappings;
freeStyleMiniData(s);
return static_cast<int>(cpCount);
}
if (chunkIdx + 1 > s.miniBitmapChunkCount) s.miniBitmapChunkCount = chunkIdx + 1;
}
const uint32_t off = span & (MINI_BM_CHUNK_SIZE - 1);
uint32_t fileOff = s.bitmapFileOffset + glyph.dataOffset;
if (fileOff != lastBitmapEnd) {
if (!file.seekSet(fileOff)) {
@@ -1046,18 +1085,21 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
}
seekCount++;
}
if (file.read(s.miniBitmap + miniBitmapOffset, glyph.dataLength) != static_cast<int>(glyph.dataLength)) {
if (file.read(s.miniBitmapChunks[chunkIdx] + off, len) != static_cast<int>(len)) {
LOG_ERR("SDCF", "Prewarm: short bitmap read (style %u)", styleIdx);
delete[] readOrder;
delete[] mappings;
freeStyleMiniData(s);
return static_cast<int>(cpCount);
}
lastBitmapEnd = fileOff + glyph.dataLength;
lastBitmapEnd = fileOff + len;
glyph.dataOffset = miniBitmapOffset;
miniBitmapOffset += glyph.dataLength;
glyph.dataOffset = span;
span += len;
}
// Footprint including inter-chunk padding: the underuse-hysteresis signal.
s.miniBitmapUsed = span;
s.miniBitmapCapacity = s.miniBitmapChunkCount * MINI_BM_CHUNK_SIZE;
}
uint32_t sdTime = millis() - sdStart;
@@ -1080,7 +1122,10 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
s.miniMetadataOnly = metadataOnly;
s.miniHysteresisPending = !metadataOnly; // one hysteresis evaluation per rebuild
memset(&s.miniData, 0, sizeof(s.miniData));
s.miniData.bitmap = s.miniBitmap;
// The mini bitmap is chunked (non-contiguous), so there is no single base
// pointer. Prewarmed SD glyphs are resolved via SdCardFont::miniGlyphBitmap()
// in the renderer; leave bitmap null so any stray &bitmap[dataOffset] faults.
s.miniData.bitmap = nullptr;
s.miniData.glyph = s.miniGlyphs;
s.miniData.intervals = s.miniIntervals;
s.miniData.intervalCount = s.miniIntervalCount;
@@ -1517,4 +1562,14 @@ const uint8_t* SdCardFont::getOverflowBitmap(const EpdGlyph* glyph) const {
return nullptr;
}
const uint8_t* SdCardFont::miniGlyphBitmap(const void* ctx, uint32_t dataOffset) const {
const auto* octx = static_cast<const OverflowContext*>(ctx);
const PerStyle& s = styles_[octx->styleIdx];
const uint32_t chunkIdx = dataOffset >> MINI_BM_CHUNK_SHIFT;
if (chunkIdx >= s.miniBitmapChunkCount) return nullptr;
const uint8_t* chunk = s.miniBitmapChunks[chunkIdx];
if (!chunk) return nullptr;
return chunk + (dataOffset & (MINI_BM_CHUNK_SIZE - 1));
}
SdCardFont* SdCardFont::fromMissCtx(void* ctx) { return static_cast<OverflowContext*>(ctx)->self; }
+22 -1
View File
@@ -94,6 +94,12 @@ class SdCardFont {
// Returns the bitmap for an on-demand-loaded (overflow) glyph.
const uint8_t* getOverflowBitmap(const EpdGlyph* glyph) const;
// Resolves a prewarmed mini glyph's chunked bitmap. `ctx` is the glyphMissCtx
// (an OverflowContext identifying the style); `dataOffset` is the glyph's
// virtual offset into the style's chunked arena. Returns nullptr if the chunk
// is absent or out of range. Called by GfxRenderer::getGlyphBitmap().
const uint8_t* miniGlyphBitmap(const void* ctx, uint32_t dataOffset) const;
// Extract SdCardFont* from an opaque glyphMissCtx pointer.
// Used by GfxRenderer::getGlyphBitmap() to recover the SdCardFont from EpdFontData::glyphMissCtx.
static SdCardFont* fromMissCtx(void* ctx);
@@ -129,6 +135,18 @@ class SdCardFont {
uint8_t ligaturePairCount = 0;
};
// The per-style mini bitmap arena is stored as a list of fixed-size chunks
// rather than one contiguous block. A whole page's 2bpp glyph bitmaps can run
// tens of KB; on a fragmented heap a single contiguous allocation of that size
// fails even when the same bytes are available as several smaller free blocks.
// Chunking lets the arena be assembled from blocks the allocator can actually
// provide. Each glyph's bitmap is placed wholly within one chunk (never
// straddling a boundary), so a glyph is addressed by a virtual offset that
// maps to (chunk index, offset-in-chunk) via miniGlyphBitmap().
static constexpr uint32_t MINI_BM_CHUNK_SHIFT = 12; // 4 KB chunks
static constexpr uint32_t MINI_BM_CHUNK_SIZE = 1u << MINI_BM_CHUNK_SHIFT;
static constexpr uint32_t MINI_BM_MAX_CHUNKS = 24; // 96 KB ceiling per style/page
// All per-style data: file offsets, intervals, kern/lig, prewarm cache, EpdFont
struct PerStyle {
CpFontHeader header{};
@@ -188,7 +206,10 @@ class SdCardFont {
EpdFontData miniData{};
EpdUnicodeInterval* miniIntervals = nullptr;
EpdGlyph* miniGlyphs = nullptr;
uint8_t* miniBitmap = nullptr;
// Chunked mini bitmap arena (see MINI_BM_CHUNK_* above). Chunks are allocated
// on demand during prewarm; miniBitmapChunkCount is how many are live.
uint8_t* miniBitmapChunks[MINI_BM_MAX_CHUNKS] = {};
uint32_t miniBitmapChunkCount = 0;
uint32_t miniIntervalCount = 0;
uint32_t miniGlyphCount = 0;
uint32_t miniIntervalCapacity = 0;
+3
View File
@@ -81,6 +81,9 @@ const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const Ep
if (sdFont->isOverflowGlyph(glyph)) {
return sdFont->getOverflowBitmap(glyph); // may be nullptr for zero-width glyphs
}
// Prewarmed SD glyph: the mini bitmap is chunked (non-contiguous), so there is
// no fontData->bitmap base to index. Resolve via the per-style chunk table.
return sdFont->miniGlyphBitmap(fontData->glyphMissCtx, glyph->dataOffset);
}
return &fontData->bitmap[glyph->dataOffset];
}
+2 -1
View File
@@ -148,7 +148,8 @@ std::string getPanicInfo(bool full) {
bool isRebootFromPanic() {
const auto resetReason = esp_reset_reason();
return resetReason == ESP_RST_PANIC || resetReason == ESP_RST_CPU_LOCKUP;
return resetReason == ESP_RST_PANIC || resetReason == ESP_RST_CPU_LOCKUP || resetReason == ESP_RST_INT_WDT ||
resetReason == ESP_RST_TASK_WDT || resetReason == ESP_RST_WDT;
}
} // namespace HalSystem
+2 -1
View File
@@ -200,6 +200,7 @@ board_build.mcu = esp32s3
build_flags =
${base.build_flags}
-DFREEINK_DEVICE_STICKY=1
; CROSSPOINT_VERSION is set by scripts/git_branch.py.
; git_branch.py only injects CROSSPOINT_VERSION for the default env; set it here.
-DCROSSPOINT_VERSION=\"${crosspoint.version}-sticky\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2
+10 -27
View File
@@ -1,20 +1,9 @@
"""
PlatformIO pre-build script: set the embedded CROSSPOINT_VERSION.
PlatformIO pre-build script: inject git branch and short SHA into
CROSSPOINT_VERSION for the default (dev) environment.
Version schema by device and build type:
X3/X4 development: <base>-dev-<branch>-<git-sha>
X3/X4 production: <base>
X3/X4 RC: <base>-rc+<git-sha>
Sticky development: <base>-dev-<branch>-<git-sha>
Sticky production: <base>
Sticky RC: <base>-rc+<git-sha>
This script injects the X3/X4 development and all Sticky versions. The
dedicated X3/X4 production and RC environments set their versions in
platformio.ini. CI marks production builds with CROSSPOINT_RELEASE_BUILD and
supplies the seven-character RC SHA through CROSSPOINT_RC_HASH.
Results in a version string like: 1.1.0-dev-feat-kosync-xpath-05c6cf8
Release environments are unaffected; they set CROSSPOINT_VERSION in the ini.
"""
import configparser
@@ -88,22 +77,16 @@ def get_base_version(project_dir):
def inject_version(env):
pioenv = env['PIOENV']
if pioenv not in ('default', 'sticky'):
# Only applies to the dev (default) environment; release envs set the
# version via build_flags in platformio.ini and are unaffected.
if env['PIOENV'] != 'default':
return
project_dir = env['PROJECT_DIR']
base_version = get_base_version(project_dir)
rc_hash = os.environ.get('CROSSPOINT_RC_HASH')
if pioenv == 'sticky' and rc_hash:
version_string = f'{base_version}-rc+{rc_hash}'
elif pioenv == 'sticky' and os.environ.get('CROSSPOINT_RELEASE_BUILD'):
version_string = base_version
else:
branch = get_git_branch(project_dir)
short_sha = get_git_short_sha(project_dir)
version_string = f'{base_version}-dev-{branch}-{short_sha}'
branch = get_git_branch(project_dir)
short_sha = get_git_short_sha(project_dir)
version_string = f'{base_version}-dev-{branch}-{short_sha}'
env.Append(CPPDEFINES=[('CROSSPOINT_VERSION', f'\\"{version_string}\\"')])
print(f'CrossPoint build version: {version_string}')
@@ -772,7 +772,9 @@ void WifiSelectionActivity::render(RenderLock&&) {
Rect screen = theme.getScreenSafeArea(renderer, true, false);
// Draw header
char countStr[32];
// STR_NETWORKS_FOUND is ~37 bytes once the Arabic translation is substituted,
// so 32 truncated it. See ClockSyncActivity for the same class of bug.
char countStr[64];
snprintf(countStr, sizeof(countStr), tr(STR_NETWORKS_FOUND), realNetworkCount);
GUI.drawHeader(renderer, Rect{screen.x, screen.y + metrics.topPadding, screen.width, metrics.headerHeight},
tr(STR_WIFI_NETWORKS), countStr);
@@ -334,7 +334,9 @@ void ClockOffsetActivity::render(RenderLock&&) {
char timeBuf[9];
const uint8_t encoded = encodeOffset(sign, hours, minutesQuarter);
if (halClock.formatTime(timeBuf, sizeof(timeBuf), encoded, SETTINGS.clockFormat == 1)) {
char preview[24];
// 24 bytes did not even hold the label itself once translated: STR_CURRENT_TIME
// is 26 bytes in Russian, 24 in Arabic and Ukrainian. See ClockSyncActivity.
char preview[64];
snprintf(preview, sizeof(preview), "%s %s", tr(STR_CURRENT_TIME), timeBuf);
renderer.drawCenteredText(UI_10_FONT_ID, centreY + 60, preview);
}
@@ -118,7 +118,10 @@ void ClockSyncActivity::render(RenderLock&&) {
case SUCCESS: {
renderer.drawCenteredText(UI_12_FONT_ID, midY - 20, tr(STR_CLOCK_SYNC_OK), true, EpdFontFamily::BOLD);
if (syncedTime[0] != '\0') {
char line[32];
// Sized for the label in any language: STR_CURRENT_TIME is 26 bytes in
// Russian (UTF-8 Cyrillic is 2 bytes per letter) versus 13 in English,
// plus a separator and up to "08:56 PM".
char line[64];
snprintf(line, sizeof(line), "%s %s", tr(STR_CURRENT_TIME), syncedTime);
renderer.drawCenteredText(UI_10_FONT_ID, midY + 10, line);
}
+20 -2
View File
@@ -385,10 +385,28 @@ bool Dictionary::readDefinition(const DictLocation& location, std::string& out,
std::string Dictionary::cleanWord(const char* word) {
if (!word) return "";
const auto* b = reinterpret_cast<const unsigned char*>(word);
size_t start = 0;
size_t end = strlen(word);
while (start < end && !isWordByte(static_cast<unsigned char>(word[start]))) start++;
while (end > start && !isWordByte(static_cast<unsigned char>(word[end - 1]))) end--;
// Curly quotes and dashes (General Punctuation U+2000-U+206F = E2 80/81 xx)
// are all >= 0x80, so isWordByte keeps them; strip those 3-byte codepoints
// from the edges too, or EPUB text like garage.” never matches a headword.
while (start < end) {
if (!isWordByte(b[start]))
start++;
else if (end - start >= 3 && b[start] == 0xE2 && (b[start + 1] == 0x80 || b[start + 1] == 0x81))
start += 3;
else
break;
}
while (end > start) {
if (!isWordByte(b[end - 1]))
end--;
else if (end - start >= 3 && b[end - 3] == 0xE2 && (b[end - 2] == 0x80 || b[end - 2] == 0x81))
end -= 3;
else
break;
}
if (start >= end) return "";
std::string result(word + start, end - start);