Latest changes: 0.8.0
WACS is a pure C# WebAssembly Interpreter for running WASM modules in .NET environments, including Godot and AOT environments like Unity's IL2CPP.
WACS supports the latest standardized webassembly feature extensions including Garbage Collection and JSPI-like async execution.
- Features
- WebAssembly Feature Extensions
- Getting Started
- Installation
- Usage
- Integration with Unity
- Integration with Godot
- Interop Bindings
- Customization
- Performance
- Roadmap
- License
- Unity Compatibility: Compatible with Unity 2021.3+ including AOT/IL2CPP modes for iOS.
- Godot Compatibility: Compatible with Godot Engine - .NET.
- Pure C# Implementation: Written in C# 9.0/.NET Standard 2.1. (No unsafe code)
- No Complex Dependencies: Uses FluentValidation and Microsoft.Extensions.ObjectPool as its only dependencies.
- WebAssembly 3.0 Spec Compliance: Passes the WebAssembly 3.0 spec test suite.
- Magical Interop: Host bindings are validated with reflection, no boilerplate code required.
- Async Tasks: JSPI-like non-blocking calls for async functions.
- WASI: Wacs.WASIp1 provides a wasi_snapshot_preview1 implementation.
WACS is for mobile games.
Because WebAssembly is memory-safe and can be ahead-of-time validated, WACS makes it possible to build safe, verifiable UGC, DLC, or plugin systems that include executable logic.
WACS is based on the WebAssembly Core 3 draft spec and passes the associated test suite.
Support for all standardized extensions is listed below.
Harnessed results from wasm-feature-detect as compares to other runtimes:
| Proposal | Features | |
|---|---|---|
| Phase 5 | ||
| JavaScript BigInt to WebAssembly i64 integration | ✳️ | |
| Bulk memory operations | ✅ | |
| Extended Constant Expressions | extended_const | ✅ |
| Garbage collection | gc | ✅ |
| Multiple memories | multi-memory | ✅ |
| Multi-value | multi_value | ✅ |
| Import/Export of Mutable Globals | ✅ | |
| Reference Types | ✅ | |
| Relaxed SIMD | relaxed_simd | ✅ |
| Non-trapping float-to-int conversions | ✅ | |
| Sign-extension operators | ✅ | |
| Fixed-width SIMD | ✅ | |
| Tail call | tail_call | ✅ |
| Typed Function References | function-references | ✅ |
| Phase 4 | ||
| Exception handling | exceptions | ✅ |
| JS String Builtins | ❌ | |
| Memory64 | memory64 | ✅ |
| Threads | threads | ❌ |
| Phase 3 | ||
| JS Promise Integration | jspi | ✳️ |
| Type Reflection for WebAssembly JavaScript API | type-reflection | 🌐 |
| Legacy Exception Handling | exceptions | ❌ |
| Streaming Compilation | streaming_compilation | 🌐 |
The easiest way to use WACS is to add the package from NuGet
dotnet add package WACS
dotnet add package WACS.WASIp1WACS.Transpiler is a companion package that ahead-of-time transpiles a
.wasm module into a .NET assembly. Installs as a dotnet global
tool,
backed by the same WACS runtime:
dotnet tool install -g WACS.Transpiler
wasm-transpile -i module.wasm -o module.dllFor WASI preview1 modules (CoreMark, anything built against wasi-libc):
wasm-transpile -i coremark.wasm -o coremark.dll --wasi --entry-point _start --run--wasi binds WACS.WASIp1 to the runtime, forwards all
wasi_snapshot_preview1 imports, shares memory with the interpreter
bindings, and invokes the entry-point export in-process. For custom
host imports (env.sayc, game bindings, etc.), use the library API
with BindHostFunction + an ImportDispatcher proxy; see
Wacs.Transpiler/README.md for the full
flag surface, library API, and v0.1 known limitations.
If you prefer to build WACS from source, you can clone the repo and build it with the .NET SDK:
git clone https://github.com/kelnishi/WACS.git
cd WACS
dotnet buildBasic usage example, how to load and run a WebAssembly module:
using System;
using System.IO;
using Wacs.Core;
using Wacs.Core.Runtime;
//Create a runtime
var runtime = new WasmRuntime();
//Bind a host function
// This can be any regular C# delegate.
// The type here will be validated against module imports.
runtime.BindHostFunction<Action<char>>(("env", "sayc"), c =>
{
System.Console.Write(c);
});
//Load a module from a binary file
using var fileStream = new FileStream("HelloWorld.wasm", FileMode.Open);
var module = BinaryModuleParser.ParseWasm(fileStream);
//Instantiate the module
var modInst = runtime.InstantiateModule(module);
//Register the module to add its exported functions to the export table
runtime.RegisterModule("hello", modInst);
//Get the module's exported function
if (runtime.TryGetExportedFunction(("hello", "main"), out var mainAddr))
{
//For wasm functions you can expect return types as Wacs.Core.Runtime.Value
// Value has implicit conversion to many useful primitive types
var mainInvoker = runtime.CreateInvokerFunc<Value>(mainAddr);
//Call the wasm function and get the result
// Implicit conversion from Value to int
int result = mainInvoker();
System.Console.Error.WriteLine($"hello.main() => {result}");
}- Window>Package Manager
- Click + Add package from git URL...
- Enter the package repo URL:
git@github.com:kelnishi/WACS-Unity.git
- Click Add
This will put the DLLs into your project. Import the WasmRunner sample to get started.
To manually add WACS to a Unity project, you'll need to add the following DLLs to your Assets directory:
- Wacs.Core.dll
- FluentValidation.dll
- Microsoft.Extensions.ObjectPool.dll
Set Player Settings>Other Settings>Api Compatibility Level to .NET Standard 2.1.
WACS is compatible with Godot Engine -.NET in C# projects.
- Add WACS via NuGet with the commandline or your IDE's NuGet tool.
- See sample/GodotSample.cs for loading wasm files.
WACS simplifies host function bindings, allowing you to easily call .NET functions from WebAssembly modules. This allows seamless communication between your host environment and WebAssembly without boilerplate code. Similarly, calling into wasm code is done by generating a typed delegate.
Example from WASIp1:
//Alias your types for readability
using ptr = System.Int32;
//WACS can bit-convert types like Enums and explicit layout structs
[WasmType(nameof(ValType.I32))]
public enum ErrNo : ushort
{
Success = 0,
...
}
//Supply the delegate definition when binding
// ExecContext is an optional first parameter for Memory and Stack manipulation
runtime.BindHostFunction<Func<ExecContext,ptr,ptr,ErrNo>>(
(module, "args_get"), ArgsGet);
// WASIp1's args_get
public ErrNo ArgsGet(ExecContext ctx, ptr argvPtr, ptr argvBufPtr)
{
var mem = ctx.DefaultMemory;
foreach (string arg in _config.Arguments)
{
// Copy argument string to argvBufPtr.
int strLen = mem.WriteUtf8String((uint)argvBufPtr, arg, true);
// Write pointer to argument in argvPtr.
mem.WriteInt32(argvPtr, argvBufPtr);
// Update offsets.
argvBufPtr += strLen;
argvPtr += sizeof(ptr);
}
return ErrNo.Success;
}If you'd like to customize the wasm runtime environment, I recommend downloading the full source for examples.
The Wacs.WASIp1 implementation is a good starting point for how to set up your own library of bindings.
It also contains examples of more advanced usage like binding multiple return values and full operand stack access.
The Spec.Test project runs the wasm spec test suite. This also contains examples for binding other runtime environment
objects like Tables, Memories, and Variables.
Custom Instruction implementations can be patched in by replacing or inheriting from SpecFactory.
WACS is a bytecode (wasm) interpreter running on a bytecode interpreted (or JIT'd) language (CIL/CLR). This is, as you can imagine, not a recipe for raw performance. However, recognizing this dynamic allows us to make certain optimizations to achieve performance closer to other languages in other VMs.
The Wasm Virtual Machine is a stack machine. This means that instructions produce operands, place them on the stack, and then other instructions consume them by popping them from the stack. WACS uses a pre-allocated linear stack for more register-like performance. However, even a virtualized stack is costly to manage as the CLR will still need to manage memory and objects at its boundaries. To optimize further, we'll need to opportunistically use register-machine semantics by swapping out equivalent operations.
The design of the WASM VM includes block labelling for branch instructions and a heterogeneous operand/control stack. WACS uses a split stack that separates operands and control. This enables us to make some key optimizations:
- Non-flushing branch jumps. We can leave operands on the stack if intermediate states don't interfere.
- Precomputed block labels. We can ditch the control frame's label stack entirely!
- Modern C# ObjectPools and ArrayPools minimize unavoidable allocation
Here's where we break WASM semantics and go off-road to claw back some performance. A linear list of WASM instructions can be inverted into an expression tree. The WAT text format supports both the linear and the tree structure; they are conceptually equivalent. We'll use this similarity by applying the transform to the binary AST. Take for example, this sequence:
i32.const 5 <- Pushes 5 onto the stack
i32.const 7 <- Pushes 7 onto the stack
i32.add <- Pops 7, Pops 5, Pushes 12 onto the stack
For a sequence representing 5+7, this is performing potentially 8+ function calls, multiple Value.ctors, memory bounds checks, etc.
All this, not even including the actual computation (+). Knowing this, we have an alternative.
Expression Tree Rewriting
i32.add
/ \
i32.const 5 i32.const 7
When enabled (runtime.SuperInstruction = true), WACS does a linear pass through the instruction sequences and rolls up interdependent instructions into directed acyclic graphs.
Instructions are replaced with functionally equivalent expression trees InstAggregate. The new aggregate instructions are in-memory
and are implemented with pre-built relational functions. Ultimately, these instructions are compiled by the dotnet build process into bytecode
to be run by the runtime. The rewriter lives in Wacs.Core.Runtime.SuperInstruction (SuperInstructionRewriter.Rewrite) with
the synthetic instructions in Wacs.Core.Instructions.SuperInstruction.
How does this differ from executing the wasm instructions linearly with the WACS VM?
- No OpStack manipulation
- Values are passed directly without casting or boxing
- The CLR's implementation can use hardware more effectively (use registers instead of heap memory)
- Avoids instruction fetching and dispatch
In my testing, this leads to roughly 60% higher instruction processing throughput (128Mips -> 210Mips). These gains are situational however. Linking of the instructions into a tree cannot 100% be determined across block boundaries. So in these cases, the rewriter just passes the sequence through unaltered. So WASM code with lots of function calls or branches will see less benefit.
Super-instruction threading squeezes more out of the interpreter, but each WASM instruction still goes through a dispatch
layer. The WACS.Transpiler package takes the next step: it compiles the module into a real .NET assembly at
ahead-of-time, producing native CLR methods the JIT can optimize like any other managed code.
Architecture
- IL emission. For every local WASM function, the transpiler walks the parsed instruction stream and emits CIL
directly into a
TypeBuilder, so the JIT sees ordinary static methods — no interpreter, no OpStack, no value boxing. - Typed CLR shapes. Module exports/imports surface as generated C# interfaces with WASM-qualified names
(
long FacSsa(long)), andref/GC types are emitted as native CLR classes with typed fields rather than boxedValue[]wrappers. - Dual SIMD paths.
v128ops have two implementations — a spec-compliant scalar reference path (--simd scalar, the default) and aSystem.Runtime.Intrinsics-backed path (--simd intrinsics). A third mode (--simd interpreter) falls back to the scalar SIMD inWacs.Core. - Transpile-time validation. A
CilValidatorverifies stack balance, typing, and branch targets as IL is emitted, so any invalid module trips at transpile time rather than as a runtimeInvalidProgramException. - Mixed-mode execution. Transpilation is opportunistic: any function the transpiler declines (e.g. very large bodies
under
--max-fn-size) falls back to the Wacs.Core interpreter for that function only, so the module still runs. - CLI + library. Installed as a dotnet global tool (
wasm-transpile) for one-shot.wasm → .dllbuilds, and exposed asWacs.Transpiler.AOT.ModuleTranspilerfor programmatic use inside a host. SeeWacs.Transpiler/README.mdfor the full flag surface and v0.1 known limitations (e.g. standalone cross-process.dllexecution is slated for v0.2).
The transpiler is spec-equivalent to the interpreter on the WebAssembly 3.0 test suite (473/473), verified on macOS ARM64 and Linux x64.
Optimization is an ongoing process and I have a few other strategies yet to implement.
When built in AOT or Release mode, my benchmarks show WACS runs between 2~10% native throughput for benchmark programs like coremark. This is roughly on par with interpreted-only Python or about ~25% of an equivalent program written in C# on dotnet.
The current TODO list includes:
- Source Generated Bindings: Use Roslyn source generator for generating bindings.
- WASI p1 Test Suite: Validate WASIp1 with the test suite for improved standard compliance.
- WASI p2 and Component Model: Implement the component model proposal.
- Text Format Parsing: Add support for WebAssembly text format.
- SIMD Intrinsics: Add hardware-accelerated SIMD (software implementation included in Wacs.Core).
- Unity Bindings for SDL: Implement SDL2 with Unity bindings.
- JavaScript Proxy Bindings: Maybe support common JS env functions.
I built and maintain WACS as a solo developer.
If you find it useful, please consider supporting me through sponsorship or work opportunities. Your support can help me continue improving WACS to make WebAssembly accessible for everyone.
Sponsor me or connect with me on LinkedIn if you're interested in collaborating!
WACS is distributed under the Apache 2.0 License. This permissive license allows you to use WACS freely in both open and closed source projects.
I would love for you to get involved and contribute to WACS! Whether it's bug fixes, new features, or improvements to documentation, your help can make WACS better for everyone.
Star this project on GitHub if you find WACS helpful! ⭐
