Host Bindings & IDL
How modules talk to the Rive engine — versioned import namespaces that any wasm-targeting language can speak, rasc included.
Rive does not keep a bindings adapter per language — the one common binding
is wasm itself. A module’s import list is its protocol
declaration, and the instantiate-time link check is the protocol negotiation.
The surface lives in versioned namespaces — rive_rt_v1,
rive_path_v1, rive_paint_v1,
rive_renderer_v1, rive_shader_v1, and friends — currently
11 namespaces and 172 operations. A third party can bring their own language as
long as it can speak this layer.
How imports are declared
An ambient namespace becomes a wasm import module — the namespace name is the module, each member is a field:
declare namespace rive_path_v1 {
function path_new(): u32;
function path_move_to(path: u32, x: f32, y: f32): void;
function path_line_to(path: u32, x: f32, y: f32): void;
function path_close(path: u32): void;
}
// Emits: (import "rive_path_v1" "path_new" (func ...)) etc.
// A plain ambient declare still imports from env:
declare function print(s: string): void; // (import "env" "print" ...)
// @external binds reserved wire names a declare identifier can't spell:
@external("rive_buffer_v1", "new")
declare function buffer_new(size: u32): u32;
Imports are lazy and wrappers tree-shake: a module only imports what it actually calls, so its declared protocol surface — and its size — scales with use. This is the inverse of native runtimes, where the whole binding surface ships whether used or not.
One IDL, four projections
The binding surface is defined once, in the IDL
(runtime/src/wasm/idl/bindings.py), which generates:
- the C header and WAMR native symbol tables (the host side),
- the JS import-object stubs for the web lane, with staged host-heap crossings,
- the AS projection —
std/rive/bindings.as, one exported ambient namespace of@externalmembers plus@unmanagedPOD descriptor classes and size constants, - documentation.
A --check drift gate in CI keeps the projections honest. Because
the generated bindings are real AS code resolved through the library path, every
tool — check, completion, formatting — sees them with zero special support.
The ABI conventions
| Kind | Convention |
|---|---|
| Numbers | i32/i64/f32/f64 map 1:1; bools are
i32; Color is a packed u32. |
| Enums | dense u32 at the ABI. Luau’s string forms
("stroke", "round") are a VM affordance; the wasm
contract is the numbers. |
| Handles | u32 = 24-bit slot index | 8-bit generation.
Type is checked from the table entry; 0 is never valid. |
| Small math values | flattened floats — a vector crosses as
(f32, f32), never as a handle. |
| Strings | (ptr, byteLen) UTF-16; the host bounds-checks
and transcodes. Cache name lookups as handles outside frame loops. |
| Buffers | (ptr, byteLen) into linear memory —
bounds-checked, zero-copy input. |
| Descriptors | packed @unmanaged structs with fixed
layout, passed by pointer; multi-result host calls use caller-provided
out-pointers into guest scratch (never wasm multi-value — out-params are
uniform across all guest toolchains). |
Enums across the lanes
Luau declares its enums as string-literal unions —
export type TextureType = '2d' | 'cube' | '3d' | '2d-array' —
and scripts pass the strings; the host parses each one per call (the atom
optimization turns that parse into an integer switch, but it is still a
string at the boundary). The wasm lane never sees those strings. The same
host enum projects as a const enum whose members fold to
i32 constants at compile time:
// Same host enum as Luau's '2d' | 'cube' | '3d' | '2d-array' —
// declaration order is the wire contract, pinned in the binding header.
// Member names follow wgpu-rs (D2, D2Array, Cube); identifiers cannot
// start with a digit, and Dawn's C++ spelling of the same set is e2D.
export const enum TextureType {
D2 = 0,
Cube = 1,
D3 = 2,
D2Array = 3,
}
declare function textureNew(w: i32, h: i32, kind: TextureType): u32;
export function makeCube(): u32 {
return textureNew(64, 64, TextureType.Cube); // compiles to i32.const 1
}
Both lanes converge on one integer switch in the builtin: the
Luau lane's string parse resolves to the same values the wasm lane passes
directly, so the C++ host keeps a single internal enum and the numeric
assignments in the .as binding header are the normative
contract. Scripts can still accept enum parameters, switch over
them, and mix them with plain enum declarations — an enum is an
i32 everywhere, so nothing special crosses the boundary.
Errors: two lanes
Sandbox violations — bad pointers, out-of-range lengths — trap the instance
and are unrecoverable. API-misuse validation returns 0 or an error
code, with detail retrievable via rive_rt_v1.last_error. This fits
rasc exactly: exceptions are trap-only in the language, so the soft lane’s
return-code shape is the only catchable one anyway.
Object lifetime
Host objects are handles owned by a per-instance slot table. A compiler-known
HostHandle base type integrates with the GC: the sweep batches freed
ids into one release_batch call per collection — no user-facing
finalizers. Heavy resources still expose dispose() for deterministic
reclaim.
Frame-scoped objects are stronger: the renderer handle the host passes into
draw(renderer) is scoped to that call — its slot generation bumps on
return, so a stashed handle traps next frame instead of ghost-drawing.
export class Path extends HostHandle {
constructor() {
super();
this.id = rive_path_v1.path_new();
}
moveTo(x: f32, y: f32): void {
rive_path_v1.path_move_to(this.id, x, y);
}
lineTo(x: f32, y: f32): void {
rive_path_v1.path_line_to(this.id, x, y);
}
}
Cost model
Boundary crossings are cheap in the outbound direction: a script-to-host
import call costs ~6–13 ns of trampoline. A thousand draws at six calls each
is well under 0.2 ms per frame before any real work. Host-to-script entry is
~10× that — so the host batches its dispatch into the protocol entry points
(advance, draw), while scripts call out freely.
Bulk geometry still crosses as one (ptr, len) array rather than
per-element calls.
The script protocol
The other direction of the contract: a script module exports the protocol
surface the host drives — init, advance,
draw, pointer events — declared once at instance creation with a
capability bitmask, so absent handlers never cost a boundary probe. The module
ABI is language-neutral by construction: the AS support library
(std/rive/host.as) and the Luau-compiled lane answer the same driver
with the same exports.