Embedding

The pieces

A tape program, “the game”, runs scripts the player or a content author wrote, with the vm module. Three things are involved:

  • tapevm100, a shared library (.dll, .so, .dylib) built from the compiler’s own sources: the parser, type checker, lowering and VM that tape run uses, with a small C interface for vm. It ships beside tapert100 in every tape package, and tape build copies it next to a program that uses vm. Its name is its contract version.
  • vmrt, the bridge module that binds that interface; only vm uses it.
  • vm, the module the game uses: contexts, instances, and the vm.host generator that turns game modules into host modules.

The game itself may be native or run by tape run: under tape run the VM in tape runs the game and the VM in tapevm100 runs the scripts.

Contexts and instances

A context is a sandbox definition: capability flags (which language features a script may use), limits (memory, call depth, iterations, instructions, string length), the host modules a script may import, and the stdlib modules it may import, read from a directory the game ships. It is immutable once made and shared by its instances.

An instance is one script. load_source compiles it in the context: the capability flags are checked on the parsed source, every import goes through the context (a host module, the instance’s virtual disk, a listed stdlib module, a search path, or “not available in this context”), and the compiler’s diagnostics are collected, never printed. Instances of a context that load the same text share the compiled program; each has its own globals, heap and call stack.

Running a slice at a time

The VM never runs a tape call on the C stack: a call pushes a frame and the interpreter goes on in it. So a run can stop between two instructions with its frames intact and continue later. run(budget_us) runs the module initializers and then the entry point, and checks the clock every 256 loop back-edges and calls; when the budget is spent it returns Running, and the next run continues where it stopped. A host function is native code and is never interrupted. A host function can also call vm.park(): the script stops when the function returns and run returns Waiting.

A plain tape run uses a copy of the interpreter loop without any of these checks, so programs that do not embed the VM pay nothing for them.

Host calls

vm.host(m, ...) reads module m at compile time (m: module, @moduleinfo, @module_api) and emits, for each exposed function, an adapter with one fixed C signature, void entry(const int64_t *args, uint8_t *out): each argument in a slot, a string as two (its bytes and length, which the adapter copies), the result in out. The script’s compiler sees the module’s interface text as a module of declarations bound to those entries. Strings are copied in both directions, so neither side holds the other’s memory; under tape run an entry is a callback into the game’s VM, natively it is the adapter’s address.

A trap inside a host function ends the script’s run as HostTrap at the script’s call: natively the trap jumps back to the call into the entry, under tape run the game’s VM hands the trap back without stopping the game.

Script components

A scripted object is a component of the script’s program. The instance keeps an object table: each spawned component’s memory, its vtable and a generation, so a Handle (index and generation) never names a later object. A dispatch looks the name up in a table built at load_source from the program’s components, then runs the handler on the instance’s own frame stack exactly as a host call’s return runs code: the checked interpreter loop, the context’s limits, a trap turned into a RuntimeError kept as the last error. The game’s typed entry points are compiler-made, like vm.host’s adapters: vm.gen_dispatch runs the generator in a scratch component when the game compiles, keeps the @dispatch declarations it emitted and emits one function per declaration that packs its arguments as a host call does, in reverse. A signature of scalars and strings takes the direct slots; one with a struct, union, array or optional is flattened by the same walker that flattens host calls, so the two sides can never disagree on the layout.

The generator itself travels as source: vm.host copies each pub @tape fn of the module into the interface text the script compiles against, so it runs in the script’s compiler and may emit vars and functions for the component as well as dispatches. It sees only the module’s pub types and its other generators, which vm.host checks when the game compiles.

The save and load code is the script compiler’s: for every component that called a generator it emits two methods walking the component’s vars (a coroutine frame through the frame code @frame_save uses), and the VM writes the record’s type and layout around them. Each Writer or Reader call crosses to the game through one entry, so the format is the game’s and the script’s program never sees a serial.Writer: the script’s program is given a hidden module carrying serial’s declarations, injected only when it has a scriptable component and importable by no spelling a script can write. A restore spawns the component, runs the load code var by var (each read into a copy, assigned when all of it was read, so a failed value leaves the record where the next var expects it) and then on_reload.

What a script can and cannot do

A script can only use what its context names: no stdlib module unless it is listed, no file system, console or network unless a host module or a listed module gives it one, no language feature the profile does not allow. Its memory is the instance’s (through the game’s allocator when it gives one), counted against its limits and given back when the run ends. It cannot hold a pointer into the game, and the game cannot hold one into it.

Last modified: