WASM Boundary
How the Rust engine compiles to WebAssembly, what it exports, and how web workers run it in the browser.
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.
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:
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:
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:
wasm-pack build --target webproduces an ESM module with an asyncdefault()init and a synchronousinitSync({module}).- Stamp the package version to
{baseVersion}-{sha256(bg.wasm).slice(0,12)}, a content hash so a changed engine busts caches. pnpm packthe tarball intopackages/archives, then rewriteapps/studio/package.json'sfile: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:
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:
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:
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.
Next steps
