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:
- Decode the binary format into a typed module structure.
- Validate it against the WebAssembly specification.
- Lower the stack-machine bytecode into a register IR.
- 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.
- Embeddable —
no_stdcore; 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(®).expect("instantiate");
let results = execute_export(®, &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 — the four-stage pipeline in depth.
- Executing a Module — the runtime API.
- Embedding via FFI — the C ABI for host embedding.
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, abr_tabletarget), 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
| Crate | Role | std? |
|---|---|---|
baedeker-ffi | C ABI (staticlib/cdylib), opaque handles, cbindgen header | yes |
baedeker-cli | command-line harness and runnable examples | yes |
baedeker-borsalino | GPU compute backend adapter (Vulkan/Metal) | yes |
baedeker-gpu | GPU compute host module (importable baedeker:gpu ABI) | yes |
baedeker-testdata | spec 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
| Feature | Enables | Default |
|---|---|---|
| (none) | the no_std + alloc engine only | yes |
std | links the standard library (for std-bearing hosts) | off |
serde | serde derives on the register IR (used by tooling) | off |
aot | ahead-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
| Feature | Enables |
|---|---|
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(®)?;
let results = execute_export(®, &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:
| Handle | Owns |
|---|---|
BaedekerModule | an Arc<RegModule> (decoded + validated + lowered) |
BaedekerInstance | a 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
| Type | Role |
|---|---|
BaedekerModule | compiles or loads an AOT module |
BaedekerInstance | instantiates and calls exports |
WasmValue | the value enum (including simd16 for v128) |
BaedekerError | mapped 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:
dispatch_verified(structural, uniform) — every dispatch carries an explicitthreads_per_group; a workgroup-divisibility proof confirms the config is sound. This catches mis-dispatch of non-default@workgroup_sizekernels.- 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).
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(®);
}
}
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(®).unwrap();
let _ = execute_export(®, &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.
| Crate | Primary surface |
|---|---|
baedeker-core | Module, validate, lower_module, Store, execute_export, HostFunction, Value |
baedeker-gpu | GpuHostModule, the baedeker:gpu import ABI |
baedeker-ffi | the C ABI (baedeker.h) |
baedeker-borsalino | BorsalinoGpu, verify_f32_add |
Conventions
- Newtype indices —
TypeIdx(u32),FuncIdx(u32),LabelIdx(u32), etc. rather than bareu32, so you cannot pass a function index where a type index is expected. - Exhaustive enums —
Value,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.
| Import | Signature |
|---|---|
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:
dispatch_verified(structural, every dispatch) — the explicitthreads_per_groupis honoured via Borsalino’sdispatch_ex, and a workgroup-divisibility proof confirms the config is sound. This catches silent mis-dispatch of non-default@workgroup_sizekernels.- 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
| Limit | Mechanism |
|---|---|
| Execution time | Store::set_fuel(Some(n)) — stops with FuelExhausted |
| Recursion depth | MAX_CALL_DEPTH = 512 — traps on exhaustion |
| Memory access | bounds-checked; traps OutOfBoundsMemoryAccess |
| Table access | bounds-checked; sparse-capable storage |
| Integer conversion | out-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_stdcore 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_addis 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:
- Tail calls (
return_call/return_call_indirect/return_call_ref) — the deferred spec files exist; validation lowering is the work. - Exception handling — the
try/catch/throwproposal. - memory64 — 64-bit memories. The FFI already uses
uint64_tsizes in anticipation. - 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. - 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
determinismmodule) 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(®).expect("instantiation failed");
let results = execute_export(®, &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