WebAssembly
The WebAssembly ABI
On this page
A guest is a core module. It is not a component: Kotlin/Wasm, the language this exists for, emits WasmGC core modules only. Everything crossing the boundary is JSON in linear memory.
The guest allocates and the host copies, always while the guest's call is still on the stack. Nothing the host writes has to outlive the call that asked for it, so a guest with a scoped allocator works.
Exports
| Export | Signature | Required | Meaning |
|---|---|---|---|
memory | memory | yes | where the copying happens |
rustmc_manifest | () -> () | yes | emit the manifest JSON |
rustmc_on_event | (len: i32) -> i32 | yes | an event of len bytes is waiting; 0 continues, anything else cancels |
rustmc_on_hook | (len: i32) -> i32 | if the manifest names hooks | a hook call of len bytes is waiting; 0 passes, anything else means "I have emitted the replacement" |
rustmc_on_message | (len: i32) -> i32 | if the manifest names listens, or the plugin answers calls | a plugin-bus message of len bytes is waiting: {"from", "request"} for a call, {"from", "topic", "data"} for news. 0 answers nothing (null to the caller); anything else means "I have emitted the answer" |
rustmc_start | () -> () | no | called once, before any event |
rustmc_stop | () -> () | no | called once, after the last event, with five calls' fuel (rustmc.yml's plugins.stop-fuel-multiple) for a final save |
_initialize | () -> () | no | WASI's library convention; called before anything else |
A guest whose call traps is replaced by a fresh instance of the same module, which runs
_initialize and rustmc_start again before it is next asked anything (limits).
Imports (module rustmc)
| Import | Signature | Meaning |
|---|---|---|
emit | (ptr: i32, len: i32) | hand the host bytes, which it copies immediately |
take | (ptr: i32) | copy what is waiting (an event, a hook call, or a call's answer) to ptr |
call | (ptr: i32, len: i32) -> i32 | make a host call; returns the answer's length, then take it |
wasi_snapshot_preview1.random_get is also provided, because Kotlin/Wasm imports it.
A hook call
The host makes the call waiting, then calls rustmc_on_hook(len):
{"hook": "packet.play.serverbound.chat", "mode": "prepend",
"input": {"player": 7, "id": 8, "body": "..."}}
An append also carries "output": .... Keys are written in sorted order.
To replace, emit the JSON of the replacement and return non-zero. For a prepend that is the
new input; for an override or an append, the output. To pass, return 0.
Host calls
→ {"call": "player_name", "player": 3}
← {"ok": "Notch"} or {"ok": null} or {"error": "..."}
An unknown call is an error, not a trap. A guest built against a newer host keeps working
on an older one.
Data encoding
Everything that crosses is UTF-8 JSON.
| What | On the wire |
|---|---|
| An event | one object: its fields plus "event": "<name>" (events) |
| A hook call | {"hook", "mode", "input", "output"?}; output only for an append |
| A host call | {"call": "<name>", ...fields}, answered {"ok": value} or {"error": "..."} |
| A bus message | {"from", "request"} for a call, {"from", "topic", "data"} for news |
| Integers | JSON numbers. A whole float (3.0) is accepted where an integer is wanted |
| 64-bit values that must be exact (a world seed) | a string where the call says so: a JSON number is a double |
Account ids (u128) | 32 hex digits; a request may include dashes |
Packet bodies, hook body and data | lower-case hex |
Plugin-channel payload | a list of byte values, 0 to 255 |
Network bytes, read_blocks cells, host_pack bytes, RSA keys | base64 |
| Text components | the component as a JSON value, not a string holding JSON |
A hook's input and output are Data: null, bool, integer, float, string, list or map. A record
becomes a map whose keys are its field names; a tuple becomes a list; Option becomes the value
or null. A replacement that does not convert back into the point's type is refused, logged, and
the old value kept.
A transfer is at most 4 MiB in either direction. A longer answer is cut to that length, and the
length call returns is the cut one, so a guest never writes past what it reserved.
Formatted text
message and broadcast send plain text. For colour and style, message_text and
broadcast_text take a text component, in the game's own JSON form:
{"call":"message_text","player":7,"component":{"text":"Saved ","color":"green","extra":[{"text":"3 rows","bold":true}]},"overlay":false}answers whether the line went. It isfalsefor somebody who has gone or has hidden chat.overlay: trueshows the line above the hotbar instead of in the chat box.{"call":"broadcast_text","component":...}answers how many it went to.
The component is checked field by field before it is sent, recursively through extra,
with and hover text. A client disconnects on a component it cannot read, so anything doubtful
is an error to the plugin, never a line sent.
Accepted:
- Content.
text,translate(withwithandfallback),keybind, andscore({name, objective}).selectorandnbtare refused: the server would have to resolve them. - Style.
color(the 16 names or#rrggbb), and the booleansbold,italic,underlined,strikethroughandobfuscated. Alsofont(an id),insertion, andshadow_color(a whole number). click_event.open_url(http or https),run_command,suggest_command,copy_to_clipboard, orchange_page(1 or more).hover_event.show_text,show_item(an item this server has, count 1 to 99, no components), orshow_entity(an entity type this server has, a uuid, an optional name).type. When given, it must agree with the content.
Any other field is refused. A component may nest at most 32 deep and be at most 32 KiB of JSON.
Completing a plugin's command
A plugin's command is sent to the client with an ask_server argument, so as a player types it
the server asks the plugin that registered it. The request is a call through rustmc_on_message
from rustmc:command, a name no plugin can have:
{"op": "command.suggest", "command": "perm", "rest": "group vi", "player": 7}
The plugin answers a list of strings, best first (at most 200, each at most 256 bytes). They are
completions of the line's last word, vi here, and the client offers them in its place.
null, or no rustmc_on_message, offers nothing. A player who may not run the command is not
asked for. Nor is a player sending more than 10 requests a second: the excess is answered with
nothing, so typing cannot be turned into a flood of calls into a plugin.
Capturing a chat line
A plugin that asks a player to "type it in chat" takes their next line with
{"call":"capture_chat","player":7,"wait_ms":60000} (and "secret":false for a line that may be
said aloud if it cannot be delivered). That line then goes to that plugin alone, as a call through
rustmc_on_message from rustmc:chat:
{"op": "chat.captured", "player": 7, "text": "builders"}
It is not a player_chat hook or event, not a log line and not a broadcast. No other plugin, not
even one logging chat, sees an answer that may be a password.
The answer says what came of the request: "captured", "offline", "busy" (another plugin is
waiting for that player's line) or "held" (see below). An error only for a bad wait_ms.
- One line. The capture ends when it arrives, when
wait_msis up (at most 15 minutes), when the plugin callsrelease_chat, when the player leaves, or when the plugin is unloaded. - Never said aloud by accident. A line the plugin cannot take (unloaded, or failing as it is
handed over) is dropped, and the player told privately that their answer could not be
delivered. Only a capture made with
"secret":falsebecomes ordinary chat instead. - Nobody muted. At most 10 of a player's lines are captured in a row; after that the answer is
"held"until a line of theirs reaches chat.
Settings
A plugin declares settings in its manifest. The server keeps their values in
plugins/<plugin>/settings.json, and the web portal draws a form for them.
{"call":"settings"}answers every declared key and its value.{"call":"setting_set","key":"limit","value":5}changes one of the plugin's own settings, exactly as the portal does. The value is checked against the declaration (an error when it does not fit), kept, andsettings_changedis told to every other plugin that hears it. The plugin that made the change is not told. It answers the value as kept.
The portal and an in-game editor therefore always show the same values.
Events a guest causes itself
A host call a guest makes can fire an event it hears: damage_saying fires damage and, if it
kills, death; set_block can fire block_place. The guest is still running when that happens,
and one guest is never entered twice at once, so such an event is queued: it is delivered
through rustmc_on_event, in the order it happened, as soon as the call that caused it returns
(and any event those cause after them). A queued event is told after the fact, so the guest's
answer to it is not asked: it cannot cancel what its own call did. At most 1024 wait at once;
past that the newest is dropped and logged.
Hook calls (rustmc_on_hook) and plugin-bus messages (rustmc_on_message) into a guest that is
still running on this thread need an answer at once, so they are not queued: they are skipped
and logged, the hook passing and the message refused.
The plugin bus
Guests are sealed from one another; the bus is how they share a service or news. Two host calls:
{"call":"plugin_call","target":"<plugin>","request":<json>}asks one plugin by name and is answered at once:{"ok":<answer or null>}(nulltoo when the target has norustmc_on_message, or answered nothing), or{"error":...}when there is no such plugin, when the call would reach back into a plugin still running on this thread (A asks B, B asks A, or B calls into A while A handles an event), when the chain is too deep, or when the request is too large, or when the target could not answer -- a guest busy on another thread, reached from inside another plugin, is waited for up to 250 ms and then refused (and logged), so two threads each inside a guest the other wants give up rather than wait for ever.{"call":"plugin_emit","topic":"<topic>","data":<json>}tells every other plugin whose manifestlistensto the topic (exact,family.*or*), and answers how many heard.
The sender is the plugin's manifest name, set by the host: a guest cannot speak for another. Any
plugin may call any plugin, so a target that does anything privileged checks from. Chains of
calls and news stop at the bus's depth limit (8); each plugin reached gets its own fuel. The target receives the message
through rustmc_on_message.
Menus
A chest-like screen a plugin opens for one player, whose slots are buttons.
| Call | Request | Answer |
|---|---|---|
menu_open | {player, rows, title, items} | the menu's id |
menu_set | {player, menu, items} | whether it was still open |
menu_close | {player, menu} | whether it was still open |
rows is 1 to 6. items is a list of {slot, item, count?, components?}: slot is the menu's own
(0..rows*9), item an id (minecraft:air, or a count of 0, empties the slot), and
components an object of data component to its SNBT value, as /give spells them:
{"minecraft:custom_name": "'Shop'", "minecraft:lore": "['Costs 5']"}. Opening a menu replaces
whatever screen the player had open, and anything on the player's cursor goes back into their
inventory. A title is at most 512 bytes and one slot's components at most 16 KiB of SNBT, well
inside what the client accepts.
Nothing in a menu can be taken or put in. The server refuses every click, in the menu and in
the player's inventory below it, and puts back whatever the client moved. Then it tells the plugin
that opened the menu, as a call through rustmc_on_message from rustmc:menu (no plugin can have
that name):
{"op": "menu.click", "menu": 3, "player": 7, "slot": 11, "top": true, "button": 0, "mode": 0}
{"op": "menu.close", "menu": 3, "player": 7, "reason": "player"}
mode is the game's click mode: 0 click, 1 shift-click, 2 number key, 3 middle, 4 drop, 5 drag,
6 double-click. top says whether the slot is the menu's. A close's reason is player,
server, or replaced (another plugin's menu, or a container, opened over it).
The plugin is not told about a close it caused itself, through menu_close or by opening another
of its own menus over one. A player who leaves is player_left, not a close.
Creatures
A plugin's own creatures: made, dressed, owned and driven (the guide).
| Call | Request | Answer |
|---|---|---|
spawn_creature | {world, kind, at: [x, y, z], options?} | the entity id, or null for a world the server does not have |
set_creature_ai | {entity, ai} (vanilla, none, plugin) | whether it is this plugin's creature |
creature_order | {entity, order, ...the order's fields} | whether it is this plugin's creature; an error for a mind that is not plugin, or an order that cannot be carried out |
creature_state | {entity} | {kind, world, position, health, max_health, ai, owner, order, target, path}, or null |
set_creature_drops | {entity, drops, experience?} (drops: null for its own) | whether it is this plugin's creature |
can_spawn_at | {world, kind, at: [x, y, z]} (a block) | whether one could stand there by its spawn placement |
options is {yaw, name, name_visible, equipment: {slot: {item, count, components}}, attributes: {name: base}, health, persistent, ai, silent, invulnerable, baby, sight_range, sees_creatures},
every field optional. The orders and their fields are in the guide.
Storage tables
A plugin's own rows -- a table, a string key, a JSON value -- in whichever backend the server
owner chose in plugins/storage.json: JSON files (the default, nothing to set up), SQLite or
MySQL. The plugin is its manifest name, as on the bus, so no plugin reads another's rows.
| Call | Request | Answer |
|---|---|---|
storage_get | {table, key} | the value, or null |
storage_put | {table, key, value} | true |
storage_delete | {table, key} | whether a row went |
storage_scan | {table, prefix, after?, limit} | [[key, value], ...], in byte order of the key; the scan is over at an empty page |
storage_count | {table, prefix} | a number |
storage_migrate | {from, to} | {started: true}, at once |
storage_migration | {} | {from, to, state, error, tables, rows} or null; state is running, done or failed |
storage_backend | {} | "json", "sqlite" or "mysql" |
storage_drop | {table} | whether the table had any rows; every row of it goes at once (a write: refused while migrating) |
Tables are [a-z0-9_]{1,48}, keys at most 256 bytes, values at most 1 MiB of JSON, a scan's
limit 1 to 1000 and its page at most 4 MiB (a page of large rows ends early). A number that
would not read back as written (an integer past 64 bits, more than 17 significant digits) is
refused: keep it as a string.
storage_migrate must start from the backend the plugin is on, and is refused unless an operator
(level 3) or the console is running one of the plugin's own commands at that moment: the backend
is the server owner's choice. The copy runs off the tick; until it ends the plugin's writes are
refused and its reads answer from the old backend. Only once every table's count and checksum
match does the new backend become the plugin's (and plugins/storage.json say so); a failure or
a crash before that leaves it where it was, with all its rows. Every one of these answers an error when the backend cannot answer -- a
database that is down, or slower than the owner's wait_ms (250 ms by default) -- rather than an
empty answer, so a guest can tell "no row" from "could not ask".
From docs/api/wasm/abi.md in the RustMC repository at bbd81209ad.