129 lines
5.3 KiB
Markdown
129 lines
5.3 KiB
Markdown
# 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:
|
|
|
|
```
|
|
<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:
|
|
|
|
```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.
|