WACS.Transpiler 0.3.0

Suggested Alternatives

WACS.Cli

dotnet tool install --global WACS.Transpiler --version 0.3.0
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local WACS.Transpiler --version 0.3.0
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=WACS.Transpiler&version=0.3.0
                    
nuke :add-package WACS.Transpiler --version 0.3.0
                    

WACS.Transpiler

An ahead-of-time transpiler that compiles WebAssembly modules to .NET assemblies (.dll), built on top of WACS. Ships as the wasm-transpile .NET CLI tool.

The generated assembly is spec-equivalent to the WACS interpreter — 473/473 on the WebAssembly 3.0 spec test suite, verified on both macOS ARM64 and Linux x64 — but runs natively via the CLR's JIT instead of the interpreter's expression-tree dispatch.

v0.2: saved .dlls now work cross-process — every transpiled assembly embeds a codec-encoded init-data resource that the generated Module ctor decodes on first use. See Loading a Transpiled Assembly for the seamless TranspiledModuleLoader API.

For library use, reference WACS.Transpiler.Lib (new in 0.2). The WACS.Transpiler package stays as the wasm-transpile dotnet-tool CLI.

Installation

Install the CLI tool globally:

dotnet tool install -g WACS.Transpiler

Verify:

wasm-transpile --help

CLI Usage

Transpile a .wasm to a .NET assembly:

wasm-transpile -i module.wasm -o module.dll

With verbose output, showing the flags in effect, function counts, and diagnostics:

wasm-transpile -i module.wasm -o module.dll -v

Options that map to TranspilerOptions

Flag Values Default Purpose
--simd interpreter / scalar / intrinsics scalar SIMD implementation strategy.
--no-tail-calls off (tail calls on) Disable the CIL tail. prefix for return_call*.
--max-fn-size N int 0 (unlimited) Skip functions larger than N instructions.
--data-storage compressed / raw / static compressed How WASM data segments are stored in the assembly.
--gc-checking FLAGS comma-separated capability names None Enable additional GC type-check layers.

--emit-main: produce a runnable host

The transpiler can bake a Program.Main(string[] args) into the output assembly that constructs the module, parses argv, and invokes a named export:

wasm-transpile -i add.wasm -o add.dll --emit-main --entry-point add
# now load + call Program.Main reflectively, or wrap in a dotnet host

v0.1 --emit-main constraints:

  • Module must have no imports.
  • The export named by --entry-point (default _start) must take scalar i32/i64/f32/f64 params and return void or a single scalar.

The transpiler can link against any assembly that exposes host bindings through the Wacs.Core.Runtime.IBindable interface:

// In your library:
public class MyGameHost : IBindable {
    public void BindToRuntime(WasmRuntime runtime) {
        runtime.BindHostFunction<Action<int>>(("env", "play_sound"), id => /*…*/);
        runtime.BindHostFunction<Func<int,int>>(("env", "random_up_to"), n => /*…*/);
    }
}

Build the library (a standard .NET assembly), then pass it to wasm-transpile with --bind:

wasm-transpile -i game.wasm \
  -o game.dll \
  --bind ./MyGameHost.dll \
  --entry-point _start \
  --run

The transpiler loads the assembly, reflects every concrete IBindable with a parameterless constructor, activates each one, and wires them into the runtime before instantiating the module. Repeat or comma-separate --bind for multiple assemblies — WASI, a scripting shim, and a telemetry sink can all be linked in at once.

Constraints for auto-discovery:

  • Each binding class needs a parameterless constructor. Bindings that require configuration should either provide sensible defaults in the parameterless ctor (Wacs.WASIp1.Wasi() does this) or be driven from the library API below.
  • IBindables that also implement IDisposable are disposed after the run. Good for file handles, network sockets, etc.
  • The transpiler itself doesn't care which CLR-side types the host uses — match what runtime.BindHostFunction<TDelegate> accepts.

--wasi: shortcut for WASI preview1

For modules that import wasi_snapshot_preview1 (anything compiled against a C/Rust/Go/Zig wasi-libc / wasi target), --wasi is a shortcut that's equivalent to --bind <path-to-Wacs.WASIp1.dll> with the CLI's trailing positional args exposed as WASI argv.

wasm-transpile -i coremark.wasm \
  -o coremark.dll \
  --wasi \
  --entry-point _start \
  --run 1 1 1 1

What happens:

  1. Before instantiation, Wacs.WASIp1.Wasi is constructed with a default configuration (stdio attached, host env inherited, Directory.GetCurrentDirectory() as the root, argv = [wasm-filename, …trailing-args]) and bound to the runtime.
  2. The module is transpiled with its WASI imports resolved.
  3. A DispatchProxy implementing the generated IImports interface forwards every import method call through the interpreter's runtime.CreateStackInvoker, so WASI syscalls go through the exact same wasi-libc-compatible handlers used by Wacs.Console.
  4. The transpiled module's ctx.Memories[0] is swapped for the interpreter's MemoryInstance so fd_write / args_get / clock_time_get see the same bytes the AOT code is reading and writing.
  5. The named export (default _start) is invoked directly on the generated Module class.

--wasi and --bind can be combined (e.g. WASI + a custom telemetry host), and both work with or without --emit-main. When --emit-main is also set, the emitted Program.Main is built but --run still goes through the hosted path.

Library Usage

The tool is also a library — use ModuleTranspiler directly to drive transpilation from your own code.

No imports

using System.IO;
using Wacs.Core;
using Wacs.Core.Runtime;
using Wacs.Transpiler.AOT;

var runtime = new WasmRuntime();
using var fileStream = new FileStream("module.wasm", FileMode.Open);
var module = BinaryModuleParser.ParseWasm(fileStream);
var moduleInst = runtime.InstantiateModule(module);

var options = new TranspilerOptions { Simd = SimdStrategy.HardwareIntrinsics };
var transpiler = new ModuleTranspiler("MyNamespace", options);
var result = transpiler.Transpile(moduleInst, runtime, "WasmModule");

// Use in-process:
var moduleType = result.ModuleClass!;
dynamic instance = System.Activator.CreateInstance(moduleType)!;

// …or persist to disk:
result.SaveAssembly("module.dll");

Custom host imports (env.sayc, game bindings, etc.)

Host imports are bound to the runtime before InstantiateModule exactly like the interpreter path. The transpiler's generated Module class takes an IImports proxy in its constructor; DispatchProxy is the cleanest way to build one that forwards to your bound hosts:

using System.Reflection;
using Wacs.Core;
using Wacs.Core.Runtime;
using Wacs.Transpiler.AOT;

var runtime = new WasmRuntime();

// 1. Bind your host functions (same API as Wacs.Core interpreter use).
runtime.BindHostFunction<System.Action<char>>(("env", "sayc"), ch =>
    System.Console.Write(ch));

// 2. Instantiate through the interpreter so imports resolve.
using var stream = new FileStream("hello.wasm", FileMode.Open);
var wasm = BinaryModuleParser.ParseWasm(stream);
var moduleInst = runtime.InstantiateModule(wasm);

// 3. Transpile.
var result = new ModuleTranspiler("MyApp", new TranspilerOptions())
    .Transpile(moduleInst, runtime);

// 4. Build an IImports proxy that forwards each method call to the
// corresponding runtime.CreateStackInvoker. See the Wacs.Transpiler
// source for the full ImportDispatcher / WasiRunner pattern — it's
// ~100 lines and fully reusable for non-WASI hosts.
object importsProxy = BuildImportsProxy(result, runtime, moduleInst);

// 5. Instantiate the Module class. The generated ctor takes IImports.
dynamic instance = System.Activator.CreateInstance(
    result.ModuleClass!, importsProxy)!;

// 6. Invoke an export (name = WASM export name, sanitized to a CLR
// identifier).
instance.main();

See Wacs.Transpiler/Cli/WasiRunner.cs for the full proxy implementation, including the memory-sharing hack (ctx.Memories[i] = runtime.RuntimeStore[moduleInst.MemAddrs[i]]) that hosts which read linear memory need.

WASI modules

Skip the custom proxy work when the imports are pure WASI preview1 — Wacs.Transpiler.Cli.WasiRunner already does the binding + proxy + memory-sharing + entry-point dispatch end-to-end:

using Wacs.Core;
using Wacs.Core.Runtime;
using Wacs.Transpiler.AOT;
using Wacs.Transpiler.Cli;
using Wacs.WASIp1;

var runtime = new WasmRuntime();
var argv = new[] { "coremark.wasm", "1", "1", "1", "1" };
using var wasi = new Wasi(WasiRunner.BuildDefaultConfiguration(argv));
wasi.BindToRuntime(runtime);

using var fs = new FileStream("coremark.wasm", FileMode.Open);
var wasm = BinaryModuleParser.ParseWasm(fs);
var moduleInst = runtime.InstantiateModule(wasm);

var result = new ModuleTranspiler().Transpile(moduleInst, runtime);

int exit = WasiRunner.Run(result, runtime, moduleInst,
    exportName: "_start", verbose: false);

The TranspilationResult also exposes ExportMethods, ImportMethods, Manifest (transpiled vs fallback function counts), and Diagnostics.

Loading a Transpiled Assembly

New in v0.2 — reference WACS.Transpiler.Lib and use the built-in loader. It discovers the generated types, wires imports, and returns a handle you can invoke directly:

using Wacs.Transpiler.Hosting;

// No-import module:
var loaded = TranspiledModuleLoader.Load("add.dll");
int sum = (int)loaded.Invoke("add", 2, 3)!;  // 5

// Or a typed delegate for hot paths:
var add = loaded.GetExport<Func<int, int, int>>("add");
int sum2 = add(2, 3);

Modules with imports: either pass an object implementing the generated IImports interface (AOT-safe, full AssemblyLoadContext isolation), or a by-name delegate dictionary (loader auto-downgrades isolation — DispatchProxy can't reference collectible assemblies):

var loaded = TranspiledModuleLoader.Load("game.dll", imports: new Dictionary<string, Delegate>
{
    ["env_play_sound"] = (Action<int>)(id => AudioEngine.Play(id)),
    ["env_random_up_to"] = (Func<int, int>)(n => Random.Shared.Next(n)),
});
loaded.Invoke("tick");

The LoadedModule handle also exposes ExportsInterface / ImportsInterface as Type so tools can enumerate methods, inspect signatures, and generate wrappers reflectively.

Manual

Raw Assembly.LoadFrom still works if you need full control:

using System.Reflection;

var asm = Assembly.LoadFrom("module.dll");
var moduleType = asm.GetType("MyNamespace.WasmModule.Module")!;
var module = Activator.CreateInstance(moduleType);
var addMethod = moduleType.GetMethod("add");
var result = addMethod!.Invoke(module, new object[] { 2, 3 });

Remaining limitations (tracked for v0.3)

  • --emit-main rejects modules with imports. A --wasi-host flag backed by WACS.WASIp1 and an --allow-missing-imports escape hatch (throwing stubs) are planned.
  • Scalar args only for --emit-maini32/i64/f32/f64. Ref-typed and v128 params aren't parsed from argv yet.
  • Rare GC init patterns (struct/array values in active element segments with non-i31 payloads) throw NotSupportedException from the codec at transpile time. Most modules — including CoreMark, typical emscripten/rustc output, and the spec suite — don't hit this.

License

WACS.Transpiler is distributed under the Apache 2.0 License.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.