DOCUMENTATION · WASM ABI 参考
WASM ABI 参考
本文是 Rust WASM 插件与宿主之间二进制接口的唯一权威来源。 src/plugins/WasmPluginLoader.ts(宿主侧)与 wasm-src//src/lib…
仅支持 Rust。 目标平台
wasm32-unknown-unknown,crate-type = ["cdylib"], 直接导出 C ABI,不使用 wasm-bindgen,不提供也不保证 AssemblyScript / C / C++ / Go / Zig 的兼容性。
ABI 版本
| 项 | 值 |
|---|---|
| ABI 版本 | 1 |
宿主插件 API 版本(PLUGIN_API_VERSION) | 1(manifest 的 apiVersion 必须等于它) |
可选导出 plugin_abi_version() 返回 | 1(建议导出 —— 声明不匹配时宿主会当场拒绝加载,而不是跑出诡异行为) |
| 通信编码 | UTF-8 JSON 字符串 |
| 消息模块名(宿主 imports) | "env" |
| 整数类型 | i32(wasm32 指针即 32 位) |
总览:跨边界通信
sequenceDiagram
participant H as 宿主 (WasmPluginLoader.ts)
participant G as WASM 插件 (Rust lib.rs)
Note over H,G: 加载阶段
H->>H: fetch(plugin.wasm) + magic 校验
H->>G: WebAssembly.instantiate(bytes, { env })
H->>H: 校验必需导出 (alloc/dealloc/plugin_init/...)
H->>G: plugin_init()
G-->>H: 状态码 0
H->>G: plugin_result_ptr() / plugin_result_len()
G-->>H: 指向 {"ok":true,"commands":[...],"events":[...]}
H->>H: 按 commands 注册命令代理,按 events 注册事件代理
Note over H,G: 调用阶段
H->>G: alloc(len) -> ptr
H->>G: 写入 UTF-8 JSON 到 [ptr, ptr+len)
H->>G: plugin_handle(ptr, len)
G->>G: 解析 JSON、执行逻辑
G->>H: host_log / host_get_state / host_set_state / host_emit
G-->>H: 状态码
H->>G: dealloc(ptr, len) 归还入参内存
H->>G: plugin_result_ptr() / plugin_result_len()
G-->>H: 指向返回 JSON
H->>H: 解析 JSON,落地 effects / message / next / stop核心约定
- 宿主 → 插件:用
alloc分配内存 → 写入 UTF-8 JSON 字节 → 调plugin_handle(ptr, len)→ 调用结束后宿主动调dealloc(ptr, len)。 - 插件 → 宿主:插件把返回 JSON 字节存进静态变量,
宿主通过
plugin_result_ptr()/plugin_result_len()读取。 - 宿主能力(imports):插件用
alloc写入请求 JSON,调用宿主函数,读完后自己dealloc。
内存布局约定
普通字符串缓冲区(alloc 分配)
ptr ──► ┌─────────────── UTF-8 bytes ───────────────┐
└──────────────── len 字节 ─────────────────┘- 编码固定为 UTF-8,无 BOM,无 NUL 结尾符(长度由
len显式给出)。 alloc返回的指针不保证任何特定对齐(当前实现按对齐 1 分配),只用于存放字节。
host_get_state 返回值缓冲区(长度前缀)
host_get_state 需要把「一段 JSON」交回插件,为了避免插件猜长度,采用 4 字节小端长度前缀:
ptr ──► ┌────────────┬──────────────── JSON (UTF-8) ────────────────┐
│ u32 LE len │ len 字节的 JSON 文本 │
└── 4 字节 ──┴──────────────────── len ──────────────────────┘- 前 4 字节是 小端无符号 32 位 的长度
len; - 紧随其后是
len字节的 UTF-8 JSON; - 整块缓冲区(
4 + len字节)由宿主的alloc分配,插件读完后必须调用dealloc(ptr, len + 4), 否则每次读取都会泄漏线性内存。 - 返回指针为
0表示失败(例如未授权或解析请求失败)。
JSON 形如:
{ "ok": true, "path": "flag.hasKey", "value": true }必需导出(插件 → 宿主)
宿主加载插件后会校验下列导出全部存在(REQUIRED_WASM_EXPORTS),缺失则报
PLUGIN_WASM_MISSING_EXPORTS。
| 导出函数 | 签名 | 说明 |
|---|---|---|
memory | WebAssembly.Memory | 线性内存。宿主用它创建 Uint8Array / DataView 读写 |
alloc | (size: i32) -> i32 | 分配 size 字节,返回指针;返回 0 表示失败 |
dealloc | (ptr: i32, size: i32) -> () | 释放 alloc 分配的内存,size 必须与分配时一致 |
plugin_init | () -> i32 | 初始化,返回 ABI 状态码 |
plugin_handle | (ptr: i32, len: i32) -> i32 | 处理一条请求,返回 ABI 状态码 |
plugin_result_ptr | () -> i32 | 最近一次返回 JSON 的指针 |
plugin_result_len | () -> i32 | 最近一次返回 JSON 的字节长度 |
可选导出
| 导出函数 | 签名 | 说明 |
|---|---|---|
plugin_abi_version | () -> i32 | 插件自行声明的 ABI 版本。宿主现在会协商:声明了就必须等于宿主版本,否则拒绝加载(PLUGIN_WASM_ABI_MISMATCH);没声明只告警,按「跟随宿主」处理 |
函数语义
alloc(size):宿主在写入入参、以及host_get_state回写时调用。size <= 0应返回 0。Rust 参考实现用std::alloc::alloc+Layout::from_size_align(size, 1)。dealloc(ptr, size):ptr <= 0 || size <= 0时可直接返回。plugin_init():宿主在实例化后调用一次。插件应在RESULT里写入{ "ok": true, "commands": [...], "events": [...] },宿主据此注册命令代理与事件代理。 返回0表示成功,负数为错误码。plugin_handle(ptr, len):入参是宿主持有的 UTF-8 JSON。插件解析后按type分发处理, 把返回 JSON 写入RESULT并返回状态码。插件不得释放ptr(由宿主负责dealloc)。plugin_result_ptr()/plugin_result_len():返回最近一次结果的指针与长度。 宿主在每次plugin_init/plugin_handle之后都会读取它们。 若返回ptr == 0或len <= 0且状态码非 0,宿主按「无结果」处理并报错。
结果 JSON 的所有权
RESULT 是插件的静态变量,其内存由插件持有,宿主只读取不释放。
插件每次处理消息都会整体替换 RESULT(Rust 参考实现里是 RESULT = bytes),
因此旧结果的指针在下次调用后即失效 —— 宿主必须在下次调用前读取完毕(当前实现满足这一点)。
宿主 imports(宿主 → 插件)
插件通过 #[link(wasm_import_module = "env")] extern "C" 声明,对应宿主的
WebAssembly.instantiate(bytes, { env })。
| import 函数 | 签名 | 请求 JSON | 返回 |
|---|---|---|---|
host_log | (level: i32, ptr: i32, len: i32) -> () | 纯文本(非 JSON) | 无 |
host_emit | (ptr: i32, len: i32) -> i32 | { "name": "...", "payload": {...} } | 0 成功 / 负数为错误码 |
host_get_state | (ptr: i32, len: i32) -> i32 | { "path": "flag.x" } | 长度前缀缓冲区指针;0 表示失败 |
host_set_state | (ptr: i32, len: i32) -> i32 | { "path": "flag.x", "value": true } | 0 成功 / 负数为错误码 |
host_log(level, ptr, len)
level:0 = debug,1 = info,2 = warn,3 = error(其它值按 debug 处理)。ptr/len指向 UTF-8 文本(不是 JSON)。- 宿主用该插件的日志器输出,因此 scope 为
plugin:<id>。 - 无权限要求;无返回值。插件应在调用后立即
dealloc该缓冲区。
host_emit(ptr, len)
- 需要权限
events:emit;未授权返回-100(HOST_REJECTED)。 - 请求 JSON:
{ "name": "wasm:greeted", "payload": { ... } };缺少name返回-1(PARSE_ERROR)。 - 宿主以
{ source: 'plugin', pluginId }广播事件。
host_get_state(ptr, len)
- 需要权限
state:read;未授权返回0。 - 请求 JSON:
{ "path": "var.gold" };缺少path返回0。 - 成功返回长度前缀缓冲区指针(布局见上文),
JSON 为
{ "ok": true, "path": "var.gold", "value": 5 };值不存在时value为null。 - 插件必须
dealloc(ptr, len + 4)。
host_set_state(ptr, len)
- 需要权限
state:write;未授权返回-100。 - 请求 JSON:
{ "path": "flag.wasmGreeted", "value": true };缺少path返回-1。 - 成功返回
0。写入会触发引擎的state:change广播与 UI 状态栏刷新。
消息格式(宿主 → 插件)
宿主发送给 plugin_handle 的 JSON 有三种 type:
1. init
{
"type": "init",
"payload": {
"pluginId": "com.example.wasmhello",
"apiVersion": 1,
"permissions": ["state:read", "state:write", "commands:register", "events:emit"],
"manifest": { "id": "com.example.wasmhello", "name": "WASM Hello", "version": "1.0.0", "type": "wasm", "entry": "plugin.wasm", "apiVersion": 1 }
}
}2. command
{ "type": "command", "name": "wasm_greet", "args": { "name": "旅行者" } }args 已经过引擎的文本插值(例如剧情里写 "{{ actor.hero.name }}" 会替换成实际值)。
3. event
{ "type": "event", "name": "story:nodeEnter", "payload": { "nodeId": "greetByWasm" } }插件在 plugin_init 的返回值里通过 events 数组声明要监听哪些事件,宿主才会转发。
返回值格式(插件 → 宿主)
plugin_result_ptr/len 指向的 JSON 对象。所有字段可选:
| 字段 | 类型 | 说明 |
|---|---|---|
ok | boolean | 是否成功,缺省视为 true。false 且状态码非 0 时宿主抛错 |
message | string | 人类可读消息;命令场景下会被引擎渲染成对白 |
error | string | 错误描述;状态码非 0 时用于错误消息 |
commands | string[] | 仅 init 阶段有效:声明本插件注册的命令名,宿主为每个名字注册代理 |
events | string[] | init 阶段:声明要监听的事件名;命令阶段:请宿主广播这些事件 |
effects | EffectSpec[] | 需要宿主落地的状态变更,如 { "type": "set", "key": "flag.x", "value": true } |
data | any | 任意结构化数据;命令阶段广播 events 时作为 payload |
next | string | null | 指定下一个剧情节点(覆盖节点 next;null 表示结束) |
stop | boolean | 是否终止引擎主循环 |
示例(init):
{ "ok": true, "message": "hello-wasm 初始化完成", "commands": ["wasm_greet"], "events": [] }示例(命令返回值):
{
"ok": true,
"message": "【WASM Hello · Rust】你好,旅行者!你现在有 5 枚金币。",
"effects": [
{ "type": "set", "key": "flag.wasmGreeted", "value": true },
{ "type": "set", "key": "var.wasmGreeting", "value": "Rust 向 旅行者 问好" }
],
"data": { "name": "旅行者", "gold": 5 }
}效果(effects)落地时机
init阶段返回的effects:由WasmPluginDriver.init通过bridge.applyEffects落地一次。- 命令 / 事件阶段返回的
effects:由WasmPluginDriver.toCommandResult落地, 同时向上返回空数组,避免引擎再落地一次(防重复)。 - 内置效果类型:
set/setFlag/addItem/removeItem/emit;其它类型交给插件的效果处理器。
错误码约定
plugin_init / plugin_handle 的返回值(负数表示错误):
| 常量 | 值 | 含义 |
|---|---|---|
WASM_STATUS.OK | 0 | 成功 |
WASM_STATUS.PARSE_ERROR | -1 | 入参 JSON 解析失败 / 请求缺少必需字段 |
WASM_STATUS.UNKNOWN_MESSAGE | -2 | 未知消息类型 / 不支持的命令 |
WASM_STATUS.INTERNAL_ERROR | -3 | 插件内部错误 |
WASM_STATUS.HOST_REJECTED | -100 | 宿主拒绝(权限不足等),同时也是 host_emit/host_set_state 的返回值 |
宿主对状态码的处理(WasmInstance.callInit / callHandle):
- 状态码为
0且能读到结果 JSON → 返回结果。 - 状态码非
0→ 抛PluginError,消息为plugin_handle 返回错误码 <code>(<可读描述或插件返回的 error>)。可读描述由describeWasmStatus提供。 - 状态码为
0但读不到结果(ptr == 0或len <= 0)→ 抛plugin_handle 返回成功但没有通过 plugin_result_ptr 写入结果。 - 返回结果不是合法 JSON → 抛
PLUGIN_WASM_BAD_RESULT,并附带原始内容前 200 字符便于排查。
宿主侧错误码(加载阶段)
WasmPluginLoader 抛出的 PluginError 带有下列 code(便于定位阶段):
| code | 触发条件 |
|---|---|
PLUGIN_WASM_FETCH_FAILED | fetch(entryUrl) 网络失败或 HTTP 非 2xx |
PLUGIN_WASM_INVALID | 文件小于 8 字节,或前 4 字节不是 \0asm(常见于服务器返回 HTML 404) |
PLUGIN_WASM_INSTANTIATE_FAILED | WebAssembly.instantiate 失败(缺 import、目标不匹配等) |
PLUGIN_WASM_MISSING_EXPORTS | 缺少必需导出之一,消息里会列出缺失项 |
PLUGIN_WASM_BAD_POINTER | 插件返回越界指针(assertRange 校验失败) |
PLUGIN_WASM_BAD_RESULT | plugin_result_ptr/len 指向的内容不是合法 JSON |
PLUGIN_WASM_CALL_FAILED | plugin_handle 返回非 0 状态码 |
Rust 实现注意事项
1. 导出必须是 #[no_mangle] pub extern "C"
#[no_mangle]
pub extern "C" fn alloc(size: i32) -> i32 { /* ... */ }否则符号名会被 Rust 修饰(mangle),宿主找不到导出。
2. 必须导出 memory
wasm32-unknown-unknown 的 cdylib 默认导出线性内存,通常无需额外配置;
若宿主报 memory 不是 WebAssembly.Memory,检查是否误用了 --target wasm32-wasi 等其它目标。
3. 静态可变变量
用静态变量保存最近一次返回值:
static mut RESULT: Vec<u8> = Vec::new();在 Rust 2021 中访问 static mut 需要 unsafe,并会产生 static_mut_refs 警告。
WASM 插件默认单线程,可在文件顶部加:
#![allow(static_mut_refs)]4. 不要被 panic 拖垮
Cargo.toml 的 release profile 设 panic = "abort",并且避免在 ABI 边界 panic:
任何 unwrap()/数组越界都会导致实例不可用。参考实现对 JSON 解析用 serde_json::from_slice(...).ok()
返回 Option,而不是 unwrap()。
5. 内存归属要分清
- 宿主
alloc的内存 → 宿主dealloc(插件不要释放plugin_handle的入参)。 - 插件为调用 imports 而
alloc的内存 → 插件自己dealloc。 host_get_state返回的缓冲区 → 插件dealloc(ptr, len + 4)(整块,含 4 字节头)。RESULT→ 插件的静态变量,宿主只读,插件不要dealloc。
6. 长度用字节而非字符数
len 一律是 UTF-8 字节长度。Rust 里用 text.as_bytes().len(),不要用 text.len() 之外的
「字符数」概念(Rust 的 str::len() 本身就是字节长度,但 chars().count() 不是)。
7. 目标与 profile
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
panic = "abort"
strip = true构建命令:
cargo build --target wasm32-unknown-unknown --release产物路径:target/wasm32-unknown-unknown/release/<crate_name>.wasm
(crate 名中的连字符会变成下划线,例如 hello-wasm → hello_wasm.wasm)。
8. 宿主内存视图会失效
WebAssembly.Memory 在增长后会替换底层 ArrayBuffer,旧视图失效。
宿主侧 WasmInstance 每次读写都重新创建 Uint8Array / DataView,插件侧无需关心,
但如果你在宿主侧扩展实现,请遵循同样的做法。
完整的最小 ABI 骨架(Rust)
#![allow(static_mut_refs)]
use std::alloc::{alloc as sys_alloc, dealloc as sys_dealloc, Layout};
use std::slice;
use serde_json::{json, Value};
#[link(wasm_import_module = "env")]
extern "C" {
fn host_log(level: i32, ptr: i32, len: i32);
fn host_emit(ptr: i32, len: i32) -> i32;
fn host_get_state(ptr: i32, len: i32) -> i32;
fn host_set_state(ptr: i32, len: i32) -> i32;
}
static mut RESULT: Vec<u8> = Vec::new();
#[no_mangle]
pub extern "C" fn alloc(size: i32) -> i32 {
if size <= 0 { return 0; }
match Layout::from_size_align(size as usize, 1) {
Ok(layout) => unsafe {
let p = sys_alloc(layout);
if p.is_null() { 0 } else { p as i32 }
},
Err(_) => 0,
}
}
#[no_mangle]
pub extern "C" fn dealloc(ptr: i32, size: i32) {
if ptr <= 0 || size <= 0 { return; }
if let Ok(layout) = Layout::from_size_align(size as usize, 1) {
unsafe { sys_dealloc(ptr as *mut u8, layout) };
}
}
#[no_mangle]
pub extern "C" fn plugin_init() -> i32 {
set_result(&json!({ "ok": true, "commands": ["ping"], "events": [] }));
0
}
#[no_mangle]
pub extern "C" fn plugin_handle(ptr: i32, len: i32) -> i32 {
if ptr <= 0 || len <= 0 { return -1; }
let bytes = unsafe { slice::from_raw_parts(ptr as *const u8, len as usize) };
let request: Value = match serde_json::from_slice(bytes) {
Ok(v) => v,
Err(_) => { set_result(&json!({ "ok": false, "error": "bad json" })); return -1; }
};
match request.get("type").and_then(Value::as_str).unwrap_or("") {
"init" => plugin_init(),
"command" => {
set_result(&json!({ "ok": true, "message": "pong" }));
0
}
_ => { set_result(&json!({ "ok": false, "error": "unknown" })); -2 }
}
}
#[no_mangle]
pub extern "C" fn plugin_result_ptr() -> i32 { unsafe { RESULT.as_ptr() as i32 } }
#[no_mangle]
pub extern "C" fn plugin_result_len() -> i32 { unsafe { RESULT.len() as i32 } }
fn set_result(value: &Value) {
let bytes = serde_json::to_vec(value).unwrap_or_else(|_| b"{\"ok\":false}".to_vec());
unsafe { RESULT = bytes };
}完整可运行示例(含调用宿主 imports 读写状态、广播事件)见 wasm-src/hello-wasm/src/lib.rs 与 PLUGIN_WASM.md。