spec/ffi.md — Foreign-function interface (`@ffi("c")`)
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:
- Locus methods (
locus L { fn ...; }). - Mode bodies (
mode bulk { ... },mode harmonic { ... }, …). - Perspective method signatures.
- Interface method signatures.
- Closure declarations.
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:
- Generic. Type parameters require monomorphization; the
C-ABI boundary is monomorphic by definition. Declare separate
@ffifns per type if needed. - Fallible.
fallible(E)is an Hale internal channel; C functions report failure via error sentinels in the return value, and the Hale wrapper above translates tofallible(E)if exposed to user code. - Defaulted. Parameter defaults are not portable across the C-ABI boundary; the wrapper layer applies defaults before the call.
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):
Decimal— i128 mantissa with platform-variable ABI. Marshal asInt(raw mantissa) orFloat(lossy conversion) at the Hale side; the wrapper handles the scale.Uint— Hale-internal type; declare asIntat the FFI signature.- Projections / fixed-size arrays / tuples — no portable C struct layout for these v0 shapes.
fallible(E)— internal channel; see Restrictions above.- Function-pointer types — wrap as a struct/handle at the C side.
LocusRef,Cell— Hale-internal.
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:
- No implicit
__caller_arenafirst parameter. Regular free fns receive the caller’scurrent_arena_ptr()as an implicit prefix;@ffifns do not. - No fallible sret slots.
@ffifns can’t befallible(E), so the sret-pair the substrate emits for fallible returns is absent. - No monomorphization.
@ffifns can’t be generic.
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:
- NOT retain
String/Bytes/ view pointers past the call boundary. If C needs persistent storage, it must copy into its own malloc’d memory. - NOT free or write through any pointer received from Hale. Arena-owned pointers are read-only at the C side.
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--link <name>— appended as-l<name>to the clang link line. The system’s dynamic linker resolves at runtime.--csrc <path>— passed directly to clang as a translation unit compiled alongside the C runtime. The library author’s.cglue file goes here. May be repeated for multiple files.
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. Library authors ship:
[ffi]link = ["raylib"]csrc = ["glue.c"]Consumers then just import:
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); duplicates are tolerated. 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:
- A
.hlfile with@ffi("c") fn ...;declarations + the user-facing Hale wrapper (locus, types, idiomatic signatures). - A
.cfile exporting the C-side symbols declared in the.hl. Often a thin shim from Hale’s snake_case to upstream C naming. - (Stage 2) An
hale.toml [ffi]section declaringlink = [...]andcsrc = [...].
Example skeleton (pond/raylib):
@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(); }}#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:
expected ; (an @ffi fn declaration has no body), got LBrace— body block written after the signature; convert to;.unsupported FFI ABI "<x>" — Stage 1 accepts only "c"\@ffi` fn must not be generic — the C-ABI boundary is monomorphic`\@ffi` fn must not be `fallible(…)` — C functions return an error sentinel, the Hale wrapper above translates to `fallible(E)` if needed`expected \fn` after `@ffi(…)` annotation`expected \fn` after `@export` annotation`\@export` and `@ffi` are mutually exclusive — an FFI import is not a module export`
Typecheck errors:
\@ffi` fn `` parameter ` ` has type Decimal — Decimal (i128) has platform-variable ABI; marshal as Int/Float at the Hale side instead`
\@ffi` is only valid on top-level free fns at Stage 1, not on locus methods`
Codegen errors:
@ffi fn \` parameter ` `: type
is not yet wired for FFI codegen at Stage 1` — user-type structs, arrays, etc. fall here. @ffi fn \`: parameter defaults are not supported across the C-ABI boundary` @export fn \`: fallible exports are not supported yet (wasm entry-inversion v1)`
WASM host interface
Section titled “WASM host interface”On the wasm32 target (hale build --target wasm32; the program
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:
- No libc sysroot. The runtime is compiled with bare
--target=wasm32against a forward-declared shim, not wasi-sdk. There are no system headers, so a translation unit that#includes<string.h>will not compile for this target. That is now a build error naming the file, rather than a silently missing symbol. [ffi] link = [...]is rejected. It names a system dynamic library, and wasm has neither a dynamic linker nor system libraries. Dropping it silently would reproduce the same failure one level up, so it is an error that points atcsrcas the alternative.
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 { }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>. The gated set
(wasm_unavailable_stdlib) is exactly:
| 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::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) |
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. Only the cross-process /
network transports (shm_ring, unix, and CONNECT-role bindings) are
unavailable in the sandbox, since they need syscalls.
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").
@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);
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; 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 without --target wasm32 (there is no native
entry-inversion to wrap), 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:
@export locus(preferred): state is the locus’s fields, mutated in one method and read in another — plain Hale, no marshalling. This is the natural shape for a browser client.@export fn(lower-level): each call’s allocations are released on return, so cross-call state goes through the runtime host seam —@ffi("c") fn lotus_wasm_state_set(b: Bytes);/lotus_wasm_state_get() -> Bytes;deep-copies a packedBytesblob into its own arena so it survives.
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”notes/ffi-design.md— design memo capturing the agreement the Stage 1 surface graduated from, plus the Stage 2/3 staging plan still pending implementation.spec/stdlib.md—std::*paths are NOT the only way to bind C libraries; this spec is the user-extensible alternative.spec/runtime.md— the C-runtime helpers (lotus_bytes_*,lotus_arena_alloc,lotus_caller_arena_or_global, etc.) that library authors typically call from C glue.docs/src/systems/webassembly.md— the pedagogical companion to the WASM host interface above (the browser-client walkthrough: loaderrun(glue), the inbox, the@export locusgame loop).
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).