Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

wasmz logo

Introduction

wasmz is a WebAssembly runtime written in Zig, designed to be fast, compact, and easy to embed. It implements a full-featured interpreter with support for modern WebAssembly proposals.

Performance

In my benchmarks, wasmz is currently the fastest WebAssembly interpreter I have tested. You can view the full benchmark report here: https://ray-d-song.github.io/wasmz/bench.html.

The benchmark chapter also includes notes on the parser and interpreter optimizations used in wasmz.

If you find workloads where wasmz has a clear disadvantage, please let me know. I will do my best to optimize them :)

Real-World Testing

wasmz passes real-world WebAssembly module tests:

  • esbuild - JavaScript bundler compiled to WASM
  • QuickJS - Lightweight JavaScript engine compiled to WASM
  • SQLite - Database engine compiled to WASM

These integration tests validate wasmz’s compatibility with production WASM workloads.

WebAssembly Support

wasmz supports all current WebAssembly proposals.

  • MVP - All core instructions and validation rules
  • Multi-value - Functions and blocks with multiple return values
  • Bulk operations - Memory and table bulk operations
  • Sign-extension - Sign-extension instructions
  • GC - Structs, arrays, and reference types with automatic memory management
  • SIMD - 128-bit vector operations
  • Exception Handling - Both legacy and new proposal formats
  • Threading - Shared memory and atomic operations

WASI Support

WASI Preview 1 - Full implementation

  • File system operations (fd_read, fd_write, path_open, etc.)
  • Socket operations (sock_accept, sock_recv, sock_send, etc.)
  • Environment variables and arguments
  • Random number generation
  • Process control (proc_exit, proc_raise)
  • Clock and time operations
  • Polling (poll_oneoff)

Embedding

  • Zig API - Native Zig interface with full type safety
  • C API - Minimal C ABI for embedding in any language
  • CLI tool - Standalone command-line runner

License

MIT License - see LICENSE file for details.

Benchmark Report: wasmz vs wasmi vs wasm3 vs wamr

Date: 2026-04-16 09:54 OS: Linux 6.17.0-1010-azure x86_64 Runs per benchmark: 20 (warmup: 5)

Versions

RuntimeVersion
wasmzdev (ReleaseFast)
wasmiwasmi 2.0.0-beta.2
wasm3Wasm3 v0.5.1 on x86_64
wamriwasm 2.4.3

Binary Size

RuntimeSize
wasmz892.6 KB
wasmi7.0 MB
wasm3466.3 KB
wamr344.8 KB

Execution Time (median ms) — lower is better

fib(30) — pure C compiled to WASM

RuntimeMedian (ms)± stddev
wasmz37.0± 1.5
wasmi38.4± 0.6
wasm339.7± 1.0
wamr49.6± 1.7

QuickJS fib(25) — JS engine running inside WASM (1.4 MB module)

RuntimeMedian (ms)± stddev
wasmz174.9± 3.4
wasmi184.4± 2.9
wasm3217.4± 8.2
wamr242.9± 4.5

esbuild — JS bundler running inside WASM (19 MB module)

Note: wamr is excluded from esbuild tests because it does not support stdin input and causes stack overflow with large workloads.

RuntimeMedian (ms)± stddev
wasmz909.5± 12.5
wasmi918.2± 19.9
wasm32215.0± 19.5

Peak RSS (memory) — lower is better

Peak RSS = highest resident set size seen at any point during the run. Avg RSS = time-weighted mean RSS sampled every 100 ms during one run (reflects actual memory consumption over the process lifetime, not just the spike).

fib(30)

RuntimePeak RSSAvg RSS
wasmz17.3 MB8.7 MB
wasmi21.9 MB11.0 MB
wasm318.9 MB9.5 MB
wamr11.0 MB5.4 MB

QuickJS fib(25)

RuntimePeak RSSAvg RSS
wasmz1.8 MB1.2 MB
wasmi1.8 MB1.2 MB
wasm31.8 MB1.2 MB
wamr1.8 MB1.4 MB

esbuild bundling

Note: wamr is excluded due to stdin/stack limitations.

RuntimePeak RSSAvg RSS
wasmz1.8 MB1.6 MB
wasmi1.8 MB1.6 MB
wasm31.8 MB1.7 MB

Performance Optimizations

The benchmark results above are the product of a series of targeted optimizations applied to wasmz’s compiler and runtime. This section documents each technique.

Register-Based IR (Stack-to-Register Lowering)

WebAssembly is a stack machine. wasmz translates every function’s stack bytecode into a flat array of typed register-IR ops during compilation. Each op carries explicit source and destination slot indices (16-bit unsigned), eliminating the push/pop bookkeeping that stack interpreters must perform at runtime.

Direct Threaded Code Dispatch

Rather than a traditional switch/case bytecode loop typical of high-level interpreters, wasmz uses direct threaded code (inspired by Marr et al., 2023). Each encoded instruction contains an 8-byte handler pointer followed by its operands. After executing each handler, the next() macro issues a tail call directly to the next handler via its pointer, eliminating the overhead of:

  • An explicit loop-condition check at the top of the bytecode loop
  • A single branch-prediction site (which saturates on complex control flow)

This approach spreads branch prediction across multiple dispatch points, improving predictor accuracy on modern CPUs.

r0 and fp0 Accumulator Registers

Inspired by the Wasm3 M3 architecture, wasmz maintains two accumulator registers:

  • r0 — holds the most recent i32/i64 result
  • fp0 — holds the most recent f32/f64 result (f32 values are bit-cast to f64)

Handlers that produce a numeric result write it to both the accumulator and the destination slot. This allows the CPU to keep the top-of-stack value in a real hardware register across instruction boundaries, avoiding a slot load on every back-to-back arithmetic instruction. The *_imm_r variants and other fusions leverage this by reading from r0 implicitly.

Superinstructions (Instruction Fusion)

The compiler performs a single forward pass over the IR and merges common multi-op patterns into one instruction. This reduces the total number of dispatched operations and removes redundant slot reads/writes.

The fused families currently implemented are:

LabelPatternFused Op
Cconst + binopbinop_imm — immediate rhs embedded in the instruction
Dbinop + local_setbinop_to_local — result written directly to a local slot
Econst + binop + local_setbinop_imm_to_local
Fcompare + jump_if_zcompare_jump_if_false — one dispatch for test-and-branch
Gconst + compare + br_ifcompare_imm_jump_if_false
Hlocal_get + binop_imm + local_set (same local)local_inplace — mutates local in-place, no temp slot
Ibinop + retbinop_ret — compute and return in one dispatch
Jcompare_jump_if_false + jumpcompare_jump_if_true
Kcopy + jump_if_nzcopy_jump_if_nz — essential for br_if with a result value

Additional local-slot specialized fusions:

  • binop_tee_local — writes the result to both a stack slot and a local (local.tee pattern)
  • cmp_to_local — comparison result written directly to a local slot
  • const_to_local — constant written directly to a local slot
  • imm_to_local — superinstruction combining a constant-to-temp with a copy-to-local, preserving the source slot for downstream use
  • load_to_local — i32/i64 memory load result written directly to a local
  • global_get_to_local — global read result written directly to a local
  • call_to_local — direct call result written directly to a local slot (saves one dispatch vs call + local_set)

r0 Accumulator Variants

For long chains of const + binop_imm sequences (common in tight loops), the compiler tracks an accumulator register r0. When the previous instruction’s destination matches the next instruction’s source, the lhs slot field is elided from the encoding, producing *_imm_r variants. This shrinks the instruction and saves one memory load per dispatch.

call_leaf Superinstruction

When a direct-call site is proven to target a leaf function (a function that makes no further calls itself) whose result is not used (void call), the compiler emits call_leaf instead of call. The VM handler skips result-slot setup and the return-value copy path entirely, reducing call overhead on hot void-dispatches.

Slot Recycling

During lowering, temporary slots created for intermediate values are recycled once their last use is seen. This reduces the total slots_len per compiled function, which directly shrinks the value-stack frame allocated at call time.

Lazy Compilation

Functions are compiled on their first invocation rather than all at once at Module.compile() time. For large modules (esbuild is 19 MB), this makes startup near-instant and amortises compilation cost over actual execution. The --eager-compile flag or Config.eager_compile = true opts into up-front compilation when predictable latency matters more than startup time.

mmap-Based Memory

Two mmap optimizations reduce peak RSS:

  1. File mapping — the .wasm file is memory-mapped rather than heap-copied. Pending (uncompiled) function bodies borrow slices directly from the mapped region, so the bytecode is never duplicated in the heap until compilation.

  2. Virtual reservation for linear memory — when allocating WebAssembly linear memory, wasmz reserves a large virtual address range with mmap(PROT_NONE) and then commits pages with mprotect as the module calls memory.grow. This avoids the RSS spike that realloc produces when doubling a backing buffer.

Lazy GC Heap Initialization

The GC heap inside Store is not allocated until the first GC-typed value is actually created. Modules that use only numeric types (MVP, no GC proposal) never touch the allocator, keeping RSS minimal.

Lazy WASI Initialization

The WASI Preview 1 host is only instantiated when the module’s import table contains at least one wasi_snapshot_preview1 import. Pure compute modules pay no initialization overhead.

Slot Width Reduction (u32 → u16)

All slot indices were narrowed from 32-bit to 16-bit integers. This halves the per-instruction slot storage for the most common instruction layouts, improving cache utilization in the hot interpreter loop.

Handler Ordering (Future Work)

Recent research has shown that the order of bytecode handler definitions in memory affects CPU branch-prediction performance, with potential speedups of 7–23% on specific benchmarks. Genetic algorithms can search for near-optimal orderings tailored to specific workloads and CPU architectures.

wasmz does not yet implement handler reordering, but the architecture (direct threaded code with multiple dispatch sites per handler) is well-suited for such optimization. The decision to prioritize other techniques first (superinstructions, accumulator registers, lazy compilation, mmap) reflects a pragmatic tradeoff: the gains from reducing dispatch count via fusion exceed what handler ordering alone typically provides, and fusion benefits apply uniformly across all workloads.

Getting Started

This chapter covers installing wasmz and running your first WebAssembly program.

Quick Start

# Clone and build
git clone https://github.com/anomalyco/wasmz.git
cd wasmz
zig build

# Run a WASM file
./zig-out/bin/wasmz examples/hello.wasm

# Call a specific function
./zig-out/bin/wasmz examples/add.wasm add 3 4

Next: Installation for detailed setup instructions.

Installation

Install from Release

Install the latest published GitHub release directly into your user directory:

Linux / macOS

curl -fsSL https://raw.githubusercontent.com/Ray-D-Song/wasmz/main/scripts/install.sh | bash

By default, this installs wasmz to ~/.local/bin.

Windows

powershell -ExecutionPolicy Bypass -c "iwr https://raw.githubusercontent.com/Ray-D-Song/wasmz/main/scripts/install.ps1 -UseBasicParsing | iex"

By default, this installs wasmz.exe to %LOCALAPPDATA%\wasmz\bin.

You can also download the installer scripts and run them locally:

./install.sh --help
powershell -ExecutionPolicy Bypass -File .\install.ps1

These scripts install the latest stable GitHub release by default, and also support installing a specific tag.

Prerequisites

  • Zig 0.15.2 - Download from ziglang.org
  • Git - For cloning the repository
  • make - For build commands

Build from Source

# Clone the repository
git clone https://github.com/anomalyco/wasmz.git
cd wasmz

# Build (ReleaseSafe - recommended for development)
make build

# Build for debugging
make build-debug

# Build for maximum performance
make release

The binary will be at zig-out/bin/wasmz.

Build Options

CommandDescription
make build-debugDebug build (unoptimized, fast compile)
make buildReleaseSafe build (optimized, safety checks)
make releaseReleaseFast build (maximum performance)
make testRun all unit tests
make clibBuild C shared library

Build Mode Differences

The build mode affects panic handling in the CLI binary:

ModePanic HandlerBinary SizeBacktrace
DebugFull panic handlerLarger✅ Readable stack trace
ReleaseSafeFull panic handlerLarger✅ Readable stack trace
ReleaseFastMinimal panic handler~127 KB smaller❌ No backtrace
ReleaseSmallMinimal panic handler~127 KB smaller❌ No backtrace

Recommendation: Use make build (ReleaseSafe) for development - it provides optimizations while keeping safety checks and readable error messages. Use make release (ReleaseFast) for production deployments where binary size matters.

Running Tests

# Run all unit tests
make test

# Run integration tests (requires building fixtures first)
zig build sqlite-wasm    # Build SQLite WASM fixture
zig build test-sqlite    # Run SQLite integration tests

Installation

Install to ~/.local/bin:

# Install debug build
make install

# Install release build
make install-release

After installation, ensure ~/.local/bin is in your PATH.

C Library

Build the C shared library for embedding:

make clib

Output files:

  • zig-out/lib/libwasmz.so (Linux)
  • zig-out/lib/libwasmz.dylib (macOS)
  • zig-out/lib/libwasmz.dll (Windows)
  • zig-out/include/wasmz.h (header)

Verify Installation

# Check the CLI
wasmz --help

# Or run directly
./zig-out/bin/wasmz --help

# Run a simple test
echo '(module (func (export "add") (param i32 i32) (result i32) local.get 0 local.get 1 i32.add))' > test.wat
wat2wasm test.wat
wasmz test.wasm add 1 2
# Output: 3

CLI Usage

The wasmz command-line tool runs WebAssembly modules directly from the terminal.

Basic Usage

# List exported functions
wasmz <file.wasm>

# Call a function with i32 arguments
wasmz <file.wasm> <func_name> [i32_args...]

# Run _start with WASI arguments
wasmz <file.wasm> --args "<wasm_args...>"

Examples

List Exports

$ wasmz module.wasm
Exported functions:
  add
  multiply
  greet

Call a Function

# Call "add" with arguments 3 and 4
$ wasmz module.wasm add 3 4
7

WASI Command Model

# Run _start with arguments passed to the WASM module
$ wasmz program.wasm --args "--verbose --output=result.txt"

Reactor Mode

# Initialize reactor and call a function
$ wasmz library.wasm --reactor --func process -- input.txt

Flags

FlagDescription
--legacy-exceptionsUse legacy exception handling proposal
--args "<string>"Arguments to pass to WASM module (space-separated)
--func <name>Exported function to call
--reactorCall _initialize before the function
--mem-statsPrint memory usage after execution
--mem-tracePrint RSS snapshots at each execution phase
--mem-limit <MB>Set memory limit in megabytes
--eager-compileCompile all functions eagerly at load time
--smart-compileAuto-select compile mode: eager for modules < 3 MB, lazy otherwise

Memory Statistics

Use --mem-stats to analyze memory usage:

$ wasmz program.wasm --mem-stats
Memory usage:
  Linear memory:  1.00 MB  (16 pages)
  GC heap:        0.12 MB  (used 45.2 KB / capacity 128.0 KB)
  Shared memory:  0.00 MB  (none)
  ────────────────────────────────
  Total:          1.12 MB

Memory Tracing

Use --mem-trace to print RSS snapshots at each execution phase (open, compile, instantiate, run):

$ wasmz program.wasm --mem-trace
[mem-trace] baseline (file mapped)    RSS 12.3 MB  (+12.3 MB)
[mem-trace] after compile             RSS 18.7 MB  (+6.4 MB)
[mem-trace] after instantiate         RSS 20.1 MB  (+1.4 MB)
[mem-trace] after _start              RSS 21.5 MB  (+1.4 MB)

Set the WASMZ_PHASE_DIAG=1 environment variable to print detailed wall-clock timing for each phase to stderr:

$ WASMZ_PHASE_DIAG=1 wasmz program.wasm
[phase-diag] wasmz exit=_start return
[phase-diag]   open+mmap     :    0.342 ms
[phase-diag]   compile       :   12.101 ms
[phase-diag]   store+linker  :    0.082 ms
[phase-diag]   instantiate   :    1.203 ms
[phase-diag]   runStart      :    0.004 ms
[phase-diag]   _start        :  245.881 ms
[phase-diag]   total         :  259.613 ms

Compilation Modes

By default, wasmz compiles functions lazily (on first call). Two flags control this behaviour:

FlagDescription
--eager-compileCompile every function during module load. Higher startup cost, zero lazy overhead at runtime.
--smart-compileAutomatically choose: eager for modules < 3 MB, lazy otherwise (good default for interactive use).

Error Handling

When a trap occurs, wasmz prints the trap message:

$ wasmz module.wasm divide 1 0
error: trap: integer divide by zero

Exit Codes

CodeMeaning
0Success
1Error (file not found, compilation error, etc.)
NWASI proc_exit code (if called by module)

Zig API

This section documents the Zig API for embedding wasmz in your applications.

Overview

const std = @import("std");
const wasmz = @import("wasmz");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Create engine
    var engine = try wasmz.Engine.init(allocator, .{});
    defer engine.deinit();

    // Compile module
    const bytes = try std.fs.cwd().readFileAlloc(allocator, "module.wasm", 1024 * 1024);
    defer allocator.free(bytes);

    var module = try wasmz.Module.compile(engine, bytes);
    defer module.deinit();

    // Create store and instance
    var store = try wasmz.Store.init(allocator, engine);
    defer store.deinit();

    var instance = try wasmz.Instance.init(&store, module, .empty);
    defer instance.deinit();

    // Call function
    const result = try instance.call("add", &.{ .{ .i32 = 1 }, .{ .i32 = 2 } });
    std.debug.print("Result: {d}\n", .{result.ok.?.readAs(i32)});
}

Core Types

TypeDescription
EngineRuntime engine with configuration
ConfigEngine configuration options
ModuleCompiled WebAssembly module
StoreRuntime context for instances
InstanceInstantiated module with memory/globals
LinkerHost function registry
HostFuncHost-provided callable
RawValGeneric value (i32/i64/f32/f64)
ExecResultExecution result (ok or trap)
TrapRuntime trap with code

Next Steps

Engine & Config

The Engine is the core runtime that manages compilation, configuration, and shared resources.

Engine

Initialization

const wasmz = @import("wasmz");

// Default configuration
var engine = try wasmz.Engine.init(allocator, .{});
defer engine.deinit();

// With configuration
var engine = try wasmz.Engine.init(allocator, .{
    .legacy_exceptions = true,
    .mem_limit_bytes = 256 * 1024 * 1024, // 256 MB limit
});
defer engine.deinit();

Methods

MethodDescription
init(allocator, config)Create engine with config
deinit()Free engine resources

Config

Configuration options for the engine:

const Config = struct {
    /// Use legacy exception handling proposal
    legacy_exceptions: bool = false,
    
    /// Memory limit in bytes (null = unlimited)
    mem_limit_bytes: ?u64 = null,

    /// When true, all local functions are compiled up front during Module.compile()
    /// instead of lazily on first call. Trades higher startup cost for zero
    /// lazy-compilation overhead at runtime.
    eager_compile: bool = false,
};

Options

FieldTypeDefaultDescription
legacy_exceptionsboolfalseUse legacy EH proposal
mem_limit_bytes?u64nullMax memory allocation
eager_compileboolfalseCompile all functions at load time

Example: Memory Limit

var engine = try wasmz.Engine.init(allocator, .{
    .mem_limit_bytes = 128 * 1024 * 1024, // 128 MB
});

Example: Legacy Exceptions

For modules using the older exception handling proposal:

var engine = try wasmz.Engine.init(allocator, .{
    .legacy_exceptions = true,
});

Example: Eager Compilation

Compile all functions up front for zero lazy overhead at runtime (useful for small modules or batch workloads):

var engine = try wasmz.Engine.init(allocator, .{
    .eager_compile = true,
});

Thread Safety

  • Engine - Reference counting is thread-safe (uses zigrc.Arc). Multiple threads can clone/deinit independently.
  • Config - Immutable after creation, safe to share across threads.

Note: While reference counting is atomic, concurrent access to the same Engine instance (e.g., calling config()) is not synchronized. Create separate Engine instances per thread if needed.

Lifecycle

The engine must outlive any stores or modules created from it:

var engine = try wasmz.Engine.init(allocator, .{});
defer engine.deinit(); // Must be called after store/module deinit

var store = try wasmz.Store.init(allocator, engine);
defer store.deinit();

var module = try wasmz.Module.compile(engine, bytes);
defer module.deinit();

Module

A Module represents a compiled WebAssembly module (read-only).

Compilation

Basic Compilation

const bytes = try std.fs.cwd().readFileAlloc(allocator, "module.wasm", max_size);
defer allocator.free(bytes);

var module = try wasmz.Module.compile(engine, bytes);
defer module.deinit();

Arc Module (Reference Counted)

For sharing modules across multiple instances:

// Compile with reference counting
var arc_module = try wasmz.Module.compileArc(engine, bytes);
defer if (arc_module.releaseUnwrap()) |m| {
    var mod = m;
    mod.deinit();
};

// Retain for each instance
var instance = try wasmz.Instance.init(&store, arc_module.retain(), linker);

Methods

MethodDescription
compile(engine, bytes)Compile module from bytes
compileArc(engine, bytes)Compile with ref counting
deinit()Free module resources
retain()Increment ref count (Arc only)
releaseUnwrap()Decrement ref count (Arc only)

Module Information

Exports

var iter = module.exports.iterator();
while (iter.next()) |entry| {
    std.debug.print("Export: {s}\n", .{entry.key_ptr.*});
}

// Check for specific export
if (module.exports.get("_start")) |_| {
    // Command module
}

Imports

for (module.imported_funcs) |import| {
    std.debug.print("Import: {s}::{s}\n", .{ import.module_name, import.func_name });
}

Validation

Modules are validated during compilation. Errors are returned:

var module = wasmz.Module.compile(engine, bytes) catch |err| {
    switch (err) {
        error.InvalidWasm => std.debug.print("Invalid WASM binary\n", .{}),
        error.OutOfMemory => std.debug.print("Out of memory\n", .{}),
        else => return err,
    }
    return;
};

Module Types

Command vs Reactor

TypeEntry PointDescription
Command_startRuns once, may call proc_exit
Reactor_initializeLibrary, multiple function calls
if (module.exports.get("_start")) |_| {
    // Command module
} else if (module.exports.get("_initialize")) |_| {
    // Reactor module
}

Thread Safety

  • Module - Not thread-safe for concurrent access.
  • ArcModule - Reference counting is thread-safe (uses zigrc.Arc). Multiple threads can safely call retain()/releaseUnwrap().

While ArcModule’s reference counting is atomic, the underlying Module data should not be accessed concurrently. Create separate ArcModule handles per thread and avoid sharing the same Module across threads.

Memory Management

  • compile(): Module owned by caller, call deinit() when done
  • compileArc(): Reference counted, use retain()/releaseUnwrap() for sharing
// Single instance - use compile()
var module = try wasmz.Module.compile(engine, bytes);
defer module.deinit();

// Multiple instances - use compileArc()
var arc = try wasmz.Module.compileArc(engine, bytes);

var inst1 = try wasmz.Instance.init(&store1, arc.retain(), linker);
var inst2 = try wasmz.Instance.init(&store2, arc.retain(), linker);

// When done
_ = arc.releaseUnwrap(); // Decrements and frees if zero

Store & Instance

The Store holds runtime context, and Instance is an instantiated module.

Store

The store manages the allocator, engine reference, and runtime state.

Initialization

var store = try wasmz.Store.init(allocator, engine);
defer store.deinit();

Properties

PropertyTypeDescription
gc_heapGCHeapGarbage-collected heap
memory_budgetMemoryBudgetMemory tracking

Memory Budget

Link the memory budget for enforcement:

store.linkBudget(); // Enable memory limits

Instance

Initialization

var instance = try wasmz.Instance.init(&store, module, linker);
defer instance.deinit();

Command Model

Run _start:

if (try instance.runStartFunction()) |result| {
    switch (result) {
        .ok => std.debug.print("Success\n", .{}),
        .trap => |t| std.debug.print("Trap: {s}\n", .{t.code}),
    }
}

Reactor Model

Initialize and call functions:

// Call _initialize if present
if (try instance.initializeReactor()) |result| {
    switch (result) {
        .trap => |t| return error.InitFailed,
        .ok => {},
    }
}

// Call exported function
const result = try instance.call("process", &args);

Methods

MethodDescription
init(store, module, linker)Create instance
deinit()Free instance
runStartFunction()Run _start if present
initializeReactor()Run _initialize if present
call(name, args)Call exported function
isCommand()Returns true if exports _start
isReactor()Returns true if no _start export

Linker

The linker provides host functions to the instance.

Creating Linker

var linker = wasmz.Linker.empty;
defer linker.deinit(allocator);

Adding Host Functions

fn host_add(ctx: ?*anyopaque, hc: *wasmz.HostContext, params: []const wasmz.RawVal, results: []wasmz.RawVal) wasmz.HostError!void {
    const a = params[0].readAs(i32);
    const b = params[1].readAs(i32);
    results[0] = wasmz.RawVal.from(a + b);
}

try linker.define(allocator, "env", "add", wasmz.HostFunc.init(
    null,
    host_add,
    &[_]wasmz.ValType{ .I32, .I32 },
    &[_]wasmz.ValType{.I32},
));

RawVal

Generic value type for all numeric WASM types:

const RawVal = wasmz.RawVal;

// Creating values
const v1 = RawVal.from(@as(i32, 42));
const v2 = RawVal.from(@as(i64, 1000000));
const v3 = RawVal.from(@as(f32, 3.14));
const v4 = RawVal.from(@as(f64, 3.14159265359));

// Reading values
const i = v1.readAs(i32);
const j = v2.readAs(i64);
const f = v3.readAs(f32);
const d = v4.readAs(f64);

ExecResult

Result of function execution:

const result = try instance.call("add", &.{ v1, v2 });

switch (result) {
    .ok => |val| {
        if (val) |v| {
            std.debug.print("Result: {d}\n", .{v.readAs(i32)});
        }
    },
    .trap => |t| {
        std.debug.print("Trap: {s}\n", .{@tagName(t.code)});
    },
}

Complete Example

const std = @import("std");
const wasmz = @import("wasmz");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var engine = try wasmz.Engine.init(allocator, .{});
    defer engine.deinit();

    const bytes = try std.fs.cwd().readFileAlloc(allocator, "add.wasm", 1024 * 1024);
    defer allocator.free(bytes);

    var module = try wasmz.Module.compile(engine, bytes);
    defer module.deinit();

    var store = try wasmz.Store.init(allocator, engine);
    defer store.deinit();

    var instance = try wasmz.Instance.init(&store, module, .empty);
    defer instance.deinit();

    const result = try instance.call("add", &.{
        RawVal.from(@as(i32, 3)),
        RawVal.from(@as(i32, 4)),
    });

    if (result.ok) |val| {
        std.debug.print("3 + 4 = {d}\n", .{val.readAs(i32)});
    }
}

Thread Safety

  • Store - Not thread-safe. Contains GC heap and mutable runtime state.
  • Instance - Not thread-safe. Contains globals, memory, and execution state.

Create separate Store/Instance per thread for parallel execution. The ArcModule reference can be safely shared (retain/release is atomic), but each thread should have its own Store and Instance.

Linker & Host Functions

The linker connects WASM imports to host-provided functions.

Linker

Creating a Linker

var linker = wasmz.Linker.empty;
defer linker.deinit(allocator);

Defining Functions

try linker.define(
    allocator,
    "module_name",  // Import module name
    "func_name",    // Import function name
    wasmz.HostFunc.init(
        null,                    // Context (optional)
        host_function,           // Function pointer
        &[_]wasmz.ValType{ .I32 }, // Parameter types
        &[_]wasmz.ValType{ .I32 }, // Result types
    ),
);

Methods

MethodDescription
define(alloc, module, name, func)Register a host function
get(module, name)Look up a function
deinit(alloc)Free linker resources

HostFunc

Creating Host Functions

fn host_print(
    ctx: ?*anyopaque,
    hc: *wasmz.HostContext,
    params: []const wasmz.RawVal,
    results: []wasmz.RawVal,
) wasmz.HostError!void {
    const value = params[0].readAs(i32);
    std.debug.print("Value: {d}\n", .{value});
    // No return value - results is empty
}

With Context

const MyContext = struct {
    counter: u32,
};

fn host_increment(
    ctx: ?*anyopaque,
    hc: *wasmz.HostContext,
    params: []const wasmz.RawVal,
    results: []wasmz.RawVal,
) wasmz.HostError!void {
    const my_ctx: *MyContext = @ptrCast(@alignCast(ctx.?));
    my_ctx.counter += params[0].readAs(u32);
    results[0] = wasmz.RawVal.from(@as(i32, @intCast(my_ctx.counter)));
}

var my_ctx = MyContext{ .counter = 0 };
try linker.define(allocator, "env", "increment", wasmz.HostFunc.init(
    &my_ctx,
    host_increment,
    &[_]wasmz.ValType{.I32},
    &[_]wasmz.ValType{.I32},
));

HostContext

The HostContext provides access to runtime state:

fn host_memory_access(
    ctx: ?*anyopaque,
    hc: *wasmz.HostContext,
    params: []const wasmz.RawVal,
    results: []wasmz.RawVal,
) wasmz.HostError!void {
    // Access instance memory
    const offset = @as(usize, @intCast(params[0].readAs(u32)));
    const memory = hc.instance.memory;
    const byte = memory.readByte(offset);
    results[0] = wasmz.RawVal.from(@as(i32, byte));
}

Properties

PropertyDescription
instanceAccess to the instance
storeAccess to the store

ValType

Value types for function signatures:

const ValType = enum {
    I32,
    I64,
    F32,
    F64,
    FuncRef,
    ExternRef,
};

WASI Integration

Add WASI functions to the linker:

const wasi = @import("wasi").preview1;

var wasi_host = wasi.Host.init(allocator);
defer wasi_host.deinit();

// Set command-line arguments
try wasi_host.setArgs(&[_][]const u8{ "program.wasm", "--verbose" });

// Add to linker
try wasi_host.addToLinker(&linker, allocator);

Error Handling from Host

Return errors from host functions:

fn host_may_fail(
    ctx: ?*anyopaque,
    hc: *wasmz.HostContext,
    params: []const wasmz.RawVal,
    results: []wasmz.RawVal,
) wasmz.HostError!void {
    const value = params[0].readAs(i32);
    if (value < 0) {
        return wasmz.HostError.Trap; // Will cause a trap
    }
    results[0] = wasmz.RawVal.from(value * 2);
}

Complete Example

const std = @import("std");
const wasmz = @import("wasmz");

fn host_add(
    _: ?*anyopaque,
    _: *wasmz.HostContext,
    params: []const wasmz.RawVal,
    results: []wasmz.RawVal,
) wasmz.HostError!void {
    const a = params[0].readAs(i32);
    const b = params[1].readAs(i32);
    results[0] = wasmz.RawVal.from(a + b);
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Setup
    var engine = try wasmz.Engine.init(allocator, .{});
    defer engine.deinit();

    var store = try wasmz.Store.init(allocator, engine);
    defer store.deinit();

    // Create linker with host function
    var linker = wasmz.Linker.empty;
    defer linker.deinit(allocator);

    try linker.define(allocator, "env", "host_add", wasmz.HostFunc.init(
        null,
        host_add,
        &[_]wasmz.ValType{ .I32, .I32 },
        &[_]wasmz.ValType{.I32},
    ));

    // Load and run WASM that imports "env::host_add"
    const bytes = try std.fs.cwd().readFileAlloc(allocator, "module.wasm", 1024 * 1024);
    defer allocator.free(bytes);

    var module = try wasmz.Module.compile(engine, bytes);
    defer module.deinit();

    var instance = try wasmz.Instance.init(&store, module, linker);
    defer instance.deinit();

    _ = try instance.runStartFunction();
}

Error Handling

Traps

A Trap represents a runtime error in WebAssembly execution.

TrapCode

pub const TrapCode = enum {
    Unreachable,
    IntegerDivisionByZero,
    IntegerOverflow,
    IndirectCallToNull,
    UndefinedElement,
    UninitializedElement,
    OutOfBoundsMemoryAccess,
    OutOfBoundsTableAccess,
    IndirectCallTypeMismatch,
    StackOverflow,
    OutOfMemory,
    // ... more codes
};

ExecResult

Function calls return an ExecResult:

const result = try instance.call("func", &args);

switch (result) {
    .ok => |val| {
        // Success - val may be null for void functions
        if (val) |v| {
            std.debug.print("Result: {d}\n", .{v.readAs(i32)});
        }
    },
    .trap => |trap| {
        // Trap occurred
        std.debug.print("Trap: {s}\n", .{@tagName(trap.code)});
        
        // Get detailed message
        const msg = try trap.allocPrint(allocator);
        defer allocator.free(msg);
        std.debug.print("Details: {s}\n", .{msg});
    },
}

Trap Message

Get a human-readable trap message:

if (result.trap) |trap| {
    const msg = try trap.allocPrint(allocator);
    defer allocator.free(msg);
    std.debug.print("Trap: {s}\n", .{msg});
}

Host Errors

Host functions can return errors:

fn host_divide(
    _: ?*anyopaque,
    _: *wasmz.HostContext,
    params: []const wasmz.RawVal,
    results: []wasmz.RawVal,
) wasmz.HostError!void {
    const a = params[0].readAs(i32);
    const b = params[1].readAs(i32);
    
    if (b == 0) {
        return wasmz.HostError.Trap;
    }
    
    results[0] = wasmz.RawVal.from(@divTrunc(a, b));
}

Common Errors

Module Compilation

var module = wasmz.Module.compile(engine, bytes) catch |err| {
    switch (err) {
        error.InvalidWasm => {
            std.debug.print("Invalid WASM binary\n", .{});
        },
        error.UnsupportedFeature => {
            std.debug.print("Feature not supported\n", .{});
        },
        error.OutOfMemory => {
            std.debug.print("Out of memory\n", .{});
        },
        else => return err,
    }
    return;
};

Instantiation

var instance = wasmz.Instance.init(&store, module, linker) catch |err| {
    switch (err) {
        error.ImportNotSatisfied => {
            std.debug.print("Missing imports:\n", .{});
            for (module.imported_funcs) |imp| {
                if (linker.get(imp.module_name, imp.func_name) == null) {
                    std.debug.print("  {s}::{s}\n", .{ imp.module_name, imp.func_name });
                }
            }
        },
        error.ImportSignatureMismatch => {
            std.debug.print("Import signature mismatch\n", .{});
        },
        else => return err,
    }
    return;
};

Memory Limit

When memory limit is exceeded:

var engine = try wasmz.Engine.init(allocator, .{
    .mem_limit_bytes = 64 * 1024 * 1024, // 64 MB
});

// If WASM tries to grow memory beyond limit:
// result.trap.code == .OutOfMemory

Stack Overflow

When call stack is exhausted:

// Recursive function without base case
// result.trap.code == .StackOverflow

C API

The C API allows embedding wasmz in any C-compatible language.

#include <wasmz.h>

Lifecycle

Engine

// Create engine with default configuration
wasmz_engine_t *engine = wasmz_engine_new();

// Create engine with a memory limit (bytes)
wasmz_engine_t *engine = wasmz_engine_new_with_limit(256 * 1024 * 1024);

// Destroy (must outlive all stores and modules)
wasmz_engine_delete(engine);

Store

wasmz_store_t *store = wasmz_store_new(engine);
// ... use store ...
wasmz_store_delete(store);

Module

uint8_t *bytes = /* pointer to .wasm bytes */;
size_t len     = /* number of bytes */;

wasmz_module_t *module = NULL;
wasmz_error_t *err = wasmz_module_new(engine, bytes, len, &module);
if (err) {
    fprintf(stderr, "Compile error: %s\n", wasmz_error_message(err));
    wasmz_error_delete(err);
    return 1;
}

// When done:
wasmz_module_delete(module);

Instance

// Without host imports
wasmz_instance_t *instance = NULL;
wasmz_error_t *err = wasmz_instance_new(store, module, &instance);

// With host imports (see Linker section below)
wasmz_error_t *err = wasmz_instance_new_with_linker(store, module, linker, &instance);

if (err) {
    fprintf(stderr, "Instantiate error: %s\n", wasmz_error_message(err));
    wasmz_error_delete(err);
    return 1;
}

// When done:
wasmz_instance_delete(instance);

Values

typedef enum {
    WASMZ_VAL_I32        = 0,
    WASMZ_VAL_I64        = 1,
    WASMZ_VAL_F32        = 2,
    WASMZ_VAL_F64        = 3,
    WASMZ_VAL_V128       = 4,
    WASMZ_VAL_REF_NULL   = 5,
    WASMZ_VAL_REF_FUNC   = 6,
    WASMZ_VAL_EXTERN_REF = 7,
} wasmz_val_kind_t;

typedef struct {
    wasmz_val_kind_t kind;
    uint8_t _pad[4];
    union {
        int32_t  i32;
        int64_t  i64;
        float    f32;
        double   f64;
        uint8_t  v128[16];   // 128-bit SIMD vector (little-endian lane order)
        uint32_t func_ref;
        void    *extern_ref;
    } of;
} wasmz_val_t;

// Convenience constructors
wasmz_val_t v = wasmz_val_i32(42);
wasmz_val_t v = wasmz_val_i64(1000000);
wasmz_val_t v = wasmz_val_f32(3.14f);
wasmz_val_t v = wasmz_val_f64(3.14159);

Function Calls

Command Model

// Run _start
wasmz_error_t *err = wasmz_instance_call_start(instance);
if (err) {
    fprintf(stderr, "Error: %s\n", wasmz_error_message(err));
    wasmz_error_delete(err);
}

Reactor Model

// Initialize
wasmz_error_t *err = wasmz_instance_initialize(instance);
if (err) {
    fprintf(stderr, "Init error: %s\n", wasmz_error_message(err));
    wasmz_error_delete(err);
    return 1;
}

// Call a function
wasmz_val_t args[2] = { wasmz_val_i32(3), wasmz_val_i32(4) };
wasmz_val_t result;
err = wasmz_instance_call(instance, "add", args, 2, &result, 1);
if (err) {
    fprintf(stderr, "Call error: %s\n", wasmz_error_message(err));
    wasmz_error_delete(err);
    return 1;
}
printf("Result: %d\n", result.of.i32);

Module Type Detection

if (wasmz_instance_is_command(instance)) {
    // Has _start export — run once
    wasmz_instance_call_start(instance);
}

if (wasmz_instance_is_reactor(instance)) {
    // No _start export — library mode
    wasmz_instance_initialize(instance);
    wasmz_instance_call(instance, "func", args, n, results, m);
}

Linker (Host Functions)

The linker lets you register host-provided functions that the WASM module can import.

Creating a Linker

wasmz_linker_t *linker = wasmz_linker_new();
// ... define functions and globals ...
// Pass to wasmz_instance_new_with_linker(), then:
wasmz_linker_delete(linker);

Defining a Host Function

// Callback type:
//   host_data   — opaque user pointer passed at registration
//   ctx         — host context; use wasmz_context_* to access memory
//   params      — input values (array of length param_count)
//   results     — output values to fill (array of length result_count)
//   return 0    — success; non-zero triggers a trap
int my_add(void *host_data, void *ctx,
           const wasmz_val_t *params, size_t param_count,
           wasmz_val_t *results,      size_t result_count)
{
    results[0] = wasmz_val_i32(params[0].of.i32 + params[1].of.i32);
    return 0;
}

wasmz_val_kind_t param_kinds[]  = { WASMZ_VAL_I32, WASMZ_VAL_I32 };
wasmz_val_kind_t result_kinds[] = { WASMZ_VAL_I32 };

wasmz_error_t *err = wasmz_linker_define_func(
    linker,
    "env", "add",           // import module :: function name
    param_kinds,  2,
    result_kinds, 1,
    my_add, NULL            // callback, optional user data
);
if (err) {
    fprintf(stderr, "define_func: %s\n", wasmz_error_message(err));
    wasmz_error_delete(err);
}

Defining a Global Import

wasmz_error_t *err = wasmz_linker_define_global(
    linker,
    "env", "my_global",
    wasmz_val_i32(42)
);

Instantiating with a Linker

wasmz_instance_t *instance = NULL;
wasmz_error_t *err = wasmz_instance_new_with_linker(store, module, linker, &instance);
if (err) {
    fprintf(stderr, "Instantiate error: %s\n", wasmz_error_message(err));
    wasmz_error_delete(err);
    return 1;
}

Host Context

Inside a host function callback, use wasmz_context_* to safely read and write the WASM instance’s linear memory:

int host_strlen(void *host_data, void *ctx,
                const wasmz_val_t *params, size_t param_count,
                wasmz_val_t *results,      size_t result_count)
{
    uint32_t ptr = (uint32_t)params[0].of.i32;

    uint8_t *mem  = wasmz_context_memory(ctx);
    size_t   size = wasmz_context_memory_size(ctx);

    if (!mem || ptr >= size) {
        wasmz_context_trap(ctx, "out of bounds pointer");
        return 1;
    }

    int32_t len = 0;
    while (ptr + len < size && mem[ptr + len] != '\0') len++;

    results[0] = wasmz_val_i32(len);
    return 0;
}

Context Functions

FunctionDescription
wasmz_context_memory(ctx)Raw pointer to linear memory (NULL if none)
wasmz_context_memory_size(ctx)Size of linear memory in bytes
wasmz_context_read_memory(ctx, addr, len, out)Bounds-checked read (returns 0 on success)
wasmz_context_write_memory(ctx, addr, data, len)Bounds-checked write (returns 0 on success)
wasmz_context_read_value(ctx, addr, out, size)Read a typed value from memory
wasmz_context_write_value(ctx, addr, value, size)Write a typed value to memory
wasmz_context_trap(ctx, msg)Raise a trap from inside the callback

Linear Memory (from outside host functions)

You can also access an instance’s memory after instantiation:

uint8_t *mem  = wasmz_instance_memory(instance);        // NULL if no memory
size_t   size = wasmz_instance_memory_size(instance);

// Grow memory by pages (1 page = 64 KiB)
int32_t ok = wasmz_instance_memory_grow(instance, 1);   // 0 on success

Module Introspection

// Does the module define a linear memory?
int has_mem = wasmz_module_has_memory(module);

// Initial and maximum page counts
uint32_t min_pages = wasmz_module_memory_min(module);
uint32_t max_pages = wasmz_module_memory_max(module); // UINT32_MAX = unlimited

// Enumerate exported function names
size_t n = wasmz_module_export_count(module);
for (size_t i = 0; i < n; i++) {
    const char *name = wasmz_module_export_name(module, i);
    printf("export[%zu]: %s\n", i, name);
}

Store User Data

Attach arbitrary state to a store for retrieval inside host callbacks:

wasmz_store_set_user_data(store, my_state_ptr);
// ... later, inside a host function or after a call:
void *state = wasmz_store_get_user_data(store);

VM Statistics

wasmz_vm_stats_t stats;
wasmz_instance_vm_stats(instance, &stats);
printf("value stack : %zu bytes (%zu slots)\n",
       stats.val_stack_bytes, stats.val_stack_slots);
printf("call stack  : %zu bytes (%zu frames)\n",
       stats.call_stack_bytes, stats.call_stack_frames);

Error Handling

All fallible functions return wasmz_error_t *. NULL means success.

wasmz_error_t *err = wasmz_module_new(engine, bytes, len, &module);
if (err != NULL) {
    fprintf(stderr, "Error: %s\n", wasmz_error_message(err));
    wasmz_error_delete(err);
    return 1;
}

Complete Example

#include <stdio.h>
#include <stdlib.h>
#include "wasmz.h"

// Host function: prints an i32 from WASM
static int host_print(void *data, void *ctx,
                      const wasmz_val_t *params, size_t param_count,
                      wasmz_val_t *results,      size_t result_count)
{
    printf("wasm says: %d\n", params[0].of.i32);
    return 0;
}

int main(int argc, char **argv) {
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <file.wasm>\n", argv[0]);
        return 1;
    }

    // Load file
    FILE *f = fopen(argv[1], "rb");
    if (!f) { perror("fopen"); return 1; }
    fseek(f, 0, SEEK_END);
    size_t len = (size_t)ftell(f);
    fseek(f, 0, SEEK_SET);
    uint8_t *bytes = malloc(len);
    fread(bytes, 1, len, f);
    fclose(f);

    // Engine + store
    wasmz_engine_t *engine = wasmz_engine_new();
    wasmz_store_t  *store  = wasmz_store_new(engine);

    // Compile
    wasmz_module_t *module = NULL;
    wasmz_error_t  *err    = wasmz_module_new(engine, bytes, len, &module);
    free(bytes);
    if (err) {
        fprintf(stderr, "Compile: %s\n", wasmz_error_message(err));
        wasmz_error_delete(err);
        wasmz_store_delete(store);
        wasmz_engine_delete(engine);
        return 1;
    }

    // Register a host function and instantiate
    wasmz_linker_t *linker = wasmz_linker_new();
    wasmz_val_kind_t p[] = { WASMZ_VAL_I32 };
    err = wasmz_linker_define_func(linker, "env", "print", p, 1, NULL, 0, host_print, NULL);
    if (err) {
        fprintf(stderr, "define_func: %s\n", wasmz_error_message(err));
        wasmz_error_delete(err);
        goto cleanup;
    }

    wasmz_instance_t *instance = NULL;
    err = wasmz_instance_new_with_linker(store, module, linker, &instance);
    if (err) {
        fprintf(stderr, "Instantiate: %s\n", wasmz_error_message(err));
        wasmz_error_delete(err);
        goto cleanup;
    }

    // Run
    if (wasmz_instance_is_command(instance)) {
        err = wasmz_instance_call_start(instance);
    } else {
        err = wasmz_instance_initialize(instance);
        if (!err) {
            wasmz_val_t result;
            err = wasmz_instance_call(instance, "run", NULL, 0, &result, 0);
        }
    }
    if (err) {
        fprintf(stderr, "Runtime: %s\n", wasmz_error_message(err));
        wasmz_error_delete(err);
    }

    wasmz_instance_delete(instance);
cleanup:
    wasmz_linker_delete(linker);
    wasmz_module_delete(module);
    wasmz_store_delete(store);
    wasmz_engine_delete(engine);
    return 0;
}

Building

# Build the C shared library
zig build clib

# Compile your program against it
gcc -o myapp myapp.c -Lzig-out/lib -lwasmz -Izig-out/include

Thread Safety

  • Engine — Reference counting is thread-safe. Do not access the same engine instance concurrently.
  • Module — Reference counting is thread-safe. The module data is read-only after compilation and safe to share.
  • Store — Not thread-safe. Contains GC heap and mutable runtime state.
  • Instance — Not thread-safe. Contains mutable execution state.

For multi-threaded applications, create a separate Store and Instance per thread. Sharing a compiled Module across threads is safe.

WASI Support

wasmz implements WASI Preview 1 (snapshot 1), enabling WebAssembly modules to interact with the host system.

Implementation Status

All WASI Preview 1 functions are implemented:

Environment

FunctionStatus
args_get
args_sizes_get
environ_get
environ_sizes_get

Clock

FunctionStatus
clock_res_get
clock_time_get

File Descriptors

FunctionStatus
fd_advise
fd_allocate
fd_close
fd_datasync
fd_fdstat_get
fd_fdstat_set_flags
fd_fdstat_set_rights
fd_filestat_get
fd_filestat_set_size
fd_filestat_set_times
fd_pread
fd_prestat_get
fd_prestat_dir_name
fd_pwrite
fd_read
fd_readdir
fd_renumber
fd_seek
fd_sync
fd_tell
fd_write

Path Operations

FunctionStatus
path_create_directory
path_filestat_get
path_filestat_set_times
path_link
path_open
path_readlink
path_remove_directory
path_rename
path_symlink
path_unlink_file

Polling

FunctionStatus
poll_oneoff

Process

FunctionStatus
proc_exit
proc_raise
sched_yield

Random

FunctionStatus
random_get

Sockets

FunctionStatus
sock_accept
sock_recv
sock_send
sock_shutdown

CLI Integration

Passing Arguments

# Pass arguments to WASM module
wasmz program.wasm --args "arg1 arg2 'arg with spaces'"

Environment Variables

Environment variables are automatically inherited from the host process.

Zig API Integration

const wasi = @import("wasi").preview1;

// Create WASI host
var wasi_host = wasi.Host.init(allocator);
defer wasi_host.deinit();

// Set arguments
try wasi_host.setArgs(&[_][]const u8{
    "program.wasm",
    "--verbose",
    "input.txt",
});

// Set environment variables
try wasi_host.setEnv("MY_VAR", "value");

// Pre-open directory
try wasi_host.preopenDir("/data", "/data");

// Add to linker
var linker = wasmz.Linker.empty;
try wasi_host.addToLinker(&linker, allocator);

Pre-opened Directories

WASI uses pre-opened directories for filesystem access. By default:

  • / is pre-opened as the current working directory
  • Additional directories can be pre-opened via the API
// Pre-open specific directories
try wasi_host.preopenDir("/tmp", "/tmp");
try wasi_host.preopenDir("/home/user/data", "/data");

Exit Code

When a WASM module calls proc_exit, the exit code is returned:

const result = try instance.call("_start", &.{});
if (result.trap) |trap| {
    if (trap.code == .Exit) {
        const exit_code = trap.exit_code;
        // Handle exit code
    }
}

Exit Callback

Register a callback for proc_exit:

fn onExit(code: u32, ctx: ?*anyopaque) void {
    std.debug.print("Module exited with code {d}\n", .{code});
}

wasi_host.setOnExit(onExit, &my_context);

Preview 2

WASI Preview 2 (Component Model) is not yet supported.
Currently, almost no languages are actually using the Preview2 proposal either, Planned for future releases.

Architecture

This section describes wasmz’s internal architecture for contributors.

Overview

┌─────────────────────────────────────────────────────────────┐
│                        Public API                            │
│  (root.zig - Engine, Module, Store, Instance, Linker)       │
└─────────────────────────────────────────────────────────────┘
                              │
┌─────────────────────────────────────────────────────────────┐
│                       wasmz Module                           │
│  (High-level API implementation)                             │
└─────────────────────────────────────────────────────────────┘
                              │
┌──────────────┬──────────────┼──────────────┬───────────────┐
│    Parser    │   Compiler   │      VM       │    WASI       │
│              │              │               │               │
│  Binary      │  Stack-to-   │  Interpreter  │  Preview 1    │
│  Parser      │  Register    │  Engine       │  Host         │
│              │  Compiler    │               │               │
└──────────────┴──────────────┴───────────────┴───────────────┘
                              │
┌─────────────────────────────────────────────────────────────┐
│                     Core Types                               │
│  (Value types, ref types, heap types, trap, etc.)           │
└─────────────────────────────────────────────────────────────┘

Pipeline

  1. Parse - Binary parser reads the WASM module
  2. Compile - Stack-to-register IR transformation
  3. Execute - VM interpreter runs compiled IR

Note: wasmz does not implement a validator. Use external tools like wasm-tools to validate WASM modules before execution.

Key Directories

DirectoryPurpose
src/core/Core data types (types, values, traps)
src/parser/WASM binary parser
src/compiler/IR generation and optimization
src/engine/Function type handling, config
src/vm/Virtual machine, GC heap
src/wasmz/High-level API implementation
src/wasi/WASI system interface
src/validator/Placeholder (not implemented)
src/libs/Vendored dependencies

Next Sections

Parser

The parser reads WebAssembly binary format incrementally.

Design

The parser is designed for streaming - it can parse modules larger than available memory by processing sections incrementally.

Interface

const Parser = parser_mod.Parser.init();

while (true) {
    const n = try reader.readSliceShort(pending_buf[pending_len..]);
    const eof = n == 0;
    var input = pending_buf[0 .. pending_len + n];

    while (true) {
        switch (parser.parse(input, eof)) {
            .parsed => |result| {
                // Handle payload
                switch (result.payload) {
                    .module_header => |header| { /* ... */ },
                    .func => |func| { /* ... */ },
                    .code_section => |code| { /* ... */ },
                    else => {},
                }
                input = input[result.consumed..];
            },
            .need_more_data => {
                // Buffer remaining data and read more
                break;
            },
            .end => return,
            .err => |e| return e,
        }
    }
}

Payloads

The parser emits payloads for each parsed element:

PayloadDescription
module_headerModule magic and version
type_sectionFunction types
import_sectionImports
func_sectionFunction declarations
code_sectionFunction bodies
data_sectionData segments
elem_sectionElement segments
global_sectionGlobals
memory_sectionMemories
table_sectionTables
export_sectionExports
start_sectionStart function

Validation

wasmz does not implement a full validator. Use external tools to validate WASM modules:

Basic sanity checks are performed during parsing (e.g., section structure, index bounds).

Memory Model

The parser allocates:

  • Payload data - Temporary, freed after handling
  • Module metadata - Types, imports, exports (owned by Module)

Key Files

FilePurpose
src/parser/root.zigParser implementation
src/parser/payload.zigPayload types
src/parser/range.zigSource ranges for debugging
src/parser/helper.zigParsing utilities

Parallel Compilation

The parser can trigger parallel compilation:

// For each code section, after parsing body:
// 1. validate body
// 2. lower body (compile to register machine)
// These can run in separate threads

Extension Proposals

New proposals are handled by:

  1. Adding new payload types
  2. Adding new opcodes to the compiler
  3. Adding new validation rules

Compiler

The compiler transforms WebAssembly’s stack machine into a register-based IR for efficient interpretation.

Purpose

WebAssembly is a stack machine - values are pushed and popped from an operand stack. This is inefficient for interpretation. The compiler transforms this into a register-based IR where values are stored in named registers.

Pipeline

WASM Bytes → Parser → Stack Instructions → Validator → Compiler → Register IR
                                                              ↓
                                                         Optimizer
                                                              ↓
                                                        Code Gen (bytecode)

Lowering Process

Stack Machine

local.get 0
local.get 1
i32.add
local.set 2

Register IR

r0 = local[0]
r1 = local[1]
r2 = i32_add(r0, r1)
local[2] = r2

IR Structure

const IR = struct {
    instructions: []Instruction,
    registers: RegisterInfo,
    locals: []LocalInfo,
    blocks: []BlockInfo,
};

const Instruction = struct {
    opcode: Opcode,
    dst: ?Register,
    src1: ?Register,
    src2: ?Register,
    // ...
};

Key Files

FilePurpose
src/compiler/root.zigCompiler entry point
src/compiler/ir.zigIR data structures
src/compiler/translate.zigStack-to-IR translation
src/compiler/lower.zigModern lowering
src/compiler/lower_legacy.zigLegacy EH lowering
src/compiler/value_stack.zigSimulated operand stack

Internal Checks

The compiler performs internal checks during lowering (not full WASM validation):

  1. Type checking - Operands match instruction requirements
  2. Reachability - Unreachable code is handled
  3. Block typing - Block inputs/outputs match

Note: These are runtime checks for compilation, not WASM specification validation. Use external tools for full validation.

Block Handling

Blocks (block, loop, if) are compiled with:

  • Separate register scopes
  • Branch targets
  • Result values
// block $b (result i32)
//   ... instructions ...
//   br $b (value)
// end

Exception Handling

Two proposals are supported:

New Proposal

try $label
  ... instructions ...
catch $label
  ... exception handler ...
end

Legacy Proposal

try
  ... instructions ...
catch
  ... handler ...
rethrow
delegate $label
end

Controlled by Config.legacy_exceptions.

Thread Safety

Compilation of function bodies can be parallelized:

// Each code section can be compiled independently
for (module.functions) |func, i| {
    // spawn thread for compile(func)
}

SIMD

SIMD instructions are handled specially:

  • Vector operations execute directly
  • SIMD-specific lowering rules

See src/core/simd/ for implementation.

Optimization

Current optimizations:

  1. Dead code elimination - Remove unreachable instructions
  2. Register coalescing - Reduce register count
  3. Constant folding - Evaluate constants at compile time

Future optimizations:

  • Value numbering
  • Common subexpression elimination

VM & Execution

The virtual machine executes compiled IR.

Execution Model

The VM is a register-based interpreter:

  1. Load compiled bytecode
  2. Execute instructions sequentially
  3. Handle branches and calls
  4. Return results or traps

Key Components

Function Execution

pub fn executeFunction(
    store: *Store,
    func: *const Func,
    args: []const RawVal,
) ExecResult {
    // Setup frame
    // Execute instructions
    // Return result
}

Instruction Dispatch

switch (opcode) {
    .I32Add => {
        const a = frame.getRegister(inst.src1).readAs(i32);
        const b = frame.getRegister(inst.src2).readAs(i32);
        frame.setRegister(inst.dst, RawVal.from(a + b));
    },
    .Call => {
        // Handle function call
    },
    .Br => {
        // Handle branch
    },
    // ...
}

Call Stack

Each call creates a frame:

const Frame = struct {
    return_ip: usize,
    return_frame: ?*Frame,
    locals: []RawVal,
    module: *const Module,
    func_index: u32,
};

Memory

Linear memory is managed per-instance:

const Memory = struct {
    data: []u8,
    min: u32,
    max: ?u32,
    
    pub fn readByte(self: *Memory, offset: usize) u8;
    pub fn writeByte(self: *Memory, offset: usize, value: u8);
    pub fn grow(self: *Memory, pages: u32) !void;
};

Table

Tables store function references for indirect calls:

const Table = struct {
    elements: []?FuncRef,
    min: u32,
    max: ?u32,
};

Global

Globals store mutable state:

const Global = struct {
    value: RawVal,
    mutable: bool,
};

Traps

Traps abort execution with an error code:

pub const TrapCode = enum {
    Unreachable,
    IntegerDivisionByZero,
    IntegerOverflow,
    // ...
};

Key Files

FilePurpose
src/vm/root.zigVM entry point, ExecResult
src/engine/root.zigEngine implementation
src/engine/func_ty.zigFunction type handling
src/engine/code_map.zigCompiled code storage

Branch Handling

Branches use continuation-passing style:

// br $label
// Jump to block label, pass values
const block = frame.getBlock(inst.label);
frame.setValues(block.params);
frame.ip = block.start;

Function Calls

Direct Calls

// call $func
const callee = module.getFunc(inst.func_index);
try pushFrame(callee, args);

Indirect Calls

// call_indirect $type
const table_index = frame.getRegister(inst.src).readAs(u32);
const func_ref = table.elements[table_index];
const sig = module.types[inst.type_index];
if (!func_ref.signature.matches(sig)) {
    return Trap{ .code = .IndirectCallTypeMismatch };
}
try pushFrame(func_ref, args);

Host Calls

Host functions are called through HostFunc:

pub const HostFunc = struct {
    context: ?*anyopaque,
    callback: *const fn (...) HostError!void,
    param_types: []const ValType,
    result_types: []const ValType,
};

Garbage Collection

wasmz implements the WebAssembly GC proposal with a managed heap.

GC Proposal Overview

The GC proposal adds:

  • Structs - Fixed-size records with typed fields
  • Arrays - Variable-size sequences of typed elements
  • Reference Types - References to GC heap objects

GC Algorithm

wasmz uses a tri-color mark-and-sweep collector with an explicit worklist.

Allocation Strategy

The heap uses a free-list allocator with bump allocation fallback:

  1. Free-list search - Find a block >= requested size
  2. Block splitting - Split if remaining space can hold another FreeBlock
  3. Bump allocation - Fallback when no suitable free block exists
pub const GcHeap = struct {
    bytes: []u8,              // Contiguous byte buffer
    free_list: FreeList,       // Singly-linked list of free blocks
    used: u32,                 // Bytes currently in use
    live_objects: ArrayList(AllocationInfo), // Track all allocations
};

Collection Phases

Phase 1: Mark

  1. Seed worklist with root references (call frames, globals)
  2. Process worklist iteratively (BFS traversal):
    • Pop object from worklist
    • Mark object by setting mark bit in header
    • Enqueue all child references
  3. Continue until worklist is empty

Phase 2: Sweep

  1. Iterate all live_objects in reverse
  2. If marked: clear mark bit (still live)
  3. If unmarked: free the block, remove from live list
pub fn collect(
    self: *Self,
    roots: []const GcRef,
    composite_types: []const CompositeType,
    struct_layouts: []const ?StructLayout,
    array_layouts: []const ?ArrayLayout,
) void {
    // Mark phase: iterative BFS with explicit worklist
    var worklist = ArrayList(u32){};
    for (roots) |ref| {
        if (ref.isHeapRef()) {
            const hdr = self.header(ref.asHeapIndex());
            if (!hdr.isMarked()) {
                hdr.setMark();
                worklist.append(ref.asHeapIndex());
            }
        }
    }
    
    while (worklist.pop()) |idx| {
        // Trace child references...
    }
    
    // Sweep phase
    for (live_objects) |info| {
        if (hdr.isMarked()) {
            hdr.clearMark();
        } else {
            self.free(info.index, info.size);
        }
    }
}

Why Tri-Color Mark-and-Sweep?

  • No stack overflow - Explicit worklist avoids deep recursion
  • Simple implementation - Two-phase algorithm is easy to understand
  • Incremental potential - Worklist design allows future incremental collection

Object Layout

Each GC object has:

  1. Header (8 bytes) - Metadata for GC and type information
  2. Payload - Field data for structs, length + elements for arrays
const GcHeader = struct {
    kind_bits: u32,    // High 6 bits = GcKind, bit 0 = mark bit
    type_index: u32,   // Type index for concrete types
};

GcKind

High 6 bits identify the object kind for subtype checking:

KindDescription
AnyTop type for references
EqEquality comparable types
I31Unboxed 31-bit integer
StructStruct object
ArrayArray object
FuncFunction reference
ExternExternal reference
ExceptionException object (internal)

Mark Bit

Bit 0 of kind_bits is used for the GC mark phase:

fn setMark(self: *GcHeader) void {
    self.kind_bits |= MARK_BIT;
}

fn isMarked(self: GcHeader) bool {
    return (self.kind_bits & MARK_BIT) != 0;
}

GcRef

References are encoded as 32-bit indices:

const GcRef = struct {
    // Index 0 = null
    // High bits encode the kind (heap, i31, func, extern)
    
    pub fn isHeapRef(self: GcRef) bool;
    pub fn asHeapIndex(self: GcRef) ?u32;
    pub fn encode(index: u32) GcRef;
};

Heap Types

const HeapType = union(enum) {
    func: void,
    extern: void,
    any: void,
    eq: void,
    i31: void,
    struct_type: *StructType,
    array_type: *ArrayType,
    // ...
};

Structs

const StructType = struct {
    fields: []FieldType,
    
    const FieldType = struct {
        type: ValType,
        mutable: bool,
    };
};

WASM Example

(type $point (struct (field i32) (field i32)))

(func $create_point (param $x i32) (param $y i32) (result (ref $point))
    struct.new $point
    local.get $x
    local.get $y
)

(func $get_x (param $p (ref $point)) (result i32)
    local.get $p
    struct.get $point 0
)

Arrays

const ArrayType = struct {
    element_type: ValType,
    mutable: bool,
};

WASM Example

(type $int_array (array (mut i32)))

(func $create_array (param $len i32) (result (ref $int_array))
    local.get $len
    i32.const 0
    array.new $int_array
)

(func $get_element (param $arr (ref $int_array)) (param $idx i32) (result i32)
    local.get $arr
    local.get $idx
    array.get $int_array
)

i31 References

Small integers stored unboxed:

const i31ref = struct {
    value: i31,  // 31-bit signed integer
};

WASM Example

(func $wrap_i31 (param $i i32) (result (ref i31))
    ref.i31
    local.get $i
)

(func $unwrap_i31 (param $r (ref i31)) (result i32)
    local.get $r
    i31.get_s
)

Key Files

FilePurpose
src/vm/gc/root.zigGC entry point
src/vm/gc/heap.zigHeap management
src/vm/gc/header.zigObject header
src/vm/gc/layout.zigObject layout calculation
src/core/gc_ref.zigReference type
src/core/heap_type.zigHeap type definitions
src/core/ref_type.zigReference type definitions

Memory Management

Allocation

// In struct.new or array.new
const total_size = HEADER_SIZE + payload_size;
const ref = gc_heap.alloc(total_size) orelse return error.OutOfMemory;

// Initialize header
const hdr = gc_heap.getHeader(ref);
hdr.kind_bits = GcKind.Struct;
hdr.type_index = type_index;

// Initialize fields...

Heap Growth

The heap grows exponentially (2x) when full:

const new_len = @max(min_needed, current_size * 2);
self.bytes = self.allocator.realloc(self.bytes, new_len);

Collection Trigger

Collection is triggered when memory limit is exceeded:

if (self.budget) |b| {
    if (!b.canGrow(additional)) {
        // Trigger GC and retry
        gc_heap.collect(roots, composite_types, layouts);
    }
}

No Write Barriers

Since wasmz uses mark-and-sweep, no write barriers are needed. References are traced during the mark phase by walking the object graph from roots.

Contributing

Thank you for your interest in contributing to wasmz!

Development Setup

Prerequisites

  • Zig 0.15.2
  • Git

Clone and Build

git clone https://github.com/anomalyco/wasmz.git
cd wasmz
make build

Running Tests

# Run all tests
make test

Project Structure

src/
├── root.zig          # Public API exports
├── main.zig          # CLI implementation
├── capi.zig          # C API implementation
├── core/             # Core types (no dependencies)
│   ├── root.zig
│   ├── func_type.zig
│   ├── ref_type.zig
│   ├── heap_type.zig
│   ├── trap.zig
│   └── ...
├── parser/           # WASM binary parser
│   ├── root.zig
│   ├── payload.zig
│   └── tests/
├── compiler/         # Stack-to-register compiler
│   ├── root.zig
│   ├── ir.zig
│   ├── translate.zig
│   └── tests/
├── engine/           # Execution engine
│   ├── root.zig
│   ├── config.zig
│   └── func_ty.zig
├── vm/               # Virtual machine
│   ├── root.zig
│   └── gc/
├── wasmz/            # High-level API
│   ├── module.zig
│   ├── store.zig
│   ├── instance.zig
│   ├── host.zig
│   └── tests/
├── wasi/             # WASI implementation
│   ├── root.zig
│   └── preview1/
└── libs/             # Vendored dependencies
    └── zigrc/

License

By contributing, you agree that your contributions will be licensed under the MIT License.