ZigXLL Documentation

A Zig framework for building Excel XLL add-ins. Define functions in Zig (or Lua), and the framework generates all Excel boilerplate at compile time: exports, type conversions, registration, COM vtables for RTD.

Overview

Why XLLs?

XLL add-ins are native DLLs that run inside the Excel process with no serialisation or IPC overhead. Excel calls your functions directly and can parallelise them across cores during recalculation.

The catch: the C SDK dates from the early 1990s. Memory management is manual, the type system is painful, and there is almost no tooling. Microsoft themselves call it "impractical for most users."

Why Zig?

Zig's C interop and comptime make the SDK usable. You write normal Zig functions with standard types. The framework generates all the Excel boilerplate at compile time.

  • No boilerplate — define functions with ExcelFunction() and macros with ExcelMacro(), the framework handles the rest
  • Type-safe conversions between Zig types and XLOPER12
  • UTF-8 strings — the framework handles UTF-16 conversion
  • Error mapping — Zig errors become #VALUE! in Excel, or return specific errors like #N/A, #DIV/0!
  • Thread-safe by default (Multi-Threaded Recalculation)
  • Zero function call overhead — 2000 Black-Scholes calculations recalc in under 7ms on a basic PC
  • Cross-compile from Mac/Linux via xwin
  • Async functions — add .is_async = true to run on a thread pool with automatic caching
  • RTD servers in pure Zig — no ATL/MFC needed
  • Embedded Lua scripting — write Excel functions in Lua with automatic type marshalling, async, and thread-safe support

Quick start

The fastest way in is to read the example project on GitHub. It is a complete, working XLL covering functions, RTD, async and Lua that you can clone, build and load into Excel. The steps below show how to wire things up from scratch.

Add ZigXLL as a dependency in your build.zig.zon:

.dependencies = .{
    .xll = .{
        .url = "https://github.com/alexjreid/zigxll/archive/refs/tags/v0.3.1.tar.gz",
        .hash = "...",
    },
},

Create your build.zig:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.resolveTargetQuery(.{
        .cpu_arch = .x86_64,
        .os_tag = .windows,
        .abi = .msvc,
    });
    const optimize = b.standardOptimizeOption(.{
        .preferred_optimize_mode = .ReleaseSmall,
    });

    const user_module = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    const xll_build = @import("xll");
    const xll = xll_build.buildXll(b, .{
        .name = "my_functions",
        .user_module = user_module,
        .target = target,
        .optimize = optimize,
    });

    const install_xll = b.addInstallFile(
        xll.getEmittedBin(), "lib/my_functions.xll",
    );
    b.getInstallStep().dependOn(&install_xll.step);
}

Define your functions:

// src/my_functions.zig
const xll = @import("xll");
const ExcelFunction = xll.ExcelFunction;
const ParamMeta = xll.ParamMeta;

pub const add = ExcelFunction(.{
    .name = "add",
    .description = "Add two numbers",
    .category = "My Functions",
    .params = &[_]ParamMeta{
        .{ .name = "a", .description = "First number" },
        .{ .name = "b", .description = "Second number" },
    },
    .func = addImpl,
});

fn addImpl(a: f64, b: f64) !f64 {
    return a + b;
}

Wire them up in src/main.zig:

pub const function_modules = .{
    @import("my_functions.zig"),
};

Build and load:

zig build          # produces zig-out/lib/my_functions.xll
zig build test     # runs tests natively, no Windows SDK needed

Output lands in zig-out/lib/my_functions.xll. Double-click to load in Excel.

Cross-compilation

Tests run natively without any Windows SDK. To cross-compile the XLL from Mac or Linux, install xwin for Windows SDK/CRT libraries:

# macOS
brew install xwin
xwin --accept-license splat --output ~/.xwin

# Linux
cargo install xwin
xwin --accept-license splat --output ~/.xwin

If you don't have Cargo, install Rust or grab a prebuilt binary from the xwin releases page.

Once set up, zig build auto-detects ~/.xwin and cross-compiles. See the README for more detail.

Performance

The example project includes Black-Scholes option pricing. 2000 calculations (1000 rows, call + put) recalculate in around 4-7ms on a basic AMD Ryzen 5500U. The Lua implementation of the same calculations is only 2-3ms slower, perfectly acceptable for interactive Excel use.

Creating Excel Functions

Quick start

Define functions using ExcelFunction() and wire them up in your main.zig.

// src/my_functions.zig
const xll = @import("xll");
const ExcelFunction = xll.ExcelFunction;
const ParamMeta = xll.ParamMeta;

pub const add = ExcelFunction(.{
    .name = "add",
    .description = "Add two numbers",
    .category = "My Functions",
    .params = &[_]ParamMeta{
        .{ .name = "a", .description = "First number" },
        .{ .name = "b", .description = "Second number" },
    },
    .func = addImpl,
});

fn addImpl(a: f64, b: f64) !f64 {
    return a + b;
}
// src/main.zig
pub const function_modules = .{
    @import("my_functions.zig"),
};

The framework discovers all ExcelFunction definitions at compile time, generates C-callable wrappers, and registers them with Excel when the XLL loads.

ExcelFunction options

pub const myFunc = ExcelFunction(.{
    .name = "MyCategory.MyFunc",  // Dots are fine - namespaces the function
    .description = "What it does", // Shows in Excel's function wizard
    .category = "My Category",     // Groups functions in the wizard
    .params = &[_]ParamMeta{ ... },
    .func = myFuncImpl,
    .thread_safe = true,           // Default: true (enables MTR)
});
FieldRequiredDefaultNotes
nameyesFunction name as it appears in Excel. Dots are allowed for namespacing.
funcyesThe Zig function to wrap.
paramsno&.{}Array of ParamMeta structs. Must match function arity if provided.
descriptionno""Shown in Excel's Insert Function dialog.
categoryno"General"Groups the function in Excel's function list.
thread_safenotrueEnables Multi-Threaded Recalculation. Set to false if your function has side effects or shared state.
is_asyncnofalseRuns the function on a background thread pool with result caching via RTD. Automatically sets thread_safe = false.

Supported types

Parameters

Zig typeExcel typeNotes
f64Number
boolBooleanTRUE/FALSE
[]const u8StringFramework handles UTF-16 to UTF-8 conversion
[][]const f64Range2D array of numbers. Empty cells become 0.0.
*XLOPER12AnyRaw Excel value, for advanced use

Optional parameters

Use ?T types for optional parameters. When Excel passes a missing value, it becomes null:

fn powerImpl(base: f64, exponent: ?f64) !f64 {
    const exp = exponent orelse 2.0;
    return std.math.pow(f64, base, exp);
}

Supported optional types: ?f64, ?bool, ?[]const u8.

Return types

Zig typeExcel typeNotes
f64Number
boolBoolean
[]const u8 / []u8StringFreed by Excel via xlAutoFree12
[][]const f64 / [][]f64ArraySpills into a range. Freed by Excel.
*XLOPER12AnyRaw, you manage memory

All return types are wrapped with !T. Errors automatically become #VALUE! in Excel.

Returning specific Excel errors

For functions returning !*xl.XLOPER12, use the static error helpers on XLValue:

const XLValue = xll.XLValue;

fn safeDivideImpl(a: f64, b: f64) !*xl.XLOPER12 {
    if (b == 0) return XLValue.errDiv0();
    // ... normal return via wrapResult handled by framework
}
HelperExcel error
XLValue.na()#N/A
XLValue.errValue()#VALUE!
XLValue.errDiv0()#DIV/0!
XLValue.errRef()#REF!
XLValue.errName()#NAME?
XLValue.errNum()#NUM!
XLValue.errNull()#NULL!

Returning strings

Allocate the result with std.heap.c_allocator. The framework marks it with xlbitDLLFree and Excel calls xlAutoFree12 to free it:

const allocator = std.heap.c_allocator;

fn reverseImpl(text: []const u8) ![]const u8 {
    var result = try allocator.alloc(u8, text.len);
    for (text, 0..) |c, i| {
        result[text.len - 1 - i] = c;
    }
    return result;
}

Returning arrays

Return a [][]f64 or [][]const f64. Excel spills the result into adjacent cells:

fn matrixImpl(rows: ?f64, cols: ?f64) ![][]f64 {
    const r: usize = @intFromFloat(rows orelse 3);
    const c: usize = @intFromFloat(cols orelse 3);

    var matrix = try allocator.alloc([]f64, r);
    for (0..r) |i| {
        matrix[i] = try allocator.alloc(f64, c);
        for (0..c) |j| {
            matrix[i][j] = @floatFromInt(i * c + j + 1);
        }
    }
    return matrix;
}

Returning raw XLOPER12

For advanced use (e.g. wrapping RTD calls), return *xl.XLOPER12:

const xl = xll.xl;
const rtd_call = xll.rtd_call;

fn livePriceImpl(symbol: []const u8) !*xl.XLOPER12 {
    return rtd_call.subscribe("myprog.rtd", &.{symbol});
}

Functions that call rtd_call.subscribe() must set .thread_safe = falsexlfRtd must run on Excel's main thread. See RTD servers for more.

Multiple modules

Split functions across files and list them all in main.zig:

pub const function_modules = .{
    @import("math_functions.zig"),
    @import("string_functions.zig"),
    @import("finance_functions.zig"),
};

Namespacing

Use dots in the function name to namespace it in Excel:

.name = "Finance.BSCall",

Excel shows this as Finance.BSCall. The exported DLL symbol uses underscores (Finance_BSCall_impl) since Windows GetProcAddress doesn't support dots.

Async functions

Add .is_async = true to run a function on a background thread pool. The cell shows #N/A while computing, then updates with the final result.

pub const slow_calc = ExcelFunction(.{
    .name = "SlowCalc",
    .description = "Expensive calculation",
    .is_async = true,
    .func = slowCalcImpl,
    .params = &[_]ParamMeta{
        .{ .name = "x", .description = "Input value" },
    },
});

fn slowCalcImpl(x: f64) !f64 {
    // This runs on a background thread - Excel stays responsive.
    doExpensiveWork();
    return x * 2.0;
}

How async works

  1. First call: cache miss, spawns work on thread pool, cell shows #N/A
  2. Worker finishes: result cached, Excel recalculates
  3. Next recalc: cache hit, returns value directly, RTD subscription dropped
  4. Subsequent calls with same args: instant cache hit (no re-computation)

Intermediate values

To send progress updates to the cell before the final result, add *AsyncContext as the last parameter:

const AsyncContext = xll.AsyncContext;

fn slowCalcImpl(x: f64, ctx: *AsyncContext) !f64 {
    ctx.yield(.{ .string = "Computing..." });     // cell updates immediately
    doFirstPhase();

    ctx.yield(.{ .double = x * 0.5 });            // partial result
    doSecondPhase();

    return x * 2.0;  // final value
}

The *AsyncContext parameter is invisible to Excel — it is not counted as a function parameter and doesn't need a ParamMeta entry.

VariantExample
.intctx.yield(.{ .int = 42 })
.doublectx.yield(.{ .double = 3.14 })
.stringctx.yield(.{ .string = "Loading..." })
.booleanctx.yield(.{ .boolean = true })

Caching

Results are cached by function name and arguments. Two calls to =SlowCalc(42) in different cells share the same cached result. To let users force a recalculation, expose a macro that clears the cache:

pub const clear_cache = ExcelMacro(.{
    .name = "ClearAsyncCache",
    .description = "Clear cached async results",
    .func = struct {
        fn f() void {
            xll.async_cache.getGlobalCache().clear();
        }
    }.f,
});

Macros (commands)

Excel macros are commands that perform actions rather than returning values. Define macros using ExcelMacro():

const xll = @import("xll");
const xl = xll.xl;
const XLValue = xll.XLValue;
const ExcelMacro = xll.ExcelMacro;

const allocator = @import("std").heap.c_allocator;

pub const hello = ExcelMacro(.{
    .name = "MyAddin.Hello",
    .description = "Show a greeting",
    .category = "My Macros",
    .func = helloImpl,
});

fn helloImpl() !void {
    var msg = try XLValue.fromUtf8String(allocator, "Hello from Zig!");
    defer msg.deinit();
    _ = xl.Excel12f(xl.xlcAlert, null, 1, &msg.m_val);
}

Limits

  • Maximum 8 parameters per function (Excel supports 255, framework currently caps at 8)
  • The *AsyncContext parameter (if used) does not count toward the 8-parameter limit
  • Functions must use the C allocator (std.heap.c_allocator) for returned strings and arrays

Lua Functions

ZigXLL can embed Lua scripts in your XLL, letting you write Excel functions in Lua instead of Zig. The framework handles all marshalling between Excel and Lua at compile time, with no runtime registry or stub pools needed. Lua functions support async execution and thread-safe parallel recalculation.

Quick start

1. Write annotated Lua scripts

--- Add two numbers
-- @param x number First number
-- @param y number Second number
function add(x, y)
    return x + y
end

--- Greet someone by name
-- @param name string Name to greet
function greet(name)
    return "Hello, " .. name .. "!"
end

--- Calculate hypotenuse
-- @param a number Side a
-- @param b number Side b
function hypotenuse(a, b)
    return math.sqrt(a * a + b * b)
end

2. List them in build.zig

const xll = xll_build.buildXll(b, .{
    .name = "my_functions",
    .user_module = user_module,
    .target = target,
    .optimize = optimize,
    .enable_lua = true,
    .lua_scripts = &.{
        "src/lua/functions.lua",
    },
});

The framework handles parsing annotations, generating Excel function declarations, embedding scripts, and registering everything at startup. No changes to main.zig required.

Annotations

Annotate Lua functions with --- doc comments directly above the function declaration:

--- Description of the function
-- @param x number First number
-- @param y string Name to greet
-- @async
-- @thread_safe false
-- @category My Category
-- @name CustomExcelName
-- @help_url https://example.com/help
function my_func(x, y) ... end
TagDescription
--- lineFunction description (first --- line)
@param name [type] [description]Parameter. Type is number (default), string, or boolean.
@rtdRTD subscription function. Automatically non-thread-safe.
@asyncRun on worker thread with result caching via RTD
@thread_safe falseDisable multi-threaded recalculation (default: thread-safe)
@category nameExcel function category (default: "Lua Functions")
@name ExcelNameOverride the auto-generated Excel name
@help_url urlURL with help information

Without @name, the Excel name is auto-generated from the Lua function name: add becomes Lua.Add (prefix + PascalCase).

Build options

OptionDefaultDescription
lua_scripts&.{}Lua script files to embed and generate declarations from
lua_prefix"Lua."Prefix for auto-generated Excel function names
lua_category"Lua Functions"Default category in Excel's function list

How it works

At compile time, LuaFunction() generates a C-callable impl function with the exact arity Excel expects, an @export of the impl function, and Excel registration metadata.

At runtime, the framework maintains a pool of independent Lua states (default 8, configurable). When Excel calls the function:

  1. Non-thread-safe: locks the main state (slot 0)
  2. Thread-safe: acquires any free state from the pool via atomic CAS (no contention)
  3. Async: spawns a worker thread that acquires a pool state, runs the Lua function, stores the result in the async cache, and notifies Excel via RTD

Async Lua functions

Add @async to the annotation to run a Lua function on a worker thread:

--- Fibonacci with simulated delay
-- @param n number Index
-- @async
function slow_fib(n)
    local a, b = 0, 1
    for i = 1, n do a, b = b, a + b end
    return a
end

This uses the same async infrastructure as Zig ExcelFunction(.{ .is_async = true }) — same cache, same RTD server, same fire-and-forget pattern.

Shared state

Since pool states are independent, global variables don't propagate between them. For state that needs to be visible across all states and threads, use the built-in xll library:

-- xll.set(key, value) - store a value (number, string, boolean, or nil to delete)
xll.set("counter", (xll.get("counter") or 0) + 1)
xll.set("status", "ready")

-- xll.get(key) - retrieve a value (returns nil if not set)
local count = xll.get("counter")

Access is serialised via a mutex on the Zig side — Lua execution stays parallel, only xll.get/xll.set calls block briefly.

Pool size

Override the number of Lua states (default 8) in build.zig:

const xll = xll_build.buildXll(b, .{
    // ...
    .enable_lua = true,
    .lua_states = 12,
});

RTD subscriptions from Lua

Lua functions can subscribe to an RTD server and return a live-updating cell value. Add @rtd to the annotation:

--- Live price for a symbol
-- @param symbol string Ticker symbol
-- @rtd
function price(symbol)
    return "myprog.rtd", symbol
end

The function's return values are interpreted as: first = prog_id, rest = topic strings. The cell updates automatically whenever the RTD server pushes a new value.

Mixing Lua and Zig functions

Both coexist in the same XLL. Zig functions go in function_modules in main.zig, Lua functions come from lua_scripts in build.zig. There is no difference from Excel's perspective.

Sandbox

The Lua state is sandboxed by default. The following are removed before any user scripts run:

RemovedReason
dofile, loadfile, load, requirePrevents loading code from the filesystem or arbitrary bytecode
io (entire library)No filesystem access
os.execute, os.remove, os.rename, os.tmpname, os.getenv, os.exitNo shell access or process control

Safe functions are kept: os.time, os.clock, os.date, os.difftime, the full math, string, and table libraries, and all standard Lua builtins.

Limitations

  • Maximum 8 parameters per function (same as ExcelFunction)
  • No matrix/table parameter or return type support yet
  • Pool states are independent — global variable mutations don't propagate (use xll.get/xll.set)
  • is_async = true automatically forces thread_safe = false
  • @rtd functions are always non-thread-safe

RTD Servers

ZigXLL provides an RTD (Real-Time Data) server framework for pushing live data into Excel from Zig. The framework handles all COM boilerplate automatically — no C++ or ATL needed.

An RTD server is a COM object that Excel polls for updated values. You implement a handler with callbacks; the framework manages IUnknown, IDispatch, DLL exports, and auto-registration.

Handler callbacks

Implement the following callbacks on your handler struct:

  • onStart — called when Excel initialises the RTD server
  • onConnect / onConnectBatch — called when a cell subscribes to a topic
  • onRefreshValue — called by Excel to retrieve the current value for a topic
  • onDisconnect — called when a cell unsubscribes
  • onTerminate — called during shutdown

RtdContext

The RtdContext gives your handler access to topic management, update notifications, and user-defined state. Key methods:

  • ctx.markAllDirty() — mark all topics as needing refresh
  • ctx.notifyExcel() — tell Excel to recalculate dirty topics
  • ctx.topics.get(topic_id) — look up a topic's metadata and strings

RtdValue

A tagged union supporting:

TypeExample
Integer (i32).{ .int = 42 }
Double (f64).{ .double = 3.14 }
Boolean.{ .boolean = true }
StringRtdValue.fromUtf8("hello")
Empty.empty

The framework converts these to COM VARIANT types automatically.

Wrapping RTD in UDFs

Wrap RTD subscriptions in regular Excel functions for cleaner formulas. Instead of =RTD("myprog.rtd",,"AAPL"), users type =MYPRICE("AAPL"):

const xl = xll.xl;
const rtd_call = xll.rtd_call;

pub const live_price = ExcelFunction(.{
    .name = "MyPrice",
    .description = "Live price for a symbol",
    .thread_safe = false,  // required for xlfRtd
    .params = &[_]ParamMeta{
        .{ .name = "symbol", .description = "Ticker symbol" },
    },
    .func = livePriceImpl,
});

fn livePriceImpl(symbol: []const u8) !*xl.XLOPER12 {
    return rtd_call.subscribe("myprog.rtd", &.{symbol});
}

Registration

Auto-registration writes registry entries under HKEY_CURRENT_USER during XLL load, requiring no admin privileges. Background threads typically fetch data in onStart, then call ctx.markAllDirty() and ctx.notifyExcel() to trigger Excel refresh cycles.

Using C/C++ Libraries

Zig is a C compiler and has first-class C interop. You can call any C or C++ library from your Excel functions without any special framework support — just import the library and wrap it with ExcelFunction().

Linking a C library

Add the library to your build.zig:

const xll = @import("xll");

pub fn build(b: *std.Build) void {
    const lib = xll.buildXll(b, .{
        .name = "my_addin",
        .xll_module = b.path("src/main.zig"),
    });

    // Link a system library
    lib.linkSystemLibrary("mylib");

    // Or link a static library built from C source
    lib.addCSourceFiles(.{
        .files = &.{ "vendor/mylib.c" },
        .flags = &.{ "-std=c99" },
    });
    lib.addIncludePath(b.path("vendor/"));
}

Wrapping C functions

Import the C headers and write a thin Zig wrapper:

const xll = @import("xll");
const ExcelFunction = xll.ExcelFunction;
const ParamMeta = xll.ParamMeta;

const c = @cImport({
    @cInclude("mylib.h");
});

fn calcImpl(x: f64, y: f64) !f64 {
    return c.expensive_calculation(x, y);
}

pub const calc = ExcelFunction(.{
    .name = "Calc",
    .func = calcImpl,
    .params = &[_]ParamMeta{
        .{ .name = "x" },
        .{ .name = "y" },
    },
});

The Zig wrapper handles XLOPER12 type conversion as usual. The C function just sees normal C types.

C++ libraries

For C++ libraries, create a C wrapper header that exposes the functions you need with extern "C" linkage, then import that header with @cImport. Zig cannot import C++ headers directly, but the C wrapper is typically straightforward.

When to use Zig vs Lua vs C

ApproachBest for
ZigNew code, performance-critical functions, full framework access
LuaRapid iteration, user-editable logic, simple calculations
C libraryReusing existing native code, vendor libraries, numerical libraries

All three can coexist in the same XLL.

How it Works

Some technical detail on how ZigXLL uses Zig's comptime to automatically generate Excel-compatible wrapper functions.

Overview

The framework is a Zig package that combines with user code at build time to produce a complete XLL. The user never writes any Excel boilerplate. The framework's buildXll() build helper creates a complete XLL by:

  1. Taking the user's module containing function_modules tuple
  2. Using the framework's xll_builder.zig as the root source file
  3. The builder exports all Excel entry points (xlAutoOpen, etc.)
  4. The builder discovers and registers user functions at compile time

ExcelFunction wrapper

The ExcelFunction() function is a compile-time code generator that takes function metadata and produces a struct containing Excel registration metadata, an impl function with the exact signature Excel expects, and type conversion logic between Zig types and XLOPER12.

The switch statement on params.len generates the exact number of parameters needed (0 to 8). Type conversions via extractArg() and wrapResult() handle:

  • f64xltypeNum (numbers)
  • boolxltypeBool (TRUE/FALSE)
  • []const u8xltypeStr (strings with UTF-8 conversion)
  • [][]const f64xltypeMulti (2D arrays/ranges)
  • *XLOPER12 ↔ raw passthrough

Function discovery

getAllFunctions() uses comptime reflection to find Excel functions in a module. It scans declarations looking for structs with the is_excel_function marker and builds a comptime array of them.

Execution flow

When a user enters =ADD(1, 2) in Excel:

  1. Excel looks up the registered exported function add_impl in the XLL DLL
  2. Excel calls add_impl(XLOPER12*, XLOPER12*) with C calling convention
  3. The generated impl function wraps each XLOPER12 in XLValue, extracts Zig types
  4. Calls the user's add(f64, f64) function
  5. Converts the result back to XLOPER12 via wrapResult()
  6. Excel displays the result
  7. Later, Excel calls xlAutoFree12() to free the returned memory

Async architecture

Async functions use Excel's RTD mechanism as a completion notifier. All async functions share a single built-in RTD server (zigxll.async) and a shared thread pool (4 workers). Results are stored in a thread-safe cache keyed by "FuncName|arg1|arg2|...".

  1. Cache miss (first call): builds a topic key, duplicates arguments, spawns a worker on the thread pool, calls xlfRtd to subscribe the cell, returns #N/A
  2. Worker completes: stores the result in the cache, calls UpdateNotify() to tell Excel to recalculate
  3. Recalc (cache hit): returns the value directly, Excel drops the RTD subscription
  4. Subsequent calls: instant cache hit, no thread pool or RTD involved

Memory management

XLValue wraps XLOPER12 with type safety and tracks whether it owns memory via m_owns_memory. When Excel calls xlAutoFree12(), the framework deallocates any owned memory. UTF-8 to UTF-16 conversion is handled by fromUtf8String() and as_utf8str().

Performance characteristics

  • Compile time: function discovery, wrapper generation, all metadata computed (no runtime overhead)
  • Runtime: direct C call, no reflection or indirection
  • Type conversion: minimal overhead (pointer deref and type check)
  • Memory allocation: only for strings and returned values
  • Registration: one-time cost at XLL load

Caveats

  • Maximum 8 parameters per function (framework limitation, not Excel's)
  • Supported types: f64, bool, []const u8, [][]const f64, *XLOPER12
  • Windows x86_64 only (XLLs can only run on Windows)
  • Requires Zig 0.15.1 or later