Skip to main content

moth bytecode

The compiler (tools/mothc, Dart) emits a .mothb blob; the VM (vm/, C) loads and interprets it. This document is normative for both.

Design: stack machine, byte-aligned, little-endian (ADR-003). Simplicity wins over density at this stage — the interpreter is not the bottleneck.

Blob format

All multi-byte integers are little-endian.

magic 4 bytes "MOTH"
version u16 MOTH_BYTECODE_VERSION (currently 6)
flags u16 reserved, 0
constants u16 count, then each: tag u8 + payload
natives u16 count, then each: name_const u16 + argc u8
globals u16 number of top-level variable slots
classes u16 count, then each:
name_const u16
nfields u8
field_names nfields × u16 (constant indices)
nmethods u16, then each: name_const u16 + func_index u16
+ member_kind u8 (0 method, 1 getter, 2 setter —
arity cannot tell a getter from a no-arg method)
ctor u16 constructor function index, or 0xFFFF
functions u16 count, then each:
name_const u16
arity u8
nlocals u8 total slots (params occupy slots 0..arity-1)
code_len u32
code code_len bytes
entry u16 index of the function to call at start (must be arity 0)
init u16 initializer function index, or 0xFFFF for none
assets u16 count, then each:
key_const u16 constant index of the string key
width u16 1..2048
height u16 1..2048
pad 0..3 bytes so pixels start 4-byte aligned
from the blob's first byte
pixels width × height u32 ARGB8888, rows top to bottom

Global slots start as null and are filled by the init function, which the VM runs to completion before entry. The compiler synthesizes it from the top-level variable initializers, in declaration order.

Constant tags: 0 = int (i64), 1 = double (f64), 2 = string (u16 byte length + UTF-8, no terminator), 3 = bool (u8), 4 = null.

Strings appear in the pool for names even when the VM has no string values yet (M1a) — they are used for native resolution and diagnostics.

Assets are how images travel: the board has no filesystem for user files, so the compiler embeds decoded pixels and the VM lends them out by reference (moth_asset_info). Nothing is copied — on a flash-mapped blob an image costs no RAM. The alignment padding exists for exactly that pointer: hosts map or allocate blobs at least 4-aligned, so aligning pixels to the blob start makes them directly readable as u32s.

Values

null, bool, int (64-bit signed), double, and heap objects: strings, lists and class instances. Heap objects are garbage collected (mark-sweep); constant strings borrow the blob's bytes and are never freed.

Fields and methods are looked up by constant index, not by string: the pool deduplicates, so identical names always share one index and lookup is an integer scan. Instance layout is the class's field order.

Instruction set

Operands are shown after the mnemonic. Stack effect is written [before → after], rightmost is top of stack.

OpByteOperandsEffect
NOP0x00[→]
CONST0x01u16 idx[→ v] push constant
INT80x02i8[→ int] push small immediate
TRUE0x03[→ true]
FALSE0x04[→ false]
NULL0x05[→ null]
POP0x06[v →]
DUP0x07[v → v v]
LOAD0x08u8 slot[→ v] push local
STORE0x09u8 slot[v →] pop into local
LOAD_GLOBAL0x0Au16 slot[→ v] push top-level variable
STORE_GLOBAL0x0Bu16 slot[v →] pop into top-level variable
ADD0x10[a b → a+b]
SUB0x11[a b → a-b]
MUL0x12[a b → a*b]
DIV0x13[a b → a/b] always double (Dart /)
IDIV0x14[a b → a~/b] truncating int divide
MOD0x15[a b → a%b] Euclidean: result always in [0, b.abs())
NEG0x16[a → -a]
BAND0x17[a b → a&b] ints only
BOR0x18[a b → a|b] ints only
BXOR0x19[a b → a^b] ints only
SHL0x1A[a b → a<<b] shifts ≥ 64 give 0
SHR0x1B[a b → a>>b] arithmetic; ≥ 64 gives 0 or −1
BNOT0x1C[a → ~a] ints only
EQ0x20[a b → bool]
NE0x21[a b → bool]
LT0x22[a b → bool]
LE0x23[a b → bool]
GT0x24[a b → bool]
GE0x25[a b → bool]
NOT0x26[a → !a]
JUMP0x30i16pc += operand (relative to end of operand)
JUMP_IF_FALSE0x31i16[v →] jump when v is false
JUMP_IF_FALSE_K0x32i16peek, jump if false, keep v (for &&)
JUMP_IF_TRUE_K0x33i16peek, jump if true, keep v (for ||)
CALL0x40u16 fn, u8 argc[args… → result] call moth function
NATIVE0x41u16 ref, u8 argc[args… → result] call host native
RET0x42[v →] return top of stack
RET_NULL0x43return null
TO_STRING0x1D[v → text] render as Dart's print would
NEW_LIST0x50u16 count[items… → list]
INDEX_GET0x51[list i → v] bounds-checked
INDEX_SET0x52[list i v → v] bounds-checked
NEW_INSTANCE0x57u16 class[→ obj] fields start null
GET_PROP0x58u16 name[obj → v] field, or .length
SET_PROP0x59u16 name[obj v → v]
INVOKE0x5Au16 name, u8 argc[obj args… → result]
CLOSURE0x5Bu16 fn, u8 captures_this[→ closure] this captured when the flag is set
CALL_VALUE0x5Cu8 argc[callee args… → result] call a function value

INVOKE resolves the method on the receiver's class at run time, since the compiler has no type information. Slot 0 of a method or constructor is the receiver, so its arity is one more than its declared parameter count.

Arithmetic and comparison are numeric-only in M1a: int op int → int (except /), any double operand promotes to double. Type errors are runtime traps (MOTH_ERR_TYPE) carrying the function name and pc, not undefined behavior.

EQ/NE accept any pair; differing types compare unequal, except int/double which compare numerically.

Truthiness

Dart is strict: only true is true. JUMP_IF_FALSE traps on a non-bool condition rather than coercing — this catches the C-programmer reflex of while (1) early and with a clear message.

Calling convention

Caller pushes arguments left to right. CALL creates a frame whose slot i is argument i; slots arity..nlocals-1 start as null. RET pops the frame and leaves exactly one value on the caller's stack. The value stack and the frame stack are both fixed-size (MOTH_STACK_MAX, MOTH_FRAMES_MAX) — overflow is a trap, never memory corruption. There is no recursion limit beyond the frame stack.

Natives

The blob's native table names each host function it needs (gpioSet, delayMs, …) with its argc. At load time the VM resolves every entry against the host's registration table; an unresolved native fails the load with the offending name, rather than trapping later at the call site. This makes "this blob needs a peripheral your board doesn't expose" a startup error.

A native receives (vm, argc, argv, user) and returns a value. It may allocate through the vm handle — its arguments stay on the stack for the duration of the call, so a collection cannot free them underneath it — but it must not re-enter the interpreter.

Trusting a blob

The VM validates what it can at load time: magic and version, every table's bounds, nlocals >= arity, the entry and initializer indices, and — because the class table is read before the function table — a second pass checking that every method and constructor index is in range and has a receiver slot. At run time, operand reads are bounds-checked against the function's code and operand-driven pops are checked against stack depth.

Then every function is verified before anything runs: each is abstractly interpreted along all reachable paths, tracking operand-stack depth. An instruction reached twice must be reached at the same depth, which rejects unbalanced jumps; the same walk checks for underflow and overflow, operand indices, call arities, jumps that land outside the code or off an instruction boundary, and unknown opcodes.

A one-off run of 500 byte-mutated blobs (not yet a committed harness): 457 are refused at load, 9 trap during the run, 34 are harmless (mutations inside constant data), and none crash.

What verification still does not check is types — a program can put text where a number belongs and trap at run time. That is a language-level guarantee moth does not make yet, not a memory-safety hole.

Not yet

Static members, exceptions, async. Closures, inheritance and getters/setters have shipped and are part of the format above.

Versioning

The version bumps on any change an older VM could not run correctly — a new opcode, a changed operand layout, a new section. The loader compares exactly, so a board running an older VM refuses a newer blob at load with a clear message, rather than accepting it and trapping on an unknown opcode partway through. That distinction matters most for hot push, where the blob and the firmware are updated separately and can drift apart.

versionadded
1integers, locals, control flow, calls
2top-level variables
3heap, strings, lists, classes
4OP_CLOSURE, OP_CALL_VALUE
5member_kind byte per class method — a getter and a zero-argument method have identical arity, so the kind is explicit
6assets section — compile-time decoded image pixels, 4-byte aligned to the blob start