DOCUMENTATION · WASM 插件开发
WASM 插件开发
本文介绍如何用 Rust 编写一个 WASM 插件。ABI 的精确约定见 ABI.mdABI.md(本文会引用它,不重复定义)。
仅支持 Rust。 目标
wasm32-unknown-unknown,crate-type = ["cdylib"],直接导出 C ABI, 不使用 wasm-bindgen。本项目不提供、也不保证 AssemblyScript / C / C++ / Go / Zig 的兼容性, 不使用 Python / Pyodide,也不存在任何.py文件或 Python 构建步骤。
目录结构
wasm-src/my-wasm/ # Rust 源码(提交到仓库)
├── Cargo.toml
├── Cargo.lock
└── src/
└── lib.rs
public/plugins/my-wasm/ # 浏览器可访问的插件目录
├── manifest.json # 插件清单
└── plugin.wasm # 构建产物(由 cargo build 后复制而来)Rust 源码放在 wasm-src/,产物复制到 public/plugins/。二者职责分离:
前者是源码(不参与前端构建),后者是静态资源(被 Vite 原样提供、被浏览器 fetch 加载)。
manifest.json 字段说明
{
"id": "com.example.wasmhello",
"name": "WASM Hello",
"version": "1.0.0",
"type": "wasm",
"entry": "plugin.wasm",
"apiVersion": 1,
"description": "Rust 编写的 WASM 插件示例",
"author": "your-name",
"permissions": ["state:read", "state:write", "commands:register", "events:emit"],
"timeoutMs": 2000
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | ✅ | 全局唯一 id |
name | string | ✅ | 展示名 |
version | string | ✅ | 语义化版本 |
type | "wasm" | ✅ | 固定为 "wasm" |
entry | string | ✅ | 必须是 .wasm 文件,相对 manifest.json 所在目录(通常为 plugin.wasm) |
apiVersion | number | ✅ | 必须为 1 |
permissions | string[] | ➖ | 申请的权限,见 PERMISSIONS.md |
description / author | string | ➖ | 说明与作者 |
timeoutMs | number | ➖ | 单次调用超时(毫秒),缺省 3000ms |
常见踩坑:若要注册命令,必须声明 commands:register。
宿主要先通过该权限为插件注册命令代理,否则 plugin_init 返回的 commands 会被拒绝,
后续剧情调用该命令会报「未注册的命令」。
WASM ABI 速览
完整的函数签名、内存布局、错误码见 ABI.md。这里给出速查表。
插件必须导出
| 导出 | 签名 | 作用 |
|---|---|---|
memory | WebAssembly.Memory | 线性内存 |
alloc | (size: i32) -> i32 | 分配内存;返回 0 表示失败 |
dealloc | (ptr: i32, size: i32) -> () | 释放内存 |
plugin_init | () -> i32 | 初始化;返回里用 commands / events 声明能力 |
plugin_handle | (ptr: i32, len: i32) -> i32 | 处理请求 |
plugin_result_ptr | () -> i32 | 结果 JSON 指针 |
plugin_result_len | () -> i32 | 结果 JSON 字节长度 |
宿主提供的 imports(模块名 env)
| import | 签名 | 需要的权限 |
|---|---|---|
host_log | (level: i32, ptr: i32, len: i32) | 无 |
host_emit | (ptr: i32, len: i32) -> i32 | events:emit |
host_get_state | (ptr: i32, len: i32) -> i32 | state:read |
host_set_state | (ptr: i32, len: i32) -> i32 | state:write |
消息(宿主 → 插件)
{ "type": "init", "payload": { "pluginId": "...", "apiVersion": 1, "permissions": [...], "manifest": {...} } }
{ "type": "command", "name": "wasm_greet", "args": { "name": "旅行者" } }
{ "type": "event", "name": "story:nodeEnter", "payload": { "nodeId": "..." } }返回值(插件 → 宿主)
{ "ok": true, "commands": ["wasm_greet"], "events": ["story:end"] }
{ "ok": true, "message": "你好", "effects": [{ "type": "set", "key": "flag.x", "value": true }] }字段:ok / message / error / commands / events / effects / data / next / stop。
错误码
| 值 | 含义 |
|---|---|
0 | 成功 |
-1 | 入参 JSON 解析失败 / 缺字段 |
-2 | 未知消息类型 / 不支持的命令 |
-3 | 插件内部错误 |
-100 | 宿主拒绝(权限不足) |
内存管理与字符串传递
这是写 WASM 插件最容易出错的部分,务必理解「谁分配、谁释放」。
宿主 → 插件(入参)
宿主: ptr = alloc(len) // 调用插件的 alloc
宿主: 把 UTF-8 JSON 写入 memory[ptr..ptr+len]
宿主: plugin_handle(ptr, len)
插件: 只读这个缓冲区,绝不 dealloc
宿主: dealloc(ptr, len) // 在 finally 里归还插件 → 宿主(返回值)
插件: RESULT = serde_json::to_vec(&value) // 存进静态变量
宿主: ptr = plugin_result_ptr(); len = plugin_result_len()
宿主: 读取 memory[ptr..ptr+len] 并 JSON.parse
宿主: 不释放(RESULT 归插件所有)插件调用宿主 imports
插件: ptr = alloc(len); 写入请求 JSON
插件: host_xxx(ptr, len) 或 host_log(level, ptr, len)
插件: dealloc(ptr, len) // 立即归还host_get_state 的特殊返回值
宿主返回的是 「4 字节小端长度 + JSON」 的整块缓冲区(由宿主的 alloc 分配),
插件读完后必须 dealloc(ptr, len + 4)。
let buffer = unsafe { host_get_state(ptr, len) };
dealloc(ptr, len); // 归还请求缓冲区
if buffer <= 0 { return None; }
let header = unsafe { slice::from_raw_parts(buffer as *const u8, 4) };
let payload_len = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as i32;
let payload = unsafe { slice::from_raw_parts((buffer + 4) as *const u8, payload_len as usize) };
let value: Option<Value> = serde_json::from_slice(payload).ok();
dealloc(buffer, payload_len + 4); // 归还整块(含 4 字节头)WASM(wasm32-unknown-unknown)对 Rust 的限制与不兼容
前提:本项目编译目标为
wasm32-unknown-unknown,直接WebAssembly.instantiate加载。 它既不是 Emscripten(没有-s链接选项、没有文件系统 shim、没有-sEXIT_RUNTIME), 也不是 WASI(wasi_snapshot_preview1那套 clock / fd / random 全都没有)。 宿主只向插件提供env模块下的 4 个函数(host_log / host_emit / host_get_state / host_set_state), 除此之外,插件位于一个几近空白的裸机环境。下面按严重程度列出 Rust 语言层受到影响或不可用的能力。
A. 标准库中「依赖操作系统」的能力:编译期就不可用或直接缺失
只要代码里用到了这些项,cargo build --target wasm32-unknown-unknown 会直接编译报错,
运行时不存在「降级可用」:
| 能力 | 状态 | 说明 |
|---|---|---|
std::fs(文件读写) | ❌ 无 | 没有文件系统,任何 File API 编译期即失败 |
std::net(TCP/UDP) | ❌ 无 | 没有网络栈,无法建立 socket |
std::process(起进程) | ❌ 无 | 没有进程概念 |
std::env(环境变量/args) | ⚠️ 空/不可靠 | args() 为空,环境变量拿不到真实值 |
std::io::stdin / stdout / stderr | ⚠️ 无输出目标 | println! / dbg! 不会打到浏览器控制台,基本静默。日志必须走 host_log |
std::time(Instant / SystemTime) | ⚠️ 不可靠 | 无接入宿主时钟时不可用或返回固定值。需要时间戳就由宿主注入或自计数,别依赖系统时间 |
多线程 std::thread | ❌ 无 | 默认未开启 atomics/threads 特性,thread::spawn 不可用。插件天生单线程 |
HashMap / HashSet 默认随机种子 | ⚠️ 固定 | RandomState 拿不到 OS 熵源,哈希种子固定、可预测。需要随机就自备 PRNG |
B. 需要「宿主环境」参与才能工作,否则陷阱
| 能力 | 陷阱 | 正确的做法 |
|---|---|---|
| panic | 默认 unwinding 在 wasm32 上无意义;panic = "abort" 下任何 panic 都是不可恢复的 trap,无法被 catch_unwind/宿主捕获为「错误码」,会造成整个实例崩溃 → 宿主报 PLUGIN_WASM_INSTANTIATE_FAILED | 永远不要依赖 panic。用 Result / Option 显式处理错误,把错误写进返回值 { "ok": false, "error": "..." } 返回非零状态码 |
panic::catch_unwind | panic=abort + 无 unwind 表格 → 实际不可靠 | 同上,别用 |
StackTrace / Backtrace | 无栈展开信息 | 错误排查靠返回值里的 error 文本 + 逐行 host_log |
静态可变项 static mut | 近期 Rust 对 static mut 的裸访问会报 static_mut_refs 告警(edition 2024 变 error) | 示例源码用 #![allow(static_mut_refs)] 显式放行(本项目 ABI 正是靠 static mut RESULT: Vec<u8> 回传) |
C. 数值与内存方面的限制
| 项目 | 现状 | 影响 |
|---|---|---|
usize / 指针宽度 | 32 位 | 单实例线性内存上限约 4 GiB(实际受浏览器单实例限制更小)。长度/指针一律用 i32 表达 |
| 浮点超越函数 | 无硬件 libm | sin/cos/exp/log 等没有对应的 wasm 指令,会退化为软件实现(体积 + 变慢),或引入 libm crate |
| 基础浮点指令 | f32/f64 的 * + - / sqrt 等有对应指令 | 编译器会用原生指令,正常 |
| SIMD | 默认不开启 simd128 特性,编译器不会自动向量化 | 重计算(如流体)若要向量化,需显式 RUSTFLAGS="-C target-feature=+simd128" 并在代码里用 #[cfg(target_feature = "simd128")] 分支 |
| 调用栈大小 | 默认栈很小(由链接期 stack pointer 初值决定,量级约几百 KB ~ 1 MB) | 深递归、[f64; 大N] 栈数组极易栈溢出 trap。大数组必须用 alloc 放到堆/线性内存上,避免深递归 |
单个 alloc 分配 | 由 Layout 容错,失败返回 0 | 分配失败要检查返回值,不要盲写 |
D. 与「本项目 ABI」有关的兼容性注意
- 名字不要被 mangle:导出必须
#[no_mangle] pub extern "C",且是简单标量参数(i32等)。误用wasm-bindgen会额外引入运行时并改变符号名,导致PLUGIN_WASM_MISSING_EXPORTS。 - 字符串跨边界是「字节」:
RESULT.len()返回字节数,不是字符数;错用chars().count()会让plugin_result_len返回错误长度 →PLUGIN_WASM_BAD_RESULT。 - 所有数据都是松散的字节:wasm 线性内存没有 GC、没有所有权概念。谁
alloc谁负责配对dealloc,宿主传入的缓冲区插件绝不释放。 - 没有额外的
println调试输出:唯一观测手段是host_log,调试请用它。
E. 一句话总结
在
wasm32-unknown-unknown上,Rust 的「数据运算、字符串、集合、迭代器、串行算法」完整可用; 凡是要操作系统 / 时钟 / 网络 / 文件 / 多线程 / 随机熵 / 真正的异常展开的能力,大多不可用或需要宿主注入。 写插件时把它当成「一个没有 OS、只有内存和 4 个回调的纯计算沙箱」,错误一律显式返回,不要 panic。
Rust 示例完整源码
完整的示例插件(响应 init 与 wasm_greet 命令,读写状态并广播事件):
wasm-src/hello-wasm/src/lib.rs
//! # hello-wasm —— Rust WASM 插件示例
//!
//! - 编译目标 wasm32-unknown-unknown,不使用 wasm-bindgen,直接按 ABI 导出 C ABI。
//! - 宿主 -> 插件:UTF-8 JSON,先 alloc 再写字节,调用 plugin_handle(ptr, len),返回后 dealloc。
//! - 插件 -> 宿主:把返回 JSON 存入静态变量 RESULT,宿主用 plugin_result_ptr/len 读取。
#![allow(static_mut_refs)]
use std::alloc::{alloc as sys_alloc, dealloc as sys_dealloc, Layout};
use std::slice;
use serde_json::{json, Value};
// ── 宿主提供的 imports(模块名固定为 env) ──────────────────────────────
#[link(wasm_import_module = "env")]
extern "C" {
/// 写一行日志。level: 0=debug 1=info 2=warn 3=error
fn host_log(level: i32, ptr: i32, len: i32);
/// 广播事件。请求体 {"name":"...","payload":{...}};返回 0 成功,负数为错误码
fn host_emit(ptr: i32, len: i32) -> i32;
/// 读取状态。请求体 {"path":"..."};返回「4 字节小端长度 + JSON」缓冲区指针,0 表示失败
fn host_get_state(ptr: i32, len: i32) -> i32;
/// 写入状态。请求体 {"path":"...","value":...};返回 0 成功,负数为错误码
fn host_set_state(ptr: i32, len: i32) -> i32;
}
// ── 静态状态 ────────────────────────────────────────────────────────────
/// 最近一次返回值(UTF-8 JSON 字节)。宿主通过 plugin_result_ptr/len 读取。
static mut RESULT: Vec<u8> = Vec::new();
// ── 内存管理(ABI 必需导出) ───────────────────────────────────────────
/// 分配 `size` 字节内存,返回指针;返回 0 表示失败。
#[no_mangle]
pub extern "C" fn alloc(size: i32) -> i32 {
if size <= 0 {
return 0;
}
// 对齐要求为 1:这里的缓冲区只存放字节,不需要更高对齐
let layout = match Layout::from_size_align(size as usize, 1) {
Ok(layout) => layout,
Err(_) => return 0,
};
unsafe {
let ptr = sys_alloc(layout);
if ptr.is_null() { 0 } else { ptr as i32 }
}
}
/// 释放 `alloc` 分配的 `size` 字节内存(size 必须与分配时一致)。
#[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) };
}
}
// ── 生命周期与消息处理(ABI 必需导出) ─────────────────────────────────
/// 插件初始化。宿主在加载插件后调用一次。
/// 副作用:把声明信息写入 `RESULT`,宿主据此注册命令代理。
#[no_mangle]
pub extern "C" fn plugin_init() -> i32 {
set_result(&json!({
"ok": true,
"message": "hello-wasm 初始化完成",
"commands": ["wasm_greet"], // 声明本插件注册的命令
"events": [] // 声明本插件要监听的事件
}));
log(1, "[hello-wasm] plugin_init 完成,已声明命令 wasm_greet");
0
}
/// 处理一条来自宿主的消息。
/// 入参 `ptr`/`len` 指向宿主持有的 UTF-8 JSON;返回 0 表示成功。
#[no_mangle]
pub extern "C" fn plugin_handle(ptr: i32, len: i32) -> i32 {
let request = match read_request(ptr, len) {
Some(value) => value,
None => {
set_result(&json!({ "ok": false, "error": "入参不是合法的 UTF-8 JSON" }));
return -1; // PARSE_ERROR
}
};
match request.get("type").and_then(Value::as_str).unwrap_or("") {
"init" => plugin_init(),
"command" => handle_command(&request),
"event" => {
let name = request.get("name").and_then(Value::as_str).unwrap_or("<unknown>");
log(1, &format!("[hello-wasm] 收到事件 {name}"));
set_result(&json!({ "ok": true }));
0
}
other => {
set_result(&json!({ "ok": false, "error": format!("未知消息类型: {other}") }));
-2 // UNKNOWN_MESSAGE
}
}
}
/// 返回结果 JSON 的内存指针(ABI 导出)。
#[no_mangle]
pub extern "C" fn plugin_result_ptr() -> i32 {
unsafe { RESULT.as_ptr() as i32 }
}
/// 返回结果 JSON 的字节长度(ABI 导出)。
#[no_mangle]
pub extern "C" fn plugin_result_len() -> i32 {
unsafe { RESULT.len() as i32 }
}
/// 可选导出:插件自行声明的 ABI 版本。
#[no_mangle]
pub extern "C" fn plugin_abi_version() -> i32 {
1
}
// ── 业务逻辑 ────────────────────────────────────────────────────────────
/// 处理 `{ "type": "command", "name": ..., "args": {...} }`。
fn handle_command(request: &Value) -> i32 {
let name = request.get("name").and_then(Value::as_str).unwrap_or("");
let args = request.get("args").cloned().unwrap_or_else(|| json!({}));
match name {
"wasm_greet" => {
let who = args.get("name").and_then(Value::as_str).unwrap_or("旅行者");
log(1, &format!("[hello-wasm] 收到命令 wasm_greet,name={who}")); // 1) 打日志
let gold = get_state("var.gold") // 2) 读状态
.and_then(|value| value.as_i64())
.unwrap_or(0);
set_state("flag.wasmGreeted", &json!(true)); // 3) 写状态
emit("wasm:greeted", &json!({ "name": who, "gold": gold })); // 4) 广播事件
// 5) 返回结果:message 由引擎渲染成对白,effects 由引擎落地
set_result(&json!({
"ok": true,
"message": format!("【WASM Hello · Rust】你好,{who}!你现在有 {gold} 枚金币。"),
"effects": [
{ "type": "set", "key": "flag.wasmGreeted", "value": true },
{ "type": "set", "key": "var.wasmGreeting", "value": format!("Rust 向 {who} 问好") }
],
"data": { "name": who, "gold": gold }
}));
0
}
other => {
set_result(&json!({ "ok": false, "error": format!("本插件不支持命令: {other}") }));
-2
}
}
}
// ── 与宿主交互的小工具 ──────────────────────────────────────────────────
/// 把 Rust 字符串写入 WASM 线性内存,返回 (ptr, len);ptr 为 0 表示失败。
fn write_guest_string(text: &str) -> (i32, i32) {
let bytes = text.as_bytes();
let len = bytes.len() as i32;
let ptr = alloc(len);
if ptr == 0 {
return (0, 0);
}
unsafe {
slice::from_raw_parts_mut(ptr as *mut u8, len as usize).copy_from_slice(bytes);
}
(ptr, len)
}
/// 从 WASM 线性内存读取宿主导入的 JSON 字符串。
fn read_request(ptr: i32, len: i32) -> Option<Value> {
if ptr <= 0 || len <= 0 {
return None;
}
let bytes = unsafe { slice::from_raw_parts(ptr as *const u8, len as usize) };
serde_json::from_slice(bytes).ok()
}
/// 保存最近一次返回值。
fn set_result(value: &Value) {
let bytes = serde_json::to_vec(value).unwrap_or_else(|_| b"{\"ok\":false}".to_vec());
unsafe { RESULT = bytes };
}
/// 调用宿主 host_log。
fn log(level: i32, text: &str) {
let (ptr, len) = write_guest_string(text);
if ptr == 0 {
return;
}
unsafe {
host_log(level, ptr, len);
dealloc(ptr, len); // 宿主读取完立即归还
}
}
/// 调用宿主 host_emit,返回宿主状态码(0 成功)。
fn emit(name: &str, payload: &Value) -> i32 {
let request = json!({ "name": name, "payload": payload }).to_string();
let (ptr, len) = write_guest_string(&request);
if ptr == 0 {
return -1;
}
let code = unsafe { host_emit(ptr, len) };
dealloc(ptr, len);
code
}
/// 调用宿主 host_set_state,返回宿主状态码(0 成功)。
fn set_state(path: &str, value: &Value) -> i32 {
let request = json!({ "path": path, "value": value }).to_string();
let (ptr, len) = write_guest_string(&request);
if ptr == 0 {
return -1;
}
let code = unsafe { host_set_state(ptr, len) };
dealloc(ptr, len);
code
}
/// 调用宿主 host_get_state,返回状态路径对应的值。
/// 宿主回传的缓冲区由本插件的 alloc 分配,读完后必须 dealloc,否则每次调用都会泄漏内存。
fn get_state(path: &str) -> Option<Value> {
let request = json!({ "path": path }).to_string();
let (ptr, len) = write_guest_string(&request);
if ptr == 0 {
return None;
}
let buffer = unsafe { host_get_state(ptr, len) };
dealloc(ptr, len);
if buffer <= 0 {
return None;
}
// 布局:4 字节小端长度 + UTF-8 JSON
let header = unsafe { slice::from_raw_parts(buffer as *const u8, 4) };
let payload_len = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as i32;
let payload = unsafe { slice::from_raw_parts((buffer + 4) as *const u8, payload_len as usize) };
let parsed = serde_json::from_slice::<Value>(payload).ok();
dealloc(buffer, payload_len + 4);
parsed.and_then(|value| value.get("value").cloned())
}Cargo.toml 示例
[package]
name = "hello-wasm"
version = "1.0.0"
edition = "2021"
description = "浏览器端插件化剧情引擎的 Rust WASM 插件示例"
license = "Apache-2.0"
[lib]
# cdylib 才会产出可被 WebAssembly.instantiate 直接加载的 .wasm
crate-type = ["cdylib"]
[dependencies]
# 跨边界统一使用 UTF-8 JSON 字符串通信
serde_json = "1"
[profile.release]
# 体积优先:WASM 插件越小,加载越快
opt-level = "s"
lto = true
codegen-units = 1
panic = "abort"
strip = true构建命令
# 1. 一次性准备:安装目标平台
rustup target add wasm32-unknown-unknown
# 2. 构建
cd wasm-src/hello-wasm
cargo build --target wasm32-unknown-unknown --release
# 3. 复制产物到静态资源目录
# crate 名 hello-wasm -> 产物 hello_wasm.wasm(连字符转下划线)
cp target/wasm32-unknown-unknown/release/hello_wasm.wasm \
../../public/plugins/hello-wasm/plugin.wasm仓库把这 3 步封装成一条命令(Node.js 脚本,不使用 Python):
npm run build:wasm脚本位置:scripts/build-wasm.mjs(会做环境检查、执行 cargo、复制产物并打印大小)。
脚本内的 PLUGINS 清单默认包含一个 crate(新增插件时在数组里追加即可):
| crate | 产物 → 插件目录 | 说明 |
|---|---|---|
hello-wasm | public/plugins/hello-wasm/plugin.wasm | 入门示例(命令 + 状态 + 事件) |
若使用自定义 crate 目录,请修改脚本内的 PLUGINS / crateDir / outputFile,或自行按上面三步执行。
世界模拟器:让 WASM 插件「每拍活一次」
WASM 插件参与世界模拟不需要新 ABI —— 复用它已有的事件代理与 imports:
- 在
plugin_init返回的events数组(或 manifestnarrative.events)里声明world:tick, 宿主会为它注册事件代理; - 每收到一次
{ "type": "event", "name": "world:tick", "payload": { "tick", "elapsedMs" } }, 推进自己的状态机(host_get_state读、host_set_state写); - 世界变化用
host_emit发布高层事件(如alert:emergency),剧情observe/ JS 插件同步收到。
完整参考:wasm-src 下任一 crate 的 src/lib.rs(如 hello-wasm)。
这与「规则先跑、广播后发」的 JS 通道(ctx.world.registerRule)共用同一条 world:tick 信号,三路平权。
常见错误与排查
1. PLUGIN_WASM_FETCH_FAILED
现象:插件列表显示下载失败。
原因:entry 路径写错、文件不存在、dev server 未运行、或 base 路径不对。
排查:在浏览器地址栏直接访问 /plugins/<目录>/plugin.wasm;确认 public/plugins/<目录>/plugin.wasm 存在。
2. PLUGIN_WASM_INVALID:magic 校验失败
现象:错误信息里附带「文件开头内容」,看起来像 <html> 或 JS 代码。
原因:服务器返回了 HTML 404 页;或误把 .wasm 当作 JS 处理(构建配置问题)。
排查:确认文件路径;确认 vite.config.ts 里 assetsInclude: ['**/*.wasm'] 未被移除。
3. PLUGIN_WASM_INSTANTIATE_FAILED
现象:实例化失败。 常见原因:
- Rust 侧用了
extern "C"但模块名不是env(宿主按{ env }传入); - 目标不是
wasm32-unknown-unknown(例如误用wasm32-wasi,会产生wasi_snapshot_preview1import); - Rust 版本与目标不匹配,或缺少
rustup target add wasm32-unknown-unknown。
4. PLUGIN_WASM_MISSING_EXPORTS
现象:错误信息列出缺失的导出名。
原因:忘了 #[no_mangle] pub extern "C",符号被 mangle;或函数名拼写错误。
排查:检查导出列表与 ABI.md 的「必需导出」是否一一对应。
5. 命令没生效 / 报「未注册的命令」
原因:manifest 缺少 commands:register 权限(最常见),或 plugin_init 的返回里没声明 commands。
排查:确认 manifest 有 commands:register;确认 plugin_init 写入的 JSON 里 commands 数组包含该命令名;
查看日志中 WASM 插件 init 响应:commands=[...] events=[...] 一行。
6. 状态读写无效
原因:缺少 state:read / state:write 权限,宿主返回 -100 / 0,而插件没有检查返回值。
排查:补权限;在 Rust 侧检查 host_get_state / host_set_state 的返回值;
日志里会有 WASM 插件调用 host_xxx,但未声明 xxx 权限,已拒绝。
7. 返回值被判定为非法 JSON
现象:PLUGIN_WASM_BAD_RESULT,错误里附带原始内容前 200 字符。
原因:RESULT 里的字节不是 UTF-8 JSON,或 plugin_result_len 返回的长度不对
(例如用字符数而非字节数)。
排查:确认用 serde_json::to_vec 生成字节、RESULT.len() 返回字节长度。
8. 内存泄漏 / 越界
现象:长时间运行内存持续增长;或报 PLUGIN_WASM_BAD_POINTER。
排查:
- 检查每个
alloc都有配对的dealloc;host_get_state的返回要dealloc(ptr, len + 4); - 不要
dealloc宿主传入的plugin_handle参数(那是宿主的责任); assertRange失败说明返回了非法指针/长度 —— 通常是plugin_result_ptr/len与RESULT不一致。
9. 页面白屏 / 插件状态 error
排查:控制台里搜 plugin:<id>;插件列表里会显示红色错误文本;
window.__storyRuntime.pluginManager.getInfo('<id>').error 可直接拿到错误消息。
单个 WASM 插件失败不会影响引擎与其它插件 —— 它只会进入
error状态并被回收能力。
关于 AssemblyScript
本项目明确不提供 AssemblyScript 版本的 WASM 插件示例,也不保证 AssemblyScript 产物与宿主 ABI 兼容。
如果你用 AssemblyScript / C / C++ / Go / Zig 编写插件,需要自行对齐 ABI.md 的全部分约定
(内存管理、extern "C" 符号名、env 模块 imports、UTF-8 JSON 编解码、长度前缀缓冲区),
并自行承担兼容性风险。