Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 

README.md

Embedding lua2wasm for scripting — proof of concept

This is a runnable PoC of the scenario: a C application/game, compiled to WebAssembly, targeting the web, that embeds the lua2wasm compiler so it can run Lua scripts. It proves the whole loop and makes the real constraints concrete.

examples/embed/build.sh                              # build engine.wasm (needs clang + wasm-ld)
node --experimental-wasm-exnref examples/embed/demo.mjs   # run the three demos

(The --experimental-wasm-exnref flag is what Node needs to run the Lua modules' exception-handling opcodes — the same flag the rest of the project's runtime uses. A browser supports them natively.)

What's here

File Role
engine.c A stand-in C "engine" with the lua2wasm compiler linked in. Compiles Lua → wasm bytes in its own linear memory (engine_build); receives numbers from running scripts (engine_on_value).
build.sh Reuses the compiler's freestanding object files (scripts/build-wasm.sh), compiles engine.c, links engine.wasm. Plain clang/wasm-ld, no Emscripten.
broker.mjs The JS orchestrator: instantiates produced modules, wires imports/exports, marshals values, and wraps the host-call ABI (callLua).
demo.mjs Three demos: script→engine, engine→script per-frame, and the engine calling named Lua functions on a persistent instance (lua_call).

The architecture this demonstrates

        ┌──────────────── Browser / Node: JS broker ────────────────┐
        │                                                           │
   engine.wasm  ──(1) engine_build(src) → script bytes──►  broker   │
   (C + compiler        (compiled in engine's linear memory)   │     │
    linked in)                                                 ▼     │
        ▲                                   (2) WebAssembly.instantiate
        │                                          │                 │
        │   (3) host.print(luaval) ──► broker ──► lua_get_int(luaval) │
        │        ◄── engine_on_value(double) ◄──── (reduce GC→prim)   │
        │                                       script.wasm (WasmGC)  │
        │   (3') host.read_num() ◄── broker ◄── lua_make_int(frame)   │
        └───────────────────────────────────────────────────────────┘

Three facts the PoC makes concrete:

  1. The compiler links into a C host with no glue. engine_build just calls the compiler's lua2wasm_compile_ex + lua2wasm_assemble (linked from src/). Because both are the same freestanding wasm build, they share one module and one linear memory.

  2. A wasm module can't instantiate another — JS must. engine.wasm produces the script bytes but cannot run them; the broker calls WebAssembly.instantiate. Your engine being wasm doesn't remove the JS layer, it adds a second module for JS to broker. (The wiring is one-time; you can pass one module's exports as another's imports so the per-call path is direct wasm↔wasm — except for value marshaling, see #3.)

  3. The engine can't touch Lua values; JS reduces them to primitives. Lua values are WasmGC objects in the script's heap. A linear-memory C function can't even name that type, so it deals only in double/i64. The broker bridges by calling the script's own lua_get_int/lua_get_float (GC→scalar) and lua_make_int/lua_make_float (scalar→GC). That's engine_on_value receiving a plain double in demo 1, and read_num returning a freshly boxed number in demo 2.

Calling named Lua functions: the host-call ABI (--embed-api)

Demo 1 and 2 are run-a-script: call main(), and the script talks to the world through host imports. Demo 3 is the real scripting model — the engine invokes a named Lua function with arguments and reads its result, against one persistent instance whose state survives between calls (damage(30) then damage(25) sees hp decrement). That's what game-style scripting needs (on_update(dt), on_collision(a, b), ...).

It's enabled by compiling with --embed-api (here engine_build passes embed_api = 1). That exports a small host-call ABI on the produced module:

Export Use
lua_str_new(n) / lua_str_setb(s,i,b) build a Lua string (a name or a string arg)
lua_make_int / lua_make_float build a Lua number
lua_get_global(name) fetch a global (your function) by name
lua_args_new(n) / lua_args_set(a,i,v) build an argument list
lua_call(fn, args) invoke; returns a results array (Lua errors throw the LuaError tag)
lua_pcall(fn, args) protected invoke; returns [ok, ...results-or-error] — no host-side try/catch needed
lua_args_len(r) / lua_args_get(r,i) read the results (handles multiple returns)
lua_table_new() / lua_table_get/set(t,k[,v]) / lua_table_len(t) build/read/write Lua tables

lua_call/lua_pcall accept any callable and set up the call frame error() needs; lua_pcall also restores call depth on a caught error, so it's safe to keep calling after a script throws.

broker.mjs wraps these as callLua(S, name, ...args). Because the ABI can reach any global and call anything, --embed-api keeps the whole stdlib live (no tree-shaking) and runs stdlib_init, so the modules are larger than a tree-shaken run-once script — the trade you make for a callable script.

The general harness for this lives in tests/test_embed_api.mjs.

A bonus the model gives you for free

Capability sandboxing. A script can only do what you wire. Instantiate an untrusted mod's module without the os_*/fs_* imports (see instantiateScript — unwired imports throw on use) and it physically cannot touch the filesystem or clock. Combined with the engine's own memory/time limits, that's a strong sandbox with no extra code.