The dice plugin
The first first-party plugin of NotesCraft: a WebAssembly module that rolls
dice. It is also the walkthrough for plugin authors — everything a plugin has
to do to live in the sandbox is in src/lib.rs, and this file explains it.
plugin.dice.roll {"expr": "2d6+3"}
-> {"total": 10, "rolls": [4, 3], "text": "2d6+3 = 10 [4,3] +3"}
Build and install
./build.sh # cargo build --release --target wasm32-unknown-unknown
build.sh leaves dice.wasm next to manifest.json. To install the plugin,
copy both into the plugin folder of a vault:
<vault>/.notescraft/plugins/dice/manifest.json
<vault>/.notescraft/plugins/dice/plugin.wasm
From the registry: dice is a registry entry (plugin:dice, permission editor). Install it in
Settings > Registry (or registry.install), switch it on for the vault (registry.enable) and approve
its permission (plugins.approve); no files are copied by hand. manifest.json here is the one the
entry ships, and crates/engine/tests/addons_first_party.rs signs, seeds, installs and rolls it.
The folder name must be the plugin id of the manifest. plugins.list shows
the plugin, plugins.enable switches it on, and after that the command
plugin.dice.roll is in the command palette, in the CLI and as an MCP tool.
The module is built from its own workspace (plugins/dice/Cargo.toml has its
own [workspace]), so building it never touches the lock file or the profile
of the app. dice.wasm is a build artefact and is never committed.
The ABI (SPEC 7.2)
The host loads the module in wasmi and speaks a small ABI with it. The
module must export:
| Export | Signature | What it does |
|---|---|---|
ns_abi_version |
() -> i32 |
must return 1; any other value is refused |
ns_alloc |
(i32) -> i32 |
hands the host len bytes of guest memory |
ns_free |
(i32, i32) -> () |
gives a block back; a bump arena may ignore it |
ns_on_load |
() -> i32 |
lifecycle hook; 0 means "fine" |
ns_on_unload |
() -> i32 |
lifecycle hook; 0 means "fine" |
ns_on_event |
(i32, i32) -> i32 |
a JSON event; 0 means "fine" |
ns_run_command |
(i32, i32, i32, i32) -> i64 |
runs one command |
memory |
the linear memory | must be exported under that name |
ns_run_command takes the command id and the JSON arguments as (ptr, len)
pairs in guest memory — the host takes them from ns_alloc, so the guest
reads them back from the block it handed out. Its answer is the JSON result
as a (ptr, len) pair packed into an i64 as (ptr << 32) | len; a
negative value is an error code, and the host turns it into
plugin: the plugin returned error code -2. A trap (an unreachable
instruction, an out-of-bounds access) is reported as a trap and is not the
same thing as a guest error: use an error code for a refusal.
The host calls (SPEC 7.2, 7.3)
A module may import only the calls of the ns module, and only the ones its
permissions cover:
| Import | Permission | Answer |
|---|---|---|
ns.log |
— | empty |
ns.notice |
— | empty |
ns.now |
— | JSON {"unix_ms": ...} |
ns.vault_read, ns.vault_list, ns.search, ns.links, ns.backlinks |
vault.read |
JSON |
ns.vault_write |
vault.write |
JSON |
ns.active_note, ns.insert_text |
editor |
JSON |
The name of the import must be the name of the host call. The host checks every import and refuses a module that imports anything else, so a Rust guest has to rename its functions:
#[link(wasm_import_module = "ns")]
unsafe extern "C" {
#[link_name = "now"]
fn ns_now() -> i64;
}
An answer always arrives in the slot the host owns for the instance, which is
not a block the guest handed out: the guest reads it with a pointer read of
its own. That is the one place where the guest needs unsafe, and it is
unavoidable — the ABI is raw memory, and a guest that wants the answer of a
host call has to read the memory the host wrote.
Randomness
The sandbox has no entropy of its own: no clock, no random device, no
network. ns.now() is the only source, so the dice plugin seeds its xorshift
with the unix_ms of the clock. Two rolls in the same millisecond therefore
roll the same dice. It is enough to keep a plugin from being perfectly
predictable, and it is the whole of what the sandbox offers.
What the dice plugin understands
NdM with an optional constant: 2d6, 2d6+3, 2d6-3, 1d1000. At most
100 dice, at most 1000 sides. kh and kl are not supported: the plugin
answers with the error code -2 (the expression is not one it understands)
or -3 (the expression is understood but out of bounds, for example 0d6,
101d6 or 1d1001).
With the editor permission the command also inserts the text of its
answer into the focused note.
Tests
The plugin has no tests of its own: it is a no_std module that imports the
host calls, so it cannot be linked into a test binary on its own. It is
tested end to end instead, through the real loader:
cargo test -p notescraft-engine --test plugin_dice -- --include-ignored
The test loads plugins/dice/dice.wasm (the build above), installs it in a
throw-away vault and drives it through the session: the ABI version, the
answer of a roll, the text inserted into the focused note, a hundred dice
inside the fuel budget and the three refused expressions.