feat(sim): two-device messaging + repeater relay over a JS ether

Ports examples/simple_repeater to variants/sim/ (new sim_simple_repeater
native env + build_wasm_repeater.sh) and adds a JS "ether"
(variants/sim/web/mesh.html) that bridges two real companion_radio WASM
instances through a real simple_repeater instance in a strict A<->R<->B
topology (no direct A-B link), proving genuine relay routing rather than
a shortcut.

Also fixes multi-instance issues Phase 2's single-instance design never
surfaced: SimDisplayDriver's canvas context/id caching was keyed on a
single global instead of per-instance, and both wasm builds were missing
_malloc/_free/HEAPU8 runtime exports needed for the ether to poke bytes
into an instance's memory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-03 09:14:15 +02:00
co-authored by Claude Sonnet 5
parent 8f4c92a217
commit 9cfb58a60b
18 changed files with 1199 additions and 54 deletions
+23
View File
@@ -3294,6 +3294,29 @@ bool MyMesh::advert() {
} }
} }
#ifdef SIM_PLATFORM
bool MyMesh::advertFlood() {
// Mirrors the CMD_SEND_SELF_ADVERT handler's flood=1 branch above
// (createSelfAdvert() + sendFloodScoped() with the default transport
// scope key) -- real protocol logic, just reached from a test-only entry
// point instead of a parsed serial command frame.
mesh::Packet* pkt;
if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) {
pkt = createSelfAdvert(_prefs.node_name);
} else {
pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon);
}
if (pkt) {
TransportKey default_scope;
memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
sendFloodScoped(default_scope, pkt, 0);
return true;
} else {
return false;
}
}
#endif
// To check if there is pending work // To check if there is pending work
bool MyMesh::hasPendingWork() const { bool MyMesh::hasPendingWork() const {
return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0; return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0;
+11
View File
@@ -108,6 +108,17 @@ public:
void loop(); void loop();
void handleCmdFrame(size_t len); void handleCmdFrame(size_t len);
bool advert(); bool advert();
#ifdef SIM_PLATFORM
// Phase 3 sim-only test hook: same as advert() but FLOOD-routed (like the
// real phone-app CMD_SEND_SELF_ADVERT command's flood=1 branch) instead of
// zero-hop, so a JS test harness can make an instance's identity actually
// propagate through an intermediate repeater instance -- advert() alone
// (zero-hop) never leaves the immediate ether link. Not reachable from any
// real hardware build (no phone app exists in the sim to send the real
// CMD_SEND_SELF_ADVERT command over), so this is additive/dead code
// everywhere else, not a behavior change.
bool advertFlood();
#endif
void sendNodeDiscoverReq(); void sendNodeDiscoverReq();
void enterCLIRescue(); void enterCLIRescue();
+100
View File
@@ -2,6 +2,19 @@
#include <Mesh.h> #include <Mesh.h>
#include "MyMesh.h" #include "MyMesh.h"
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
// Phase 3: true only once setup() has fully finished (set at the very end
// of setup(), below) -- lets a JS test harness poll "has this instance
// actually booted" instead of guessing a fixed delay after the MODULARIZE
// factory promise resolves, which resolves once the wasm module is
// instantiated, well before sim_idbfs_ready()'s async IDBFS callback ever
// invokes setup() (see variants/sim/sim_main.cpp). Calling any of the other
// sim_test_*() hooks below before this is true would run against a
// the_mesh that exists (global C++ construction already ran) but hasn't
// had begin()/an identity loaded yet.
static bool g_sim_ready = false;
#endif
// Believe it or not, this std C function is busted on some platforms! // Believe it or not, this std C function is busted on some platforms!
static uint32_t _atoi(const char* sp) { static uint32_t _atoi(const char* sp) {
uint32_t n = 0; uint32_t n = 0;
@@ -289,6 +302,9 @@ void setup() {
NRF_WDT->TASKS_START = 1; NRF_WDT->TASKS_START = 1;
#endif #endif
board.onBootComplete(); board.onBootComplete();
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
g_sim_ready = true;
#endif
} }
void loop() { void loop() {
@@ -323,3 +339,87 @@ void loop() {
} }
#endif #endif
} }
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
#include <emscripten.h>
// Phase 3 sim test hooks -- a browser test harness (no real phone app, no
// on-device keyboard-driven compose flow in this sim yet) needs SOME way
// to trigger "send a flood advert" / "send a DM" from JS. Every one of
// these calls straight into the exact same real BaseChatMesh/MyMesh
// functions the real phone-app serial protocol (CMD_SEND_SELF_ADVERT,
// CMD_SEND_TXT_MSG in this same file) or the on-device UI compose flow
// (MessagesScreen::afterSend) already use -- real crypto, real routing,
// real contact table, nothing about the mesh/message logic is faked here,
// only "what UI gesture triggers it" is short-circuited. See the Phase 3
// report for why: scripting the on-device virtual keyboard widget
// key-by-key to compose free text was judged not worth the fragility for
// an automated test, versus this ~20-line, obviously-inert-on-real-hardware
// addition.
extern "C" EMSCRIPTEN_KEEPALIVE int sim_is_ready() {
return g_sim_ready ? 1 : 0;
}
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_advert_flood() {
if (!g_sim_ready) return 0;
return the_mesh.advertFlood() ? 1 : 0;
}
// getContactByIdx(idx, ...) indexes DIRECTLY into the raw contacts[] array
// (src/helpers/BaseChatMesh.cpp) with NO offset applied -- getNumContacts()
// only SUBTRACTS MAX_ANON_CONTACTS from the count to hide the reserved
// anon-request slots at the front of that array, it doesn't shift where
// index 0 points. The real UI/CLI code never hits this because it always
// walks contacts via startContactsIterator() (BaseChatMesh.cpp), which
// already begins at MAX_ANON_CONTACTS -- this small helper mirrors that
// same offset for these test-only hooks instead of duplicating an iterator.
static bool findFirstChatContact(ContactInfo& out) {
int n = the_mesh.getNumContacts();
for (int i = 0; i < n; i++) {
ContactInfo ci;
if (the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, ci) && ci.type == ADV_TYPE_CHAT) {
out = ci;
return true;
}
}
return false;
}
// Finds the first known contact of type ADV_TYPE_CHAT (i.e. another
// companion_radio instance, not a repeater/room) and sends it a real DM via
// BaseChatMesh::sendMessage() -- the exact function CMD_SEND_TXT_MSG calls.
// Returns MSG_SEND_SENT_FLOOD/MSG_SEND_SENT_DIRECT/MSG_SEND_FAILED (see
// src/helpers/BaseChatMesh.h), or -1 if no chat contact is known yet.
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_send_msg_to_first_contact(const char* text) {
if (!g_sim_ready) return -1;
ContactInfo ci;
if (!findFirstChatContact(ci)) return -1;
uint32_t expected_ack, est_timeout;
uint32_t ts = rtc_clock.getCurrentTimeUnique();
return the_mesh.sendMessage(ci, ts, 0, text, expected_ack, est_timeout);
}
// How many contacts this instance has discovered so far (any type) -- lets
// 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() {
return the_mesh.getNumContacts();
}
#ifdef DISPLAY_CLASS
// Jumps the on-device UI straight to the DM thread with the first known
// ADV_TYPE_CHAT contact (UITask::openContactDM() -- the exact same real
// function NearbyScreen's contact-list "select" action calls) so a test
// harness can screenshot the canvas and see the actual received message
// text, rendered by the real MessagesScreen/DisplayDriver code, without
// having to script the on-device contact-list navigation key-by-key.
// Returns 1 if a chat contact was found and the screen switched, 0 if not
// (e.g. advert propagation hasn't reached this instance yet).
extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_open_dm_with_first_contact() {
if (!g_sim_ready) return 0;
ContactInfo ci;
if (!findFirstChatContact(ci)) return 0;
ui_task.openContactDM(ci);
return 1;
}
#endif
#endif
+4
View File
@@ -1022,6 +1022,8 @@ bool MyMesh::formatFileSystem() {
return LittleFS.format(); return LittleFS.format();
#elif defined(ESP32) #elif defined(ESP32)
return SPIFFS.format(); return SPIFFS.format();
#elif defined(SIM_PLATFORM)
return _fs->format();
#else #else
#error "need to implement file system erase" #error "need to implement file system erase"
return false; return false;
@@ -1181,6 +1183,8 @@ void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) {
IdentityStore store(*_fs, "/identity"); IdentityStore store(*_fs, "/identity");
#elif defined(RP2040_PLATFORM) #elif defined(RP2040_PLATFORM)
IdentityStore store(*_fs, "/identity"); IdentityStore store(*_fs, "/identity");
#elif defined(SIM_PLATFORM)
IdentityStore store(*_fs, "/identity");
#else #else
#error "need to define saveIdentity()" #error "need to define saveIdentity()"
#endif #endif
+51
View File
@@ -22,6 +22,34 @@ void halt() {
while (1) ; while (1) ;
} }
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
#include <emscripten.h>
// Phase 3 sim ether: a JS-visible "did this repeater just relay a packet"
// hook. MyMesh (src/Mesh.h's Mesh base class) already keeps an exact count
// of packets actually re-transmitted in the repeater/transport role --
// n_forwarded, incremented at the real ACTION_RETRANSMIT decision points in
// src/Mesh.cpp (routeRecvPacket()) and decremented in onRetransmitCancelled()
// if an overhear cancels a queued retransmit before it goes out -- and
// already exposes it publicly as getNumForwarded() (the same number
// DiagnosticsScreen.h prints on a real companion_radio's screen). Rather
// than re-deriving "was this a relay" from TX/RX byte timing (fragile,
// and duplicates logic the real Mesh class already gets right, including
// the overhear-cancellation edge case), this just exports that exact
// counter for JS to poll and diff on its own ether tick -- zero changes
// to any shared/platform-agnostic src/ file.
extern "C" EMSCRIPTEN_KEEPALIVE uint32_t sim_repeater_get_relay_count() {
return the_mesh.getNumForwarded();
}
// Same "has setup() actually finished" readiness flag as
// examples/companion_radio/main.cpp -- see that file's comment for why
// this is needed instead of a fixed post-ready delay.
static bool g_sim_ready = false;
extern "C" EMSCRIPTEN_KEEPALIVE int sim_is_ready() {
return g_sim_ready ? 1 : 0;
}
#endif
static char command[160]; static char command[160];
#ifdef ETHERNET_ENABLED #ifdef ETHERNET_ENABLED
static char ethernet_command[160]; static char ethernet_command[160];
@@ -37,7 +65,14 @@ static unsigned long userBtnDownAt = 0;
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
#ifndef SIM_PLATFORM
// Skip this one-shot boot pause in the sim: under Emscripten it would
// synchronously block the browser's single JS thread for a full second
// (Arduino.h's sim delay() is a real std::this_thread::sleep_for(), and
// this runs before emscripten_set_main_loop ever hands control back) --
// harmless on a real board's own thread, unnecessary UX friction here.
delay(1000); delay(1000);
#endif
board.begin(); board.begin();
@@ -81,6 +116,19 @@ void setup() {
fs = &LittleFS; fs = &LittleFS;
IdentityStore store(LittleFS, "/identity"); IdentityStore store(LittleFS, "/identity");
store.begin(); store.begin();
#elif defined(SIM_PLATFORM)
// Real files under ./sim_data_repeater/ (relative to the process's cwd,
// native; or the Emscripten virtual FS, wasm) -- deliberately a DIFFERENT
// root than examples/companion_radio/main.cpp's "./sim_data" so a
// repeater instance's identity can never collide with a companion
// instance's, even if both happened to run from the same cwd (native) or
// the same page (wasm, see variants/sim/sim_main.cpp's SIM_FS_ROOT).
// "static" (not a plain local) so sim_fs outlives setup() -- IdentityStore
// only stores a pointer to it, same reasoning as the ui_task static above.
static SimFS sim_fs("./sim_data_repeater");
fs = &sim_fs;
IdentityStore store(sim_fs, "/identity");
store.begin();
#else #else
#error "need to define filesystem" #error "need to define filesystem"
#endif #endif
@@ -120,6 +168,9 @@ void setup() {
#endif #endif
board.onBootComplete(); board.onBootComplete();
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
g_sim_ready = true;
#endif
} }
void loop() { void loop() {
+12 -1
View File
@@ -22,7 +22,18 @@ public:
virtual long getHDOP() { return -1; } virtual long getHDOP() { return -1; }
virtual bool isValid() = 0; virtual bool isValid() = 0;
virtual long getTimestamp() = 0; virtual long getTimestamp() = 0;
virtual void sendSentence(const char * sentence); // Default no-op body (every existing subclass overrides this anyway --
// MicroNMEALocationProvider.h forwards to the NMEA lib,
// EnvironmentSensorManager.cpp's GPS classes no-op it explicitly) --
// previously declared with NO definition anywhere in the codebase
// (confirmed by repo-wide grep), which is latently a hard link error
// ("undefined symbol: typeinfo for LocationProvider") for ANY subclass
// that doesn't override it, since base-subobject construction of any
// LocationProvider-derived object needs this class's own vtable/RTTI to
// exist as a real linkable symbol. Surfaced by variants/sim's
// SimLocationProvider (Phase 3 of the sim plan) being the first
// consumer to construct one outside of code that always overrides it.
virtual void sendSentence(const char * sentence) { }
virtual void reset() = 0; virtual void reset() = 0;
virtual void begin() = 0; virtual void begin() = 0;
virtual void stop() = 0; virtual void stop() = 0;
+46 -21
View File
@@ -167,8 +167,31 @@ private:
// //
// Each call reaches into the DOM via EM_ASM (synchronous, main-thread JS -- // Each call reaches into the DOM via EM_ASM (synchronous, main-thread JS --
// fine since this build has no pthreads/proxying). The canvas is looked up // fine since this build has no pthreads/proxying). The canvas is looked up
// by id once in begin() and cached on a JS global (window.__simCtx) so every // by id once in begin() and cached on a per-instance JS property (see below)
// later call is one property read, not a fresh getElementById(). // so every later call is one property read, not a fresh getElementById().
//
// Phase 3 addendum: cached on Module.__simCtx, NOT window.__simCtx as this
// class originally did in Phase 2. Phase 2 only ever ran one instance on a
// page, so a plain `window` global was invisible/harmless as a design smell;
// Phase 3 loads multiple MeshCoreSim()/MeshCoreSimRepeater() instances on
// ONE page, and `window` is the single real browser global shared by every
// one of them (MODULARIZE isolates each instance's own Module/wasm linear
// memory, but NOT the DOM/window) -- two instances' begin() calls would
// stomp the same window.__simCtx in turn, and both would end up drawing
// through whichever one won. `Module` itself, by contrast, IS a distinct
// object per instance (that's the whole point of MODULARIZE) and is already
// reachable from inside EM_ASM here as the current instance's own Module
// (same access pattern SimFS.h's sim_fs_mount_idbfs()/SimInstance.h's
// sim_instance_salt() already rely on for Module['simInstanceTag']), so
// storing it there instead scopes it correctly per instance for free.
//
// The canvas element id is ALSO made per-instance the same way: an untagged
// instance (no Module['simInstanceTag'], e.g. Phase 2's original
// single-instance web/index.html harness) still looks for plain
// "sim-canvas", byte-for-byte the pre-Phase-3 behavior; a tagged instance
// (Module['simInstanceTag'] = 'A', from a Phase 3 multi-instance host page
// like web/mesh.html) looks for "sim-canvas-A" instead, so two instances on
// one page never fight over the same <canvas> element either.
class SimDisplayDriverCanvas : public DisplayDriver { class SimDisplayDriverCanvas : public DisplayDriver {
bool _on = false; bool _on = false;
int _cursor_x = 0, _cursor_y = 0; int _cursor_x = 0, _cursor_y = 0;
@@ -180,10 +203,12 @@ public:
bool begin() { bool begin() {
_on = true; _on = true;
EM_ASM({ EM_ASM({
var c = document.getElementById('sim-canvas'); var tag = (typeof Module !== 'undefined' && Module['simInstanceTag']) ? Module['simInstanceTag'] : '';
if (!c) { console.error('[sim] #sim-canvas not found in the host page'); return; } var id = tag ? ('sim-canvas-' + tag) : 'sim-canvas';
window.__simCtx = c.getContext('2d'); var c = document.getElementById(id);
window.__simCtx.imageSmoothingEnabled = false; if (!c) { console.error('[sim] #' + id + ' not found in the host page'); return; }
Module.__simCtx = c.getContext('2d');
Module.__simCtx.imageSmoothingEnabled = false;
}); });
return true; return true;
} }
@@ -193,9 +218,9 @@ public:
void turnOff() override { void turnOff() override {
_on = false; _on = false;
EM_ASM({ EM_ASM({
if (!window.__simCtx) return; if (!Module.__simCtx) return;
window.__simCtx.fillStyle = '#000'; Module.__simCtx.fillStyle = '#000';
window.__simCtx.fillRect(0, 0, 128, 64); Module.__simCtx.fillRect(0, 0, 128, 64);
}); });
} }
void clear() override { turnOff(); _on = true; } void clear() override { turnOff(); _on = true; }
@@ -203,9 +228,9 @@ public:
void startFrame(Color bkg = DARK) override { void startFrame(Color bkg = DARK) override {
_color = LIGHT; _color = LIGHT;
EM_ASM({ EM_ASM({
if (!window.__simCtx) return; if (!Module.__simCtx) return;
window.__simCtx.fillStyle = '#000'; Module.__simCtx.fillStyle = '#000';
window.__simCtx.fillRect(0, 0, 128, 64); Module.__simCtx.fillRect(0, 0, 128, 64);
}); });
} }
@@ -222,8 +247,8 @@ public:
void print(const char* str) override { void print(const char* str) override {
if (!str) return; if (!str) return;
EM_ASM({ EM_ASM({
if (!window.__simCtx) return; if (!Module.__simCtx) return;
var ctx = window.__simCtx; var ctx = Module.__simCtx;
ctx.fillStyle = UTF8ToString($3) === 'L' ? '#ffb000' : '#000'; ctx.fillStyle = UTF8ToString($3) === 'L' ? '#ffb000' : '#000';
ctx.font = '8px monospace'; ctx.font = '8px monospace';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
@@ -248,16 +273,16 @@ public:
void fillRect(int x, int y, int w, int h) override { void fillRect(int x, int y, int w, int h) override {
EM_ASM({ EM_ASM({
if (!window.__simCtx) return; if (!Module.__simCtx) return;
window.__simCtx.fillStyle = UTF8ToString($4) === 'L' ? '#ffb000' : '#000'; Module.__simCtx.fillStyle = UTF8ToString($4) === 'L' ? '#ffb000' : '#000';
window.__simCtx.fillRect($0, $1, $2, $3); Module.__simCtx.fillRect($0, $1, $2, $3);
}, x, y, w, h, (_color != DARK) ? "L" : "D"); }, x, y, w, h, (_color != DARK) ? "L" : "D");
} }
void drawRect(int x, int y, int w, int h) override { void drawRect(int x, int y, int w, int h) override {
EM_ASM({ EM_ASM({
if (!window.__simCtx) return; if (!Module.__simCtx) return;
var ctx = window.__simCtx; var ctx = Module.__simCtx;
ctx.strokeStyle = UTF8ToString($4) === 'L' ? '#ffb000' : '#000'; ctx.strokeStyle = UTF8ToString($4) === 'L' ? '#ffb000' : '#000';
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.strokeRect($0 + 0.5, $1 + 0.5, $2 - 1, $3 - 1); ctx.strokeRect($0 + 0.5, $1 + 0.5, $2 - 1, $3 - 1);
@@ -274,8 +299,8 @@ public:
// a plain integer and the JS side indexes HEAPU8 with it directly. // a plain integer and the JS side indexes HEAPU8 with it directly.
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override {
EM_ASM({ EM_ASM({
if (!window.__simCtx) return; if (!Module.__simCtx) return;
var ctx = window.__simCtx; var ctx = Module.__simCtx;
var x0 = $0; var x0 = $0;
var y0 = $1; var y0 = $1;
var w = $2; var w = $2;
+32 -1
View File
@@ -283,14 +283,45 @@ public:
// main() before sim_idbfs_ready() ever ran -- so this needs its own // main() before sim_idbfs_ready() ever ran -- so this needs its own
// try/catch, unlike Emscripten's own test (which never pre-creates the dir // try/catch, unlike Emscripten's own test (which never pre-creates the dir
// through a second path first). // through a second path first).
// Phase 3 addendum: when two module instances mount IDBFS at the exact same
// path (both companion_radio instances always do -- SimFS's own root is a
// hardcoded literal, "./sim_data", chosen once at static-init time long
// before any JS-supplied per-instance config is reachable -- see
// SimInstance.h's header comment for why runtime differentiation had to
// happen at a different layer), they'd naively share ONE real IndexedDB
// database: this SDK's IDBFS (src/lib/libidbfs.js, IDBFS.getDB) keys the
// actual browser-level database by the mount path string ITSELF, with no
// mount-option override for that. Since both instances run in the same
// browser tab (same origin), that's a real collision, not a hypothetical
// one -- confirmed by reading the vendored emsdk's own libidbfs.js during
// this phase.
//
// Fix: monkey-patch IDBFS.getDB (only when the host page opted in via
// Module['simInstanceTag'], e.g. MeshCoreSim({simInstanceTag: 'A'})) so the
// REAL database name gets the tag appended, while the mount PATH stays
// "/sim_data" for every instance -- it has to, since that's the exact path
// SimFS's own fopen()-shaped calls resolve to, and only files actually
// living under the mounted path get persisted at all. This only touches
// this module instance's own IDBFS global (each MeshCoreSim() call has its
// own independent copy, per MODULARIZE), never the vendored emsdk source
// itself. Untagged instances (Module['simInstanceTag'] unset, e.g. the
// original Phase 2 single-instance web/index.html harness) get IDBFS.getDB
// completely unpatched -- byte-for-byte the pre-Phase-3 behavior.
inline void sim_fs_mount_idbfs(const char* root) { inline void sim_fs_mount_idbfs(const char* root) {
EM_ASM({ EM_ASM({
var root = UTF8ToString($0); var root = UTF8ToString($0);
var tag = (typeof Module !== 'undefined' && Module['simInstanceTag']) ? Module['simInstanceTag'] : '';
if (tag) {
var origGetDB = IDBFS.getDB;
IDBFS.getDB = function(name, callback) {
return origGetDB.call(IDBFS, name + '#' + tag, callback);
};
}
try { FS.mkdir(root); } catch (e) { /* already exists -- see comment above */ } try { FS.mkdir(root); } catch (e) { /* already exists -- see comment above */ }
FS.mount(IDBFS, { autoPersist: true }, root); FS.mount(IDBFS, { autoPersist: true }, root);
FS.syncfs(true, function(err) { FS.syncfs(true, function(err) {
if (err) console.error('[sim] IDBFS initial syncfs(true) failed:', err); if (err) console.error('[sim] IDBFS initial syncfs(true) failed:', err);
else console.log('[sim] IDBFS pull from IndexedDB complete, booting app...'); else console.log('[sim] IDBFS pull from IndexedDB complete, booting app...' + (tag ? (' (instance ' + tag + ')') : ''));
_sim_idbfs_ready(); _sim_idbfs_ready();
}); });
}, root); }, root);
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <cstdint>
// Phase 3: per-instance "salt" for the sim build.
//
// The two-companion-radio + one-repeater browser demo loads the SAME
// compiled companion_radio.wasm/js twice (see build_wasm.sh -- there is no
// separate "instance A" vs "instance B" build, by design: MODULARIZE gives
// each MeshCoreSim() call its own independent globals/linear memory, so one
// binary genuinely serves both roles). That means nothing can be baked in
// at compile time to tell the two apart -- any per-instance difference has
// to come from the host page, passed in as a plain JS value on the Module
// config object *before* that instance's factory promise resolves (e.g.
// MeshCoreSim({ simInstanceTag: 'A' })), and read back from C++ only at
// *runtime* (never at static-init time -- global C++ constructors run
// before any JS-supplied Module config is reachable from generated code,
// confirmed by a standalone getenv()-via-Module.ENV experiment during this
// phase that came back null; Module.arguments/argv has the same timing
// problem for the same reason). Every call site below only ever runs from
// setup()-time code (never a global/static initializer), so this is safe.
//
// Used for two unrelated purposes that both care about the two instances
// NOT looking identical to each other:
// 1. SimRNG::begin() / SimRadio::getRngSeed() -- without this, two
// instances started in the same browser tick could produce the exact
// same seed (same wall-clock second, same `rand()` state, and often
// the same `this` pointer value across independent-but-identically-
// laid-out linear memories), which would hand both simulated devices
// the same Ed25519 identity. Mixing in a per-instance tag makes that
// collision a non-issue regardless of whether the timing/address
// coincidence happens.
// 2. SimFS.h's sim_fs_mount_idbfs() namespacing the real IndexedDB
// database per instance so instance A/B don't silently share
// persisted storage on a page reload (see the long comment there).
//
// Native (and any wasm build that never sets simInstanceTag) gets salt 0,
// i.e. exactly the pre-Phase-3 behavior -- single-instance builds are
// unaffected.
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
inline uint32_t sim_instance_salt() {
return (uint32_t)EM_ASM_INT({
var t = (typeof Module !== 'undefined' && Module['simInstanceTag']) ? Module['simInstanceTag'] : '';
var h = 2166136261; // FNV-1a, plain JS numbers (>>> 0 keeps it unsigned 32-bit)
for (var i = 0; i < t.length; i++) {
h = h ^ t.charCodeAt(i);
h = (h * 16777619) >>> 0;
}
return h >>> 0;
});
}
#else
inline uint32_t sim_instance_salt() { return 0; }
#endif
+67 -10
View File
@@ -1,20 +1,77 @@
#pragma once #pragma once
#include <helpers/sensors/LocationProvider.h> #include <helpers/sensors/LocationProvider.h>
#include <helpers/SensorManager.h>
#include <ctime>
// LocationProvider stub for the native sim build: no real GPS, always // LocationProvider stub for the sim build. Phase 1/2: no real GPS, always
// reports "no fix". A settable lat/lon (JS-driven) is a Phase-2/3 concern. // reports "no fix" (default-constructed state below reproduces that
// exactly). Phase 3: JS-settable per instance via sim_location_set(),
// declared at the bottom of this file.
//
// Note on how this actually reaches the advertised location: companion_radio
// itself never calls a LocationProvider through this class -- it reads
// `sensors.node_lat`/`node_lon` directly (see e.g.
// examples/companion_radio/MyMesh.cpp's createSelfAdvert() call sites), and
// `sensors` (declared in target.h) is a plain base `SensorManager`, not a
// subclass wired to any LocationProvider -- there was never a real GPS
// object plugged in here to begin with (see SensorManager.h: "modify
// node_lat/node_lon directly, if you want to affect Advert location"). So
// sim_location_set() below writes BOTH this class's own state (satisfying
// the LocationProvider interface faithfully, in case future sim code wants
// a real one) AND `sensors.node_lat/node_lon` directly (the field
// companion_radio's advert path actually reads) -- belt and suspenders,
// same underlying values either way.
class SimLocationProvider : public LocationProvider { class SimLocationProvider : public LocationProvider {
long _lat_e6 = 0, _lon_e6 = 0, _alt_mm = 0;
bool _valid = false;
public: public:
long getLatitude() override { return 0; } long getLatitude() override { return _lat_e6; }
long getLongitude() override { return 0; } long getLongitude() override { return _lon_e6; }
long getAltitude() override { return 0; } long getAltitude() override { return _alt_mm; }
long satellitesCount() override { return 0; } long satellitesCount() override { return _valid ? 8 : 0; }
bool isValid() override { return false; } bool isValid() override { return _valid; }
long getTimestamp() override { return 0; } long getTimestamp() override { return _valid ? (long)time(NULL) : 0; }
void reset() override { } void reset() override { _valid = false; }
void begin() override { } void begin() override { }
void stop() override { } void stop() override { }
void loop() override { } void loop() override { }
bool isEnabled() override { return false; } bool isEnabled() override { return _valid; }
void sendSentence(const char* sentence) override { }
void set(float lat_deg, float lon_deg, float alt_m = 0.0f) {
_lat_e6 = (long)(lat_deg * 1000000.0f);
_lon_e6 = (long)(lon_deg * 1000000.0f);
_alt_mm = (long)(alt_m * 1000.0f);
_valid = true;
}
}; };
// Single instance, mirroring the pattern of every other Sim* global
// (board/radio_driver/rtc_clock/sensors) declared in target.h/target.cpp --
// this one isn't wired into target.h/.cpp itself since nothing on the
// SIM_PLATFORM boot path currently constructs a LocationProvider at all
// (see the class comment above), so it lives here instead as a
// self-contained, opt-in addition.
extern SensorManager sensors; // defined in variants/sim/target.cpp
inline SimLocationProvider& sim_location_provider() {
static SimLocationProvider instance;
return instance;
}
inline void sim_location_apply(float lat_deg, float lon_deg, float alt_m = 0.0f) {
sim_location_provider().set(lat_deg, lon_deg, alt_m);
sensors.node_lat = lat_deg;
sensors.node_lon = lon_deg;
sensors.node_altitude = alt_m;
}
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
// C linkage (no default args, unlike sim_location_apply() above) so JS's
// ccall('sim_location_set', ...) can find it by its exact, unmangled name.
extern "C" inline EMSCRIPTEN_KEEPALIVE void sim_location_set(float lat_deg, float lon_deg, float alt_m) {
sim_location_apply(lat_deg, lon_deg, alt_m);
}
#endif
+7 -4
View File
@@ -3,16 +3,19 @@
#include <Utils.h> #include <Utils.h>
#include <cstdlib> #include <cstdlib>
#include <ctime> #include <ctime>
#include "SimInstance.h"
// mesh::RNG implementation for the native sim build. Not cryptographically // mesh::RNG implementation for the native sim build. Not cryptographically
// strong (rand() under the hood) -- fine for a terminal demo; a real // strong (rand() under the hood) -- fine for a terminal demo. Phase 3 mixes
// two-device-messaging phase (Phase 3 of the sim plan) may want to swap // in sim_instance_salt() so that two module instances of the SAME compiled
// this for something seeded from the OS CSPRNG. // binary (the browser's two companion_radio instances) can't end up with
// the same seed -- see SimInstance.h for why that's a real risk here, not
// a hypothetical one.
class SimRNG : public mesh::RNG { class SimRNG : public mesh::RNG {
public: public:
SimRNG() { } SimRNG() { }
void begin() { void begin() {
unsigned seed = (unsigned)time(NULL) ^ (unsigned)(uintptr_t)this; unsigned seed = (unsigned)time(NULL) ^ (unsigned)(uintptr_t)this ^ sim_instance_salt();
srand(seed); srand(seed);
} }
void random(uint8_t* dest, size_t sz) override { void random(uint8_t* dest, size_t sz) override {
+139 -10
View File
@@ -1,32 +1,65 @@
#pragma once #pragma once
#include <Dispatcher.h> #include <Dispatcher.h>
#include <MeshCore.h> // MAX_TRANS_UNIT
#include <ctime> #include <ctime>
#include <cstdlib> #include <cstdlib>
#include <cstring>
#include "SimInstance.h"
// mesh::Radio implementation for the native sim build (Phase 1). Mirrors // mesh::Radio implementation for the native sim build. Mirrors the
// the FakeRadio in test/test_kiss_modem/test_tx_backpressure.cpp in spirit // FakeRadio in test/test_kiss_modem/test_tx_backpressure.cpp in spirit
// (always-succeed send, no real RF) but is written directly against the // (always-succeed send, no real RF) but is written directly against the
// REAL mesh::Radio interface in src/Dispatcher.h -- that test mock is for a // REAL mesh::Radio interface in src/Dispatcher.h -- that test mock is for a
// different, out-of-date mocked Mesh.h (see the Phase-1 plan) and must not // different, out-of-date mocked Mesh.h (see the Phase-1 plan) and must not
// be copied. // be copied.
// //
// Phase 1 has exactly one logical device, so there is nothing to actually // Phase 1/2 had exactly one logical device, so there was nothing to
// exchange packets with: recvRaw() always reports "nothing received", // actually exchange packets with: recvRaw() always reported "nothing
// startSendRaw()/isSendComplete() always report success instantly. Phase 3 // received", startSendRaw()/isSendComplete() always reported success
// of the sim plan (two simulated devices + a repeater) is where this class // instantly. Phase 3 adds a real in-memory "ether": a bounded FIFO of whole
// grows a real in-memory "ether" so two instances can actually talk. // raw packets in each direction, drained/filled by the JS-facing functions
// at the bottom of this file. Dispatcher::checkRecv()/checkSend() only ever
// deal in whole packets (recvRaw() returns 0-or-a-whole-packet in one call;
// startSendRaw() is handed one whole packet to send) -- see
// src/Dispatcher.cpp -- so queueing whole packets (not a byte stream)
// matches that contract exactly, no framing/reassembly needed on either side.
class SimRadio : public mesh::Radio { class SimRadio : public mesh::Radio {
uint32_t n_recv = 0, n_sent = 0, n_recv_errors = 0; uint32_t n_recv = 0, n_sent = 0, n_recv_errors = 0;
bool _power_save = false; bool _power_save = false;
bool _rx_boosted_gain = false; bool _rx_boosted_gain = false;
int8_t _tx_dbm = 0; int8_t _tx_dbm = 0;
// A "clean, high-quality" fake link by default -- packetScore() below is
// already a flat 100.0, these back getLastRSSI()/getLastSNR() (read by
// Dispatcher for scoring/logging and by MyMesh for the advert path's SNR
// display) with plausible non-zero numbers instead of the base class's
// default 0/0.
float _last_snr = 40.0f; // Packet::_snr stores this * 4 as an int8_t (see Dispatcher.cpp)
float _last_rssi = -60.0f;
struct QueuedPacket {
uint8_t data[MAX_TRANS_UNIT];
int len = 0;
};
static const int QUEUE_CAP = 16;
QueuedPacket _tx_queue[QUEUE_CAP];
int _tx_head = 0, _tx_count = 0;
QueuedPacket _rx_queue[QUEUE_CAP];
int _rx_head = 0, _rx_count = 0;
public: public:
void begin() override { } void begin() override { }
int recvRaw(uint8_t* bytes, int sz) override { int recvRaw(uint8_t* bytes, int sz) override {
return 0; // never any incoming data yet (Phase 3: real ether) if (_rx_count == 0) return 0;
QueuedPacket& p = _rx_queue[_rx_head];
int n = p.len < sz ? p.len : sz;
memcpy(bytes, p.data, n);
_rx_head = (_rx_head + 1) % QUEUE_CAP;
_rx_count--;
n_recv++;
return n;
} }
uint32_t getEstAirtimeFor(int len_bytes) override { uint32_t getEstAirtimeFor(int len_bytes) override {
@@ -41,7 +74,23 @@ public:
bool startSendRaw(const uint8_t* bytes, int len) override { bool startSendRaw(const uint8_t* bytes, int len) override {
n_sent++; n_sent++;
return true; // instantly "succeeds" -- nothing is actually transmitted yet if (len > 0) {
int n = len > MAX_TRANS_UNIT ? MAX_TRANS_UNIT : len;
if (_tx_count == QUEUE_CAP) {
// Nobody (no JS ether tick) is draining the outbox -- true for the
// Phase 1/2 single-instance builds, since nothing there ever polls
// sim_radio_poll_tx(). Drop the oldest queued TX rather than growing
// unboundedly; a long-running single-instance sim just silently
// "transmits into the void" exactly as it always did pre-Phase-3.
_tx_head = (_tx_head + 1) % QUEUE_CAP;
_tx_count--;
}
int idx = (_tx_head + _tx_count) % QUEUE_CAP;
memcpy(_tx_queue[idx].data, bytes, n);
_tx_queue[idx].len = n;
_tx_count++;
}
return true; // instantly "succeeds" -- matches every real RadioLib wrapper's fire-and-forget startSendRaw()
} }
bool isSendComplete() override { return true; } bool isSendComplete() override { return true; }
@@ -49,6 +98,9 @@ public:
bool isInRecvMode() const override { return true; } bool isInRecvMode() const override { return true; }
float getLastRSSI() const override { return _last_rssi; }
float getLastSNR() const override { return _last_snr; }
// --- Extra methods below (not part of mesh::Radio) ------------------- // --- Extra methods below (not part of mesh::Radio) -------------------
// MyMesh.cpp/DataStore.cpp/the Settings/Diagnostics UI screens call these // MyMesh.cpp/DataStore.cpp/the Settings/Diagnostics UI screens call these
// directly on the concrete radio_driver object on every real board, the // directly on the concrete radio_driver object on every real board, the
@@ -58,7 +110,13 @@ public:
// static/no-op values. // static/no-op values.
uint32_t getRngSeed() { uint32_t getRngSeed() {
return (uint32_t)time(NULL) ^ (uint32_t)(uintptr_t)this ^ (uint32_t)rand(); // sim_instance_salt(): see SimInstance.h -- without it, two module
// instances of the same compiled binary started in the same browser
// tick could plausibly compute the exact same seed here (same
// time(NULL) second, same `rand()` process state, often the same
// `this` address across independent-but-identically-laid-out linear
// memories) and end up with correlated "random" behaviour.
return (uint32_t)time(NULL) ^ (uint32_t)(uintptr_t)this ^ (uint32_t)rand() ^ sim_instance_salt();
} }
void getFreqBounds(float& min_mhz, float& max_mhz) const { void getFreqBounds(float& min_mhz, float& max_mhz) const {
@@ -89,4 +147,75 @@ public:
if (sf < 7) sf = 7; else if (sf > 12) sf = 12; if (sf < 7) sf = 7; else if (sf > 12) sf = 12;
return -7.5f - 2.5f * (float)(sf - 7); return -7.5f - 2.5f * (float)(sf - 7);
} }
// --- Ether hooks (Phase 3) ---------------------------------------------
// Called from the JS-facing extern "C" wrappers below (and reusable from
// a native test harness, since neither depends on Emscripten). These are
// the ONLY way bytes cross between two SimRadio instances -- there is no
// shared C++ state between module instances, on purpose (see the plan's
// "never run two logical devices in one process" decision).
// Pop one queued outbound packet (FIFO) into `out`, truncated to
// `max_len`. Returns bytes written, or 0 if nothing is queued. A JS ether
// tick calls this once per instance per tick to drain whatever this
// device tried to transmit since the last tick.
int pollTx(uint8_t* out, int max_len) {
if (_tx_count == 0) return 0;
QueuedPacket& p = _tx_queue[_tx_head];
int n = p.len < max_len ? p.len : max_len;
memcpy(out, p.data, n);
_tx_head = (_tx_head + 1) % QUEUE_CAP;
_tx_count--;
return n;
}
// Push one raw packet into this device's inbox for recvRaw() to pick up
// on Dispatcher's next checkRecv() poll. Returns false (no-op) if `len`
// is out of range or the inbox is already full (oldest entry dropped to
// make room rather than blocking -- a real radio would just drop an
// over-the-air packet it couldn't buffer either).
bool injectRx(const uint8_t* data, int len) {
if (len <= 0 || len > MAX_TRANS_UNIT) return false;
if (_rx_count == QUEUE_CAP) {
_rx_head = (_rx_head + 1) % QUEUE_CAP;
_rx_count--;
n_recv_errors++;
}
int idx = (_rx_head + _rx_count) % QUEUE_CAP;
memcpy(_rx_queue[idx].data, data, len);
_rx_queue[idx].len = len;
_rx_count++;
return true;
}
}; };
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
// JS-facing ether bridge. `radio_driver` is a file-scope global defined in
// variants/sim/target.cpp (one instance per compiled module -- see
// target.h's `extern SimRadio radio_driver;`), so these two functions
// always operate on THIS module instance's own radio, never any other's.
// Because -sMODULARIZE=1 -sEXPORT_NAME=MeshCoreSim gives every
// MeshCoreSim() call its own independent Module/globals/linear memory
// (verified empirically for this phase, not just assumed from the build
// flags -- see the Phase 3 report), calling instanceA.ccall('sim_radio_poll_tx', ...)
// and instanceB.ccall('sim_radio_poll_tx', ...) really do reach two
// separate SimRadio objects with no way to cross-talk except through
// whatever the host page's ether loop explicitly wires together by
// shuttling bytes from one instance's poll_tx into another's inject_rx.
//
// `inline` (not just EMSCRIPTEN_KEEPALIVE'd) because this header is
// included from several .cpp translation units (via target.h) -- without
// it, each would emit its own non-inline definition and the link would
// fail with duplicate symbols, same reasoning as sim_fs_mount_idbfs() in
// SimFS.h.
extern SimRadio radio_driver;
extern "C" inline EMSCRIPTEN_KEEPALIVE int sim_radio_poll_tx(uint8_t* out_buf, int max_len) {
return radio_driver.pollTx(out_buf, max_len);
}
extern "C" inline EMSCRIPTEN_KEEPALIVE void sim_radio_inject_rx(const uint8_t* data, int len) {
radio_driver.injectRx(data, len);
}
#endif
+14 -1
View File
@@ -167,6 +167,18 @@ done
# Module.FS.readFile('/sim_data/identity/_main.id') -- to prove IDBFS # Module.FS.readFile('/sim_data/identity/_main.id') -- to prove IDBFS
# persistence with a real file-content comparison across a reload, not just # persistence with a real file-content comparison across a reload, not just
# "the app didn't crash". Not required for the app itself. # "the app didn't crash". Not required for the app itself.
#
# EXPORTED_FUNCTIONS=_main,_malloc,_free (Phase 3 addition): every
# EMSCRIPTEN_KEEPALIVE-attributed function (all the sim_*() hooks across
# variants/sim/ and examples/companion_radio/) is exported regardless of
# this list -- that's what the attribute is FOR -- so this only adds
# malloc()/free() themselves, needed by web/mesh.html's JS "ether" to
# allocate a scratch buffer per instance for sim_radio_poll_tx()/
# sim_radio_inject_rx() (see variants/sim/SimRadio.h). Without this,
# Module._malloc() would abort at runtime with "malloc() called but not
# included in the build" -- confirmed by hitting exactly that during Phase
# 3. Purely additive: nothing Phase 2's web/index.html already does
# (sim_enqueue_key() with a plain number, no buffer marshaling) is affected.
"$EMXX" \ "$EMXX" \
"${OBJS[@]}" \ "${OBJS[@]}" \
-lidbfs.js \ -lidbfs.js \
@@ -176,7 +188,8 @@ done
-sEXPORT_NAME=MeshCoreSim \ -sEXPORT_NAME=MeshCoreSim \
-sENVIRONMENT=web \ -sENVIRONMENT=web \
-sEXIT_RUNTIME=0 \ -sEXIT_RUNTIME=0 \
-sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap \ -sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap,HEAPU8 \
-sEXPORTED_FUNCTIONS=_main,_malloc,_free \
-o "$OUT_DIR/meshcore_sim.js" -o "$OUT_DIR/meshcore_sim.js"
echo "" echo ""
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
# Phase 3 (Emscripten) build script for the examples/simple_repeater sim --
# sibling of build_wasm.sh (the companion_radio one), NOT a modification of
# it, so the existing companion_radio build stays byte-for-byte untouched
# (see the "hard constraints" in the Phase 3 plan: build_wasm.sh must keep
# working exactly as before).
#
# Differences from build_wasm.sh, all deliberate:
# - examples/simple_repeater/{main,MyMesh}.cpp instead of
# examples/companion_radio/{main,MyMesh,DataStore}.cpp +
# ui-new/UITask.cpp -- no UI/display/BLE in this app at all, and
# UITask.cpp specifically does NOT compile cleanly with no display
# defined (real Arduino GPIO constants used unconditionally -- see
# variants/sim/platformio.ini's [env:sim_simple_repeater] comment for
# the same issue hit there), so it's excluded, matching that env.
# - No -DDISPLAY_CLASS -- headless, matching every real hardware
# repeater env's convention when no display is fitted.
# - -DSIM_FS_ROOT=\"/sim_data_repeater\" -- must match the
# "./sim_data_repeater" SimFS root examples/simple_repeater/main.cpp's
# own SIM_PLATFORM branch constructs (see variants/sim/sim_main.cpp),
# kept DIFFERENT from companion_radio's "/sim_data" so a repeater
# instance's IDBFS-backed identity storage can never collide with a
# companion instance's, even on the same page/origin.
# - -sEXPORT_NAME=MeshCoreSimRepeater (not MeshCoreSim) -- the host page
# loads both build/meshcore_sim.js (build/) and
# build/repeater/meshcore_sim_repeater.js side by side; each is its own
# MODULARIZE factory function under a distinct global name so loading
# both on one page can't collide.
# - Output lands under web/build/repeater/, not web/build/, so the two
# builds' .wasm/.js/obj/ trees never share a directory.
#
# Usage:
# variants/sim/build_wasm_repeater.sh # release-ish build (-O2)
# variants/sim/build_wasm_repeater.sh debug # -O0 -g
#
# Same emsdk 6.0.9 requirement as build_wasm.sh -- see that script's header
# comment for the install command if variants/sim/tools/emsdk/ is missing.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
EMSDK_DIR="$SCRIPT_DIR/tools/emsdk"
EMXX="$EMSDK_DIR/upstream/emscripten/em++"
OUT_DIR="$SCRIPT_DIR/web/build/repeater"
if [ ! -x "$EMXX" ]; then
echo "error: em++ not found at $EMXX" >&2
echo "Install it first:" >&2
echo " cd $EMSDK_DIR && python3 ./emsdk.py install 6.0.9 && python3 ./emsdk.py activate 6.0.9" >&2
exit 1
fi
BUILD_MODE="${1:-release}"
if [ "$BUILD_MODE" = "debug" ]; then
OPT_FLAGS=(-O0 -g)
else
OPT_FLAGS=(-O2)
fi
mkdir -p "$OUT_DIR"
cd "$REPO_ROOT"
# Same list as variants/sim/platformio.ini's [env:sim_simple_repeater]
# build_src_filter, spelled as real paths. Keep in sync with that file by
# hand (same convention build_wasm.sh already established for the
# companion_radio side).
SRCS=(
src/Dispatcher.cpp
src/Identity.cpp
src/Mesh.cpp
src/Packet.cpp
src/Utils.cpp
src/helpers/AdvertDataHelpers.cpp
src/helpers/BaseChatMesh.cpp
src/helpers/ClientACL.cpp
src/helpers/CommonCLI.cpp
src/helpers/ConfigSerializer.cpp
src/helpers/DeviceDiag.cpp
src/helpers/IdentityStore.cpp
src/helpers/RegionMap.cpp
src/helpers/StaticPoolPacketManager.cpp
src/helpers/TransportKeyStore.cpp
src/helpers/TxtDataHelpers.cpp
lib/ed25519/add_scalar.c
lib/ed25519/fe.c
lib/ed25519/ge.c
lib/ed25519/key_exchange.c
lib/ed25519/keypair.c
lib/ed25519/sc.c
lib/ed25519/seed.c
lib/ed25519/sha512.c
lib/ed25519/sign.c
lib/ed25519/verify.c
variants/sim/sim_main.cpp
variants/sim/target.cpp
variants/sim/thirdparty/crypto/AES128.cpp
variants/sim/thirdparty/crypto/AESCommon.cpp
variants/sim/thirdparty/crypto/BigNumberUtil.cpp
variants/sim/thirdparty/crypto/BlockCipher.cpp
variants/sim/thirdparty/crypto/Crypto.cpp
variants/sim/thirdparty/crypto/Curve25519.cpp
variants/sim/thirdparty/crypto/Ed25519.cpp
variants/sim/thirdparty/crypto/Hash.cpp
variants/sim/thirdparty/crypto/rng_stub.cpp
variants/sim/thirdparty/crypto/SHA256.cpp
variants/sim/thirdparty/crypto/SHA512.cpp
variants/sim/thirdparty/cayennelpp/CayenneLPP.cpp
variants/sim/thirdparty/cayennelpp/CayenneLPPPolyline.cpp
examples/simple_repeater/main.cpp
examples/simple_repeater/MyMesh.cpp
)
INCLUDES=(
-Ivariants/sim/arduino
-Ivariants/sim
-Ivariants/sim/thirdparty/crypto
-Ivariants/sim/thirdparty/cayennelpp
-Ivariants/sim/thirdparty/arduinojson
-Ilib/ed25519
-Isrc
-Iexamples/simple_repeater
)
DEFINES=(
-DSIM_PLATFORM
-DMESH_DEBUG=0
-DENABLE_ADVERT_ON_BOOT=1
-DSIM_FS_ROOT="\"/sim_data_repeater\""
)
# -funsigned-char: same reasoning as build_wasm.sh (KEY_* codes aren't
# actually used by this app at all -- no UI -- but every other sim TU is
# compiled with this flag and mixing char signedness across TUs that share
# struct layouts/bitfields is asking for trouble, so keep it uniform).
COMMON_FLAGS=(-std=c++17 -funsigned-char "${OPT_FLAGS[@]}" "${DEFINES[@]}" "${INCLUDES[@]}")
# Mirror each source's own directory under obj/ (see build_wasm.sh's own
# comment on this -- the sha512.o/SHA512.o macOS case-collision reason
# applies identically here since it's the same source tree).
OBJ_DIR="$OUT_DIR/obj"
rm -rf "$OBJ_DIR"
OBJS=()
for src in "${SRCS[@]}"; do
obj="$OBJ_DIR/${src%.*}.o"
mkdir -p "$(dirname "$obj")"
"$EMXX" -c "${COMMON_FLAGS[@]}" "$src" -o "$obj"
OBJS+=("$obj")
done
"$EMXX" \
"${OBJS[@]}" \
-lidbfs.js \
-sALLOW_MEMORY_GROWTH=1 \
-sFORCE_FILESYSTEM=1 \
-sMODULARIZE=1 \
-sEXPORT_NAME=MeshCoreSimRepeater \
-sENVIRONMENT=web \
-sEXIT_RUNTIME=0 \
-sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap,HEAPU8 \
-sEXPORTED_FUNCTIONS=_main,_malloc,_free \
-o "$OUT_DIR/meshcore_sim_repeater.js"
echo ""
echo "Built: $OUT_DIR/meshcore_sim_repeater.js (+ .wasm alongside it)"
+76
View File
@@ -62,3 +62,79 @@ build_src_filter =
+<../variants/sim/thirdparty/cayennelpp/*.cpp> +<../variants/sim/thirdparty/cayennelpp/*.cpp>
+<../examples/companion_radio/*.cpp> +<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp> +<../examples/companion_radio/ui-new/*.cpp>
; Phase 3: native build of the real examples/simple_repeater app logic
; (MyMesh/no UI) against the same variants/sim/ interfaces as
; [env:sim_companion_radio] above. No DISPLAY_CLASS defined -- a repeater
; doesn't need one (examples/simple_repeater/main.cpp already gates its
; UITask/display use behind #ifdef DISPLAY_CLASS, same as every real
; headless-repeater hardware env does), which also means UITask.cpp/.h
; never need to be ported/compiled for this target at all.
;
; Everything else mirrors [env:sim_companion_radio] deliberately closely
; (same src/*.cpp list, same real hardware envs' own convention of sharing
; one broad src/helpers/*.cpp filter across companion_radio/simple_repeater/
; room_server -- see e.g. variants/ebyte_eora_s3/platformio.ini's
; [env:Ebyte_EoRa-S3] base + its "_Repeater" env only adding
; "+<../examples/simple_repeater>" on top) -- a few of those .cpp files
; (BaseChatMesh.cpp, ConfigSerializer.cpp, DeviceDiag.cpp) aren't actually
; reachable from simple_repeater's own code, but compiling them in unused is
; harmless and keeps this env a straightforward diff against the one above
; rather than a hand-pruned, easy-to-drift-out-of-sync list.
[env:sim_simple_repeater]
platform = native
build_type = debug
build_flags =
-std=c++17
-g
-funsigned-char
-D SIM_PLATFORM
-D MESH_DEBUG=0
; Real hardware repeater envs get this from [arduino_base] (which this
; from-scratch platform=native env deliberately doesn't extend -- see the
; header comment on [env:sim_companion_radio] above); set it explicitly so
; the repeater actually broadcasts its own identity on boot, same as a
; real one would.
-D ENABLE_ADVERT_ON_BOOT=1
-I variants/sim/arduino
-I variants/sim
-I variants/sim/thirdparty/crypto
-I variants/sim/thirdparty/cayennelpp
-I variants/sim/thirdparty/arduinojson
-I lib/ed25519
-I src
-I examples/simple_repeater
build_src_filter =
-<*>
+<../src/Dispatcher.cpp>
+<../src/Identity.cpp>
+<../src/Mesh.cpp>
+<../src/Packet.cpp>
+<../src/Utils.cpp>
+<../src/helpers/AdvertDataHelpers.cpp>
+<../src/helpers/BaseChatMesh.cpp>
+<../src/helpers/ClientACL.cpp>
+<../src/helpers/CommonCLI.cpp>
+<../src/helpers/ConfigSerializer.cpp>
+<../src/helpers/DeviceDiag.cpp>
+<../src/helpers/IdentityStore.cpp>
+<../src/helpers/RegionMap.cpp>
+<../src/helpers/StaticPoolPacketManager.cpp>
+<../src/helpers/TransportKeyStore.cpp>
+<../src/helpers/TxtDataHelpers.cpp>
+<../lib/ed25519/*.c>
+<../variants/sim/*.cpp>
+<../variants/sim/thirdparty/crypto/*.cpp>
+<../variants/sim/thirdparty/cayennelpp/*.cpp>
+<../examples/simple_repeater/*.cpp>
; UITask.cpp uses real Arduino GPIO constants (HIGH/LOW/digitalRead(),
; gated on real #if PIN_USER_BTN/UI_HAS_JOYSTICK defines this env never
; sets) directly at file scope in its button-polling code, unconditionally
; -- unlike companion_radio's ui-new/UITask.cpp, it isn't written to
; compile cleanly with no display at all. Since DISPLAY_CLASS is
; deliberately undefined for this headless repeater env (main.cpp's own
; `#ifdef DISPLAY_CLASS` already skips constructing a UITask instance
; entirely), this translation unit is simply never needed -- exclude it
; rather than porting GPIO stubs into variants/sim/arduino/Arduino.h for
; code that would never run.
-<../examples/simple_repeater/UITask.cpp>
+16 -6
View File
@@ -48,13 +48,23 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_idbfs_ready() {
emscripten_set_main_loop(sim_main_loop_tick, 0, 1); emscripten_set_main_loop(sim_main_loop_tick, 0, 1);
} }
// The app-level SimFS root to mount IDBFS at -- must match whatever
// relative "./sim_data..." path that app's own main.cpp constructs its
// SimFS with (see the long comment on sim_fs_mount_idbfs() in SimFS.h for
// why those are the same filesystem node under Emscripten's default cwd,
// "/"). Defaults to companion_radio's root, unchanged from Phase 2 --
// Phase 3's simple_repeater build (variants/sim/build_wasm_repeater.sh)
// overrides this via -DSIM_FS_ROOT so its identity storage lands under a
// DIFFERENT IDBFS-backed root than a companion instance's, same reasoning
// as examples/simple_repeater/main.cpp's SIM_PLATFORM branch using
// "./sim_data_repeater" instead of "./sim_data" for its SimFS.
#ifndef SIM_FS_ROOT
#define SIM_FS_ROOT "/sim_data"
#endif
int main() { int main() {
printf("MeshCore sim (wasm) starting -- mounting IDBFS at /sim_data...\n"); printf("MeshCore sim (wasm) starting -- mounting IDBFS at " SIM_FS_ROOT "...\n");
// "/sim_data" must match the relative "./sim_data" DataStore store(sim_fs, sim_fs_mount_idbfs(SIM_FS_ROOT);
// ...) in examples/companion_radio/main.cpp resolves to -- see the long
// comment on sim_fs_mount_idbfs() in SimFS.h for why those are the same
// filesystem node under Emscripten's default cwd ("/").
sim_fs_mount_idbfs("/sim_data");
// Keep the runtime alive after main() returns instead of tearing it down // Keep the runtime alive after main() returns instead of tearing it down
// (the default for a `main()` that returns under Emscripten) -- the real // (the default for a `main()` that returns under Emscripten) -- the real
// boot sequence hasn't happened yet, it's waiting on sim_idbfs_ready() // boot sequence hasn't happened yet, it's waiting on sim_idbfs_ready()
+6
View File
@@ -11,6 +11,12 @@
#include "SimMainBoard.h" #include "SimMainBoard.h"
#include "SimRTCClock.h" #include "SimRTCClock.h"
#include <helpers/SensorManager.h> #include <helpers/SensorManager.h>
// Included here (rather than only where it's used) so its JS-facing
// sim_location_set() EMSCRIPTEN_KEEPALIVE export actually gets compiled
// into every SIM_PLATFORM target that includes target.h (companion_radio
// AND simple_repeater) -- an inline function nobody #includes never gets
// emitted at all, KEEPALIVE or not.
#include "SimLocationProvider.h"
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
#include "SimDisplayDriver.h" #include "SimDisplayDriver.h"
+375
View File
@@ -0,0 +1,375 @@
<!DOCTYPE html>
<!--
Phase 3 proof harness -- two real companion_radio instances (A, B) and one
real simple_repeater instance (R), all three the SAME compiled binaries as
build_wasm.sh/build_wasm_repeater.sh produce (no special "multi" build),
loaded TWICE (A/B) and once (R) via their MODULARIZE factory functions
(MeshCoreSim()/MeshCoreSimRepeater()) -- each call returns an independent
Module instance with its own linear memory/globals, confirmed empirically
(see the Phase 3 report). They are bridged ONLY by the plain JS "ether"
loop below, which shuttles real raw packet bytes between each instance's
SimRadio TX/RX queues (sim_radio_poll_tx()/sim_radio_inject_rx(), see
variants/sim/SimRadio.h) -- no protocol logic is reimplemented here, this
is pure transport plumbing.
Topology (deliberately NOT a full mesh): A <-> R <-> B only. A and B are
never wired directly to each other, so any message that reaches the other
side MUST have been relayed by R -- this is what proves A->R->B routing
rather than a direct A->B shortcut.
Serve this directory with a real static file server (http://, not
file://), e.g. from this directory:
python3 -m http.server 8080
then open http://localhost:8080/mesh.html .
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>MeshCore sim -- two devices + repeater (Phase 3)</title>
<style>
body {
background: #1b1b1b;
color: #ddd;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
padding: 20px;
}
h1 { font-size: 16px; font-weight: 600; margin: 0 0 4px; }
.sub { font-size: 12px; color: #999; margin-bottom: 16px; }
.devices { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
.device { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.device h2 { font-size: 13px; margin: 0; color: #ffb000; }
canvas.sim-canvas {
background: #000;
border: 2px solid #444;
image-rendering: pixelated;
width: 384px;
height: 192px;
}
.status { font-size: 11px; color: #999; min-height: 1.2em; text-align: center; }
.row { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
button {
background: #2a2a2a;
color: #eee;
border: 1px solid #555;
border-radius: 6px;
font-size: 12px;
padding: 6px 10px;
cursor: pointer;
}
button:active { background: #444; }
button:disabled { opacity: 0.4; cursor: default; }
.repeater {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 18px;
border: 2px solid #444;
border-radius: 8px;
min-width: 160px;
justify-content: center;
}
.repeater h2 { font-size: 13px; margin: 0; color: #7fd0ff; }
.tower {
font-size: 32px;
line-height: 1;
filter: grayscale(1) brightness(0.6);
transition: filter 0.15s, transform 0.15s;
}
.tower.relaying {
filter: grayscale(0) brightness(1.3);
transform: scale(1.15);
}
.relay-count { font-size: 12px; color: #7fd0ff; }
#controls { margin-top: 20px; display: flex; flex-direction: column; gap: 10px; }
#controls .row { justify-content: flex-start; }
#log {
margin-top: 16px;
width: 100%;
max-width: 900px;
height: 220px;
overflow-y: auto;
background: #111;
border: 1px solid #333;
font: 11px/1.5 ui-monospace, monospace;
color: #7a7;
padding: 8px 10px;
white-space: pre-wrap;
}
.ether-config { font-size: 11px; color: #999; display: flex; gap: 14px; align-items: center; }
.ether-config input[type=number] { width: 56px; }
</style>
</head>
<body>
<h1>MeshCore sim -- two devices + repeater, bridged by a JS "ether"</h1>
<div class="sub">Real companion_radio x2 (A, B) + real simple_repeater (R), same compiled wasm as the single-instance harness -- topology is A &lt;-&gt; R &lt;-&gt; B only (no direct A-B link), so any delivered message proves real relay routing.</div>
<div class="devices">
<div class="device">
<h2>Instance A (companion_radio)</h2>
<canvas id="sim-canvas-A" class="sim-canvas" width="128" height="64"></canvas>
<div class="status" id="status-A">loading...</div>
<div class="row">
<button data-instance="A" data-key="0xB5">&uarr;</button>
<button data-instance="A" data-key="0xB4">&larr;</button>
<button data-instance="A" data-key="13">OK</button>
<button data-instance="A" data-key="0xB7">&rarr;</button>
<button data-instance="A" data-key="0xB6">&darr;</button>
<button data-instance="A" data-key="27">Esc</button>
</div>
</div>
<div class="repeater">
<h2>Instance R (simple_repeater)</h2>
<div class="tower" id="tower">&#128225;</div>
<div class="relay-count" id="relay-count">relayed: 0</div>
<div class="status" id="status-R">loading...</div>
</div>
<div class="device">
<h2>Instance B (companion_radio)</h2>
<canvas id="sim-canvas-B" class="sim-canvas" width="128" height="64"></canvas>
<div class="status" id="status-B">loading...</div>
<div class="row">
<button data-instance="B" data-key="0xB5">&uarr;</button>
<button data-instance="B" data-key="0xB4">&larr;</button>
<button data-instance="B" data-key="13">OK</button>
<button data-instance="B" data-key="0xB7">&rarr;</button>
<button data-instance="B" data-key="0xB6">&darr;</button>
<button data-instance="B" data-key="27">Esc</button>
</div>
</div>
</div>
<div id="controls">
<div class="ether-config">
<label>ether delay (ms): <input type="number" id="ether-delay" value="150" min="0" max="5000"></label>
<label>drop probability (0-1): <input type="number" id="ether-drop" value="0" min="0" max="1" step="0.05"></label>
<span id="ether-status">ether: stopped</span>
</div>
<div class="row">
<button id="btn-advert-a">Send Advert (A, flood)</button>
<button id="btn-advert-b">Send Advert (B, flood)</button>
<button id="btn-dm-ab">Send DM A&rarr;B</button>
<button id="btn-dm-ba">Send DM B&rarr;A</button>
<button id="btn-open-dm-a">Open DM screen on A</button>
<button id="btn-open-dm-b">Open DM screen on B</button>
<button id="btn-run-demo">Run full demo (advert both ways, send A&rarr;B, open B's DM)</button>
</div>
</div>
<div id="log"></div>
<script src="build/meshcore_sim.js"></script>
<script src="build/repeater/meshcore_sim_repeater.js"></script>
<script>
const logEl = document.getElementById('log');
function log(msg) {
const line = document.createElement('div');
line.textContent = '[' + new Date().toISOString().substr(11, 12) + '] ' + msg;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
}
const MAX_PKT = 256; // MAX_TRANS_UNIT (255) rounded up, src/MeshCore.h
// Per-instance wrapper: allocates a persistent scratch TX-poll buffer,
// exposes small ccall-based helpers. `mod` is a ready Emscripten Module
// instance (post-await on the MODULARIZE factory promise).
function wrapInstance(mod, label) {
const txBuf = mod._malloc(MAX_PKT);
return {
mod, label, txBuf,
isReady() { return mod.ccall('sim_is_ready', 'number', [], []) === 1; },
pollTx() {
// Drain every queued outbound packet this tick (SimRadio's queue
// is a bounded FIFO -- see variants/sim/SimRadio.h -- so a single
// poll might not be enough if several packets queued up between
// ether ticks).
const packets = [];
for (;;) {
const n = mod.ccall('sim_radio_poll_tx', 'number', ['number', 'number'], [txBuf, MAX_PKT]);
if (n <= 0) break;
packets.push(mod.HEAPU8.slice(txBuf, txBuf + n));
}
return packets;
},
injectRx(bytes) {
const ptr = mod._malloc(bytes.length);
mod.HEAPU8.set(bytes, ptr);
mod.ccall('sim_radio_inject_rx', null, ['number', 'number'], [ptr, bytes.length]);
mod._free(ptr);
},
};
}
// --- The "ether": fixed delay + optional flat drop probability, per
// the plan's "keep it simple, not a full RF model" instruction. Each
// link below is one-directional; the topology array at the bottom
// encodes A<->R<->B with no direct A<->B link.
let etherLinks = []; // [{from, to}]
let etherTimer = null;
function etherTick() {
const delayMs = Number(document.getElementById('ether-delay').value) || 0;
const dropProb = Math.min(1, Math.max(0, Number(document.getElementById('ether-drop').value) || 0));
for (const link of etherLinks) {
const packets = link.from.pollTx();
for (const pkt of packets) {
for (const to of link.to) {
if (Math.random() < dropProb) {
log(`ether: dropped ${pkt.length}B ${link.from.label} -> ${to.label}`);
continue;
}
setTimeout(() => {
to.injectRx(pkt);
}, delayMs);
}
}
}
}
function startEther() {
if (etherTimer) return;
etherTimer = setInterval(etherTick, 60);
document.getElementById('ether-status').textContent = 'ether: running';
}
let lastRelayCount = 0;
function pollRelay(R) {
if (!R.isReady()) return;
const n = R.mod.ccall('sim_repeater_get_relay_count', 'number', [], []);
document.getElementById('relay-count').textContent = 'relayed: ' + n;
if (n > lastRelayCount) {
log(`*** Repeater R relayed a packet (count ${lastRelayCount} -> ${n}) ***`);
const tower = document.getElementById('tower');
tower.classList.add('relaying');
setTimeout(() => tower.classList.remove('relaying'), 400);
}
lastRelayCount = n;
}
function sendKey(inst, code) {
inst.mod.ccall('sim_enqueue_key', null, ['number'], [code]);
}
async function main() {
log('booting instance A (companion_radio)...');
const modA = await MeshCoreSim({ simInstanceTag: 'A' });
log('booting instance B (companion_radio)...');
const modB = await MeshCoreSim({ simInstanceTag: 'B' });
log('booting instance R (simple_repeater)...');
const modR = await MeshCoreSimRepeater({ simInstanceTag: 'R' });
const A = wrapInstance(modA, 'A');
const B = wrapInstance(modB, 'B');
const R = wrapInstance(modR, 'R');
// Wait for each instance's real setup() to finish (see sim_is_ready()
// in examples/companion_radio/main.cpp + examples/simple_repeater/main.cpp)
// -- the MODULARIZE factory promise resolves once the wasm module is
// instantiated, well before the async IDBFS-ready callback actually
// invokes setup().
async function waitReady(inst, statusElId) {
for (let i = 0; i < 200; i++) {
if (inst.isReady()) {
document.getElementById(statusElId).textContent = 'ready';
return;
}
await new Promise(r => setTimeout(r, 50));
}
document.getElementById(statusElId).textContent = 'TIMED OUT waiting for boot';
log(`WARNING: instance ${inst.label} never became ready`);
}
await Promise.all([
waitReady(A, 'status-A'),
waitReady(B, 'status-B'),
waitReady(R, 'status-R'),
]);
log('all instances ready.');
// Topology: A <-> R <-> B. No direct A<->B link -- see file header.
etherLinks = [
{ from: A, to: [R] },
{ from: B, to: [R] },
{ from: R, to: [A, B] },
];
startEther();
setInterval(() => pollRelay(R), 100);
document.querySelectorAll('button[data-instance]').forEach((btn) => {
btn.addEventListener('click', () => {
const inst = btn.dataset.instance === 'A' ? A : B;
sendKey(inst, Number(btn.dataset.key));
});
});
document.getElementById('btn-advert-a').addEventListener('click', () => {
const ok = A.mod.ccall('sim_test_advert_flood', 'number', [], []);
log(`A: sent flood self-advert (ok=${ok})`);
});
document.getElementById('btn-advert-b').addEventListener('click', () => {
const ok = B.mod.ccall('sim_test_advert_flood', 'number', [], []);
log(`B: sent flood self-advert (ok=${ok})`);
});
document.getElementById('btn-dm-ab').addEventListener('click', () => {
const text = 'Hello B, this is A! (' + new Date().toLocaleTimeString() + ')';
const result = A.mod.ccall('sim_test_send_msg_to_first_contact', 'number', ['string'], [text]);
log(`A -> B: sendMessage() result=${result} (1=flood,2=direct,0=failed,-1=no contact yet) text="${text}"`);
});
document.getElementById('btn-dm-ba').addEventListener('click', () => {
const text = 'Hello A, this is B! (' + new Date().toLocaleTimeString() + ')';
const result = B.mod.ccall('sim_test_send_msg_to_first_contact', 'number', ['string'], [text]);
log(`B -> A: sendMessage() result=${result} (1=flood,2=direct,0=failed,-1=no contact yet) text="${text}"`);
});
document.getElementById('btn-open-dm-a').addEventListener('click', () => {
const ok = A.mod.ccall('sim_test_open_dm_with_first_contact', 'number', [], []);
log(`A: opened DM screen (ok=${ok})`);
});
document.getElementById('btn-open-dm-b').addEventListener('click', () => {
const ok = B.mod.ccall('sim_test_open_dm_with_first_contact', 'number', [], []);
log(`B: opened DM screen (ok=${ok})`);
});
async function waitForContacts(inst, minCount, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const n = inst.mod.ccall('sim_test_get_num_contacts', 'number', [], []);
if (n >= minCount) return true;
await new Promise(r => setTimeout(r, 100));
}
return false;
}
document.getElementById('btn-run-demo').addEventListener('click', async () => {
log('=== running full demo sequence ===');
A.mod.ccall('sim_test_advert_flood', 'number', [], []);
log('A: sent flood advert');
B.mod.ccall('sim_test_advert_flood', 'number', [], []);
log('B: sent flood advert');
const gotA = await waitForContacts(A, 1, 5000);
const gotB = await waitForContacts(B, 1, 5000);
log(`A has contact: ${gotA}, B has contact: ${gotB}`);
if (!gotA || !gotB) {
log('demo aborted: advert propagation through repeater did not complete in time');
return;
}
const text = 'Hello B, this is A! (' + new Date().toLocaleTimeString() + ')';
const result = A.mod.ccall('sim_test_send_msg_to_first_contact', 'number', ['string'], [text]);
log(`A -> B: sendMessage() result=${result}, text="${text}"`);
await new Promise(r => setTimeout(r, 1500));
const ok = B.mod.ccall('sim_test_open_dm_with_first_contact', 'number', [], []);
log(`B: opened DM screen (ok=${ok}) -- check B's canvas for the received text`);
log('=== demo sequence complete ===');
});
window.__meshSim = { A, B, R }; // exposed for console poking / Playwright
}
main().catch((err) => {
log('FATAL: ' + err);
console.error(err);
});
</script>
</body>
</html>