Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

README.md

plugin

Compiled code in a world, used the way a module is.

A plugin is a WebAssembly component in a <name>.plugin/ asset. Code that uses one requires it and calls it like any module: the calls answer values, and a refusal raises. Nothing about the call says compiled code ran, the way calling into a native library from Python does not.

local counter = require("counter")

local total = await(counter.add(2))     -- a number
print(await(counter.total()))           -- 2

It is for work Luau cannot do fast enough, or cannot do at all: a simulation step, a decoder, a solver, a model.

Getting one into a world

Write the component into the world as a .wasm file (upload it). It becomes a plugin asset of the same name, with the component moved inside, and it loads. The asset starts with:

counter.plugin/
├── plugin.wasm        the component
├── plugin.yaml        what it offers, and where it loads
├── surface.module/    the module a `require` of the asset lands on
├── README.md          this plugin's own description
└── .metadata

Nothing is callable until plugin.yaml lists it. Edit it to name the methods the component offers, and the plugin reloads with them.

plugin.yaml

profiles:        # where it loads; required
  - editor
  - runtime
storage: none    # none | private; none when left out
events: private  # private | exposed; private when left out
memory: 512MiB   # the most memory it may hold; 4GiB when left out
exposed:         # the methods a caller may run; none when left out
  - name: add
  - name: total
  • profiles says where the plugin loads. A runtime plugin is part of the game: components and scene entrypoints can call it, and a player loads it. An editor plugin is for authoring, and a call to it from a component or an entrypoint is refused, because the shipped game would not have it.
  • storage private gives the plugin a store of its own, in this world, that survives a restart. With none what it stores lasts until it is reloaded.
  • events exposed lets code in the world listen to the events the plugin raises. With private nothing in the world hears them.
  • memory is the most memory the plugin may hold, written with KiB, MiB or GiB, up to 4GiB, which is also what a plugin that says nothing gets. A plugin that would grow past it stops, and the log says it went past its memory:.
  • exposed is the plugin's whole callable surface. A method not listed cannot be called, whatever the component contains, and a listing that names a method the component does not have is refused when the plugin loads. An entry with caller: trusted is callable by the engine's trusted side alone: a world script or a panel calling it is refused, and it asks for no consent, since no person is its caller.

Using it

The asset's surface.module is its Luau face, and require("<name>"), with the asset's name, lands on it. Its code is surface.module/init.luau, and each of its functions calls the plugin through the asset it belongs to:

local self = asset.containing(__FILE__)
local me = self.modules.shared.forAsset(self)

local M = {}

function M.add(amount: number)
    return me.call("add", amount)
end

return M
  • Arguments are numbers, strings, booleans, nil, buffers, handles, and tables of them. A whole number arrives as an integer. A string has to be text; send bytes as a buffer, which arrives whole, as bytes.

  • A file or a folder goes as a handle, which the plugin reads or writes by itself, without a copy through the call:

    local weights = vfs.open(self.path .. "/weights.bin")            -- read a file
    local model = vfs.openDir(self.path .. "/model")                  -- read a folder
    local out = vfs.sink("/source/out/sphere.zvol", { maxBytes = 64 * 1024 * 1024 })
    local frames = vfs.dirSink("/source/frames", { maxFiles = 240, maxBytes = 200 * 1024 * 1024 })
    me.call("render", model, frames)
    

    A handle lets the plugin do what this script could do at that path, and nothing more: a plugin has no filesystem of its own. A sink's file appears when the plugin closes the sink or lets go of it, and only the world's own files take a sink. The plugin-development guide has the rules.

  • Entities go as a binding: ecs.bindEntities(ids):forPlugin(). The plugin reads and writes their fields as packed floats and never learns an id; a write it makes answers to the same locks, owners and builds this script's own entity.batchWrite does.

  • A GPU buffer goes as buf:forPlugin() (a "gpu" buffer from substrate.createBuffer), and a compute shader as shaderRef:forPlugin(). The plugin writes and reads the buffer's floats and dispatches the shader over buffers the same script handed it. It never learns a buffer's name or a shader's key, so it reaches no buffer it was not given.

  • The answer is the value the plugin returned: a table as a table, a number as a number, bytes as a buffer.

  • A refusal raises where it is awaited: a method not exposed, a plugin that is not loaded, or the plugin's own error.

  • me.on(function(eventType, payload) ... end) listens to the plugin's events, waiting for it to load first. The payload is a value, as an answer is. It needs events: exposed.

  • A refusal is an error like any other, and pcall catches it.

  • plugins.isLoaded("<name>") answers whether it is running, and never raises.

More than one part

A plugin can carry more than its surface: modules the surface builds on, and an editor panel. They sit beside surface.module inside the asset:

counter.plugin/
├── plugin.wasm
├── plugin.yaml
├── surface.module/    what `require("counter")` lands on
├── history.module/    a module the surface uses
└── panel.editorPanel/ a dock tab

The parts reach each other with the dot form, which means the same thing wherever the asset is installed:

  • From a module, require(".history") is the module beside it.
  • From the panel, require(".") is the plugin (its surface), and require(".history") the module beside the panel.

The surface IS the plugin: it has no name of its own, so no part requires .surface. Don't write a path (./history.module) or a name that says where the asset is installed (require("counter.history")): moved into a library, the asset stops finding its own parts.

While it runs

  • A new build replaces it in place. Upload the component again under the same name and the running plugin is replaced, with no restart. Uploading the same bytes again changes nothing.
  • Editing plugin.yaml reloads it.
  • Removing the asset stops it.
  • A new build or an edit stops a call in progress. A call the plugin is in the middle of answers that the plugin was stopped, however long it would have run, and the new build starts.
  • A crash restarts it. If the component traps, the plugin stops, the log says so in one line, and a call raises that it stopped and why. The engine then loads the asset again after a pause of 1 s, then 5 s, then 20 s for each trap that follows within a minute of a start. A fourth such trap leaves it stopped, and every call raises why, until it loads again: a new build, or an edit to plugin.yaml. A plugin that runs a minute after a start has its count cleared.
  • Its listeners hear it stop and start. me.on receives "plugin.stopped" with { reason, restarting, restartInSecs? } when the plugin traps, and "plugin.started" with { restarted = true, attempt } once it is running again. Work you were waiting on the plugin for (a running request, a turn) ended with the trap: end it there, with the reason.
  • An engine that stops lets it finish. When the engine is stopped (its window closed, or a stop of the engine; in a browser, a stop the page asks for), each plugin's shutdown runs and its files are closed, within 2 s in all; on the desktop the calls in flight are given the first second of it to finish. A plugin that trapped is not started again once the engine stops.
  • plugins.status() lists each plugin: whether it is loaded, why not, and its component: the SHA-256, the size, and what built it.

The same holds in the desktop engine and in a browser.

Building one

The plugin-development guide covers writing a component: the interface in zero-plugin.wit beside this file, a Rust project that builds one, how calls are encoded, what a plugin may use, and the development loop.