Embedding Guide

How to integrate the Zym scripting language into your C/C++ projects — VM lifecycle, native functions, value system, and two-way FFI.

Critical: Zym has no built-in I/O. All printing, file access, and networking must be provided by the embedder through native functions. The print() function used in examples below must be registered by you before scripts can use it.

Important: No Built-In I/O

Zym's core provides no print, input, or file I/O functions. This gives you complete control over how scripts interact with the outside world. You must register these functions as natives.

Minimal print() Implementation

Using a variadic native, a single registration handles any number of arguments:

C
#include <stdio.h>
#include <math.h>
#include "zym/zym.h"

ZymValue native_print(ZymVM* vm, ZymValue* args, int argc) {
    if (argc == 0) {
        printf("\n");
        return zym_newNull();
    }

    if (argc == 1) {
        zym_printValue(vm, args[0]);
        printf("\n");
        return zym_newNull();
    }

    // 2+ args: first arg is format string, rest are values
    if (!zym_isString(args[0])) {
        zym_runtimeError(vm, "print() first argument must be a string");
        return ZYM_ERROR;
    }
    // ... format string handling with args[1..argc-1] ...
    printf("\n");
    return zym_newNull();
}

void setupPrint(ZymVM* vm) {
    zym_defineNativeVariadic(vm, "print(...)", native_print);
}

Usage from Zym:

Zym
print("Hello, world!")
print(42)
print([1, 2, 3])
print("Name: %s, Age: %n", "Alice", 30)
print()  // empty line

A single variadic registration replaces what would otherwise require multiple fixed-arity registrations. Format specifiers like %v (value), %s (string), %n (number), %b (boolean) can be handled in the format string path.

VM Lifecycle

Create & Destroy

C
#include "zym/zym.h"

// Create a new VM instance (NULL = default allocator)
ZymVM* vm = zym_newVM(NULL);

// Register your native functions here...

// Clean up when done
zym_freeVM(vm);

Running Scripts

Zym has no "run this string" one-shot helper. Compilation and execution are explicit, separate stages. A minimal end-to-end run looks like this:

C
const char* source = "var x = 42\nprint(x)";

// 1. Register the source buffer with the VM so diagnostics carry a file id.
ZymFileId file_id = zym_registerSourceFile(vm, "main.zym", source, strlen(source));

// 2. Preprocess (expands #define / #include; safe to call on any script).
ZymSourceMap* map = zym_newSourceMap(vm);
const char* processed = NULL;
if (zym_preprocess(vm, source, map, file_id, &processed) != ZYM_STATUS_OK) {
    // Drain diagnostics (see "Diagnostics" below) and bail.
}

// 3. Compile into a chunk.
ZymChunk* chunk = zym_newChunk(vm);
ZymCompilerConfig config = { .include_line_info = true };
// Last argument is the retained-parse-tree out-parameter; NULL unless you want it.
if (zym_compile(vm, processed, chunk, map, "main.zym", config, NULL) != ZYM_STATUS_OK) {
    // Drain diagnostics and bail.
}

// 4. Execute.
ZymStatus result = zym_runChunk(vm, chunk);

if (result == ZYM_STATUS_OK) {
    printf("Script ran successfully\n");
} else if (result == ZYM_STATUS_COMPILE_ERROR) {
    printf("Compile error\n");
} else if (result == ZYM_STATUS_RUNTIME_ERROR) {
    printf("Runtime error\n");
}

// Cleanup.
zym_freeChunk(vm, chunk);
zym_freeProcessedSource(vm, processed);
zym_freeSourceMap(vm, map);
Why no zym_interpret() convenience wrapper? Zym targets embedded, server, and game-mod hosts that typically want to cache bytecode chunks, register natives between stages, or compile in one VM and run in another. Explicit stages make those patterns first-class; see Compilation Pipeline & Bytecode for the production flow.

Multiple VM Instances

Each VM is completely independent — they share no state and can run different scripts concurrently (from different threads).

C
ZymVM* vm1 = zym_newVM(NULL);
ZymVM* vm2 = zym_newVM(NULL);
// Each VM has its own globals, GC, and module state

Diagnostics

Compile-time errors (scanner, parser, compiler) are recorded on the VM as structured ZymDiagnostic records rather than written to stderr. The embedder is responsible for draining them and deciding how to surface them: CLI, log buffer, LSP protocol, on-screen overlay, or silent discard.

The ZymDiagnostic Struct

typedef struct {
    ZymDiagSeverity severity;   // ERROR / WARNING / INFO / HINT
    ZymFileId       fileId;     // Source file id (or ZYM_FILE_ID_INVALID)
    int             startByte;  // Byte offset into that file's buffer (-1 if unknown)
    int             length;     // Span length in bytes (0 if unknown)
    int             line;       // 1-based line number
    int             column;     // 1-based UTF-8 byte column (-1 if unknown)
    const char*     message;    // Human-readable message, VM-owned
#if ZYM_HAS_DIAGNOSTIC_CODES
    const char*     code;       // Stable code (e.g. "E0001"), optional
    const char*     hint;       // Optional fix-it hint
#endif
} ZymDiagnostic;

The code / hint fields are gated on the ZYM_ENABLE_DIAGNOSTIC_CODES compile-time flag (on by default; MCU builds can compile it out). See Build-Time Feature Flags.

Draining Diagnostics

const ZymDiagnostic* zymGetDiagnostics(const ZymVM* vm, int* out_count);
void                  zymClearDiagnostics(ZymVM* vm);

Call zymGetDiagnostics after any non-OK zym_preprocess / zym_compile, inspect the array, then zymClearDiagnostics to reset the buffer for the next compile. The pointer is VM-owned and is invalidated by the next compile or clear.

Example: Print Diagnostics to stderr

C
static void drain_diagnostics(ZymVM* vm, const char* entry_file) {
    int n = 0;
    const ZymDiagnostic* diags = zymGetDiagnostics(vm, &n);
    for (int i = 0; i < n; ++i) {
        const ZymDiagnostic* d = &diags[i];
        fprintf(stderr, "%s: [%s] line %d:%d: %s\n",
            d->severity == ZYM_DIAG_ERROR ? "error" : "warning",
            entry_file, d->line, d->column, d->message);
    }
    zymClearDiagnostics(vm);
}

// Usage:
if (zym_compile(vm, src, chunk, map, "main.zym", cfg, NULL) != ZYM_STATUS_OK) {
    drain_diagnostics(vm, "main.zym");
    return 1;
}
Byte-granular spans. Parser-originated diagnostics carry full fileId / startByte / length / column. Compiler-originated diagnostics carry the same when the offending token is known (identifier-level errors); a few paths still report line-only. Tooling consumers should treat startByte == -1 as "fall back to line".

What About Runtime Errors?

Runtime errors (type mismatches, out-of-bounds, stack overflow) are still surfaced through the ZymErrorCallback legacy hook below. Migration of the runtime path to the diagnostics sink is tracked as a post-Phase-1 follow-up.

Error Callback (runtime)

ZymErrorCallback remains the way to capture runtime errors (type mismatches, out-of-bounds access, stack overflows). Pre-existing frontend callback routing was removed: compile-time errors now flow exclusively through Diagnostics.

typedef void (*ZymErrorCallback)(ZymVM* vm, ZymStatus type, const char* file, int line, const char* message, void* user_data);
void zym_setErrorCallback(ZymVM* vm, ZymErrorCallback callback, void* user_data);

Cooperative Cancellation

An in-flight compile can be aborted from another thread, useful for LSP hosts that supersede a stale request, REPLs with Ctrl-C, or game tools that want to cap compile time. The frontend polls a volatile sig_atomic_t flag at every statement/declaration boundary.

void zym_requestCancel(ZymVM* vm);   // Ask the current compile to abort (thread-safe, one-way 0→1 signal)
void zym_clearCancel(ZymVM* vm);     // Reset the flag before the next compile (explicit, not automatic)
bool zym_wasCancelled(const ZymVM* vm);

A cancelled compile returns ZYM_STATUS_COMPILE_ERROR and pushes a single "Compilation cancelled." diagnostic. The host distinguishes cancellation from a real compile error by calling zym_wasCancelled(vm) after the non-OK status. The flag is not cleared automatically between compiles (avoids silently masking a stale cancel).

Scope. This governs only the compile pipeline. Bytecode execution is not interruptible through this API; runtime interruption is handled by the separate preemption machinery.

Custom Allocators

By default, Zym uses the standard C allocator (malloc/realloc/free). You can replace this with your own allocator by passing a ZymAllocator struct to zym_newVM(). This is essential for embedded environments, memory pools, tracking, or arenas.

The ZymAllocator Struct

typedef struct ZymAllocator {
    void* (*alloc)(void* ctx, size_t size);
    void* (*calloc)(void* ctx, size_t count, size_t size);
    void* (*realloc)(void* ctx, void* ptr, size_t old_size, size_t new_size);
    void  (*free)(void* ctx, void* ptr, size_t size);
    void* ctx;
} ZymAllocator;

Using the Default Allocator

Pass NULL to use the built-in allocator (standard malloc/free):

C
ZymVM* vm = zym_newVM(NULL);  // Uses malloc/calloc/realloc/free

Custom Allocator Example

C
static void* my_alloc(void* ctx, size_t size) {
    return malloc(size);
}

static void* my_calloc(void* ctx, size_t count, size_t size) {
    return calloc(count, size);
}

static void* my_realloc(void* ctx, void* ptr, size_t old_size, size_t new_size) {
    return realloc(ptr, new_size);
}

static void my_free(void* ctx, void* ptr, size_t size) {
    free(ptr);
}

// Set up the allocator
ZymAllocator allocator = {
    .alloc   = my_alloc,
    .calloc  = my_calloc,
    .realloc = my_realloc,
    .free    = my_free,
    .ctx     = NULL  // Or your pool/arena/context pointer
};

ZymVM* vm = zym_newVM(&allocator);

Retrieving the Allocator

You can query the allocator a VM is using at any time:

C
const ZymAllocator* alloc = zym_getAllocator(vm);
Important: The allocator is copied into the VM at creation time. All VM-managed memory (objects, strings, GC bookkeeping) goes through your allocator. Memory allocated by the host (e.g., read_file() buffers) still uses your own allocation strategy.

Compilation Pipeline & Bytecode

For production deployments, you can separate compilation from execution. Compile scripts once into bytecode, then load and execute the bytecode multiple times. This eliminates parsing overhead and speeds up startup.

The Pipeline

  1. Preprocess (optional — only if using macros like #define)
  2. Compile source to bytecode chunk
  3. Serialize chunk to buffer
  4. Deserialize buffer in runtime VM
  5. Execute loaded chunk

Separate Compile & Run VMs

C
// ===== COMPILATION VM (ephemeral) =====
ZymVM* compile_vm = zym_newVM(NULL);

// CRITICAL: Register natives in compile VM
// Compiler needs them for name resolution
setupNatives(compile_vm);

const char* source = "func add(a, b) { return a + b; }";
ZymFileId file_id = zym_registerSourceFile(compile_vm, "script.zym",
                                           source, strlen(source));

ZymChunk*     chunk = zym_newChunk(compile_vm);
ZymSourceMap* map   = zym_newSourceMap(compile_vm);

// Step 1: Preprocess (expands #define / #include; always safe to call).
const char* processed = NULL;
if (zym_preprocess(compile_vm, source, map, file_id, &processed) != ZYM_STATUS_OK) {
    drain_diagnostics(compile_vm, "script.zym"); // see Diagnostics section
}

// Step 2: Compile — pass the SourceMap so frontend diagnostics can map
//         expanded byte offsets back to the user-visible origin.
ZymCompilerConfig config = { .include_line_info = true };
if (zym_compile(compile_vm, processed, chunk, map,
                "script.zym", config, NULL) != ZYM_STATUS_OK) {
    drain_diagnostics(compile_vm, "script.zym");
}

// Step 3: Serialize to bytecode (allocates via malloc)
char*  bytecode;
size_t bytecode_size;
zym_serializeChunk(compile_vm, config, chunk, &bytecode, &bytecode_size);

// ===== RUNTIME VM (long-lived) =====
ZymVM* run_vm = zym_newVM(NULL);

// CRITICAL: Register natives in run VM
// Runtime needs them for actual function pointers
setupNatives(run_vm);

ZymChunk* loaded_chunk = zym_newChunk(run_vm);

// Step 4: Deserialize (automatically sets vm->chunk for GC safety)
zym_deserializeChunk(run_vm, loaded_chunk, bytecode, bytecode_size);

// Step 5: Execute
zym_runChunk(run_vm, loaded_chunk);

// Cleanup
zym_freeChunk(compile_vm, chunk);
zym_freeChunk(run_vm, loaded_chunk);
zym_freeSourceMap(compile_vm, map);
zym_freeProcessedSource(compile_vm, processed);
zym_freeVM(compile_vm);
zym_freeVM(run_vm);
free(bytecode);
Important: Native functions must be registered in both VMs:
  • Compile VM: Compiler resolves mangled names (funcName@arity)
  • Run VM: Runtime needs actual function pointers for execution

Saving & Loading Bytecode Files

C
// Save compiled bytecode to file
FILE* f = fopen("script.zymc", "wb");
fwrite(bytecode, 1, bytecode_size, f);
fclose(f);

// Later: Load bytecode from file
f = fopen("script.zymc", "rb");
fseek(f, 0, SEEK_END);
size_t size = ftell(f);
rewind(f);
char* loaded_bytecode = malloc(size);
fread(loaded_bytecode, 1, size, f);
fclose(f);

// Deserialize and execute
ZymChunk* runtime_chunk = zym_newChunk(run_vm);
zym_deserializeChunk(run_vm, runtime_chunk, loaded_bytecode, size);
zym_runChunk(run_vm, runtime_chunk);

free(loaded_bytecode);

Parse-Only & Check Entry Points

Two frontend entry points stop short of bytecode. Both are gated on build-time feature flags and are not declared on MCU builds, so guard calls with the matching ZYM_HAS_* predicate.

#if ZYM_HAS_PARSE_TREE_RETENTION
ZymStatus zym_parseOnly(ZymVM* vm, const char* source,
                        const ZymSourceMap* source_map,
                        const char* entry_file,
                        ZymParseTree** out_tree);
#endif

#if ZYM_HAS_SYMBOL_TABLE
ZymStatus zym_check(ZymVM* vm, const char* source,
                    const ZymSourceMap* source_map,
                    const char* entry_file,
                    ZymParseTree** out_tree,
                    ZymSymbolTable** out_table);
#endif

zym_parseOnly runs scan + preprocess + parse and stops before codegen. No bytecode is produced and no ZymChunk is touched. On success *out_tree receives a caller-owned ZymParseTree*, released with zym_freeParseTree. On parse failure it returns ZYM_STATUS_COMPILE_ERROR, leaves *out_tree == NULL, and pushes diagnostics to the VM's sink (drain with zymGetDiagnostics). The retained tree, trivia buffer, and spans are identical to what an execute-mode compile hands back.

zym_check runs the same frontend and then the parallel resolver, handing back both the tree and a caller-owned ZymSymbolTable* (released with zym_freeSymbolTable). The resolver never influences code generation and is never invoked from zym_compile; it exists for tooling consumers: LSP, doc generators, outline views.

source_map is the map produced by zym_preprocess, or NULL when passing raw, unpreprocessed text.

Retained trees from zym_compile. zym_compile's last parameter is the same out-parameter. Pass NULL unless you want the AST handed back: with retention on, a non-NULL out_tree, and a successful compile, *out_tree receives a caller-owned tree; in every other case it is set to NULL and the AST is freed at the end of the compile. The parameter is accepted unconditionally so host code compiles against either build profile.

Value System

All Zym values are 64-bit NaN-boxed ZymValue. You create and inspect them with helper functions.

Creating Values

C
ZymValue num     = zym_newNumber(42.0);
ZymValue str     = zym_newString(vm, "hello");
ZymValue boolean = zym_newBool(1);
ZymValue null_v  = zym_newNull();
ZymValue list    = zym_newList(vm);
ZymValue map     = zym_newMap(vm);

Type Checking

C
zym_isNumber(value)    // true if number
zym_isString(value)    // true if string
zym_isBool(value)      // true if boolean
zym_isNull(value)      // true if null
zym_isList(value)      // true if list
zym_isMap(value)       // true if map

Extracting Values

C
double n          = zym_asNumber(value);
const char* s     = zym_asCString(value);
int b             = zym_asBool(value);

List Operations (from C)

C
ZymValue list = zym_newList(vm);
zym_listAppend(vm, list, zym_newNumber(1));
zym_listAppend(vm, list, zym_newNumber(2));
zym_listAppend(vm, list, zym_newNumber(3));

int len = zym_listLength(list);           // 3
ZymValue first = zym_listGet(list, 0);    // 1

Map Operations (from C)

C
ZymValue map = zym_newMap(vm);
zym_mapSet(vm, map, "name", zym_newString(vm, "Alice"));
zym_mapSet(vm, map, "age", zym_newNumber(30));

ZymValue name = zym_mapGet(vm, map, "name");
int count = zym_mapSize(map);   // 2

NaN-Boxing Explained

Zym uses NaN-boxing to pack all value types into a single 64-bit uint64_t. This enables efficient value passing without heap allocation for primitives.

Value types:

Safe vs Unsafe Extraction

Always check types before extraction to avoid undefined behavior:

C — UNSAFE (fast, no checks)
if (zym_isNumber(value)) {
    double num = zym_asNumber(value);  // No overhead after check
}
C — SAFE (returns false on mismatch)
double num;
if (zym_toNumber(value, &num)) {
    printf("Got number: %g\n", num);
} else {
    printf("Not a number\n");
}

Structs & Enums

Structs and enums require a schema defined in Zym script before C code can create instances:

Zym — define schema first
struct Point {
    x;
    y;
}

enum Color {
    RED, GREEN, BLUE
}
C — create instances after schema defined
// Create struct
ZymValue point = zym_newStruct(vm, "Point");
if (zym_isStruct(point)) {
    zym_structSet(vm, point, "x", zym_newNumber(10));
    zym_structSet(vm, point, "y", zym_newNumber(20));
    ZymValue x = zym_structGet(vm, point, "x");
}

// Create enum
ZymValue color = zym_newEnum(vm, "Color", "RED");
if (zym_isEnum(color)) {
    const char* variant = zym_enumGetVariant(vm, color);  // "RED"
}

Value Inspection

C
// Get type name as string
const char* type = zym_typeName(value);  // "string", "number", "list", etc.

// Convert any value to string representation
ZymValue str = zym_valueToString(vm, value);
const char* repr = zym_asCString(str);

// Print any value to stdout (same format as Zym's print)
zym_printValue(vm, value);

Native Functions

Native functions bridge C code into Zym scripts.

Signature Format

The signature string tells the VM the function name and its parameter names.

"functionName(param1, param2)"            // fixed arity
"add(a, b)"                                // two params
"greet(name)"                              // single param
"print(...)"                               // variadic (0+ args)
"format(template, ...)"                    // fixed + variadic (1+ args)

Registering a Native Function

C
// The native function implementation
ZymValue native_add(ZymVM* vm, ZymValue a, ZymValue b) {
    return zym_newNumber(zym_asNumber(a) + zym_asNumber(b));
}

// Register it
zym_defineNative(vm, "add(a, b)", native_add);

Now Zym scripts can call add(3, 4) and get 7.

Variadic Native Functions

Variadic natives mirror the same pattern as fixed-arity natives: fixed parameters are passed as individual C arguments, followed by a pointer to the remaining variadic arguments and their count. This keeps the C function signature natural and readable. Use ... in the signature to indicate a variadic function.

ZymStatus zym_defineNativeVariadic(ZymVM* vm, const char* signature, void* func_ptr);

Function signature pattern:

C — pure variadic (0+ args)
ZymValue native_log(ZymVM* vm, ZymValue* vargs, int vargc) {
    for (int i = 0; i < vargc; i++) {
        if (i > 0) printf(" ");
        zym_printValue(vm, vargs[i]);
    }
    printf("\n");
    return zym_newNull();
}

zym_defineNativeVariadic(vm, "log(...)", native_log);
C — fixed + variadic (min 1 arg)
// "template" is passed directly as a C parameter
// vargs/vargc contain only the extra arguments after it
ZymValue native_format(ZymVM* vm, ZymValue template, ZymValue* vargs, int vargc) {
    const char* tmpl = zym_asCString(template);
    // vargs[0..vargc-1] are the format values
    // ... formatting logic ...
    return zym_newString(vm, result);
}

zym_defineNativeVariadic(vm, "format(template, ...)", native_format);
Min arity: The number of named parameters before ... defines the minimum argument count. Calling with fewer arguments triggers a runtime error automatically.

Error Handling

Always return ZYM_ERROR after calling zym_runtimeError():

C
ZymValue native_divide(ZymVM* vm, ZymValue a, ZymValue b) {
    if (!zym_isNumber(a) || !zym_isNumber(b)) {
        zym_runtimeError(vm, "divide() requires two numbers");
        return ZYM_ERROR;
    }

    double divisor = zym_asNumber(b);
    if (divisor == 0.0) {
        zym_runtimeError(vm, "Division by zero");
        return ZYM_ERROR;
    }

    return zym_newNumber(zym_asNumber(a) / divisor);
}

Defining Global Variables

Use zym_defineGlobal() to expose a pre-created value directly in Zym's global scope. Unlike zym_defineNative() which registers a callable function, this sets a global variable that scripts can access by name — ideal for singleton objects or constant values.

ZymStatus zym_defineGlobal(ZymVM* vm, const char* name, ZymValue value);

Returns: ZYM_STATUS_OK on success, ZYM_STATUS_COMPILE_ERROR on failure.

Singleton Module Pattern

A common pattern is to create a map of native closures (a "module object") and expose it as a singleton global. This avoids requiring users to call a factory function and gives direct access:

C
// Create a singleton Console module
ZymValue consoleObj = createConsoleModule(vm);  // returns a map of closures

// Register as a global — scripts access it directly
zym_defineGlobal(vm, "Console", consoleObj);

// In Zym script:
// Console.write("Hello")      ← direct access, no factory call needed
// var w = Console.getWidth()  ← singleton shared across all code

Simple Constants

C
// Expose constants to scripts
zym_defineGlobal(vm, "VERSION", zym_newString(vm, "1.0.0"));
zym_defineGlobal(vm, "MAX_PLAYERS", zym_newNumber(16));
zym_defineGlobal(vm, "DEBUG", zym_newBool(false));

// In Zym script:
// print(VERSION)       // "1.0.0"
// print(MAX_PLAYERS)   // 16
Note: GC protection is handled automatically — zym_defineGlobal() roots the value during insertion. However, the value must be alive (not collected) when you call this function.

Native Closures

Native closures enable you to bind external C modules (file handles, database connections, sockets) to Zym without exposing internal details. Each closure has:

Complete Native Closure Example

C — File Module with Native Closures
// 1. Define private data structure
typedef struct {
    FILE* handle;
    char* path;
    bool is_open;
} FileHandle;

// 2. Define cleanup function (called by GC)
void file_cleanup(ZymVM* vm, void* native_data) {
    FileHandle* file = (FileHandle*)native_data;
    if (file->is_open) {
        fclose(file->handle);
    }
    free(file->path);
    free(file);
}

// 3. Define closure methods (context is first parameter)
ZymValue file_read(ZymVM* vm, ZymValue context, ZymValue lengthVal) {
    // Extract private data from context
    FileHandle* file = (FileHandle*)zym_getNativeData(context);

    if (!file->is_open) {
        zym_runtimeError(vm, "File is not open");
        return ZYM_ERROR;
    }

    if (!zym_isNumber(lengthVal)) {
        zym_runtimeError(vm, "read() requires a number");
        return ZYM_ERROR;
    }

    size_t length = (size_t)zym_asNumber(lengthVal);
    char* buffer = malloc(length + 1);
    size_t read_count = fread(buffer, 1, length, file->handle);
    buffer[read_count] = '\0';

    ZymValue result = zym_newString(vm, buffer);
    free(buffer);
    return result;
}

ZymValue file_close(ZymVM* vm, ZymValue context) {
    FileHandle* file = (FileHandle*)zym_getNativeData(context);

    if (file->is_open) {
        fclose(file->handle);
        file->is_open = false;
    }

    return zym_newNull();
}

// 4. Factory function to create file objects
ZymValue native_openFile(ZymVM* vm, ZymValue pathVal) {
    if (!zym_isString(pathVal)) {
        zym_runtimeError(vm, "openFile() requires a string");
        return ZYM_ERROR;
    }

    const char* path = zym_asCString(pathVal);

    // Allocate private data
    FileHandle* file = malloc(sizeof(FileHandle));
    file->handle = fopen(path, "r");
    file->path = strdup(path);
    file->is_open = file->handle != NULL;

    if (!file->is_open) {
        free(file->path);
        free(file);
        zym_runtimeError(vm, "Failed to open file: %s", path);
        return ZYM_ERROR;
    }

    // Create context with finalizer
    ZymValue context = zym_createNativeContext(vm, file, file_cleanup);
    zym_pushRoot(vm, context);  // Protect during construction

    // Create closures bound to this context
    ZymValue readMethod = zym_createNativeClosure(vm, "read(length)", file_read, context);
    zym_pushRoot(vm, readMethod);

    ZymValue closeMethod = zym_createNativeClosure(vm, "close()", file_close, context);
    zym_pushRoot(vm, closeMethod);

    // Create map to hold the file "object"
    ZymValue fileObj = zym_newMap(vm);
    zym_pushRoot(vm, fileObj);

    zym_mapSet(vm, fileObj, "path", pathVal);
    zym_mapSet(vm, fileObj, "read", readMethod);
    zym_mapSet(vm, fileObj, "close", closeMethod);

    // Pop temp roots
    zym_popRoot(vm);  // fileObj
    zym_popRoot(vm);  // closeMethod
    zym_popRoot(vm);  // readMethod
    zym_popRoot(vm);  // context

    return fileObj;
}

// Register the factory function
zym_defineNative(vm, "openFile(path)", native_openFile);

Usage from Zym:

Zym
var file = openFile("data.txt")
var content = file.read(100)
print(content)
file.close()  // Explicit close

// Or rely on GC (finalizer runs automatically):
var file2 = openFile("other.txt")
var data = file2.read(50)
// When file2 goes out of scope and is collected, finalizer closes the file

Variadic Native Closures

Use zym_createNativeClosureVariadic() to create closures that accept a variable number of arguments. Like variadic native functions, fixed parameters are passed as individual C arguments after the context, followed by the variadic args pointer and count.

ZymValue zym_createNativeClosureVariadic(ZymVM* vm, const char* signature, void* func_ptr, ZymValue context);

Function signature pattern:

C — pure variadic closure with context
typedef struct {
    char* prefix;
} LoggerState;

ZymValue native_log(ZymVM* vm, ZymValue context, ZymValue* vargs, int vargc) {
    LoggerState* state = zym_getNativeData(context);
    printf("%s: ", state->prefix);
    for (int i = 0; i < vargc; i++) {
        if (i > 0) printf(" ");
        zym_printValue(vm, vargs[i]);
    }
    printf("\n");
    return zym_newNull();
}

// Create context and variadic closure
LoggerState* state = malloc(sizeof(LoggerState));
state->prefix = "[INFO]";
ZymValue ctx = zym_createNativeContext(vm, state, logger_finalizer);
zym_pushRoot(vm, ctx);

ZymValue logClosure = zym_createNativeClosureVariadic(vm, "log(...)", native_log, ctx);
zym_popRoot(vm);

// Add to a module map
zym_mapSet(vm, loggerObj, "log", logClosure);

Two-Way FFI

Calling Script Functions from C

You can retrieve and call Zym functions from C code.

C
// Call a script function by name; arguments are variadic.
ZymStatus st = zym_call(vm, "myCallback", 2, zym_newNumber(10), zym_newNumber(20));

// The return value is read separately.
if (st == ZYM_OK) {
    ZymValue result = zym_getCallResult(vm);
}

Checking What Can Be Called

Three related questions, three separate answers. Pick the one you actually mean.

bool zym_hasFunction(ZymVM* vm, const char* funcName, int arity);
bool zym_hasAnyFunction(ZymVM* vm, const char* funcName);
bool zym_canCallWith(ZymVM* vm, const char* funcName, int argc);
C
// Guarding an optional script hook: ask the dispatch question, not the
// slot question — "onFrame(dt)" may be served by a variadic overload.
ZymValue args[] = { zym_newNumber(dt) };
if (zym_canCallWith(vm, "onFrame", 1)) {
    zym_callToCompletion(vm, "onFrame", 1, args);
} else if (zym_hasAnyFunction(vm, "onFrame")) {
    fprintf(stderr, "onFrame exists but takes no 1-argument form\n");
}

Setting Globals from C

C
// Expose a constant to scripts
zym_defineGlobal(vm, "VERSION", zym_newString(vm, "1.0.0"));
zym_defineGlobal(vm, "MAX_PLAYERS", zym_newNumber(64));

Function Overloading (Dispatchers)

Register multiple arities of the same function.

C
// Zero args
ZymValue native_greet0(ZymVM* vm) {
    return zym_newString(vm, "Hello!");
}
// One arg
ZymValue native_greet1(ZymVM* vm, ZymValue name) {
    // ... build greeting string ...
}

zym_defineNative(vm, "greet()", native_greet0);
zym_defineNative(vm, "greet(name)", native_greet1);

Dispatcher with Variadic Fallback

Combine fixed-arity overloads with a variadic fallback using zym_setVariadicFallback(). Exact arity matches are tried first; the variadic fallback catches everything else.

bool zym_setVariadicFallback(ZymVM* vm, ZymValue dispatcher, ZymValue closure, int min_arity);
C — overloaded with variadic fallback
// Optimized 1-arg and 2-arg versions
zym_defineNative(vm, "sum(a)", native_sum1);
zym_defineNative(vm, "sum(a, b)", native_sum2);

// Variadic fallback for 0 or 3+ args
zym_defineNativeVariadic(vm, "sum(...)", native_sum_variadic);

When multiple zym_defineNative and zym_defineNativeVariadic calls share the same base name, the compiler automatically builds a dispatcher that tries exact arity first, then falls back to the variadic.

Script priority: If a script defines a function with the same name and arity as a registered native, the compiler will emit an error. Native function names cannot be redefined by scripts.

Garbage Collection

When creating Zym objects in C, you must protect them from the GC until they are safely rooted (e.g., assigned to a variable or pushed to a list).

Temporary Roots

C
// Push a temporary GC root
zym_pushRoot(vm, value);

// ... do allocations that might trigger GC ...

// Pop when safe
zym_popRoot(vm);
Rule of thumb: If you create a Zym object and then call any function that might allocate (including zym_newString, zym_newList, etc.), protect the first object with zym_pushRoot, and balance it with zym_popRoot.

GC Control from C

There is no public C mirror of the script-side GC API. A host does not pause, resume, or force collection through zym.h. The collector is driven by allocation, and the two supported ways to influence it from C are both indirect.

The first is the allocator hooks. Every byte the VM takes passes through the ZymAllocator you hand to zym_newVM(), so a host that needs to observe or bound allocation does it there, by accounting in its own alloc / realloc / free, or by refusing a request. That is also where an arena, a pool, or a shared-budget allocator is installed.

C
// The hooks the VM allocates through; ctx is passed back to each call.
typedef struct ZymAllocator {
    void* (*alloc)(void* ctx, size_t size);
    void* (*calloc)(void* ctx, size_t count, size_t size);
    void* (*realloc)(void* ctx, void* ptr, size_t old_size, size_t new_size);
    void  (*free)(void* ctx, void* ptr, size_t size);
    void* ctx;
} ZymAllocator;

ZymAllocator zym_defaultAllocator(void);
const ZymAllocator* zym_getAllocator(ZymVM* vm);

Note that realloc and free receive the old size, so a host can keep an exact byte count without a side table.

The second is the memory ceiling, which is the supported way to bound a VM without writing an allocator: zym_setMemoryLimit caps what the VM may retain, and zym_memoryUsed reports where it stands.

Beyond this: the collector's internals live in src/gc.h and src/vm.h, not in the public headers. A host can include them directly to reach what the script-side API exposes, but that is internal surface: it is not covered by the public API and may change between releases.

Module System

Zym provides a compile-time module system for organizing code across multiple files. Modules are loaded, preprocessed, and combined into a single compilation unit before being compiled to bytecode.

Scope: this section covers the host half, the callbacks the embedder supplies and the C entry points that drive them. The embedder provides a read callback and, optionally, a resolve callback, so what an import spec means and where its bytes come from is the host's policy, not the VM's: the loader owns the traversal, the host owns resolution and I/O. The language-level view of import() semantics, exports, and scoping is covered in Modules.

Module Loading Architecture

Entry File → Preprocess → Module Loader → Combined Source → Compiler → Bytecode
                                    ↓
                            Module Files (preprocessed individually)

Key Features:

Basic Module Usage

Module file (math.zym):

Zym
// Math module - provides basic operations

func add(a, b) {
    return a + b
}

func multiply(a, b) {
    return a * b
}

var PI = 3.14159

// Export values by returning a map
return {
    add: add,
    multiply: multiply,
    PI: PI
}

Entry file (main.zym):

Zym
// Import module (path relative to this file)
var math = import("math.zym")

// Use exported values
var sum = math.add(5, 3)
var product = math.multiply(4, 7)
print(math.PI)

Module Loader Integration

Include the module loader header:

C
#include "zym/module_loader.h"

Module Callback

The module loader requires a callback that reads and preprocesses each module file:

C
// Result type returned by callback
typedef struct {
    char*         source;      // Preprocessed source (owned by caller)
    ZymSourceMap* source_map;  // Origin map populated by zym_preprocess
    ZymFileId     file_id;     // File id registered for this module
} ModuleReadResult;

// Callback signature
typedef ModuleReadResult (*ModuleReadCallback)(
    const char* path,
    void* user_data
);

// Optional resolve callback (see "Resolve Callback" below).
// Runs UPSTREAM of all path math -- before resolve_module_path /
// normalize_path, before the cycle detector, and before the module
// cache lookup. Receives the raw import spec exactly as written at
// the call site, plus the canonical key of the importer (NULL on
// the entry edge). Return a borrowed C string to use as the
// canonical key, or NULL to fall back to the loader's default
// `resolve_module_path(importer_dir, spec)` behavior.
typedef const char* (*ModuleResolveCallback)(
    const char* spec,
    const char* importer,
    void* user_data
);

// Example implementation
static ModuleReadResult moduleCallback(const char* path, void* user_data) {
    ZymVM* vm = (ZymVM*)user_data;
    ModuleReadResult result = { .source = NULL, .source_map = NULL,
                                .file_id = ZYM_FILE_ID_INVALID };

    // 1. Read the file
    char* raw_source = readFile(path);
    if (!raw_source) return result;

    // 2. Register with the VM so diagnostics carry a file id.
    ZymFileId fid = zym_registerSourceFile(vm, path, raw_source, strlen(raw_source));

    // 3. Preprocess (pass the SourceMap + fileId so origin bytes are tracked).
    ZymSourceMap* module_map = zym_newSourceMap(vm);
    const char* preprocessed = NULL;
    ZymStatus status = zym_preprocess(vm, raw_source, module_map, fid, &preprocessed);

    free(raw_source);

    if (status != ZYM_STATUS_OK) {
        zym_freeSourceMap(vm, module_map);
        return result;
    }

    result.source     = (char*)preprocessed;
    result.source_map = module_map;
    result.file_id    = fid;
    return result;
}

Complete Integration Example

C
int main(void) {
    ZymVM* vm = zym_newVM(NULL);
    ZymChunk* chunk = zym_newChunk(vm);

    // Read, register, and preprocess the entry file.
    const char* entry_file = "main.zym";
    char* entry_source = readFile(entry_file);

    ZymFileId entry_fid = zym_registerSourceFile(vm, entry_file,
                                                 entry_source, strlen(entry_source));

    ZymSourceMap* entry_map = zym_newSourceMap(vm);
    const char* preprocessed_entry = NULL;
    zym_preprocess(vm, entry_source, entry_map, entry_fid, &preprocessed_entry);

    // Load modules
    ModuleLoadResult* modules = loadModules(
        vm,
        preprocessed_entry,      // already-preprocessed entry source
        entry_map,               // entry file's origin map
        entry_file,              // entry path (used for relative resolution)
        moduleCallback,          // callback to read + preprocess modules
        vm,                      // user_data
        true,                    // use human-readable debug names
        true,                    // write debug output
        "module_debug.zym"       // debug output path
    );

    if (modules->has_error) {
        fprintf(stderr, "Module loading failed: %s\n", modules->error_message);
        freeModuleLoadResult(vm, modules);
        return 1;
    }

    printf("Loaded %d module(s)\n", modules->module_count);

    // Compile combined source (pass the combined SourceMap built by loadModules).
    ZymCompilerConfig config = { .include_line_info = true };
    if (zym_compile(vm, modules->combined_source, chunk,
                     modules->source_map, entry_file, config, NULL) != ZYM_STATUS_OK) {
        // Drain diagnostics here (see Diagnostics section).
        freeModuleLoadResult(vm, modules);
        return 1;
    }

    // Execute
    zym_runChunk(vm, chunk);

    // Cleanup
    freeModuleLoadResult(vm, modules);
    zym_freeSourceMap(vm, entry_map);
    zym_freeProcessedSource(vm, preprocessed_entry);
    free(entry_source);
    zym_freeChunk(vm, chunk);
    zym_freeVM(vm);

    return 0;
}

Extended Entry Point: loadModulesEx

loadModules is a thin wrapper around loadModulesEx, which adds one extra parameter: an optional ModuleResolveCallback. Passing NULL for the resolver is byte-identical to calling loadModules; embedders that don't need to canonicalize module keys can keep using loadModules verbatim. When a resolver is installed, it sees the raw (spec, importer) pair and is authoritative over the canonical key. The loader does not join spec against importer's directory before the call.

C
ModuleLoadResult* loadModulesEx(
    ZymVM*                vm,
    const char*          entry_source,
    ZymSourceMap*         entry_source_map,
    const char*          entry_path,
    ModuleReadCallback    read_callback,
    ModuleResolveCallback resolve_callback,   // NULL = default behavior
    void*                user_data,
    bool                 debug_names,
    bool                 write_debug_output,
    const char*          debug_output_path
);

Resolve Callback

Without a resolver, the C module loader keys its cycle detector and module cache on the string produced by its built-in path resolver (resolve_module_path(importer_dir, spec)normalize_path), i.e. the same root-relative path that read_callback sees as its path argument. That key is decided before read_callback ever runs, which means a read_callback cannot, on its own, distinguish two physically-distinct modules that happen to collapse to the same root-relative string, and it has no opportunity to express non-filesystem schemes like @/foo, pkg:json, std/..., or absolute /-rooted paths without the loader's default join mangling them first.

Concretely, given the chain script → m1 → (dataDir)m2 → (dataDir)m1, where (dataDir) is a script-side routing decision, the loader resolves the second m1 to the bare key "m1" (already on the ImportStack from the first hop) and synthesizes a false-positive Circular import detected: m1 → m2 → m1 before any read callback fires. Symmetrically, two parallel imports of "m1" from different namespaces silently alias into a single cache slot.

ModuleResolveCallback plugs in upstream of all path math. The loader hands it the raw spec exactly as it appeared at the import("...") call site, together with the canonical key of the importer (or NULL on the entry edge), and uses whatever non-NULL string the callback returns as the canonical key directly, with no further joining or normalize_path pass. That key drives cycle detection, the module cache, the read_callback path argument, and the importer argument passed to the resolver on any transitive import() calls discovered inside that module. The same resolver is also consulted at every import("...") call-site rewrite, so the encoded identifier emitted at the use site stays in sync with the encoded identifier of the loaded module. Returning NULL (or installing no resolver) keeps the loader's default resolve_module_path(importer_dir, spec) behavior byte-for-byte.

C — example resolver: data-dir namespace
static const char* resolveCallback(const char* spec,
                                     const char* importer,
                                     void* user_data) {
    // `spec` is the raw string from the import() call site.
    // `importer` is the canonical key of the file doing the import,
    // or NULL when the loader is resolving an entry-level edge.

    // Sibling import inside an already-known data-dir module:
    // keep it in the data-dir namespace.
    if (importer && strncmp(importer, "data:", 5) == 0) {
        static __thread char buf[1024];
        snprintf(buf, sizeof(buf), "data:%s", spec);
        return buf;   // borrowed; loader copies on return
    }

    // Bare name from the entry tree -> route to data dir.
    if (!strchr(spec, '.')) {
        static __thread char buf[1024];
        snprintf(buf, sizeof(buf), "data:%s", spec);
        return buf;
    }

    return NULL;   // keep loader default for ordinary local imports
}

/* ... */

ModuleLoadResult* modules = loadModulesEx(
    vm,
    preprocessed_entry,
    entry_map,
    entry_file,
    moduleCallback,
    resolveCallback,         // NEW: optional, NULL = today's behavior
    vm,
    true, true, "module_debug.zym"
);

With this resolver installed, the chain script → m1 → (dataDir)m2 → (dataDir)m1 appears to the loader as ["m1", "data:m2", "data:m1"]: no collision with the entry-tree m1, no false cycle, and a genuine data:m1 → data:m2 → data:m1 still trips the cycle detector correctly. The read_callback receives each canonicalized key and is responsible for translating it back into a filesystem read (e.g. stripping the data: prefix and joining against the data-dir root).

Supported Characters in Resolver-Returned Keys

The string a resolve_callback returns is used by the loader as the canonical cache key and as the source of the __module_<encoded> identifier emitted at every import("...") call site. That identifier has to be a legal Zym/C identifier, so the loader only knows how to escape a fixed alphabet. Anything outside it passes through one byte for one byte and the generated identifier will then be syntactically invalid, causing the compile of any module that references such a key to fail.

The supported alphabet is:

Characters not in this set (e.g. ?, =, +, (, ), ,, <, >, ', ", ;, |, ^, non-ASCII bytes, etc.) are passed through verbatim by the encoder and will produce an invalid __module_... identifier. If you need to surface a spec that contains them, map it inside your resolver, e.g. turn pkg:foo?v=2 into pkg:foo/v2 before returning. Note that \ collapses to / by design (Windows-style separators canonicalize to POSIX), so a\b and a/b are not distinguishable keys.

Module-Loader Runtime Introspection

The loader maintains an internal ImportStack that records the chain of in-flight read_callback (and resolve_callback) invocations. The bottom of the stack is the entry module's canonical key; the top is the key currently being processed. Three accessors expose that state to embedders without changing any callback signature:

C
// Number of import frames currently active on this VM.
// Returns 0 when no callback is in flight.
int zym_currentImportDepth(ZymVM* vm);

// Canonical key at position `i` (0 == entry, depth-1 == current).
// Returns NULL if `i` is out of range or no callback is active.
const char* zym_currentImportPathAt(ZymVM* vm, int i);

// Convenience: the immediate requester of the current import,
// i.e. zym_currentImportPathAt(vm, depth - 2). NULL on the entry
// edge or when no callback is active.
const char* zym_currentImportCaller(ZymVM* vm);

Scope of validity.

How Module Loading Works

  1. Entry file processing: Entry file read and preprocessed by host
  2. Dependency discovery: Module loader scans for import("path") calls
  3. Recursive loading: For each module:
    • Resolves path relative to containing file
    • Calls your callback to read and preprocess
    • Scans for nested import() calls
    • Repeats recursively
  4. Module wrapping: Each module wrapped as function: func __module_<hash>() { <module code> }
  5. Import transformation: import("math.zym")__module_467954409()
  6. Combination: All module functions emitted first, entry file transformed and appended
  7. Compilation: Combined source compiled as one unit to single bytecode chunk

Path Resolution

Module paths are resolved relative to the file containing the import() call:

project/
├── main.zym           (imports "lib/math.zym")
├── lib/
│   ├── math.zym       (imports "helpers.zym")
│   └── helpers.zym

Resolution:

Circular Dependency Detection

The module loader detects circular dependencies at compile time:

Zym — a.zym
var b = import("b.zym")
return { data: "A" }
Zym — b.zym
var a = import("a.zym")  // Error: Circular dependency!
return { data: "B" }

Error: Module loading failed: Circular dependency detected: b.zym

Debug Output

When write_debug_output is true, the module loader writes the combined source:

module_debug.zym
// ===== Module Loader Debug Output =====
// Entry: main.zym
// Loaded 2 module(s):
//   - main.zym
//   - lib/math.zym
// =======================================

func __module_467954409() {
    func add(a, b) {
        return a + b
    }

    return {
        add: add
    }
}

var math = __module_467954409()
var sum = math.add(5, 3)
print(sum)

This is useful for debugging module loading issues and verifying transformations.

Modules with Serialization

Modules are resolved before compilation, so serialized bytecode contains all modules:

C
// Compile with modules
ModuleLoadResult* modules = loadModules(...);
zym_compile(vm, modules->combined_source, chunk, modules->source_map,
            "entry.zym", config, NULL);

// Serialize (contains all modules)
char* bytecode;
size_t size;
zym_serializeChunk(vm, config, chunk, &bytecode, &size);

// Later, deserialize and run (no module loading needed)
ZymChunk* loaded = zym_newChunk(run_vm);
zym_deserializeChunk(run_vm, loaded, bytecode, size);
zym_runChunk(run_vm, loaded);  // Works! All modules embedded

Preemption

Preemption lets you limit how many VM instructions execute before control returns to the host. This is essential for sandboxing untrusted scripts, building cooperative schedulers, and preventing infinite loops from hanging your application.

How It Works

A single instruction countdown drives a small table of independent preempt entries, so a host watchdog, a host event pump, and a script scheduler coexist without fighting over one global timer. Each dispatched instruction decrements the countdown; all table work happens on expiry, so the cost in the dispatch loop is one decrement and one predicted branch whether or not any entries exist.

When an entry comes due, one of two things happens:

  1. If the entry has a callback, the VM calls it in-place (no C-stack unwinding). The callback can capture a continuation, do bookkeeping, or simply return.
  2. If the entry has no callback, the VM suspends and returns ZYM_STATUS_SUSPENDED to the host, with zym_vmCause() reading ZYM_CAUSE_PREEMPT. You resume later with zym_resume().

Slices are instruction counts, not wall-clock time, so they are deterministic and machine-independent. A host that needs a wall-clock deadline drives zym_preemptTrigger() from its own clock, or uses the hard stop. A slice below 1 is clamped to 1, so an entry always makes forward progress.

Host-Side Preemption Entries

Entries registered through this API are host-owned. Script cannot cancel them, retune them, or trigger them, and cannot mask them unless you pass ZYM_PREEMPT_MASKABLE. The reverse is not true: the host can address script-owned entries by id.

typedef uint32_t ZymPreemptId;   // 0 is never a valid id

#define ZYM_PREEMPT_MASKABLE  (1u << 0)   // may be suppressed by a script shield
#define ZYM_PREEMPT_ONESHOT   (1u << 1)   // retire after firing instead of rearming

ZymPreemptId zym_preemptRegister(ZymVM* vm, int slice,
                                 ZymValue callback, uint32_t flags);
bool zym_preemptUnregister(ZymVM* vm, ZymPreemptId id);
bool zym_preemptSetSlice(ZymVM* vm, ZymPreemptId id, int slice);
int  zym_preemptRemaining(ZymVM* vm, ZymPreemptId id);
bool zym_preemptTrigger(ZymVM* vm, ZymPreemptId id);
int  zym_preemptCapacity(void);
FunctionReturnsNotes
zym_preemptRegister(vm, slice, callback, flags)ZymPreemptIdRegisters a host-owned entry firing every slice instructions. 0 when the table is full, or when callback is a closure taking arguments. Check it. Pass zym_newNull() for a watchdog.
zym_preemptUnregister(vm, id)boolRemoves an entry. false if the id is unknown. A host may unregister a script-owned entry.
zym_preemptSetSlice(vm, id, slice)boolSets the interval and restarts the countdown: the entry next fires slice instructions from now. This is how you give an exhausted entry more budget.
zym_preemptRemaining(vm, id)intInstructions until this entry fires; -1 if unknown.
zym_preemptTrigger(vm, id)boolFires the entry at the next instruction boundary regardless of its countdown. The hook for wall-clock deadlines, signals, and UI cancel buttons.
zym_preemptCapacity()intTotal entries per VM.
Capacity is a build-time constant, shared with script. It defaults to 8 (the MCU figure) and is overridden with -DZYM_PREEMPT_MAX_ENTRIES=N, the same way FRAMES_MAX and STACK_MAX are; the zym CLI builds with 32. Each slot costs 24 bytes of VM struct. Read zym_preemptCapacity() rather than hard-coding a number. A library compiled against one value and a host assuming another is the failure this avoids.

Watchdogs: a NULL callback means abort

Passing zym_newNull() as the callback registers a watchdog. On expiry the VM runs nothing: it returns ZYM_STATUS_SUSPENDED with zym_vmCause() reading ZYM_CAUSE_PREEMPT.

That is the shape to reach for when supervising code you do not trust. A callback-based entry runs script, which is something the script can subvert: it could loop inside the callback, raise from it, or arrange for it never to complete. A watchdog gives it nothing to work with.

C
// Non-maskable so a script shield cannot defer it; rearming so each
// zym_resume grants another slice.
ZymPreemptId wd = zym_preemptRegister(vm, 1000000, zym_newNull(), 0);
if (wd == 0) { /* table full */ }

Reserving Slots From Script

The table is shared, so a script that registers greedily can leave the host unable to arm a watchdog or a deadline later. A reserve holds slots back: script's ceiling becomes capacity - reserve, while the host stays free to use any slot script has not taken. A reserve is a floor for the host and a ceiling for script.

bool zym_setHostPreemptReserve(ZymVM* vm, int slots);
int  zym_getHostPreemptReserve(const ZymVM* vm);

int  zym_preemptCount(const ZymVM* vm, bool script_owned_only);
int  zym_preemptScriptCapacity(const ZymVM* vm);   // capacity - reserve
int  zym_preemptScriptAvailable(const ZymVM* vm);
int  zym_preemptIds(const ZymVM* vm, ZymPreemptId* out, int max);
FunctionReturnsNotes
zym_setHostPreemptReserve(vm, slots)boolHolds slots back from script. false once the VM has executed anything, and false for a value outside [0, capacity].
zym_getHostPreemptReserve(vm)intThe reserve currently in force.
zym_preemptCount(vm, script_owned_only)intLive entries; pass true to count just the ones script registered.
zym_preemptScriptCapacity(vm)intcapacity - reserve — the ceiling script sees.
zym_preemptScriptAvailable(vm)intWhat script could still take right now.
zym_preemptIds(vm, out, max)intWrites up to max live ids into out (host and script alike) and returns the total number live, which may exceed max. Pass NULL to count only.

The reserve is settable only before the VM has executed anything. That restriction is the point: script must be able to treat its capacity as fixed for the whole run, so a budget read at the start is still bindable at the end. It also means the reserve can never fail to be satisfied: at bring-up script holds nothing. It is expressed as a reserve rather than a script quota so it stays correct when ZYM_PREEMPT_MAX_ENTRIES changes with the build target: a quota of 24 is right on a 32-slot build, wrong on an 8-slot one, and stale on a 64-slot one.

The payoff is late binding without idle cost. Without a reserve, a host that might need a slot later has to register one up front, and a live entry is not free. It joins every rearm calculation and every expiry scan, and a rearming entry fires on its own schedule whether the host wants it yet or not.

C
ZymVM* vm = zym_newVM(NULL);
zym_setHostPreemptReserve(vm, 8);      // before anything runs
// ... register natives, define globals, compile ...
// script may now hold at most capacity - 8, and 8 remain the host's

Host and Script Authority

Basic Host-Side Preemption

The simplest pattern: arm a watchdog, and handle suspensions in a loop.

C
ZymVM* vm = zym_newVM(NULL);
setupNatives(vm);

// Rearming, non-maskable watchdog: control returns every 100k instructions.
ZymPreemptId wd = zym_preemptRegister(vm, 100000, zym_newNull(), 0);

ZymStatus status = zym_runChunk(vm, chunk);

// The "preempt pump" — handle suspensions in a loop.
int slices = 0;
while (status == ZYM_STATUS_SUSPENDED) {
    if (zym_vmCause(vm) != ZYM_CAUSE_PREEMPT) break;  // stop or memory ceiling: not ours to resume
    if (++slices > 1000) break;                     // a rearming entry grants budget forever

    // Opportunity to do host-side work:
    // - Check timeouts or resource limits
    // - Process events
    // - Decide whether to continue or abort
    status = zym_resume(vm);
}

if (status != ZYM_STATUS_OK) {
    fprintf(stderr, "Script did not complete\n");
}

zym_preemptUnregister(vm, wd);
zym_freeVM(vm);

The slice bound is the point of the loop. A rearming watchdog on its own grants budget forever, one slice at a time; the counter is what converts it into a hard total. See VM State & Cause for why a bare while (status == ZYM_STATUS_SUSPENDED) status = zym_resume(vm); is never correct.

Calling Functions with Preemption

When calling script functions from C, zym_callToCompletion() applies the same policy as zym_runToCompletion(): it continues past suspensions the host has no decision to make about and hands back everything else.

C
ZymValue args[] = { zym_newNumber(delta_time) };
ZymStatus result = zym_callToCompletion(vm, "update", 1, args);

if (result == ZYM_STATUS_SUSPENDED) {
    // A watchdog, a host stop, or the memory ceiling — ask zym_vmCause().
} else if (result != ZYM_STATUS_OK) {
    // Handle error
}

Script-Side Callback (In-VM Preemption)

For fully script-driven scheduling (e.g. fiber schedulers), a callback runs inside the current execution context when its entry expires, with no C-stack unwinding. Register one from C by passing a closure to zym_preemptRegister(), or from script with Preempt.every() / Preempt.once().

void zym_setPreemptCallback(ZymVM* vm, ZymValue callback);

zym_setPreemptCallback is the older single-callback shim, retained on the VM. New host code registers callbacks per entry through zym_preemptRegister, which is host-owned and carries its own slice and flags.

Preemption API

FunctionDescription
zym_resume(vm)Continue execution after ZYM_STATUS_SUSPENDED
zym_runToCompletion(vm, chunk)Run a chunk, continuing past suspensions the host cannot decide about (see VM State & Cause)
zym_callToCompletion(vm, fn, argc, argv)Same policy, for a call into a script function
zym_setPreemptCallback(vm, callback)Register a ZymValue closure as the single-callback preemption handler
Status CodeMeaning
ZYM_STATUS_OKExecution completed successfully
ZYM_STATUS_SUSPENDEDPaused at an instruction boundary with frames, stack, and ip intact. Not an error. Ask zym_vmCause() why
ZYM_STATUS_RUNTIME_ERRORScript error occurred
ZYM_STATUS_COMPILE_ERRORCompilation failed

Sandboxing Example

Both halves of a resource budget: a watchdog bounds time, the ceiling bounds allocation.

C
ZymPreemptId wd = zym_preemptRegister(vm, 500000, zym_newNull(), 0);
zym_setMemoryLimit(vm, zym_memoryUsed(vm) + (4u << 20));   // +4 MiB

ZymStatus status = zym_runChunk(vm, chunk);
int slices = 0;

while (status == ZYM_STATUS_SUSPENDED) {
    if (zym_oomPending(vm)) {
        fprintf(stderr, "script exceeded its memory budget\n");
        break;
    }
    if (++slices > 20) {
        fprintf(stderr, "script exceeded its total instruction budget\n");
        break;
    }
    /* host work between slices */
    status = zym_resume(vm);
}

zym_preemptUnregister(vm, wd);

Testing the cause inside the loop is what separates "needs more time" from "needs more memory". Every suspension arrives as ZYM_STATUS_SUSPENDED, and the two call for different decisions. zym_vmCause() answers it directly; zym_oomPending() is the narrower check used above.

Script-side API: The Preempt module is available automatically in every VM. Scripts can call Preempt.every(), Preempt.once(), Preempt.shield(), and read their own budget with Preempt.capacity() / Preempt.available(). That surface is deliberately weaker than this one: it owns only its own entries, and a shield it raises never suppresses a host entry. See the Continuations & Preemption docs for the full script-side API.

Hard Stop

A stop outranks everything. It is checked before any masking logic, so a script shield, an in-flight preempt callback, or an empty entry table cannot suppress it, and the VM never clears it on the host's behalf.

void zym_requestStop(ZymVM* vm);
void zym_clearStop(ZymVM* vm);
bool zym_stopRequested(const ZymVM* vm);
bool zym_isAborting(const ZymVM* vm);
FunctionDescription
zym_requestStop(vm)Stops the VM at its next instruction. Unmaskable and sticky.
zym_clearStop(vm)Clears it. Required before the VM can run again.
zym_stopRequested(vm)Whether a stop is pending.
zym_isAborting(vm)The same condition, read from inside a native that wants to bail out early.

Execution suspends with ZYM_STATUS_SUSPENDED and cause ZYM_CAUSE_HOST_STOP. It is deliberately not a runtime error: no diagnostic is pushed and no script-visible handler runs, so a sandboxed script cannot observe or intercept its own termination.

Cross-context. The stop flag is declared for cross-context writes, so zym_requestStop is safe to call from a signal handler, an ISR, or another thread while the VM runs. The rest of the preemption API is not.
Propagate it. A native that re-enters the VM must pass ZYM_STATUS_SUSPENDED back out rather than treating it as an ordinary failure. Swallowing it defeats the stop: the script continues running inside the native's error path.

Stop is per-VM. Stopping a child VM leaves its parent, and every sibling, running.

Memory Ceiling

A per-VM byte budget. A watchdog bounds how long a script runs; this bounds how much it allocates. 0, the default, means unlimited.

void   zym_setMemoryLimit(ZymVM* vm, size_t bytes);
size_t zym_getMemoryLimit(const ZymVM* vm);
size_t zym_memoryUsed(const ZymVM* vm);
bool   zym_oomPending(const ZymVM* vm);
void   zym_clearOom(ZymVM* vm);
FunctionDescription
zym_setMemoryLimit(vm, bytes)Set the ceiling. 0 is unlimited. Setting it above current usage retires a pending breach automatically.
zym_getMemoryLimit(vm)The ceiling currently in force.
zym_memoryUsed(vm)Bytes currently accounted to this VM.
zym_oomPending(vm)Whether the ceiling has been crossed and not yet cleared.
zym_clearOom(vm)Clear the breach without raising the limit.

Crossing the ceiling does not fail the allocation. The request is satisfied (the host allocator still has memory) and the VM is then suspended at the next instruction boundary with ZYM_STATUS_SUSPENDED and cause ZYM_CAUSE_MEMORY_LIMIT, exactly like a watchdog. Failing the allocation instead would strand every caller inside the VM that assumes allocation succeeds, and would leave the host nothing to recover from. Overshoot is therefore bounded by one allocation rather than zero.

A collection is attempted before the ceiling is declared crossed, so a program that merely produces garbage is never charged for it. Only what it retains counts.

The condition is sticky, like a hard stop: resuming without clearing it suspends again immediately. The host's options are to raise the limit (which clears the breach automatically once usage is back under budget), free what it can and call zym_clearOom(), or discard the VM.

C
// Budget this run at 4 MiB above whatever the VM already holds.
zym_setMemoryLimit(vm, zym_memoryUsed(vm) + (4u << 20));

// After a suspension:
if (zym_oomPending(vm)) {
    fprintf(stderr, "script exceeded its memory budget (%zu / %zu bytes)\n",
            zym_memoryUsed(vm), zym_getMemoryLimit(vm));
}

When the Allocator Itself Fails

A ceiling breach is recoverable because memory is available and the host merely declined to hand more over: the allocation succeeds and the VM suspends. A genuine allocation failure cannot work that way, because the reallocation path has to return usable memory to callers that assume success.

So it unwinds instead. The VM leaves the operation entirely and the call returns ZYM_STATUS_RUNTIME_ERROR (or ZYM_STATUS_COMPILE_ERROR from zym_compile) with zym_vmCause() reading ZYM_CAUSE_OUT_OF_MEMORY, in state ZYM_STATE_FAILED rather than SUSPENDED. That distinction is deliberate: the frames were abandoned mid-operation, so the VM is not continuable. Free it.

The unwind lands at the nearest API boundary, so a native that re-entered the VM gets a status back and returns normally rather than being jumped over. One case remains fatal: an allocation failure with no boundary armed (outside any VM operation, such as during zym_newVM itself). There is nowhere to unwind to.

During a compile. A compile has no instruction boundary to suspend at, so the memory ceiling reaches it through the frontend's cancellation poll instead: crossing the ceiling mid-compile stops it at the next statement boundary with a compile error, rather than letting it run away unbounded.

The ceiling bounds script-driven allocation only. It does not make a genuine allocator failure recoverable, and it does not cover the collector's own internal allocations.

VM State & Cause

A ZymStatus tells you what one call returned. These tell you what the VM is, and why. Both are readable at any time, including from inside a native while the VM is running, or from another context.

ZymVmState zym_vmState(const ZymVM* vm);
ZymVmCause zym_vmCause(const ZymVM* vm);
void       zym_vmInfo(const ZymVM* vm, ZymVmInfo* out);

The two are separate axes on purpose. State answers whether execution can continue; cause answers what put it there. New reasons to stop become new causes, so neither the state enum nor any existing branch on it has to grow.

States

ZymVmStateMeaning
ZYM_STATE_IDLENever ran, or the last run finished
ZYM_STATE_RUNNINGInside dispatch (observable from natives and from other contexts)
ZYM_STATE_SUSPENDEDStopped mid-execution, frames intact
ZYM_STATE_FAILEDThe last run ended in an error

Causes

ZymVmCauseMeaning
ZYM_CAUSE_NONENo transition out of RUNNING has been recorded
ZYM_CAUSE_SCRIPT_YIELDReserved. There is no script-visible yield today, so nothing sets it; it exists so that adding a cooperative yield later is a new cause rather than a new state
ZYM_CAUSE_PREEMPTA preempt entry expired
ZYM_CAUSE_PREEMPT_BLOCKEDAn entry came due but its callback frame could not be pushed (call depth or stack exhausted). Control returns to the host rather than silently dropping the callback
ZYM_CAUSE_HOST_STOPzym_requestStop
ZYM_CAUSE_MEMORY_LIMITThe memory ceiling was crossed
ZYM_CAUSE_OUT_OF_MEMORYThe allocator itself failed and a collection did not free enough. Unlike MEMORY_LIMIT this is not resumable. The VM was unwound and must be discarded
ZYM_CAUSE_RUNTIME_ERRORThe run ended in a runtime error
ZYM_CAUSE_COMPILE_ERRORThe compile ended in an error

cause latches the reason for the last transition out of RUNNING and stays readable until the next run begins, so it can be inspected after the fact.

ZymVmInfo

One call for the whole picture:

typedef struct {
    ZymVmState   state;
    ZymVmCause   cause;
    bool         resumable;     // can zym_resume succeed *right now*
    ZymPreemptId preempt_id;    // which entry, when cause is a preemption
    size_t       bytes_wanted;  // the allocation that crossed, when MEMORY_LIMIT
    size_t       memory_limit;
    size_t       memory_used;
} ZymVmInfo;

The detail fields are meaningful only for their own cause: preempt_id for the preemption causes, bytes_wanted for ZYM_CAUSE_MEMORY_LIMIT. resumable is the field to branch on when driving a resume loop: it folds together "is anything suspended" with "has every sticky condition been cleared", which is otherwise three flags the host has to check itself.

Resuming

A suspension does not unwind. Frames, stack, and instruction pointer are all intact, which is what makes zym_resume() meaningful. There is one suspended status, not one per reason, because there is one VM state. zym_vmCause() says which condition applies, and they call for different responses.

Suspended byTo continue
a rearming watchdogzym_resume — each call grants one fresh slice
a watchdog you are finished withzym_preemptUnregister first
a watchdog needing a different budgetzym_preemptSetSlice, which restarts the countdown
zym_requestStopzym_clearStop first; sticky by design
the memory ceilingzym_setMemoryLimit higher, or zym_clearOom

If more than one condition is pending, all must be cleared. Resuming a VM that is not suspended returns ZYM_STATUS_RUNTIME_ERROR rather than executing from a stale position.

Running to Completion

ZymStatus zym_runToCompletion(ZymVM* vm, ZymChunk* chunk);
ZymStatus zym_callToCompletion(ZymVM* vm, const char* funcName,
                               int argc, ZymValue* argv);

Both run and transparently continue past suspensions the host has no decision to make about: today only ZYM_CAUSE_PREEMPT_BLOCKED, where a preempt callback could not be pushed because the call stack was exhausted and the entry has already been rearmed. A watchdog, a host stop, and the memory ceiling all return ZYM_STATUS_SUSPENDED to the caller, because auto-resuming those would defeat them.

Never write while (s == ZYM_STATUS_SUSPENDED) s = zym_resume(vm); That disarms every watchdog on the VM: it grants a fresh slice forever and the supervision never reaches the host. The completion helpers exist so the policy lives in one place; when you do drive the loop yourself, branch on zym_vmCause() and bound the total number of slices.
Calling in from a native. These entry points are re-entrant (a native may call back into the same VM), but three properties are the caller's responsibility. Natives are atomic with respect to suspension and capture: while a C frame is live the VM cannot suspend out past it and a continuation cannot be captured across it; script running underneath is still preemptible in place. A failed VM stays failed: calling in while zym_vmState() is ZYM_STATE_FAILED is permitted, and a call that succeeds does not clear the failure. A suspension survives your call: the parked run is restored when the call returns. What you must not do is start a nested run or resume from inside a preempt callback.

Build-Time Feature Flags

zym_core exposes a small set of compile-time options so resource-constrained hosts (MCUs, firmware, minimum-footprint embeds) can strip LSP-facing machinery out of the binary entirely. Flags are pure CMake options. There is no runtime toggle and the omitted code/types are not linked.

CMake optionWhat it gates
ZYM_ENABLE_LSP_SURFACEUmbrella: flips the four flags below in one step.
ZYM_ENABLE_PARSE_TREE_RETENTIONRetained AST + trivia buffer for LSP consumers.
ZYM_ENABLE_SYMBOL_TABLEParallel resolver producing a (fileId, byte) → Symbol map. Requires PARSE_TREE_RETENTION.
ZYM_ENABLE_NATIVE_METADATAsummary / docs / params strings on native registrations.
ZYM_ENABLE_DIAGNOSTIC_CODEScode / hint string fields on ZymDiagnostic.

For each ZYM_ENABLE_* option, the build generates a matching ZYM_HAS_* predicate in zym/config.h (always 0 or 1). Use these in your own code to guard LSP-only APIs:

C
#include "zym/config.h"

#if ZYM_HAS_DIAGNOSTIC_CODES
    if (d->code) fprintf(stderr, " [%s]", d->code);
#endif

// zymConfigSummary() is a compile-time concatenated string describing
// which flags are enabled in the linked zym_core. Useful for diagnostics.
printf("zym built with: %s\n", zymConfigSummary());

Recommended Profiles

ProfileFlagsTarget
fullall ON (default)Desktop CLI, server, LSP host
embed-midPARSE_TREE_RETENTION + NATIVE_METADATA + DIAGNOSTIC_CODES ON; SYMBOL_TABLE OFFMid-range MCU with on-device compile, no resolver
mcu-diagall OFF except DIAGNOSTIC_CODESSmall MCU, wants codes for error reporting
mcu-minall OFF (-DZYM_ENABLE_LSP_SURFACE=OFF)Minimum-footprint MCU, source → bytecode → run
Semantic invariance. Flags never alter VM semantics. A script that compiles and runs on full compiles and runs identically on mcu-min; only the surface area available to the embedder changes.

Comprehensive API Reference

VM Lifecycle

FunctionDescription
zym_newVM(allocator)Create a new VM instance (NULL for default allocator)
zym_getAllocator(vm)Get the allocator used by a VM
zym_freeVM(vm)Destroy a VM and free all memory
zym_setErrorCallback(vm, cb, data)Set runtime error callback (legacy; compile-time errors flow through zymGetDiagnostics)
zymConfigSummary()Return a compile-time string describing enabled feature flags

Source Files & Diagnostics

FunctionDescription
zym_registerSourceFile(vm, path, bytes, len)Register a source buffer; returns a ZymFileId used in diagnostics
zym_newSourceMap(vm)Create a source map for preprocessor origin tracking
zym_freeSourceMap(vm, map)Free a source map
zymGetDiagnostics(vm, &count)Read the VM's compile-time diagnostic buffer (VM-owned)
zymClearDiagnostics(vm)Reset the diagnostic buffer before the next compile

Compilation & Execution

FunctionDescription
zym_newChunk(vm)Create a new bytecode chunk
zym_freeChunk(vm, chunk)Free a bytecode chunk
zym_preprocess(vm, src, map, fileId, &out)Preprocess source; populates map with origin spans
zym_freeProcessedSource(vm, out)Free the buffer returned from zym_preprocess
zym_compile(vm, src, chunk, map, file, config, out_tree)Compile source to bytecode (map may be NULL for raw, unpreprocessed input; out_tree may be NULL)
zym_parseOnly(vm, src, map, file, out_tree)Scan + preprocess + parse only; hands back a caller-owned parse tree (requires ZYM_HAS_PARSE_TREE_RETENTION)
zym_check(vm, src, map, file, out_tree, out_table)Parse plus the parallel resolver; hands back tree and symbol table (requires ZYM_HAS_SYMBOL_TABLE)
zym_runChunk(vm, chunk)Execute a compiled chunk (may return ZYM_STATUS_SUSPENDED)
zym_resume(vm)Continue execution after a suspension
zym_runToCompletion(vm, chunk)Run a chunk, continuing only past suspensions the host cannot decide about
zym_callToCompletion(vm, fn, argc, argv)Same policy, for a call into a script function
zym_setPreemptCallback(vm, callback)Register script closure as the single-callback preemption handler
zym_serializeChunk(vm, config, chunk, buf, size)Serialize chunk to buffer (allocates via malloc)
zym_deserializeChunk(vm, chunk, buf, size)Deserialize buffer to chunk
zym_requestCancel(vm)Ask an in-flight compile to abort cooperatively (thread-safe)
zym_clearCancel(vm)Reset the cancel flag before the next compile
zym_wasCancelled(vm)True if the previous non-OK compile was cancelled rather than failed

Preemption (Host Side)

FunctionDescription
zym_preemptRegister(vm, slice, cb, flags)Register a host-owned entry; zym_newNull() as cb makes it a watchdog. Returns 0 on failure
zym_preemptUnregister(vm, id)Remove an entry (host or script owned)
zym_preemptSetSlice(vm, id, slice)Retune an entry and restart its countdown
zym_preemptRemaining(vm, id)Instructions until the entry fires (-1 if unknown)
zym_preemptTrigger(vm, id)Fire the entry at the next instruction boundary
zym_preemptCapacity()Build-time table size (entries per VM)
zym_preemptCount(vm, script_owned_only)Live entries, optionally only script's
zym_preemptIds(vm, out, max)Write live ids into out; returns the total live
zym_setHostPreemptReserve(vm, slots)Hold slots back from script (before the VM has executed anything)
zym_getHostPreemptReserve(vm)The reserve in force
zym_preemptScriptCapacity(vm)capacity - reserve
zym_preemptScriptAvailable(vm)What script could still register

Stop, Memory Ceiling & VM State

FunctionDescription
zym_requestStop(vm)Stop the VM at its next instruction (unmaskable, sticky, cross-context safe)
zym_clearStop(vm)Clear a pending stop; required before the VM can run again
zym_stopRequested(vm)Whether a stop is pending
zym_isAborting(vm)Same condition, for a native that wants to bail out early
zym_setMemoryLimit(vm, bytes)Set the per-VM byte ceiling (0 = unlimited)
zym_getMemoryLimit(vm)The ceiling in force
zym_memoryUsed(vm)Bytes currently accounted to this VM
zym_oomPending(vm)Whether the ceiling was crossed and not yet cleared
zym_clearOom(vm)Clear the breach without raising the limit
zym_vmState(vm)IDLE / RUNNING / SUSPENDED / FAILED
zym_vmCause(vm)Why the VM last left RUNNING
zym_vmInfo(vm, &out)State, cause, resumable, and the detail fields in one call

Module System

FunctionDescription
loadModules(...)Load and combine modules into single compilation unit (thin wrapper around loadModulesEx with resolve_callback = NULL)
loadModulesEx(..., resolve_callback, ...)Like loadModules plus an optional ModuleResolveCallback that canonicalizes module keys before the cycle/cache check (see Resolve Callback)
freeModuleLoadResult(vm, result)Free module load result
zym_currentImportDepth(vm)Active import-frame depth (0 outside any read/resolve callback)
zym_currentImportPathAt(vm, i)Canonical key at frame i (0 == entry, depth-1 == current)
zym_currentImportCaller(vm)Immediate requester of the in-flight import (NULL on entry edge)

Type Checking

FunctionDescription
zym_isNull(val)Check if value is null
zym_isBool(val)Check if value is boolean
zym_isNumber(val)Check if value is number
zym_isString(val)Check if value is string
zym_isList(val)Check if value is list
zym_isMap(val)Check if value is map
zym_isStruct(val)Check if value is struct
zym_isEnum(val)Check if value is enum
zym_isFunction(val)Check if value is function
zym_isClosure(val)Check if value is native/script closure
zym_typeName(val)Get type name as string (e.g., "string", "number")

Value Creation

FunctionDescription
zym_newNull()Create null value
zym_newBool(b)Create boolean value
zym_newNumber(n)Create number value
zym_newString(vm, s)Create string value (null-terminated)
zym_newStringN(vm, s, len)Create string value with explicit length
zym_newList(vm)Create empty list
zym_newMap(vm)Create empty map
zym_newStruct(vm, name)Create struct (requires script-defined schema)
zym_newEnum(vm, name, variant)Create enum (requires script-defined schema)

Value Extraction

FunctionDescription
zym_asNumber(val)Extract number (unsafe, fast)
zym_asBool(val)Extract bool (unsafe, fast)
zym_asCString(val)Extract C string (unsafe, VM-owned)
zym_toNumber(val, out)Extract number (safe, returns false on mismatch)
zym_toBool(val, out)Extract bool (safe, returns false on mismatch)
zym_toString(val, out, len)Extract string (safe, returns char count)
zym_toStringBytes(val, out, len)Extract string (safe, returns byte count)

Value Inspection

FunctionDescription
zym_stringLength(val)Get UTF-8 character count
zym_stringByteLength(val)Get raw byte count
zym_valueToString(vm, val)Convert any value to string representation
zym_printValue(vm, val)Print any value to stdout

List Operations

FunctionDescription
zym_listLength(list)Get list length
zym_listGet(vm, list, idx)Get element at index (returns ZYM_ERROR if out of bounds)
zym_listSet(vm, list, idx, val)Set element at index
zym_listAppend(vm, list, val)Append element to end
zym_listInsert(vm, list, idx, val)Insert element at index
zym_listRemove(vm, list, idx)Remove element at index

Map Operations

FunctionDescription
zym_mapSize(map)Get map size (number of keys)
zym_mapGet(vm, map, key)Get value by key (returns ZYM_ERROR if not found)
zym_mapSet(vm, map, key, val)Set key-value pair
zym_mapHas(map, key)Check if key exists
zym_mapDelete(vm, map, key)Delete key
zym_mapForEach(vm, map, fn, data)Iterate over map entries

Struct & Enum Operations

FunctionDescription
zym_structGet(vm, s, field)Get struct field value
zym_structSet(vm, s, field, val)Set struct field value
zym_structHasField(s, field)Check if field exists
zym_structGetName(s)Get struct type name
zym_structFieldCount(s)Get number of fields
zym_structFieldNameAt(s, idx)Get field name by index
zym_enumGetName(vm, e)Get enum type name
zym_enumGetVariant(vm, e)Get enum variant name
zym_enumVariantIndex(vm, e)Get enum variant index
zym_enumEquals(a, b)Compare two enum values

Native Functions

FunctionDescription
zym_defineNative(vm, sig, fn)Register fixed-arity native function with signature
zym_defineNativeVariadic(vm, sig, fn)Register variadic native function (... in signature)
zym_defineGlobal(vm, name, val)Define a global variable accessible from scripts
zym_hasFunction(vm, name, arity)Check if function exists (uses mangled names)
zym_call(vm, name, argc, ...)Call script function (varargs)
zym_callv(vm, name, argc, argv)Call script function (array)
zym_getCallResult(vm)Get return value after successful call
zym_runtimeError(vm, fmt, ...)Report runtime error (printf-style)

Native Closures

FunctionDescription
zym_createNativeContext(vm, data, finalizer)Create context with private data and cleanup
zym_getNativeData(context)Extract private data from context
zym_createNativeClosure(vm, sig, fn, ctx)Create fixed-arity closure bound to context
zym_createNativeClosureVariadic(vm, sig, fn, ctx)Create variadic closure bound to context
zym_getClosureContext(closure)Extract context from native closure

Function Overloading

FunctionDescription
zym_createDispatcher(vm)Create dispatcher (max 8 overloads)
zym_addOverload(vm, disp, closure)Add arity-based overload to dispatcher
zym_setVariadicFallback(vm, disp, closure, min)Set variadic fallback on dispatcher

GC Protection

FunctionDescription
zym_pushRoot(vm, val)Protect value from GC (heap objects only)
zym_popRoot(vm)Release GC protection (must balance with pushRoot)
zym_peekRoot(vm, depth)Inspect root stack (0 = top)

Calling Script Functions

FunctionDescription
zym_hasFunction(vm, name, arity)Check the exact fixed-arity slot name@arity
zym_hasAnyFunction(vm, name)Check whether any callable with that base name is reachable, at any arity
zym_canCallWith(vm, name, argc)Check whether a call with exactly argc args can dispatch
zym_call(vm, name, argc, ...)Call script function (varargs, may return ZYM_STATUS_SUSPENDED)
zym_callv(vm, name, argc, argv)Call script function (array args)
zym_callClosurev(vm, closure, argc, argv)Call a closure value directly
zym_getCallResult(vm)Get return value of last call

Error Handling

FunctionDescription
zym_setErrorCallback(vm, cb, data)Set error callback (NULL restores stderr)
zym_runtimeError(vm, fmt, ...)Report a runtime error from native code

Debugging

FunctionDescription
disassembleChunk(chunk, name)Disassemble entire chunk to stdout
disassembleChunkToFile(chunk, name, f)Disassemble chunk to file
disassembleInstruction(chunk, offset)Disassemble single instruction

See also: Language GuideGC APIContinuations API