C++ 接口¶
用 Pier SDK 编译的 C++ 模组直接包含 sdk/abi.h,在 pier_main 里拿到函数表 PierApi,之后每次调用都写成 api->槽位名(...)。表里的 201 个槽位按主题分在下面各页,每个槽位注明了它在 abi.h 里所在的分节和在表里的序号,还列出了 Rust、Go、Zig 里调用它的接口。
调用一个槽位前要做两项检查:struct_size 够不够得着这个槽位,槽位是不是 NULL。写法和完整的例子见 C++ 绑定。
bindings/bridge/pier-bridge.h 是给 LeviLamina 原生插件调用 Pier 服务用的,不在这一部分,见 bridge。
| 页面 | 槽位和宏 | 类型 | 常量 |
|---|---|---|---|
| 核心 | 13 | 4 | 0 |
| 事件 | 3 | 2 | 0 |
| 命令 | 6 | 2 | 0 |
| 服务器、刻与系统信息 | 16 | 0 | 8 |
| 世界 | 31 | 0 | 0 |
| 批量编辑世界 | 7 | 0 | 0 |
| 玩家 | 22 | 0 | 74 |
| 实体 | 19 | 0 | 85 |
| 方块 | 7 | 0 | 42 |
| 物品与容器 | 15 | 0 | 52 |
| 计分板 | 1 | 0 | 10 |
| 表单 | 1 | 1 | 0 |
| NBT 与键值数据库 | 10 | 3 | 0 |
| 经济 | 10 | 1 | 4 |
| 数据包 | 6 | 5 | 7 |
| 模拟玩家 | 4 | 0 | 0 |
| 客户端 | 6 | 4 | 0 |
| 自定义维度 | 13 | 5 | 30 |
| 跨模组 | 16 | 5 | 8 |
| 注册表 | 1 | 0 | 3 |
| 通用类型 | 0 | 10 | 0 |
abi.h 文件头¶
文件头写着整份 ABI 的约定,原文如下:
Pier ABI:sdk/abi.h(ABI v2)
这个头文件就是产品本身:它是 C++ 宿主(pier-host 加上各个能力包)和任何语言写的 SDK 之间唯一的契约。参照的镜像是 bindings/ 下的 pier-sys-rs crate,手写,没有用 bindgen,它也可以当作这个文件的一份可读注解。
这个文件必须能当 C 解析。使用它的是「任何语言」,所以它只用 C11:没有 std::string_view,没有 enum class,没有嵌套类型。C++ 的便利封装(PierStr 和 string_view 互相转换)放在 pier-support 里,不在这里;契约里一旦出现某种语言特有的类型,其他所有语言都得去猜那个类型的内存布局。CI 会把这个文件按 C11 和 C++20 各编译一次。
修改这个文件的规则,整个项目里只有这几条版本规则:
- 只能在
PierApi的末尾追加。不能调整已有槽位的顺序,不能删除,不能改签名。追加不提升PIER_ABI_VERSION。 - 追加之后,逐个槽位更新所有 SDK 镜像;
sys-mirrors-abi检查会核对顺序。 - 只有非追加的改动(调整顺序、删除、改签名)才提升
PIER_ABI_VERSION和PIER_ABI_MIN_SUPPORTED,两者提升到同一个数。
追加不提升版本,因为版本号回答的问题是「哪些已经编译好的模组还能加载」。追加的槽位不会让任何旧模组失效:旧表是新表逐字节相同的前缀,旧模组也永远够不着新槽位。为追加提升版本,等于宣布一个并不存在的不兼容。两个方向各有各的关卡:新宿主配旧模组,由版本范围把关(见 PIER_ABI_MIN_SUPPORTED);旧宿主配新模组,由模组逐个槽位比较 struct_size 把关,对超出表长的那一次调用报告「宿主缺少这项能力」。
所有构建目标上的内存布局都一样。PierApi 里没有条件编译:仅客户端的能力和维度能力对应的槽位始终在布局里,宿主没有编入那个包时,槽位就是 NULL。「有这项能力」的意思就是「槽位不为 NULL」,SDK 据此报告「宿主没有提供 X」。这样做换来三点:镜像不需要条件编译;目标不匹配时不会调到错的槽位;结构体只有一个追加点,就是末尾。
整个文件通用的约定,每个槽位的注释只记录例外:
- 字符串是 UTF-8 的 (ptr, len) 视图,不保证以 NUL 结尾。
- 传进回调的字符串归调用方所有,只在这次调用期间有效;要保留就复制一份。
- 模组在当前调用帧内通过输出回调把字符串交出去。所有权从不跨越边界:这个 ABI 里没有「返回一个指针,由另一边释放」的写法。
- 线程:除非槽位另有说明,只能在服务器线程调用。
log、gaming_status、schedule和schedule_after是线程安全的。所有回调(事件、命令、定时任务)都在服务器线程上触发。
读取世界(扫描)
各个领域的数据类型
跨模组事件总线的 FFI 类型
一个模组不能把函数指针交给另一个模组:ModHost::unload 会调用 FreeLibrary,发布方手里就会留下一个指向已卸载 dylib 的指针。所以订阅表由加载器持有,做法和 Forms.cpp、按模组归属的调度器一样,用 weak_ptr 加票据。
加载器从不解析 payload:它是不透明的 UTF-8,可以是 JSON、SNBT,或者两个模组约定的任何格式。加载器不关心格式是有意的,另一种做法是定一套每个发布方都要满足、加载器还得维护版本的格式规范。
主题就是普通的字符串,请加上命名空间:写 plot:enter,不要写 enter。
同工具链快速通道
总线和服务都是「(名字, UTF-8 载荷) -> UTF-8 载荷」这个形状。这是各种语言都能用的公共形状,任何语言写的模组都能用它通信。代价是每次调用都要序列化一个来回,类型信息全部埋在字符串里。
快速通道只服务一种特殊情况:两边由同一个工具链构建,两个动态库里按 C 布局的函数表逐字节相同,指针可以直接交过去。
加载器持有「名字 -> 通道」的表(和服务一样是独占的),核对指纹,发放和回收租约,并持有一个存活标记,提供方一离开就把它清零。数据和函数表它一个字节都不解读,两个指针对它都是不透明的,和总线的载荷一样。
加载器必须参与,是因为 ModHost::unload 会调用 FreeLibrary。提供方的内存可以靠引用计数继续存在,但它的代码段会被卸载,使用方手里的函数指针就成了释放后使用。崩溃会发生在使用方那里,日志里没有任何内容指向刚刚离开的那个模组。因此:
alive指向加载器自己堆上的一个单元,永不释放(通道只有几十个,泄漏的是几十个uint32)。提供方离开时加载器在里面写 0,使用方每次调用前先读这个单元:一次普通的原子读,没有 FFI,没有锁。这里说的「快」就是指热路径上加载器不运行任何代码。- 提供方离开时,加载器在
FreeLibrary之前对每一个还没归还的租约调用release,让提供方在自己的 dylib 里、用自己的分配器释放自己的对象。
大多数原生语言没有稳定的 ABI。同一个契约类型编译进两个 cdylib,编译器元数据不同时字段顺序可能不同,结果是没有任何报错的内存损坏,连崩溃都没有。所以检查用的是指纹,不用版本号:编译器版本、目标三元组、契约的名字和版本,以及函数表的类型标识、大小和对齐,全部折叠成一个 u64。任何一项不同,指纹就不同,lane_acquire 返回 PIER_LANE_FINGERPRINT,一个指针都不交出去。
失败时的结果是「变慢」(使用方退回到服务通道),永远不会是未定义行为。这个通道之所以被允许存在,正是因为这一点。
宿主只比较指纹是否相等,从不解读指纹的内容;必须这样,否则「在指纹里多加一项」就会变成一次 ABI 改动。
数据包拦截的 FFI 类型
供 packet_hook_register 和 packet_conn_hook_register 使用。完整的约定见 PierApi 里这两个字段上方的注释。
客户端能力组的 FFI 类型。这些类型声明始终存在(它们不占布局);能力是否可用,看 PierApi 里的 client_* 槽位是不是 NULL。
属性和动作的键。只能追加:不能重新编号,也不能删除。不认识的值会让调用返回 false;安全的 SDK 层把它转成「不支持」的错误,向前兼容就是这样协商的。