Files
notescraft/plugins/dice

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.