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

ExportSignatureRequiredMeaning
memorymemoryyeswhere the copying happens
rustmc_manifest() -> ()yesemit the manifest JSON
rustmc_on_event(len: i32) -> i32yesan event of len bytes is waiting; 0 continues, anything else cancels
rustmc_on_hook(len: i32) -> i32if the manifest names hooksa hook call of len bytes is waiting; 0 passes, anything else means "I have emitted the replacement"
rustmc_on_message(len: i32) -> i32if the manifest names listens, or the plugin answers callsa 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() -> ()nocalled once, before any event
rustmc_stop() -> ()nocalled once, after the last event, with five calls' fuel (rustmc.yml's plugins.stop-fuel-multiple) for a final save
_initialize() -> ()noWASI'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)

ImportSignatureMeaning
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) -> i32make 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.

WhatOn the wire
An eventone 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
IntegersJSON 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 datalower-case hex
Plugin-channel payloada list of byte values, 0 to 255
Network bytes, read_blocks cells, host_pack bytes, RSA keysbase64
Text componentsthe 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 is false for somebody who has gone or has hidden chat. overlay: true shows 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 (with with and fallback), keybind, and score ({name, objective}). selector and nbt are refused: the server would have to resolve them.
  • Style. color (the 16 names or #rrggbb), and the booleans bold, italic, underlined, strikethrough and obfuscated. Also font (an id), insertion, and shadow_color (a whole number).
  • click_event. open_url (http or https), run_command, suggest_command, copy_to_clipboard, or change_page (1 or more).
  • hover_event. show_text, show_item (an item this server has, count 1 to 99, no components), or show_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_ms is up (at most 15 minutes), when the plugin calls release_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":false becomes 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, and settings_changed is 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>} (null too when the target has no rustmc_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 manifest listens to 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.

A chest-like screen a plugin opens for one player, whose slots are buttons.

CallRequestAnswer
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).

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

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