2026-09-03 09:14:15 +02:00
<!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 : 20 px ;
}
h1 { font-size : 16 px ; font-weight : 600 ; margin : 0 0 4 px ; }
. sub { font-size : 12 px ; color : #999 ; margin-bottom : 16 px ; }
. devices { display : flex ; gap : 24 px ; align-items : flex-start ; flex-wrap : wrap ; }
. device { display : flex ; flex-direction : column ; align-items : center ; gap : 8 px ; }
. device h2 { font-size : 13 px ; margin : 0 ; color : #ffb000 ; }
canvas . sim-canvas {
background : #000 ;
border : 2 px solid #444 ;
image-rendering : pixelated ;
width : 384 px ;
height : 192 px ;
}
. status { font-size : 11 px ; color : #999 ; min-height : 1.2 em ; text-align : center ; }
. row { display : flex ; gap : 8 px ; flex-wrap : wrap ; justify-content : center ; }
button {
background : #2a2a2a ;
color : #eee ;
border : 1 px solid #555 ;
border-radius : 6 px ;
font-size : 12 px ;
padding : 6 px 10 px ;
cursor : pointer ;
}
button : active { background : #444 ; }
button : disabled { opacity : 0.4 ; cursor : default ; }
. repeater {
display : flex ;
flex-direction : column ;
align-items : center ;
gap : 8 px ;
padding : 12 px 18 px ;
border : 2 px solid #444 ;
border-radius : 8 px ;
min-width : 160 px ;
justify-content : center ;
}
. repeater h2 { font-size : 13 px ; margin : 0 ; color : #7fd0ff ; }
. tower {
font-size : 32 px ;
line-height : 1 ;
filter : grayscale ( 1 ) brightness ( 0.6 );
transition : filter 0.15 s , transform 0.15 s ;
}
. tower . relaying {
filter : grayscale ( 0 ) brightness ( 1.3 );
transform : scale ( 1.15 );
}
. relay-count { font-size : 12 px ; color : #7fd0ff ; }
# controls { margin-top : 20 px ; display : flex ; flex-direction : column ; gap : 10 px ; }
# controls . row { justify-content : flex-start ; }
# log {
margin-top : 16 px ;
width : 100 % ;
max-width : 900 px ;
height : 220 px ;
overflow-y : auto ;
background : #111 ;
border : 1 px solid #333 ;
font : 11 px / 1.5 ui-monospace , monospace ;
color : #7a7 ;
padding : 8 px 10 px ;
white-space : pre-wrap ;
}
. ether-config { font-size : 11 px ; color : #999 ; display : flex ; gap : 14 px ; align-items : center ; }
. ether-config input [ type = number ] { width : 56 px ; }
</ 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 < -> R < -> 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" > ↑ </ button >
< button data-instance = "A" data-key = "0xB4" > ← </ button >
< button data-instance = "A" data-key = "13" > OK</ button >
< button data-instance = "A" data-key = "0xB7" > → </ button >
< button data-instance = "A" data-key = "0xB6" > ↓ </ 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" > 📡 </ 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" > ↑ </ button >
< button data-instance = "B" data-key = "0xB4" > ← </ button >
< button data-instance = "B" data-key = "13" > OK</ button >
< button data-instance = "B" data-key = "0xB7" > → </ button >
< button data-instance = "B" data-key = "0xB6" > ↓ </ 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→ B</ button >
< button id = "btn-dm-ba" > Send DM B→ 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→ 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 ]);
}
2026-09-03 10:06:44 +02:00
function sendKeyLongPress ( inst , code ) {
inst . mod . ccall ( 'sim_enqueue_key_longpress' , null , [ 'number' ], [ code ]);
}
// Same 1000ms MomentaryButton hold threshold as the single-instance
// harness (web/index.html) -- see that file's comment for why holding
// OK/Enter matters (real context-menu access via
// UITask::handleLongPress()).
const LONG_PRESS_MS = 1000 ;
2026-09-03 09:14:15 +02:00
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 ) => {
2026-09-03 10:06:44 +02:00
const inst = btn . dataset . instance === 'A' ? A : B ;
const code = Number ( btn . dataset . key );
let timer = null , longFired = false ;
btn . addEventListener ( 'pointerdown' , ( e ) => {
e . preventDefault ();
longFired = false ;
timer = setTimeout (() => { longFired = true ; sendKeyLongPress ( inst , code ); }, LONG_PRESS_MS );
2026-09-03 09:14:15 +02:00
});
2026-09-03 10:06:44 +02:00
btn . addEventListener ( 'pointerup' , () => {
if ( timer ) { clearTimeout ( timer ); timer = null ; }
if ( ! longFired ) sendKey ( inst , code );
});
const cancel = () => { if ( timer ) { clearTimeout ( timer ); timer = null ; } };
btn . addEventListener ( 'pointerleave' , cancel );
btn . addEventListener ( 'pointercancel' , cancel );
2026-09-03 09:14:15 +02:00
});
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 >