vm

tape
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.

tape
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.

FlagAllows
allow_structstruct, tagged union and union declarations
allow_enumenum declarations
allow_loopswhile, for
allow_recursiona cycle of calls among the script’s functions
allow_allocanything that allocates: building strings (concatenation, interpolation), heap memory; off, mem and collections cannot be imported either
allow_deferdefer, errdefer
allow_regionsany 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_ptrpointer types, &x
allow_inline_asm@asm, #bytecode
allow_dll_importextern fn, @link
LimitEnds the run when
max_memorythe script’s heap, globals and frames pass this many bytes (MemLimit)
max_call_depthmore calls are live than this (StackOverflow)
max_iterationsloops have gone round this many times in the run (IterLimit)
max_instructionsthe run has executed this many VM instructions (InstrLimit)
max_string_lena 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
@tape { vm.host(screen, "SCREEN"); }
@tape { vm.host(system, "SYSTEM", ["uptime", "halt"]); }   // two functions only

vm.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:

tape
@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

tape
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:

StepMeans
Runningthe budget is spent; call run again (next frame)
Waitinga 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
Idlenothing 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

tape
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

tape
// 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:

tape
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:

tape
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

tape
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 Crashed

spawn 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.

SpawnErrorMeans
NoSuchComponentthe program declares no component of that name
NotScriptablethe component calls no generator of a host module
MemLimitthe instance’s memory limit
Runningthe instance is inside a run
Crashedon_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
DispatchErrorMeans
Gonethe handle was despawned, or came from an earlier program
Runningthe 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
Crashedthe 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

tape
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:

plaintext
{"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.

SaveErrorMeans
Gone, Runningas for a dispatch
ForeignPointera frame holds a pointer to outside itself and its instance
Crashedthe save code trapped or hit a limit; last_error() has it, the writer may hold a partial record, the object is untouched
RestoreErrorMeans
NoSuchComponent, NotScriptable, MemLimit, Runningas for a spawn
Malformedthe 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
Crashedon_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: