- C++ 78.3%
- Shell 11.8%
- CMake 4.9%
- C 4.1%
- Python 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| bin | ||
| cmake | ||
| docs | ||
| include | ||
| src | ||
| test | ||
| third_party | ||
| tools | ||
| .gitignore | ||
| CMakeLists.txt | ||
| README.md | ||
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) —
xTaskCreatespawns a realstd::threadper task;vTaskDelayetc. 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/Posixport. Genuine priority-based preemption — proved byfreertos_priority_smoke, always built/tested regardless of which backendbonkgotchi_emuitself 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 OSsleep_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 touchesdelayMicroseconds()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=posixwhen 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'ssrc/+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'splatformio.iniresolves).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 fromSprites/Smiley.h, independent of the decoder.