Emulator Developer Guide¶
This document explains the WASM emulator architecture for contributors who want to fix bugs, add features, or understand how the pieces fit together. For user-facing how-to and troubleshooting, see Browser Emulator.
Architecture Overview¶
The emulator compiles real Cyber Fidget C++ app code to WebAssembly using Emscripten. Instead of emulating the ESP32 CPU, it replaces the HAL with browser-native equivalents.
flowchart TB
subgraph Browser
UI[emulator.js: Device mockup]
Bridge[wasm_bridge.js: Glue]
WASM[app.wasm + loader.js]
UI <-->|events / framebuffer, LEDs| Bridge
Bridge <-->|cwrap, callbacks| WASM
end
subgraph WASM_module
App[App C++ code]
HAL_WASM[HAL_WASM.cpp]
Shims[Arduino.h, SSD1306Wire.h, NeoPixel, ...]
App --> HAL_WASM
HAL_WASM --> Shims
end
Data flow (one frame)¶
sequenceDiagram
participant User
participant Emulator
participant Bridge
participant WASM
User->>Emulator: Click button / move slider
Emulator->>Bridge: onButtonEvent / onSliderChange
Bridge->>WASM: wasm_button_press / wasm_set_slider
Note over WASM: mainLoop: loopHardware, updateStrip, app.update()
WASM->>Bridge: onFrameReady(framebuffer)
WASM->>Bridge: onLedUpdate(index, r, g, b, w)
WASM->>Bridge: onSerialOutput(text)
Bridge->>Emulator: writeFramebuffer / setLED
Bridge->>Emulator: Serial Monitor
- User input →
emulator.jsfiresonButtonEvent/onSliderChange - Bridge →
wasm_bridge.jscalls exported C functions (wasm_button_press,wasm_set_slider) - App loop → Emscripten's
emscripten_set_main_loopcallsmainLoop()at 50 FPS - Display output →
SSD1306Wire::display()pushes framebuffer to JS viaEM_JS - LED output →
HAL::loopHardware()detectsneedsShowflag, callsjs_set_led()viaEM_JS - Serial output →
HardwareSerial::print()routes tojs_serial_write()viaEM_JS
Repository Layout¶
Firmware Repo (CyberFidget_Bundled_Demo_Platformio)¶
wasm/
├── CMakeLists.txt # Emscripten build config
├── main_wasm.cpp # Entry point (main loop, exported C functions)
├── hal/
│ ├── HAL_WASM.cpp # HAL namespace implementation for browser
│ ├── wasm_runtime.cpp # NeoPixel ColorHSV, serial EM_JS bindings
│ ├── wasm_fonts.cpp # Real OLED font data from ThingPulse
│ └── audio_wasm.cpp # Web Audio tone generation
├── shims/
│ ├── Arduino.h # millis, delay, String, Serial, PROGMEM, etc.
│ ├── SSD1306Wire.h # OLED display with full drawing API
│ ├── Adafruit_NeoPixel.h # NeoPixel strip with WRGB Color() packing
│ ├── SparkFun_LIS2DH12.h # Accelerometer stub
│ └── ... (22 total shim headers)
└── app/ # Generated at compile time for custom apps
├── app_include.h
├── MyApp.h
└── MyApp.cpp
Website Repo (cyberfidget_website)¶
assets/js/
├── emulator.js # CyberFidgetEmulator class (DOM rendering)
├── wasm_bridge.js # WasmBridge class (WASM ↔ emulator glue)
└── ai_builder.js # App Builder integration (compile, cache, load)
build.html # App Builder page with emulator panel
Key Components¶
emulator.js — Device Mockup¶
Renders an interactive device that mirrors the physical layout. Key methods:
writeFramebuffer(buffer)— Accepts a flatUint8Array(128×64 pixels, 1 byte per pixel) and renders to the OLED canvassetLED(index, r, g, b, w)— Updates LED indicator color with brightness boosting for dim valuessetAllLEDsOff()— Resets all LED indicators to the dim/off stateonButtonEvent/onSliderChange— Callbacks wired by the bridge
LED index mapping: The firmware uses index 0 = back, 1 = front top, 2 = front middle, 3 = front bottom. The emulator maps these via LED_INDEX_MAP = [3, 0, 1, 2] to match the visual layout.
Brightness floor: LED values below brightness 50 are proportionally scaled up so they're visible on screen. Real NeoPixels emit visible light even at very low values; CSS backgrounds don't.
wasm_bridge.js — WASM Glue¶
Two loading methods:
loadModule(url)— Loads a pre-compiled.jsloader from a URL (for demo/official apps)loadFromBytes(jsText, wasmBinary)— Loads from raw bytes (for custom-compiled apps)
Both methods:
1. Create a <script> tag to execute the Emscripten-generated JS loader
2. Call CyberFidgetModule({...}) with callbacks for onFrameReady, onLedUpdate, onSerialOutput
3. Use cwrap() to bind exported C functions
4. Wire emulator events to the WASM functions
The stop() method calls _wasm_stop() (which internally calls emscripten_cancel_main_loop) and resets LEDs.
HAL_WASM.cpp — Browser HAL¶
Implements the HAL namespace that all apps depend on:
- Uses
EM_JSmacros for JS interop (framebuffer push, LED updates, serial output) loopHardware()runs every frame: updates timing globals, processes button events, reads slider value from JS, pushes LED state when the strip'sneedsShowflag is setupdateStrip()(fromRGBController.cpp) must be called each frame to flush the dirty flag intoshow()
Adafruit_NeoPixel.h — NeoPixel Shim¶
Critical detail: The Color() function packs as WRGB to match the real Adafruit library:
// Color(r=255, g=0, b=0, w=0) → 0x00FF0000
static uint32_t Color(uint8_t r, uint8_t g, uint8_t b, uint8_t w = 0) {
return ((uint32_t)w << 24) | ((uint32_t)r << 16) | ((uint32_t)g << 8) | b;
}
setPixelColor() unpacks in the same WRGB order. getLedRGBW() returns the stored values directly.
main_wasm.cpp — Entry Point¶
The main loop runs at 50 FPS via emscripten_set_main_loop:
static void mainLoop() {
HAL::loopHardware(); // timing, buttons, slider, LED push
updateStrip(); // flush RGBController dirty flag → show()
// ... button event dispatch ...
APP_INSTANCE.update(); // the app's per-frame logic
}
Order matters: loopHardware pushes LED state set in the previous frame, updateStrip flushes the dirty flag for the current frame, and the app sets new state for the next frame.
Custom App Compilation Flow¶
flowchart LR
A[User .h + .cpp] --> B[Base64 encode]
B --> C[GitHub API: workflow_dispatch]
C --> D[compile-wasm.yml]
D --> E[Write wasm/app/, emcmake, build]
E --> F[Upload artifacts]
F --> G[Frontend: poll run, download ZIP]
G --> H[JSZip: extract .js + .wasm]
H --> I[IndexedDB cache]
I --> J[loadFromBytes]
- Frontend base64-encodes the
.hand.cppfiles - Triggers
workflow_dispatchon the user's firmware fork via GitHub API - The
compile-wasm.ymlworkflow: - Writes decoded files to
wasm/app/ - Generates
app_include.h - Runs
emcmake cmakewith-DWASM_APP=Custom -DCUSTOM_APP_NAME=... - Uploads
cyberfidget.js+cyberfidget.wasmas artifacts - Frontend polls the workflow run, downloads the artifact ZIP
- Extracts
.jsand.wasmfiles using JSZip - Stores in IndexedDB (keyed by code hash) and loads via
loadFromBytes()
Bugs & Pitfalls Found During Development¶
These are documented here so future contributors don't repeat the same mistakes.
1. Color() byte order must match real Adafruit library¶
The real Adafruit_NeoPixel::Color(r, g, b, w) packs as WRGB: (w<<24)|(r<<16)|(g<<8)|b. Our original shim packed as RGBW which swapped every color channel. Always match the real library's format.
2. updateStrip() must be called every frame¶
RGBController uses a dirty-flag pattern: markDirty() sets a flag, updateStrip() checks it and calls strip.show(). Without calling updateStrip() in the main loop, show() never fires and LEDs never update.
3. emscripten_cancel_main_loop can't be directly exported¶
Emscripten internal functions can't appear in EXPORTED_FUNCTIONS. Solution: wrap it in a user-defined wasm_stop() function and export that instead.
4. HEAPU8 is no longer a valid EXPORTED_RUNTIME_METHODS entry¶
Newer Emscripten versions expose HEAPU8 automatically. Listing it causes a build warning/error.
5. Globals.cpp filename is case-sensitive on Linux¶
GitHub Actions runs Ubuntu. The file is globals.cpp (lowercase) but CMakeLists had Globals.cpp. Windows doesn't care; Linux does.
6. Low LED brightness values are invisible on screen¶
A NeoPixel at brightness 3/255 emits visible photons. CSS rgb(0, 3, 0) on a dark background is invisible. The emulator applies a minimum brightness floor of 50 to make all "on" LEDs visible.
7. LED index 0 is NOT the front-top LED¶
Physical wiring: index 0 = back LED, 1 = front top, 2 = front middle, 3 = front bottom. The emulator must remap indices to match the visual layout.
8. IndexedDB cache ignores firmware changes¶
The cache is keyed by app code hash. If you update the firmware shims/HAL but not the app code, the cached WASM is stale. The Compile button always forces a fresh build to handle this.
9. RGBController's red() and green() have swapped arguments¶
The firmware's red() calls Color(0, 25, 0, 0) which by Adafruit convention is actually green (r=0, g=25). This is likely compensating for a hardware channel swap on the PCB. The emulator reproduces whatever the firmware produces.
10. LEDs persist across app switches¶
When stopping one app and loading another, the LED DOM elements retain their last CSS style. bridge.stop() must call emulator.setAllLEDsOff().
Adding a New Built-In App to the Emulator¶
- Add the app's library directory to
include_directories()inwasm/CMakeLists.txt - Add an
elseif(WASM_APP STREQUAL "YourApp")block with the appropriate source files - Add an
#elif defined(WASM_APP_YOURAPP)block inwasm/main_wasm.cpp - Test locally:
emcmake cmake -S wasm -B wasm/build -DWASM_APP=YourApp && cmake --build wasm/build
Adding a New Shim¶
If an app pulls in a new ESP32 library:
- Create a stub header in
wasm/shims/with the same filename as the real library - Implement enough of the API for compilation to succeed (no-ops are fine for hardware-specific features)
- If the library has actual logic needed at runtime, implement it in a
.cppunderwasm/hal/
Local Development (No GitHub Actions)¶
flowchart LR
subgraph Local
Code[Your app or built-in]
emcc[emcmake + cmake]
Out[cyberfidget.js + .wasm]
Code --> emcc --> Out
end
subgraph Run
Server[python -m http.server]
Browser[Browser loads from localhost]
Out --> Server --> Browser
end
Prerequisites¶
- Emscripten SDK —
emsdk install 3.1.51 && emsdk activate 3.1.51 - CMake 3.13+
- Ninja (or make)
- Python 3 with
http.serverfor local testing
Build¶
cd CyberFidget_Bundled_Demo_Platformio
source emsdk/emsdk_env.sh # or emsdk_env.bat on Windows
emcmake cmake -S wasm -B wasm/build -G Ninja -DWASM_APP=DinoGame
cmake --build wasm/build
Test¶
cd wasm/build
python -m http.server 8080
# Open http://localhost:8080 and load cyberfidget.js from your test page
WASM files must be served over HTTP
Browsers block WASM loading from file:// URLs due to CORS. Always use a local HTTP server.