Extensions & ABI
How to expose native code as a Zinc module, and the frozen zinc_api ABI extensions build against.
Extensions let you expose native code (C libraries, system APIs, GPU backends, databases, anything with a C ABI) as a Zinc module without modifying or recompiling the interpreter.
How It Works
When Zinc resolves an import, every module is cached by import path after its first successful import — and native modules (os, fs, math, strings, ...) are pre-registered in that same cache at startup, before any file search ever runs. So if mylib names a native module, it's returned from the cache immediately; a local mylib.zn/mylib.dll is never even looked for. The search order below only applies when the path isn't already cached (doesn't name a native module):
import "mylib"
1. <script_dir>/mylib.zn ← local .zinc file (checked first)
2. <script_dir>/mylib/mod.zn ← local directory module
3. $ZINC_PATH/mylib.zn ← stdlib .zinc file (only if $ZINC_PATH set)
4. $ZINC_PATH/mylib/mod.zn ← stdlib directory module (only if $ZINC_PATH set)
5. the stdlib embedded in the interpreter binary
6. native cache (os, fs, math…) — extensions (.dll/.so/.dylib) also resolve here
7. error ("cannot find module")
A .dll/.so/.dylib extension is found the same way a bare-name module is (import "mylib" searches for mylib.dll etc. alongside mylib.zn), or via an explicit path with the suffix (see below) — it does NOT get priority over a .zn file of the same name; whichever the search reaches first wins.
You can also use an explicit path with the extension suffix:
import "libs/mygpu.dll"
import "../vendor/sqlite.so"
Writing an Extension (Odin)
An extension is a shared library (.dll / .so / .dylib) that exports two symbols with the C calling convention:
@(export)
zinc_abi_version :: proc "c" () -> i32 { return zinc.ZINC_ABI_VERSION }
@(export)
zinc_open :: proc "c" (api: ^zinc.Zinc_API) -> bool
The loader calls zinc_abi_version first and refuses to load the library — a clean runtime error, not a crash — if the export is missing or its value doesn't match the interpreter's own ZINC_ABI_VERSION. This is a real check, not just a comment: before it existed, any library exporting a same-named zinc_open (built against a future/different ABI, or not a Zinc extension at all) would load and run against the wrong Value/Object memory layout with no diagnostic whatsoever.
zinc_open receives a Zinc_API handle with callbacks to register functions and constants into the module. Return true on success, false on failure.
You do not copy any types. Import the real zinc_api package — it is the single source of truth for the ABI, shared by the interpreter and every extension, so layouts can never drift:
import zinc "path/to/src/zinc_api"
Each native function takes a ^zinc.Zinc_Ctx as its first argument. The context carries value-builder callbacks (make_string, make_list, make_map, list_append, map_set, str_view) so you can construct rich return values without ever touching the interpreter's internals. Native functions use Odin's default calling convention, so context, make, append, and multiple return values all just work — build your extension with the same Odin toolchain as the interpreter.
Minimal Example
package my_extension
import zinc "path/to/src/zinc_api"
// signature: proc(ctx: ^zinc.Zinc_Ctx, args: []zinc.Value) -> ([]zinc.Value, string)
my_add :: proc(ctx: ^zinc.Zinc_Ctx, args: []zinc.Value) -> ([]zinc.Value, string) {
a, e1 := zinc.arg_int(args, 0); if e1 != "" { return nil, e1 }
b, e2 := zinc.arg_int(args, 1); if e2 != "" { return nil, e2 }
return zinc.ret(zinc.val_int(a + b))
}
@(export)
zinc_abi_version :: proc "c" () -> i32 { return zinc.ZINC_ABI_VERSION }
@(export)
zinc_open :: proc "c" (api: ^zinc.Zinc_API) -> bool {
api.register_fn(api, "add", 2, my_add)
api.register_int(api, "VERSION", 1)
return true
}
A complete, runnable extension lives in ext/example_greet/greet.odin (returns a string and a list of maps), and a C-library wrapper in ext/example_sqlite/sqlite.odin (source-only — see ext/example_sqlite/NOTE.md for the missing sqlite3.lib dependency). ext/raylib is a larger real-world example: a complete wrapper around the raylib game/graphics library, with a prebuilt raylib.dll and a demo.zn/smoke_test.zn.
Build Commands
# Windows
odin build . -build-mode:shared -out:myext.dll
# Linux
odin build . -build-mode:shared -out:myext.so
# macOS
odin build . -build-mode:shared -out:myext.dylib
Use from Zinc
import "myext" // finds myext.dll / .so automatically
result := myext.add(3, 4)
println(result) // 7
println(myext.VERSION) // 1
ABI Reference (zinc_api package)
The full ABI (version 1) is defined in src/zinc_api/zinc_api.odin. The two handles you interact with:
// Registration handle — passed to zinc_open (proc "c").
Zinc_API :: struct {
self: rawptr, // internal (^VM)
module: rawptr, // internal
// arity: number of fixed args, or -1 for variadic.
register_fn: proc "c" (api: ^Zinc_API, name: cstring, arity: int, fn: Native_Fn_Proc),
register_int: proc "c" (api: ^Zinc_API, name: cstring, value: i64),
register_float: proc "c" (api: ^Zinc_API, name: cstring, value: f64),
register_bool: proc "c" (api: ^Zinc_API, name: cstring, value: bool),
register_str: proc "c" (api: ^Zinc_API, name: cstring, value: cstring),
}
// Call-time context — the first argument to every native function.
Zinc_Ctx :: struct {
self: rawptr, // internal (^VM)
make_string: proc(ctx: ^Zinc_Ctx, s: string) -> Value,
make_list: proc(ctx: ^Zinc_Ctx) -> Value,
list_append: proc(ctx: ^Zinc_Ctx, list: Value, item: Value),
make_map: proc(ctx: ^Zinc_Ctx) -> Value,
map_set: proc(ctx: ^Zinc_Ctx, m: Value, key: Value, value: Value),
str_view: proc(ctx: ^Zinc_Ctx, v: Value) -> (string, bool),
}
Native_Fn_Proc :: #type proc(ctx: ^Zinc_Ctx, args: []Value) -> ([]Value, string)
Value/^Object past the call that produced it. The GC pause only covers ONE call: if your extension stashes a Value in its own global/static state (a cache, a connection handle wrapping a Zinc value, etc.) to reuse on a LATER call, nothing roots it in between — a collection during any other call (yours or anyone else's) can free it out from under you, since the only thing keeping a value alive between calls is the Zinc program's own reachable state (locals, globals, struct fields, ...), which your private stash isn't part of. There is currently no root/unroot vtable pair to opt into (planned before ABI v2) — for now, don't hold a Value past the native call it came from. If you need to remember something across calls, store a plain Odin value (a string you've cloned, an int, your own handle/id) instead of a Zinc Value.Argument and Return Helpers
The zinc_api package ships allocation-free argument accessors and result builders — no copying required, just zinc.<name>:
// Primitive argument extraction (returns error string on type mismatch)
v, err := zinc.arg_int(args, 0) // -> (i64, string)
v, err := zinc.arg_float(args, 0) // -> (f64, string)
v, err := zinc.arg_bool(args, 0) // -> (bool, string)
// Strings are read through the ctx (they live on the heap)
s, ok := ctx.str_view(ctx, args[0]) // -> (string, bool)
// Pure value constructors
zinc.val_int(42); zinc.val_float(3.14); zinc.val_bool(true)
// Result builders
return zinc.ret(zinc.val_int(42)) // single value
return zinc.ret_pair(zinc.val_int(-1), zinc.val_bool(false)) // (value, ok)
Wrapping a C Library
Use Odin's foreign import to bind C functions, then wrap them as native function bodies:
foreign import mylib "mylib.lib" // or "system:mylib" on Linux
@(default_calling_convention = "c")
foreign mylib {
mylib_init :: proc() -> c.int ---
mylib_compute :: proc(x: c.double) -> c.double ---
}
native_compute :: proc(ctx: ^zinc.Zinc_Ctx, args: []zinc.Value) -> ([]zinc.Value, string) {
x, err := zinc.arg_float(args, 0)
if err != "" { return nil, err }
return zinc.ret(zinc.val_float(mylib_compute(x)))
}
@(export)
zinc_open :: proc "c" (api: ^zinc.Zinc_API) -> bool {
if mylib_init() != 0 { return false }
api.register_fn(api, "compute", 1, native_compute)
return true
}
Stateful Extensions
Extensions often need to keep state (open handles, connection pools, etc.). The simplest approach is a module-level table in the extension itself, with integer handles exposed to Zinc:
MAX_HANDLES :: 64
handles: [MAX_HANDLES]rawptr
used: [MAX_HANDLES]bool
alloc_handle :: proc(ptr: rawptr) -> (int, bool) {
for i in 0..<MAX_HANDLES {
if !used[i] { handles[i] = ptr; used[i] = true; return i, true }
}
return -1, false
}
// Zinc sees: handle := myext.open("...") (returns int)
// myext.close(handle)
// myext.do_thing(handle, args...)
Native Extensions vs .zinc Stdlib
.zinc file | Native extension .dll/.so | |
|---|---|---|
| Edit without recompile | ✓ | ✓ (just rebuild the .dll) |
| Access C/system APIs | ✗ | ✓ |
| Pure Zinc logic | ✓ | tedious |
| Deploy complexity | single file | need .dll alongside |
| Debug in Odin | N/A | ✓ with -debug |
| Performance | interpreter speed | native speed |
The right split: use .zinc for business logic, algorithms, and anything that can be expressed in the language; use extensions for C library wrappers, OS features not exposed in the stdlib, GPU/audio backends, database drivers, and performance-critical inner loops.
Lifecycle
- Extensions are loaded on first
import(lazy). - The library handle is stored in
vm.ext_libs. - On
vm_free(interpreter shutdown), all extension libraries are unloaded viadynlib.unload_library. No explicit cleanup is needed from Zinc. - The module returned by
zinc_openis GC-tracked like any other module.
Platform Notes
| Platform | Suffix | Build flag |
|---|---|---|
| Windows | .dll | -build-mode:shared |
| Linux | .so | -build-mode:shared |
| macOS | .dylib | -build-mode:shared |
The loader probes all three suffixes automatically when given a bare name, so import "sqlite" works cross-platform as long as the right binary is present.
ABI Stability
The Value/Object memory layout, Zinc_Ctx, Zinc_API, Native_Fn_Proc, and zinc_open together form extension ABI version 1 (ZINC_ABI_VERSION in src/zinc_api/zinc_api.odin), defined once in that file. Because both the interpreter and your extension import that same package, the layouts cannot drift within a single build. The loader also enforces this at load time: it calls your extension's exported zinc_abi_version before zinc_open and refuses to load on a missing export or a mismatched value (see "Writing an Extension" above) — rebuild any extension whenever ZINC_ABI_VERSION bumps. Build extensions with the same Odin toolchain as the interpreter (the native-function calling convention relies on a matching Odin ABI).