* first pass tests * more tests * Fix two crafted-admin-packet crashes found by the E5 fuzzer Both are reachable from an authorized admin (local from==0, admin channel, or PKC) - remote DoS: 1. SIGFPE in LoRa config validation. A set_config LoRaConfig with use_preset=false and bandwidth=0 makes freqSlotWidth 0, so numFreqSlots is 0 and `hash(name) % numFreqSlots` (RadioInterface.cpp) divides by zero. Guard the modulo; the existing channel_num check then rejects/ clamps the config. 2. Stack overflow in Channels::getKey. A SECONDARY channel at the primary slot with an empty PSK recursed into getKey(primaryIndex) forever. Skip the primary-key borrow when chIndex == primaryIndex. Re-enable the E5 admin fuzzer to hit both triggers again (use_preset both ways incl. bandwidth 0, plus the set_channel tag) as regression guards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Correct fuzz-test invariants after the crash fixes - E5 admin fuzz: node eviction under a filling NodeDB is legitimate, so assert only the bounded-count invariant, not that a specific seed node survives 6000 mutating ops. - TMM blitz: scope off the nodeinfo direct-response send path (it needs a fully-wired MeshService/phone queue the fixture doesn't provide; the deterministic directResponse tests cover it). The crafted-nodenum rate/unknown/position cache stress is unchanged. clod helped too * realistic tests * test: dedup fuzz RNG into shared test/support/DeterministicRng.h The four in-tree fuzz suites (test_fuzz_decode, test_fuzz_packets, test_hop_scaling, test_traffic_management) each carried a byte-identical copy of the seeded 64-bit LCG (rngSeed/rngNext/rngByte/rngRange). Hoist it into one shared header so there is a single generator to reason about and no risk of the copies drifting. static inline keeps per-suite state per translation unit and avoids -Wunused-function for suites that don't use every helper. Also corrects a stale comment in test_traffic_management (the blitz's nodeinfo direct-response path is intentionally left off). No behavioral change: same constants, same per-suite seeds. clod helped too * test: fuzz uncovered ProtobufModule handlers and the MQTT downlink ingress Extend the in-tree fuzz coverage to packet sources that previously had none: - test_fuzz_packets E8/E9/E10: drive PositionModule, DeviceTelemetryModule and NeighborInfoModule at handleReceivedProtobuf directly (via using-shims, bypassing the ProtobufModule reply/send path so no router is needed). The fixture already stands up nodeDB/service/channels, and nodeStatus/powerStatus are auto-initialized in main.cpp, so no new globals are required. Adds a shared fuzzRxHeader() helper for crafting adversarial RX packet headers. - test_fuzz_decode: add meshtastic_KeyVerification to the decode table. The KeyVerification and StoreForward handler paths are documented as decode-level only, with the concrete reason each is intrinsic (private-state gating / PSRAM + self-pointer wiring), not a fixture gap. - test_mqtt: test_receiveFuzzServiceEnvelope blitzes the non-RF broker-push ingress (onReceiveProto) two ways - raw garbage bytes that must fail envelope decode cleanly, and a well-formed ServiceEnvelope wrapping a crafted inner MeshPacket over crafted channel_id/gateway_id - exercising the channel match, isFromUs, XEdDSA receive policy and perhapsDecode chain. Adds a deliverRaw() passthrough to MQTTUnitTest. All under the coverage env (ASan/LSan). No firmware/src changes. Full sweep GREEN 27/27, 544 cases. clod helped too * Harden LoRa/channel config against crafted admin messages; consolidate test helpers Production (review findings on the hot-fuzz crash fixes): - Clamp bandwidth at the source (clampBandwidthKHz) in checkOrClampConfigLora and applyModemConfig so numFreqSlots can never be 0 for any consumer; a bandwidth-0 set_config previously passed validation and re-armed the SIGFPE on the next applyModemConfig. - Guard applyModemConfig's hash % numFreqSlots (the validator's sibling modulo was fixed earlier but this one was still unguarded). - Enforce the primary-channel invariant in Channels::onConfigChanged: a config demoting every slot now re-promotes the stale SECONDARY slot (keeping its key) or restores the default channel if the slot is DISABLED, instead of leaving every getPrimaryIndex() reader on a non-primary slot. The getKey recursion guard stays as defense-in-depth. Tests: - New test/support/MockMeshService.h and AdminModuleTestShim.h replace four byte-identical mocks and three divergent admin shims (test_mqtt's capturing mock is genuinely different and stays). - DeterministicRng.h: add rngFill() (replaces 14 hand-rolled fill loops) and rngEdgeNodeNum() (unifies the three NodeNum boundary pools). - Extract fuzzChannelSettings() shared by the set_channel case and fuzzBeacon. - fuzzBeacon: the un-terminated branch now fills the whole buffer with non-NUL bytes so the strnlen bound is actually stressed (~50% of iterations, not ~4%). - E6 beacon fuzz: replace the TEST_ASSERT_TRUE(true) tautology with real invariants (handler never consumes; offers land in lastReceivedOffer keyed to the sender). - Trim the seven over-long comment blocks flagged against the 1-2 line rule; the FINDINGS trailer moves to this commit message (see production notes). Full native suite GREEN 27/27 under the coverage (ASan/LSan) env. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Clamp UTF-8 char length in the emote walkers; add test_fuzz_emotes A TEXT_MESSAGE payload is opaque protobuf bytes, so PB_VALIDATE_UTF8 never screens it - invalid UTF-8 and truncated multi-byte lead bytes reach the emote/width render path verbatim. EmoteRenderer's walkers advanced by utf8CharLen(lead) without clamping to the bytes actually remaining, so a truncated lead (e.g. a lone 0xF0, which claims 4 bytes) near the end of the buffer made getUtf8ChunkWidth's memcpy read past the string. ASan confirms a heap-buffer-overflow READ from measureStringWithEmotes. Add utf8CharLenClamped() and use it at every walk site (width measure, truncation cut-loop, and the draw-path text-run/chunk builders); the one already-guarded site (matchAtIgnoringModifiers) is unchanged. New test/test_fuzz_emotes drives measureStringWithEmotes and truncateToWidth over adversarial byte strings (biased to embed/end in truncated multi-byte leads) in exact-sized heap buffers so any over-read is a hard ASan fault. Its headless display uses a synthetic font (firstChar 0, fontData centered in a large buffer) so the stock OLEDDisplay::getStringWidth - which indexes the font jump table with a signed char and over-reads for any byte >= 0x80 - does not mask the finding. native-suite-count bumped 27 -> 28. Full native suite GREEN 28/28 under the coverage (ASan/LSan) env. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Keep emote width measurement in-bounds for non-ASCII bytes OLEDDisplay::getStringWidth (the utf8=false path EmoteRenderer uses on default builds) indexes the font jump table by (c - firstChar) with a signed char and no bounds check, so any byte outside printable ASCII - high bytes from UTF-8 text, but also a stray control byte like 0x0A - reads outside the font array. On-device this reads adjacent flash and returns a garbage width; under ASan the test_fuzz_emotes fuzzer flags it as a global-buffer-overflow, and it made the non-ASCII width measurement meaningless either way. The OLED driver is a pinned upstream dependency, so guard it firmware-side in EmoteRenderer's getStringWidth helper: measure a sanitized copy where any byte outside [0x20, 0x7E] counts as a '?' placeholder. Printable ASCII is unchanged and the UA/RU lookup path is untouched. test_fuzz_emotes now drives a real ArialMT font instead of the synthetic in-bounds font it needed before this fix, so the suite exercises the true production width path (utf8CharLen clamp + this sanitizer) end to end. The same fuzzer tripped the global-buffer-overflow before this change. Full native suite GREEN 28/28 under the coverage (ASan/LSan) env. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
454 lines
14 KiB
C++
454 lines
14 KiB
C++
#include "configuration.h"
|
|
#if HAS_SCREEN
|
|
|
|
#include "graphics/EmoteRenderer.h"
|
|
#include <algorithm>
|
|
#include <cstring>
|
|
|
|
namespace graphics
|
|
{
|
|
namespace EmoteRenderer
|
|
{
|
|
|
|
static inline int getStringWidth(OLEDDisplay *display, const char *text, size_t len)
|
|
{
|
|
#if defined(OLED_UA) || defined(OLED_RU)
|
|
return display->getStringWidth(text, len, true);
|
|
#else
|
|
// OLEDDisplay::getStringWidth (utf8=false) indexes the font jump table by (c - firstChar) with a
|
|
// signed char and no bounds check, so any byte outside printable ASCII (high bytes, but also
|
|
// control bytes like a stray 0x0A) reads outside the font array. Measure a sanitized copy in which
|
|
// unrepresentable bytes count as a '?' placeholder; printable ASCII is passed through unchanged.
|
|
char safe[64];
|
|
if (len > sizeof(safe) - 1)
|
|
len = sizeof(safe) - 1;
|
|
for (size_t i = 0; i < len; i++) {
|
|
const uint8_t c = static_cast<uint8_t>(text[i]);
|
|
safe[i] = (c >= 0x20 && c <= 0x7E) ? static_cast<char>(c) : '?';
|
|
}
|
|
safe[len] = '\0';
|
|
return display->getStringWidth(safe, len, false);
|
|
#endif
|
|
}
|
|
|
|
size_t utf8CharLen(uint8_t c)
|
|
{
|
|
if ((c & 0xE0) == 0xC0)
|
|
return 2;
|
|
if ((c & 0xF0) == 0xE0)
|
|
return 3;
|
|
if ((c & 0xF8) == 0xF0)
|
|
return 4;
|
|
return 1;
|
|
}
|
|
|
|
// Bytes the UTF-8 char at s[pos] occupies, clamped to what actually remains. A truncated multi-byte
|
|
// lead (e.g. a lone 0xF0) near the end otherwise makes a walker read past the buffer - the payload of
|
|
// a TEXT message is opaque bytes, so such truncated sequences are attacker-reachable.
|
|
static inline size_t utf8CharLenClamped(const char *s, size_t pos, size_t len)
|
|
{
|
|
return std::min(utf8CharLen(static_cast<uint8_t>(s[pos])), len - pos);
|
|
}
|
|
|
|
static inline bool isPossibleEmoteLead(uint8_t c)
|
|
{
|
|
// All supported emoji labels in emotes.cpp are currently in these UTF-8 lead ranges.
|
|
return c == 0xE2 || c == 0xF0;
|
|
}
|
|
|
|
static inline int getUtf8ChunkWidth(OLEDDisplay *display, const char *text, size_t len)
|
|
{
|
|
char chunk[5] = {0, 0, 0, 0, 0};
|
|
if (len > 4)
|
|
len = 4;
|
|
memcpy(chunk, text, len);
|
|
return getStringWidth(display, chunk, len);
|
|
}
|
|
|
|
static inline bool isFE0FAt(const char *s, size_t pos, size_t len)
|
|
{
|
|
return pos + 2 < len && static_cast<uint8_t>(s[pos]) == 0xEF && static_cast<uint8_t>(s[pos + 1]) == 0xB8 &&
|
|
static_cast<uint8_t>(s[pos + 2]) == 0x8F;
|
|
}
|
|
|
|
static inline bool isSkinToneAt(const char *s, size_t pos, size_t len)
|
|
{
|
|
return pos + 3 < len && static_cast<uint8_t>(s[pos]) == 0xF0 && static_cast<uint8_t>(s[pos + 1]) == 0x9F &&
|
|
static_cast<uint8_t>(s[pos + 2]) == 0x8F &&
|
|
(static_cast<uint8_t>(s[pos + 3]) >= 0xBB && static_cast<uint8_t>(s[pos + 3]) <= 0xBF);
|
|
}
|
|
|
|
static inline size_t ignorableModifierLenAt(const char *s, size_t pos, size_t len)
|
|
{
|
|
// Skip modifiers that do not change which bitmap we render.
|
|
if (isFE0FAt(s, pos, len))
|
|
return 3;
|
|
if (isSkinToneAt(s, pos, len))
|
|
return 4;
|
|
return 0;
|
|
}
|
|
|
|
const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet, int emoteCount)
|
|
{
|
|
if (!label || !*label)
|
|
return nullptr;
|
|
|
|
for (int i = 0; i < emoteCount; ++i) {
|
|
if (emoteSet[i].label && strcmp(label, emoteSet[i].label) == 0)
|
|
return &emoteSet[i];
|
|
}
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
static bool matchAtIgnoringModifiers(const char *text, size_t textLen, size_t pos, const char *label, size_t &textConsumed,
|
|
size_t &matchScore)
|
|
{
|
|
// Treat FE0F and skin-tone modifiers as optional while matching.
|
|
textConsumed = 0;
|
|
matchScore = 0;
|
|
if (!label || !*label || pos >= textLen)
|
|
return false;
|
|
|
|
const size_t labelLen = strlen(label);
|
|
size_t ti = pos;
|
|
size_t li = 0;
|
|
|
|
while (true) {
|
|
while (ti < textLen) {
|
|
const size_t skipLen = ignorableModifierLenAt(text, ti, textLen);
|
|
if (!skipLen)
|
|
break;
|
|
ti += skipLen;
|
|
}
|
|
while (li < labelLen) {
|
|
const size_t skipLen = ignorableModifierLenAt(label, li, labelLen);
|
|
if (!skipLen)
|
|
break;
|
|
li += skipLen;
|
|
}
|
|
|
|
if (li >= labelLen) {
|
|
while (ti < textLen) {
|
|
const size_t skipLen = ignorableModifierLenAt(text, ti, textLen);
|
|
if (!skipLen)
|
|
break;
|
|
ti += skipLen;
|
|
}
|
|
textConsumed = ti - pos;
|
|
return textConsumed > 0;
|
|
}
|
|
|
|
if (ti >= textLen)
|
|
return false;
|
|
|
|
const uint8_t tc = static_cast<uint8_t>(text[ti]);
|
|
const uint8_t lc = static_cast<uint8_t>(label[li]);
|
|
const size_t tlen = utf8CharLen(tc);
|
|
const size_t llen = utf8CharLen(lc);
|
|
|
|
if (tlen != llen || ti + tlen > textLen || li + llen > labelLen)
|
|
return false;
|
|
if (memcmp(text + ti, label + li, tlen) != 0)
|
|
return false;
|
|
|
|
ti += tlen;
|
|
li += llen;
|
|
matchScore += llen;
|
|
}
|
|
}
|
|
|
|
const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet, int emoteCount)
|
|
{
|
|
// Prefer the longest matching label at this byte offset.
|
|
const Emote *matched = nullptr;
|
|
matchLen = 0;
|
|
size_t bestScore = 0;
|
|
if (!text || pos >= textLen)
|
|
return nullptr;
|
|
|
|
if (!isPossibleEmoteLead(static_cast<uint8_t>(text[pos])))
|
|
return nullptr;
|
|
|
|
for (int i = 0; i < emoteCount; ++i) {
|
|
const char *label = emoteSet[i].label;
|
|
if (!label || !*label)
|
|
continue;
|
|
if (static_cast<uint8_t>(label[0]) != static_cast<uint8_t>(text[pos]))
|
|
continue;
|
|
|
|
const size_t labelLen = strlen(label);
|
|
if (labelLen == 0)
|
|
continue;
|
|
|
|
size_t candidateLen = 0;
|
|
size_t candidateScore = 0;
|
|
if (pos + labelLen <= textLen && memcmp(text + pos, label, labelLen) == 0) {
|
|
candidateLen = labelLen;
|
|
candidateScore = labelLen;
|
|
} else if (matchAtIgnoringModifiers(text, textLen, pos, label, candidateLen, candidateScore)) {
|
|
// Matched with FE0F/skin tone modifiers treated as optional.
|
|
} else {
|
|
continue;
|
|
}
|
|
|
|
if (candidateScore > bestScore) {
|
|
matched = &emoteSet[i];
|
|
matchLen = candidateLen;
|
|
bestScore = candidateScore;
|
|
}
|
|
}
|
|
|
|
return matched;
|
|
}
|
|
|
|
static LineMetrics analyzeLineInternal(OLEDDisplay *display, const char *line, size_t lineLen, int fallbackHeight,
|
|
const Emote *emoteSet, int emoteCount, int emoteSpacing)
|
|
{
|
|
// Scan once to collect width and tallest emote for this line.
|
|
LineMetrics metrics{0, fallbackHeight, false};
|
|
if (!line)
|
|
return metrics;
|
|
|
|
for (size_t i = 0; i < lineLen;) {
|
|
size_t matchLen = 0;
|
|
const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
|
|
if (matched) {
|
|
metrics.hasEmote = true;
|
|
metrics.tallestHeight = std::max(metrics.tallestHeight, matched->height);
|
|
if (display)
|
|
metrics.width += matched->width + emoteSpacing;
|
|
i += matchLen;
|
|
continue;
|
|
}
|
|
|
|
const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
|
|
if (skipLen) {
|
|
i += skipLen;
|
|
continue;
|
|
}
|
|
|
|
const size_t charLen = utf8CharLenClamped(line, i, lineLen);
|
|
if (display)
|
|
metrics.width += getUtf8ChunkWidth(display, line + i, charLen);
|
|
i += charLen;
|
|
}
|
|
|
|
return metrics;
|
|
}
|
|
|
|
LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight, const Emote *emoteSet, int emoteCount,
|
|
int emoteSpacing)
|
|
{
|
|
return analyzeLineInternal(display, line, line ? strlen(line) : 0, fallbackHeight, emoteSet, emoteCount, emoteSpacing);
|
|
}
|
|
|
|
int maxEmoteHeight(const Emote *emoteSet, int emoteCount)
|
|
{
|
|
int tallest = 0;
|
|
for (int i = 0; i < emoteCount; ++i) {
|
|
if (emoteSet[i].label && *emoteSet[i].label)
|
|
tallest = std::max(tallest, emoteSet[i].height);
|
|
}
|
|
return tallest;
|
|
}
|
|
|
|
int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet, int emoteCount, int emoteSpacing)
|
|
{
|
|
if (!display)
|
|
return 0;
|
|
|
|
if (!line || !*line)
|
|
return 0;
|
|
|
|
return analyzeLine(display, line, 0, emoteSet, emoteCount, emoteSpacing).width;
|
|
}
|
|
|
|
static int appendTextSpanAndMeasure(OLEDDisplay *display, int cursorX, int fontY, const char *text, size_t len, bool draw,
|
|
bool fauxBold)
|
|
{
|
|
// Draw plain-text runs in chunks so UTF-8 stays intact.
|
|
if (!text || len == 0)
|
|
return cursorX;
|
|
|
|
char chunk[33];
|
|
size_t pos = 0;
|
|
while (pos < len) {
|
|
size_t chunkLen = 0;
|
|
while (pos + chunkLen < len) {
|
|
const size_t charLen = utf8CharLenClamped(text, pos + chunkLen, len);
|
|
if (chunkLen + charLen >= sizeof(chunk))
|
|
break;
|
|
chunkLen += charLen;
|
|
}
|
|
|
|
if (chunkLen == 0) {
|
|
chunkLen = std::min(len - pos, sizeof(chunk) - 1);
|
|
}
|
|
|
|
memcpy(chunk, text + pos, chunkLen);
|
|
chunk[chunkLen] = '\0';
|
|
if (draw) {
|
|
if (fauxBold)
|
|
display->drawString(cursorX + 1, fontY, chunk);
|
|
display->drawString(cursorX, fontY, chunk);
|
|
}
|
|
cursorX += getStringWidth(display, chunk, chunkLen);
|
|
pos += chunkLen;
|
|
}
|
|
|
|
return cursorX;
|
|
}
|
|
|
|
size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, const char *ellipsis,
|
|
const Emote *emoteSet, int emoteCount, int emoteSpacing)
|
|
{
|
|
if (!out || outSize == 0)
|
|
return 0;
|
|
|
|
out[0] = '\0';
|
|
if (!display || !line || maxWidth <= 0)
|
|
return 0;
|
|
|
|
const size_t lineLen = strlen(line);
|
|
const int suffixWidth =
|
|
(ellipsis && *ellipsis) ? measureStringWithEmotes(display, ellipsis, emoteSet, emoteCount, emoteSpacing) : 0;
|
|
const char *suffix = (ellipsis && suffixWidth <= maxWidth) ? ellipsis : "";
|
|
const size_t suffixLen = strlen(suffix);
|
|
const int availableWidth = maxWidth - (*suffix ? suffixWidth : 0);
|
|
|
|
if (measureStringWithEmotes(display, line, emoteSet, emoteCount, emoteSpacing) <= maxWidth) {
|
|
strncpy(out, line, outSize - 1);
|
|
out[outSize - 1] = '\0';
|
|
return strlen(out);
|
|
}
|
|
|
|
int used = 0;
|
|
size_t cut = 0;
|
|
for (size_t i = 0; i < lineLen;) {
|
|
// Keep whole emotes together when deciding where to cut.
|
|
int tokenWidth = 0;
|
|
size_t advance = 0;
|
|
|
|
if (isPossibleEmoteLead(static_cast<uint8_t>(line[i]))) {
|
|
size_t matchLen = 0;
|
|
const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
|
|
if (matched) {
|
|
tokenWidth = matched->width + emoteSpacing;
|
|
advance = matchLen;
|
|
}
|
|
}
|
|
|
|
if (advance == 0) {
|
|
const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
|
|
if (skipLen) {
|
|
i += skipLen;
|
|
cut = i;
|
|
continue;
|
|
}
|
|
|
|
const size_t charLen = utf8CharLenClamped(line, i, lineLen);
|
|
tokenWidth = getUtf8ChunkWidth(display, line + i, charLen);
|
|
advance = charLen;
|
|
}
|
|
|
|
if (used + tokenWidth > availableWidth)
|
|
break;
|
|
|
|
used += tokenWidth;
|
|
i += advance;
|
|
cut = i;
|
|
}
|
|
|
|
if (cut == 0) {
|
|
strncpy(out, suffix, outSize - 1);
|
|
out[outSize - 1] = '\0';
|
|
return strlen(out);
|
|
}
|
|
|
|
size_t copyLen = cut;
|
|
if (copyLen > outSize - 1)
|
|
copyLen = outSize - 1;
|
|
if (suffixLen > 0 && copyLen + suffixLen > outSize - 1) {
|
|
copyLen = (outSize - 1 > suffixLen) ? (outSize - 1 - suffixLen) : 0;
|
|
}
|
|
|
|
memcpy(out, line, copyLen);
|
|
size_t totalLen = copyLen;
|
|
if (suffixLen > 0 && totalLen < outSize - 1) {
|
|
memcpy(out + totalLen, suffix, std::min(suffixLen, outSize - 1 - totalLen));
|
|
totalLen += std::min(suffixLen, outSize - 1 - totalLen);
|
|
}
|
|
out[totalLen] = '\0';
|
|
return totalLen;
|
|
}
|
|
|
|
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet,
|
|
int emoteCount, int emoteSpacing, bool fauxBold)
|
|
{
|
|
if (!line)
|
|
return;
|
|
|
|
const size_t lineLen = strlen(line);
|
|
// Center text vertically when any emote is taller than the font.
|
|
const int maxIconHeight =
|
|
analyzeLineInternal(nullptr, line, lineLen, fontHeight, emoteSet, emoteCount, emoteSpacing).tallestHeight;
|
|
const int lineHeight = std::max(fontHeight, maxIconHeight);
|
|
const int fontY = y + (lineHeight - fontHeight) / 2;
|
|
|
|
int cursorX = x;
|
|
bool inBold = false;
|
|
|
|
for (size_t i = 0; i < lineLen;) {
|
|
// Toggle faux bold.
|
|
if (fauxBold && i + 1 < lineLen && line[i] == '*' && line[i + 1] == '*') {
|
|
inBold = !inBold;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
|
|
if (skipLen) {
|
|
i += skipLen;
|
|
continue;
|
|
}
|
|
|
|
size_t matchLen = 0;
|
|
const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
|
|
if (matched) {
|
|
const int iconY = y + (lineHeight - matched->height) / 2;
|
|
display->drawXbm(cursorX, iconY, matched->width, matched->height, matched->bitmap);
|
|
cursorX += matched->width + emoteSpacing;
|
|
i += matchLen;
|
|
continue;
|
|
}
|
|
|
|
size_t next = i;
|
|
while (next < lineLen) {
|
|
// Stop the text run before the next emote or bold marker.
|
|
if (fauxBold && next + 1 < lineLen && line[next] == '*' && line[next + 1] == '*')
|
|
break;
|
|
|
|
if (ignorableModifierLenAt(line, next, lineLen))
|
|
break;
|
|
|
|
size_t nextMatchLen = 0;
|
|
if (findEmoteAt(line, lineLen, next, nextMatchLen, emoteSet, emoteCount) != nullptr)
|
|
break;
|
|
|
|
next += utf8CharLenClamped(line, next, lineLen);
|
|
}
|
|
|
|
if (next == i)
|
|
next += utf8CharLenClamped(line, i, lineLen);
|
|
|
|
cursorX = appendTextSpanAndMeasure(display, cursorX, fontY, line + i, next - i, true, fauxBold && inBold);
|
|
i = next;
|
|
}
|
|
}
|
|
|
|
} // namespace EmoteRenderer
|
|
} // namespace graphics
|
|
|
|
#endif // HAS_SCREEN
|