DOCUMENTATION · WASM 插件开发

WASM 插件开发

本文介绍如何用 Rust 编写一个 WASM 插件。ABI 的精确约定见 ABI.mdABI.md(本文会引用它,不重复定义)。

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

仅支持 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
}
字段类型必填说明
idstring✅全局唯一 id
namestring✅展示名
versionstring✅语义化版本
type"wasm"✅固定为 "wasm"
entrystring✅必须是 .wasm 文件,相对 manifest.json 所在目录(通常为 plugin.wasm)
apiVersionnumber✅必须为 1
permissionsstring[]➖申请的权限,见 PERMISSIONS.md
description / authorstring➖说明与作者
timeoutMsnumber➖单次调用超时(毫秒),缺省 3000ms

常见踩坑:若要注册命令,必须声明 commands:register。 宿主要先通过该权限为插件注册命令代理,否则 plugin_init 返回的 commands 会被拒绝, 后续剧情调用该命令会报「未注册的命令」。


WASM ABI 速览

完整的函数签名、内存布局、错误码见 ABI.md。这里给出速查表。

插件必须导出

导出签名作用
memoryWebAssembly.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) -> i32events:emit
host_get_state(ptr: i32, len: i32) -> i32state:read
host_set_state(ptr: i32, len: i32) -> i32state: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_unwindpanic=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 表达
浮点超越函数无硬件 libmsin/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」有关的兼容性注意

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-wasmpublic/plugins/hello-wasm/plugin.wasm入门示例(命令 + 状态 + 事件)

若使用自定义 crate 目录,请修改脚本内的 PLUGINS / crateDir / outputFile,或自行按上面三步执行。


世界模拟器:让 WASM 插件「每拍活一次」

WASM 插件参与世界模拟不需要新 ABI —— 复用它已有的事件代理与 imports:

  1. 在 plugin_init 返回的 events 数组(或 manifest narrative.events)里声明 world:tick, 宿主会为它注册事件代理;
  2. 每收到一次 { "type": "event", "name": "world:tick", "payload": { "tick", "elapsedMs" } }, 推进自己的状态机(host_get_state 读、host_set_state 写);
  3. 世界变化用 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

现象:实例化失败。 常见原因:

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。 排查:

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 编解码、长度前缀缓冲区), 并自行承担兼容性风险。