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)
GC note: the interpreter pauses garbage collection for the duration of a native call, so every value you build through the ctx is safe until you return. After you return, results are reachable from the Zinc program and tracked like any other value — no manual rooting or freeing, as long as you don't keep your own reference to a 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 fileNative extension .dll/.so
Edit without recompile✓ (just rebuild the .dll)
Access C/system APIs
Pure Zinc logictedious
Deploy complexitysingle fileneed .dll alongside
Debug in OdinN/A✓ with -debug
Performanceinterpreter speednative 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 via dynlib.unload_library. No explicit cleanup is needed from Zinc.
  • The module returned by zinc_open is GC-tracked like any other module.

Platform Notes

PlatformSuffixBuild 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).

On this page