Command Palette

Search for a command to run...

Anmelden

WASM Boundary

How the Rust engine compiles to WebAssembly, what it exports, and how web workers run it in the browser.

5 Min. Lesezeit

The same Rust engine that runs on a hosted node also runs in your browser tab. Two crates, wowlab-engine and wowlab-common, compile to WebAssembly, and the simulation runs in a web worker rather than on the main thread, so a long sim never freezes the UI. Game data the engine needs is fetched back across the boundary through a JavaScript callback object.

This page expands the Browser box of the system-context diagram. The figure below is the Zoom-1 wasm-boundary figure; this page also owns the Zoom-2 worker-pool-state view of its web worker box.

Abbildung 21
WASM Boundary
Expands the Browser box of the system context: the engine and common crates compiled to wasm-bindgen exports, loaded in a Comlink web worker, with JsResolver adapting the JS resolver (in-memory, IndexedDB, Supabase).

What compiles, and what it exports

The engine crate is built crate-type = ["rlib", "cdylib"]; the cdylib is what produces the .wasm. Its WASM module is gated behind both target_arch = "wasm32" and a wasm feature, and it force-links the generated spec content so the inventory registry survives dead-stripping. There is no LLVM in the browser, so the WASM build always uses the rotation interpreter, never the JIT. See the rotation compiler.

The engine's WASM module has five submodules, init, metadata, rotation, simulation, error, and exports these functions across the boundary:

Tabelle 13
Engine WASM Exports
Export (js_name)ModuleAsync?What it does
runSimulationsimulationyesRuns a chunk, returns protobuf ChunkTelemetry bytes
runSimulationWithProgresssimulationyesSame, plus a per-tick JS progress callback; returns {bytes, chunkIndex}
runIterationTracesimulationyesOne traced iteration for the preview pane (forces the interpreter)
getImplementedSpecsmetadatanoArray of implemented specs (id, class, slug, counts)
getSpecIntrospectionmetadatanoSpell/aura names with zeroed values (empty game data)
getSpecIntrospectionResolvedmetadatayesIntrospection with real resolved values
validateRotationrotationnoStructural rotation validation
validateRotationForSpecrotationnoSpec-aware validation (resolves spell/aura/talent names)
getFieldDescriptorsrotationnoThe rotation field schema for a spec
getEngineVersion / getEngineGitHashmetadatanoBuild identity
The wasm-bindgen exports of the engine crate by module (runSimulation, runSimulationWithProgress, runIterationTrace, metadata, rotation validation), sync vs async, and purpose.

The parse, build, and decode helpers a host also needs, parseSimc, buildSimConfig, decodeJobResult, decodeAndDerive, resolveItem, are not in the engine crate. They live in wowlab-common. Because wowlab-engine depends on wowlab-common with the wasm feature, the engine bundle's .d.ts re-exports all of them too, so the engine package is a superset. Studio nonetheless imports parse/decode/build from the wowlab-common package and sim/trace/metadata from wowlab-engine. The content system and simulation UI consume these on the main thread.

When the module instantiates, init_wasm_runtime runs once: it calls the WASM constructors, force-links the generated specs, installs a panic hook, and panics if the spec registry is empty:

rust
let descriptor_count = inventory::iter::<SpecDescriptor>().count();
if descriptor_count == 0 {
    // #t(panic) boot invariant: spec registry must not be empty after wasm initialization
    panic!(
        "engine spec registry is empty in wasm runtime; check inventory ctor wiring (__wasm_call_ctors) and generated force-link registrations"
    );
}

That panic is a boot invariant. An engine that links but registers no specs is broken, and failing loudly at instantiate is better than returning empty results later. The installed panic hook also posts engine panics back to the worker host via self.postMessage({ type: "wowlab:engine-panic" }), so a crash inside the WASM module surfaces as a message on the main thread instead of a silent wedge.

The build pipeline

pnpm build runs buildCommon then buildEngineWasm, each calling buildWasmPkg. The pipeline is:

  1. wasm-pack build --target web produces an ESM module with an async default() init and a synchronous initSync({module}).
  2. Stamp the package version to {baseVersion}-{sha256(bg.wasm).slice(0,12)}, a content hash so a changed engine busts caches.
  3. pnpm pack the tarball into packages/archives, then rewrite apps/studio/package.json's file: dependency to point at it.

Studio's predev/prebuild then run sync:wasm, which copies both .wasm blobs into apps/studio/public/wasm/ under content-hashed names and writes a manifest.json mapping engineWasm/commonWasm to their hashed URLs. The browser reads that manifest to fetch the right bytes.

Loading: default() on main, initSync() in workers

The main thread loads each module lazily as a singleton: await import("wowlab-engine") then await m.default(), which fetches and instantiates the .wasm. The WasmIsland provider gates this behind a mount check and a WebAssembly.Module support probe, then exposes useCommon()/useEngine() to consumers for synchronous main-thread calls like parse and decode.

Workers take a different path. The pool fetches the .wasm bytes once on the main thread and Comlink.transfers the ArrayBuffers into each worker zero-copy. Each worker then instantiates each module synchronously from the bytes it was handed, guarded so a repeat init() is a no-op. Both modules follow the same shape:

typescript
if (!commonReady) {
  commonMod.initSync({ module: commonWasm });
  commonReady = true;
}

So the main thread uses default(), which is async and fetches, and workers use initSync(), which is synchronous and reads transferred bytes. The split avoids re-fetching the same .wasm per worker.

The JS resolver protocol

The engine never reaches the network itself. Inside run_simulation it wraps the JS object the host passed in with JsResolver::new, then erases it to a DynDataResolver. JsResolver implements the Rust DataResolver trait by reflecting on the JS object: every call does Reflect::get, casts to a js_sys::Function, calls it, casts the result to a Promise, and awaits via JsFuture:

rust
async fn call_method(&self, method: &str, args: &[&JsValue]) -> Result<JsValue, ResolverError> {
    let func = js_sys::Reflect::get(&self.inner, &JsValue::from_str(method)).map_err(|e| {
        ResolverError::JsBridge {
            method: method.to_string(),
            message: format!("missing method: {e:?}"),
        }
    })?;
    let func: js_sys::Function = func.dyn_into().map_err(|e| ResolverError::JsBridge {
        method: method.to_string(),
        message: format!("not a function: {e:?}"),
    })?;
    let promise = match args {
        [] => func.call0(&self.inner),
        [a] => func.call1(&self.inner, a),
        _ => unreachable!("call_method supports 0 or 1 args"),
    }
    .map_err(|e| ResolverError::JsBridge {
        method: method.to_string(),
        message: format!("call failed: {e:?}"),
    })?;
    let promise: js_sys::Promise = promise.dyn_into().map_err(|e| ResolverError::JsBridge {
        method: method.to_string(),
        message: format!("did not return a Promise: {e:?}"),
    })?;
    JsFuture::from(promise)
        .await
        .map_err(|e| ResolverError::JsBridge {
            method: method.to_string(),
            message: format!("rejected: {e:?}"),
        })
}

All deserialization is serde_wasm_bindgen, no JSON roundtrip. The trait methods are thin wrappers over this one helper.

The JS object must implement five methods: getSpell(id) returns SpellDataFlat, getItem(id) returns ItemDataFlat, getScalingData() returns ItemScalingData, getPowerTypes() returns the power-type list, and getRotationScript(id) returns a string. The studio implementation backs each method with the same 3-layer read-through cache the resolver chapter describes: an in-memory Map, then IndexedDB keyed under patch-v1:, then Supabase PostgREST. A spell or item resolved by one chunk is cheap for the next.

The worker pool

The sim path runs in a pool of workers, each created with new Worker(new URL("sim-worker.ts", ...), { type: "module" }) and wrapped with Comlink.wrap. The WorkerManager defaults are a watchdog of 120_000 ms, maxWorkers 8, and poolSize 4; the auto worker count is min(navigator.hardwareConcurrency, maxWorkers).

A single slot's life looks like this:

Abbildung 22
Browser Worker Pool
Expands the web-worker box of the WASM boundary: a pool slot from initializing to idle to busy (fetching, simulating, signing, submitting) and back, with watchdog timeout to restart.

Inside runChunk, the worker builds a resolver, calls runSimulationWithProgress whose progress callback updates live counters and emits per-phase metrics (fetching, simulating, signing, submitting), normalizes the returned {bytes, chunkIndex} to a Uint8Array, signs the body with the node keypair via buildSignMessageBytes + signMessage, and POSTs the protobuf to the sentinel's /chunks/complete with the X-Node-Key/X-Node-Sig/X-Node-Ts headers. That Ed25519 signing and the sentinel side of the handshake are covered under hosted compute; the browser is just one more signing node.

The watchdog is the resilience mechanism. Each slot arms a setTimeout; every progress beat re-arms it. If a slot goes silent past the timeout, a wedged sim, an engine panic, or a hung fetch, the manager marks it errored and restarts the whole pool: it rejects pending work, releases the Comlink proxy, and terminates the worker. This is coarse, one stuck slot restarts all of them, but it keeps a single bad chunk from silently stalling the contribution loop, and the chunk it dropped is reclaimed by the sentinel for another node.

Nächste Schritte