Spec M33 -- WebAssembly: an AST-consuming backend, <sys/wasi>, <sys/browser>, examples/wasm
Owner's direction (2026-09-03, last in the queue): wasm examples for the browser and for WASI,
binary .wasm and text .wat, preceded by an architecture sweep saying what the core would need.
This spec is that sweep's verdict plus the design. Nothing wasm-specific enters src/ unless it is
a generic mechanism paid for by more than one target.
1. Verdict -- what the core needs, and what is module work #
Two core changes, both generic, both already owed to other milestones. Everything else is module work.
Core need 1 -- gen_resolve(unit): resolution and typing split out of gen_lower #
src/gen_arm64.mc computes every expression's type as a side effect of AArch64 instruction
selection: 18 set_nd_type calls in gen_binary/gen_ident/gen_cast/gen_unary, while
src/parse.mc stamps a placeholder TY_I64 on every raw identifier. Measured:
build/mc1 --dump-ast tests/024-arena.mc prints IDENT type=i64 name=heap for a u8[4096] and
CALL type=i64 name=alloc for a function returning uptr. A backend trusting those fields emits
i64.div_s where mc means udiv -- a silent wrong answer on exactly what tests/041-udiv.mc
exists to catch, since gen_binary's rule is "only i64 divides with sign", keyed off
nd_type(nd_a(n)). Name resolution, string-literal identification (the nd_op(n) = 1 filter in
gen_sections keeping a reloc() symbol name out of __cstring), literal dedup and every semantic
diagnostic live in the same place.
void gen_resolve(i64 unit); // runs before gen_lower, which then consumes it i64 res_type(i64 n); // resolved type of any expression node i64 res_bind(i64 n); // N_IDENT/N_ASSIGN/N_ADDR: local i, -(global+1), // or FN_BASE + function index i64 res_addr_taken(i64 f, i64 l); // local l of function f is the operand of some & i64 res_fn_addr_taken(i64 fi); // function fi is the operand of some &
Output goes in a side table -- one arena block of nnodes entries allocated by gen_resolve,
not a new node field: ND_SIZE stays 104, dump_node keeps walking nd_a..nd_d as child chains,
no --dump-ast golden moves. Cost ~155 lines in src/, mostly moved rather than written; zero
lines in stage0, which stays the frozen monolithic seed and serves as the oracle.
Two targets: M17 step A's walker cannot ask a machine for m_bin(op, d, d2) without knowing each
depth's type, and M24 says so outright ("the walker tracks the type of each depth"). M28's LSP
go-to-definition is res_bind and hover is res_type; M30's DWARF needs the local-to-slot map.
Land it with M17 step A, under M17's own acceptance (arm64 objects byte-identical after the
refactor), never as an M33-only edit.
Core need 2 -- target(os, arch, obj_backend, exe_backend): a target registry #
src/driver.mc hardcodes the whitelist: i64 drv_linux, if (drv_linux) return "elf-obj"; return
"macho";, toml_err_key("target.os", "only macos and linux"), toml_err_key("target.arch", "only
aarch64"). A wasm backend cannot be selected from mc.toml without putting wasi, browser and
wasm32 into the driver, which the non-negotiable forbids. Same shape as backend(): one linear
table walked in registration order, last registration wins, ~55 lines. Two targets: M17
(arch = "x86_64") and M19/M20 (os = "windows" + COFF) hit the identical wall, and M16's
drv_linux flag is the wart this removes. Fallback if it slips: --backend=wasm on the command
line still works; only mc build and [target] are unreachable.
Zero-cost convention -- // skip-<target>: in the test header #
tests/032-svc.mc already carries // skip-linux: and scripts/test-linux.sh already parses it.
Generalize the parse in scripts/test.sh so // skip-wasm: and M17's // skip-x86_64: share one
mechanism. Shell only, zero compiler lines.
Explicitly NOT core needs #
uptrwidth. 8 bytes on both wasm32 and wasm64 (section 2). No layout changes anywhere.- A "structured control flow view" of the AST. It already is one: the
N_*enum insrc/ast.mchas no unstructured jump,#ruletemplates expand at parse time into core nodes, and every label in the compiler is created insidegen_arm64and dies there. The backend walksN_LOOP/N_IF/N_BREAK/N_CONTINUE/N_RETURNdirectly. MAXPARAMSabove 8. WASI'spath_opentakes nine, but an 8-parameter wrapper pinningfdflags = 0covers everything mc'sopen/creatneed. Windows (CreateProcessW) is the real forcing function; leave it to M19/M20.- A generic two-writer mechanism.
Ins+dump_buf+encodealready is that pattern; the wasm backend declares its own record locally. [run] cmd/argsinmc.toml. Convenient (M16 hardcodesdocker runin a script today), butexamples/wasm/test.shcan invoke the runner directly. Deferred, not blocking.
2. Target decisions, with evidence #
Address width: uptr is 8 bytes on every wasm variant. The prior that memory64 is what secures
this is half wrong: wasm has native i64 arithmetic independent of the memory index space, so
uptr is always an i64 value and only the address operand of a load/store narrows, via one
i32.wrap_i64 from one backend function. Measured: the wasm64 and wasm32 forms of
tests/013-putnum.mc differ in 16 of 58 .wat lines, all mechanical, 372 vs 374 bytes, identical
output (46368, exit 0) under node:wasi. Nothing like M18's 32-bit x86, where i64 itself needs
register pairs. ND_SIZE 104 and every ld64/st64 layout in the compiler and lib/: untouched.
Default arch = "wasm32"; wasm64 opt-in. Re-verified on this machine:
| engine | memory64 | wasm32 (with i64 values) |
|---|---|---|
| node v24.16.0 (V8 13.6) | runs, no flag | runs |
| Chromium 148 (in-app browser) | runs, no flag | runs |
JavaScriptCore / Safari 26 (jsc) | validate = false; --useWasmMemory64=true validates but still rejects an i64 data offset | validate = true, no flags |
| wat2wasm 1.0.41 | accepts, no flag | accepts |
| wasm-validate / wasm2wat 1.0.41 | needs --enable-memory64 | no flag |
A memory64 browser example therefore does not run in Safari today; a wasm32 one runs everywhere.
Supporting both costs one function, so both ship and the default is the portable one. If a runtime
lacks memory64 the answer is "use wasm32", never "shrink uptr".
WASI: preview1 (wasi_snapshot_preview1). Frozen, implemented by node:wasi, and 32-bit by
definition -- every pointer handed to a WASI import goes through i32.wrap_i64 and the module keeps
its addresses below 4 GiB (a stated layout invariant, trivially satisfied). preview2 / the component
model is wasm32-only and out of scope.
Tooling present: wabt 1.0.41 (wat2wasm, wasm2wat, wasm-validate, wasm-objdump), node
v24.16.0 with node:wasi, wasm-ld (present but unused -- mc writes a complete module, so
M11's "no linker" property is free here), the in-app Chromium, and jsc. Missing, not
installed: wasmtime, wasm-tools, deno. So scripts/check-wasm.sh requires only node,
prefers wasmtime when present, and self-skips otherwise -- the pattern the Makefile already uses
for test-linux without ld.lld; wat2wasm gates only the .wat half and self-skips too.
docs/plan.md's M33 acceptance names wasmtime; either CI installs it or the line reads
"wasmtime, else node".
3. Backend design #
Four .mc files, none touching stage0: src/wasm_write.mc (LEB128 + section framing, ~380),
src/backend_wasm.mc (layout, imports, entry, address width, errors, ~520), src/wasm_walk.mc
(the AST walk and selection, ~640), src/wasm_text.mc (the .wat writer, ~260) -- about 1800
lines of .mc. src/main.mc registers wasm (binary) and wasm-text next to the existing three.
Phases: gen_resolve(unit); wa_collect (function indices, type dedup, imports, funcref table and
element segment, data segments, memory size); wa_walk (one recursive descent per N_FUNC into a
flat WIns { op, a, b, imm } buffer); wa_write / wa_text (two sinks over that buffer).
gen_lower is never called and no Ins is built -- wasm has no relocations, so src/macho.mc's
object model is bypassed entirely.
Value model. The wasm operand stack is mc's depth stack, so MAXDEPTH, spills and
save_live/restore_live disappear (tests/016-spill.mc's depth-14 expression needs no spill at
all -- verified). A scalar local or parameter with res_addr_taken == 0 becomes a wasm local; an
array (N_VAR with nd_val != 0) or an address-taken scalar is demoted to a shadow frame in
linear memory at the offset the resolver computed -- not hypothetical, the compiler's own source
uses uptr src = read_file(path, &len) throughout. Prologue
global.set $sp (local.tee $fp (i64.sub (global.get $sp) (i64.const F))), epilogue restores; &x
is i64.add $fp, off; an address-taken parameter is copied into its slot on entry; F == 0 emits
no frame. return from a framed function is local.set $ret; br $epi inside a (block $epi ...)
wrapper -- what gen_func's lepi already does.
Memory. [0, 1024) null guard; [1024, STACK_TOP) shadow stack growing down; then __cstring
and __data as data segments and __bss as reserved space (wasm memory is born zeroed, so
tests/020 and tests/030's zerofill are right for free). Initial pages = ceil(top / 65536). On
wasm64 the data segments are passive plus memory.init from a start function -- JSC rejects
an active segment with an i64 offset (verified); wasm32 uses active segments. memory.grow is the
wasm form of M23's mmap (verified from JS and from inside a module).
Control flow. No new mechanism: a label stack of (kind, level), br = level - target_level.
if frames occupy a level too, so from inside an (if) in the inner of two loops break is
depth 2 and break 2 is depth 4 -- verified against tests/015-break-n.mc. Getting this wrong
yields a module that parses, validates and jumps to the wrong place, so it earns its own test.
| mc | wasm |
|---|---|
loop { B } | (block $brk (loop $cnt B (br $cnt))) |
break / break N | br to the Nth enclosing $brk |
continue | br $cnt |
if (c) A else B | (if (then A) (else B)) |
return e | return, or local.set $ret; br $epi when framed |
a && b / a || b | (if (result i64) ...) with the short-circuit constant in the other arm |
Operators and types. u8 u16 u32 u64 i64 uptr all map to i64. / % >> pick _s/_u from
res_type(nd_a(n)), mc's actual rule. Comparisons are always signed in mc, so lt_s/le_s/gt_s/ge_s
unconditionally; they yield i32, re-widened with i64.extend_i32_u (dropped by peephole when the
result feeds a condition directly). Casts to u8/u16/u32 are i64.and MASK. ld8/16/32/64 are
i64.load8_u/load16_u/load32_u/load and st8..st64 are i64.store8/16/32/store -- zero-extend and
truncate semantics match tests/014-ldst.mc exactly. Shifts match exactly: mc/arm64 and wasm
both mask the count mod 64 (1 << 65 == 2, -8 >> 65 == -4, verified on both sides).
Division diverges, and is documented rather than guarded. Measured with build/mc1 --exe:
mc/arm64 gives 7/0 = 0, 7%0 = 7, INT64_MIN / -1 = INT64_MIN; wasm div_s/div_u/rem_s/
rem_u trap in all three. Guarding costs ~6 instructions per division and makes wasm the odd
target out; mc's behaviour is an accident of the AArch64 ISA, not a designed semantic, so a trap is
the better answer. Goes in docs/core-language.md beside "comparisons are always signed".
Calls, &fn, callp. A defined function is call idx, an extern a call on an imported
index. &fn is a funcref table index, index 0 left null so &fn is never 0. Every
address-taken function gets one thunk of the single canonical type (i64 x7) -> i64 calling the
real function with its real arity, pushing i64.const 0 for a void callee; every callp site
zero-pads to 7 arguments and does call_indirect against that one type -- which covers
tests/060-callp.mc's mixed 1-and-7 arity with zero core change and makes the runtime type check
unfailable. &fn in a global initializer is the literal index in the data segment, no relocation,
because the backend owns the layout as backend_exe.mc already does for R_UNSIGNED. To document:
function and data uptrs are disjoint numeric spaces, so ld8(&fn) is meaningless. Do not add an
fptr type; that breaks the single-opaque-uptr decision.
Imports and exports. Every N_EXTERN becomes an import; the module name comes from mechanisms
that already exist -- #dylib "wasi_snapshot_preview1", or [libs] + [externs] read through the
public dylib_count()/dylib_path(i)/extern_lib_find(name) that src/backend_exe.mc uses for
ordinals -- defaulting to env. WASI preview1's i32-pointer signatures are a fixed table of ~14
entries inside src/backend_wasm.mc: preview1 is frozen, so hard-coding it is honest and keeps
src/ free of wasm mechanism. A naive all-i64 import table fails at instantiation
(Cannot convert 28 to a BigInt, verified), which is what makes that table load-bearing. Exports
strip the compiler's leading _, as backend_elf.mc already does (__start -> _start, _main
-> main); memory is exported too.
Writers. Binary: magic, version, then sections in canonical order -- type(1) import(2)
function(3) table(4) memory(5) global(6) export(7) start(8) element(9) code(10) data(11), plus
datacount(12) when passive. Each is id, uleb(size), payload, payloads built into a growable buffer
and framed on close; LEB128 (uleb32, uleb64, sleb64, the i33 block type) written byte by
byte through buf_u8, never a struct write. A prototype of exactly this writer was written in
.mc, compiled by the real mc1 --exe, and produced a 61-byte fib module node ran
(fib(24) = 46368n) -- the byte-level half needs nothing new from the language. Text: the same
WIns buffer in flat (non-folded) form with explicit end, so the .wat is a literal transcript
of the byte stream and the two sinks cannot drift structurally; --backend=wasm-text -o /dev/stdout
is the audit view, so no --dump- registry is needed.
Directives with no meaning. emit(), reloc() and #opcode are refused with a positioned
err_node ("emit() has no meaning on a wasm target"). No wasm-specific raw form: a validated,
typed, variable-length instruction stream is a different thing from a 32-bit word, and M24's
#machine ARCH task ENCODING is the mechanism reserved for naming instructions per architecture.
#section naming a data segment becomes a linear-memory region; naming a code segment is accepted
and ignored (wasm has no code placement), which is what lets tests/030 pass.
Determinism. Types deduped in first-use order through a parallel array (never a hash walk);
imports in declaration order; functions in gen_func order; strings in the core's existing dedup
order; shortest-form LEB; no dates, paths or pointer hashing; no padding. All rules of
docs/determinism.md carry over unchanged; mc1 vs mc2 byte-identity of the .wasm is the check.
4. <sys/wasi> and <sys/browser> #
lib/sys_wasi.mc (~190 lines) -- the sibling of lib/sys_linux.mc with no #opcode at all; a
hosted target's system layer is extern declarations and nothing else. Imports: fd_write,
fd_read, fd_close, path_open, args_sizes_get, args_get, proc_exit. write(fd, b, n)
builds a {u32 base, u32 len} iovec plus a 4-byte nwritten on the shadow stack, calls fd_write,
returns nwritten or -errno (WASI returns errno, not -1); read is symmetric; close is
fd_close; exit is proc_exit. O_* take the WASI oflags (CREAT 1, DIRECTORY 2, EXCL 4,
TRUNC 8), not the macOS or Linux ones -- lib/io.mc already says those constants are per-system and
belong in the sys file, and lib/io.mc itself needs no change. Three details, each verified by
running a hand-written module:
path_opentakes nine parameters and mc refuses at eight; the backend synthesizes an 8-parameter wrapper pinningfdflags = 0, enough foropen/creatsince the language has noO_APPEND/O_NONBLOCKto pass.path_openneeds a preopened directory fd (3, 4, ...) and a relative path.fs_rights_base = -1is refused withERRNO_NOTCAPABLE;0x26(FD_READ|FD_SEEK|FD_TELL) withdirflags = 1works.tests/025-linecount.mcopens its own source, so the runner passes--dir ..args_getwrites an array of u32 pointers while mc'smain(i64 argc, uptr argv)doesld64(argv)._startrepacks into u64 slots plus a NULL terminator (~12 lines) and emits theargs_sizes_get/args_getprelude only whenmaintakes two parameters.
lib/sys_browser.mc (~110 lines) plus ~90 lines of JS glue. No WASI: the host is ours, so the
imports are plain i64 (env.mc_write, mc_read, mc_exit, mc_now, mc_grow) with no adapter
table. The glue keeps instance.exports.memory and decodes with
new Uint8Array(mem.buffer, Number(p), Number(n)) -- on wasm64 the pointer arrives as a BigInt,
the one ergonomic wrinkle worth documenting.
Not expressible in either layer: everything built on #opcode (lib/sys_svc.mc and
lib/sys_linux.mc have no wasm counterpart and need none), mmap (use memory.grow), sockets
(preview2), and posix_spawnp/waitpid -- so mc build cannot run under wasm, since it spawns
the linker and the taught compiler. M33 is a cross-compilation target: mc runs on macOS, emits wasm.
5. examples/wasm #
examples/wasm/ README.md mc.toml [target] os = "wasi" arch = "wasm32" out = build/hello.wasm mc.browser.toml [target] os = "browser" arch = "wasm32" out = build/page.wasm wasi/main.mc putnum-style CLI: fib(24), argv echo, exit code wasi/count.mc the small file tool: open + read + count lines, via a preopen web/main.mc the browser module: writes through env.mc_write, returns 42 web/index.html the page web/glue.js instantiate, decode strings out of exports.memory, console + DOM test.sh make check-wasm
test.sh follows examples/lang/test.sh: build both targets with mc build, then (1)
wasm-validate each module (--enable-memory64 on a wasm64 build), (2) round-trip
wat2wasm build/hello.wat and check the result behaves identically, (3) run the WASI half under
wasmtime --dir . if present, else node run.mjs with node:wasi and a preopen, comparing stdout
and exit code, (4) serve web/ locally and load it in the in-app browser, asserting the page's own
RESULT: PASS line. Every optional tool is guarded by command -v and prints a skip message rather
than failing, as Makefile's test-linux guard does; CI (macos-15, bare) needs only node.
6. Suite mapping #
29 of 32 run. 001 002 003 010 011 012 013 014 015 016 020 021 022 023 024 025 030 040 041 042
043 050 051 052 053 054 056 060 061. Two conditions: 025-linecount needs the runner's preopened
directory, and 030-section passes only because code-segment placement is accept-and-ignore (its
__DATA,__tbl regular and __DATA,__zt zerofill halves map cleanly and its __TEXT,__hot function
is simply called). 3 skipped, each getting a // skip-wasm: header: 031-opcode, 032-svc,
033-reloc -- they need a 32-bit AArch64 word stream, which a stack machine does not have.
Hand-verified end to end before writing this spec, as memory64 .wat run under node:wasi:
013 (46368, exit 0), 015 (break 2 through an if frame, exit 42), 016 (no spill, exit 42),
021 (string dedup, hello, exit 0), 024 (bss + data + arena, exit 42), a 025-shaped
path_open + fd_read (26 lines), 060 (&fn + call_indirect, exit 42), an &local
out-parameter case (exit 42), an argv repack, and the browser module in Chromium and in JSC.
7. Acceptance #
gen_resolvelands first, on its own commit, green:check-obj32/32 andcheck-asmbyte-identical againstbuild/mc0as the frozen oracle,check-ast/check-lexunchanged,bootstrapfixed point (mc2.o == mc3.o), golden rewritten at most once. No wasm code yet.make check-wasm: every non-skipped test compiled to.wasmand to.wat, both validated, both run, stdout and exit code equal to the header -- 29/29, with the three skips printed.- The
.watpath round-trips:wat2wasm out.watproduces a module that validates and behaves identically. Byte-identity with our own.wasmis the goal and is achievable (an existing 372-byte module survivedwasm2wat | wat2wasmunchanged) but depends on our writer matching wabt's LEB widths and type-dedup order -- claim it only once demonstrated; behaviour is the contractual check. examples/wasm/test.shgreen: the WASI CLI runs and the browser page reports PASS in the in-app browser. Determinism:mc1andmc2produce byte-identical.wasmfor the whole corpus.- stage0 untouched;
make checkgreen; golden rewritten once.
8. Decisions (architect, 2026-09-03) #
- Default
wasm32,wasm64opt-in ([target] arch = "wasm32" | "wasm64");uptris 8 bytes on both (only the address operand narrows). The plan row is reworded accordingly. - Division traps on wasm; the three divergent cases (
x/0,x%0,MIN/-1) are documented in the guide and indocs/reference/language.md; no guard knob. - Runtimes:
node(withnode:wasi) is the required runner forcheck-wasminmake checkand CI;wasmtimeis used when present;wat2wasm/wasm-validate(wabt) optional for the round-trip check, with--enable-memory64when the target is wasm64. gen_resolveships with M17 step A (typed side table;ND_SIZEunchanged), so M33 starts after M17 step A lands; the target registrytarget(os, arch, obj_backend, exe_backend)also lands in M17 and is reused by M19/M20/M33.[run]inmc.toml(cmd,argswith{out}placeholders) lands with M33 and is adopted bytest-linux.sh(Docker) andcheck-wasm.sh(node).