A binary translator from WebAssembly (Core 1 / MVP) to Udon Assembly — the textual form executed by VRChat's Udon VM. Written in Zig.
The name wasdon = WaSM → Udon.
VRChat's Udon VM is a custom bytecode interpreter that runs inside a .NET sandbox. Assembly is usually produced by Udon Graph or UdonSharp; wasdon-zig opens a second path by taking arbitrary WASM as input and emitting equivalent assembly, so you can author Udon-world behaviour in C, Rust, Zig, AssemblyScript, or any language that targets WebAssembly.
The translator's main design challenges come from Udon's unusual constraints:
- No local variables. Everything is a field on the UdonBehaviour (on the heap). WASM locals and the WASM value stack are all flattened into per-function heap slots.
- No call / return instructions. Only
JUMP,JUMP_INDIRECT, andJUMP_IF_FALSEexist. The translator synthesises a return-address-constant (RAC) based ABI. - No raw byte memory. WASM linear memory is lowered to a two-level chunked array (
SystemObjectArray×SystemUInt32Array); byte-level access expands to shift/mask sequences. - Host calls are
EXTERNwith .NET signature strings. Import names themselves are parsed as Udon extern signatures and dispatched generically — no per-import tables, no translator edits for new externs.
The authoritative design is in docs/; each spec_*.md covers one translation concern.
Covers enough of the surface to translate examples/wasm-bench structurally end to end:
- WASM Core 1 / MVP binary parser (types, imports/exports, code, data, element, custom/name)
-
__udon_metaJSON metadata extraction from data segments - Arithmetic (full i32 / i64 / f64 via EXTERN signatures)
- Structured control flow (block / loop / if-else / br / br_if / br_table / return)
- Locals and globals (
__{fn}_P{i}__/__{fn}_L{i}__/__G__{name}naming) - Direct calls with the RAC-based ABI
- Full
call_indirect(shared__ind_P*/__ind_R*+ per-function indirect entry + trampoline) - Linear memory
i32.load/i32.storewith compile-time aligned fast path and 3-branch (unaligned within-chunk / page-straddling) fallback, plusi64.load/i64.storeword-pair combine,memory.size - Full
i32.load8_*/i32.store8andi32.store16shift/mask RMW expansion -
memory.growreal allocation (per-pageSystemUInt32Arrayctor + outerSetValue, bounds-checked againstmaxPages) - Conversion opcodes (
i32.wrap_i64,i64.extend_i32_*,i{32,64}.trunc_f{32,64}_{s,u},f{32,64}.convert_i{32,64}_{s,u},f32.demote_f64,f64.promote_f32,{i,f}{32,64}.reinterpret_{f,i}{32,64}) - Host import dispatch via signature grammar, with
SystemStringmarshalling from(ptr, len) -
__udon_meta.functions→ Udon event-label mapping (_start/_update/_interact/ …) - Recursive-function call-stack spill (opt-in via
__udon_meta.options.recursion = "stack", Tarjan-SCC based detection, prologue/epilogue spill of P / L / R / RA onto__call_stack__) - CLI (
translate <in.wasm> [-o <out.uasm>]) -
i32.trunc_sat_*(0xFC prefix, post-MVP) and other non-MVP extensions
Zig 0.16+ is required (pinned via mise.toml).
zig build # builds the CLI into zig-out/bin/wasdon_zig
zig build test # runs the full test suite (162 tests)
zig build wasm-example # compiles examples/wasm-bench/main.zig to MVP WASM and copies it into the testdata dirs# build the bench fixture, then translate it
zig build wasm-example
zig build run -- translate src/translator/testdata/bench.wasm -o /tmp/bench.uasm
# or use the installed binary directly
./zig-out/bin/wasdon_zig translate path/to/input.wasm -o output.uasmOmit -o to write to stdout.
Host functions are declared with Zig's raw-identifier syntax using the Udon extern signature as the import name:
extern "env" fn @"SystemConsole.__WriteLine__SystemString__SystemVoid"(
ptr: [*]const u8,
len: usize,
) void;
export fn on_start() void {
const msg = "hi";
@"SystemConsole.__WriteLine__SystemString__SystemVoid"(msg.ptr, msg.len);
}The translator parses the import name against the Udon extern signature grammar (docs/udon_specs.md §7) and dispatches it generically, so adding new externs never requires touching the translator. SystemString arguments are automatically UTF-8 decoded from the (ptr, len) pair.
For an end-to-end walkthrough — toolchain pinning (Zig + Rust wasm32v1-none), Cargo workspace layout, host-import declarations, mutable-state limitations, __udon_meta discovery rules, recursion opt-in, and the build → translate pipeline — see docs/producer_guide.md. Working examples live under examples/ (Zig: wasm-bench, udon-orbit; Rust: wasm-bench-rs, udon-orbit-rs).
Udon-side field names, events, sync modes, and memory sizing are configured via a __udon_meta JSON blob embedded in the module (see docs/spec_udonmeta_conversion.md):
const udon_meta_json =
\\{
\\ "version": 1,
\\ "functions": {
\\ "start": { "source": {"kind":"export","name":"on_start"}, "label":"_start", "export": true, "event":"Start" }
\\ },
\\ "fields": {
\\ "counter": { "source":{"kind":"global","name":"counter"}, "udonName":"_counter", "type":"int", "export": true }
\\ },
\\ "options": {
\\ "memory": { "initialPages": 1, "maxPages": 16 }
\\ }
\\}
;
export fn __udon_meta_ptr() [*]const u8 { return udon_meta_json.ptr; }
export fn __udon_meta_len() u32 { return @intCast(udon_meta_json.len); }docs/ # Specs — the source of truth for translation strategy
├─ udon_specs.md # Udon Assembly reference
├─ w3c_wasm_binary_format_note.md # Notes on the WASM Core 1 binary format
├─ spec_variable_conversion.md # WASM locals/globals → Udon field naming
├─ spec_linear_memory.md # Linear memory → two-level chunked array
├─ spec_call_return_conversion.md # Synthesising call/return from RAC + JUMP_INDIRECT
├─ spec_udonmeta_conversion.md # __udon_meta JSON schema and resolution rules
├─ spec_host_import_conversion.md # Generic host-import dispatch via signature grammar
└─ producer_guide.md # Producer-side guide: writing WASM that the translator accepts (Zig, Rust on wasm32v1-none, raw WAT)
src/
├─ wasm/ # WASM Core 1 / MVP binary parser (translator-agnostic)
├─ udon/ # Udon Assembly construction primitives (type-name encoder + asm writer + 2-pass layout)
├─ translator/ # WASM → Udon lowering core
│ ├─ names.zig # Naming-convention helpers
│ ├─ lower_numeric.zig # opcode → EXTERN signature dispatch table
│ ├─ extern_sig.zig # Udon extern signature parser
│ ├─ lower_import.zig # Generic host-import dispatcher + type-erased Host interface
│ └─ translate.zig # Per-instruction lowering, call ABI, call_indirect, memory init, event entries
├─ root.zig # Library surface
└─ main.zig # CLI
examples/ # Producer-side fixtures (also serve as worked examples for docs/producer_guide.md)
├─ wasm-bench/ # Zig → MVP WASM bench fixture
├─ wasm-bench-rs/ # Rust port of wasm-bench (wasm32v1-none, no_std)
├─ udon-orbit/ # Zig VRChat orbit-and-clone showcase
└─ udon-orbit-rs/ # Rust port of udon-orbit
zig build test runs 185 unit and integration tests:
- Parser tests in
src/wasm/*.zig(including__udon_metaparse coverage foroptions.recursion) - Assembly writer tests in
src/udon/*.zig - Signature-parser tests in
src/translator/extern_sig.zig, including a regression round-trip over the entire numeric EXTERN table - Call-graph / Tarjan SCC tests in
src/translator/recursion.zig(self-recursion, mutual recursion, linear chains) - Mock-
Host-based tests for the generic import dispatcher insrc/translator/lower_import.zig - End-to-end structural assertions in
src/translator/translate.zigthat@embedFilethe compiledbench.wasmand check the emitted assembly, plus synthesized-module tests for unaligned memory access and recursion spill
Execution on the real Udon VM depends on the VRChat runtime and cannot be validated from CI, so the translator's responsibility stops at "emit a structurally spec-conformant Udon Assembly program."
Add wasdon-zig as a dependency in build.zig.zon and @import("wasdon_zig"):
const wasdon_zig = @import("wasdon_zig");
pub fn translate_wasm(
gpa: std.mem.Allocator,
wasm_bytes: []const u8,
writer: *std.Io.Writer,
) !void {
try wasdon_zig.translateBytes(gpa, wasm_bytes, writer, .{});
}The sub-modules (wasdon_zig.wasm / .udon / .translator) are independently importable — if you only need the WASM parser, pull the wasm module alone.