A small, dependency-free WebAssembly interpreter for embedding plugin systems in Zig applications.
wick runs wasm 1.0 core modules by direct interpretation — no JIT, no
codegen, no libc. An std.mem.Allocator is the only thing it asks of
its host. It is built for one job: letting an application execute
third-party code where the host controls every doorway (imports are
the only way out of the sandbox) and every budget (memory ceiling,
call depth, instruction fuel).
wick is a deliberate interpreter, not a JIT: a compiling engine will still beat it by several times on compute-bound guests, and that tradeoff is the point. What you get instead is roughly 800–1550 MIPS and 126M host calls per second from a dependency-free library that compiles into your binary — fast enough that plugin calls belong on per-request and per-frame paths, not just at startup.
-
wasm 1.0 core execution — full control flow, i32/i64/f32/f64 arithmetic and conversions (including trapping and saturating float→int truncations), linear memory with bounded growth, globals, funcref tables with
call_indirect(runtime type-checked), active data and element segments, and the bulk-memory ops LLVM toolchains emit (memory.copy,memory.fill,memory.init,data.drop). -
Typed host bindings — declare host functions with native Zig signatures; comptime codegen does the argument unpacking, including
[]const u8parameters resolved through guest memory with bounds checks:const Host = struct { fn log(call: wick.bindings.Ctx(Host), level: u32, msg: []const u8) void { std.log.info("plugin[{d}]: {s}", .{ level, msg }); } }; const imports = [_]wick.HostImport{ .{ .module_name = "env", .field_name = "log", .func = wick.bind(Host, Host.log) }, };
-
Typed guest calls — the mirror image: resolve an export against a Zig function type once and call it with native values. A missing export fails with
error.ExportNotFoundand a mismatched one witherror.SignatureMismatch, both before any guest code runs:const add = try plugin.func("add", fn (i32, i32) i32); const sum = try add.call(.{ 2, 3 });
-
Resource limits — per-instance
Limits: instruction fuel (a runaway module returnserror.OutOfFuelinstead of hanging your host), call-depth cap, value-stack cap, and a memory ceiling the host imposes on top of whatever the module declares. -
Hostile-input hardening — decode bounds every count against the bytes backing it, so a forged section can't drive a multi-gigabyte allocation; unimplemented opcodes fail loudly with
error.UnsupportedOprather than miscomputing silently.
zig build bench on an M-series laptop, best of 5:
| workload | time | MIPS |
|---|---|---|
| fib — call-heavy recursion | 2.99 ms | 851 |
| memsum — linear-memory loops | 7.35 ms | 1516 |
| pingpong — host-call round trips | 2.38 ms | 1387 (126M calls/s) |
| strcount — byte-wise scanning | 15.19 ms | 1569 |
Each function body is translated once at decode time into pre-decoded IR — immediates unpacked, branch targets resolved to direct indices, stack heights validated — and executed by a threaded-dispatch loop over preallocated stacks. Nothing inside the interpreter loop allocates.
Fuel metering costs 2–14%, so there is no real speed argument for running untrusted code unmetered. Fuel is charged once per basic block; because a block has no internal branches, the total charged still equals the exact instruction count.
ROADMAP.md records how this was measured, including one optimization that was implemented, measured, and rejected.
SIMD, threads, multi-memory, reference types beyond funcref tables, and WASI. Gaps surface as typed errors, never as wrong answers.
const wick = @import("wick");
var plugin = try wick.Plugin.init(allocator, wasm_bytes, .{
.imports = &imports,
.user_data = &host,
.limits = .{
.fuel = 10_000_000, // ~instructions per call
.max_memory_pages = 64, // 4 MiB ceiling
},
});
defer plugin.deinit();
// Resolve an export against a Zig signature, then call it with
// native types. A missing or mismatched export fails here, before
// any guest code runs.
const add = try plugin.func("add", fn (i32, i32) i32);
const sum = try add.call(.{ 2, 3 });Plugin is the one-call path; decode / instantiateWithLimits /
invoke are still there underneath and reachable through
plugin.module and plugin.instance.
Anything bigger than a scalar crosses as a (ptr, len) pair into
guest memory, since that is all wasm itself has. Point
wick.GuestAllocator at whatever allocation exports your guest
provides:
var ga = try wick.GuestAllocator.init(&plugin.instance, "alloc", "free");
const ptr = try ga.dupe("hello, plugin");
defer ga.free(ptr, 13) catch {};
_ = try greet.call(.{ ptr, 13 });Instance also has direct slice, writeBytes, read/write
(little-endian scalars), and readStruct/writeStruct (for
extern structs) — every one bounds-checked against guest memory.
See examples/ for a complete host and a real Zig plugin
compiled to wasm; zig build example builds and runs both.
Requires Zig 0.16.0 or newer.
zig fetch --save git+https://github.com/ooyeku/wick#v0.1.0// build.zig
const wick = b.dependency("wick", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("wick", wick.module("wick"));zig build test runs the suite: decoder hardening, control-flow
regressions, float semantics (NaN, saturation), call_indirect
dispatch and traps, fuel exhaustion, and an end-to-end test of the
full plugin-host integration pattern (host imports, user_data,
guest strings, export lookup and invocation).
Four more build steps back the roadmap:
-
zig build example— compile the example plugin to wasm and run the host that loads it. -
zig build bench— the benchmark suite (ReleaseFast), reporting per-workload instruction counts and MIPS. -
zig build fuzz— fuzz targets for the decoder and executor. -
zig build spec— the official WebAssembly spec test suite scoreboard; runscripts/fetch-spec-tests.shonce first (needs git and wabt).