# 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 ```sh ./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: ``` /.notescraft/plugins/dice/manifest.json /.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: ```rust #[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: ```sh 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.