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

Introduction

Baedeker is a WebAssembly runtime implemented in Rust, targeting every platform Rust compiles to — Linux, macOS, iOS, Android, and beyond — as a first-class, embeddable engine.

Named after the Hindmost Baedeker from Larry Niven’s Ringworld / Fleet of Worlds series: cautious, methodical, but ultimately willing to venture into the unknown.

What it does

Baedeker runs WebAssembly modules through a four-stage pipeline:

  1. Decode the binary format into a typed module structure.
  2. Validate it against the WebAssembly specification.
  3. Lower the stack-machine bytecode into a register IR.
  4. Execute the register IR on a self-contained interpreter.

The core is no_std + alloc — no filesystem, no threads, no OS — so it embeds cleanly into bare metal, mobile apps, or even another runtime. A complete platform-integration surface (C ABI, Swift package, GPU offload, ahead-of-time compilation) wraps that core for production embedding.

Why it exists

Industrial Algebra builds a broader ecosystem of Rust crates focused on geometric algebra, information geometry, and high-performance functional programming (Amari, Cliffy, Orlando, Minuet). A primary motivation is running that ecosystem on any platform — mobile, desktop, and embedded — without per-platform rewrites. WebAssembly is the portable substrate; Baedeker is the engine that runs it.

Baedeker is a language-runtime and systems project. Its binary decoding, malformed-input handling, validation, and robustness testing exist strictly to improve standards-compliant WebAssembly execution, portability, embedding, and implementation quality.

Key features

  • WASM 2.0 core complete — full reference types, fixed-width SIMD, multi-value, bulk memory, multi-module linking, and host functions.
  • Spec-verified — the official WebAssembly test suite runs clean: 85 files, 19,204 assertions, 993 modules, 0 failures.
  • Differentially tested — generated modules executed against Wasmtime with zero divergences.
  • Embeddableno_std core; C ABI and Swift package for native embedding.
  • GPU offload — bulk SIMD work dispatches to GPU compute via Borsalino (Vulkan/Metal), with a layered verification strategy.
  • Fuel-bound — interpreter fuel caps execution; categorised traps give precise diagnostics.

Status

Baedeker’s first public release targets WebAssembly 2.0 as a complete, verified milestone. WebAssembly 3.0 proposals (tail calls, exception handling, memory64, GC, threads) are the post-release roadmap. See Roadmap.

Getting Started

Baedeker runs a WebAssembly module through decode → validate → lower → instantiate → execute. This page walks through the smallest end-to-end example.

Install

Baedeker is a Rust workspace published to crates.io:

[dependencies]
baedeker-core = "0.1"

For a command-line harness, install the CLI:

cargo install baedeker-cli

Your first module

This is the execute example (crates/baedeker-cli/examples/execute.rs), running a module that exports add(i32, i32) -> i32:

#![allow(unused)]
fn main() {
use baedeker_core::binary::module::Module;
use baedeker_core::lower::lower_module;
use baedeker_core::runtime::{Store, Value, execute_export};

// The four-stage pipeline every Baedeker run goes through.
let module = Module::decode(WASM_BYTES).expect("decode");
module.validate().expect("validate");
let reg = lower_module(&module).expect("lower");
let store = Store::instantiate(&reg).expect("instantiate");

let results = execute_export(&reg, &store, "add", &[Value::I32(20), Value::I32(22)])
    .expect("execute");
assert_eq!(results, vec![Value::I32(42)]);
}

WASM_BYTES is a &[u8] containing a .wasm binary. In the example it is embedded with include_bytes!("add.wasm"); in your code it comes from wherever modules originate (the filesystem, the network, a build step).

Run the bundled examples

# Full pipeline ending in executing an exported function.
cargo run -p baedeker-cli --example execute
# add(20, 22) = [I32(42)]

# Decode + structural summary (no validation or execution).
cargo run -p baedeker-cli --example inspect

Decode without executing

To inspect a .wasm binary’s structure:

baedeker path/to/module.wasm

To validate and lower it to an ahead-of-time artifact:

baedeker compile path/to/module.wasm -o module.bdkaot

Next steps

Architecture

Baedeker is a pipeline runtime: a WebAssembly binary flows through four stages, each producing a richer representation, until it reaches an executable register IR.

 .wasm bytes
     │
     ▼
┌─────────┐     ┌───────────┐     ┌────────┐     ┌───────────┐
│ Decode  │────▶│ Validate  │────▶│ Lower  │────▶│ Execute   │
└─────────┘     └───────────┘     └────────┘     └───────────┘
  binary           spec rules     stack→reg       register IR
  parsing          + types        IR lowering     interpreter

Decode

Module::decode parses the WebAssembly binary format into a typed Module: types, imports, functions, tables, memories, globals, elements, data, exports, code bodies. Malformed input produces a structured decode error carrying the byte offset.

Validate

Module::validate checks the module against the WebAssembly specification: type checking, reference subtyping, block result types, init-expression correctness, and structural constraints. This is where the bulk of spec conformance lives — and where the official test suite exercises the engine.

Lower

lower_module translates the stack-machine bytecode into a register IR: a representation better suited to direct execution than the operand stack. Control flow becomes explicit blocks with phi-copy joins; values flow through registers rather than an implicit stack. See The Register IR.

Execute

Store::instantiate resolves imports and builds the runtime state (memories, tables, globals, funcref identity). execute_export then runs the register IR on a self-contained interpreter. Execution is fuel-bound and produces categorised traps (out-of-bounds memory, integer overflow, call exhaustion, unreachable, …).

Workspace layout

crates/
├── baedeker-core/       no_std engine — decode, validate, lower, execute
├── baedeker-ffi/        C ABI (staticlib/cdylib) for host embedding
├── baedeker-cli/        command-line harness + examples
├── baedeker-borsalino/  optional GPU offload (via Borsalino)
├── baedeker-gpu/        GPU compute host module (importable baedeker:gpu ABI)
└── baedeker-testdata/   spec fixtures, incl. the vendored official suite

The no_std core holds no GPU, FFI, or OS code; those live in the std-bearing crates that wrap it. See Embedding Model.

The Register IR

WebAssembly specifies a stack machine: instructions consume operands from and push results to an implicit operand stack. Baedeker does not execute that stack machine directly. Instead, lower_module translates it into a register IR — a representation where values live in named registers and control flow is explicit.

Why lower to registers

A register IR separates what a value is from how it flows:

  • Phi-copy joins. When control flow converges (end of an if/else, a loop back-edge, a br_table target), each branch produces its values into registers, and the join copies them into the continuation’s expected locations. This makes polymorphic-stack and multi-value joins mechanical.
  • No implicit stack. The interpreter never reconstructs operand-stack depths; each instruction reads its inputs from explicit register slots.
  • Type-checked once. Validation runs over the stack machine; the register IR inherits well-typedness, so execution trusts the IR shape.

Branch values and block types

WebAssembly blocks carry result types. Baedeker carries branch values through the register IR: a br to a target with arity n copies n registers into the target’s incoming slots. br_table joins require a consistent arity across all targets and per-target subtype conformance — a property the official br_table spec tests exercise heavily.

Funcref identity

Reference values carry an (instance, function) pair rather than a bare index, so funcref identity is meaningful across linked modules. Instance 0 is the default for unlinked execution; linking rewrites references to the resolved instance.

The register IR is serializable (behind the serde feature), which is what the ahead-of-time pipeline builds on.

Embedding Model

Baedeker’s central design constraint is that the engine core is no_std + alloc. Everything that needs an operating system lives in a std-bearing wrapper crate.

The no_std core

baedeker-core uses no filesystem, no threads, and no OS services — only alloc for heap data structures. This means the same engine that runs in a server process also runs:

  • inside an iOS app (linked as a static library, called from Swift),
  • on bare metal (no allocator surprises, explicit resource limits),
  • inside another WebAssembly runtime (Baedeker compiling to WASM, a future target).

All public APIs use owned types or explicit lifetimes. Error types carry byte offsets into the original binary and structured context, so diagnostics survive the no_std boundary.

The wrapper crates

CrateRolestd?
baedeker-ffiC ABI (staticlib/cdylib), opaque handles, cbindgen headeryes
baedeker-clicommand-line harness and runnable examplesyes
baedeker-borsalinoGPU compute backend adapter (Vulkan/Metal)yes
baedeker-gpuGPU compute host module (importable baedeker:gpu ABI)yes
baedeker-testdataspec fixtures and test WASM (dev-only, not published)yes

The host decides which wrappers to pull in. A server embedder uses the CLI or FFI; an Apple embedder uses the FFI behind a Swift package; a GPU workload adds the Borsalino adapter and the GPU host module.

Resource limits

Because the core is untrusted-input-facing, it carries explicit limits: interpreter fuel (Store::set_fuel), a maximum call depth, and bounds-checked memory/table access that traps rather than escapes. These are the levers an embedder uses to run untrusted modules safely. See Security Considerations.

Installing and Feature Flags

Workspace crates

Baedeker is published to crates.io as a set of crates. Pull in the one that matches your embedding target:

# The engine itself (no_std + alloc).
baedeker-core = "0.1"

# C ABI for host embedding (staticlib/cdylib).
baedeker-ffi = "0.1"

# Optional GPU offload.
baedeker-borsalino = "0.1"
baedeker-gpu = "0.1"

Install the command-line harness with cargo install baedeker-cli.

baedeker-core features

FeatureEnablesDefault
(none)the no_std + alloc engine onlyyes
stdlinks the standard library (for std-bearing hosts)off
serdeserde derives on the register IR (used by tooling)off
aotahead-of-time artifact envelope (serde + postcard)off

Features are additive and independent. A no_std embedder uses the defaults; a host that wants ahead-of-time loading enables aot; a std embedder enables std.

baedeker-borsalino features

FeatureEnables
vulkan (default off-Linux/macOS)the Vulkan backend
metal (default on macOS)the Metal backend

The adapter selects a backend at compile time by target OS; both pull Borsalino from crates.io.

Rust toolchain

Baedeker targets the stable toolchain with the wasm32-unknown-unknown target available. rust-toolchain.toml pins the channel; edition 2024 requires Rust 1.85 or later.

Executing a Module

This page covers the runtime API in detail. For the quick version, see Getting Started.

The pipeline

#![allow(unused)]
fn main() {
use baedeker_core::binary::module::Module;
use baedeker_core::lower::lower_module;
use baedeker_core::runtime::{Store, Value, execute_export};

let module = Module::decode(bytes)?;
module.validate()?;
let reg = lower_module(&module)?;
let store = Store::instantiate(&reg)?;
let results = execute_export(&reg, &store, "add", &[Value::I32(20), Value::I32(22)])?;
}

Module::decode borrows the input bytes (Module<'a>), so it allocates no copy of the binary. lower_module consumes the module into an owned RegModule. Store::instantiate resolves the module’s imports (or fails if a required import is unmet) and builds runtime state.

Values

Arguments and results are Value:

pub enum Value {
    I32(i32), I64(i64), F32(f32), F64(f64),
    FuncRef(Option<(u32, u32)>),
    ExternRef(Option<u32>),
    V128([u8; 16]),
}

The V128 variant carries raw little-endian bytes; lane interpretation happens per operation (fixed-width SIMD).

Fuel and traps

Cap execution with fuel before running untrusted code:

#![allow(unused)]
fn main() {
use baedeker_core::runtime::Store;
// let store: Store = /* ... */;
// store.set_fuel(Some(1_000_000));
}

When fuel is exhausted, execution stops with FuelExhausted rather than running unbounded. Other traps are categorised: out-of-bounds memory/table access, integer overflow on float→int truncation, indirect-call type mismatch, unreachable, call exhaustion (MAX_CALL_DEPTH = 512). Each carries the context needed for a precise diagnostic.

Host functions

Register host functions before instantiation so the module’s imports resolve:

#![allow(unused)]
fn main() {
use baedeker_core::lower::RegModule;
use baedeker_core::runtime::{HostFunction, Store};
fn example(reg: &RegModule, store: &mut Store) {
  use baedeker_core::types::{FuncType, NumType, ValType};
  let ft = FuncType { params: vec![ValType::Num(NumType::I32)], results: vec![] };
  let log = HostFunction::new(ft, Box::new(|_args| Ok(vec![])));
  store.register_host_func("env", "log", log).unwrap();
}
}

register_host_func errors if the module does not declare the import (catching typos) or if the signature mismatches.

Embedding via FFI

baedeker-ffi exposes the engine as a C ABI (staticlib + cdylib) for host embedding from C, C++, Swift, Kotlin, or any language with C interop. A build.rs runs cbindgen to generate baedeker.h from the Rust source.

Handle model

The FFI is handle-based to keep ownership on the Rust side:

HandleOwns
BaedekerModulean Arc<RegModule> (decoded + validated + lowered)
BaedekerInstancea Store + module reference

Compile a module once, instantiate it many times. Every FFI entry returns a status enum; on failure a thread-local last-error string is available via baedeker_last_error.

A C end-to-end run

#include "baedeker.h"

BaedekerModule *mod = baedeker_module_compile(wasm_bytes, len);
BaedekerInstance *inst = baedeker_instance_new(mod);

BaedekerValue args[2] = {{.tag = BAEDeker_I32, .i32 = 20},
                         {.tag = BAEDeker_I32, .i32 = 22}};
BaedekerValue out[1];
size_t n = baedeker_instance_call(inst, "add", args, 2, out, 1);
assert(out[0].i32 == 42);

(See crates/baedeker-ffi/tests/smoke.c for the full, compiling version.)

Memory, fuel, and sizes

The FFI exposes memory read/write (baedeker_instance_memory_data/read/write), fuel (baedeker_instance_set_fuel), and host-function registration. Size parameters are uint64_t from day one, anticipating the memory64 proposal.

The value union

BaedekerValue is an extensible tagged union:

typedef enum { BAEDeker_I32, BAEDeker_I64, BAEDeker_F32,
               BAEDeker_F64, BAEDeker_V128 } BaedekerValueTag;

typedef struct {
    BaedekerValueTag tag;   // a plain uint8_t — see the ABI note below
    union { int32_t i32; int64_t i64; float f32;
            double f64; uint8_t v128[16]; } u;
} BaedekerValue;

The tag is a plain uint8_t, not a C enum: pre-C23 C enums are int-sized (four bytes), which would read three bytes of undefined padding against Rust’s one-byte discriminant. cbindgen is post-processed to emit the tag as a typed enum on both branches.

Apple Platforms (Swift)

Baedeker ships a Swift Package Manager package in swift/ that wraps the FFI into an idiomatic Swift API. It builds a multi-platform XCFramework so the same engine runs on macOS, iOS device, and iOS simulator.

The Swift API

TypeRole
BaedekerModulecompiles or loads an AOT module
BaedekerInstanceinstantiates and calls exports
WasmValuethe value enum (including simd16 for v128)
BaedekerErrormapped from the FFI status + last-error
let module = try BaedekerModule.compile(wasmBytes)
let instance = try BaedekerInstance(module: module)
let result = try instance.call("add", args: [.i32(20), .i32(22)])
// result == [.i32(42)]

callAsync dispatches on a background queue for non-blocking host integration.

Host functions

Host functions are Swift closures bridged through a C trampoline:

instance.registerHostFunction("env", "log") { args in
    print("guest called log:", args)
    return []
}

The trampoline retains the closure for the instance’s lifetime.

Building the XCFramework

swift/build-xcframework.sh builds three slices — macOS universal, iOS device, iOS simulator — and stitches them into a .xcframework. The package’s module.modulemap is staged so SPM can consume the static library. Run it from the repo root; the script is executable and committed.

Fuel and memory

instance.setFuel(_:), instance.memoryData(), and the memory read/write APIs mirror the FFI surface. Fuel is the primary lever for bounding untrusted guest execution on mobile.

GPU Offload

Baedeker can dispatch bulk SIMD work to GPU compute via Borsalino, a thin GPU abstraction over Vulkan (Linux/Windows) and Metal (macOS). There are two distinct surfaces.

Level-1 SIMD offload (engine-internal)

baedeker-core’s runtime optionally holds a GpuBackend slot. Large elementwise operations — currently Store::f32_add_region — dispatch to the GPU when the element count exceeds an offload threshold, falling back to CPU otherwise. Below the threshold, dispatch overhead dominates, so the register IR executes element-wise on CPU. The threshold is tunable per host.

This is transparent to guest modules: a WASM program that adds two large f32 regions simply runs faster when a GPU is attached.

The GPU host module (baedeker-gpu)

For guest modules that want explicit GPU compute, baedeker-gpu exposes a versioned import ABI under the module name baedeker:gpu. A guest imports buffer_create, buffer_upload, kernel_create, dispatch, buffer_read, and friends; the host module owns a GpuBackend and per-instance handle tables, and captures the guest’s linear memory so kernels read from and write back into it.

(import "baedeker:gpu" "buffer_upload" (func $upload (param i32 i32) (result i32)))
(import "baedeker:gpu" "dispatch" (func $dispatch (param i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))

All operands are i32; handles are non-negative indices; every function returns -1 on failure with a stashed diagnostic for gpu_last_error. Bounds are checked against guest memory and buffer sizes.

Layered verification

GPU dispatch is verified in two layers:

  1. dispatch_verified (structural, uniform) — every dispatch carries an explicit threads_per_group; a workgroup-divisibility proof confirms the config is sound. This catches mis-dispatch of non-default @workgroup_size kernels.
  2. Exact-match numerical (opt-in, on demand) — for known-linear kernels, binary {0,1} inputs are run through both the GPU kernel and an FP32 CPU reference and compared with bit-exact equality below the FP16 exact-integer ceiling (2048).

See Verification & Hardening.

Ahead-of-Time Compilation

Baedeker can serialize a lowered module to an ahead-of-time (AOT) artifact, skipping decode + validate + lower at load time. This is useful for embedded targets where startup cost matters or where carrying a validator is unnecessary.

The artifact format

An AOT artifact is a postcard-serialized RegModule wrapped in a small envelope:

BDKAOT1  magic (7 bytes)
< u32 >  version
< postcard-encoded RegModule >

The aot feature (baedeker-core’s aot = serde + postcard) enables serialization and deserialization.

Compile

baedeker compile path/to/module.wasm -o module.bdkaot

The CLI decodes, validates, lowers, and writes the envelope. Or, in code:

#![allow(unused)]
fn main() {
use baedeker_core::binary::module::Module;
use baedeker_core::lower::lower_module;
use baedeker_core::aot;
fn go(bytes: &[u8]) {
let reg = lower_module(&Module::decode(bytes).unwrap()).unwrap();
let artifact = aot::serialize(&reg);
}
}

Load

#![allow(unused)]
fn main() {
use baedeker_core::runtime::{Store, execute_export, Value};
use baedeker_core::aot;
fn go(artifact: &[u8]) {
let reg = aot::deserialize(artifact).expect("valid artifact");
let store = Store::instantiate(&reg).unwrap();
let _ = execute_export(&reg, &store, "add", &[Value::I32(1), Value::I32(2)]);
}
}

Deserialization skips validation — the artifact is trusted. The FFI mirrors this with baedeker_module_from_aot.

Version-locking

AOT artifacts are version-locked: the magic and version prefix let a loader reject artifacts from an incompatible Baedeker version rather than misinterpreting them. Regenerate artifacts when bumping the IR.

API Reference Overview

Baedeker’s API is split across the workspace crates. This book documents the shapes and usage patterns; for exhaustive signatures, the generated rustdoc (cargo doc --no-deps or https://docs.rs/baedeker-core) is authoritative.

CratePrimary surface
baedeker-coreModule, validate, lower_module, Store, execute_export, HostFunction, Value
baedeker-gpuGpuHostModule, the baedeker:gpu import ABI
baedeker-ffithe C ABI (baedeker.h)
baedeker-borsalinoBorsalinoGpu, verify_f32_add

Conventions

  • Newtype indicesTypeIdx(u32), FuncIdx(u32), LabelIdx(u32), etc. rather than bare u32, so you cannot pass a function index where a type index is expected.
  • Exhaustive enumsValue, ValType, trap kinds, and section kinds are enums, not boolean flags.
  • Owned or lifetime’d — public APIs use owned types or explicit lifetime parameters; Module<'a> borrows the input bytes.
  • Structured errors — decode/validate errors carry byte offsets; runtime errors are categorised traps.

The naming follows the WebAssembly spec: FuncType, ValType, BlockType, MemArg, and so on. Internal IR types are prefixed (RegInstr, RegBlock, RegFunc).

Core Engine API

The engine lives in baedeker_core. The pipeline types are re-exported at the crate root and in runtime.

Decode + validate + lower

pub struct Module<'a> { /* types, imports, exports, functions, ... */ }

impl Module<'_> {
    pub fn decode(bytes: &[u8]) -> Result<Module<'_>, DecodeError>;
    pub fn validate(&self) -> Result<(), ValidationError>;
}

pub fn lower_module(module: Module<'_>) -> Result<RegModule, LowerError>;

Module exposes its parsed sections as public fields (types, imports, exports, functions, memories, globals, tables, data, codes, …) so tooling can inspect a module without executing it.

The runtime

pub struct Store { /* memories, tables, globals, funcrefs, fuel, gpu slot */ }

impl Store {
    pub fn instantiate(module: &RegModule) -> Result<Store, RuntimeError>;
    pub fn register_host_func(&mut self, module: &str, name: &str, f: HostFunction)
        -> Result<(), RuntimeError>;
    pub fn set_fuel(&mut self, fuel: Option<u64>);
    pub fn with_memory(&self, idx: usize, f: impl FnOnce(&[u8])) -> Option<()>;
    pub fn with_memory_mut(&mut self, idx: usize, f: impl FnOnce(&mut [u8])) -> Option<()>;
}

pub fn execute_export(
    module: &RegModule,
    store: &Store,
    name: &str,
    args: &[Value],
) -> Result<Vec<Value>, RuntimeError>;

Values and types

pub enum Value {
    I32(i32), I64(i64), F32(f32), F64(f64),
    FuncRef(Option<(u32, u32)>), ExternRef(Option<u32>),
    V128([u8; 16]),
}

ValType and FuncType mirror the spec; HostFunction::new(func_type, closure) wraps a host callable. Host closures are Box<dyn FnMut(&[Value]) -> Result<Vec<Value>, RuntimeError>>.

Errors

Decode and validation errors carry byte offsets into the original binary. Runtime errors are categorised (RuntimeErrorKind): unknown export/memory, traps (RuntimeTrap), fuel exhaustion, and GPU errors. See the rustdoc for the full taxonomy.

GPU Host Module API

baedeker_gpu exposes the baedeker:gpu import ABI as a host module a guest WASM module can import for explicit GPU compute.

Building and registering

use baedeker_core::runtime::Store;
use baedeker_gpu::GpuHostModule;
fn example(store: &mut Store, reg: &baedeker_core::lower::RegModule, backend: ()) {
  // let backend: Box<dyn baedeker_core::runtime::gpu::GpuBackend> = /* ... */;
  let backend: Box<dyn baedeker_core::runtime::gpu::GpuBackend> = unimplemented!();
let gpu = GpuHostModule::for_store(backend, store).unwrap();
gpu.register(store, reg).unwrap();
}

for_store captures the instance’s linear memory 0 (it fails if the module declares none). register wires up every baedeker:gpu import the module declares and silently skips the rest.

The v1 ABI

All imports take i32 operands; handles are non-negative indices. Every function returns -1 (or a non-positive length for gpu_last_error) on failure, stashing a diagnostic.

ImportSignature
gpu_probe() -> i32
buffer_create(size) -> handle
buffer_upload(mem_offset, len) -> handle
buffer_read(handle, buf_offset, mem_offset, len) -> i32
kernel_create(code_ptr, code_len) -> handle
dispatch(kernel, wg_x, wg_y, wg_z, tpg_x, tpg_y, tpg_z, bindings_ptr, bindings_len) -> i32
gpu_last_error(mem_offset, max_len) -> bytes_written

dispatch routes through GpuBackend::dispatch_verified so the explicit per-workgroup thread count is honoured.

v1 constraints

The backing GpuBackend trait offers whole-buffer upload (on create) and full readback, but no partial writes or explicit destruction. v1 is therefore upload-on-create + read-back: upload inputs, allocate an uninitialised output, dispatch, read the result. Re-uploading mid-computation leaks the prior buffer until the instance is dropped — a v2 concern once the trait gains a partial-write method.

Verification & Hardening

Baedeker is verified against three independent oracles, each catching a different class of bug.

The official spec suite

The WebAssembly specification ships a conformance test suite. Baedeker vendors it (baedeker-testdata) and runs it through a harness:

85 files, 12 deferred, 19,204 assertions, 993 modules, 0 failures.

The 12 deferred files are the WebAssembly 3.0 surface (tail calls, GC types, exceptions, memory64) — features on the post-release roadmap, not a 2.0 gap. Every conformance bug the suite exposed during development was fixed; the remaining green is real.

Differential testing vs Wasmtime

A differential harness generates modules with wasm-smith, executes them on both Baedeker and Wasmtime, and compares results. A sweep of 9,024 executions produced zero divergences. This catches correctness bugs the spec suite does not exercise (spec-conformant but unusual module shapes) and guards against regressions.

Fuzzing

cargo-fuzz targets cover the untrusted-input surface:

  • decode — arbitrary bytes through the decoder (OOM-hardened).
  • validate_lower — decode + validate + lower, asserting no panics.
  • trap_edges — modules that should trap, asserting the right trap.
  • smith_module — wasm-smith modules end-to-end.

Trap-edge fuzzing ran 3M iterations clean.

GPU verification (both, layered)

GPU dispatch correctness matters because GPU floating-point is non-associative — different thread orderings produce different accumulation sequences, so tolerance-based checks are unreliable. Baedeker verifies in two layers:

  1. dispatch_verified (structural, every dispatch) — the explicit threads_per_group is honoured via Borsalino’s dispatch_ex, and a workgroup-divisibility proof confirms the config is sound. This catches silent mis-dispatch of non-default @workgroup_size kernels.
  2. Exact-match numerical (opt-in, on demand) — for known-linear kernels, binary {0,1} inputs guarantee every partial sum is a small non-negative integer, exact within the FP16 ceiling (2048). The GPU output is compared against an FP32 CPU reference with bit-exact equality below the threshold.

The pure comparison core (compare_outputs) is unit-tested without a GPU; the dispatch driver is generic over GpuBackend so the pass/fail logic is tested with CPU fakes, and an #[ignore] lavapipe test proves it on real hardware.

Security Considerations

Baedeker executes WebAssembly, which is designed to be a safe sandbox. This page records how Baedeker upholds that and where its limits are. Baedeker is language-runtime infrastructure: its binary parsing, malformed-input handling, and validation exist to improve safe execution behavior, not for offensive use.

The sandbox contract

A correctly validated WebAssembly module, executed by a conformant engine, cannot:

  • read or write memory outside its linear memories (bounds-checked access traps),
  • call functions it did not import or export (type-checked indirect calls),
  • run forever (interpreter fuel caps execution),
  • recurse without bound (MAX_CALL_DEPTH = 512).

Baedeker upholds these through validation + categorised traps + fuel.

Resource limits

LimitMechanism
Execution timeStore::set_fuel(Some(n)) — stops with FuelExhausted
Recursion depthMAX_CALL_DEPTH = 512 — traps on exhaustion
Memory accessbounds-checked; traps OutOfBoundsMemoryAccess
Table accessbounds-checked; sparse-capable storage
Integer conversionout-of-range float→int traps IntegerOverflow

An embedder running untrusted modules sets fuel before execution and treats any trap as a guest error, not a host crash.

Malformed input

Decode and validation reject malformed or non-conformant binaries with structured errors carrying byte offsets. There is no unsafe in baedeker-core outside performance-critical interpreter dispatch (each such block carries a SAFETY: comment). OOM on pathologically large inputs is guarded (the fuzz targets harden the decoder against allocation bombs).

GPU offload

GPU dispatch is sandboxed to the host module’s handle tables: a guest cannot forge buffer or kernel handles, and every binding is bounds-checked against guest memory and buffer sizes. The verification layers (see Verification) address GPU numerical correctness, which is orthogonal to the memory sandbox.

Known limitations

  • WebAssembly 3.0 proposals (tail calls, exception handling, memory64, GC, threads) are not implemented; modules using them are rejected at validation. See Roadmap.
  • Fuel accounting is instruction-count-based, not wall-clock; a host that needs wall-clock deadlines should layer its own watchdog.
  • Spectre-class side channels are out of scope for a software interpreter; rely on process isolation for cross-tenant workloads.

Critique & Future Work

Snapshot: 0.1.0 (unreleased). This is an honest self-assessment at the first public release, not a marketing document. It records what is solid, what is deliberately narrow, and what is known to be missing.

What is solid

  • Spec conformance. The official WebAssembly 2.0 suite runs at 19,204 assertions / 0 failures. This is the strongest available evidence that the engine is correct, and it is green, not aspirational.
  • Differential agreement. Zero divergences against Wasmtime across a wasm-smith sweep. A second, independent oracle agrees.
  • Embeddability. The no_std core genuinely has no OS dependencies, and the FFI + Swift + AOT surface is exercised by tests, not just sketched.
  • GPU verification is layered and honest. Structural proof runs on every dispatch; numerical proof is opt-in for known-linear kernels with a clearly stated applicability bound (not non-linear ops).

What is deliberately narrow

  • The interpreter is a register-IR interpreter, not a JIT. This is a deliberate 0.1.0 choice for portability and embeddability (no codegen, no platform-specific backend). Throughput is adequate for many embedders but not competitive with Cranelift/Wasmtime on hot loops. A register-block JIT is a future integration-level option (see the Borsalino integration notes).
  • GPU offload is one kernel deep. f32_add is the offloaded operation; the host-module ABI is upload-on-create + read-back. This proves the pipeline end to end; expanding the kernel set and adding partial-write/destroy to the backend trait are follow-ups.
  • Numerical verification covers f32_add. The exact-match protocol applies to linear/bilinear kernels; the reference for each additional kernel must be written. Non-linear kernels are out of scope for this protocol by design.

Known gaps

  • WebAssembly 3.0 is absent. Tail calls, exception handling, memory64, GC, and threads are rejected at validation. These are the roadmap, not a defect of the 2.0 milestone.
  • No hosted documentation site yet at release. This book is the first cut; the API reference points at rustdoc and is intentionally terse.
  • MSRV is asserted (1.85) but CI tests on stable only. A dedicated MSRV CI job would harden the claim.
  • Threading/shared-everything threads are a post-3.0 consideration; the engine is single-threaded by design today.

What this release is not

  • Not a production-hardated server runtime (no observability hooks, no component-model support).
  • Not a JIT (interpreter only).
  • Not a drop-in Wasmtime replacement (smaller surface, different goals).

It is a correct, portable, embeddable WebAssembly 2.0 engine with a real platform-integration story — a credible foundation for the 3.0 work and for running the IA ecosystem on any platform.

Roadmap

Status snapshot: 0.1.0 (unreleased). WebAssembly 2.0 is complete and verified; 3.0 proposals are the forward plan.

Released

  • WebAssembly 2.0 core — full reference types, fixed-width SIMD, multi-value, bulk memory, multi-module linking, host functions.
  • Platform integration (Phase 5) — C ABI, Swift package, GPU offload (Borsalino), GPU host module, AOT pipeline, interpreter fuel.
  • Verification — official spec suite green, differential testing vs Wasmtime, cargo-fuzz targets, layered GPU verification.

In progress / next

  • 0.1.0 release — crates.io publish of core/cli/ffi/borsalino/gpu; tag v0.1.0. (This book is part of that release polish.)

WebAssembly 3.0 proposals (the post-release track)

Ordered roughly by dependency and value:

  1. Tail calls (return_call / return_call_indirect / return_call_ref) — the deferred spec files exist; validation lowering is the work.
  2. Exception handling — the try/catch/throw proposal.
  3. memory64 — 64-bit memories. The FFI already uses uint64_t sizes in anticipation.
  4. Garbage collection — struct/array/reference types, GC compaction. This is where Borsalino 0.6.0’s GC-safety epoch tracking (prove_quiescent / dispatch_verified_gc) becomes relevant: compaction must defer while GPU dispatches are in flight.
  5. Threads / shared-everything — shared memories and atomic operations; single-threaded today.

Each proposal is engine-internal and does not affect the ABI shape — except memory64, which is why the FFI sizes are u64 from day one.

Ecosystem integration

  • Amari is the first workload — geometric algebra on WASM across platforms.
  • The GPU host module’s geometric-product kernel and Borsalino’s GeometricProductReference (a 5D GA exact-match reference) point at GPU-accelerated algebra as a near-term integration target once Amari targets Baedeker.

Verification growth

  • More offload kernels gain exact-match numerical references as they are added.
  • A determinism-check layer (Borsalino’s determinism module) is available for a future verification slice.

Example: Execute a Module

The full decode → validate → lower → instantiate → execute pipeline. This is crates/baedeker-cli/examples/execute.rs, runnable with cargo run -p baedeker-cli --example execute.

use baedeker_core::binary::module::Module;
use baedeker_core::lower::lower_module;
use baedeker_core::runtime::{Store, Value, execute_export};

fn main() {
    let bytes: &[u8] = include_bytes!("../../../crates/baedeker-cli/examples/add.wasm");

    let module = Module::decode(bytes).expect("decode failed");
    module.validate().expect("validation failed");
    let reg = lower_module(&module).expect("lowering failed");
    let store = Store::instantiate(&reg).expect("instantiation failed");

    let results = execute_export(&reg, &store, "add", &[Value::I32(20), Value::I32(22)])
        .expect("execution trapped");

    println!("add(20, 22) = {results:?}");
    assert_eq!(results, vec![Value::I32(42)]);
}

The add.wasm module exports a single function add(i32, i32) -> i32 that returns the sum of its arguments. In your own code, replace include_bytes! with whatever supplies the .wasm bytes.

Expected output

add(20, 22) = [I32(42)]

Example: Inspect a Module

Decode a .wasm binary and summarise its structure — the first pipeline stage, without validating or executing. This is crates/baedeker-cli/examples/inspect.rs, runnable with cargo run -p baedeker-cli --example inspect.

use baedeker_core::binary::module::Module;

fn main() {
    let bytes: &[u8] = include_bytes!("../../../crates/baedeker-cli/examples/add.wasm");
    let module = Module::decode(bytes).expect("decode failed");

    println!("Baedeker decoded add.wasm:");
    println!("  types:     {}", module.types.len());
    println!("  functions: {}", module.functions.len());
    println!("  exports:   {}", module.exports.len());
    println!("  memories:  {}", module.memories.len());
    println!("  globals:   {}", module.globals.len());

    for export in &module.exports {
        println!("  export:    {}", export.name);
    }
}

Module exposes its parsed sections as public fields (types, imports, exports, functions, tables, memories, globals, elements, data, codes, start), so tooling can inspect a module without running it.

Expected output

Baedeker decoded add.wasm:
  types:     1
  functions: 1
  exports:   1
  memories:  0
  globals:   0
  export:    add