spec/ffi.md — Foreign-function interface (`@ffi("c")`)

User-extensible bindings to external C-ABI libraries. Library authors declare extern symbols in .hl source via an @ffi("c") annotation; the compiler emits…

Reference material, synced from the compiler repo’s spec/. The guide is the gentler path in.

User-extensible bindings to external C-ABI libraries. Library authors declare extern symbols in .hl source via an @ffi("c") annotation; the compiler emits LLVM declare for the signature and the linker resolves against C source files supplied at build time. No stdlib expansion is required to bind a new library.

Syntax

Section titled “Syntax”
@ffi("c") fn raylib_init_window(w: Int, h: Int, title: String) -> ();
@ffi("c") fn raylib_should_close() -> Bool;
@ffi("c") fn raylib_clear_background(c: Color) -> ();

Grammar:

ffi_annotation ::= '@' 'ffi' '(' STRING ')'
ffi_fn_decl ::= ffi_annotation 'fn' Ident '(' params ')' ('->' type_expr)? ';'

The annotation precedes the fn keyword. The fn body MUST be absent — the declaration terminates with ;. The compiler synthesizes an empty body internally so downstream passes keep the same FnDecl shape; user code MAY NOT write a {...} block.

The ABI string is the literal "c" (native C-ABI binding) or "js" (a WASM host import — see § WASM host interface). Any other ABI string is rejected at parse time.

Position

Section titled “Position”

@ffi("c") is valid only on top-level free fn declarations. The annotation is rejected on:

The position restriction matches the substrate’s expectation that the C-ABI boundary crosses at top-level program scope only; locus and perspective methods carry implicit Hale-side context (self, scratch arena, lifecycle hooks) that doesn’t translate to C.

Restrictions

Section titled “Restrictions”

An @ffi("c") fn declaration MUST NOT be:

The parser rejects all three with a diagnostic at the annotation or marker position.

Type marshalling

Section titled “Type marshalling”

The typechecker validates @ffi("c") parameter and return types against a portable subset. LLVM lowers each Hale type to a matching C-ABI representation at the call boundary:

Hale type LLVM type C type Notes
Int i64 int64_t 64-bit signed throughout.
Float double double 64-bit IEEE 754.
Bool i32 int32_t Hale’s i1 zero-extends to i32 at the call, truncates back at the return. Avoids C _Bool cross-platform ambiguity.
String ptr const char * NUL-terminated. Caller owns; callee MUST NOT retain past the call. A StringView does not implicitly coerce to a String parameter — it isn’t NUL-terminated, so a char*-expecting callee would strlen past its end. Pass a view as a StringView parameter (→ lotus_view_t, length-carrying) or materialize it first via std::str::clone.
Bytes ptr void * (header) Points at Hale’s [int64 len][payload] header — callee uses lotus_bytes_len(p) / lotus_bytes_data(p) (declared in lotus_arena.h) to inspect. Caller owns.
BytesView / StringView { ptr, i64 } (struct by value) lotus_view_t 16-byte F.30b view layout. C glue MAY use lotus_view_data to recover the payload pointer + length.
Duration / Time i64 int64_t Both are 64-bit nanosecond counts under the hood.
() (unit) void void Return-position only — declared as -> () or omitted entirely. Empty-tuple return type accepted but normalized to ().
User struct (type T { ... }) ptr const T * (param) / T *out (sret return) Passed by pointer at the boundary; struct returns use a hidden sret first arg (see User-type structs section below). Layout match is the library author’s responsibility.

Reserved at Stage 1 (typecheck rejects with a clear diagnostic):

User-type structs

Section titled “User-type structs”

User-type structs (type Color { r: Int = 0; ... }) are passed by pointer at the C-ABI boundary, not by value. The Hale side already stores user structs as heap pointers, so the natural mapping is ptr at the LLVM level. C glue authors write:

// Param-position: const T * (or T * if the callee mutates).
void raylib_clear_background(const Color *c) {
ClearBackground((::Color){
(uint8_t)c->r, (uint8_t)c->g,
(uint8_t)c->b, (uint8_t)c->a,
});
}

Struct returns use sret-style: Hale allocates the return slot in the caller’s arena and passes a pointer as a hidden first argument. The LLVM-level fn signature is void foo(T *out, <user args>); the C glue writes the struct into *out:

// Return-position: hidden T *out first param, returns void.
void vec3i_scale(Vec3i *out, const Vec3i *v, int64_t k) {
out->x = v->x * k;
out->y = v->y * k;
out->z = v->z * k;
}

The Hale-side call expression

let scaled = vec3i_scale(v, 10);

sees the sret slot’s pointer as its result — same value-shape as any other struct-returning expression. The sret transformation is hidden from user code; only the C glue author sees it.

Why pointer + sret instead of by-value: SysV / Win64 / aarch64 all classify struct-by-value differently based on size. A portable implementation would need a per-platform ABI-lowering pass. The pointer convention sidesteps that entirely — every target lowers ptr the same way — at the cost of one dereference per arg on the C side. For the workloads Hale is shaped for (locus methods, bus dispatch, FFI to system libraries), that cost is negligible compared to the portability win.

Layout contract: the Hale struct’s field order + types must match the C struct on the other side. The library author guarantees this. Future spec iteration may add a compile-time layout-assertion mechanism (@ffi_layout("c") on the type decl); today the contract is documented but not machine-checked.

Calling convention

Section titled “Calling convention”

@ffi fns differ from regular Hale free fns at the LLVM ABI level:

The LLVM symbol name is the literal Hale fn name as written. There is no __std_* mangling, no per-import alias prefix, no generic-instantiation suffix. The library author’s C glue exports a function with that exact name; the linker resolves directly.

Lifetime rules

Section titled “Lifetime rules”

The Hale-side caller of an @ffi fn owns every pointer it passes. The C-side callee MUST:

If a C function needs to RETURN heap-allocated String or Bytes, the convention matches stdlib primitives that allocate return values: call lotus_arena_alloc(lotus_caller_arena_or_global(), size, align) to land the storage in the caller’s arena, then return the pointer. The caller’s arena outlives the C-side function frame, so the returned pointer survives.

Exceptions MUST NOT cross the FFI boundary. C code that fails returns an error sentinel (NULL, -1, etc.); the Hale-side wrapper translates to a fallible(E) shape if the error needs to propagate.

Build surface

Section titled “Build surface”

The hale build CLI accepts repeatable flags that thread the library author’s C glue + link surface through to clang:

hale build mydir/ --link raylib --csrc pond/raylib/glue.c \
--link curl --csrc pond/curl/glue.c

Both flags are optional; programs that don’t use @ffi declarations don’t need either.

hale.toml [ffi] auto-pickup (Stage 2)

Section titled “hale.toml [ffi] auto-pickup (Stage 2)”

When hale build resolves an import against a directory that contains an hale.toml, it reads the file’s [ffi] section and appends those values to the build’s link surface automatically. hale run and hale replay build with the same options, from the same function, and hale test and hale bench apply the same pickup. Library authors ship:

pond/raylib/hale.toml
[ffi]
link = ["raylib"]
csrc = ["glue.c"]

Consumers then just import:

myapp/main.hl
import "vendor/raylib" as ray;
fn main() {
let w = ray::Window { width: 1280, height: 720 };
...
}

hale build myapp/ reads vendor/raylib/hale.toml, picks up link=["raylib"] + csrc=["glue.c"], and threads them through to the clang invocation. The CLI flags from the prior section still work as additive overrides (CLI first, then toml- sourced). A flag naming what an imported package’s [ffi] names — a --csrc with the canonical path of a package’s csrc, a --link of a library a package links — is a no-op, not an error: the package’s entry stands and the flag’s is dropped, so the C file is compiled once and the options (and the execution identity) are the flagless invocation’s. This holds for hale build, hale run and hale replay alike. Single-file imports (import "helpers" → helpers.hl) have no companion toml and contribute nothing.

De-duplication: a lib referenced under two aliases or via multiple files in the same seed contributes its FFI flags once per unique resolved directory.

Transitive FFI is NOT walked at Stage 2: only the entry’s top-level imports are scanned for hale.toml. If a directly- imported lib itself imports another @ffi-using lib, the transitive lib’s [ffi] must be re-declared (or surfaced via manual --link / --csrc) at the entry. Resolved if a workload surfaces the need.

Library-author surface

Section titled “Library-author surface”

A binding library typically ships:

  1. A .hl file with @ffi("c") fn ...; declarations + the user-facing Hale wrapper (locus, types, idiomatic signatures).
  2. A .c file exporting the C-side symbols declared in the .hl. Often a thin shim from Hale’s snake_case to upstream C naming.
  3. (Stage 2) An hale.toml [ffi] section declaring link = [...] and csrc = [...].

Example skeleton (pond/raylib):

pond/raylib/raylib.hl
@ffi("c") fn raylib_init_window(w: Int, h: Int, title: String) -> ();
@ffi("c") fn raylib_close_window() -> ();
locus Window {
params { width: Int = 1280; height: Int = 720; title: String = ""; }
birth() { raylib_init_window(self.width, self.height, self.title); }
dissolve() { raylib_close_window(); }
}
pond/raylib/glue.c
#include <stdint.h>
#include "raylib.h"
void raylib_init_window(int64_t w, int64_t h, const char *t) {
InitWindow((int)w, (int)h, t);
}
void raylib_close_window(void) { CloseWindow(); }
# pond/raylib/hale.toml (Stage 2)
[ffi]
link = ["raylib"]
csrc = ["glue.c"]

Diagnostic surface

Section titled “Diagnostic surface”

Parser errors:

Typecheck errors:

Codegen errors:

WASM host interface

Section titled “WASM host interface”

On the wasm32 target (hale build --target wasm32, or a program that declares target wasm { }) the foreign boundary is the JavaScript host rather than a C library. The same @ffi machinery serves the inbound direction, and a dual annotation @export serves the outbound direction.

Package [ffi] under wasm32

Section titled “Package [ffi] under wasm32”

A package’s [ffi] csrc translation units are compiled for wasm32 and linked into the module (#213, 2026-08-03). Before that they were silently skipped: link_wasm was invoked without the build options, so every @ffi("c") symbol a package defined in C surfaced as an undefined env import, --allow-undefined swallowed it, and the generated JS loader stubbed unknown imports with () => 0. The build reported success and every such call returned 0 forever.

Two constraints follow from the wasm build being freestanding:

One sharp edge worth stating, because linking the C is what exposes it: Hale’s Int is 64-bit, so a C declaration must use long long, not int. A mismatch links, and then traps at call time with a wasm signature_mismatch. That is louder than the native ABI, which would quietly truncate — and far louder than the previous behaviour, where the symbol was a stub and the mismatch could not be observed at all.

The target declaration + stdlib gating

Section titled “The target declaration + stdlib gating”

The program opts into the wasm backend with a top-level target declaration whose name is wasm (or the alias browser_js — both select the same backend and gating):

target wasm { }

Top-level is part of the rule, not a habit. A target declaration inside a module { } is a parse error (GH #901, spec/semantics.md § “Declarations inside module { }”): every consumer of a target reads the program’s own item list, so one declared at depth used to be accepted and then ignored — the same program that this section gates reported ok with its target wasm { } one brace deeper.

The effective target. hale check, hale build and the editor act on one target, for analysis and for the artifact alike:

An explicit --target of another class than a written declaration’s is refused at the declaration, on hale check and hale build alike: this program declares `target wasm`, and is being checked for `<triple>`: build it with `--target wasm32`, or drop the declaration. The editor takes no --target, so it shows what hale check and hale build without one compute. hale run and hale replay execute what they build, and a declared program builds a module this host cannot execute, so they refuse it, as they refuse --target wasm32.

The portable stdlib (std::str, std::bytes, std::json, std::math, std::text, …) works unchanged. The POSIX-backed namespaces are rejected at typecheck under this target — the browser sandbox has no syscalls — with the diagnostic error: `std::...` is unavailable under `target wasm`: <reason> (under `--target wasm32` when the configuration, not a declaration, put the program under wasm32). The gated set is exactly the table below, rendered from the compiler’s capability matrix (hale_types::capability).

Every use, not every spelling. The refusal covers every way a program reaches a gated namespace, read off the resolved program (hale_types::capability::uses), not the text of its calls:

A use reached through a chain is worded error: `std::<namespace>` is unavailable under `target wasm`: <reason> — witness: `<use>` → … → `<primitive>` , naming each link. The program’s own sources are the horizon: a fn the program writes is judged at the use in its own body, once, and the calls to it carry nothing; a fn of an imported seed or of the stdlib is judged at the call that crosses into it, and a locus of one at its construction. The horizon relocates a refusal; it never erases a requirement. A type that only names a gated namespace — a parameter, field or return typed std::io::tcp::Stream — is not a use. A call through a local bound by let to a fn or a stdlib path is that fn’s use, the local a link of the witness (let f = pid; f() → `f` → `pid` → `std::process::pid`). In a fn’s, a hook’s or a method’s body, a call through a function value the local does not name — a function-typed parameter, a computed callee, a local bound to anything but a fn (let f = self.g; f()) or reassigned (a local a while or for loop reassigns anywhere in its condition or body is unresolved for the whole loop and after it, since a call ahead of the assignment runs, on the next iteration, the value it stored) — reaches the program’s function values of its type (spec/verification.md, “An indirect call reaches the program’s function values”), each that fn’s use, the local a link of the witness (`lib::via` → `f` → `lib::pid` → `std::process::pid`). A call whose requirements cannot be established (a method on a receiver whose type is not known; such a call no function value of the program can be; and, in the program’s own or another seed’s params initializer or on_failure handler, any call through a local function value bound to anything but a fn, or reassigned, by the same loop rule) is refused under wasm32 as cannot establish what `<callee>` requires on wasm32: <why>, since an unknown requirement is never an admission there; on the native targets it is admitted.

Rejected path Browser substitute
std::io::tcp a WebSocket bus adapter (ws://)
std::io::udp (no raw UDP in the browser)
std::io::tls the browser does TLS transparently for wss:// / https://
std::io::fs, std::io::file fetch via an @ffi("js") host import, or a bus message
std::io::h2 a WebSocket bus adapter (ws://), or an @ffi("js") host import
std::io::stdin, std::io::stdout println(...) (the loader routes it to the host console)
std::term (no terminal in the browser)
std::process (no OS process control)
std::http (server is built on raw TCP)
std::env configuration handed in through an @ffi("js") host import or an @export fn’s arguments
std::io::mirror (no shared memory in the browser)
std::io::sockopt (no sockets in the browser)
std::io::unix a WebSocket bus adapter (ws://), or an @ffi("js") host import
std::ring (no shared memory in the browser)
std::time a host clock (performance.now, Date.now) or timer through an @ffi("js") host import
std::ts parsing on the host, through an @ffi("js") host import

The in-process typed bus is fully available under target wasm: topic declarations and bus { publish … } / bus { subscribe … } across loci lower the same way they do natively — a Subject <- payload is delivered to every matching in-module subscriber’s handler, payload-copied through the synthesized __serialize_T / __deserialize_T wire codec. Those codecs follow the lotus_serialize_fn / lotus_deserialize_fn ABI (ssize_t(const void *, …, size_t)), whose ssize_t / size_t widths are target-pointer-width — i32 on wasm32, i64 on the native 64-bit targets — so the runtime’s lotus_bus_dispatch indirect call matches the codec on both. The cross-process / network transports need syscalls the sandbox does not have, and the module runs on its host’s one thread, so under wasm32 the check refuses every bindings { } entry (unix, shm_ring, an adapter) at the binding, and every placement that asks for a thread of its own or a pool’s — pinned, cooperative(pool = X) with X other than main, where async_io — at the placement entry. (Before, a unix or shm_ring binding and a pool were admitted and never ran, and a pinned locus or an adapter failed late in wasm-ld.)

Reach the outside world through @ffi("js") host imports and the inbox/state seam below instead.

@ffi("js") — host imports (host → into Hale’s callees)

Section titled “@ffi("js") — host imports (host → into Hale’s callees)”

@ffi("js") fn name(...); declares a function the JS loader provides at instantiation (a wasm env import), e.g.:

target wasm { }
@ffi("js") fn console_log(msg: String);
@ffi("js") fn draw_line(x1: Float, y1: Float, z1: Float,
x2: Float, y2: Float, z2: Float);

Marshalling: Float passes directly as a JS number (f64). Int and Duration are i64 internally, but at the @ffi("js") boundary they marshal as f64 (JS number), not i64 (which crosses as a JS BigInt) — the host handler receives a plain number, with no Number(x) dance, and an Int-returning host import accepts a plain JS number back (the runtime sitofps before the call and fptosis the return). The trade-off is f64’s 53-bit integer range: an Int whose magnitude exceeds 2^53 loses precision across this boundary — pass such values as a String/Bytes payload instead. (This is only @ffi("js"). @ffi("c") keeps i64 — on wasm those resolve to linked runtime C symbols that genuinely expect i64.) String/Bytes pass as a pointer into wasm linear memory (the loader reads them with a TextDecoder over the module’s memory). The generated .mjs loader supplies a built-in console_log plus the libm set (sin/cos/tan/sqrt/… mapped to JS Math.*, so std::math works under wasm with no app glue); an app wires its own imports through run(glue). Position and the generic / defaulted restrictions are the same as @ffi("c").

Only the wasm32 loader supplies a js import. On a native target an @ffi("js") declaration is refused at the declaration, called or not (the declaration is the use): `@ffi("js")` fn `name` is a host import of the wasm32 loader, and this program is built for `<triple>`: …, where it used to become an undefined symbol at the native link, and only once something called it.

What a wasm32 module imports

Section titled “What a wasm32 module imports”

The link keeps every symbol nothing defines as an env import (wasm-ld --allow-undefined). The generated loader supplies an import from its writers (the libc output functions println lowers to, console_log and the libm set) or from the app’s run(glue), and stubs any other with () => 0. The runtime’s thread, pool, mailbox and transport paths a wasm32 module cannot run (the target refuses pinned, every pool but main and every transport binding) are compiled out of it, the runtime’s own fprintf(stderr, …) messages are no-ops there, and codegen emits no observation probe for a target that is neither recorded nor replayed. So a module imports the loader’s writers and the program’s declared @ffi("js") names, and today two more, from generated code:

The loader has no stderr writer, so it stubs both with () => 0: on wasm32 eprintln writes nothing, and an unabsorbed violation’s message is dropped before its exit(1) traps the module.

An @ffi("c") name no [ffi] csrc defines is imported too, and runs as () => 0 under the generated loader. A host that instantiates the module with its own imports object supplies every one of these names. crates/hale-codegen/tests/wasm_import_backstop.rs holds every module the wasm tests build to this list. It checks linkage only: an operation the runtime lowers to an inline stub imports nothing, so passing it does not show the operation does anything.

@export — exports (Hale → callable by the host)

Section titled “@export — exports (Hale → callable by the host)”

Two forms; both are wasm-only (a no-op on the native target) and both produce a wasm module export the host calls by its literal name.

@export fn name(...) { ... } — a top-level free fn. Unlike @ffi it has a real Hale body. It is valid only on top-level free fns (same position rule as @ffi), is not @ffi (mutually exclusive — an import is not an export), and is not fallible(E) (v1 — the host has no error channel).

@export locus L { ... } — the persistent singleton “app.” At most one per program. It is instantiated once (birth runs; it is never dissolved), and each of its non-fallible fn methods becomes a wasm export the host calls (inst.exports.<method>()). State lives in the locus’s params — ordinary Hale fields that survive across calls because the singleton persists. The locus must not define run() (it is host-driven via its methods, not a cooperative run loop): under wasm32 the check refuses the run() it writes with @export locus `L` must not define `run()` — a wasm singleton is host-driven via its `@export` methods, not a cooperative run loop; natively the locus is an ordinary one. fallible methods stay internal (not exported).

Entry-inversion run-model

Section titled “Entry-inversion run-model”

A program built with @export runs inverted: instead of a blocking main, the host drives the exports. The compiler synthesizes and exports _hale_start(), which creates a persistent program arena (and bus queue) that is not torn down — and, for an @export locus, instantiates the singleton there and stashes its pointer. The generated loader calls _hale_start once at instantiation and then the host calls the exports (e.g. one per requestAnimationFrame). A program with no fn main is valid when it has any @export and its effective target is wasm32; on a native target its entry is its fn main, so the check refuses it at its first @export: a program with no `fn main` is an export-only module, which needs wasm32: declare `target wasm { }` or build with `--target wasm32` . If _hale_start is present the loader does not call main (its create-then-destroy of the arena would clobber the persistent one).

--wrap-main (browser-playground entry synthesis). A bare fn main program is not the @export shape a wasm build needs. The hale build … --target wasm32 --wrap-main flag synthesizes it on the parsed AST: when the program has a top-level fn main() and no @export entry, it replaces fn main with an @export locus __Main { birth() { <main's body> } } (routing the body through the _hale_start path) and injects a target wasm { } gate if absent. Because it operates on the AST — not the source text — every diagnostic keeps the user’s original line/col (no offset) and a {/} inside a string or comment can’t mis-wrap it. It is wasm-only and opt-in: a hard error unless the effective target of the written sources is wasm32 — --target in any wasm32 spelling, or a written declaration; the declaration the flag injects never selects the target — since there is no native entry-inversion to wrap. It is never implied by the target (a wasm program may legitimately keep a bare fn main exported as main), and a no-op when an explicit @export entry already exists (prefer-explicit).

Holding state across calls:

Inbound messages use the seam in either model: lotus_wasm_alloc(n) / lotus_wasm_set_inbox(len) (wasm exports the host calls to write bytes in) + @ffi("c") fn lotus_wasm_inbox() -> Bytes; (Hale reads them and parses with std::json / std::bytes).

Cross-references

Section titled “Cross-references”

C→Hale re-entry: @export fn on native (Crumb batch 2, 2026-07-27)

Section titled “C→Hale re-entry: @export fn on native (Crumb batch 2, 2026-07-27)”

The inverse direction of @ffi: an @export fn free fn is emitted as an unmangled C-ABI symbol on native targets (the same annotation wasm entry-inversion already used; the internal arena-ABI implementation is renamed __hale_impl_<name> and the literal name is claimed by a C-callable wrapper). C code — an engine registering host functions, a callback-taking library — links the symbol and calls straight into Hale.

Marshalling is the same FFI-portable set as imports, in the same C shapes: Int ↔ int64_t, Float ↔ double, Bool ↔ bool, String ↔ const char*, Bytes ↔ lotus blob pointer (lotus_bytes_len/lotus_bytes_data are linkable for C-side access). Typecheck rejects non-portable params/returns, defaulted params (fixed C arity), and fallible exports (no C error channel).

The v1 contract is same-thread re-entry: the callback must fire while Hale is inside an in-flight @ffi call on that same thread (the engine-host-function shape — JS calls a host fn during crumb_js_run_file). Codegen publishes the call site’s arena in the caller-arena TLS around every @ffi call (save/set/restore, nesting-safe); the export wrapper’s prologue picks it up, so the re-entered body composes with the established context — bus publishes dispatch, eager loci instantiate and dissolve, all on the thread that owns them. Entry from a foreign thread (or outside any in-flight @ffi call) aborts with a pointed diagnostic naming this section; cross-thread entry is the documented follow-up.

Invariants the re-entered code inherits (spelled out per the Crumb request): re-entry runs on the calling thread’s context — the same single-threaded interiority as the locus/fn that made the @ffi call. Publishes enqueue exactly as they would from that caller; a re-entered export must not assume it runs on main unless the @ffi call site does. JSValue-style by-value foreign structs never cross — trampolines convert to the marshalling set C-side (the pond/sqlite handles-in/scalars-out pattern).