fix(sim): text-width/render bugs, splash version, wasm-fetch error, battery lag

Code-review pass over the buzzer/sim commits turned up several real bugs,
plus two issues found afterward from manual browser testing:

Rendering (SimDisplayDriverCanvas, variants/sim/SimDisplayDriver.h + target.cpp):
- getTextWidth() measured UTF-8 BYTES (strlen()*6), not codepoints. Since
  b067e95b stopped stripping accents, any accented string now measures
  double its real width -- mis-centred titles, premature ellipsis/marquee,
  badges pushed off-screen. Now uses the real MiscFixedRenderer measurement
  (miscFixedTextWidth()), same as SH1106Display/SSD1306Display.
- Added the matching getCodepointWidth() override (O(1) single-glyph
  advance), same pattern as SSD1306Display.
- isSingleFont() was left at the base class's `false`, though this backend
  only ever renders MiscFixed -- UITask.cpp's status-bar indicator height
  keys off this (`lh-2` vs `lh`), so the sim drew it 2px taller than a real
  board.
- print() blitted the full 128x64 canvas on every call (dozens per frame,
  60fps) -- now tracks a dirty bounding box and only clears/blits the
  region actually touched.

Web Audio (buzzer bridge, index.html + mesh.html):
- No AudioContext.resume() -- a context created (or later suspended) in the
  'suspended' state (Safari/Firefox, or any browser backgrounding the tab)
  stayed silent forever. Now resumed on every gesture.
- linearRampToValueAtTime with no anchoring setValueAtTime interpolates
  from the LAST scheduled event, not "now" -- so the anti-click ramps could
  effectively snap instead of fading. Fixed with cancelScheduledValues +
  setValueAtTime(current) before each ramp.
- mesh.html: a gesture only armed the clicked instance's audio. Click A,
  send A->B, and B (the one actually meant to beep on receipt) stayed
  silent. Now any gesture arms both A and B.
- RTTTL rests (freq=0, still "playing") now explicitly hold pitch and drop
  gain instead of it happening to work by coincidence.

Misc: sim_test_get_num_contacts() was missing the g_sim_ready gate every
other sim_test_* hook has, so it could return a bogus negative count before
setup() finishes seeding num_contacts.

Splash screen missing "Solo <version>" bar: variants/sim never defined
FIRMWARE_SOLO_BUILD (every real Solo board does), so SplashScreen silently
skipped that whole line -- the sim looked like a plain non-Solo companion
build. Added -D FIRMWARE_SOLO_BUILD=1 to platformio.ini and build_wasm.sh.
Verified on a real canvas screenshot: "MESHCORE 1.17.1 / 19 Aug 2026 /
Solo v1.27".

Wasm-fetch error message: "failed to start: RuntimeError: Aborted(both
async and sync fetching of the wasm failed)" is Emscripten's own opaque
message for the single most common real cause -- the page opened via
file://...index.html instead of served over http(s) (fetch() on a local
file is blocked by CORS in both Chrome and Safari, confirmed by reproducing
the exact same error/stack via file://). Both harnesses now detect
location.protocol === 'file:' and show an actionable message with the
one-line fix instead of the raw stack trace.

Battery-set latency: SimMainBoard's battery value is an exact, instantaneous
JS-set integer (see sim_battery_set_mv()), but UITask's battery-check code
polls it every 8s and runs it through an EMA (alpha=0.2) meant to smooth a
REAL board's noisy ADC -- so a value typed into the demo UI could take tens
of seconds to visibly settle. SIM_PLATFORM now checks every 250ms and skips
the EMA (nothing to smooth), since the reading is already clean. Measured
on real canvas pixels: indicator update now lands within one screen-refresh
cycle instead of up to 8s+.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-03 19:32:23 +02:00
co-authored by Claude Sonnet 5
parent f61d6832a7
commit b2cf459460
9 changed files with 198 additions and 35 deletions
+4
View File
@@ -402,6 +402,10 @@ extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_send_msg_to_first_contact(const cha
// the JS ether-tick loop poll "has advert propagation finished yet" without
// guessing a fixed timeout.
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_get_num_contacts() {
// Same g_sim_ready gate as every other hook here: before setup() runs,
// BaseChatMesh::num_contacts hasn't been seeded to MAX_ANON_CONTACTS yet,
// so getNumContacts() would report a negative count.
if (!g_sim_ready) return 0;
return the_mesh.getNumContacts();
}
+23 -1
View File
@@ -2456,7 +2456,8 @@ void UITask::loop() {
// above, in the #if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
// block) -- this stdin-poll branch was only ever meant for Phase 1's
// native terminal target.
// Native terminal input: stdin is put into raw/non-canonical mode by
//
// stdin is put into raw/non-canonical mode by
// variants/sim/sim_main.cpp's main(), so keys arrive here one at a time
// with no Enter-to-submit line buffering. Non-blocking select() on fd 0
// (VMIN=0/VTIME=0 on the fd itself would also work, but select() keeps
@@ -2752,8 +2753,20 @@ void UITask::loop() {
if ((int32_t)(millis() - next_batt_chck) >= 0) {
uint16_t raw = AbstractUITask::getBattMilliVolts();
if (raw > 0) {
#ifdef SIM_PLATFORM
// SimMainBoard::getBattMilliVolts() returns exactly whatever value the
// host page's JS last set (see sim_battery_set_mv() in
// variants/sim/SimMainBoard.h) -- a clean, instantaneous number, not a
// noisy ADC reading. Real hardware needs the EMA below to smooth a
// voltage divider's jitter under load; applying that same filter here
// just makes a value typed into the demo UI visibly crawl toward its
// target over several 8s samples, which reads as the whole sim being
// laggy for no benefit the sim actually needs.
_batt_mv = raw;
#else
// EMA filter: alpha=0.2 (80% old, 20% new) — smooths ADC noise from uneven load
_batt_mv = (_batt_mv == 0) ? raw : (uint16_t)((_batt_mv * 4u + raw) / 5u);
#endif
}
uint16_t low_mv = _node_prefs ? _node_prefs->low_batt_mv : 0;
// Don't shut down while on external power (charging) — avoids a shutdown loop.
@@ -2775,7 +2788,16 @@ void UITask::loop() {
}
shutdown();
}
#ifdef SIM_PLATFORM
// A real ADC read has a real cost, worth spacing out 8s apart; the sim's
// "read" is just returning a JS-set integer, so there's no reason to sit
// on a stale value for up to 8s after Set was clicked. 250ms keeps this
// a poll (not a push wired to the input's own event, which would need
// its own plumbing) while feeling immediate.
next_batt_chck = millis() + 250;
#else
next_batt_chck = millis() + 8000;
#endif
}
// GPS duty-cycle hold — tells the sensor manager whether *anything* needs
+4 -1
View File
@@ -230,7 +230,10 @@ public:
#ifdef PIN_BUZZER
// Lets a host page poll the buzzer's current state every frame to drive a
// Web Audio oscillator (see sim_buzzer_is_playing()/sim_buzzer_freq_hz()
// in main.cpp) -- `buzzer` itself is a private member, so a free function
// in UITask.cpp, next to sim_enqueue_key() -- they live there, not in
// main.cpp, because the `g_sim_ui_task_for_js` pointer they dispatch
// through is file-static to that translation unit) -- `buzzer` itself is a
// private member, so a free function
// outside this class needs these to reach it, same reason injectSimKey()
// above is public.
// Not const: genericBuzzer::isPlaying() itself isn't const-qualified (its
+22 -3
View File
@@ -250,6 +250,14 @@ public:
int getCharWidth() const override { return 6 * _text_sz; }
int getLineHeight() const override { return 9 * _text_sz; }
// Misc-fixed 6x9 is this backend's one and only font, exactly like a real
// SSD1306Display/SH1106Display built with OLED_MISC_FIXED_FONT=1 (both
// return true here too). UITask's status-bar indicator height keys off
// this (`ind_h = display.isSingleFont() ? lh - 2 : lh`, UITask.cpp) -- left
// at the base class's false, the sim drew that row 2px taller than the
// real board it's mirroring.
bool isSingleFont() const override { return true; }
// Amber-on-black palette (a common OLED look) for LIGHT/DARK; the other
// Color enumerators (RED/GREEN/BLUE/YELLOW/ORANGE) aren't used on the real
// monochrome OLED boards this sim mirrors either (DisplayDriver.h's own
@@ -332,9 +340,20 @@ public:
}, x, y, w, h, bits, (_color != DARK) ? "L" : "D");
}
uint16_t getTextWidth(const char* str) override {
return str ? (uint16_t)(strlen(str) * getCharWidth()) : 0;
}
// Measured off the real MiscFixed glyph table, per CODEPOINT -- not
// strlen() * 6, which counts UTF-8 BYTES. Since translateUTF8ToBlocks()
// above stopped transliterating accents away, strings reaching here really
// do carry multi-byte sequences, and a byte count made every accented
// character measure double: mis-centred titles, text ellipsized/marquee'd
// far too early, right-aligned badges pushed off. Same implementation the
// real single-font OLED drivers use (SH1106Display::getTextWidth() ->
// miscFixedTextWidth()). Defined out-of-line in target.cpp for the same
// reason print() is -- only that TU may include MiscFixedRenderer.h.
uint16_t getTextWidth(const char* str) override;
// O(1) single-glyph advance, mirroring SSD1306Display::getCodepointWidth()
// -> glyphXAdvance(). The base class would otherwise re-encode the
// codepoint and call getTextWidth() on it.
uint16_t getCodepointWidth(uint32_t cp) override;
// Every draw call above already lands directly on the visible canvas
// (see the class comment) -- nothing left to flush.
+3
View File
@@ -131,6 +131,9 @@ DEFINES=(
# Dummy sentinel (no real pin) -- see platformio.ini's own comment on the
# native env's identical flag.
-DPIN_BUZZER=0
# See platformio.ini's native env for the same flag: without it the splash
# screen's "Solo <version>" bar never draws.
-DFIRMWARE_SOLO_BUILD=1
-DMAX_CONTACTS=100
-DMAX_GROUP_CHANNELS=8
)
+6
View File
@@ -33,6 +33,12 @@ build_flags =
; way). genericBuzzer's own #ifdef SIM_PLATFORM branch (src/helpers/ui/
; buzzer.cpp) never touches a real pin, so the value itself is unused.
-D PIN_BUZZER=0
; Every real board running this same ui-new/ tree builds as a Solo config
; (see e.g. solo/heltec_v3/platformio.ini) -- without this, SplashScreen
; (UITask.cpp) skips the whole "Solo <version>" bar under the big MeshCore
; version digits, and the splash silently looks like a plain non-Solo
; companion build instead of what it actually is.
-D FIRMWARE_SOLO_BUILD=1
-D MAX_CONTACTS=100
-D MAX_GROUP_CHANNELS=8
-I variants/sim/arduino
+45 -15
View File
@@ -83,17 +83,44 @@ mesh::LocalIdentity radio_new_identity() {
class SimGfxCanvas : public Adafruit_GFX {
public:
uint8_t px[128 * 64];
SimGfxCanvas() : Adafruit_GFX(128, 64) { memset(px, 0, sizeof(px)); }
// Inclusive bounding box of everything plotted since the last resetDirty().
// Without it, print() blitted all 8192 cells on every single call -- and a
// busy screen makes dozens of print() calls per frame, at 60fps, so the
// JS-side pixel loop dominated the whole frame budget for what is usually
// one short row of text.
int dx0, dy0, dx1, dy1;
SimGfxCanvas() : Adafruit_GFX(128, 64) { memset(px, 0, sizeof(px)); resetDirty(); }
void resetDirty() { dx0 = 128; dy0 = 64; dx1 = -1; dy1 = -1; }
bool isDirty() const { return dx1 >= dx0 && dy1 >= dy0; }
void drawPixel(int16_t x, int16_t y, uint16_t color) override {
if ((unsigned)x >= 128 || (unsigned)y >= 64) return;
px[y * 128 + x] = (color != 0) ? 1 : 0;
if (x < dx0) dx0 = x;
if (x > dx1) dx1 = x;
if (y < dy0) dy0 = y;
if (y > dy1) dy1 = y;
}
};
// Real MiscFixed metrics, same source of truth the glyph plotting above
// uses -- see SimDisplayDriver.h for why these are here and not inline.
uint16_t SimDisplayDriverCanvas::getTextWidth(const char* str) {
return str ? miscFixedTextWidth(str, _text_sz) : 0;
}
uint16_t SimDisplayDriverCanvas::getCodepointWidth(uint32_t cp) {
return miscFixedXAdvance(cp, _text_sz);
}
void SimDisplayDriverCanvas::print(const char* str) {
if (!str) return;
static SimGfxCanvas gfx;
memset(gfx.px, 0, sizeof(gfx.px));
// Only the previously-dirtied region needs clearing, not all 8 KB.
if (gfx.isDirty()) {
for (int y = gfx.dy0; y <= gfx.dy1; y++)
memset(&gfx.px[y * 128 + gfx.dx0], 0, (size_t)(gfx.dx1 - gfx.dx0 + 1));
}
gfx.resetDirty();
gfx.setCursor(_cursor_x, _cursor_y);
// color arg is just our own internal "lit" marker (1) -- the real on-screen
// amber/black choice is applied once at blit time below, from _color, same
@@ -105,20 +132,23 @@ void SimDisplayDriverCanvas::print(const char* str) {
// startFrame() already blanks the whole canvas to black every frame, so
// only the lit pixels need drawing here -- unlit buffer cells are already
// correct background. One EM_ASM call blits the whole 128x64 buffer
// (reading it directly out of wasm memory, same pattern as drawXbm()
// below) rather than one call per glyph pixel.
EM_ASM({
if (!Module.__simCtx) return;
var ctx = Module.__simCtx;
var buf = $0;
ctx.fillStyle = UTF8ToString($1) === 'L' ? '#ffb000' : '#000';
for (var y = 0; y < 64; y++) {
for (var x = 0; x < 128; x++) {
if (HEAPU8[buf + y * 128 + x]) ctx.fillRect(x, y, 1, 1);
// correct background. One EM_ASM call blits the buffer (reading it directly
// out of wasm memory, same pattern as drawXbm() below) rather than one call
// per glyph pixel, and only over the rows/columns this string actually
// touched rather than the full 128x64.
if (gfx.isDirty()) {
EM_ASM({
if (!Module.__simCtx) return;
var ctx = Module.__simCtx;
var buf = $0;
ctx.fillStyle = UTF8ToString($5) === 'L' ? '#ffb000' : '#000';
for (var y = $2; y <= $4; y++) {
for (var x = $1; x <= $3; x++) {
if (HEAPU8[buf + y * 128 + x]) ctx.fillRect(x, y, 1, 1);
}
}
}
}, gfx.px, (_color != DARK) ? "L" : "D");
}, gfx.px, gfx.dx0, gfx.dy0, gfx.dx1, gfx.dy1, (_color != DARK) ? "L" : "D");
}
// Same external contract as every other DisplayDriver backend here (see
// SimDisplayDriver's own ASCII print()): only _cursor_x advances by the
+47 -8
View File
@@ -189,6 +189,26 @@
console[level] = (...args) => { orig(...args); log('[js] ' + args.join(' ')); };
}
// Emscripten's own "both async and sync fetching of the wasm failed" is
// near-unreadable to anyone who hasn't debugged this exact runtime
// before. Its single most common real-world cause: the page was opened
// directly (double-clicked, file://...index.html) instead of served
// over http -- `fetch()` on a local file is blocked by CORS in both
// Chrome and Safari, with no server-side fix possible (it's the browser
// refusing the request, not a missing/misnamed file). Detect that
// specific case and say so plainly; anything else, show the raw error
// so it's at least visible instead of silently swallowed.
function reportBootFailure(err) {
console.error(err);
if (location.protocol === 'file:') {
statusEl.textContent = 'error: this page must be served over http(s), not opened as a file:// URL '
+ '(the browser blocks the wasm fetch either way). Run e.g. '
+ '"cd variants/sim/web && python3 -m http.server 8080" and open http://localhost:8080/index.html instead.';
} else {
statusEl.textContent = 'error: failed to start the wasm module -- ' + err + ' (see devtools console for detail).';
}
}
if (typeof MeshCoreSim !== 'function') {
statusEl.textContent = 'error: build/meshcore_sim.js missing or failed to load -- run variants/sim/build_wasm.sh first.';
} else {
@@ -226,8 +246,17 @@
// unavoidable limitation any embedded page with boot sound has.
let audioCtx = null, buzzOsc = null, buzzGain = null, buzzWasPlaying = false;
function ensureAudio() {
if (audioCtx) return;
// Not just "create once": a context can also be created in, or later
// fall back to, the 'suspended' state (Safari/Firefox start it
// suspended even inside a gesture handler; any browser may suspend
// it again when the tab is backgrounded). Without resuming it here
// on every gesture, the oscillator keeps running silently forever.
if (audioCtx) {
if (audioCtx.state === 'suspended') audioCtx.resume();
return;
}
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
buzzOsc = audioCtx.createOscillator();
buzzGain = audioCtx.createGain();
buzzGain.gain.value = 0;
@@ -245,6 +274,17 @@
// silence regardless of what's "playing" (mutes without touching the
// sim's own note-advance timing).
const BUZZER_GAIN = [0, 0.02, 0.05, 0.09, 0.15];
// A linear ramp interpolates from the PREVIOUS automation event, not
// from "now" -- with no event anchoring the curve's start, a ramp
// scheduled long after the last one effectively snaps to its target
// instead of easing over the requested few ms, which is exactly the
// click the ramp was meant to avoid. Anchoring at the current value
// first makes the short fade real.
function rampGain(target, seconds, now) {
buzzGain.gain.cancelScheduledValues(now);
buzzGain.gain.setValueAtTime(buzzGain.gain.value, now);
buzzGain.gain.linearRampToValueAtTime(target, now + seconds);
}
function pollBuzzer() {
if (!audioCtx || !Module || !Module._sim_buzzer_is_playing) return;
const playing = Module.ccall('sim_buzzer_is_playing', 'number', [], []) === 1;
@@ -252,10 +292,12 @@
if (playing) {
const freq = Module.ccall('sim_buzzer_freq_hz', 'number', [], []);
const vol = Module.ccall('sim_buzzer_get_volume', 'number', [], []);
buzzOsc.frequency.setValueAtTime(freq > 0 ? freq : 440, now);
buzzGain.gain.linearRampToValueAtTime(freq > 0 ? BUZZER_GAIN[vol] || 0 : 0, now + 0.005);
// freq 0 is a real RTTTL rest ('p') -- still "playing", just
// silent, so hold the last pitch and drop the gain instead.
if (freq > 0) buzzOsc.frequency.setValueAtTime(freq, now);
rampGain(freq > 0 ? (BUZZER_GAIN[vol] || 0) : 0, 0.005, now);
} else if (buzzWasPlaying) {
buzzGain.gain.linearRampToValueAtTime(0, now + 0.01);
rampGain(0, 0.01, now);
}
buzzWasPlaying = playing;
}
@@ -414,10 +456,7 @@
const result = Module.ccall('sim_test_login_first_repeater', 'number', ['string'], [pw]);
log(`login request result=${result} (1=sent,0=send failed,-1=no contact yet; actual accept/reject is async -- check the Admin screen)`);
});
}).catch((err) => {
statusEl.textContent = 'failed to start: ' + err;
console.error(err);
});
}).catch(reportBootFailure);
}
</script>
</body>
+44 -7
View File
@@ -429,9 +429,23 @@
// -- currentInst(key) below always resolves the live one, so a reset
// instance's sound keeps working against the same AudioContext.
const buzzAudio = {};
// Any gesture anywhere on the page arms BOTH companion instances, not
// just the one that was clicked: the whole point of this demo is that A
// sends and B *beeps on receipt*, so waiting for a separate gesture on
// B's own panel would leave the receiving side silent in exactly the
// scenario the page exists to show.
function ensureAudioAll() { ensureAudio('A'); ensureAudio('B'); }
function ensureAudio(key) {
if (key === 'R' || buzzAudio[key]) return;
if (key === 'R') return;
// Also resume, not just create: a context can start suspended
// (Safari/Firefox) or be suspended again when the tab is
// backgrounded, and would then stay silent forever. See index.html.
if (buzzAudio[key]) {
if (buzzAudio[key].ctx.state === 'suspended') buzzAudio[key].ctx.resume();
return;
}
const ctx = new (window.AudioContext || window.webkitAudioContext)();
if (ctx.state === 'suspended') ctx.resume();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
gain.gain.value = 0;
@@ -442,6 +456,14 @@
buzzAudio[key] = { ctx, osc, gain, wasPlaying: false };
}
const BUZZER_GAIN = [0, 0.02, 0.05, 0.09, 0.15];
// See index.html's rampGain(): a linear ramp interpolates from the last
// scheduled event, so it needs an anchor at the current value to
// actually fade rather than snap.
function rampGain(b, target, seconds, now) {
b.gain.gain.cancelScheduledValues(now);
b.gain.gain.setValueAtTime(b.gain.gain.value, now);
b.gain.gain.linearRampToValueAtTime(target, now + seconds);
}
function pollBuzzer(key) {
const b = buzzAudio[key];
const inst = currentInst(key);
@@ -451,10 +473,11 @@
if (playing) {
const freq = inst.mod.ccall('sim_buzzer_freq_hz', 'number', [], []);
const vol = inst.mod.ccall('sim_buzzer_get_volume', 'number', [], []);
b.osc.frequency.setValueAtTime(freq > 0 ? freq : 440, now);
b.gain.gain.linearRampToValueAtTime(freq > 0 ? (BUZZER_GAIN[vol] || 0) : 0, now + 0.005);
// freq 0 is a real RTTTL rest ('p'): still playing, just silent.
if (freq > 0) b.osc.frequency.setValueAtTime(freq, now);
rampGain(b, freq > 0 ? (BUZZER_GAIN[vol] || 0) : 0, 0.005, now);
} else if (b.wasPlaying) {
b.gain.gain.linearRampToValueAtTime(0, now + 0.01);
rampGain(b, 0, 0.01, now);
}
b.wasPlaying = playing;
}
@@ -470,7 +493,7 @@
let timer = null, longFired = false;
btn.addEventListener('pointerdown', (e) => {
e.preventDefault();
ensureAudio(key);
ensureAudioAll();
longFired = false;
timer = setTimeout(() => { longFired = true; sendKeyLongPress(currentInst(key), code); }, LONG_PRESS_MS);
});
@@ -650,7 +673,7 @@
const code = resolveKey(e);
if (code === null) return;
e.preventDefault();
ensureAudio(focusedKey);
ensureAudioAll();
if (keyHold.has(e.key)) return;
const state = { longFired: false };
state.timer = setTimeout(() => { state.longFired = true; sendKeyLongPress(currentInst(focusedKey), code); }, LONG_PRESS_MS);
@@ -672,8 +695,22 @@
}
main().catch((err) => {
log('FATAL: ' + err);
console.error(err);
// Emscripten's "both async and sync fetching of the wasm failed" is
// near-unreadable on its own -- its single most common real-world
// cause is opening this page directly (file://...mesh.html) instead
// of serving it over http(s): `fetch()` on a local file is blocked by
// CORS in both Chrome and Safari, with no server-side fix (it's the
// browser refusing the request, not a missing/misnamed file). See
// index.html's matching reportBootFailure() for the single-instance
// harness's version of this same check.
if (location.protocol === 'file:') {
log('FATAL: this page must be served over http(s), not opened as a file:// URL '
+ '(the browser blocks the wasm fetch either way). Run e.g. '
+ '"cd variants/sim/web && python3 -m http.server 8080" and open http://localhost:8080/mesh.html instead.');
} else {
log('FATAL: ' + err);
}
});
</script>
</body>