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
+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>