Oxidized Massive Network Game Object Server
rust wow-emulation wow mangos
README.md

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 #

  1. 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)
    
  2. 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 (default plugins/, relative to the server's working directory):

    cp target/wasm32-wasip2/debug/myplugin.wasm myplugin.wasm
    

    (Deployed *.wasm are gitignored — they are build artifacts, not source.) The just plugin-deploy recipe does the build + copy in one step.

  3. 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 myplugin
    

    The plugin layer must be enabled (it is by default; WORLD_PLUGINS__ENABLED=false turns 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.