跳转至

Pier 是怎么设计的

这里的每一个机制,都对应 为什么有 Pier 里的某一条。本页讲的是 C++ 侧: 宿主做了什么,以及为什么这么排布。写模组的人不需要它,绑定语言的人需要, 而正在判断要不要把东西建在 Pier 上的人,应该读。

规矩本身在 CONTRACT.md 里, 每一条都写了理由。

一切从头文件开始

packages/pier-abi/include/sdk/abi.h 是交付物。仓库里其余的一切都只是它的一个实现, 任何为了迁就实现而弯折 ABI 的改动都是走反了。

所以它必须能被 C11 解析:契约的消费方是任何语言,而 C 是它们唯一都读得懂的那一种。 CI 每次推送都用 C11 和 C++20 各编一遍,还有检查拒绝其中出现 C++ 专属拼写或平台相关宽度。

八个包,边只朝一个方向

Text Only
pier-abi        纯 C 头,零源文件,零依赖               <- 契约
   ^
pier-support    跨包的小工具
   ^
pier-host       模组生命周期、SPI 注册处、PierApi 表的所有者
   ^
   |-- pier-api           核心域实现
   |-- pier-hooks         用原生 detour 合成的事件
   |-- pier-lane          同工具链快车道                 (可选)
   |-- pier-dimensions    自定义维度                     (可选)
   +-- pier-client        客户端专属槽位                 (客户端构建)

箭头只能向上,而且这张图就是全部的边。能力包之间互不 include、互不链接。

多余的边同样算违规。 add_deps 里躺着一条当前没用到的横边,今天不出事, 但它把「这条边不存在」从一条结构性事实降级成一句巧合——下一个人伸手去用的时候, 编译和链接都不会拦他。pkg-layering 检查同时读两张图:链接图和编译图。

能力包自己向宿主注册

装载器的派发表直接呼叫各包的函数,这正是可选包删不掉的原因。Pier 把这条边反了过来。

能力包通过一套服务提供方接口(SPI)向宿主注册自己,共四个注册面:

注册什么 宿主何时用
槽位包 装载期填 PierApi
拆除步骤(带 stage 编号) 卸载时按 stage 升序执行
卸载否决 允许卸载之前问一遍
事件提供方 解析一次订阅时

包不在,注册就不发生,槽位保持 NULL,pier-host 一行都不用改。

「可选」因此是可执行的,而且就是这么定义的:把根构建文件里那一行 includes(...) 删掉,配置、编译、运行全部照常,对应槽位为 NULL。有一条检查逐包执行一遍这句话。

自注册有一个前提值得单独说:所有包都编成 object 库而不是静态库。静态库会让链接器丢弃 没有外部符号引用的编译单元,而那些注册恰恰是零外部引用的文件级静态对象。 被丢弃之后的症状是功能静默消失。这条写进了契约,也有检查守着——因为它在写进契约、 并且交付说明已经宣称它成立之后,仍然被违反过一次,而且违反在那四个完全依赖自注册的包上。

所有目标共用同一张函数表

PierApi 里没有任何条件编译。客户端那组槽和维度那组槽在每一种构建里都占着位置, 只在对应能力包没编进来时为 NULL。

「有没有这个能力」于是变成「这个槽是不是空」——一个运行期的问题,有运行期的答案, 绑定按名字报出来。装载器需要的版本号标记,以及为守它而生的那一串补丁,全都不需要了。

那份头文件的 Rust 镜像是手写的,而检查是逐参数比对,不是比槽名。cargo check 逮得到 「int 不是 Rust 类型」;它逮不到「镜像写 i32 而头文件是 int64_t」——那种两边都编得过, 运行期读到半个数。

每次调用前的两道闸

访问一个非核心槽之前,两道都要过:

C
if (api->struct_size < offsetof(PierApi, slot) + sizeof(void*)) { /* 宿主太老 */ }
if (api->slot == NULL)                                          { /* 没编进来 */ }

缺一不可,顺序也不能反。只查非空,会在宿主更老、表更短时读越界, 而越界读回来的东西常常看起来就像一个合法的函数指针;只查长度,会在表够长 但能力包没编进来时调一个空指针。

这两种失败也分开报,因为要做的事不一样:超出表长说明该升级 Pier, 在表内但为空说明这个构建没带那个包,升级没用,得换一个构建。

跨边界传的是名字和 id

没有任何东西以「指向服务端内部的指针」跨过 ABI。玩家是选择器,实体是 ActorUniqueID, 方块是维度加坐标。每次调用重新解析。

所以句柄可以跨 tick 留着:实体没了之后调用返回错误,而不是跳进已释放的内存。 代价是每次调用多一次查表——对于一条另一侧是 Pier 管不着的代码的边界,这个交换划算。

有一条身份规则要单独说,因为它是安全性质。按名字寻址的玩家选择器,在账号名匹配不上时 会退到显示名,而显示名可以被别的模组改。一个玩家把显示名改成某个离线玩家的账号名, 就能让所有按名字寻址的调用落到自己身上。权限、经济、归属一律用 xuid, Rust 绑定把这个区别写进类型,让它在调用点上看得见。

谁分配的缓冲区,谁释放

任何跨边界的缓冲区,由产出方分配、产出方释放,接收方要留就自己拷。

形状 谁分配 接收方能做什么
PierStr(指针 + 长度) 调用方 返回前读完,要留自己拷
Sink 回调 宿主 在 sink 内拷走,返回后指针失效
车道的 data / vtable 提供方 只在存活标志为真时调用

ABI 上永远不会返回一个需要对方释放的指针。那需要跨边界的分配器契约,而两边的分配器 不是同一个。所有输出都走 sink。

结构化数据走 SNBT 而不是按值传递的结构体,这样一门表达不了引擎内存布局的语言也不必去表达。

答不上来的时候

一个槽位答不上来时,会做下面三件事中的一件:返回一个能表示「没有答案」的值;打一条日志并回退,在日志里写明回退成了什么;或者拒绝执行。

举个例子:一个判断「这个玩家能不能进领地」的函数,可能因为读不到玩家而答不上来。 如果它只返回 bool,读不到时返回 false,调用方分不清「不能进」和「不知道」;返回 true,读不到的玩家就进去了。 所以这类函数的返回类型里留着第三种情况。

日志写的是下一步该做什么。同样是订阅失败,日志写的是「这个事件在当前 BDS 版本上不存在,注册表里相近的有 A、B、C」, 读日志的人看完就知道该去改哪个名字。

订阅时事件名怎么匹配

宿主里只有一个匹配器 idMatches:要么完全相等,要么是带命名空间分隔符的后缀,不做子串匹配。 假如有两个匹配器,一个按子串、一个按后缀,同一个名字在两处可能匹配到两个不同的事件。

解析按这个顺序进行,哪一步认领了,就由哪一步负责到底:

  1. 事件提供方认领它。订阅失败就报错,不下落到别的路径。
  2. 注册表精确名,再唯一后缀。
  3. 全部失败则报错,并列出相近的 id。

提供方之所以排在注册表之前:命令事件的发射器就在注册表里,但 LeviLamina 只把它 派发给类型化监听器——动态路径查得到、接不到。这类提供方声明自己覆盖注册表路径, 替换掉那条路径是修复,不告警。纯合成的事件声明自己不覆盖,一旦上游出现同后缀的 id 就打告警,因为这种遮蔽必须可见。

新功能怎么加进函数表

新增能力就是在表尾追加一个槽,版本号不动。重排、删除或改签名,则 PIER_ABI_VERSION 和 PIER_ABI_MIN_SUPPORTED 一起推进到同一个数,在那之前编译的模组会在装载时被 明确拒绝并说明原因,而不是被放进去撞上一个挪了位置的槽。

兼容是区间,不是相等:MIN_SUPPORTED <= 模组的版本 <= 宿主的版本。

tools/abi-v1.slots 是基线,每次推送都有检查对着它比,改动了已有槽位的提交会在这一步失败。

仓库里的检查脚本

object 库那一条,最早只写在契约里,交付说明也说它成立;后来它在完全依赖自注册的四个包上被违反了。 从那以后,契约里的规则都配了一个检查脚本,放在 tools/checks/。

python3 tools/run-checks.py 一次跑齐。它们查的是编译器和 clippy 查不到的东西:

abi-c-parse 头文件按 C11 和 C++20 各编得过
abi-additive 相对基线,槽序只增不减
abi-fixed-width 契约里没有平台相关宽度
abi-no-lang 头文件里没有消费方语言的拼写或类型
pkg-layering 两张图都沿着包依赖图
object-kind 每个包都是 object 库
optional-drops 可选包的符号无人跨包引用
sys-mirrors-abi Rust 镜像逐参数对得上
manifest-matches-host 示例 manifest 真装得上
host-loadable 内存算子与模组注册必然进产物
comment-claims 声称「异常已捕获」的注释旁边真有 catch
no-silent-fallback 没有把异常丢掉且不留痕迹的 catch

最后三条共享一个值得记的形状:它们盯的错误在编译期完全看不见,装载期才报, 而报出来的话和构建过程毫无关系。manifest 的 type 写错,模组根本不会被扫到,不报错; 少写内存算子,链接一切正常,启服时才被拒绝。

每条检查都写明自己覆盖到哪、看不见什么,交付说明只许照抄那句话。 脚本通过只等于机械规则成立,不等于那条性质成立。