Builtin Library
- ..
- README.md8.6 KB
- about.yaml468 B
- behavior.luau17.2 KB
- inspector.luau5.4 KB
- shared.module
- type.yaml2.7 KB
- zero-plugin.wit29.1 KB
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
runtimeplugin is part of the game: components and scene entrypoints can call it, and a player loads it. Aneditorplugin 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
privategives the plugin a store of its own, in this world, that survives a restart. Withnonewhat it stores lasts until it is reloaded. - events
exposedlets code in the world listen to the events the plugin raises. Withprivatenothing in the world hears them. - memory is the most memory the plugin may hold, written with
KiB,MiBorGiB, up to4GiB, 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 itsmemory:. - 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: trustedis 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-developmentguide 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 ownentity.batchWritedoes. -
A GPU buffer goes as
buf:forPlugin()(a"gpu"buffer fromsubstrate.createBuffer), and a compute shader asshaderRef: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 needsevents: exposed. -
A refusal is an error like any other, and
pcallcatches 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), andrequire(".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.onreceives"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
shutdownruns 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.