vm
import io from "io";
import vm from "vm";
import screen from "./screen"; // an ordinary module of your program
@tape { vm.host(screen, "SCREEN"); } // what scripts get: `import screen from host;`
const TERMINAL: vm.Profile = vm.Profile { allow_loops: true; allow_alloc: true; };
pub fn main() -> i32 {
let hosts: [1]vm.HostModule = [SCREEN];
let ctx: vm.Context = vm.context("terminal", vm.ContextSpec {
profile: TERMINAL;
host: hosts;
.. // no stdlib, no files
}) or { return 1; };
var inst: vm.Instance = vm.create(&ctx, vm.Options { user: 7; .. });
let r: vm.CompileResult = inst.load_source(source, "player.tape");
if (!r.ok) {
for (e in r.errors) { io.println("{e.file}:{e.line}:{e.col}: {e.message}"); }
return 1;
}
inst.start("main") or { return 1; };
var going: bool = true;
while (going) {
match (inst.run(2000)) { // 2 ms of script, then back to you
vm.Step.Running => { /* draw a frame, then run again */ }
vm.Step.Waiting => { /* a host function parked it: run when there is news */ }
vm.Step.Finished { code } => { going = false; }
vm.Step.Crashed { err } => { io.println("{err.message} at {err.file}:{err.line}"); going = false; }
vm.Step.Idle => { going = false; }
}
}
inst.destroy();
return 0;
}vm embeds tape’s VM in a tape program, “the game” below, so that players
and content authors can script things in tape: an in-world computer running
what the player typed, a mod, an NPC’s behaviour. The game decides what each
kind of script may do and see, runs each one a slice of time at a time, and
gets every error back as data to show in its own UI.
The VM is the compiler’s own, shipped as a library beside the program
(tapevm100.dll / .so / .dylib, which tape build copies next to a
program that imports vm). Scripts are always interpreted; the game itself
runs natively or under tape run, the same either way.
programs/embed/terminal.tape in tape’s repository is a complete example:
four in-game terminals running player scripts, one waiting for a message,
one sending it, one dividing by zero, one that does not compile.
Contexts
A context is the sandbox: what a script’s source may contain, which of your modules it can call, which stdlib modules it may import. It exposes nothing it does not name.
pub fn context(name: string, spec: ContextSpec) -> Context or ContextError;
pub struct ContextSpec {
profile: Profile;
host: []const HostModule; // modules scripts import `from host`
modules: []const string = []; // stdlib modules by name; none unless listed
stdlib_path: string = ""; // where their sources are (you ship them)
search_paths: []const string = []; // directories for `import x from "x"`
allow_relative: bool = false; // `import x from "./x"` among them
parent: ?*const Context = null; // a child never exceeds its parent
}A context is immutable once made: any number of instances use it, on any
threads. Errors: ExceedsParent (a flag or limit above the parent’s),
DuplicateHost, NoStdlibPath (modules listed without a path), NotFound
(a path that does not exist), Threads (allow_threads is not available
in this release), BadHost (a host module’s interface did not compile;
never from vm.host’s own output).
Profile
Every flag is false and every limit 0 (no limit) unless you set it: a profile allows what it names.
| Flag | Allows |
|---|---|
allow_struct | struct, tagged union and union declarations |
allow_enum | enum declarations |
allow_loops | while, for |
allow_recursion | a cycle of calls among the script’s functions |
allow_alloc | anything that allocates: building strings (concatenation, interpolation), heap memory; off, mem and collections cannot be imported either |
allow_defer | defer, errdefer |
allow_regions | any region but #code (#view, #test, …); a component in #code needs no flag |
allow_comptime | @tape, @emit, @embed; not a component’s @tape { engine.scriptable(); }, which only calls the game’s generators |
allow_raw_ptr | pointer types, &x |
allow_inline_asm | @asm, #bytecode |
allow_dll_import | extern fn, @link |
| Limit | Ends the run when |
|---|---|
max_memory | the script’s heap, globals and frames pass this many bytes (MemLimit) |
max_call_depth | more calls are live than this (StackOverflow) |
max_iterations | loops have gone round this many times in the run (IterLimit) |
max_instructions | the run has executed this many VM instructions (InstrLimit) |
max_string_len | a string longer than this would be made (StringLimit) |
A refused construct is a compile error at its position:
defer is not available in this context, struct declarations are not available in this context. An import the context does not expose is
module "io" is not available in this context. A limit costs nothing when
it is 0.
Host modules
@tape { vm.host(screen, "SCREEN"); }
@tape { vm.host(system, "SYSTEM", ["uptime", "halt"]); } // two functions onlyvm.host(m, name, only) turns one of your modules into something scripts
can import: it makes a module-level let named name of type
vm.HostModule, holding the module’s interface (its pub functions, or the
ones in only, and its pub types) and an entry for each function. A script
writes import screen from host; and calls screen.print("hi"); the call
runs your screen.print, in your program, with your memory. The functions
not exposed do not exist for the script: no entry, no declaration, no name.
Inside a host function, vm.caller() is the user value of the instance
whose script called it (one module serves many terminals), and vm.park()
asks the VM to stop that script when the function returns: run returns
Waiting and the next run continues right after the call. A script
polling an empty inbox that you park costs nothing until you run it again.
Checking scripts in the editor
Outside your game, import screen from host; names nothing: tape check
and the editor don’t know your game’s host modules. Tell them with
@host, the module of your game that calls vm.host, relative to the
script:
@host("../game/scripting.tape");
import screen from host;Then a check compiles that module and checks the script against the host
modules it makes, as your game will compile it: hover, completion and
errors for screen.print work. @host is only for checking; at run time
your context decides what from host means. Without @host, or when it
can’t tell what screen is, the import is left unchecked (no errors for
screen.anything), and the rest of the script is still checked.
What crosses
A host function’s parameters and result may be: bool, integers, floats,
color, enums, string, and structs, tagged unions and fixed arrays made
of those, nested to any depth; ?T of any of them; and as a result T or E, E an
enum or a tagged union (T or E is only ever a result). A string is copied
both ways wherever it sits, in a struct field, an array element or a
variant’s payload: your function gets its own copy, and the script gets its
own copy of what you return; neither side ever holds the other’s memory.
Pointers, slices, function pointers and components never cross: vm.host
refuses a function that uses one at compile time, naming the function and
the parameter.
A fixed array crosses as its elements, every one, so keep large buffers in
a string or behind your own functions. A type from another module in an
exposed signature (geo.tint(c: paint.Color)) must be one your module
imports too (import paint from "./paint.tape";), or vm.host says so.
The script can hold and use such a value without naming its type; it
names it, and calls its module’s functions, when you expose that module as
well, and then it is one type for the script whichever module handed it
over.
A trap in a host function (your bug) ends the calling script’s run as
Crashed with kind HostTrap, the function’s name and the trap in the
message; your run call returns normally and your program goes on.
Instances
pub fn create(ctx: *const Context, opts: Options) -> Instance;
pub struct Options {
user: i64 = 0; // vm.caller() and inst.user()
allocator: ?Allocator = null; // every byte of the instance; the heap when null
sources: ?fn(i64, []const u8) -> ?string = null;
// a virtual disk: (user, module name) -> source or null
}
pub fn Instance.load_source(*self, source: string, name: string) -> CompileResult;
pub fn Instance.start(*self, entry: string) -> void or StartError;
pub fn Instance.run(*self, budget_us: i64) -> Step;
pub fn Instance.reset(*self);
pub fn Instance.user(*const self) -> i64;
pub fn Instance.destroy(*self);An instance holds one script and its state, and is used by one thread at a
time. load_source compiles a program in the instance’s context; failure
leaves the previous program loaded. Instances of one context that load the
same text share one compiled program and compile it once; each keeps its
own globals, heap and frames. start begins a run of entry (fn main()
or fn main() -> i32) from a fresh state. run runs for at most
budget_us microseconds (0: to the end) and returns:
| Step | Means |
|---|---|
Running | the budget is spent; call run again (next frame) |
Waiting | a host function parked the script |
Finished { code } | the entry returned (code 0 for fn main()) |
Crashed { err } | a trap, a limit or a host trap ended the run |
Idle | nothing is running |
The budget is checked at loop back-edges and calls, so a run may go past it
by a few hundred instructions, plus a host call, which is never interrupted.
reset ends the run and frees all of its memory; the program stays
loaded. destroy frees everything.
The virtual disk (sources) answers a script’s import lib from "lib"
before the context’s modules: return the module’s source, or null for
“not here”. Its text is compiled under the script’s profile.
Errors as data
pub struct CompileError { kind: ErrorKind; message: string; file: string; line: i64; col: i64; len: i64; }
pub enum ErrorKind : u8 { Parse = 1; Type = 2; Profile = 3; Import = 4; }
pub struct RuntimeError {
kind: RuntimeErrorKind; // Trap, IterLimit, InstrLimit, MemLimit, StackOverflow,
// StringLimit, HostTrap
message: string; // "division by zero"
file: string;
line: i64;
col: i64;
call_stack: []StackFrame; // innermost first: function_name, file, line
}Nothing is printed: the messages are the compiler’s and the VM’s own,
sorted by position, for you to show. CompileResult.errors and
RuntimeError.call_stack are views of the instance’s storage, valid until
its next load_source, start or run.
Memory and threads
With an Allocator, every block of the instance’s runs (its globals, heap,
strings and frames) comes from your alloc and goes back through your
free; an allocator that returns null ends the run as MemLimit would.
A new start, reset and destroy give back everything the run held.
Different instances may run on different threads at the same time; one
instance is used by one thread at a time. Host functions run on the thread
that called run, so whatever they share is yours to guard.
Script components
A script may declare components that your game drives: you spawn them by
name, call into them through typed entry points, and save and restore them
as records. programs/embed/scenario/ shows all of it: a scripted in-world
computer, saved in the middle of its behaviour and resumed after a load.
The dispatch interface
// engine.tape, a host module of the game
pub @tape fn scriptable() {
@emit @dispatch fn on_spawn(id: i64);
@emit @dispatch fn on_tick(dt: f32);
}A host module declares what the game calls on a scripted object as a pub
@tape fn that emits @dispatch declarations. Expose the module with
vm.host as any other. A script’s component opts in by calling the
generator, and implements the handlers it wants as plain functions of those
names; one it leaves out is a no-op:
import engine from host;
component Computer {
@tape { engine.scriptable(); } // on_spawn, on_tick
var id: i64 = 0;
fn on_spawn(new_id: i64) { id = new_id; }
fn on_tick(dt: f32) { ... }
}That @tape block needs no allow_comptime: it only calls the game’s
generators, which is the game’s code running, as a host call is. A
component declared in #code needs no allow_regions either.
In the game, @tape { vm.gen_dispatch(engine); } emits one function per
dispatch the generator declares:
fn on_spawn(inst: *vm.Instance, h: vm.Handle, id: i64) -> void or vm.DispatchError;
fn on_tick(inst: *vm.Instance, h: vm.Handle, dt: f32) -> void or vm.DispatchError;A dispatch’s parameters and result may be anything a host call carries
(§What crosses), strings copied both ways; a generator’s own types (a
Vec2 it declares) are engine.Vec2 in the component and in the entry.
A generator takes no parameters, and it may name its module’s pub types
and its other generators, nothing else: vm.host refuses one that does,
naming the generator, when the game compiles.
Spawning and dispatching
pub struct Handle { raw: i64; } // index and generation, a value; never names a later object
pub fn Instance.spawn(*self, name: string) -> Handle or SpawnError;
pub fn Instance.despawn(*self, h: Handle);
pub fn Instance.type_of(*const self, h: Handle) -> ?string; // null once despawned
pub fn Instance.last_error(*const self) -> ?RuntimeError; // the crash behind the last Crashedspawn names a component of the loaded program that called a generator of
one of the context’s host modules; any other component is NotScriptable,
since nothing could call into it. It allocates the object in the instance’s
memory (counted against max_memory), gives props their defaults and vars
their initializers, and runs on_mount when the component declares it;
configuration arrives through a dispatch such as on_spawn(id). The
program’s module initializers run before the first spawn. despawn runs
on_unmount, drops the fields and frees the object; reset despawns every
object the same way before it discards the program, destroy frees
everything without running hooks. Many objects share one program; each has
its own vars.
A dispatch runs the handler to completion, with no budget but under the
context’s limits (call depth, iterations, instructions, memory, string
length). Inside it, vm.caller() is the instance’s user, host modules
work as in a run, and a host function’s trap is HostTrap as in a run.
SpawnError | Means |
|---|---|
NoSuchComponent | the program declares no component of that name |
NotScriptable | the component calls no generator of a host module |
MemLimit | the instance’s memory limit |
Running | the instance is inside a run |
Crashed | on_mount or a module initializer trapped; see last_error(). After an initializer trap every spawn of the instance is Crashed until load_source loads a program again |
DispatchError | Means |
|---|---|
Gone | the handle was despawned, or came from an earlier program |
Running | the instance is inside a run (a fn main() suspended by its budget), or the dispatch came from a host function the same instance is calling |
Crashed | the handler trapped or hit a limit: last_error() has the kind, message and call stack until the next dispatch; the object and the other objects stay usable |
A program is free-running or component-driven, never both at once: finish
or reset a fn main() before dispatching.
Saving and restoring
pub fn Instance.save(*self, h: Handle, w: *serial.Writer) -> void or SaveError;
pub fn Instance.restore(*self, r: *serial.Reader) -> Handle or RestoreError;save writes the object into the record the writer has open: type (the
component’s name), layout (a hash of its vars) and vars, every var by
name: numbers and booleans as they are, an enum as its integer, a string
as text, a struct or fixed array as a record (an array’s fields named 0,
1, …), a ?T as a record with some and, when true, value, and a
coroutine frame as @frame_save writes it. Props are not saved (they keep
their defaults), nor the vtable, nor a handler in mid-execution: save
between dispatches. The format is the writer’s: pass json.frame_writer’s
writer for JSON, or any serial.Writer of your own. A save made by the
scenario program:
{"type":"Computer","layout":"1572401465773477635","vars":{"id":7,"received":"HI","messages":0,
"job":{"layout":"13689542721476194895","point":"idle","point_layout":"7248905816412022087",
"args":{},"locals":{"part":{"some":false}}}}}The code that writes and reads a component is made by the script’s
compiler for every scriptable component, when the script compiles. It
honours @serial.skip (not written, left at its initializer on restore)
and @serial.rename on a var, and a script writes them without importing
serial. A var that has no saved form, a tagged union, a slice, a Vec or
other container, a pointer, a component, a function pointer, is a compile
error of the script unless it is @serial.skip: “var ‘cache’ of ‘Computer’
cannot be saved: a Vec has no saved form yet; mark it @serial.skip”. Keep
ids and indices in what a scripted object saves, not references.
restore reads type and spawns it (on_mount runs), then its vars, and
then runs on_reload, where the component re-derives whatever is not a
var. With the same layout every var is read, and a value missing or of
another kind is Malformed. With another layout, a record saved by an
earlier version of the script, the vars migrate by name and type: a var
whose name and type match is read, a new var keeps its initializer, a var
that is gone is skipped, a var whose type changed keeps its initializer
with a warning in inst.warnings (“var ‘hp’ of ‘Npc’ was saved as another
type: it keeps its initializer”), which restore clears first. A coroutine
frame is carried over when its yield @label(name) is unchanged, and left
not started otherwise, so on_reload can start it again.
SaveError | Means |
|---|---|
Gone, Running | as for a dispatch |
ForeignPointer | a frame holds a pointer to outside itself and its instance |
Crashed | the save code trapped or hit a limit; last_error() has it, the writer may hold a partial record, the object is untouched |
RestoreError | Means |
|---|---|
NoSuchComponent, NotScriptable, MemLimit, Running | as for a spawn |
Malformed | the record is not a saved component (no type, vars not a record, a value of another kind under the same layout); nothing is left behind |
Crashed | on_mount, the load code or on_reload trapped; last_error() has it, and the object is despawned |
Not yet
Reloading a context’s scripts in place, and trusted contexts (pointers, threads), are planned.
Last modified: