Emulate ESP32 project and external peripherals so we can vibe code hardware
  • C++ 78.3%
  • Shell 11.8%
  • CMake 4.9%
  • C 4.1%
  • Python 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-06 18:40:10 -07:00
bin Add interactive local playground 2026-08-06 17:33:46 -07:00
cmake Add interactive local playground 2026-08-06 17:33:46 -07:00
docs M1: GPIO bus + Arduino/FreeRTOS/ESP-IDF shim, bonkgotchi-firmware compiles and runs standalone 2026-08-04 22:07:15 -07:00
include Keep live playground running across firmware reboot 2026-08-06 18:00:34 -07:00
src Add interactive accelerometer controls 2026-08-06 18:40:10 -07:00
test Add interactive accelerometer controls 2026-08-06 18:40:10 -07:00
third_party M6: FreeRTOS-Kernel POSIX port swap-in (opt-in second backend) 2026-08-05 03:21:01 -07:00
tools M2: Sharp LCD decoder + PNG output, test-first 2026-08-05 01:38:12 -07:00
.gitignore Ignore generated CMake build outputs 2026-08-06 16:53:12 -07:00
CMakeLists.txt Add interactive accelerometer controls 2026-08-06 18:40:10 -07:00
README.md Add interactive accelerometer controls 2026-08-06 18:40:10 -07:00

esp32-emu — bonkgotchi board emulator

Runs the real bonkgotchi-firmware source, unmodified, as a native Linux process — compiled against a purpose-built Arduino/FreeRTOS/ESP-IDF compatibility shim instead of the real ESP32-S3 — so the display (Sharp LS013B7DH03), PWM-driven piezo speaker, and 2-wire RGB LED can be decoded, rendered, and reviewed without real hardware.

Status

The core emulator and local review workflow are operational: firmware compiles and runs (default: the naive std::thread-per-task FreeRTOS backend); the display/audio/LED are decoded from real GPIO activity and verified against golden/known-correct values; scripted joystick input drives the firmware exactly like a human pressing buttons would; every run produces a full output directory (manifest, serial log, display frames, audio, LED timeline, input events); accelerometer/battery/charge-status are scriptable stubs, and NVS persists across runs via --nvs-file; esp32-emu serve exposes any run through a read-only web UI bound only to the local machine; esp32-emu play runs an interactive localhost playground with a live display, RGB LED, and joystick controls. A second, opt-in FreeRTOS backend (the real vendored FreeRTOS-Kernel, via its official POSIX port) is also available — see "FreeRTOS backends" below for what it's for and its real tradeoff.

FreeRTOS backends

Two interchangeable backends implement the freertos/*.h API the firmware calls (same firmware source either way — see cmake/RtosBackend.cmake):

  • naive (default) — xTaskCreate spawns a real std::thread per task; vTaskDelay etc. are thin wrappers over the OS's own scheduler. Fast (a full boot + display refresh takes ~1-2s), but real OS thread scheduling isn't priority-faithful to FreeRTOS semantics — it can't meaningfully claim "task A always preempts lower-priority task B".
  • posix — the real, vendored FreeRTOS-Kernel (MIT-licensed) running its official portable/ThirdParty/GCC/Posix port. Genuine priority-based preemption — proved by freertos_priority_smoke, always built/tested regardless of which backend bonkgotchi_emu itself uses (a synthetic two-task test showing a lower-priority task gets zero CPU time while a higher-priority task is ready, the thing "naive" can't claim). Verified end-to-end against the real firmware too: it boots and runs a full boot->loop() cycle correctly under this backend. Its tick-driven, signal-based scheduling doesn't preserve microsecond-level timing precision for busy/short delays the way a raw OS sleep_for() does — delayMicroseconds(1) calls between individual GPIO edges each actually take tens of microseconds under this backend. This used to matter a lot: while the display used bit-banged (software) SPI, a single refresh was ~55,000 such GPIO ops, and a run that took ~1-2s under "naive" could take ~20s+ under "posix". Now that the display uses real hardware SPI (see "Display: hardware SPI" below), that cost is gone — a display refresh no longer touches delayMicroseconds() at all, and both backends run a full boot+run at roughly real-time speed. The RGB LED's 2-wire protocol is still bit-banged (HarvatekLED.cpp, delayMicroseconds(1) per bit), but at ~336 GPIO ops per color command it's comparatively negligible. Opt in with -DESP32EMU_RTOS_BACKEND=posix when real priority-scheduling fidelity (e.g. chasing a suspected priority-inversion/race bug) matters more than the (now much smaller) speed cost.

Display: hardware SPI

Main.cpp constructs Adafruit_SharpMem with its hardware-SPI constructor (Adafruit_SharpMem(&SPI, LCD_SCS, 128, 128)) rather than the 3-pin software-SPI one — GPIO10/11/12 are the ESP32-S3's dedicated FSPI pins, so this was always the intended wiring. Adafruit_SPIDevice on that path issues whole-buffer SPI.transfer(buffer, len) calls instead of per-bit digitalWrite/delayMicroseconds bit-banging. The emulator models this with SpiBus (src/core/spi_bus.h), a singleton parallel to GpioBus that delivers whole transferred buffers to watchers instead of reconstructing bytes from SCK/MOSI edges. DisplayDecoder::onSpiBytes() feeds those buffers into the exact same per-byte protocol state machine onSck() already used — CS transaction boundaries are unaffected, since Adafruit_SharpMem toggles CS with plain digitalWrite() regardless of SPI transport, so that still flows through GpioBus exactly as before. Both delivery paths are wired into run_manager.cpp unconditionally; only one is ever active for a given firmware build depending on which Adafruit_SharpMem constructor it calls.

Build & run

bin/esp32-emu build --firmware-dir /path/to/bonkgotchi-firmware   # optional; defaults to
                                                                     # ~/Documents/PlatformIO/Projects/bonkgotchi-firmware
bin/esp32-emu run --out runs/my-run --script test/scripts/music_notes.json --duration 6000
bin/esp32-emu serve --run-dir runs/my-run
bin/esp32-emu play --out runs/live

bin/esp32-emu is a thin wrapper: --firmware-dir maps to a CMake reconfigure (the compiled binary has no runtime concept of switching firmware checkouts — see ESP32EMU_FIRMWARE_DIR in CMakeLists.txt), everything else forwards straight to the built binary's matching subcommand. You can also build/run directly:

mkdir -p build && cd build && cmake -G Ninja -DFIRMWARE_DIR=/path/to/bonkgotchi-firmware .. && ninja
./bonkgotchi_emu run --out ../runs/my-run [--script <path>] [--duration <ms>] [--seed <n>] [--trace-gpio]

Each run writes <out>/manifest.json, serial.log, input_events.json, display/*.png+frames.json, audio/speaker.wav+tones.json, led/timeline.json, and (with --trace-gpio) gpio_trace.jsonl.

The LCD panel is physically mounted 180 degrees from the device's normal viewing orientation, which the firmware compensates for with display.setRotation(2). The decoder retains those raw panel coordinates, but PNGs default to --display-view device: they are rotated back 180 degrees so screenshots are upright for the web UI, people, and vision models. Use --display-view panel when debugging the exact controller rows and columns sent over SPI. The selected view is recorded in manifest.json.

Scripted input

--script takes a JSON file describing joystick button presses over time, relative to the run's start (t=0, same clock every output timestamp is relative to — note the firmware's own ~2s boot-screen hold means nothing useful happens before t≈2000-2200ms):

{"events": [
  {"t_ms": 2300, "pin": "JOY_RIGHT", "action": "press"},
  {"t_ms": 2450, "pin": "JOY_RIGHT", "action": "release"}
]}

See test/scripts/music_notes.json for a full worked example (navigates to the Music app and plays all four directional notes) and test/integration/music_notes_e2e.sh for what a real run against it produces.

Power / accelerometer stubs, NVS persistence

--battery-mv <n> (default 3700), --vbus (USB power present), and --charge-complete feed PowerMonitor.cpp's VBAT_ADC/VBUS_STAT/CHARGE_STAT reads. The accelerometer model (MC36XX) exposes the chip ID and live raw-axis registers, resting at (0, 0, +1g). Batch runs remain stationary unless a harness drives those registers; the interactive playground adds tilt and shake injection through the same model.

--nvs-file <path> loads NVS state from that file at the start of a run and saves it back at the end, so GameState's persistence (e.g. mins_alive) survives across separate run invocations — omit it and NVS is fresh (in-memory only) every run.

Local web UI

bin/esp32-emu run --out runs/my-run --script test/scripts/music_notes.json --duration 6000
bin/esp32-emu serve --run-dir runs/my-run                 # http://127.0.0.1:8000/
bin/esp32-emu serve --run-dir runs/my-run --port 8080     # optional alternate port

The server binds to 127.0.0.1 only and serves a complete browser UI with the display-frame gallery, WAV player, LED color-swatch timeline, tone segments with note names, and serial log. Assets stay in the run directory and are served directly; no report bundle is generated and nothing is uploaded. Stop the server with Ctrl-C.

Interactive playground

bin/esp32-emu play --out runs/live
# Open http://127.0.0.1:8000/

play starts the real firmware and the loopback-only UI together. The page updates the upright LCD at 25Hz, shows the decoded RGB LED, and maps the arrow keys plus Enter/Space (or the on-screen D-pad) to the five joystick inputs. Input is applied directly to GpioBus; the remaining response time comes from the real firmware's 50ms button debounce and 50ms main-loop cadence. The UI reports the HTTP-handler portion in microseconds.

The accelerometer panel has a spring-loaded X/Y tilt pad, a Z slider, exact numeric fields, and a small 3D device preview. Releasing returns to (0,0,+1g) unless Hold tilt is enabled. Shake injects a reproducible six-sample waveform and pulses the real ACCL_INT GPIO, so it exercises the firmware's ISR and animation task rather than directly invoking application behavior.

Automation can use the same local interface directly: GET /api/state returns the latest frame, LED, and timing state, while POST /api/input?pin=JOY_RIGHT&action=press (or release) injects a joystick edge. POST /api/accelerometer?x=.25&y=-.5&z=1&units=g&interrupt=1 sets exact axes (units=raw is also supported), while POST /api/accelerometer/shake performs the same deterministic gesture as the UI. Accelerometer state, raw counts, and interrupt count are included in GET /api/state. No browser-specific bridge or agent connector is involved.

Ctrl-C ends the session gracefully and writes manifest.json, display frames/index, LED, input, and accelerometer timelines, tone segments, synthesized audio/speaker.wav, and the serial log to --out. The page also follows the live LEDC speaker state with a local Web Audio square-wave oscillator. Browser autoplay rules require a gesture, so sound enables on the first D-pad/key press; the Sound button can explicitly enable or mute it. /api/state exposes the same frequency, duty, and timing state for automation and agents that cannot hear browser audio. --port changes the default 8000 port, and --duration <ms> provides a bounded session for automation.

Selecting System -> Reboot performs a real process-image restart instead of ending play or calling setup() again inside stale firmware state. The page briefly reports a disconnect, then reconnects at the same URL and shows the new boot; /api/state exposes the zero-based reboot_count. Live NVS is persisted to <out>/nvs.json by default (or the explicit --nvs-file), and the serial log plus numbered display frames retain history across boots. Each frame index entry includes its boot generation, so a future boot animation can be inspected frame by frame.

Tests

cd build && ctest --output-on-failure

Unit tests (test/unit/) are protocol/logic-level and fast — no firmware compile involved. Integration tests (test/integration/) build and run the real firmware and check its actual output. Every peripheral decoder was written test-first (see individual commit messages).

Layout

  • include/arduino_shim/ — narrow Arduino-ESP32 + FreeRTOS + ESP-IDF compatibility headers (scope is exactly what bonkgotchi-firmware's src/+lib/ actually call — see per-header comments for what's stubbed and why).
  • src/core/GpioBus (the central virtual peripheral bus), LedcModel (PWM), SimClock, JsonWriter/JsonValue (output/input).
  • src/shim/ — implementations backing the arduino_shim headers.
  • src/peripherals/ — decoders that sit on top of GpioBus/LedcModel: display_decoder+display_png (Sharp LCD -> PNG), audio_synth (ledc -> WAV), led_decoder (HarvatekLED -> color timeline), nvs_store, accel_stub.
  • src/harness/input_script (scripted joystick input), run_manager (orchestrates one run + writes the output directory), local_web_ui (localhost-only run browser), live_playground (interactive firmware + web session), arduino_main (CLI entry point).
  • third_party/ — vendored, unmodified Adafruit_GFX / Adafruit_SharpMem / Adafruit_BusIO / stb_image_write sources (pinned to the versions bonkgotchi-firmware's platformio.ini resolves).
  • docs/pin_table.md — documented pin table, including the Revision 3 vs Revision 4 PCB discrepancy found while planning this project.
  • tools/gen_golden_smiley.py — derives the display decoder's golden test bitmap directly from Sprites/Smiley.h, independent of the decoder.