Return to Home

Roc Platforms: Supporting multi-entrypoint hosts

~8 mins reading time

This post summarises a problem we discovered while implementing the test platforms: how to “run” a Roc app with our pre-built interpreter-shim when the platform host expects multiple entrypoints. The solution involves a small layer of glue generated at runtime that maps multiple entrypoints to a single interpreter entrypoint.

The short version:

Background: Roc App + Platform Host = Executable

A Roc application is linked with a platform host to produce an executable.

Here’s an illustration in Zig of a host invoking two entrypoints implemented by a Roc app:

fn platform_main() !void {
    // Create the RocOps struct; roc_alloc, roc_dealloc, roc_crash, roc_dbg, etc...
    var roc_ops = RocOps{ ... };

    // Generate random integers
    var rand = std.Random.DefaultPrng.init(@intCast(std.time.timestamp()));
    const a = rand.random().intRangeAtMost(i64, 0, 100);
    const b = rand.random().intRangeAtMost(i64, 0, 100);

    // Arguments struct for passing two integers to Roc as a tuple
    const Args = extern struct { a: i64, b: i64 };
    var args = Args{ .a = a, .b = b };

    // Call `addInts`
    var add_result: i64 = undefined;
    roc__addInts(&roc_ops, @as(*anyopaque, @ptrCast(&add_result)), @as(*anyopaque, @ptrCast(&args)));

    // Call `multiplyInts`
    var multiply_result: i64 = undefined;
    roc__multiplyInts(&roc_ops, @as(*anyopaque, @ptrCast(&multiply_result)), @as(*anyopaque, @ptrCast(&args)));
}

This is the “normal” compiled workflow.

The Interpreter Shim

For a fast dev loop (roc app.roc without a subcommand), we use an interpreter which is very fast to start executing as it avoids lowering the program to machine code and linking.

This gives fast iteration: your app logic runs under the interpreter while the host and ABI remain identical.

The multi-entrypoint problem

Pre-building the interpreter shim means it cannot know the platform and the expected entrypoints ahead of time. To remain platform-agnostic, the shim is compiled to export only a single generic entrypoint:

export fn roc_entrypoint(entry_idx: u32, ops: *builtins.host_abi.RocOps, ret_ptr: *anyopaque, arg_ptr: ?*anyopaque) callconv(.C) void

So: how do we link a host that expects multiple symbols with an interpreter shim that only exports one?

The multi-entrypoint solution

We generate a thin, per-platform “adapter” at runtime using Zig’s standard library API std.zig.llvm.Builder.

A simplified LLVM bitcode file example for three entrypoints:

; ModuleID = 'platform_host_shim'
source_filename = "platform_host_shim"

declare void @roc_entrypoint(i32 %0, ptr %1, ptr %2, ptr %3)

define void @roc__init(ptr %0, ptr %1, ptr %2) {
entry:
  call void @roc_entrypoint(i32 0, ptr %0, ptr %1, ptr %2)
  ret void
}

define void @roc__render(ptr %0, ptr %1, ptr %2) {
entry:
  call void @roc_entrypoint(i32 1, ptr %0, ptr %1, ptr %2)
  ret void
}

define void @roc__update(ptr %0, ptr %1, ptr %2) {
entry:
  call void @roc_entrypoint(i32 2, ptr %0, ptr %1, ptr %2)
  ret void
}

Each defined symbol (roc__init, roc__render, roc__update) and forwards to roc_entrypoint with a distinct index.

This keeps the interpreter shim simple and reusable, while meeting every platform host’s multi-entrypoint ABI.