DOCUMENTATION · WASM ABI 参考

WASM ABI 参考

本文是 Rust WASM 插件与宿主之间二进制接口的唯一权威来源。 src/plugins/WasmPluginLoader.ts(宿主侧)与 wasm-src//src/lib…

同步自 docs/ABI.md(461 行) · commit b2b4124 · 读源文件 ↗

仅支持 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

核心约定

  1. 宿主 → 插件:用 alloc 分配内存 → 写入 UTF-8 JSON 字节 → 调 plugin_handle(ptr, len) → 调用结束后宿主动调 dealloc(ptr, len)。
  2. 插件 → 宿主:插件把返回 JSON 字节存进静态变量, 宿主通过 plugin_result_ptr() / plugin_result_len() 读取。
  3. 宿主能力(imports):插件用 alloc 写入请求 JSON,调用宿主函数,读完后自己 dealloc。

内存布局约定

普通字符串缓冲区(alloc 分配)

ptr ──► ┌─────────────── UTF-8 bytes ───────────────┐
        └──────────────── len 字节 ─────────────────┘

host_get_state 返回值缓冲区(长度前缀)

host_get_state 需要把「一段 JSON」交回插件,为了避免插件猜长度,采用 4 字节小端长度前缀:

ptr ──► ┌────────────┬──────────────── JSON (UTF-8) ────────────────┐
        │ u32 LE len │          len 字节的 JSON 文本                 │
        └── 4 字节 ──┴──────────────────── len ──────────────────────┘

JSON 形如:

{ "ok": true, "path": "flag.hasKey", "value": true }

必需导出(插件 → 宿主)

宿主加载插件后会校验下列导出全部存在(REQUIRED_WASM_EXPORTS),缺失则报 PLUGIN_WASM_MISSING_EXPORTS。

导出函数签名说明
memoryWebAssembly.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);没声明只告警,按「跟随宿主」处理

函数语义

结果 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)

host_emit(ptr, len)

host_get_state(ptr, len)

host_set_state(ptr, len)


消息格式(宿主 → 插件)

宿主发送给 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 对象。所有字段可选:

字段类型说明
okboolean是否成功,缺省视为 true。false 且状态码非 0 时宿主抛错
messagestring人类可读消息;命令场景下会被引擎渲染成对白
errorstring错误描述;状态码非 0 时用于错误消息
commandsstring[]仅 init 阶段有效:声明本插件注册的命令名,宿主为每个名字注册代理
eventsstring[]init 阶段:声明要监听的事件名;命令阶段:请宿主广播这些事件
effectsEffectSpec[]需要宿主落地的状态变更,如 { "type": "set", "key": "flag.x", "value": true }
dataany任意结构化数据;命令阶段广播 events 时作为 payload
nextstring | null指定下一个剧情节点(覆盖节点 next;null 表示结束)
stopboolean是否终止引擎主循环

示例(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)落地时机


错误码约定

plugin_init / plugin_handle 的返回值(负数表示错误):

常量值含义
WASM_STATUS.OK0成功
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):


宿主侧错误码(加载阶段)

WasmPluginLoader 抛出的 PluginError 带有下列 code(便于定位阶段):

code触发条件
PLUGIN_WASM_FETCH_FAILEDfetch(entryUrl) 网络失败或 HTTP 非 2xx
PLUGIN_WASM_INVALID文件小于 8 字节,或前 4 字节不是 \0asm(常见于服务器返回 HTML 404)
PLUGIN_WASM_INSTANTIATE_FAILEDWebAssembly.instantiate 失败(缺 import、目标不匹配等)
PLUGIN_WASM_MISSING_EXPORTS缺少必需导出之一,消息里会列出缺失项
PLUGIN_WASM_BAD_POINTER插件返回越界指针(assertRange 校验失败)
PLUGIN_WASM_BAD_RESULTplugin_result_ptr/len 指向的内容不是合法 JSON
PLUGIN_WASM_CALL_FAILEDplugin_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. 内存归属要分清

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。