Embedding Guide
How to integrate the Zym scripting language into your C/C++ projects — VM lifecycle, native functions, value system, and two-way FFI.
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:
#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:
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
#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:
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);
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).
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
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; }
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);
type— alwaysZYM_STATUS_RUNTIME_ERRORfor frontend-post callbacks.message— fully formatted message (includes stack trace for runtime errors).user_data— opaque pointer fromzym_setErrorCallback.
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).
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;
alloc— Allocatesizebytes (likemalloc)calloc— Allocatecount * sizezero-initialized bytesrealloc— Resize allocation. Receivesold_sizeso pool/arena allocators can track without headersfree— Free allocation. Receivessizefor the same reasonctx— Opaque pointer passed as first argument to every call (your allocator state, pool handle, etc.)
Using the Default Allocator
Pass NULL to use the built-in allocator (standard malloc/free):
ZymVM* vm = zym_newVM(NULL); // Uses malloc/calloc/realloc/free
Custom Allocator Example
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:
const ZymAllocator* alloc = zym_getAllocator(vm);
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
- Preprocess (optional — only if using macros like
#define) - Compile source to bytecode chunk
- Serialize chunk to buffer
- Deserialize buffer in runtime VM
- Execute loaded chunk
Separate Compile & Run VMs
// ===== 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);
- Compile VM: Compiler resolves mangled names (
funcName@arity) - Run VM: Runtime needs actual function pointers for execution
Saving & Loading Bytecode Files
// 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.
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
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
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
double n = zym_asNumber(value); const char* s = zym_asCString(value); int b = zym_asBool(value);
List Operations (from 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)
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:
- Immediate:
null,bool,number,enum— stored directly in the 64-bit value - Heap objects:
string,list,map,struct,function,reference— pointer tagged in upper bits
Safe vs Unsafe Extraction
Always check types before extraction to avoid undefined behavior:
if (zym_isNumber(value)) { double num = zym_asNumber(value); // No overhead after check }
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:
struct Point { x; y; } enum Color { RED, GREEN, BLUE }
// 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
// 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
// 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:
"func(...)"→ZymValue myFunc(ZymVM* vm, ZymValue* vargs, int vargc)"func(a, ...)"→ZymValue myFunc(ZymVM* vm, ZymValue a, ZymValue* vargs, int vargc)"func(a, b, ...)"→ZymValue myFunc(ZymVM* vm, ZymValue a, ZymValue b, ZymValue* vargs, int vargc)
vargs— Pointer to the remaining arguments after the fixed parametersvargc— Number of variadic arguments (total args minus fixed params)
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);
// "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);
... defines the minimum argument count. Calling with fewer arguments triggers a runtime error automatically.Error Handling
Always return ZYM_ERROR after calling zym_runtimeError():
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);
vm— The VM instancename— Global variable name (accessible from Zym scripts)value— Any ZymValue (number, string, map, native closure object, etc.)
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:
// 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
// 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
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:
- Private Data: Opaque C structure that Zym never inspects
- Finalizer: Cleanup function called automatically by GC
- Bound Context: Passed implicitly as first parameter to closure functions
Complete Native Closure Example
// 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:
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:
"func(...)"→ZymValue myFunc(ZymVM* vm, ZymValue context, ZymValue* vargs, int vargc)"func(a, ...)"→ZymValue myFunc(ZymVM* vm, ZymValue context, ZymValue a, ZymValue* vargs, int vargc)
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.
// 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);
zym_hasFunction— strict slot presence: doesfuncName@arityliterally resolve to a callable? Variadic mangling (name@vF) is not consulted.zym_hasAnyFunction— is any callable with that base name reachable in the VM's globals, at any fixed arity ([email protected]_NATIVE_ARITY) or any variadic prefix ([email protected]_NATIVE_ARITY)? Mirrors how the compiler's own dispatcher discovers a base name.zym_canCallWith— can a call with exactlyargcarguments dispatch without raising a runtime error? True if and only iffuncName@argcis bound, or somefuncName@vFis bound withargc >= F.
// 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
// 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.
// 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);
dispatcher— Dispatcher created withzym_createDispatcher()closure— A variadic native closure to use as the fallbackmin_arity— Minimum number of arguments required (fixed params before...)
// 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.
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
// Push a temporary GC root zym_pushRoot(vm, value); // ... do allocations that might trigger GC ... // Pop when safe zym_popRoot(vm);
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.
// 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.
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.
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:
- Compile-time resolution: Zero runtime overhead — all modules resolved during compilation
- Relative path resolution: Module paths resolved relative to the loading file
- Circular dependency detection: Compile-time error on circular imports
- Function hoisting: Module functions automatically available in correct scope
- Debug output: Optional file showing combined source before compilation
Basic Module Usage
Module file (math.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):
// 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:
#include "zym/module_loader.h"
Module Callback
The module loader requires a callback that reads and preprocesses each module file:
// 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
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.
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.
- Borrowed return: the loader copies the returned string into its own allocator-owned storage on return. The callback's pointer does not need to outlive the call.
- Per edge: the resolver runs once per
import()edge, including for edges that ultimately hit the module cache. That's the whole point, since it must run before the cache lookup. - Active import frame: inside the resolver (and
inside
read_callback), the loader's current import frame is queryable via the introspection API below. Use it to decide what namespace to canonicalize into.
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:
A–Z,a–z,0–9, and_pass through unchanged.- The following punctuation, each mapped to a reversible
_<name>_escape so runtime error frames decode the key back to the original spec:Char Encoded as Notes /_slash_path separator \_slash_encode-only alias for /(collapses)._dot_extensions, dotted segments -_dash_kebab-case (space)_space_space :_colon_scheme/namespace separator, e.g. pkg:foo@_at_project-root sigil, e.g. @/foo$_dollar_sigil #_hash_sigil %_pct_sigil &_amp_sigil *_star_sigil ~_tilde_home-style sigil !_bang_sigil
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:
// 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.
- All three are meaningful only while the loader is
actively dispatching a
resolve_callbackorread_callbackonvm(zym_currentImportDepth(vm) > 0). - Outside that window depth is
0and the path accessors returnNULL. Higher-level bindings (e.g. the CLI'svm.moduleLoader.getCaller()/getStack()in theZymnative) are responsible for translating "depth == 0" into a language-level runtime error when called outside an active callback. - Returned pointers are owned by the loader and are valid only for the duration of the current callback invocation. Copy them if you need to outlive the call.
- Inside a
resolve_callback, the resolver already receives theimporterdirectly as its second argument, so consultingzym_currentImportCallerthere is redundant; the introspection API is primarily useful insideread_callbackor deeper script-side hooks that don't have the pair plumbed in explicitly. - Module caching means a given canonical key's
read_callbackis fired at most once.zym_currentImportPathAtreflects the chain that triggered the load, not every subsequent import site that resolves to that key.
How Module Loading Works
- Entry file processing: Entry file read and preprocessed by host
- Dependency discovery: Module loader scans for
import("path")calls - 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
- Module wrapping: Each module wrapped as function:
func __module_<hash>() { <module code> } - Import transformation:
import("math.zym")→__module_467954409() - Combination: All module functions emitted first, entry file transformed and appended
- 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:
main.zymcallsimport("lib/math.zym")→ resolves toproject/lib/math.zymmath.zymcallsimport("helpers.zym")→ resolves toproject/lib/helpers.zym
Circular Dependency Detection
The module loader detects circular dependencies at compile time:
var b = import("b.zym") return { data: "A" }
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 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:
// 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:
- 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.
- If the entry has no callback, the VM suspends and returns
ZYM_STATUS_SUSPENDEDto the host, withzym_vmCause()readingZYM_CAUSE_PREEMPT. You resume later withzym_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);
| Function | Returns | Notes |
|---|---|---|
zym_preemptRegister(vm, slice, callback, flags) | ZymPreemptId | Registers 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) | bool | Removes an entry. false if the id is unknown. A host may unregister a script-owned entry. |
zym_preemptSetSlice(vm, id, slice) | bool | Sets 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) | int | Instructions until this entry fires; -1 if unknown. |
zym_preemptTrigger(vm, id) | bool | Fires the entry at the next instruction boundary regardless of its countdown. The hook for wall-clock deadlines, signals, and UI cancel buttons. |
zym_preemptCapacity() | int | Total entries per VM. |
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.
// 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);
| Function | Returns | Notes |
|---|---|---|
zym_setHostPreemptReserve(vm, slots) | bool | Holds slots back from script. false once the VM has executed anything, and false for a value outside [0, capacity]. |
zym_getHostPreemptReserve(vm) | int | The reserve currently in force. |
zym_preemptCount(vm, script_owned_only) | int | Live entries; pass true to count just the ones script registered. |
zym_preemptScriptCapacity(vm) | int | capacity - reserve — the ceiling script sees. |
zym_preemptScriptAvailable(vm) | int | What script could still take right now. |
zym_preemptIds(vm, out, max) | int | Writes 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.
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
- Non-maskable by default. A
Preempt.shield(...)raised in script suppresses only entries carryingZYM_PREEMPT_MASKABLE, so a shield never defers a host watchdog. - Invisible, not merely untouchable.
Preempt.remainingreturns-1for an entry script does not own andPreempt.ids()lists only its own, so a script cannot map host supervision by probing the id space. Expose a deadline through your own native if you want it seen. - One callback per expiry. When several entries come due on the same instruction, the first by registration order runs; the rest keep refreshed deadlines for a later pass. A callback-less entry always wins over a callback, so a watchdog is honoured before any script runs.
- An entry is masked while its own callback runs, so it cannot re-enter itself. Other entries still fire.
- Callbacks must have arity 0.
zym_preemptRegisterreturns0for a callback that takes arguments, the same way it reports a full table. The VM has no way to invoke one, so it will not hand back an id for an entry that could never fire. - Registration is not cross-context. Register, unregister, and retune from the thread that owns the VM.
zym_requestStopis the only entry point safe to call from a signal handler, an ISR, or another thread.
Basic Host-Side Preemption
The simplest pattern: arm a watchdog, and handle suspensions in a loop.
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.
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
| Function | Description |
|---|---|
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 Code | Meaning |
|---|---|
ZYM_STATUS_OK | Execution completed successfully |
ZYM_STATUS_SUSPENDED | Paused at an instruction boundary with frames, stack, and ip intact. Not an error. Ask zym_vmCause() why |
ZYM_STATUS_RUNTIME_ERROR | Script error occurred |
ZYM_STATUS_COMPILE_ERROR | Compilation failed |
Sandboxing Example
Both halves of a resource budget: a watchdog bounds time, the ceiling bounds allocation.
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.
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);
| Function | Description |
|---|---|
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.
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.
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);
| Function | Description |
|---|---|
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.
// 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.
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
| ZymVmState | Meaning |
|---|---|
ZYM_STATE_IDLE | Never ran, or the last run finished |
ZYM_STATE_RUNNING | Inside dispatch (observable from natives and from other contexts) |
ZYM_STATE_SUSPENDED | Stopped mid-execution, frames intact |
ZYM_STATE_FAILED | The last run ended in an error |
Causes
| ZymVmCause | Meaning |
|---|---|
ZYM_CAUSE_NONE | No transition out of RUNNING has been recorded |
ZYM_CAUSE_SCRIPT_YIELD | Reserved. 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_PREEMPT | A preempt entry expired |
ZYM_CAUSE_PREEMPT_BLOCKED | An 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_STOP | zym_requestStop |
ZYM_CAUSE_MEMORY_LIMIT | The memory ceiling was crossed |
ZYM_CAUSE_OUT_OF_MEMORY | The 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_ERROR | The run ended in a runtime error |
ZYM_CAUSE_COMPILE_ERROR | The 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 by | To continue |
|---|---|
| a rearming watchdog | zym_resume — each call grants one fresh slice |
| a watchdog you are finished with | zym_preemptUnregister first |
| a watchdog needing a different budget | zym_preemptSetSlice, which restarts the countdown |
zym_requestStop | zym_clearStop first; sticky by design |
| the memory ceiling | zym_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.
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.
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 option | What it gates |
|---|---|
ZYM_ENABLE_LSP_SURFACE | Umbrella: flips the four flags below in one step. |
ZYM_ENABLE_PARSE_TREE_RETENTION | Retained AST + trivia buffer for LSP consumers. |
ZYM_ENABLE_SYMBOL_TABLE | Parallel resolver producing a (fileId, byte) → Symbol map. Requires PARSE_TREE_RETENTION. |
ZYM_ENABLE_NATIVE_METADATA | summary / docs / params strings on native registrations. |
ZYM_ENABLE_DIAGNOSTIC_CODES | code / 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:
#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
| Profile | Flags | Target |
|---|---|---|
full | all ON (default) | Desktop CLI, server, LSP host |
embed-mid | PARSE_TREE_RETENTION + NATIVE_METADATA + DIAGNOSTIC_CODES ON; SYMBOL_TABLE OFF | Mid-range MCU with on-device compile, no resolver |
mcu-diag | all OFF except DIAGNOSTIC_CODES | Small MCU, wants codes for error reporting |
mcu-min | all OFF (-DZYM_ENABLE_LSP_SURFACE=OFF) | Minimum-footprint MCU, source → bytecode → run |
full compiles and runs identically on mcu-min; only the surface area available to the
embedder changes.
Comprehensive API Reference
VM Lifecycle
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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)
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
zym_setErrorCallback(vm, cb, data) | Set error callback (NULL restores stderr) |
zym_runtimeError(vm, fmt, ...) | Report a runtime error from native code |
Debugging
| Function | Description |
|---|---|
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 Guide — GC API — Continuations API