- 版权类型
- 原创
- 语言支持
- 多语言
- 前置组件
- LeviLamina
[MD]
# Pier
### 让 C++ 之外的语言也能写 LeviLamina 模组的装载器。

[](https://github.com/Maskviva/pier/releases)
[](LICENSE)


Pier 用一份 C ABI 把基岩服务端暴露出来。模组就是一个会说这份 ABI 的动态库,
所以**用什么语言写,是作者的选择,不是平台替他做的决定**。
Rust、C++、Go 和 Zig 都有官方绑定。任何有 C FFI 的语言都可以有自己的绑定,
而且加一个绑定不需要动 Pier。
## Pier 为什么存在
它的前身是 [levilamina-rust-loader](https://github.com/Maskviva/levilamina-rust-loader),
当初只为回答一个问题:LeviLamina 的模组能不能用 Rust 写?
能。而这个答案带出了一个更难的问题。那个装载器的接口是一次加一个函数长出来的,
底下的形状错了,而且错在补丁补不掉的地方:
- **布局按构建目标分岔。** 函数表里的条件编译,让它的尾部在客户端构建和服务端构建上
落在不同的偏移。为此在版本号高位打了个标记补救,可这个标记护不住任何一个没重新编译的
模组——分岔点之后的每一个槽位都打到了错的函数指针上,而两边都编得过。
- **中心直接呼叫每个能力包。** 派发表里写着各个包的函数名,每一个名字都是一条从中心
伸向能力包的链接边,于是能力包根本删不掉。「可选」是文档里的一个词,不是代码的性质。
- **读不到的值,变成了看起来合理的值。** 一个读不出来的字段返回 0,而 0 对多数东西
来说都是个合法答案。土地保护模组读不到事件里的维度,写了 `unwrap_or(0)`,于是
每个事件都被当成发生在主世界——主世界拒绝、别处全部放行,一行日志都没有。
- **事件名做子串匹配。** 上游哪天新增一个含相同词干的事件,订阅方就会被静默劫持到
载荷形状完全不同的合成事件上,而且没有任何办法再订到真事件。
这四条单独看都能修,也确实补过几个。但合起来它们说明的是一件补丁修不了的事:
**这个接口从来不是设计出来的,是长出来的**,而那些补丁是在守一个本就不该存在的形状。
那个装载器是测试版。测试版的作用就是告诉你该怎么正经地建,它做到了,然后就不再维护。
Pier 是重新设计:先有接口,后有实现。
## Pier 是怎么设计的
Pier 的全部就是一份头文件
[`packages/pier-abi/include/sdk/abi.h`](packages/pier-abi/include/sdk/abi.h)。
仓库里的 C++ 宿主照着它实现,各语言的绑定也照着它写。下面几节讲的,是这个头文件里定下来的几件事各自是怎么运作的。
### 所有目标共用同一张函数表
`PierApi` 里没有条件编译。客户端专用的槽位、自定义维度的槽位,在每一种构建里都占着同样的位置;
对应的能力包没编进来时,这些槽位的值是 NULL。
模组想知道宿主有没有某个能力,就在运行时看那个槽位是不是 NULL。同一份模组源码因此在服务端和客户端都编得过。
早先为了区分构建在版本号高位加的标记,以及维护那个标记的一串补丁,都已经删掉了。
### 能力包自己向宿主注册
Pier 由八个 C++ 包组成。`pier-host` 管着函数表和模组的生命周期,但不知道表里填的是什么。
每个能力包通过一套服务提供方接口(SPI)向宿主登记四样东西:
| 登记什么 | 宿主什么时候用 |
|---|---|
| 槽位包 | 装载时填进 `PierApi` |
| 拆除步骤(带 stage 编号) | 卸载时按 stage 从小到大执行 |
| 卸载否决 | 允许卸载之前逐个询问 |
| 事件提供方 | 解析一次订阅时 |
把某个包从构建里删掉,它就不会登记,对应的槽位停在 NULL,`pier-host` 的代码一行不用改。
根构建文件里每个包是一行 `includes(...)`:删掉那一行,配置、编译、运行都照常。
有一条检查在每次推送时把每个包轮流删掉,再构建一遍。
自注册有一个前提:所有包都编译成 object 库,不能是静态库。链接静态库时,链接器会丢掉没有被外部符号引用的编译单元,
而登记用的正是没有外部引用的文件级静态对象。它们被丢掉以后,对应的功能就不见了,启动日志里一个字也没有。
这一条写进契约以后,还是在四个包上被违反过一次,之后才加了检查。
### 跨边界传的是名字和 id
指向服务端内部对象的指针不会跨过 ABI。玩家用选择器表示,实体用 id,方块用维度加坐标;每次调用,宿主都重新查一遍。
所以句柄可以跨 tick 保存。实体没了以后再调用,宿主查不到它,返回一个错误,调用不会落到一块已经释放的内存上。
每次调用也因此要多查一次表。
按**名字**找玩家时有一处要当心:账号名对不上时,选择器会退到显示名去找,而显示名可以被别的模组改。
一个玩家把自己的显示名改成某个离线玩家的账号名,按名字寻址的调用就会落到他身上。
所以权限、经济、归属判定都用 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 查不到的东西:头文件能不能按 C11 和 C++20 各编一遍,
槽位顺序是不是只增不减,Rust 镜像和头文件是不是逐个参数对得上,能力包之间有没有横向依赖,有没有注释在描述代码没做的事。
有三条检查盯的错误,在编译期完全看不到,装载时才出现,出现时的日志也看不出和构建有什么关系。
比如 manifest 的 type 写错,模组根本不会被扫到,也不报错;少写了内存操作符,构建全部通过,启动服务器时才被拒绝。
每条检查都写明了它覆盖什么、看不到什么。交付说明引用检查结果时照抄这句话,读的人就知道哪些部分还得靠人去看。
规则本身在 [`CONTRACT.md`](CONTRACT.md) 里,每一条都附了理由。
## 写一个模组
**[Rust](docs/zh/rust/index.md)** 是第一个官方绑定,也是功能最全的;**[C++](docs/zh/cpp/index.md)** 直接使用头文件,
**[Go](docs/zh/go/index.md)** 把模组构建成 c-shared DLL,**[Zig](docs/zh/zig/index.md)** 通过 translate-c 读取头文件。
```rust
use levilamina::prelude::*;
struct MyMod;
impl LeviMod for MyMod {
fn on_load(ctx: &ModContext) -> Result<Self> {
ctx.logger().info("hello from Rust");
Ok(MyMod)
}
}
levilamina::register_mod!(MyMod);
```
- [写你的第一个模组](docs/zh/rust/first-mod.md) 从构建到装上服务器走一遍。
- [pier-mod-template](https://github.com/Maskviva/pier-mod-template) 是一个直接就能跑的 Rust 模组:
它会打日志、订阅聊天、注册命令、排一个延迟任务。在 GitHub 上点 *Use this template*,或者用 `cargo generate`,都能从它开始。
## 绑定另一门语言
一份文件就够了。`sdk/abi.h` 能被 C11 解析,FFI 工具直接吃;它同时就是参考文档:
每个槽位都写明参数含义、失败时返回什么、对线程有什么要求。
1. 镜像 `PierApi`,**无条件声明每一个字段**,不带任何目标分支。
2. 导出 `pier_main`,往 vtable 里填 `struct_size`、`abi_version`、`mod_flags`
和三个生命周期回调。
3. 调用任何非核心槽之前,先查 `struct_size` 覆盖得到它,再查槽位非空。
`bindings/rust/pier-sys-rs` 是参考实现。
[加一门语言](docs/zh/guide/adding-a-language.md) 有详细步骤,
[`CONTRACT.md`](CONTRACT.md) 第十节是权威版本。
## 安装
用 [lip](https://lip.futrime.com):
```bash
lip install github.com/Maskviva/Pier
```
或者把发布压缩包解压到 `plugins/pier/`。
Pier 需要 BDS 1.26.51 上的 LeviLamina 26.51.5。
[LegacyMoney](https://github.com/LiteLDev/LegacyMoney) 是可选的:它走延迟加载,
没装的服务器照常启动,只有经济相关的调用返回失败值。
## 从源码构建
```bash
xmake f --target_type=server && xmake # 宿主本体 pier.dll
cargo build --release # Rust 绑定与示例
```
两条构建线之间没有构建依赖,只有那份头文件,所以它们可以分别修。
```bash
python3 tools/run-checks.py # 契约第九节的机器检查
```
## 许可
Apache-2.0,见 [`LICENSE`](LICENSE)。
---
*与 Mojang、Microsoft、LeviMC 无关。Minecraft 是 Mojang Synergies AB 的商标。*
[/MD]