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
Here's a basic single-argument print to get started:
#include <stdio.h> #include <math.h> #include "zym/zym.h" ZymValue native_print(ZymVM* vm, ZymValue value) { if (zym_isString(value)) { printf("%s", zym_asCString(value)); } else if (zym_isNumber(value)) { double num = zym_asNumber(value); if (isfinite(num) && num == (double)(long long)num && num >= -1e15 && num <= 1e15) { printf("%.0f", num); // Integer-like } else { printf("%g", num); // Float } } else if (zym_isBool(value)) { printf("%s", zym_asBool(value) ? "true" : "false"); } else if (zym_isNull(value)) { printf("null"); } else { // For lists, maps, structs — use VM's formatter zym_printValue(vm, value); } printf("\n"); return zym_newNull(); } void setupPrint(ZymVM* vm) { zym_defineNative(vm, "print(value)", native_print); }
Usage from Zym:
print("Hello, world!") print(42) print([1, 2, 3])
For production use, you might want to register multiple arities to support formatted output
(e.g., print("Name: %s, Age: %n", name, age)) with format specifiers like
%v (value), %s (string), %n (number), %b (boolean).
VM Lifecycle
Create & Destroy
#include "zym/zym.h" // Create a new VM instance ZymVM* vm = zym_newVM(); // Register your native functions here... // Clean up when done zym_freeVM(vm);
Running Scripts
// Run from a string ZymResult result = zym_interpret(vm, "var x = 42\nprint(x)"); // Run from a file ZymResult result = zym_interpretFile(vm, "scripts/main.zym"); // Check result if (result == ZYM_OK) { printf("Script ran successfully\n"); } else if (result == ZYM_COMPILE_ERROR) { printf("Compile error\n"); } else if (result == ZYM_RUNTIME_ERROR) { printf("Runtime error\n"); }
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();
ZymVM* vm2 = zym_newVM();
// Each VM has its own globals, GC, and module state
Error Callback
By default, all error messages (parse errors, compile errors, runtime errors) are written to stderr.
For embedded or non-console environments, you can install an error callback to capture these messages yourself.
Callback Signature
typedef void (*ZymErrorCallback)(ZymVM* vm, ZymStatus type, const char* file, int line, const char* message, void* user_data);
vm— The VM that produced the errortype—ZYM_STATUS_COMPILE_ERRORorZYM_STATUS_RUNTIME_ERRORfile— Source file name (may beNULL)line— Line number (-1if unknown)message— Fully formatted error message (includes stack trace for runtime errors)user_data— Opaque pointer passed through fromzym_setErrorCallback
Setting the Callback
void zym_setErrorCallback(ZymVM* vm, ZymErrorCallback callback, void* user_data);
Call this after creating the VM and before compiling or running code. Pass NULL as the callback to restore default stderr behavior.
Example: Logging Errors to a Buffer
static char error_log[4096]; static int error_log_pos = 0; void my_error_handler(ZymVM* vm, ZymStatus type, const char* file, int line, const char* message, void* user_data) { const char* prefix = (type == ZYM_STATUS_COMPILE_ERROR) ? "[compile] " : "[runtime] "; error_log_pos += snprintf(error_log + error_log_pos, sizeof(error_log) - error_log_pos, "%s%s\n", prefix, message); } // Usage: ZymVM* vm = zym_newVM(); zym_setErrorCallback(vm, my_error_handler, NULL); // All errors now go to error_log instead of stderr
NULL), all errors are printed to stderr exactly as before — existing code is unaffected.
What Gets Routed
- Parse errors — syntax errors with file, line, and token context
- Compile errors — too many constants, invalid references, scope violations
- Runtime errors — type mismatches, out-of-bounds access, stack overflows (includes full stack trace)
The preprocessor currently reports errors silently (sets a failure status without a message). Preprocessor error reporting through the callback is planned for a future release.
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
// Error callback — prints to console (same as default, shown for illustration) void on_error(ZymVM* vm, ZymStatus type, const char* file, int line, const char* message, void* user_data) { const char* prefix = (type == ZYM_STATUS_COMPILE_ERROR) ? "COMPILE" : "RUNTIME"; fprintf(stderr, "[%s] %s\n", prefix, message); } // ===== COMPILATION VM (ephemeral) ===== ZymVM* compile_vm = zym_newVM(); zym_setErrorCallback(compile_vm, on_error, NULL); // CRITICAL: Register natives in compile VM // Compiler needs them for name mangling and parameter qualifiers setupNatives(compile_vm); ZymChunk* chunk = zym_newChunk(compile_vm); ZymLineMap* line_map = zym_newLineMap(compile_vm); const char* source = "func add(a, b) { return a + b; }"; // Step 1: Optional preprocessing (skip if not using macros) const char* processed = NULL; if (zym_preprocess(compile_vm, source, line_map, &processed) != ZYM_STATUS_OK) { // Handle error } // Step 2: Compile (use processed if you preprocessed, else source) ZymCompilerConfig config = { .include_line_info = true }; const char* to_compile = processed ? processed : source; zym_compile(compile_vm, to_compile, chunk, line_map, "script.zym", config); // 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(); zym_setErrorCallback(run_vm, on_error, 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_freeLineMap(compile_vm, line_map); zym_freeVM(compile_vm); zym_freeVM(run_vm); free(bytecode);
- Compile VM: Compiler resolves mangled names (
funcName@arity) and reads parameter qualifiers (ref,val,clone) - 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);
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_listPush(vm, list, zym_newNumber(1)); zym_listPush(vm, list, zym_newNumber(2)); zym_listPush(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" }
References
References are mutable storage locations that can be dereferenced and assigned:
if (zym_isReference(ref)) { // Dereference to get underlying value ZymValue val = zym_deref(vm, ref); // Modify through reference zym_refSet(vm, ref, zym_newNumber(100)); }
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, with optional qualifiers.
"functionName(param1, param2)" // basic "add(a, b)" // two params "swap(ref a, ref b)" // ref params "process(val data)" // val (deep copy)
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.
Parameter Qualifiers
Zym supports rich parameter semantics via qualifiers in the signature string:
| Qualifier | Behavior | Use Case |
|---|---|---|
| (none) | Pass by shared reference | Default: lists/maps shared, primitives copied |
ref |
Pass mutable reference | Modify caller's variable |
val |
Structural value copy | Copy container structure, refs preserved |
clone |
Deep recursive copy | Fully independent copy, breaks all aliases |
slot |
Pass binding, not value | Rebind caller's variable |
REF Example: Modify Caller's Variable
ZymValue native_increment(ZymVM* vm, ZymValue ref) {
ZymValue val = zym_deref(vm, ref); // Dereference to get actual value
if (!zym_isNumber(val)) {
zym_runtimeError(vm, "increment() requires a number reference");
return ZYM_ERROR;
}
ZymValue result = zym_newNumber(zym_asNumber(val) + 1.0);
zym_refSet(vm, ref, result); // Write back through reference
return result;
}
// Register with 'ref' qualifier
zym_defineNative(vm, "native_increment(ref x)", native_increment);
// In Zym script:
// var count = 0;
// native_increment(count); // count is now 1
VAL Example: Structural Copy
Container structure copied, but references preserved:
ZymValue native_reverse(ZymVM* vm, ZymValue list) {
if (!zym_isList(list)) {
zym_runtimeError(vm, "reverse() requires a list");
return ZYM_ERROR;
}
// List structure is copied by VM before this function is called (due to 'val').
// Elements themselves are shared (primitives copied, objects/refs preserved).
int len = zym_listLength(list);
for (int i = 0; i < len / 2; i++) {
ZymValue temp = zym_listGet(vm, list, i);
ZymValue other = zym_listGet(vm, list, len - 1 - i);
zym_listSet(vm, list, i, other);
zym_listSet(vm, list, len - 1 - i, temp);
}
return list;
}
zym_defineNative(vm, "native_reverse(val list)", native_reverse);
// In Zym script:
// var a = [1, 2, 3];
// var b = native_reverse(a); // a unchanged, b is reversed structural copy
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
Two-Way FFI
Calling Script Functions from C
You can retrieve and call Zym functions from C code.
// Get a script function by name ZymValue fn = zym_getGlobal(vm, "myCallback"); // Call it with arguments ZymValue args[] = { zym_newNumber(10), zym_newNumber(20) }; ZymValue result = zym_call(vm, fn, args, 2);
Setting Globals from C
// Expose a constant to scripts zym_setGlobal(vm, "VERSION", zym_newString(vm, "1.0.0")); zym_setGlobal(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);
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_pushTempRoot(vm, value); // ... do allocations that might trigger GC ... // Pop when safe zym_popTempRoot(vm);
zym_newString, zym_newList, etc.), protect the first object with zym_pushTempRoot.GC Control from C
// Pause/resume zym_gcPause(vm); zym_gcResume(vm); // Force a collection cycle zym_gcCycle(vm); // Query memory usage size_t bytes = zym_gcBytesTracked(vm);
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.
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):
// Load module (path relative to this file) var math = load("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 (caller will free) ZymLineMap* line_map; // Line map from preprocessing (caller will free) } ModuleReadResult; // Callback signature typedef ModuleReadResult (*ModuleReadCallback)( const char* path, void* user_data ); // Example implementation static ModuleReadResult moduleCallback(const char* path, void* user_data) { ZymVM* vm = (ZymVM*)user_data; ModuleReadResult result = { .source = NULL, .line_map = NULL }; // 1. Read the file char* raw_source = readFile(path); if (!raw_source) return result; // 2. Preprocess it ZymLineMap* module_map = zym_newLineMap(vm); const char* preprocessed = NULL; ZymStatus status = zym_preprocess(vm, raw_source, module_map, &preprocessed); free(raw_source); if (status != ZYM_STATUS_OK) { zym_freeLineMap(vm, module_map); return result; } result.source = (char*)preprocessed; result.line_map = module_map; return result; }
Complete Integration Example
int main(void) { ZymVM* vm = zym_newVM(); ZymChunk* chunk = zym_newChunk(vm); ZymLineMap* line_map = zym_newLineMap(vm); // Read and preprocess entry file const char* entry_file = "main.zym"; char* entry_source = readFile(entry_file); const char* preprocessed_entry = NULL; zym_preprocess(vm, entry_source, line_map, &preprocessed_entry); // Load modules ModuleLoadResult* modules = loadModules( vm, preprocessed_entry, // Already preprocessed entry source line_map, // Line map from preprocessing entry entry_file, // Entry file path (for relative resolution) moduleCallback, // Callback to read/preprocess modules vm, // User data (VM for preprocessing) 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 ZymCompilerConfig config = { .include_line_info = true }; if (zym_compile(vm, modules->combined_source, chunk, modules->line_map, entry_file, config) != ZYM_STATUS_OK) { fprintf(stderr, "Compilation failed\n"); freeModuleLoadResult(vm, modules); return 1; } // Execute zym_runChunk(vm, chunk); // Cleanup freeModuleLoadResult(vm, modules); free(entry_source); zym_freeChunk(vm, chunk); zym_freeLineMap(vm, line_map); zym_freeVM(vm); return 0; }
How Module Loading Works
- Entry file processing: Entry file read and preprocessed by host
- Dependency discovery: Module loader scans for
load("path")calls - Recursive loading: For each module:
- Resolves path relative to containing file
- Calls your callback to read and preprocess
- Scans for nested
load()calls - Repeats recursively
- Module wrapping: Each module wrapped as function:
func __module_<hash>() { <module code> } - Load transformation:
load("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 load() call:
project/ ├── main.zym (loads "lib/math.zym") ├── lib/ │ ├── math.zym (loads "helpers.zym") │ └── helpers.zym
Resolution:
main.zymcallsload("lib/math.zym")→ resolves toproject/lib/math.zymmath.zymcallsload("helpers.zym")→ resolves toproject/lib/helpers.zym
Circular Dependency Detection
The module loader detects circular dependencies at compile time:
var b = load("b.zym") return { data: "A" }
var a = load("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->line_map, "entry.zym", config); // 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
The VM maintains a yield budget — a countdown of instructions. Each time the VM dispatches an instruction, it decrements the budget. When the budget reaches zero (or a yield is explicitly requested), one of two things happens:
- If a script callback is registered — the VM calls it in-place (no C-stack unwinding). The callback can capture a continuation, do bookkeeping, or simply return.
- If no callback is set — the VM suspends and returns
ZYM_STATUS_YIELDto the host. You resume later withzym_resume().
Basic Host-Side Preemption
The simplest pattern: let scripts run with a budget, and handle yields in a loop.
ZymVM* vm = zym_newVM(); setupNatives(vm); // Enable preemption from script (or do it from C before running) // Scripts call: Preempt.enable() and Preempt.setTimeslice(1000) ZymStatus status = zym_runChunk(vm, chunk); // The "preempt pump" — handle yields in a loop while (status == ZYM_STATUS_YIELD) { // 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 error\n"); } zym_freeVM(vm);
Calling Functions with Preemption
When calling script functions from C with zym_call(), the same yield handling applies:
ZymStatus result = zym_call(vm, "update", 1, zym_newNumber(delta_time)); while (result == ZYM_STATUS_YIELD) { result = zym_resume(vm); } if (result != ZYM_STATUS_OK) { // Handle error }
Script-Side Callback (In-VM Preemption)
For fully script-driven scheduling (e.g., fiber schedulers), register a callback from C using zym_setPreemptCallback().
When the budget expires, the VM calls this function inside the current execution context — no C-stack unwinding.
// Option 1: Set callback from C (before running script) // You need a ZymValue closure — typically obtained from script globals zym_runChunk(vm, chunk); // Script defines the callback // Option 2: Script sets its own callback // From script: Preempt.setCallback(func() { ... })
The callback runs with preemption automatically disabled to prevent recursive preemption. The disable depth is restored when the callback returns.
Preemption API
| Function | Description |
|---|---|
zym_resume(vm) | Continue execution after ZYM_STATUS_YIELD |
zym_setPreemptCallback(vm, callback) | Register a ZymValue closure as the preemption handler |
| Status Code | Meaning |
|---|---|
ZYM_STATUS_OK | Execution completed successfully |
ZYM_STATUS_YIELD | VM suspended (preemption with no script callback) |
ZYM_STATUS_RUNTIME_ERROR | Script error occurred |
ZYM_STATUS_COMPILE_ERROR | Compilation failed |
Sandboxing Example
Use preemption to limit untrusted script execution time:
// In script: enable preemption with a tight budget // Preempt.enable() // Preempt.setTimeslice(10000) ZymStatus status = zym_runChunk(vm, chunk); int yields = 0; int max_yields = 1000; // Safety limit while (status == ZYM_STATUS_YIELD) { yields++; if (yields > max_yields) { fprintf(stderr, "Script exceeded execution limit\n"); break; } status = zym_resume(vm); }
Preempt module is available automatically in every VM. Scripts can call Preempt.enable(), Preempt.setTimeslice(), Preempt.setCallback(), etc. See the CLI Preemption and Continuations & Preemption docs for the full script-side API.
Comprehensive API Reference
VM Lifecycle
| Function | Description |
|---|---|
zym_newVM() | Create a new VM instance |
zym_freeVM(vm) | Destroy a VM and free all memory |
zym_setErrorCallback(vm, cb, data) | Set error callback (NULL restores stderr default) |
Compilation & Execution
| Function | Description |
|---|---|
zym_newChunk(vm) | Create a new bytecode chunk |
zym_freeChunk(vm, chunk) | Free a bytecode chunk |
zym_newLineMap(vm) | Create a new line map for error reporting |
zym_freeLineMap(vm, map) | Free a line map |
zym_preprocess(vm, src, map, out) | Preprocess source (optional, for macros only) |
zym_compile(vm, src, chunk, map, file, config) | Compile source to bytecode |
zym_runChunk(vm, chunk) | Execute a compiled chunk (may return ZYM_STATUS_YIELD) |
zym_resume(vm) | Continue execution after a yield |
zym_setPreemptCallback(vm, callback) | Register script closure as 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 |
Module System
| Function | Description |
|---|---|
loadModules(...) | Load and combine modules into single compilation unit |
freeModuleLoadResult(vm, result) | Free module load result |
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_isReference(val) | Check if value is reference |
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 |
Reference Operations
| Function | Description |
|---|---|
zym_deref(vm, ref) | Dereference to get underlying value |
zym_refSet(vm, ref, val) | Write through reference |
Native Functions
| Function | Description |
|---|---|
zym_defineNative(vm, sig, fn) | Register native function with 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 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 |
Native References
| Function | Description |
|---|---|
zym_createNativeReference(vm, ctx, offset, get, set) | Create reference to C struct field with optional hooks |
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 if a script function exists |
zym_call(vm, name, argc, ...) | Call script function (varargs, may return ZYM_STATUS_YIELD) |
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