Embedding
lyrembed.dll lets a C# host compile and run Lyric. Reference it and create a VM:
using Lyric.Embedding;
var vm = new LangVm(new HostOptions
{
StdlibRoot = "stdlib",
Capabilities = Capability.None,
});
HostOptions decides what scripts may reach: the standard library location, the granted
capabilities, and where their output goes. A module that requires a capability it was not granted
is rejected at load time, before any instruction runs.
Compiling and running
var module = vm.Compile(source, "game");
var exitCode = vm.Run(module);
Compile takes the source and the module name. The name is not optional; it is what the script's
own declarations are qualified with.
To call individual functions instead of running a main, create an instance:
var instance = vm.Instantiate(module);
instance.CallVoid("onStart");
var next = instance.Call<long>("onUpdate", 16L);
An instance holds the globals. Two instances of the same module do not share state. Instantiate
is what a host uses for scripts that have no entry point at all — the common case for embedded
code.
Reloading
var reloaded = instance.Reload();
Reload() produces a fresh instance from the same module with its globals initialized again. The
old instance stays valid; nothing about it changes.
Registering functions
vm.RegisterFunction("playSound", (string name) => audio.Play(name));
vm.RegisterFunction("random", (long limit) => rng.NextInt64(limit));
A script reaches them through the host module:
import host { playSound, random };
fn main(): int {
playSound("hit");
return random(6) as int;
}
There is no implicit namespace: without the import the names are unknown.
An SDK of your own
RegisterFunction generates the declaration from the delegate, which is right for a handful of
functions. For an engine with a hundred of them the signature ends up in two places: in the C# call
and in whatever documents the API.
A native root is a directory whose modules may declare functions without a body. The declarations
are ordinary .lyr files you ship and version:
// sdk/engine/input.lyr
module engine.input;
pub fn keyDown(key: int): bool;
pub fn anyKey(): bool { return keyDown(32) || keyDown(27); }
var vm = new LangVm(new HostOptions
{
NativeRoots = new Dictionary<string, string> { ["engine"] = "sdk" },
});
vm.RegisterNative("engine.input.keyDown", (long key) => input.IsDown(key));
import engine.input { anyKey };
fn main(): int { return if (anyKey()) 1 else 0; }
Three things follow from how it is keyed:
- The root decides, not the file. The same file outside a native root is a missing body and an error. Whether a module may reach into the host follows where it came from, so naming a file well enough is not a way in.
- The segment belongs to the root.
engineis taken out of the program's own directory, so which file answers an import is never a question of precedence. - A declaration needs an implementation under the same qualified name.
RegisterNativewrites no declaration — that is the file's job — and a declaration nobody implements fails when the script is instantiated, not at the call site.
A module in a native root may hold ordinary Lyric code beside its declarations; anyKey above is
compiled like any other function.
Value types across the boundary
A native signature may use a struct an SDK module declares, with scalar and string fields only.
Since v2.5 it may be a struct of another module of the SDK — a vector is declared once and
named wherever a native takes or returns one, imported selectively or written module-qualified.
The declaration stays fully typed on the script side; on the wire the struct is flattened:
// sdk/engine/geo.lyr
module engine.geo;
pub struct Vec2 { x: float, y: float }
pub fn setPosition(entity: int, at: Vec2);
pub fn positionOf(entity: int): Vec2;
A struct parameter crosses as its fields. The host registers exactly the delegate it would
have written for scalar parameters — setPosition above binds against (long, double, double):
vm.RegisterNative("engine.geo.setPosition",
(long entity, double x, double y) => world.SetPosition(entity, x, y));
A struct return comes back through a buffer the runtime owns: the implementation receives
the ordinary arguments plus the buffer's slots and fills one value per field, in field order.
That is the NativeRegistry surface a game host uses:
natives.RegisterStructReturning("engine.geo.positionOf",
[TypeTag.I64], [TypeTag.F64, TypeTag.F64],
(args, result) =>
{
var p = world.PositionOf(args[0].AsI64);
result[0] = LyrValue.FromF64(p.X);
result[1] = LyrValue.FromF64(p.Y);
});
On the script side nothing special happens — let p = positionOf(e); binds an ordinary value
with value semantics, and mutating p afterwards changes nothing the host sees. The point of
the arrangement is what it costs: nothing allocates. A Vec2 built fresh and passed in, or
received back in a loop of a hundred thousand iterations, measures 0 bytes per call — the fields
travel as scalars, and the result buffer exists once per program.
Registration checks the layout at load time: a host that fills three fields against a struct the SDK declares with two is rejected with the import's name in the message, before any instruction runs. That check is what makes the module boundary irrelevant here — what crosses is a layout, and the host is held to it whichever file wrote the declaration down.
Bounding what a script may spend
A capability decides what a script may REACH. It says nothing about how long it may run, and
while (true) { } needs no capability at all. For code you did not write — a mod, something
downloaded — hand in a budget:
var budget = new ExecutionBudget(2_000_000);
try
{
instance.CallVoid("onUpdate", budget, 0.016);
}
catch (ScriptBudgetException)
{
// still working when the budget ran out
mods.Disable(instance);
}
The budget counts instructions, not milliseconds, and that is the point rather than a
shortcut: the same script under the same limit stops at the same instruction on every machine and
in every run, so a replay stays a replay. Read budget.Consumed after a call that fits to find
out what your own workload actually costs — there is no other way to arrive at a number worth
setting.
One object, several calls: Reset() refills it, and passing one budget to several calls makes
them share it, which is how a host bounds a whole frame rather than a single script. A host
function that calls back into the script draws from whichever budget its own call was given.
Instantiate takes one too, and for foreign code it should:
var instance = vm.Instantiate(module, new ExecutionBudget(10_000_000));
The module's constant initializer runs there, before the host has called anything — a
module-level let x = spin(); would otherwise hang the load itself.
Three things worth knowing before you rely on it:
- The stop is not catchable by the script. It arrives as a panic, so no
catchinside the program sees it and nodeferruns afterwards. A stop a script could catch is one it could sit out. ScriptBudgetExceptionis aScriptPanicException, so a host that already catches panics keeps catching this — but the separate type is what lets you tell "this script is broken" from "this script was still working".- A budget bounds bytecode, not host time. A native of yours that blocks for a second blocks for a second; the budget charges it one instruction. What it bounds is the script's own loops and calls, which is where a mod runs away.
An instance whose call was stopped is left mid-computation: its globals hold whatever the interrupted code had written. Treat it the way you would treat one that panicked.
Running scripts compiled
Scripts are interpreted by default. A host that wants the hot paths as machine code sets one option:
var vm = new LangVm(new HostOptions { Capabilities = Capability.None, Compile = true });
Each function is then compiled to IL the first time it is called, and the runtime's own compiler
turns that into machine code. A loop over floats stops being limited by the VM and starts being
limited by the CPU.
Off by default, and that is the useful default. Compiled code has no instruction boundaries: a debugger cannot stop inside it, and a budget cannot count it. So the shape this serves is develop on the interpreter — where breakpoints, stepping and hot reload all work — and ship with this on.
A metered call is never compiled. A call carrying an ExecutionBudget, and any call under a
debugger, stays interpreted even with Compile = true. That is not a limitation to work around;
it is what makes the option safe to set for a whole VM:
var vm = new LangVm(new HostOptions { Compile = true });
instance.CallVoid("onUpdate", 0.016); // compiled — your own code, your own frame
instance.CallVoid("modUpdate", budget, 0.016); // interpreted — counted to the instruction
The budget's promise is the reason: it counts instructions so that the same script under the same limit stops at the same instruction on every machine. Compiled code cannot make that promise, so it is not used where the promise was made.
Refusal is normal. Compilation is per function, and what the compiler does not understand it declines — the interpreter keeps that function, which costs speed and never correctness. What is compiled today: arithmetic, comparisons, branches, locals, globals, arrays, fields, optionals, interface values, object construction, string constants and comparison, and calls — to a native, or to another function that compiles. Still declined: closures, exceptions, enums, recursion, and the narrow integer widths, which need re-normalising after every operation.
Two properties say what happened, and they are worth reading after a few seconds of real work rather than at startup — a function is compiled when it is first called:
Console.WriteLine($"{instance.CompiledFunctions} compiled");
foreach (var (function, reason) in instance.Refusals)
Console.WriteLine($" {function}: {reason}");
The reasons are short phrases (enum, closure, call arity) rather than sentences, because
what a host wants from them is a histogram: which construct stands between it and a compiled hot
path.
Ahead-of-time publishing and this are alternatives, not a pair. Compiling a script at run time
needs a runtime that can emit IL, and a NativeAOT build has none. There Compile = true is
ignored — every function is declined with no runtime code generation, every script is
interpreted, and nothing else about the host changes.
The other half of fast
Compile decides how a script RUNS; Profile decides how it is COMPILED, and the two defaults
agree. A VM compiles the debug profile unless told otherwise (chapter
16): no inlining, no scalar replacement, every frame and every
name kept, the shape a backtrace and a debugger can read. A host that ships sets both:
var vm = new LangVm(new HostOptions
{
Capabilities = Capability.None,
Profile = Profile.Release, // optimized bytecode, slot names dropped, source map kept
Compile = true, // and machine code for what the emitter accepts
});
Profile left unset takes the process default, which is debug unless LYRIC_PROFILE names
release. Struct-heavy loops run about two and a half times slower in the debug profile on the
interpreter; a host whose scripts are mostly glue will not notice, a host whose scripts do
vector mathematics per frame will, and that host is the one shipping with Compile anyway.
Registering types
RegisterType exposes a C# class to scripts. Scripts receive such an object and pass it on; they
cannot construct one and cannot read its fields.
vm.RegisterType<Player>("Player", t => t
.Getter("name", (Player p) => p.Name)
.Getter("health", (Player p) => p.Health)
.Method("damage", (Player p, long amount) => p.Damage(amount), mutates: true));
vm.RegisterFunction("hero", () => world.Hero);
On the script side a host value looks like any other:
import host { hero, playSound };
fn main(): int {
let player = hero();
if (player.health() > 0) {
player.damage(10);
playSound("ouch");
}
return player.health() as int;
}
A host member is read as a call — player.name(), not player.name. A host type has no field
layout the script could index into, so every access is a method.
The object travels; it is not copied. The .NET garbage collector keeps it alive as long as a Lyric value can reach it. There is no release or revocation protocol.
Attributes: what a script says about itself
An attribute is a struct; where it may sit is the marker interface it declares — OnModule,
OnType or OnFunction, all from std.core. An SDK declares the vocabulary, a script uses it,
and the host reads the result:
import std.core { OnModule, OnType, OnFunction };
pub struct Plugin :: [OnModule] { name: string, api: int }
pub struct Component :: [OnType] { }
pub struct System :: [OnFunction] { order: int = 0 }
@Component
pub struct Health { value: int, max: int }
@System { order = 10 }
pub fn damageTick(dt: float): void { }
The arguments are literals, and a field the script does not write carries its default, so a row is always complete. An attribute describes; it does nothing: a runtime that ignores the rows runs the program unchanged, and no attribute in this vocabulary means anything to the compiler.
On the host side the rows hang off the compiled module and off an instance, joined and ready to ask:
var module = vm.Compile(source, "game");
foreach (var plugin in module.Attributes.OnModule)
Console.WriteLine($"{plugin.Value("name")?.Text} wants API {plugin.Value("api")?.AsInt}");
var instance = vm.Instantiate(module);
foreach (var system in instance.Attributes.OnFunctions("System"))
instance.CallVoid(system, 0.016); // the use carries the function index; nothing is
// resolved by name again
Three details carry the weight:
module.Attributesworks beforeInstantiate. For foreign bytes — mods, downloaded scripts — the module row is how a host decides whether to load at all.- A hit is a handle.
CallVoid(use, …)calls by the index the row carries, so the per-frame path stays the raw one. A typo in the script is now a compile error (unknown type), not a function nobody finds. - An attributed type reports its shape.
module.Attributes.FieldsOf(use.Target)yields the field names and types ofHealth— the bytecode carries field names exactly for types an attribute references, and for nothing else.
Attribute names are unqualified: System, not engine.ecs.System. An SDK owns its attribute
names the way it owns its native names.
A field that is a handle
A field carries one more answer, and it exists for a decision a host has to make rather than for
tidiness. An opaque type Entity = int is a distinct type in the script and an i64 in the
module, so a save writer walking the fields of an attributed class sees a handle and a level
number as the same thing — and a handle is exactly what must not be written down, because the
slot it names belongs to something else after a restart.
foreach (var field in module.Attributes.FieldsOf(saved.Target)!)
if (field.OpaqueName is { } opaque)
throw new InvalidOperationException(
$"{field.Name} is a '{opaque}' handle and does not survive a restart");
OpaqueName is null for every ordinary field, and for every module a compiler before 2.11
wrote. It is the LEAF name: a field of type Entity[] answers Entity, and field.Type still
says it is an array. What the host does with the answer is its own business — the language keeps
refusing to say what an attribute means.
A VM owns what its scripts open
Since 4.3 LangVm is IDisposable, and disposing it closes every socket, child process and file
its scripts left open:
using var vm = new LangVm(new HostOptions
{
Capabilities = Capability.FileAccess | Capability.OsAccess,
});
var instance = vm.Instantiate(vm.Compile(source, "mod"));
instance.CallVoid("run", new ExecutionBudget(1_000_000));
// leaving the block closes whatever the script still held
A script has no obligation to close what it opens. It may be stopped by a budget, it may
panic, or it may simply return with a handle in hand — and none of those run its close. Before
4.3 those handles were held per THREAD, so neither dropping the VM nor collecting it released
them: a host that stopped an untrusted script was left holding its resources, and on Windows a
file the script had opened stayed locked. That was the half of the sandbox a host could not work
around.
Two things follow, and the second is a real limit rather than a detail:
A child process is DISOWNED, not killed. That is what std.process.close does by hand, and
releasing a handle automatically must not mean more than releasing it by hand. If a host needs
the child gone, the script kills it, or the host holds the pid itself.
There is no finalizer. A VM that is merely dropped keeps its handles until the process ends.
The alternative would be a finalizer running on the finalizer thread against Socket,
FileStream and Process objects that have their own, in an order nobody controls — a half
answer that hides the case instead of closing it. So: dispose a VM whose scripts touch the
outside world.
Two VMs never share a descriptor, on one thread or on several. The tables belong to the VM, so which thread opened a handle does not decide who owns it, and disposing one VM cannot touch another's.
A disposed VM still computes, but opens nothing. Calling into one is not an error: pure code
runs as it did. What it cannot do is acquire a file, a socket or a child process — those answer
the ordinary "could not" of the operation, and a script handles it like any other I/O failure.
The rule exists so that a call after Dispose cannot strand handles the next Dispose would
skip.
Dispose may be called while a script is still running, from another thread, and for one
case it is the only thing that works: a guest parked in a wait is out of reach of an
ExecutionBudget, because a budget is counted on the guest's own thread and a parked guest is
not executing. So a host that has to take a hung script down does it from outside.
It gives the thread back. A task can be waiting in exactly three ways, and disposing ends all
three: a descriptor wait, because Dispose closes the descriptor under it; an interrupt
wait — the shutdown shape, which holds no descriptor at all — because a disposed VM answers it the
way an interrupt does, so tasks parked on Wait.Interrupt wake and the run drains; and a
deadline, because the sleep blocks on the VM's own signal rather than on the clock. A guest
that asked to sleep ten minutes does not cost the host ten minutes.
A guest in the middle of an I/O call sees that call fail the ordinary way, and nothing crosses the
boundary that is not a ScriptException. A run that then keeps asking to wait — a task that parks
on the interrupt again in a loop — is told once and panics on the next ask: there is nothing left
that could answer it.
Errors
A script that fails throws on the host side:
| Exception | Cause |
|---|---|
ScriptException |
compilation or a runtime error inside the script |
ScriptPanicException |
the script panicked |
EmbeddingException |
the host used the API wrongly — an unknown function, a signature mismatch |
These are declared in Lyric.Embedding; a host does not reference the runtime assembly to catch
them — which is also why each of them carries what a host would otherwise have gone looking for.
An EmbeddingException carries the diagnostics with their place already resolved:
catch (EmbeddingException failed)
{
foreach (var diagnostic in failed.Diagnostics)
Console.Error.WriteLine(diagnostic); // src/held.lyr:129:15: error[LYR-SEM0002]: …
}
File, Line and Column stand on the diagnostic, and on every note under it. The resolution
happens at the throw: a compiler span is an index into the compilation's source manager, and that
manager is gone by the time you catch anything — which is why a code and a message used to be all
that arrived. A diagnostic about the compilation rather than about a position in it has
File == null and line 0. A module compiled from memory is named by the name you passed to
Compile; one compiled from disk carries its path, and an error one import away carries THAT
file's path rather than the entry's.
A ScriptPanicException carries the Lyric call stack the same way:
catch (ScriptPanicException panic)
{
Console.Error.WriteLine(panic.Message);
foreach (var frame in panic.Backtrace)
Console.Error.WriteLine($" in {frame}"); // update (game:7)
}
The frames are innermost first and name their line while the module carries a source map, which it
does unless it was built with --no-source-map.