Skip to main content

Roadmap

Each milestone is independently useful and demoable. Order matters: nothing in M3+ starts until M1's demo runs on real hardware.

M0 — Design (done)

Architecture, decisions, and scope documented. This directory.

Demo: blink.dart compiled on the Mac, driving a GPIO pin on an ESP32-S3.

Blink turned out to be a smaller first step than fizzbuzz: no strings means no heap and no GC, so this milestone is the whole pipeline with the least runtime.

  • Bytecode format + constant pool spec (docs/BYTECODE.md)
  • mothc: analyzer parser → lowering → blob (functions, int/double/bool, locals, if/while/for/break/continue, calls, recursion, short-circuit logic)
  • C interpreter: dispatch loop, call frames, arithmetic, comparisons
  • Natives resolved by name at load time; unknown ones fail the load
  • mothrun desktop simulator — traces pins against a virtual clock
  • Golden tests, with expectations generated by the real Dart SDK
  • ESP-IDF host: embedded blob, GPIO/delay/millis natives

M1b — The heap (done, except enums)

Demo: labeled output, so programs can finally say what they mean.

  • Heap objects, string values, concatenation and interpolation
  • Mark-sweep GC (roots: stack, globals, constant pool)
  • List: literals, index get/set, .length, add/removeLast/clear, for-in
  • Classes: fields with initializers, constructors (including this.x parameters), methods, implicit this
  • Single inheritance with method overriding
  • Named parameters (constructors and top-level functions)
  • Enums
  • package:moth device API — the idiomatic Dart layer over the native boundary (OutputPin, InputPin, AnalogPin, PwmPin, Buzzer, Servo, I2c, I2cDevice, Uart, Prefs), per ADR-009. Examples and docs get rewritten around it.

M2 — Dart drives the display (done)

Demo: a tappable UI written in Dart, running on the ESP32-S3 panel.

The two tracks finally meet: the VM calls the backend contract, so the same Dart program draws in a desktop window and on the board.

  • ui/ — the contract bound as ui* natives (create/attach/set/animate, commit, poll)
  • Event queue: renderer → queue → uiPoll, drained by the program's own loop (polling until closures exist)
  • mothsim — desktop host with a window, plus --tap X,Y so the click path is testable without a mouse
  • ui/esp-s3 — the same VM, renderer and bindings on the CO5300 panel with CST9217 touch
  • Callbacks instead of polling — onTap(() { ... }), dispatched in Dart because a native cannot re-enter the VM
  • ESP32-P4 host — the renderer already runs its demo scene on a P4 dev board (moth_render/esp/); the VM host and touch are not ported

M2.5 — Closures (done)

Demo: box.onTap(() { ... }) running on the panel.

  • Function values, lambdas, closures in lists, functions as arguments
  • A lambda inside a method captures this, reaching fields and methods
  • Capturing a local of the enclosing function — rejected at compile time for now; needs boxed cells or upvalues

M3 — Widgets and setState (done)

Demo: examples/ui/counter.dart — a counter that rebuilds on tap.

  • Widget / Element split, dirty list, rebuild on the next frame
  • setState(() { ... }) marking its element dirty
  • Reconciler: matches by widget type, updates nodes in place, mounts and unmounts children as the list grows or shrinks
  • Event bubbling — hit-testing reports the innermost node, so a tap walks up to the first ancestor with a handler
  • Keys, so a reordered child keeps its element and node
  • Multi-file imports, so the framework is a library programs import
  • Publish it as package:moth rather than a file in examples/
  • Flutter-named widgets: Container, Column, Row, Stack, Center, Padding, SizedBox, GestureDetector, Divider, Text/TextStyle, CircularProgressIndicator
  • Slider and Switch: renderer-owned gestures (press jumps the thumb, drag tracks, tap toggles) emitting VALUE_CHANGED; Flutter-shaped controlled components in the widget layer; contract-tested end to end (moth_render/test/controls_test.cpp) — events, clamping, dedupe, and painted pixels asserted against the same geometry the gesture uses
  • Disabled controls: onChanged: null should disable the gesture and grey the control (Flutter's semantics). Today the renderer still drags the thumb and the next rebuild snaps it back; needs an enabled prop in the backend contract, so it rides the next contract bump
  • Image widget: Image('logo.png') — the compiler decodes the PNG/JPEG at compile time and embeds raw ARGB in the blob's assets section (bytecode v6), so the board needs no filesystem and blits straight from mapped flash at zero RAM cost. Intrinsic or scaled (nearest-neighbour) sizing, alpha blending, rounded-corner clipping; assets die with their program on swap (render_image_test pins the lifetime). 512KB budget with a human error message when an asset blows it
  • Golden tests: widget tree in → sequence of ui* calls out

Known limitations to close

  • The collector marks recursively. Depth is one C frame per level of nesting, which is fine on a host stack but risks overflowing an ESP-IDF task stack (a few KB) if deeply nested structures are collected on device. Needs an explicit mark stack or a depth cap.
  • No bytecode verifier — done: every function is abstractly interpreted at load, so unbalanced or malformed code is refused before it runs. Types are still unchecked, which is a language guarantee rather than a memory-safety one.
  • Native argument counts are taken from the blob. moth_register does not record an arity for the host to cross-check against.

M4 — Hot push (shipped)

Demo: mothc app.dart --push <target> replaces the running program.

  • Push protocol (vm/host/push_proto.h): a framed blob carrying a sender-invented nonce; the reply is a binary verdict (MPOK/MPRJ) echoing that nonce, sent only AFTER the receiver verified the blob — so "pushed" always means "verified and running", and no log line or stale reply can fake it. Paired boards require the authenticated MPH2 frame (ADR-010)
  • moth_request_halt — a host can stop a running program at the next instruction, which is what makes swapping into an endless loop possible
  • mr_reset — the outgoing program's nodes go with it, so the new UI does not draw over a tree it does not own
  • mothsim --listen PORT, and mothc --push HOST:PORT
  • ESP32 side: vm/host/push.c compiles unchanged against lwIP. WiFi credentials live in NVS, written from the host by tools/provision/provision.py — never compiled in. The board prints its push target when it connects; port 7621.
  • Push over the USB cable: the USB-Serial-JTAG console doubles as a push transport (mothc app.dart --push /dev/cu.usbmodemXXXX), so the out-of-box loop needs no WiFi at all — provisioning is the upgrade, not the prerequisite. Measured on the board: 125–180ms compile-to-verdict over serial, persistence across reboot, and fps unchanged at 38 with the transport polling on the frame hook. WiFi pushes verified on-board end to end (the paired flows under M5).
  • Persist across reboot: the blob lands in a dedicated mothb partition behind a CRC header, and boots run it straight from mapped flash — a stored program costs no RAM
  • Crash-loop protection: a strike counter in NVS driven by esp_reset_reason() — a panic or watchdog reset while the pushed program ran is a strike, any clean reset clears, and three strikes falls back to the embedded program and invalidates the store. (Brownouts are power faults and never count.) Runtime failures drop the stored program immediately

M5 — v0.1 public release

  • Push pairing: WiFi pushes are HMAC-authenticated against a phrase set at provision time (ADR-010); a paired board refuses unsigned pushes, an unpaired one warns at boot. Serial needs no pairing — the cable is possession. Replay consciously deferred to v0.2 (ADR-010 records why)
  • Editor support: every built-in is declared external in package:moth, so the Dart analyser resolves moth programs and offers autocomplete and type checking (it reported 112 errors before, and the missing declarations were hiding a real bug in Uart). mothc create writes the pubspec and analysis options an editor needs, and mothc check reports the subset's rejections without writing a blob. CI keeps package:moth and a freshly-created project analyzing clean
  • mothc run — the flutter-run loop: device auto-selection (one board wins, simulator as fallback, -d to pick), compile + push + attach with the program's output streaming, r = hot restart in ~173ms on hardware (37ms sim, measured), honest about state resetting. The attach session owns the serial port, so restarts never fight the console over it
  • README demo GIF — two unedited hardware takes: moth run with a device picker and a hot restart (docs/img/moth-run.gif), and renderer-owned touch tracking a finger (docs/img/moth-touch.gif)
  • mothc create <dir> project template: one app.dart (a tap counter in Flutter's shape), a README with the run commands, a .gitignore — nothing to install, since the compiler resolves package:moth itself. create_test.dart compiles the template on every make test, so the scaffold can never greet a beginner with a compile error
  • Golden tests wired to CI (simulator, no hardware)
  • Contributor docs — CONTRIBUTING.md (build, test culture, review dispositions, hard constraints), RELEASING.md, and docs/PERF_REVIEW.md as the render-change checklist
  • Publish: GitHub + pub.dev for the CLI and package:moth

Track R — moth_render (parallel, best-effort)

The native backend (ADR-008). Runs alongside M1–M4, never blocks them. Desktop-first; no Dart dependency until the framework exists.

  • R0 — Backend contract (docs/BACKEND.md) + component scaffold with SDL harness

  • R1 — Flex layout per §4 of the contract; software paint (boxes visible in the harness). The layout goldens themselves are still open — they are the conformance gate a second backend must pass (BACKEND.md §7)

  • R2a — Software paint: flat fills, rounded rects, borders, arcs with antialiasing, and antialiased bitmap text with real metrics and wrapping (tools/fontgen generates the faces)

  • R2b — ThorVG: scalable text at any size (faces are fixed sizes today), gradients, and true vector images (SVG rendered on device, crisp at any scale — compile-time SVG rasterization in mothc is the cheaper interim if wanted, but needs a rasterizer Dart does not have or an optional system tool)

  • R3 — Damage tracking: repaint only the rows that changed.

    **Measured on an ESP32-S3 at 466x466**, `examples/ui/frame_bench.dart`
    with `MOTH_FRAME_PROFILE=1`:

    | phase | full frame | damage-tracked |
    | --- | --- | --- |
    | layout + paint | 339.4ms | 138.3ms |
    | ARGB to RGB565 | 21.9ms | 8.3ms |
    | QSPI transfer | 25.6ms | 10.2ms |
    | **frame** | **391.8ms** | **160.9ms** |

    2.44x, and all three phases fell together to about 38% — which is the
    damaged band, 177 of 466 rows. That the three scale identically is the
    evidence the band is doing the work rather than something else.

    Bands are rows, not rectangles: a band spans the full width, so nothing
    can be partly covered by a sibling and the hard cases — overlap,
    translucency, z-order — cannot arise. Rectangles would tighten it
    further and are the obvious next step, at the cost of those cases
    becoming real.

    Two things had to be true before any of it worked. Properties are
    compared before being stored, because a rebuild re-applies every
    property of every widget and without that every node is "changed" every
    frame. And the band is reset each commit — accumulating it pinned the
    whole screen forever, since the first frame legitimately damages
    everything.
  • R3a — Paint what shows. Per-primitive profiling (MR_PROFILE) found 115ms of the 138ms paint was rectangle fills — and nearly all of that was fully transparent wrapper boxes (every Stack, Column, Padding, and label background) taking the blend path, which read and wrote every pixel back unchanged: a ~9.5ms round trip through PSRAM per full-width node, times a dozen nested wrappers.

    Three fixes, same benchmark:
    - skip fills whose color or opacity make them invisible (115ms → 18.5ms;
    what remains is two real opaque background fills at PSRAM write speed)
    - scan arcs by per-row annulus spans instead of rejecting the full band
    width pixel by pixel (18.3ms → 7.4ms)
    - integer blend, alpha widened 0..256 so opaque stays exact. Float cost
    the same in internal RAM as in PSRAM — 34ms vs 13ms over a 177-row
    band — so it was the conversions, not the memory. Renders differ from
    the float path by at most 2/255 per channel, only on blended pixels.

    | phase | R3 | R3a |
    | --- | --- | --- |
    | layout + paint | 138.3ms | 30.1ms |
    | ARGB to RGB565 | 8.3ms | 8.3ms |
    | QSPI transfer | 10.2ms | 10.2ms |
    | **frame** | **160.9ms** | **53.1ms** |

    6.2 → 18.8 fps. A boot microbench (`membench` in ui/esp-s3) records the
    floors this was measured against: writing the whole 177-row band costs
    9.5ms in PSRAM, so the remaining fill time is bandwidth, not waste.
  • R3b — Tighter damage. A label's wrap hint now dies with the text it described (mr_set_str), so changed text re-measures unconstrained — this fixed both a visible premature-wrap bug and a damage band twice the label's height. The lesson that transfers: cached layout hints must be invalidated by the change that made them, not trusted across it. The band is now the 88 rows the label occupies.

    Also here: painting starts at the last node in paint order that fills
    every damaged row opaquely (find_band_cover, generalizing the old
    whole-frame clear skip). Everything before it — the clear, the root's
    background under a full-bleed panel — was being painted only to be
    overwritten; that was a full extra band of PSRAM writes a frame.

    | phase | R3a | R3b |
    | --- | --- | --- |
    | layout + paint | 30.1ms | 12.2ms |
    | ARGB to RGB565 | 8.3ms | 4.2ms |
    | QSPI transfer | 10.2ms | 5.3ms |
    | **frame** | **53.1ms** | **26.2ms** |

    18.8 → 38.2fps; 6.2fps at the start of R3a. The ~4.5ms the phases do
    not account for is the VM rebuilding the widget tree each frame.
  • R3c — If more is ever needed: an RGB565 framebuffer deletes the convert phase (4.2ms) and halves fill traffic, at the cost of the mr_framebuffer() ARGB contract and some banding on antialiased edges. Not worth it at 38fps on a watch face.

  • R4 — ESP-IDF port on the P4: the demo scene runs on an ST7796 SPI panel today (moth_render/esp/, full-frame present, ~30ms, no touch); esp_lcd + PPA acceleration and damage tracking are open

  • Graduation review: conformance green + on-hardware comparison vs LVGL

Later / help wanted

Deliberately out of v0.x scope — meaty, self-contained problems for contributors:

  • async/await lowering onto the event loop
  • Network images (Image.network) — gated on the event loop and Dart networking above, plus an on-device PNG decoder and PSRAM for decoded pixels; today decode deliberately happens at compile time so the board never pays for it. Until then, hot-push re-embeds new images in ~300ms
  • Stateful in-place hot reload (preserve State across pushes)
  • Virtualized ListView (recycle nodes for long scrolling lists)
  • Local-variable capture in closures (boxed cells or upvalues)
  • ESP32-P4 bring-up; non-ESP ports (RP2350?) via the portable VM core
  • Computed-goto dispatch + interpreter profiling on RISC-V
  • InheritedWidget-style scoped state
  • Debugger wire protocol (breakpoints over the hot-push channel)