language

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:

ambient.as
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:

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

KindConvention
Numbersi32/i64/f32/f64 map 1:1; bools are i32; Color is a packed u32.
Enumsdense u32 at the ABI. Luau’s string forms ("stroke", "round") are a VM affordance; the wasm contract is the numbers.
Handlesu32 = 24-bit slot index | 8-bit generation. Type is checked from the table entry; 0 is never valid.
Small math valuesflattened 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.
Descriptorspacked @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:

texture_type.as
// 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.

generated wrapper shape
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.