Embedding the engine How To

This engine ships in three shapes. The CLI (ScriptLangCore.exe, 19.6 MB) does everything by itself. The shared library (ScriptLangCore.dll, 19.1 MB) exposes a C ABI of twenty-two calls, for a native program that wants to run scripts inside its own process. The launcher (ScriptLangCore.Launcher.exe, 15 KB) holds no engine code at all: it loads the library and drives it, and it doubles as the smallest complete example of the ABI. A host does not have to be a C or .NET program either: Python, PowerShell and bash take the same library, C# takes it with one plain delegate per export, and 64-bit Excel and VBA call it directly, with a module that ships beside it.

The library is the engine, not a wrapper. It is the same NativeAOT image the exe is built from, compiled as a shared library with the exports marked [UnmanagedCallersOnly]. Nothing is interpreted a second time and no second runtime is started, so a script answered through the ABI behaves as it does from the command line, crashes and depth guard included.

Table of contents

What a machine needs

The published DLL and executable are NativeAOT images, so the runtime travels inside them. A deployment machine needs Windows and nothing else: no .NET installation and no Visual C++ redistributable. On Windows 10 and later the Universal CRT is part of Windows; on Windows 7 SP1 its API sets come with Microsoft's Universal C Runtime update.

The Windows floor

RequirementWhy
Windows 7 SP1 (with the latest service packs), x64The oldest Windows this project supports, and the floor the installer enforces. A 32-bit Windows cannot load the x64 image.
Windows 10 version 1703 for the DPI mode the manifest namesdpiAwareness PerMonitorV2 and SetProcessDpiAwarenessContext. On an older build the engine falls back to SetProcessDpiAwareness from shcore, so a monitor is handled per-monitor v1 instead.
Windows 8.1, Windows 10, Windows 11, Windows Server 2016 and laterSupported. The artifacts in this repository were built and run on Windows 11 25H2, build 26200.
Windows 11 on ARM64Runs the x64 image under the operating system's x64 emulation. There is no native arm64 build here.

What it links against, and what it does not

The static imports are 18 DLLs and 396 functions, every one a Windows component: KERNEL32, ADVAPI32, CRYPT32, bcrypt, ncrypt, Secur32, ole32, WS2_32, IPHLPAPI, VERSION, two API sets of the core (one of them for WaitOnAddress, which is Windows 8 and later) and six API sets of the Universal CRT. It does not import the .NET runtime, vcruntime140.dll, msvcp140.dll or ucrtbase.dll: the C runtime is linked into the image, and on Windows 7 SP1 the Universal CRT API sets come with Microsoft's Universal C Runtime update.

A further fifteen libraries are bound by name at the first call rather than at load, so an API a machine lacks answers as a refusal from that call instead of a failure to start: user32, gdi32, comctl32, comdlg32, shell32, uxtheme, winmm, dwmapi, shcore, winhttp, ws2_32, advapi32, ole32, ntdll and kernel32.

The one library that is not part of Windows is sqlite3.dll, and only a script that uses Sqlite loads it. It ships beside the executable; a host that is not the engine must load it by full path first, as the host section describes.

Linux, macOS and anything else

There is no non-Windows build, and one cannot be produced from this host: dotnet publish -r linux-x64 is refused with Cross-OS native compilation is not supported, because NativeAOT compiles to the target's machine code and needs that platform's toolchain. What a port would face is the platform layer rather than the language: 205 [LibraryImport] declarations across eight files (Ui.cs holds 145 of them, and 10,816 of the 93,187 lines in src), the COM bridge, MCI, the clipboard listener, global hotkeys, and six native calls the core itself needs (the dynamic export lookup, the thread stack probe behind the depth guard, the memory query behind the VM's frame cap, two handle queries). The language core does compile for linux-x64 as IL, so the work is the platform layer and a UI toolkit, not the parser, the interpreter or the VM.

Running the Windows artifact under Wine is not supported and is not tested here. A console script is the part most likely to work and a form is the part most likely not to, so treat Wine as an experiment rather than a deployment route.

Building the artifacts

One batch builds everything: compile_aot.bat. Each AOT artifact is a separate configuration of its project, which is what gives each its own obj\ and bin\ by construction, so no leg can be handed another leg's intermediate output.

ArtifactCommandLands in
ScriptLangCore.exedotnet publish ScriptLangCore.csproj -c Release -r win-x64 --self-containedpublish-aot\
ScriptLangCore.dlldotnet publish ScriptLangCore.csproj -c Embed -r win-x64 --self-contained -o publish-dllpublish-dll\
ScriptLangCore.Launcher.exesrc\embed\launcher-c\build.bat publish-launcher\ScriptLangCore.Launcher.exepublish-launcher\

The launcher is written in C and built by GCC, because its own work is a DLL load, ten export lookups, one resource read and two string builders: the NativeAOT version of the same program carried a 1.2 MB runtime for that, and this one is 15 KB. Two measurements took it there from the 60 KB the first GCC build produced: three sites asked the C runtime for a format string, which pulled 26 KB of formatted-output machinery in for four hex digits and a decimal, and the CRT itself was then dropped (`-nostartfiles -nostdlib`, with the allocator and the string helpers that mattered written out in the source), so the image now links only kernel32 and shell32. It is behaviour-identical, message for message, since src/build/embed-check.sh pins both the words and the exit codes of every refusal, and the C# original it was ported from stays in src\embed\launcher\ as the reference. An x86_64 GCC is required, and the build refuses a 32-bit one by name: a 32-bit image cannot load the win-x64 engine at all, and Windows reports that as "the specified module could not be found", which reads like a missing engine. Point SLC_MINGW_BIN at a toolchain folder to choose one explicitly (a folder that does not hold a usable x86_64 compiler is then refused rather than skipped). The build script also puts the toolchain's own folder on PATH for the compile (its cc1 loads its DLLs from there, and without it gcc exits 1 and says nothing), then verifies the image it produced, naming the machine and subsystem bytes.

C:\msys64\usr\bin\bash.exe -lc "pacman -S --needed mingw-w64-x86_64-gcc"
src\embed\launcher-c\build.bat                 REM into src\embed\launcher-c\build\
src\embed\launcher-c\build.bat out\App.exe     REM or exactly where you want it

The library is published with NativeLib=Shared and OutputType=Library, which the Embed configuration states. An image without exports answers nothing: the export list is written to obj\Embed\net11.0\win-x64\native\ScriptLangCore.def and lists all twenty-two names, which is the cheapest way to confirm a build is the one you think it is.

A configuration you invent must say Optimize. The SDK leaves Optimize false for a configuration name it does not know, and the AOT compiler derives both its codegen mode and method-body folding from it. Without the line, this engine's library came out at 37 MB instead of 19 MB for the same sources. Both configurations here state it.

Quick start

A complete C program: load the library, start a session, register an output callback, run a file, release. It takes the DLL's path on the command line so it works from any directory.

#include <stdio.h>
#include <stdint.h>
#include <windows.h>

typedef int  (*slc_version_fn)(char*, int);
typedef int64_t (*slc_create_fn)(const char*);
typedef int  (*slc_set_output_fn)(int64_t, void*, void*);
typedef int  (*slc_run_file_fn)(int64_t, const char*);
typedef int  (*slc_free_fn)(int64_t);
typedef int  (*slc_last_error_fn)(int64_t, char*, int);

static void on_line(int64_t handle, int32_t channel, const char* utf8, void* user)
{
    fputs(channel == 0 ? utf8 : "", channel == 0 ? stdout : stderr);
    fputc('\n', channel == 0 ? stdout : stderr);
}

int main(int argc, char** argv)
{
    if (argc < 3) { fprintf(stderr, "usage: %s <engine.dll> <script.slc>\n", argv[0]); return 2; }

    HMODULE lib = LoadLibraryA(argv[1]);
    if (!lib) { fprintf(stderr, "no library at %s (error %lu)\n", argv[1], GetLastError()); return 3; }

    slc_version_fn    version = (slc_version_fn)    GetProcAddress(lib, "slc_version");
    slc_create_fn     create  = (slc_create_fn)     GetProcAddress(lib, "slc_create");
    slc_set_output_fn output  = (slc_set_output_fn) GetProcAddress(lib, "slc_set_output");
    slc_run_file_fn   runfile = (slc_run_file_fn)   GetProcAddress(lib, "slc_run_file");
    slc_free_fn       release = (slc_free_fn)       GetProcAddress(lib, "slc_free");
    slc_last_error_fn lasterr = (slc_last_error_fn) GetProcAddress(lib, "slc_last_error");

    char text[256];
    version(text, sizeof text);
    printf("%s\n", text);

    int64_t session = create("{\"home\":\"C:\\\\engine\"}");   /* the folder holding assets\ and tools\ */
    if (session == 0) { lasterr(0, text, sizeof text); fprintf(stderr, "no session: %s\n", text); return 3; }

    output(session, (void*)on_line, NULL);
    int status = runfile(session, argv[2]);
    if (status != 0) { lasterr(session, text, sizeof text); fprintf(stderr, "run answered %d: %s\n", status, text); }

    release(session);
    FreeLibrary(lib);
    return status;
}

Build it with cl demo.c (the exports are looked up at run time, so no import library is needed) or link the import library that the publish leaves beside the native object files, bin\Embed\net11.0\win-x64\native\ScriptLangCore.lib.

Strings are UTF-8 and the buffers are yours. Every output takes a caller-owned buffer and follows the snprintf convention: the call returns the length it NEEDS, writes at most capacity - 1 bytes plus a terminator, and answers the same length when capacity is 0, so slc_last_error(session, NULL, 0) sizes the buffer. Nothing you receive points into engine memory.

The ABI

CallSignatureAnswers
slc_versionint slc_version(char* buffer, int capacity)the engine version, the runtime and the ABI revision as one sentence, so a host log answers which build answered
slc_abi_revisionint slc_abi_revision(void)the ABI revision alone, for a binding that compares a contract instead of parsing a sentence
slc_capabilitiesint slc_capabilities(void)the bitmask of what THIS build answers, so a binding drops what an older library lacks instead of failing a lookup
slc_createint64_t slc_create(const char* optionsJson)a session handle, or 0 with the reason in slc_last_error(0, ...)
slc_freeint slc_free(int64_t handle)0 released, 2 an unknown or already freed handle, 3 a run is in flight (the handle stays valid, so freeing can be retried)
slc_thread_modeint slc_thread_mode(int64_t handle)0 the engine's own thread, 1 the host's, -1 an unknown handle: the two modes have different stack budgets
slc_last_errorint slc_last_error(int64_t handle, char* buffer, int capacity)one sentence about the last call on that handle, or empty when it succeeded; handle 0 answers the calling thread's last slc_create failure
slc_last_resultint slc_last_result(int64_t handle, char* buffer, int capacity)the last run's or call's verdict as JSON (Status, Ok, Error, Fatal, Cancelled, Code, Message, Line, Checks, Result), one door for everything a verb answered
slc_statsint slc_stats(int64_t handle, char* buffer, int capacity)a JSON snapshot of the session for a host's own log: revision, capability mask, thread mode, apartment, stack budget, globals
slc_set_outputint slc_set_output(int64_t handle, void* callback, void* user)0 installed a line callback (channel 0 print, 1 engine log), NULL removes it; with none the output is dropped rather than written to the host's console
slc_run_fileint slc_run_file(int64_t handle, const char* path)the run's status: runs source text in the session's interpreter, so what it declares stays callable; a compiled bundle is refused by name
slc_run_textint slc_run_text(int64_t handle, const char* source, const char* name)the run's status for source text; the optional name is what Script.Directory() and ./ mean for a snippet
slc_adoptint slc_adopt(int64_t handle, void* dispatch)registers the host's own COM object (called on the HOST's thread), so a script reaches the application that loaded the engine with Com.Host()
slc_pollint slc_poll(int64_t handle)1 while an asynchronous run is in flight, 0 idle, negative for an unknown handle; the finished run's verdict lands in slc_last_result
slc_run_text_asyncint slc_run_text_async(int64_t handle, const char* source, const char* name)the same run as slc_run_text, started and not waited for (0 accepted, 2 refused), which is what lets an STA host pump while the script drives it
slc_run_file_asyncint slc_run_file_async(int64_t handle, const char* path)slc_run_file, started and not waited for; the same rules and the same compiled-bundle refusal
slc_run_bundle_bytesint slc_run_bundle_bytes(int64_t handle, const void* data, int count)the run's status for a compiled bundle held in memory, which is what a packaged executable needs; the run's directory is the session's home
slc_main_ownsint slc_main_owns(const char* argument)1 when the engine's own argument dispatch owns this first argument (--compile, --repl, --mcp, ...), so a launcher tells its arguments from the CLI's
slc_mainint slc_main(int64_t handle, const char* argvJson)the code the process would exit with, from the engine's own dispatch with a JSON array standing in for its command line (no arguments is the REPL)
slc_run_bundleint slc_run_bundle(int64_t handle, const char* path)the run's status for a .slcbc: one carrying its program as source runs that source, a source-less one runs its compiled entry
slc_callint slc_call(int64_t handle, const char* name, const char* argsJson)the call's status for a function the session already has, with the return value in slc_last_result's Result and a JSON array of arguments
slc_cancelint slc_cancel(int64_t handle)0 the request was made, 2 nothing was running; the one verb callable from another thread while a run is in flight

Four rules decide the rest. Nothing throws across the boundary: a failure is a status plus a message, never a C++ exception, a stack trace or a resource key. One call at a time per handle: a verb names its refusal when another is in flight rather than interleaving. Handles are never reused: a stale handle answers "no session with that handle" instead of hitting a new session. The host's console is never touched: with no callback registered, a script's output is dropped rather than written into your stdout.

Two verbs, two kinds of file, and the engine says which. slc_run_file reads SOURCE, so a compiled bundle handed to it is refused by name (... is a compiled bundle, not source text: run it with slc_run_bundle) instead of being parsed as text. slc_run_bundle runs a .slcbc in either shape: one carrying its program as source runs that source, and one built with --source=strip runs its compiled entry. Which one a file is, is decided from its first four bytes, so the extension cannot lie about it.

A run that awaits at the file's top level does not hold the session thread, and that is what makes the asynchronous verbs worth using. The statement suspends until the awaited task completes, so the host keeps control while the script waits: its own message loop runs, slc_poll reads 1 for as long as the run is in flight and 0 once its verdict has landed, and slc_cancel lands at that suspension rather than after the awaited call returns, so a cancelled run reports itself finished at once instead of when the call it was inside lets go. The synchronous verbs are unchanged: slc_run_text, slc_run_file and slc_run_bundle wait on the caller's thread for the verdict, exactly as they always have.

The contract is written down once

Every call above, the direction of its parameters and the revision it arrived in live in docs/embed/abi.json, and the declarations a host compiles against are RENDERED from it: bindings/ScriptLangCore.h is the C header, with SLC_ABI_REVISION and one SLC_CAP_* macro per capability; bindings/ScriptLangCore.cs is one delegate per export plus the export-name constants, and the embed harness compiles that file, so a wrong signature breaks the harness build rather than a host at run time; bindings/scriptlangcore.py is the ctypes binding, where load(path) binds every signature and text(export, ...) applies the snprintf probe for you. Regenerate them with powershell -NoProfile -File src/build/gen-abi-bindings.ps1; bash src/build/check-abi-manifest.sh fails when the manifest, the exports in src/Host/EmbedApi.cs, the bits in src/Host/EmbedCapabilities.cs or the generated files disagree, and it runs as part of src/build/check-invariants.sh. The Windows installer copies them, with the runnable samples and the two VBA check drivers, to bindings\ under the install folder (the optional bindings component), so a host built on that machine compiles against the declarations the installed ScriptLangCore.dll answers.

Two numbers, no string parsing. slc_abi_revision() answers the revision as an integer and slc_capabilities() answers a bitmask of what THIS build provides, both without a handle, so a binding asks its two questions once and then drops what an older library lacks, instead of calling GetProcAddress and treating a missing export as a crash. slc_version's sentence and slc_stats carry the same two numbers for a log line.

MaskCapabilitySinceWhat the bit promises
0x01output2slc_set_output and its line callback, with channel 0 for print and 1 for the engine's log lines
0x02call2slc_call, and Result in the verdict, so a host calls what a run declared
0x04cancel2slc_cancel, the one verb a host may call while a run is in flight
0x08bundles2slc_run_bundle and slc_run_bundle_bytes, the compiled-payload verbs
0x10main2slc_main and slc_main_owns, the executable's own argument dispatch
0x20async4slc_run_text_async, slc_run_file_async and slc_poll, the verbs that return before the run does
0x40adopt4slc_adopt, which registers the host's own object so Com.Host() reaches it
0x80com5the in-proc COM server (ScriptLangCore.Session, DllGetClassObject), which only the Embed configuration compiles: the one build-dependent bit
0x100sandbox5the session honors the "dirs" and "deny" options, so a host can confine a script's paths and forbid the process, network, UI, COM and clipboard families

The REPL is the engine's own

slc_main with no arguments (or a null command line) runs exactly the loop ScriptLangCore.exe opens: the same banner and prompt, the same dot commands (.help, .vars, .run, .gc, .export, .exit, and the rest), the same history and the same Ctrl+C behaviour. A host that wants a console front end therefore needs no REPL of its own, which is what the launcher does with no arguments.

ScriptLangCore.Launcher.exe
ScriptLangCore 1.0.0.0 (NativeAOT), embed ABI 5
>>> 1 + 1
= 2
>>> print("hello from the engine")
hello from the engine
>>> .exit
👋 Goodbye!

Running a tool from that prompt, with its arguments

.run executes a file in the session, and everything after -- is the SCRIPT's command line rather than another file, so a tool that takes its input from Application.Args runs from the prompt exactly as it would from a shell. This is the whole packaging workflow performed inside the launcher's own REPL, starting in the folder the launcher lives in (app.slc is the script to package):

publish-launcher> ScriptLangCore.Launcher.exe
>>> .run ..\assets\language\probes\slc-pack.slc -- ScriptLangCore.Launcher.exe app.slc myapp.exe --version 1.2.3.4
packed: myapp.exe <- app.slc (script text, 35 byte(s))
resource: SLCPAYLOAD = 35 byte(s) (matches the payload)
version: 1.2.3.4 (VS_FIXEDFILEINFO under RT_VERSION 1)
exe: 1306624 byte(s) at D:\...\publish-launcher\myapp.exe; run it, with the engine DLL beside it
  Done: 1 file(s) in 0:00 (68 ms)
>>> .exit
👋 Goodbye!

publish-launcher> myapp.exe
hello from a packaged app

Four things make that work, and each one is a rule a reader will otherwise trip over. The arguments after -- arrive as [the tool's path, ScriptLangCore.Launcher.exe, app.slc, myapp.exe, --version, 1.2.3.4], which is the same list the CLI gives a script, so a file written for the command line needs no second form. A QUOTED argument stays one argument, which is how a path with a space gets through: -- "C:\Program Files\app.exe". A relative path is a SEARCH (as given, then beside the tool, then each ancestor of the tool's folder), which is why ..\assets\language\probes\slc-pack.slc and a bare ScriptLangCore.Launcher.exe both resolve even when the prompt was started somewhere else; the tool reports the path it used, so nothing is guessed silently. And the arguments belong to THAT run: the next statement reads the session's own Application.Args again.

There is no shell behind the prompt. A wildcard is literal text (*.slc is not expanded), there is no redirection or piping, and .run resolves the file itself (a direct path, a glob it expands, |-separated lists). That is the price of a prompt that runs the engine in-process, and it is why the tool's own arguments are the thing -- exists for.

Two divisions of labour are worth knowing before wiring it into a UI. The PROMPT, the echoes and a dot command's own output go to the PROCESS's standard output, because a REPL's terminal is its user interface; a script's print and the engine's log lines still travel through the output callback, so a host renders the program's output wherever it likes. And the interpreter is the SESSION's, adopted rather than replaced: the REPL ends without disposing it, so slc_call and a later run still see what was typed, while .reset hard is refused with a line saying why (it exists to replace the interpreter, and the host holds a handle to this one; .reset all clears it in place).

Session options

slc_create takes one JSON object. Every key is optional, and an unknown key is refused by name rather than ignored, so a typo does not silently leave you with the defaults.

KeyMeaningDefault
homethe engine's own folder: its assets\, tools\ and the natives it loads by name, such as sqlite3.dll.beside the host executable
imagewhat Application.FilePath reports.the host's own image
argswhat Application.Args reports, WITHOUT an executable path.no arguments
thread"auto", "own" or "host". See the next section."auto"
dirsthe directories a SCRIPT's files must live in, each an existing directory. A path outside them is refused by name, naming the list.no confinement
denythe capabilities this session does not have: file, process, network, ui, com, clipboard.all of them

Confining what a script may touch

"dirs" and "deny" are the session's sandbox, and both refuse BY NAME: a path outside the directories answers '...' is outside the session's "dirs" list (...), and a denied call answers the "process" capability is denied for this session ("deny" in the session options), so Process.Start cannot run. That sentence is what a host shows its user, and it is why a denial is not reported as an unknown member.

The vocabulary is closed and validated at slc_create: file covers the File and Directory namespaces, Bytecode's exports, the import door and a SQLite database path; process covers Process; network covers Http; com covers Com; clipboard covers Clipboard; ui covers Application.CreateForm. A name outside that list, or a "dirs" entry this machine does not have, is refused at create with the accepted values named.

A guardrail, not a security boundary. The engine runs inside your process with your privileges, and NativeCalls can reach any function the process can. What the sandbox buys is that a script cannot accidentally read the wrong folder, start a process, fetch a URL or drive Excel, and that a host running untrusted input gets a refusal instead of a silent ability. A security boundary is an out-of-process door; that is the route for hostile code, not a JSON option.

args defaults to EMPTY on purpose: the host's own command line is another program's flags, and a script that read them would be reading something nobody gave it. A relative path passed to slc_run_file resolves against the host's current directory, which is the rule any other library follows.

Threads and apartments

A session runs a script on ONE thread, and which thread it is decides two things this engine cannot fake: how deep a script may recurse (the depth guard measures what is LEFT of the stack a run is on) and whether COM works at all (COM Automation here requires an STA thread).

ModeWhat happens
"auto"the HOST's thread when it is STA and offers at least 16 MB of stack, otherwise a thread the session creates: 32 MB, STA, named SLC embed.
"own"always a thread of the session's own. This is how a host whose calling thread is small still gets a 32 MB budget.
"host"never a thread of its own: the session FAILS at slc_create if the calling thread cannot carry a run, naming the reason (the apartment, or how much stack it found). Zero marshalling, and the session can only be driven from that thread.

slc_thread_mode tells you which you got, and slc_stats reports the apartment and the stack in megabytes. A console program's main thread is MTA with about 1 MB, so "auto" there gives the engine's own thread, which is why 50,000-deep recursion and a form both work from a plain launcher.

Why 16 MB is the number. The engine's shipped exe gives its main thread 32 MB. A DLL runs on the HOST's stack instead, and the same script that recursed 1,392,612 frames in the exe refused at 59,990 inside a host thread, because the guard measures what is left of somebody else's budget. Half the engine's own number is the point where a host thread is worth using, and below it the session takes a thread of its own.

Output

slc_set_output takes one callback, shared by two channels: 0 is the script's print, 1 is the engine's own log lines (a warning, an error, a refusal the engine composed). It is invoked on the thread that runs the script, with a NUL-terminated UTF-8 string that is valid only for the duration of the call, so copy it if you keep it.

typedef void (*slc_line_cb)(int64_t handle, int32_t channel, const char* utf8, void* user);

A callback may not drive its own session: it runs on the session's thread, so a verb called from inside one is refused by name instead of waiting on itself. Post the work to another thread if a callback needs to run something.

Verdicts

A run's status is the engine's own exit code family, so the numbers match what the CLI exits with: 0 done, 1 failed, 2 the call itself was wrong (a missing path, an unknown option), 3 the engine refused or is busy. A script that ends with exit(n) answers n.

slc_last_result carries the detail as JSON, which is what a host should log or show. Every member is PascalCase, the same convention every other object this engine writes for a host uses (slc_stats, a form document):

{"Status":1,"Ok":false,"Error":true,"Fatal":false,"Cancelled":false,
 "Code":"Unspecified","Message":"Division by zero","Line":2,"Column":5}
FieldMeaning
Statusthe number the process would exit with
Error / Fatala script failure, or an unexpected engine failure (the arm that also asks for a crash-ring dump)
Cancelledthe run was stopped by slc_cancel rather than failing. A cancelled run is never reported as the author's mistake
Code, Message, Line, Column, Framesthe engine's own sentence and where it happened, plus the script frames when the failure came from inside a function. A frame reads Inner [L12:C5]: the function, and the position it was entered at, so a host can log a trace a reader can follow
Checkswhat the Check family reported during THIS run: {"Passed":0,"Failed":1,"Checks":[{"Label":"this test will fail","Line":1,"Column":6}]}. Absent when the run reported no check. It exists because a suite's own verdict is exit(CheckFailed()), which is the SCRIPT's decision, so the status alone says 1 and nothing else
Resultthe run's answer serialized as JSON: for slc_call the function's return value, and for a run that reported checks the Checks report itself, with the value the run ended with inside it named for where it came from: Answer when the script has a top-level return <value>, Echo when it is only the value of the last expression statement (the = ... a REPL prints). Measured, a suite ending CheckSummary(); exit(CheckFailed()); answers "Echo":false, which is that call's value and not a verdict. slc_run_file and slc_run_text report it; slc_main records a verdict rather than an answer, so there the report appears as Checks alone
ResultFrom"checks" when Result is that report rather than a value the script returned, so a host can tell the two apart
A failed slc_main run reports the same detail. slc_main runs the executable's own dispatch, so a host that launches a file through it (a launcher does) used to learn only the code: the message, the position and the frames went to a console the host does not own, and the verdict read {"Status":1,"Ok":false,"Error":false,"Fatal":false,"Cancelled":false}. It now carries them, taken from the same run the CLI describes.

Calling back into a script

A run happens in the SESSION's interpreter, so whatever a file declared stays callable. slc_call takes a function name and a JSON array of arguments, and answers the return value in slc_last_result:

slc_run_file(session, "rules.slc");            /* declares int Score(int n) */
slc_call(session, "Score", "[21]");            /* status 0 */
/* slc_last_result -> {"Status":0,...,"Result":42} */

A name nothing declares is a usage refusal that says so, not a silent success. Arguments are coerced by the script's own parameter types, so [21] reaches an int parameter as an integer and a string reaches a string.

One exe, one DLL: a script packaged into the launcher

The launcher carries a resource slot called SLCPAYLOAD (type RT_RCDATA), and a script can fill it: copy the launcher, write the payload into the copy's resource, done. The copy then IS the application, with the engine DLL beside it.

slc-pack.slc <launcher.exe> <script.slc | bundle.slcbc> [<out.exe>] [--icon <file.ico>] [--version <a.b.c.d>]
            [--gui | --console]

ScriptLangCore.exe assets\language\probes\slc-pack.slc ScriptLangCore.Launcher.exe app.slc app.exe
ScriptLangCore.exe assets\language\probes\slc-pack.slc ScriptLangCore.Launcher.exe app.slcbc app.exe
ScriptLangCore.exe assets\language\probes\slc-pack.slc ScriptLangCore.Launcher.exe app.slc app.exe --icon app.ico --version 1.2.3.4
ScriptLangCore.exe assets\language\probes\slc-pack.slc ScriptLangCore.Launcher.exe form.slc form.exe --gui
app.exe first second

The subsystem is the packaging choice that decides whether the app owns a console. The launcher is a console exe, so a copy of it puts a black window on screen at every double-click even when the payload only opens a form. --gui writes subsystem 2 (Windows subsystem) into the copy and --console writes 3 back; no flag leaves the launcher's own byte alone, so a script that already packages keeps behaving as it does. The field is the DOS header's e_lfanew at 0x3C, the PE signature after it, and Subsystem at OptionalHeader + 0x44, which is pe + 0x5C for 32-bit and 64-bit images alike. Two consequences a packager should know: a GUI image still writes to a redirected stream (app.exe > out.txt captures every print, since the handles are inherited), while a double-clicked one has no console to write to, so a form app is the shape --gui is for and a command-line tool is the shape it is not. Patching the header also invalidates a signature and the PE checksum, so pack first and sign afterwards.

One door to the console, never two. A console image has its console before its first statement runs, so a script that then calls AllocConsole (or frees and re-creates it in order to position it) produces a second window: the first one flashes white as it paints and then goes away. A GUI image owns no console at all, which is why the window the payload creates is the only one that ever appears. The rule is therefore one door per app: --gui when the script opens its own console, and in that case call it BEFORE the first print, because a GUI image has nowhere to write until then; --console when the caller's console is the one to use, and in that case the script must not create one. Either way, a GUI image started from a shell still writes to a redirected stream (app.exe > out.txt captures every line), while the same output without a redirection goes nowhere: that is the trade the flag makes.

From a session, pass the tool its arguments after --: .run assets\language\probes\slc-pack.slc -- ScriptLangCore.Launcher.exe app.slc app.exe --version 1.2.3.4. Everything after the separator is the SCRIPT's command line (Application.Args is its own path plus those words, quoted ones kept whole), so a tool whose input is its argv needs no second form and no wrapper script.

The app can name itself. --icon replaces the launcher's own icon: each image of the given .ico becomes an RT_ICON under the launcher's group id (32512, the id .NET's <ApplicationIcon> uses, so Explorer and the taskbar read it with no shell work), and the image bytes are the file's own. The copy always carries a version resource, and it describes the APP rather than the launcher it came from: the four numbers come from --version when it is given and from the launcher's own VS_FIXEDFILEINFO otherwise (so a copy declares the engine version it contains instead of inventing one), and the strings name the app, which is what a right-click shows. Concretely, packaging app.slc into app.exe gives FileDescription and ProductName app, OriginalFilename app.exe, and Comments naming the payload it was built from, in an English-Unicode string table (040904b0) with the matching VarFileInfo translation. Leaving it out is what an earlier version did, and it read wrong either way on a launcher that carries strings of its own: without --version the copy claimed ProductName = ScriptLangCore, and with it a numbers-only blob replaced the whole resource, so the description and product name read empty.

A packaged exe is still the CLI when asked. Its payload runs for its OWN arguments, and an argument the engine's dispatch owns (--compile, --repl, --dump-bytecode, --mcp, --mcp-bc) takes over exactly as it would on the unpacked launcher, so a packaged app can compile a file or open the REPL. The launcher asks the engine (`slc_main_owns`) rather than keeping a list of its own, which is why a flag added to the engine works in a packaged app the day it lands, and why a flag the APP invents (--verbose) still reaches the app.

Three more rules make the packed exe behave like an application rather than like a script with a wrapper. The payload is identified by content, its first four bytes, so a bundle's bytes and a script's text travel in the same slot with no second convention (and the packer reports which it found). Application.Args is the packed exe's own command line with the exe dropped, exactly as the CLI reports it, so Application.Args[0] is app.exe in a packed app where it is app.slc under the CLI. And the run's directory is the exe's folder, so ./ and Script.Directory() point at where the application lives, not at wherever it was started.

One payload, so one file. A packaged app carries its script and nothing else: a script that imports a sibling module, reads a data file by a relative path or opens a template beside itself needs those files next to the exe, exactly as it would unpacked. Packing a bundle is what makes the app a single file: a bundle carries its functions and, unless it was stripped, its own source, and its literal imports still resolve against the exe's folder. There is no extraction to disk either: the payload is read into memory and run from there, which is why nothing appears in a temporary folder when a packaged app starts.

The slot is created by the packer, not by the build. A NativeAOT image cannot be given a Win32 resource from MSBuild (<Win32Resource> is ignored by the native link, measured), so the unpacked launcher simply has no such entry and the packer's UpdateResource adds one. That is also why the launcher's test for "am I packaged?" is the entry's absence or a zero size, and why it costs nothing when it is not.

When the engine DLL is not there, the app SAYS SO in a window

A package is the launcher plus a payload, and the engine (ScriptLangCore.dll, with its assets\, tools\ and sqlite3.dll) is the third thing it needs. Reported from a live run: a packaged GUI app started without that DLL exited with code 3 and showed nothing at all, because the launcher's explanation went to a stderr a windowed app does not have. It now shows a message box, titled ScriptLangCore: the engine DLL is missing, listing where it looked and what to copy, and it keeps the stderr lines for a console run, a script and src/build/embed-check.sh. The box appears when nobody would see those lines, which is three shapes, not one: a double-clicked GUI copy (no console at all), a double-clicked console copy (Windows gives it a console of its own, and that console disappears with the process, so the words would flash and vanish), and anything else where stdout and stderr are a real window rather than a pipe or a file. A shell shares its console with the app, and a captured run reads stderr through a pipe, so neither gets a box. It is MessageBoxTimeoutW with a 20 second timeout, so a window nobody is there to dismiss cannot hang a machine. The same door covers a DLL that exists but will not load, and an engine that refuses to start a session.

The same packaging from the compiler UI

assets\language\bc\bc_compiler_ui.slc carries the same mechanism as a button: row 2 builds a package out of what row 1 produced, with two boxes that are the only two choices it has. package the .slcbc (else the .slc) says WHAT travels (the compiled bundle, or the source itself when nothing is compiled yet), and windowed exe (no console) says WHICH KIND of exe it becomes (subsystem 2, a WinExe; unticked leaves the console exe a launcher copy already is). The launcher is SEARCHED for rather than named, the output is derived from the payload (<name>.exe beside it), and the log reads the copy back: the resource's stored size against the payload's and the subsystem byte as the file now answers it, so a package is reported by what it IS rather than by what the calls returned. Its headless self-test packages the subject both ways, verifies each copy and deletes its own artifact.

The launcher

publish-launcher\ holds a 15 KB C exe with no engine in it, plus the DLL and its assets\, tools\ and sqlite3.dll beside it, so the folder runs as published:

ScriptLangCore.Launcher.exe myscript.slc arg1 arg2
ScriptLangCore.Launcher.exe a_bundle.slcbc

It writes nothing of its own by default, which is the property a packaged app needs: print goes to stdout, the engine's log lines go to stderr, and the exit code is the script's own verdict. The engine's version line is one environment variable away (SLC_LAUNCHER_INFO=1), which is how a stale DLL is told from a fresh one when a report looks wrong, and the run's verdict JSON is behind that same switch: without it a run that chooses exit(3) leaves stderr empty, exactly as the CLI does, because a script picking its own code is not a failure. That distinction is not cosmetic: a suite asserts that its child's stderr is silent for a chosen exit code, so an unconditional verdict line made two suites report errors the engine never raised. A host that wants the reason reads it on demand (slc_last_result, or this switch). It is also the working sample for the ABI: everything it does is the quick start above.

Where the launcher finds the engine

ScriptLangCore.dll does not have to sit beside the exe: one engine can serve many launchers, which is what you want when several small apps share a single 19 MB DLL. The search runs in four steps, and the first hit wins:

stepwhat it looks for
SLC_ENGINE_DLLa FILE named by that environment variable
beside the exethe shipped shape, and the one a packaged app uses: a copy here WINS over the two steps below
SLC_ENGINE_HOMEthat directory, holding ScriptLangCore.dll
PATHeach directory in its own order, first hit wins

Beside the exe comes before the environment on purpose. An installed engine sets SLC_ENGINE_HOME for every process on the machine, so with the environment first a packaged app that SHIPS its own engine would silently run the installed one instead, changing the version the app was built against. A launcher with no engine of its own still finds an installed one through the environment or PATH, and sqlite3.dll follows whichever engine answered, because the engine's home is the folder its own DLL came from.

rem one shared engine, several launchers, nothing copied beside them
set SLC_ENGINE_HOME=C:\Tools\ScriptLangCore
myapp.exe

rem or: the engine's folder is already on PATH, so nothing is declared at all
set PATH=C:\Tools\ScriptLangCore;%PATH%
myapp.exe

A declared location is obeyed, not consulted. When nothing is beside the exe and SLC_ENGINE_HOME names a folder with no ScriptLangCore.dll, the launcher exits 3 naming what it was told and what it did not find, rather than falling back to another copy: a typo must not load a different engine than the one you named, which is the failure mode that would be hardest to notice. SLC_ENGINE_DLL naming a missing file is refused the same way. When the engine came from anywhere but the exe's own folder, one line on stderr says which one answered (launcher: engine from C:\Tools\ScriptLangCore\ScriptLangCore.dll), next to the version line.

One interpreter per session, and it is the one running

A session runs a script on the interpreter it ADOPTED, so slc_main with a .slc behaves exactly like .run in a REPL: the file's declarations, its script classes, its bundle exports, its ./ relative paths and Application.SingleInstance's lock all belong to the one interpreter that is running. That is not an implementation detail a host has to know, but it is why a 526-suite corpus behaves identically through the launcher and from the command line: a SECOND interpreter in one process inherits its namespaces from the engine's blueprint, and a namespace member keeps the interpreter that registered it, so the two disagree about every per-run table. Measured on the corpus before this was fixed: Bytecode.ExportScript reported "entry function 'f' is not VM-compiled", Sqlite.Query<T> answered "Class 'X' not found", ./x had no current directory, and a second process never saw the single-instance lock.

The launcher carries the engine's manifest

The launcher embeds the same app.manifest the engine's exe carries, because a payload runs as THIS process: the Common-Controls v6 assembly dependency (which is what registers SysLink and draws a themed control) and PerMonitorV2 awareness have to be this image's. Without it a UI script fails with CreateWindowEx for 'SysLink' failed: 1407 through the launcher while the same script works from the CLI.

The engine's folder becomes the session's home , which is what makes a shared engine work: assets\, tools\ and sqlite3.dll live with the DLL it was published beside. One consequence is worth stating because it is visible from a script: Application.Path reports that folder, so a run against C:\Tools\ScriptLangCore answers Application.Path = C:\Tools\ScriptLangCore even though the exe is elsewhere. A packaged app's own files are still found beside the APP, through ./ and Script.Directory(), which name the exe's folder.

Embedding from C# and .NET

Nothing here is C-specific: the ABI is twenty-two plain exports, so a managed host binds them with NativeLibrary and delegates, and bindings/ScriptLangCore.cs already carries one delegate per export, with no marshalling attributes to get wrong. This is the whole of it, and it prints engine: ScriptLangCore 1.0.0.0 (NativeAOT), embed ABI 5, status: 0, the script's two lines and its outcome JSON:

using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;

// A .NET host: load the DLL once, create one session, point the sinks at this process, run, read the outcome, release.
// Every export is a plain C ABI, so `NativeLibrary.GetExport` IS the binding, and no generated marshalling is needed.
var lib = NativeLibrary.Load(System.IO.Path.Combine(AppContext.BaseDirectory, "ScriptLangCore.dll"));

T Export<T>(string name) => Marshal.GetDelegateForFunctionPointer<T>(NativeLibrary.GetExport(lib, name));

var version = Export<VersionFn>("slc_version");
var create = Export<CreateFn>("slc_create");
var free = Export<FreeFn>("slc_free");
var runText = Export<RunTextFn>("slc_run_text");
var setOutput = Export<SetOutputFn>("slc_set_output");
var lastResult = Export<MeasureFn>("slc_last_result");

Console.WriteLine($"engine: {ReadVersion(version)}");
var handle = create(IntPtr.Zero);                      // null options: default thread mode, home = this folder
Console.WriteLine($"session: {handle}");

// Output arrives as UTF-8 lines with a channel: 0 is the script's stdout, 1 is the engine's log.
var lines = new List<string>();
LineCb sink = (h, channel, utf8, user) => lines.Add($"{channel}|{Marshal.PtrToStringUTF8(utf8)}");
setOutput(handle, Marshal.GetFunctionPointerForDelegate(sink), IntPtr.Zero);

var status = runText(handle, Utf8("print('hello from a script');\nint Twice(int n) { return n * 2; }\nprint('twice: ' + Twice(21));"), IntPtr.Zero);
Console.WriteLine($"status: {status}");
foreach (var line in lines) Console.WriteLine($"  {line}");
Console.WriteLine($"outcome: {Ask(lastResult, handle)}");
Console.WriteLine($"free: {free(handle)}");
GC.KeepAlive(sink);                                    // the callback must outlive the calls: the one lifetime rule

IntPtr Utf8(string s) => Marshal.StringToCoTaskMemUTF8(s);

static string ReadVersion(VersionFn call)
{
    var needed = call(IntPtr.Zero, 0);
    var buffer = Marshal.AllocCoTaskMem(needed + 1);
    try { call(buffer, needed + 1); return Marshal.PtrToStringUTF8(buffer) ?? ""; }
    finally { Marshal.FreeCoTaskMem(buffer); }
}

static string Ask(MeasureFn measure, long handle)
{
    var needed = measure(handle, IntPtr.Zero, 0);
    if (needed <= 0) return "";
    var buffer = Marshal.AllocCoTaskMem(needed + 1);
    try { measure(handle, buffer, needed + 1); return Marshal.PtrToStringUTF8(buffer) ?? ""; }
    finally { Marshal.FreeCoTaskMem(buffer); }
}

delegate long CreateFn(IntPtr optionsJson);
delegate int FreeFn(long handle);
delegate int RunTextFn(long handle, IntPtr source, IntPtr name);
delegate int SetOutputFn(long handle, IntPtr callback, IntPtr user);
delegate int MeasureFn(long handle, IntPtr buffer, int capacity);
delegate int VersionFn(IntPtr buffer, int capacity);   // slc_version takes no handle
delegate void LineCb(long handle, int channel, IntPtr utf8, IntPtr user);

Three details decide whether a managed host works first time. The DLL must be findable: a published host keeps ScriptLangCore.dll beside its own exe, which is what AppContext.BaseDirectory names (under dotnet run that is bin\<config>\<tfm>\, so copy the DLL there while experimenting). The output callback is a DELEGATE the runtime marshals, so it must outlive the calls that use it, which is why the sample keeps it in a local and calls GC.KeepAlive; a callback collected mid-run is a crash, not an error. And nothing throws across the boundary: a script's failure comes back as a status plus the outcome JSON from slc_last_result, so a host reads verdicts rather than catching exceptions.

The full working host is src/embed/Program.cs in this repository: about sixty checks that exercise every export, including slc_main, the bytes verb, cancellation and the free-while-running refusal. When a question is not answered on this page, that file is the reference.

A session is per host, and the free matters. slc_create hands back a handle and slc_free releases the session, its interpreter and its feeds; a host that creates one session for its lifetime and frees it at shutdown has nothing else to do. Handles are never reused, so a stale one answers a refusal naming it instead of reaching another session, and slc_free refuses while a run is in flight rather than abandoning it mid-statement.

SQLite as a third door

The same ScriptLangCore.dll is also a SQLite loadable extension, so a database can call script code from SQL. There is no separate artifact to build: the Embed configuration exports the entry point beside the twenty-two slc_* verbs, and registers six functions.

sqlite> .load ./ScriptLangCore init
sqlite> select slc_eval('a0 + a1', 2, 3);
{"HasError":false,"ExitCode":0,"ReturnValue":5}
sqlite> select json_extract(slc_eval('a0 * 2', 21), '$.ReturnValue');
42
The prompts are the shell's, not SQL, and 3.54 changed them. The blocks below use the classic pair, sqlite> and ...> for a statement continued on the next line. Starting with SQLite 3.54 the continuation prompt shows the characters still needed to close the statement instead of a fixed ...>, so a statement that only lacks its semicolon is met with ;-> (and one open parenthesis with );-> ), while the main prompt now carries the application and version. Both are cosmetic: nothing in them is typed. To pin the pair these examples show, for the session:
sqlite> .prompt 'sqlite> ' '   ...> '
or for every session, before the shell starts (3.54 and newer):
export SQLITE_PS1='sqlite> '
export SQLITE_PS2='   ...> '
.prompt with two strings works in every shell; on 3.54 and newer .prompt --show also prints the pair currently in use, and an older shell does not report that flag, so set rather than inspect below 3.54. The escape codes the new defaults are built from (/C for the closing characters, /B for the padding) are listed on sqlite.org/cliprompt.html.

Three entry-point names, and the third one is for COM. init is the short one the examples use. sqlite3_scriptlangcore_init is the name SQLite DERIVES when no entry point is given (the basename, without its extension, without a leading lib, lowercased), which is what makes a bare .load ./ScriptLangCore work; the two land on the same body. init_sta is that same body with one step in front of it: it makes the calling thread the main STA, which is what a script that drives COM needs.

sqlite> .load ./ScriptLangCore init_sta
sqlite> select json_extract(slc_file('D:/rules/sheet.slc'), '$.ReturnValue');

Why it is a separate name rather than a flag: SQLite fixes the entry point's signature, so there is no argument to carry a flag, and choosing the name IS the opt-in. Why it is needed at all: the shell's own thread never initialized COM, so Windows reports an implicit MTA apartment for it and a COM call there is refused with COM Automation requires an STA thread. A thread that never chose an apartment can still become one, which is why OleInitialize succeeds on it and the same script then answers; a thread whose host did choose MTA cannot be converted, and init_sta refuses it by name. The cost is the host's own thread, so it is stated rather than buried: after this load that thread is the main STA for the rest of its life, and a host that later asks for MTA on it gets RPC_E_CHANGED_MODE. A connection that only evaluates expressions pays nothing and should keep using init.

A worked payload for init_sta is assets/language/samples/sqlite/excel_matrix_roundtrip.slc: it builds a matrix of ints, doubles, decimals, dates and text, writes it into one Excel range with a SUBTOTAL formula under it, reads the whole range back, creates an SQLite table and inserts every row, then answers that table with SELECTs that compare its SUM against Excel's own subtotal. Its answer is one object holding the verdict and the whole matrix, which is how a caller reads a payload whose print this door discards.

The answer is always the run's result, as JSON

slc_eval(code, a0, a1, ...) evaluates one script expression and answers Json.Serialize of the run's result, which is the same value RunScript gives a script. A caller reads what it needs from it:

{"HasError":false,"ExitCode":0,"ReturnValue":5}
{"HasError":true,"ExitCode":0,"ErrorMessage":"Undefined variable 'nope'",
 "ErrorInfo":{"Code":101,"Message":"Undefined variable 'nope'"},
 "ErrorLine":1,"ErrorColumn":5,"StackTrace":"..."}

A script that fails is a result and never a SQL error, so a query over many rows keeps its rows and reports the failures per row. The extension's own failures are different and they are SQL errors: no code argument at all, a session that went missing, or re-entrancy past the guard. A code text that does not parse is a result too, with its message and position, because the caller wrote it and a refusal reads better than a broken statement.

SQLite reads JSON natively, so the value stays usable: json_extract(..., '$.ReturnValue') hands back a typed scalar or a nested document, and json_type says what arrived. One consequence is worth knowing before writing a WHERE clause: the function answers TEXT, and SQLite orders every TEXT above every number, so slc_eval(...) > 3 is not the comparison it looks like. Extract first, and compare the extracted value.

A JSON boolean comes back as the INTEGER 1 or 0, and that is json_extract, not the engine. The result is a JSON document with real booleans in it ({"HasError":false,"ExitCode":0,"ReturnValue":true}), and SQLite has no boolean storage class, so its JSON functions map one onto a number when they hand a value to SQL. The full mapping, as the shell prints it:

json_extract('{"b":true}',  '$.b')   1      integer     json_type: true
json_extract('{"b":false}', '$.b')   0      integer     json_type: false
json_extract('{"s":"x"}',   '$.s')   x      text
json_extract('{"n":1.5}',   '$.n')   1.5    real
json_extract('{"a":[1,2]}', '$.a')   [1,2]  text        an array or an object travels as its JSON text
json_extract('{"z":null}',  '$.z')   (null) NULL

So 1 in a result cell is not a corrupted value. When the boolean itself matters there are three spellings, and the first one is the one to reach for: json_type(x, '$.ReturnValue') answers the word true or false, the -> operator answers the JSON text (true, a TEXT value), and comparing what json_extract gave you (= 1) is the numeric reading, which is fine as long as nothing else in that column can be 1 for a different reason.

The verdict is ExitCode, and ReturnValue is what the file answered. They are different questions. A file answers with its top-level return <value> when it has one, and otherwise with its last top-level expression statement, so a suite that ends in CheckSummary(); exit(CheckFailed()); answers {"HasError":false,"ExitCode":0,"ReturnValue":true} when every check passed: exit ends the run before a value is left, and the summary's own flag is the last one that was. Read $.ExitCode for "did the run pass" and $.HasError for "did it refuse to run", and treat ReturnValue as the file's own answer. That answer can be a whole document: a script that records each step in a class instance and ends with return log; hands the caller one object, which a query walks with json_each (select value from json_each(slc_file(path), '$.ReturnValue.Steps') where json_extract(value, '$.State') = 'failed'). Printing the whole document (select slc_file(...)) is also the quickest way to see a value you did not expect, since json_extract has already converted it by the time you read a single field.

Arguments arrive as a0, a1, ...

Nothing is spliced into source text. The SQL arguments after the code are bound as the globals a0, a1 and so on, mapped by their SQL type: integer to long, real to double, text to string, blob to a byte array, NULL to null. A list or a dictionary travels as JSON text and is read back with Json.Parse:

sqlite> select slc_eval('a0.Length', 'hello');
{"HasError":false,"ExitCode":0,"ReturnValue":5}
sqlite> select slc_eval('{n: a0, s: a1}', 7, 'two');
{"HasError":false,"ExitCode":0,"ReturnValue":{"n":7,"s":"two"}}
sqlite> select slc_eval('Json.Parse(a0).Count()', '[1,2,3]');
{"HasError":false,"ExitCode":0,"ReturnValue":3}

The scalar form, when a value is what you want

slc_eval_scalar(code, a0) answers the run's value as a NATIVE SQL value, which is what a WHERE clause, an ORDER BY, a sum() or a column that stores a number needs. A failing run becomes a SQL error here rather than a JSON envelope, because a scalar answer has nowhere to put the failure; the message still names the position. The benefit is the type, not the speed: measured over ten thousand rows the two forms differ by about four percent, because what a row costs is the script's own evaluation and not the envelope around it.

The argument count is part of each function's registration, so pragma_function_list reports the truth and SQLite refuses a wrong count at prepare time (wrong number of arguments to function slc_map()), which is what keeps an extra argument from being silently ignored: a fixed six (slc_file 1, slc_map 2, slc_agg 2, slc_eval_scalar 2, slc_avro_schema 1, slc_avro_json 1) and a variadic two (slc_eval, slc_map_agg, reported as -1, whose count is the caller's).

sqlite> select slc_eval_scalar('a0 * 2', 21), typeof(slc_eval_scalar('a0 * 2', 21));
42|integer

Values map by their script type. A number arrives as integer or real, text and character as text, a byte array as a blob, null as NULL, and a type SQL has no equivalent for (a DateTime, a list, a class instance) becomes text through the engine's own display rule, so a caller sees what a script would print rather than a CLR name.

Folds: slc_agg

slc_agg(code, value) is an aggregate, and one is enough to express any fold, which is why there is no min/max/sum family to keep in step. Per row the code is evaluated with a0 the running state and a1 the row's value, and its answer becomes the new state; the final state is the result. On the first row a0 is NULL, so a fold starts from a default it states itself. A default can also be passed as another argument, which keeps the quoting out of the expression:

sqlite> with recursive t(n) as (select 1 union all select n+1 from t where n<5)
   ...> select slc_agg('(a0 ?? 0) + a1', n) from t;
15
sqlite> with t(s) as (values ('a'),('b'),('c'))
   ...> select slc_agg('(a0 ?? a2) + a1', s, '') from t;
abc

An aggregate that sees no rows answers NULL, and a step that fails aborts the statement with the script's own message.

A set at a time: slc_map and slc_map_agg

Two spellings of the same answers, because the rows reach them differently. slc_map(code, rows) takes a JSON ARRAY and needs no query at all, which is what you want for a document you already hold. slc_map_agg(code, a0, a1, ...) is an aggregate, so the rows arrive from a table and the arguments are spelled the way slc_eval spells them. Both answer a JSON array of values.

A stored Avro container: slc_avro_schema and slc_avro_json

An Avro Object Container File is a BLOB to SQLite, and SQLite knows JSON rather than Avro, so these two functions are the bridge, one per question. slc_avro_schema(blob) answers the .avsc TEXT the container carries in its own header, which is how a stored file describes itself; slc_avro_json(blob) answers its records as PURE JSON, DECOMPRESSED with the codec the file declares, so json_extract, json_each and json_group_array read it like any other JSON. Both share one reader with the script members Avro.ContainerSchema and Avro.ContainerJson, so a query and a script cannot disagree about the same bytes.

sqlite> select json_extract(slc_avro_json(data), '$[1].Name') from files;
two
sqlite> select json_extract(slc_avro_schema(data), '$.name') from files;
Row
sqlite> select count(*), sum(value) from json_each(slc_avro_json(data));
2|3

The argument is one BLOB, and a TEXT argument is REFUSED by name rather than decoded, because a container is binary and a text cast has already changed every byte above 0x7F: store the file with File.ReadAllBytes and bind it as a BLOB. A NULL answers NULL. A block that is not a valid frame of its codec is refused naming the codec, which is what keeps a damaged store from reading as a short one.

sqlite> select slc_map('a0 * a0', '[1,2,3,4]');
[1,4,9,16]
sqlite> with t(n) as (values (1),(2),(3)) select slc_map_agg('a0 * a0', n) from t;
[1,4,9]
sqlite> with t(a,b) as (values (1,10),(2,20)) select slc_map_agg('a0 + a1', a, b) from t;
[11,22]
sqlite> with t(n) as (values (2),(3))
   ...> select key, json_extract(value, '$.sq')
   ...> from json_each((select slc_map_agg('{n: a0, sq: a0*a0}', n) from t));
0|4
1|9

For the JSON form an element that is itself an array supplies the arguments in order, and any other element supplies a0 alone, so json_group_array(n) can be handed straight in. A row that fails raises a SQL error naming its position, and a set whose rows disagree about how many arguments they supply is refused by name, because one call reads one shape of row. An empty or NULL set answers [], and the aggregate evaluates nothing until it ends, so a failing row is reported once with its index.

Which door for a big set

Measured over one hundred thousand rows held in a table, three passes each, on the shipped Embed DLL, timed by the shell's .timer. The control and the last row show how much of a set door is SQLite's own array work:

sum(n*n)                                          4 / 4 / 4 ms        no engine at all
sum(slc_eval_scalar('a0*a0', n))                 91 / 98 / 84 ms     one call a row, no JSON
slc_agg('(a0 ?? 0) + a1 * a1', n)               109 / 131 / 107 ms   one call a row, folded
length(slc_map_agg('a0*a0', n))                 111 / 123 / 237 ms   the set, no unnest
sum(value) ... slc_map_agg, then json_each       131 / 126 / 224 ms   the set, read back as rows
length(slc_map('a0*a0', json_group_array(n)))    136 / 125 / 241 ms   the same through a JSON array
sum(value) ... slc_map, then json_each           150 / 141 / 218 ms   the same, read back
sum(json_extract(slc_eval('a0*a0', n), ...))     202 / 222 / 200 ms   one envelope a row
  build the array in SQL, then read it            15 / 15 / 23 ms     SQLite's own floor

Three conclusions, and the first reverses the obvious reading. A VALUE per row is the cheapest door there is (slc_eval_scalar, about 0.84 microseconds a row), so when what you need is a number, the per-row scalar function beats both set doors. The ENVELOPE is what costs, about 2.4 times the scalar door, because each row serializes a whole ScriptResult; it is worth that when a caller needs the failure fields of the row. And the set doors sit between them at 1.1 to 1.4 microseconds a row: slc_map_agg leads slc_map by about ten percent because it never builds the intermediate JSON text, which is the reason it exists. So choose by the shape of what you have rather than by the table: slc_map_agg when the rows are in a table and the SQL side is one expression, slc_map when what you have is already a JSON document, slc_eval_scalar when the answers are values you consume as rows, and slc_eval when each row's failure matters.

The JSON1 rules this door leans on. json_group_array keeps NULL inputs in the array and answers [] when it saw no rows, which is why an empty or NULL set answers [] here rather than a refusal. A BLOB cannot be put into a JSON array at all (json_group_array(x'01') is "JSON cannot hold BLOB values"), so encode a binary column first if it is part of the set. A JSONB value is a BLOB, so a set that is already JSONB needs the json() wrapper: slc_map(code, json(rows)), and json(jsonb_group_array(n)) is the JSONB-building aggregate read back as text. json_each answers a key that is the array index, a type, and a value that is the SQL value for a primitive and the JSON TEXT for an object or an array, which is why a field of an answer is read with json_extract(value, '$.field').

Loading a file: slc_file

slc_file(path) runs a script FILE in this connection's interpreter and answers the run as JSON, so a schema can carry its logic instead of one expression. Its functions stay callable from the other three afterwards, which is what removes the need for a registration ceremony of its own:

sqlite> select json_extract(slc_file('D:/rules/grades.slc'), '$.HasError');
0
sqlite> select slc_eval_scalar('Grade(a0)', score) from results;

The path is resolved against the process directory, which is the only directory a SQL host has, and the file's own REPL dot commands are stripped, so it behaves as it does from the command line.

A path holding a compiled bundle is run through the same door, and the answer is decided by the file's content (the bundle magic in its first four bytes) rather than by its extension, so slc_file('D:/rules/grades.slcbc') answers what slc_file('D:/rules/grades.slc') answers. That includes the run's verdict: a script that sets exitcode reports it either way, so $.ExitCode answers the same number from a bundle as from its source. A program bundle works whether it carries its source or was stripped with --source=strip: the source it carries is run, and a source-less one runs its compiled entry in the connection's interpreter, so its functions are callable afterwards exactly as a source file's are. A library blob names no entry and is refused by name (this library names no entry, so load it with import or .loadslcbc), which is the one shape this door cannot run.

What the file answers is its top-level return <value>, and otherwise the value of its last top-level expression statement. So the shape that makes a file a callable unit with data coming back is: declare the functions, then end with either return and the value you want, or the call whose answer you want.

-- D:/rules/box.slc:  class Point { public int X; public int Y; }
--                     object Build() { var p = new Point(); p.X = 3; p.Y = 4; return p; }
--                     Build();          -- the last statement is what the run answers
sqlite> select slc_file('D:/rules/box.slc');
{"HasError":false,"ExitCode":0,"ReturnValue":{"X":3,"Y":4}}
sqlite> select json_extract(slc_file('D:/rules/box.slc'), '$.ReturnValue.X'),
   ...>        json_extract(slc_eval('(Build()).Y'), '$.ReturnValue');
3|4

A script's own print is DISCARDED on this door. The interpreter behind slc_file has a null output sink, so the SQL result stream stays clean and the JSON answer stays parseable: measured, a file printing two lines and exiting 7 answered {"HasError":false,"ExitCode":7} and nothing else, while the same file run from the CLI printed both lines. Write what a reader must see into the run's own answer (return <value>) or into a file the script owns, and read it back in SQL; a tool that reports progress by printing must write through Console.Out instead of print.

Two details decide what a reader sees. A file that only DECLARES things (classes, functions, no call) answers {"HasError":false,"ExitCode":0} with no ReturnValue member at all, and a last statement whose value is null reads the same way, so json_type(... , '$.ReturnValue') is the honest test rather than a comparison with null. And the object travels through the engine's JSON writer, so a class instance arrives as its fields, a dictionary literal as a document, and a list as an array: that is the door to a serialized type from SQL.

A run that reported checks answers with their report. A subject written as a suite (Check("...", cond)) reports Passed, Failed and the failing labels with their positions in ReturnValue, with the value the run ended with inside it as Answer (a top-level return) or Echo (its last expression statement), because the alternative was the value of the last call, which for CheckSummary() is a bare false:

sqlite> select json_extract(j,'$.ReturnValue.Failed'), json_extract(j,'$.ReturnValue.Checks[0].Label'),
   ...>        json_extract(j,'$.ExitCode') from (select slc_file('D:/rules/suite.slc') as j);
1|this test will fail|1

That is the same report an embedding host reads in slc_last_result's Checks, so a suite's verdict is actionable through either door without anyone parsing the console text.

The same run from a SCRIPT is RunScript(path, true), and its .ReturnValue is that identical value, with Json.Serialize(result) producing byte for byte the text slc_file answers. The difference is scope: RunScript runs in an isolated interpreter by default, so the file's functions do not stay callable in the caller, while slc_file runs in the connection's interpreter and they do. Pass preserveContext: true to RunScript when the caller wants the same.

Load once, then call. slc_file re-reads, re-parses and re-compiles the file on EVERY call, because the file may have changed, so it is a loader and not a per-row door: measured, one run allocates about 165 KB for a 120-byte file and 12 MB for a 7 KB file declaring 200 functions, against about 18 KB for a call into a function the file already declared. A loop of slc_file therefore makes the process' committed memory climb to the high-water mark of that churn (the engine retains nothing: live managed memory stays at about 455 KB, and a full collection measures 0 bytes of growth per call over 15,000 calls, while the committed segments are not handed back to the OS). One slc_file per connection and then slc_eval/slc_eval_scalar over its functions is both the cheap shape and the one a schema wants.

JSONB comes for free

SQLite converts JSON text to its binary form itself, so the JSONB variant of this function is the SQL spelling jsonb(slc_eval(...)) and needs nothing from the engine. Measured over ten thousand rows, the conversion is inside the noise of the run itself (228 and 236 ms against 236 and 232 ms), the binary form is about a quarter smaller for a small document (96 bytes of text against 71 of blob), and json_extract and ->> read both identically. A slc_eval_jsonb inside the engine would be worse than this: the binary layout is internal to SQLite and version-coupled, so building one from C# either hand-writes that layout or re-enters SQLite with a prepared statement per row.

What the function is registered as, and why

slc_eval is registered with SQLITE_DIRECTONLY and without SQLITE_DETERMINISTIC. The first is not optional here: this language starts processes, reads files, drives COM and opens sockets, so without it a database file could ship a view, a trigger or a generated column that runs script code the moment a client opens it. Only a statement the application itself writes may call the function. The second is left off because an expression may read a clock or a file, and the planner must not assume otherwise.

A host older than SQLite 3.31 has no SQLITE_DIRECTONLY, and registering there would succeed while losing the guard, so the load is refused by version instead.

Lifetime, threads and output

Each connection owns one interpreter, carried as the function's application pointer and released by sqlite3_create_function_v2's destructor when the connection closes, so no session outlives its connection and none is keyed on a handle. Calls re-enter freely up to sixteen deep, which is what a scalar subquery or a view calling the function needs. The expression's print output is discarded, because a SQL function's answer is its result and a host's console has no line for a script to land on; the engine's own log still reaches the host's standard error.

A SQLite function runs on whatever thread the host calls it from, and the interpreter is created on that thread. A host that shares one connection between threads therefore shares the interpreter, which is the same rule the C ABI states: one call at a time per session.

What a row costs

The code text becomes the body of a callable once per distinct (code, argument count), and a row is then a CALL: no parse, no compile, no run setup. A hundred thousand rows, three passes each, best shown, on the shipped Embed DLL:

sum(slc_eval_scalar('a0*a0', n))                                84 ms   0.84 us a row
sum(json_extract(slc_eval('a0*a0', n), '$.ReturnValue'))       200 ms   2.00 us a row
sum(json_extract(jsonb(slc_eval('a0*a0', n)), '$.ReturnValue')) 206 ms  2.06 us a row

So a value per row is about 0.84 microseconds and a whole envelope is about 2.0, and the JSONB conversion between the first two lines sits inside the noise. Most of the remaining cost is the engine's own call: measured in a script loop, one script call is about 0.59 microseconds, its argument pool, local frame pool and return coercion included. The code text is decoded once per statement while it does not change, so a repeated code is the fast path: a code that changes on every row costs about fifteen percent more, which is the decode and the cache key it forces.

Positions are the ones in your own text. Because the expression is wrapped as a lambda internally, a failure could have named a column shifted by the wrapper; it does not, and a mistake reports the line and column you would point at in the code you passed.

Verifying it yourself

sqlite3> .load D:/path/to/publish-dll/ScriptLangCore init
sqlite3> select name, narg from pragma_function_list where name = 'slc_eval';

.load FILE with no entry point also works, and then SQLite looks for the derived name. If it answers no such function, spell the entry point: .load FILE init.

The engine can host it too, which is the shortest self-test on a machine that has no SQLite shell to hand:

var lib = LoadLibrary("sqlite3");
var db = Sqlite.OpenDb(":memory:");
print(Sqlite.LoadExtensions(lib, db, "D:/path/to/publish-dll/ScriptLangCore.dll"));
foreach (var row in Sqlite.Query(lib, db, "select slc_eval('a0 + a1', 2, 3) as j")) {
    print(row["j"]);
}

The indices behind the entry point

SQLite hands an extension a table of function pointers, and calling the wrong entry in it is memory corruption rather than an error. The positions the engine uses are taken from offsetof against tools/SqliteExt/sqlite3ext.h, not from counting the struct by eye: a member whose parameters are function pointers, and a parenthesised comment, both fool a text pass, and the first version of this file proved it by reading a version string out of an integer. On top of that, every load resolves each routine by NAME from the host's own exports when the host exports them, and refuses the load when the name and the index disagree. A host that links SQLite statically exports nothing and is served from the table alone, which is why the load's first call is libversion: its answer must read as a version, or the load is refused instead of trusting the table.

What this door does not do. There is no table-valued function: one scalar, one scalar-as-value, one fold, one file loader and two set maps, because those are the parts an application needs to put logic next to its data, and a virtual table is a much larger surface. A script evaluated this way also runs with the permissions of the host process, so a database that arrives from elsewhere should not be able to add columns that call it, which is what SQLITE_DIRECTONLY enforces and why the flag is not configurable. Measured, that refusal is exact per shape: an expression index, a CHECK constraint and a generated column are refused at CREATE, while a VIEW, a TRIGGER and a DEFAULT clause are created and refused with unsafe use of slc_eval_scalar() when something reaches them.

Hosts that are not C: Python, PowerShell, bash

The ABI is a C ABI with plain types, so any language with an FFI can drive it, and any host that can talk to SQLite can take the third door instead. What follows is what each host needs installed before it can load the DLL, what you deploy beside it, and the smallest program that proves the whole path.

Install these first

HostWhat the DLL needs from itPackage to install
PythonA 64-bit CPython 3.x. ctypes and sqlite3 ship with it, so nothing else is needed.the python.org x64 installer, or winget install Python.Python.3.13
PowerShellNothing. Add-Type and [DllImport] are in-box, and they are the host's P/Invoke door.Windows PowerShell 5.1 ships with Windows; PowerShell 7 is optional (winget install Microsoft.PowerShell)
bashNo FFI at all, so it needs a 64-bit sqlite3 command line and takes the SQL door.MSYS2: pacman -S mingw-w64-x86_64-sqlite3. Or winget install SQLite.SQLite, which puts sqlite3.exe on PATH.

What you deploy beside the host

One folder holding the engine's own layout, which is what compile_aot.bat produces: ScriptLangCore.dll with sqlite3.dll, assets\ and tools\ next to it. The DLL is 19.7 MB and is the engine itself, not a wrapper. No sample below spells that folder's path. Each one reads it from SLC_ENGINE_HOME, and the one that compiles against the ABI reads the bindings folder from SLC_ENGINE_BINDINGS: the Windows installer's env task declares both, as {app}\bin and {app}\bindings, and puts the first on PATH (the second only when the bindings component was chosen, because that component is what fills it). SLC_ENGINE_DLL is deliberately not set, so the DLL's path is derived from the folder that does hold it. From a checkout instead, set the two variables yourself: set SLC_ENGINE_HOME=D:\your\publish-dll and set SLC_ENGINE_BINDINGS=D:\your\repo\bindings. A variable that is not set stops the sample on its own first line, naming itself: that is the one failure a reader can fix without guessing where a path came from, whereas a literal would point at a folder that exists on the author's machine and nowhere else. A value naming a folder that no longer holds an engine (after an uninstall, or after moving the folder) shows at the first load, where ctypes and the SQL extension both answer with the path they could not open.

Both sides must be 64-bit, and the shell's own bitness says nothing. The published DLL is x64: a 32-bit host cannot load it (Windows answers 193, "not a valid Win32 application"), and a 32-bit sqlite3 refuses the extension with "the specified module could not be found". A 64-bit bash still cannot load a DLL, because bash has no FFI, which is why the bash recipe goes through sqlite3.

Python: the ABI through ctypes

import ctypes, json, os

HOME = os.environ["SLC_ENGINE_HOME"]        # the installer's env task declares this folder
DLL  = os.path.join(HOME, "ScriptLangCore.dll")

dll = ctypes.WinDLL(DLL)                    # ctypes is in the standard library
dll.slc_version.restype = ctypes.c_int
dll.slc_version.argtypes = [ctypes.c_char_p, ctypes.c_int]
buf = ctypes.create_string_buffer(256)
dll.slc_version(buf, 256)
print(buf.value.decode())                   # ScriptLangCore 1.0.0.0 (NativeAOT), embed ABI 5

dll.slc_create.restype = ctypes.c_longlong
dll.slc_create.argtypes = [ctypes.c_char_p]
session = dll.slc_create(json.dumps({"home": HOME}).encode())

OUTPUT = ctypes.CFUNCTYPE(None, ctypes.c_longlong, ctypes.c_int, ctypes.c_char_p, ctypes.c_void_p)
def on_output(handle, channel, text, user):  # channel 0 is print, 1 is the engine's own log
    print(channel, text.decode("utf-8", "replace"))
callback = OUTPUT(on_output)                 # keep it alive for as long as the session lives
dll.slc_set_output.restype = ctypes.c_int
dll.slc_set_output.argtypes = [ctypes.c_longlong, ctypes.c_void_p, ctypes.c_void_p]
dll.slc_set_output(session, ctypes.cast(callback, ctypes.c_void_p), None)

dll.slc_run_text.restype = ctypes.c_int
dll.slc_run_text.argtypes = [ctypes.c_longlong, ctypes.c_char_p, ctypes.c_char_p]
dll.slc_run_text(session, b'print("hello from python"); int Twice(int n) { return n * 2; }', b"host.slc")

dll.slc_call.restype = ctypes.c_int         # a declaration of the run stays callable
dll.slc_call.argtypes = [ctypes.c_longlong, ctypes.c_char_p, ctypes.c_char_p]
dll.slc_call(session, b"Twice", b"[21]")

dll.slc_last_result.restype = ctypes.c_int  # the verdict, as JSON
dll.slc_last_result.argtypes = [ctypes.c_longlong, ctypes.c_char_p, ctypes.c_int]
outcome = ctypes.create_string_buffer(4096)
dll.slc_last_result(session, outcome, 4096)
print(outcome.value.decode())               # {"Status":0,"Ok":true,...,"Result":42}

dll.slc_free.restype = ctypes.c_int
dll.slc_free.argtypes = [ctypes.c_longlong]
print(dll.slc_free(session))                # 0, and 2 if the handle is freed twice

Python: the same program with the shipped module

bindings/scriptlangcore.py carries the whole signature table from the manifest, so a host stops writing restype and argtypes by hand and reads a buffer through text(), which applies the snprintf probe for it. It is one file with no dependency beyond ctypes, and the sample below takes its folder from SLC_ENGINE_BINDINGS rather than working it out: the installer declares that variable when it copies the folder to bindings\, and a checkout sets it to this repository's own bindings\. The script below is shipped as bindings/BasicTest.py, both in this repository and in the installed folder, so it can be run rather than retyped.

import ctypes, json, os, sys

HOME = os.environ["SLC_ENGINE_HOME"]        # the installer's env task declares this folder
DLL  = os.path.join(HOME, "ScriptLangCore.dll")
sys.path.insert(0, os.environ["SLC_ENGINE_BINDINGS"])   # the three generated files, declared with the env task

import scriptlangcore

lib = scriptlangcore.load(DLL)              # every signature bound from the manifest, once
print(scriptlangcore.ABI_REVISION, lib.slc_abi_revision())     # 5 5
print(scriptlangcore.capabilities(lib.slc_capabilities()))     # ['output', 'call', 'cancel', 'bundles', 'main', 'async', 'adopt', 'com', 'sandbox']
print(scriptlangcore.text(lib.slc_version))                    # ScriptLangCore 1.0.0.0 (NativeAOT), embed ABI 5

session = lib.slc_create(json.dumps({"home": HOME}).encode())  # 1, a handle; 0 would be a refused create

OUTPUT = ctypes.CFUNCTYPE(None, ctypes.c_int64, ctypes.c_int, ctypes.c_char_p, ctypes.c_void_p)
def on_output(handle, channel, text, user): # channel 0 is print, 1 is the engine's own log
    print(channel, text.decode("utf-8", "replace"))
callback = OUTPUT(on_output)                # the module binds this export's SIGNATURE, not its callback
lib.slc_set_output(session, ctypes.cast(callback, ctypes.c_void_p), None)

lib.slc_run_text(session, b'print("hello from python"); int Twice(int n) { return n * 2; }', b"host.slc")
lib.slc_call(session, b"Twice", b"[21]")    # a declaration of the run stays callable
print(scriptlangcore.text(lib.slc_last_result, session))
print(lib.slc_free(session))                # 0

Run as printed on a machine where the installer declared both folders (or with the two variables pointed at a published folder and at this repository's bindings\) it writes the version line and the capability names, then 0 hello from python, then {"Status":0,"Ok":true,"Error":false,"Fatal":false,"Cancelled":false,"Result":42}, then 0. Two facts make the module shorter than the sample above rather than merely different: load() sets every export's types, so a wrong argument count raises TypeError at the call site instead of corrupting the stack, and text() takes only what comes before the buffer, because the manifest states that every output is the last two parameters. What it deliberately does not wrap is the output callback: a function pointer is the host's own object, so the CFUNCTYPE above is still yours, and it must outlive the session.

Python: the SQL door through the sqlite3 module

import os, sqlite3, tempfile
HOME = os.environ["SLC_ENGINE_HOME"]        # the installer's env task declares this folder
con = sqlite3.connect(":memory:")
con.enable_load_extension(True)
con.load_extension(os.path.join(HOME, "ScriptLangCore.dll"))
print(con.execute("select slc_eval_scalar('a0 * 2', ?)", (21,)).fetchone()[0])              # 42
print(con.execute("select slc_eval('a0 + a1', 2, 3)").fetchone()[0])                        # the run's result, as JSON

script = os.path.join(tempfile.gettempdir(), "slc_sql_door.slc")   # any .slc: its declarations join this connection
open(script, "w", encoding="utf-8").write('string Slug(string s) { return s.ToLower().Replace(" ", "-"); }')
print(con.execute("select slc_file(?)", (script,)).fetchone()[0])                            # {"HasError":false,"ExitCode":0}
print(con.execute("select slc_eval_scalar('Slug(a0)', 'Hello World!')").fetchone()[0])       # hello-world!

The sample writes the script it loads instead of naming one: slc_file takes any path, and the corpus under assets\ belongs to the optional samples component, so a path into it would be a sample that runs only on a full install. Once a file has joined the connection, its top-level functions are what an expression can call, which is the Slug above.

The six functions are described in the SQLite section: slc_eval answers the run's result as JSON, slc_eval_scalar answers the bare value, slc_file loads a script file into the connection, slc_agg folds, and slc_map and slc_map_agg take a set at a time.

PowerShell: the ABI through Add-Type

$engine = $env:SLC_ENGINE_HOME               # the installer's env task declares this folder
if (-not $engine) { throw 'SLC_ENGINE_HOME is not set: run the installer, or set it to the folder holding ScriptLangCore.dll' }
$dll = Join-Path $engine 'ScriptLangCore.dll'
Add-Type -TypeDefinition @"
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
public static class Slc {
    public delegate void OutputFn(long handle, int channel, IntPtr text, IntPtr user);
    public static readonly List<string> Lines = new List<string>();
    public static readonly OutputFn Callback = OnOutput;      // a COMPILED delegate, held in a field
    private static string Utf8(IntPtr p) {                    // 5.1 has no Marshal.PtrToStringUTF8
        if (p == IntPtr.Zero) return "";
        var bytes = new List<byte>();
        for (int i = 0; ; i++) { byte b = Marshal.ReadByte(p, i); if (b == 0) break; bytes.Add(b); }
        return Encoding.UTF8.GetString(bytes.ToArray());
    }
    private static void OnOutput(long h, int channel, IntPtr text, IntPtr user) { Lines.Add(channel + "|" + Utf8(text)); }
    [DllImport(@"$dll")] public static extern int  slc_version(byte[] buffer, int capacity);
    [DllImport(@"$dll")] public static extern long slc_create(byte[] options);
    [DllImport(@"$dll")] public static extern int  slc_set_output(long handle, IntPtr callback, IntPtr user);
    [DllImport(@"$dll")] public static extern int  slc_run_text(long handle, byte[] source, byte[] name);
    [DllImport(@"$dll")] public static extern int  slc_call(long handle, byte[] name, byte[] argsJson);
    [DllImport(@"$dll")] public static extern int  slc_last_result(long handle, byte[] buffer, int capacity);
    [DllImport(@"$dll")] public static extern int  slc_free(long handle);
    public static byte[] U8(string s) { return Encoding.UTF8.GetBytes(s + "\0"); }
    public static string Text(byte[] b) { return Utf8(Marshal.UnsafeAddrOfPinnedArrayElement(b, 0)); }
}
"@
$buf = New-Object byte[] 256
[Slc]::slc_version($buf, 256) | Out-Null
[Slc]::Text($buf)                                    # ScriptLangCore 1.0.0.0 (NativeAOT), embed ABI 5

$h = [Slc]::slc_create([Slc]::U8((@{ home = $engine } | ConvertTo-Json -Compress)))   # ConvertTo-Json escapes the backslashes
[Slc]::slc_set_output($h, [Runtime.InteropServices.Marshal]::GetFunctionPointerForDelegate([Slc]::Callback), [IntPtr]::Zero) | Out-Null
[Slc]::slc_run_text($h, [Slc]::U8('print("hello from powershell"); int Twice(int n) { return n * 2; }'), [Slc]::U8('ps.slc')) | Out-Null
[Slc]::slc_call($h, [Slc]::U8('Twice'), [Slc]::U8('[21]')) | Out-Null

$outcome = New-Object byte[] 4096
[Slc]::slc_last_result($h, $outcome, 4096) | Out-Null
[Slc]::Text($outcome)                                # {"Status":0,"Ok":true,...,"Result":42}

[Slc]::slc_free($h) | Out-Null
[Slc]::Lines                                         # 0|hello from powershell
The output callback must be a compiled delegate. A callback cast from a scriptblock kills the process silently at the first print: the shell exits with code 5 and says nothing, so the C# half is not ceremony. And Windows PowerShell 5.1 has no Marshal.PtrToStringUTF8, which is why the sample reads the UTF-8 bytes up to the terminator; on PowerShell 7 that call exists and can be used directly.

bash: the SQL door, because bash has no FFI

SQLITE=/mingw64/bin/sqlite3                     # the x64 build: see the note under this sample
ENGINE="${SLC_ENGINE_HOME:?SLC_ENGINE_HOME is not set: run the installer, or export it as the folder holding ScriptLangCore.dll}"
DLL="$(cygpath -m "$ENGINE")/ScriptLangCore.dll"    # sqlite3 wants a Windows path, and -m gives one with / in it
$SQLITE :memory: ".load '$DLL'" "select slc_eval_scalar('a0 * 2', 21);"
# 42

$SQLITE :memory: \
  ".load '$DLL'" \
  "select 'fold ' || slc_agg('a0 == null ? a1 : a0 + a1', v) from (select 3 as v union all select 4 union all select 5);" \
  "select 'map  ' || slc_map('a0 * 10', '[1,2,3]');"
# fold 12
# map  [10,20,30]

Two shell idioms carry this sample. ${SLC_ENGINE_HOME:?...} stops the run with that sentence when the variable is unset or empty, which is the same fail-fast the Python samples get from os.environ[...], and cygpath -m turns the Windows folder the installer declared into the mixed path form sqlite3.exe accepts, so no separator or space in C:\Program Files\... has to be handled by hand.

Use the 64-bit sqlite3, and name it by its full path. MSYS2 keeps the 32-bit CLI in /mingw32/bin and the 64-bit one in /mingw64/bin, and on a machine where both exist the 32-bit one is often the one on PATH. Loading the DLL through it answers "the specified module could not be found" and then no such function: slc_eval_scalar, which reads like a missing extension. A refused expression exits sqlite3 with status 1 and prints the engine's own sentence, for instance Runtime error near line 2: Cannot convert 'not a number' to number (at line 1, column 4). You do not have to install anything if an official 64-bit sqlite3.exe is already beside the DLL: copying its two files into the deployment folder works, and $SQLITE is then that folder's own sqlite3.exe, reached through cygpath the same way.

Three things a host must say, and what a host cannot do

  1. Where the engine lives. Without it, Application.Path is the HOST's own folder: slc_create({"home": "..."}) naming the engine's folder makes it that one, which is where assets\, tools\ and sqlite3.dll must be found, and the folder to pass is the one SLC_ENGINE_HOME holds. A script that reads a file next to itself, or calls Sqlite, needs this.
  2. That it wants the output. print and the engine's log reach a host only through the slc_set_output callback; with no callback those lines are dropped, which is what the SQL door always does, because a SQL function's answer is its result.
  3. A terminated statement. slc_run_text takes source text, and a statement needs its ;: print("hi") is refused with Expect ';' after expression, the same rule a piped REPL line follows.

A host that loads the DLL also inherits the engine's process-wide facts, the same ones the limits section states for C: one GC and one crash domain, a session per thread, and a run inside a native call that cannot be interrupted. A native named library is looked for beside the executable, so a host whose process is not the engine must load it first: LoadLibrary on the engine folder's sqlite3.dll (the folder SLC_ENGINE_HOME names) in script, or LoadLibrary through the host's own FFI, before Sqlite.OpenDb can succeed.

Driving it from Excel and VBA

64-bit Office calls the engine DLL with nothing in between. On Windows x64 there is one calling convention, so the cdecl-versus-stdcall question that matters on a 32-bit host does not arise, and a PtrSafe declaration reaches the exported functions directly. What a VBA host does need is help with three things the C ABI deliberately leaves to the caller, and that is what assets\language\stdcall\ ships: one standard module, two class modules and a workbook example.

What that puts in reach of a VBA programmer is the engine's whole surface, from a macro or a cell formula: an EXPRESSION evaluated in one call (EngineEval("2 + 2"), and =EngineVersion() as a live worksheet formula), a SCRIPT run as text or as a file with its answer read back as a string (EngineRun, EngineRunFile), a script DEFINED once so its functions can be called from VBA like VBA functions (EngineRun then EngineCall("twice", "[21]"), or StdCallDemo.Define then CallFunction), and the run's VERDICT as an object rather than a sentence to parse (ScriptResult: Ok, Status, Code, Message, Line, Column, ResultText, Frames, Summary), with the session living as long as the module, or as long as the class instance that made it. A script that refuses is a verdict a macro can branch on and report, never a VBA error to trap, and exitcode belongs to that verdict, so a script that sets it is not Ok even when it also returns a value.

32-bit Office cannot load this DLL. The published engine is a PE32+ x64 image and the runtime targets x64 and arm64 only, so a 32-bit Excel has nothing to call, wrapper or not. The module keeps every public name working there and answers one sentence saying so, because the message Windows gives for a 32-bit host ("Bad DLL calling convention", or runtime error 48) sends a reader after the wrong problem.

What the three files are

FileWhat it gives you
StdCall.basThe declarations, the UTF-16 to UTF-8 conversion, one engine session per caller, EngineRun, EngineEval, EngineCall, EngineVerdict, EngineRelease, and RunAll, which is the module's own self-test.
StdCallDemo.clsA class front for a workbook: Define a script once, then RunScript, Evaluate_, CallFunction and LastResult. The session is created when the object is and released when it goes.
ScriptResult.clsThe engine's answer as an object: Ok, Status, Code, Message, Line, Column, ResultText, Frames, Summary, and the raw Json.
StdCallExample.xlsmA workbook with all three imported, a live =EngineVersion() formula, and three macros: DemoRun and RunSelfTest, plus DemoHost, which drives the workbook it lives in through Com.Host().

Import them with File > Import File... in the VBA editor. StdCall.bas is the only required file: the two classes are what make calling code read well, and they depend on the module, never the other way round.

Where the DLL has to be

The module declares Lib "ScriptLangCore.dll" by name, so Windows' own search must find it. The installer's layout answers that: the engine lives in {app}\bin and the installer puts that folder on PATH. To pin a machine instead, replace the name in the ten Declare statements with a full path in double quotes.

A hosted Excel does not inherit the environment of whatever started it, so its search uses the MACHINE's PATH and not the caller's. Measured on a box with the engine installed: a run that had put a freshly published folder first on its own PATH still loaded the installed engine, and the first call to an export that build lacked failed with error 453: cannot find entry point in ScriptLangCore.dll; with no engine on that path at all the answer is error 53. The module answers both with one sentence naming the DLL it could not use and the two ways out, because a bare VBA number sends a reader after the wrong problem. Pin the full path when a workbook must run a particular build, which is what bindings\vba-check.ps1 does with a copy of the module.

The engine resolves its assets\, tools\ and sqlite3.dll beside the DLL, not beside the workbook, and a bare relative path in a script resolves against the process's current directory, which for Excel is its own default folder. Use Script.Directory() inside a script, or an absolute path, when a file belongs to the workbook.

A script that drives the workbook it came from: Com.Host()

The engine cannot reach the Excel instance that hosts it through COM's own cache, and both halves are measured from inside Excel: Com.GetObject("Excel.Application") refuses (the running-object table holds no entry for the instance that loaded the DLL), and Com.CreateObject answers a DIFFERENT instance (a different window handle, 1249132 against the host's 4000928). So the module hands THIS application over and a script reaches it with Com.Host():

EngineRunAsync "return Com.Run((scope) => { " & _
    "var app = Com.Host(); " & _
    "app.ActiveWorkbook.Sheets(""Sheet1"").Range(""A1"").Value = 42; " & _
    "return app.Name; });"
Debug.Print EngineWait(10000)        ' pumps Excel, then answers the verdict
Debug.Print EngineResult             ' "Microsoft Excel"
Debug.Print EngineOk                 ' True

Three things about that shape, each measured rather than assumed. It must be asynchronous: Excel's Application belongs to this thread's apartment, and an STA cannot answer a COM call while it is inside one, so a synchronous EngineRun stalls until the run it belongs to is over; under EngineRunAsync this thread goes back to its message loop (EngineWait pumps with DoEvents) and the script's calls arrive. Com.Host() answers null when nothing was adopted, so a flow can decide without a try, and Com.Host(true) is the strict spelling that names the reason instead. The adoption belongs to the session: the module adopts when it creates the session, EngineAdoptHost adopts again or adopts something else (a Word.Application, for instance), and StdCallDemo adopts in Class_Initialize, so a class created after EngineRelease still answers with this application.

And the engine holds that reference only while a run uses it, which a reader will care about the first time they quit Excel. A COM reference kept between runs stops Excel's own quit from completing: measured, a workbook that adopted the application and then quit left an invisible EXCEL.EXE behind for 25 seconds and still counting, with no window, until something released it; the same workbook after EngineRelease left in half a second, exactly as a session that never adopted does. So the registration lives for the duration of a run (BeginHostUse/EndHostUse in EmbedSession) and an idle engine keeps nothing. A script that stores a Com.Host() value in a global still keeps the application alive through that value, which is ordinary COM semantics rather than a leak: drop the value (or end the session) and it goes. bindings\vba-check.ps1 pins the behaviour in a child process, which is the only honest subject: a client that adopted the application, did its work and died must leave EXCEL nothing to wait for, and the same check against the engine from before this fix reports the leftover process.

The three rules the module exists to keep

RuleWhy
Text crosses as UTF-8, and VBA text is UTF-16.A ByVal ... As String parameter is converted to the system ANSI code page at the boundary, so an accented identifier or string arrives mangled. The module hands over a Byte() of UTF-8 and converts the answer back with MultiByteToWideChar, in one place.
A script ANSWERS with return, not with print.The engine's output callback runs on the session's own thread, which is not VBA's: inside Excel the session reports "Thread":"own","Apartment":"STA","StackMB":32 while VBA runs on its own thread, and a VBA procedure must never be entered from another thread. So the module installs no callback. EngineRun answers the verdict, EngineEval answers the value.
A live worksheet formula is for stateless reads only.Excel calls a user-defined function on its calculation thread, and a session belongs to the thread that created it, so =EngineVersion() is safe while anything that starts a session belongs in a macro.

The whole surface, in one block

' one expression, answered as text
Debug.Print EngineEval("2 + 2")                        ' 4

' declare once, call many times: the session survives between calls
EngineRun "int twice(int n) { return n * 2; }"
Debug.Print EngineCall("twice", "[21]")                ' 42

' a whole script, and its verdict as an object
Dim r As ScriptResult
Set r = New ScriptResult
r.LoadFromJson EngineRun("exitcode = 3; return 7;")
Debug.Print r.Summary                                  ' FAILED  Status=3  Result=7

' the class front, which releases the session when the object goes
Dim engine As StdCallDemo, outcome As ScriptResult
Set engine = New StdCallDemo
engine.Define "int twice(int n) { return n * 2; }"
Debug.Print engine.CallFunction("twice", "[21]")       ' 42
Set outcome = engine.LastResult
If Not outcome.Ok Then MsgBox outcome.Summary
Set engine = Nothing

A refusal is never raised as a VBA error: it arrives as Code, Message, Line and Column, which is what a macro reports to a user. And exitcode IS the run's verdict in this language, so a script that sets it is not Ok even when it also returns a value.

Checking it

powershell -NoProfile -ExecutionPolicy Bypass -File bindings\vba-check.ps1         # assert, in real Excel
powershell -NoProfile -ExecutionPolicy Bypass -File bindings\vba-check.ps1 -Build  # also rewrite the example workbook

The check imports the three files into a throwaway workbook, runs the module's self-test and both class probes, prints one line per assertion, and exits 0 when every line says ok:, 1 on a failure, and 2 when it cannot run at all (no engine DLL, or no Excel). assets/language/repl/com/vba_layer_test.slc runs it through the engine's own gate. Two Windows settings the check needs and a user does not: Trust access to the VBA project object model, which lets it import the modules, and macros enabled, which the example needs.

A module file is CRLF. The VBA editor's importer rejects a class module whose lines end in a bare LF, and it reports that only as "the macro cannot be run", with no line number. The files in assets\language\stdcall\ are CRLF for that reason, and an editor that rewrites line endings will break them silently.

The DLL as a COM server: CreateObject("ScriptLangCore.Session")

The same ScriptLangCore.dll is also an in-proc automation server. A host that cannot declare the C ABI (VBA, VBScript, any automation client) reaches the engine through one ProgID, the way it reaches Excel:

Dim engine As Object
Set engine = CreateObject("ScriptLangCore.Session")
MsgBox engine.RunText("print(2 + 2);")     ' 4: what the snippet printed
MsgBox engine.LastResult                   ' {"Status":0,"Ok":true,...}: the engine's verdict
Set engine = Nothing                       ' tears the session down

Nothing else is needed at the call site: no Declare PtrSafe, no UTF-8 buffer, no handle. That is the point of this door, and it is also its limit, stated below.

The seven members

MemberWhat it does
Version()The engine's own line, with the runtime and the embed ABI revision.
RunText(source [, name])Runs a snippet in the session's interpreter. Answers what the snippet printed, or the verdict JSON when it printed nothing. name is optional and only labels where the code came from.
RunFile(path)The same for a file. A .slcbc bundle is refused by name, exactly as the C ABI refuses it.
LastResult()The verdict of the last run as JSON: Status, Ok, Error, Code, Message, Line and, when the run returned a value, Result.
LastError()The sentence for the last failure, empty after a success.
Eval(name, arg1, ...)Calls a script function an earlier run declared, with ordinary VARIANT arguments, and answers its return value.
Free()Tears the session down. A later call answers that it has been freed rather than crashing: one object holds one session.

Registering it

Two doors, one identity: the ProgID ScriptLangCore.Session and the CLSID {4A9D69C4-D9E6-4914-B7DF-ABC442BF5096}.

InprocServer32 names the shared library the installer already ships ({app}\bin\ScriptLangCore.dll), so this adds no file. It also carries ThreadingModel=Apartment, which is what Office's STA wants.

What it does not do, stated plainly

How it is built, and what proves it

The server lives in src\Host\ComServer.cs and is compiled only into the Embed configuration (the one that publishes the shared library), behind the SLC_EMBED_COM constant: the exe legs keep exactly the code they had. Its members forward to the same bodies the C ABI uses (EmbedApi.RunTextBody, RunFileBody, CallBody) and the same session object, so a snippet cannot behave differently through the two doors.

Two checks hold it: the embed-check harness loads the published DLL, walks DllGetClassObject, the factory and the IDispatch vtable by hand, and asserts the members' answers without any registry; and src/build/check-com-registration.sh compares the ProgID and CLSID across the server, the harness, the .reg and the installer, and fails if one of them drifts.

The reader's own copy of this door ships beside the module: assets\language\stdcall\ComEngine.bas is the seven members, late bound, and ComCallExample.xlsm in the same folder is a workbook that carries it, with two buttons on its ComEngine sheet that run ComDemoRun and the module's own RunComSelfTest. bindings\vba-com-check.ps1 -Build regenerates the workbook from the shipped module, and -Verify proves the saved copy against a registration it writes and removes itself.

The session runs on the engine's own thread, not the caller's. That is a measurement, not a preference: a session pinned to the calling thread needs 16 MB of stack, Excel's main thread offers 1 MB, and EmbedSession.Create refuses the pin by name. The engine therefore owns a 32 MB thread and the COM call waits for it, which is what a synchronous STA verb means anyway.

Limits, stated plainly

There is no unloading

A loaded library stays loaded, and the AOT runtime inside it has threads of its own, so FreeLibrary after slc_free is not a supported teardown: the runtime would keep pointing at code the loader took away. Release sessions, keep the library, and let it live as long as the process does.

A run inside a native call cannot be interrupted

slc_cancel stops a script at its next boundary: the next statement, the next loop back-edge, or the next chunk of a sleep. A run blocked inside a native call (a blocking read, a COM call that never returns, a native busy loop) keeps running, and slc_free answers 3 with the reason instead of pretending. There is no thread abort, because a thread cannot be aborted safely on this platform. If untrusted scripts must be killable, run them in a CHILD PROCESS: that is what the CLI is, and what the clipboard tool's Kill button depends on.

One GC and one crash domain

The engine shares your process, so a native fault inside it is a fault in your process: a LoadLibrary of a broken native, a corrupted sqlite3.dll, or a bug in the interpreter ends the host, not just the run. A script that recurses through the tree walker is caught by the engine's depth guard, which answers Maximum call depth exceeded (3000), possible runaway recursion. as a status-1 refusal with the host still alive, but a fault in native code cannot be contained this way.

Process statics are shared by every session

These live on the process, so two sessions in one process share them: the MCP tool registry, the form registry behind Application.FindForm and Application.SingleInstance, the main-thread task queue, the Diagnostics gates, the clipboard listener, global hotkeys. Two sessions are two interpreters, not two processes.

The host's identity leaks into a few answers

Environment.ProcessPath is the host, so a script that respawns itself with it respawns the host. Application.FilePath follows image (the host's own image by default), and Application.Path follows home. The run's own directory comes from the file being run, so ./x still means "beside the script".

One call at a time, and a pinned session is single-threaded

A second verb while one is in flight is refused by name. A session opened with "thread":"host" can only be driven from that thread; every other thread's call is refused with the reason. slc_cancel is the one verb that works from anywhere, because a token is a signal.

Windows only, and AOT only

The exports come from a NativeAOT image; a JIT build cannot produce them (the attribute is honoured only by the AOT compiler), so the Debug configuration is for the engine's own suites and not for embedding. The whole surface this engine exposes (UI, COM, hotkeys) is Windows, and compile_aot.bat publishes win-x64.

Verifying an artifact

bash src/build/embed-check.sh builds a small host (src/embed/, no reference to the engine), loads the published DLL and drives the ABI: the thread modes, the output callback, a run's declarations staying callable, a compiled bundle in both shapes, a runaway recursion coming back as a refusal, a cancel from another thread landing inside sleep, slc_free refusing while a run is in flight, and create/free cycles that leak no handles. It refuses a DLL older than src/ rather than measuring it, prints the version and ABI revision it loaded, and exits 0 only when every check passed.

Troubleshooting

slc_create answered 0

The reason is in slc_last_error(0, ...), since there is no handle yet. The three shapes it names: an option it does not know (a typo), a thread value that is not auto, own or host, and a "thread":"host" request on a thread that cannot carry a run (it names the apartment and the stack it found).

Nothing a script prints appears

No output callback is registered, and with none the output is DROPPED rather than written to the host's console. Call slc_set_output after slc_create and before the run.

The run answered 2

The call itself was wrong, not the script: a path that does not exist (slc_last_error names the resolved path), an empty name for slc_call, arguments that are not a JSON array, a session opened as "thread":"host" being driven from another thread, or a compiled bundle handed to slc_run_file, which says so and names slc_run_bundle.

slc_free answered 3

A run is in flight. A free cannot abort a thread, so it refuses and leaves the handle valid: wait for the run, or cancel it and then free. Freeing twice, or freeing a handle that was never handed out, answers 2.

A script that used to be deep now refuses

Read slc_stats: stackMB is the budget the depth guard measures against, and thread says whether the session is on your thread (host) or its own (own). A host thread with a small stack is the usual cause; "thread":"own" or "auto" fixes it.

COM refuses with "requires an STA thread"

That message means the session is on an MTA thread, which happens only when you asked for "thread":"host" on one, or made the session from a thread that later changed its apartment (which COM does not allow). Let the engine own the thread.