OMaNGOS plugins #
This directory is a separate Cargo workspace from the server (different build universe: untrusted guest code, a different target). Plugins are sandboxed WebAssembly components that the world server loads at runtime: they subscribe to game events and call back into the world through a typed contract, with no access to the host beyond what the contract exposes.
The bundled plugins — ruleset, vendor, enchanter — are worked examples; examples/hello is the
minimal one. The host side (the runtime, the contract, how events are
dispatched) is documented in docs/design/plugins.md and ADR 0007.
ruleset is the one to read if you are writing a plugin that changes the rules rather than adding
content (it also absorbed the former standalone boost plugin, for the reason in
docs/design/rulesets.md): it uses the amount-modifying hooks (on_give_xp and friends, which return the value to
grant) and the gates (on_can_trade, on_can_resurrect, …, which refuse an action), keeps permanent
per-character state in the durable KV, and drives an optional cosmetic client addon. Its shape is worth
copying too — every decision lives in a pure module that unit-tests on the host target, and the
Script impl is wiring only. It is also the worked example for operator settings: it declares
one per offered option in on_install and reads them with setting::flag(..), so a realm chooses
which Auras exist without a rebuild.
Prerequisites #
rustup target add wasm32-wasip2
That is the only extra tool — plugins build with the same pinned toolchain as the server.
Anatomy of a plugin #
A plugin is a small crate that depends on the SDK and exports a single Script:
plugins/myplugin/Cargo.toml
[package]
name = "myplugin"
edition = { workspace = true }
version = { workspace = true }
publish = { workspace = true }
[lib]
crate-type = ["cdylib"] # built as a wasm component
[dependencies]
mangos-plugin-sdk = { path = "../sdk" }
plugins/myplugin/src/lib.rs
use mangos_plugin_sdk::prelude::*;
#[derive(Default)]
struct MyPlugin;
#[plugin]
impl Script for MyPlugin {
// Implementing a method *is* the registration — the #[plugin] macro reads the impl block
// and subscribes you to exactly the events you handle. Every Script method has a default.
fn on_login(&mut self, ctx: &mut EventCtx) -> bool {
ctx.player().send_message("Hello from a WASM plugin!");
true // returning false suppresses the core default for an event; true keeps it
}
// A chat command: `.greet` in the client.
#[command("greet")]
fn greet(&mut self, ctx: &mut EventCtx, _args: &str) -> bool {
ctx.player().send_message("Greetings!");
false
}
}
Then add the crate to the workspace members in Cargo.toml.
Keyed events and gossip #
#[plugin(...)] takes keys to subscribe to a specific entity, so you only get the events you want:
#[plugin(creature = 1234)] // creature-entry events (spawn, death, …)
#[plugin(creature_gossip = 1234, player_gossip = 50001)] // NPC gossip menu + a self-menu id
#[plugin(item = 1234)] // item-entry events (use, equip, …)
#[plugin(spell = 1234)] // spell-id events (cast, hit, proc, …)
#[plugin(creature_gossip = owned("shroomie"))] // an installer-allocated entry (see on_install)
Build a gossip menu with the GossipMenu builder and send it from a gossip / command handler:
GossipMenu::new(MENU_ID)
.item(Icon::Vendor, "Buy something", /*sender*/ 1, /*action*/ 2)
.send(ctx.player(), ctx.source());
The player and item proxies cover the common host calls — add_item, add_aura, equip_item,
learn_spells, teleport, send_message, class/team/level, item set_permanent_enchant, and
more. See the SDK (sdk/src) and the generated host API in
docs/plugin-api.md.
Installing content #
To ship your own NPCs/items/tables, implement on_install and declare them through the installer —
the host allocates ids, tracks them in an ownership ledger, and reverts them on uninstall:
fn on_install(&mut self, ctx: &mut InstallCtx, _from: Option<String>) -> Result<(), String> {
let entry = ctx
.define_creature_template("shroomie", &CreatureTemplate { /* … */ })
.map_err(|e| format!("{e:?}"))?;
log::info(&format!("my NPC is entry {entry} — place it with: .npc add {entry}"));
Ok(())
}
Persistent per-plugin state uses the key/value store (Persisted<T>, serde-backed).
Build → deploy → install #
-
Build the component (cargo turns
-into_in the output filename):cd plugins cargo build --target wasm32-wasip2 -p myplugin # -> plugins/target/wasm32-wasip2/debug/myplugin.wasm (add --release for an optimized build) -
Deploy it into the server's plugins directory as
<name>.wasm, where<name>is the registry/manifest name you install it under. The directory is[plugins].dir(defaultplugins/, relative to the server's working directory):cp target/wasm32-wasip2/debug/myplugin.wasm myplugin.wasm(Deployed
*.wasmare gitignored — they are build artifacts, not source.) Thejust plugin-deployrecipe does the build + copy in one step. -
Install it into the running world server — this writes the registry row, runs
on_install, and hot-reloads it across the live maps:cargo run -p mangos-admin -- world install-plugin mypluginThe plugin layer must be enabled (it is by default;
WORLD_PLUGINS__ENABLED=falseturns it off). Re-running the install picks up a rebuilt component.
To remove one: mangos-admin world uninstall-plugin myplugin reverts everything in its ownership
ledger.
The contract (extending the host surface) #
The host/guest boundary is a single WIT contract, generated from the declarative source in
crates/plugin-idl — do not edit wit/plugin.wit by hand:
cargo run -p plugingen # regenerates wit/plugin.wit + docs/plugin-api.md
Adding a new host capability is a host-side change: extend crates/plugin-idl, regenerate, implement
it in crates/wasm-host, and surface it in the SDK. Day-to-day plugin authoring does not need this —
the existing surface covers events, gossip, the player/item proxies, content installation, and KV
state.
Testing #
The SDK and plugins also compile and unit-test on the host target, so logic tests run without a wasm runtime:
cd plugins && cargo test
A loaded-component smoke test lives host-side (crates/wasm-host, #[ignore]d) and loads a built
component to assert its registrations:
cargo build --target wasm32-wasip2 -p ruleset-plugin # in plugins/
cargo nextest run -p wasm-host --run-ignored all -E 'test(loads_ruleset)'
That is worth doing for any plugin whose hooks gate something: a registration that silently stopped happening means the rule simply does not apply, and nothing looks broken until someone gets away with what the plugin was supposed to prevent.
Anything that touches a host import can only be exercised by loading a real component, which is the
argument for keeping the Script impl thin and the decisions in pure modules.
Acknowledgements #
The design of this plugin layer — event-driven scripts that hook into the game, subscribe to creature/item/spell/gossip events, register chat commands, and install their own content — owes a great deal to Eluna and its developers. Eluna pioneered an approachable, well-documented scripting surface for MaNGOS/TrinityCore servers, and its event model and host-API shape directly inspired the contract you write plugins against here. We swap Lua for sandboxed WebAssembly components, but the ergonomics and the catalogue of hooks are a debt to Eluna's groundwork.
Sincere thanks to the Eluna developers and contributors. Eluna is licensed under the GPL-3.0.