Files
Emberwild/docs/ADDON_SDK.md

126 lines
5.9 KiB
Markdown

# Addon SDK — design spec
This is the differentiating feature: **easy add-ons with guard rails.** Modders
get a friendly, well-documented API; the server stays safe from buggy or
malicious addons.
## Design goals
1. **Easy to write.** A modder writes a small Lua script + a manifest. No build
step, no engine knowledge.
2. **Impossible to hurt the host.** No filesystem, no network, no arbitrary code
against the engine, no hanging the server tick, no unbounded memory.
3. **Server-authoritative.** Addons run on the server. Their effects replicate to
clients like any other game state.
4. **Content without code where possible.** Many mods (new items, recipes,
building pieces) are pure *data* — declared in a manifest, no Lua needed.
## Why Lua (not WASM, not GDScript, not Python)
- Lua is the industry-standard embedded game-modding language. Modders already
know it (WoW, Factorio-ish, Garry's Mod, countless others).
- A Lua VM is trivially sandboxable: you build the environment table yourself, so
the addon literally cannot see anything you don't hand it.
- GDScript was rejected: it can reach engine internals — too powerful for untrusted
code.
- WASM was considered (stronger isolation, authors use Rust/AssemblyScript) but is
harder for casual modders. Keep it as a possible *advanced* lane later.
- Embedding: a Lua-in-Godot binding (e.g. `godot-luaAPI` GDExtension) or a custom
GDExtension wrapping Lua 5.4 / LuaJIT.
## Addon package layout
```
my_addon/
addon.json manifest (required)
main.lua entry script (optional — data-only addons skip it)
items/ data definitions (optional)
prefabs/ low-poly meshes/scenes the addon adds (optional, validated)
assets/ textures, icons (optional, size-capped)
```
### addon.json (manifest)
```json
{
"id": "com.paul.ironpike",
"name": "Iron Pike",
"version": "1.0.0",
"api": "1", // SDK API version it targets
"author": "Paul",
"permissions": ["items", "recipes", "events"], // explicit capability opt-in
"entry": "main.lua" // omit for data-only addons
}
```
- **Permissions are explicit.** An addon only gets the API groups it declares, and
the server can refuse/limit them. A data-only addon declares no code perms at all.
## The sandbox (how the guard rails actually work)
1. Each addon gets its **own fresh Lua VM** (isolation between addons).
2. The global environment is a **whitelist table we build** — standard-library
access is stripped: no `os`, `io`, `package`, `require`, `dofile`, `loadfile`,
`load`, `debug`. Only safe primitives (`math`, `string`, `table`, `pairs`,
`ipairs`, `tostring`, a sandboxed `print` that routes to the server log).
3. The host injects a single global, `game`, which is the **entire API surface** —
the only way an addon touches the world.
4. **Instruction/time quota:** every addon callback runs under a hook that counts
VM instructions (or wall-clock) and aborts if it exceeds the budget. An addon
that loops forever is killed, the tick is never blocked.
5. **Memory quota:** the VM's allocator is capped; exceeding it aborts the addon,
not the server.
6. **No reentry into the engine:** the `game` API functions are thin, validated
shims written in GDScript/C++. The addon can't reach the Godot API, the
filesystem, or the network.
7. **Fault isolation:** an error in an addon callback is caught, logged with the
addon id, and the addon can be disabled — the server keeps running.
## API surface (v1 sketch — `game.*`)
Events (register handlers):
```lua
game.on("player_join", function(player) ... end)
game.on("block_place", function(player, piece, pos) ... end) -- can veto
game.on("tick", function(dt) ... end) -- quota-capped
game.on("entity_death", function(entity, killer) ... end)
```
World queries (read-only, validated):
```lua
local p = game.players.get(id) -- safe player handle
local n = game.world.count_entities("tree")
local b = game.world.get_block(x, y, z) -- within loaded bounds only
```
World commands (intents the host validates & applies):
```lua
game.world.spawn("wolf", x, y, z) -- subject to limits/quotas
game.chat.broadcast("Welcome, " .. p.name)
game.inventory.give(p, "wood", 5) -- bounded
```
Content declaration (data, validated at load):
```lua
game.define_item{ id="iron_pike", name="Iron Pike", damage=42, stack=1 }
game.define_recipe{ output="iron_pike", inputs={iron=10, wood=4}, station="forge" }
```
Everything an addon can do lives under `game.`. If it's not there, the addon can't
do it — that's the guard rail.
## Limits the server enforces (configurable by the host owner)
- Max addons loaded
- Per-callback instruction/time budget
- Per-addon memory ceiling
- Rate limits on spawn/give/broadcast (so an addon can't spam)
- Asset size caps (prefab poly count, texture dimensions)
- Permission allow/deny per addon
## Threat model (what we explicitly defend against)
| Threat | Defence |
|--------|---------|
| Addon reads server files / secrets | No `io`/`os`; no filesystem in the sandbox env |
| Addon opens network connections | No socket library exposed |
| Addon hangs the server (infinite loop) | Instruction/time quota aborts the callback |
| Addon exhausts RAM | Per-VM memory cap |
| Addon crashes the server | Every callback wrapped; errors isolate & disable the addon |
| Addon escalates via another addon | Each addon = separate VM, no shared globals |
| Addon spams spawns/messages | Host rate limits on world commands |
| Malicious prefab (huge mesh) | Asset validation + poly/texture caps at load |
## A test we must pass (M3 done-criteria)
Ship a deliberately-malicious example addon in the test suite that tries to:
read a file, open a socket, loop forever, and allocate gigabytes. The server must
contain all four, log them, disable the addon, and keep running. If that passes,
the guard rails are real.