Go¶
Pier 的 Go 绑定把模组写成一个编译成 DLL 的 Go 包。导入 github.com/Maskviva/pier/bindings/go/levilamina,
代码里写 levilamina.Subscribe(...);最小的可用模组是 examples/hello-pier-go。
路径里写的是 Pier,表明这个包属于哪套 ABI;包名写的是你在做的东西,一个 LeviLamina 模组。
Rust 绑定也是这样:包名是 pier-rs,crate 名是 levilamina。
它是怎么搭起来的¶
cgo 直接读 abi.h。 绑定不手写函数表镜像:cgo 包含这个头文件,PierApi 的布局由 C 编译器排定,和宿主完全一致。
包里带着一份头文件副本,因为 go get 拿不到模块之外的东西;有一项检查保证副本和原件逐字节相同。
每个槽位都通过生成的 C 函数调用。 Go 不能调用 C 函数指针,所以 slots_gen.h 为每个槽位生成两个函数:
piergo_call_<槽位> 转发调用,piergo_has_<槽位> 执行契约里的两道检查(表够长、槽位不为空)。
它由 tools/gen-go-slots.py 从 abi.h 生成,过期时 go-binding 检查会失败。
宿主调用导出的 Go 函数。 pier_main、生命周期回调,以及任务、事件、命令的回调都用 //export 导出,
再由一个小 C 文件把它们作为宿主期望的函数指针交出去。其中任何一个发生 panic 都会在边界被恢复并记日志,不会展开进宿主。
需要什么¶
- Go 1.21 或更高版本。
PATH上有 MinGW-w64 的gcc,cgo 用它编译 C 部分。DLL 只通过 C 函数和 Pier 交互,在 x64 上 MinGW 和 MSVC 的调用方式相同,所以 C++ 模组"必须是 MSVC ABI"的要求在这里不适用。CGO_ENABLED=1,用go build -buildmode=c-shared构建。
构建出来的 DLL 应当只依赖系统 DLL;依赖服务器上没有的 MinGW 运行时 DLL,会以错误 0x7E 加载失败。
API¶
API 分三层,检查方式完全相同:
levilamina.Raw:每个不带回调的槽位一个带类型的方法,共 173 个,从abi.h生成并带上它的文档。返回值的含义与abi.h的说明一致。- 生成的门面:常量表里的每个属性和动作在
Player、Entity、BlockAt、Item上各有一个方法,比如Player.Level、Player.SetLevel、Entity.IsOnFire、Item.Count;每个值也有对应的 Go 常量,比如PPropLevel。 - 手写函数,建在前两层之上,范围接近 Rust 绑定:
| 范围 | 函数 |
|---|---|
| 生命周期和日志 | Register、Mod、Loader、Unloader、Context.Logger() |
| 服务器和世界 | Status、CurrentTick、Tps、Mspt、Time、SetWeather、GameRule、ExecuteCommand、ListPlayers、ListActors、SpawnMob、Explode、FillRegion、SaveLevel |
| 任务 | Schedule、ScheduleAfter、Cancel、PendingTasks |
| 事件 | Subscribe、Event.Payload、Event.Dim、Event.CheckComplete、Event.Set、Event.Cancel |
| 命令和表单 | RegisterCommand、带类型重载的 NewCommand、RegisterCommandEnum、RegisterSoftEnum、SendForm |
| 玩家和实体 | Player.SendMessage、Teleport、Inventory、CarriedItem、Entity.Snapshot、Owner、Target,以及生成的属性方法 |
| 方块、物品、容器 | BlockAt.Info、State、SetState、Item.Payload、Container.Items、AddItem、Clear |
| 数据 | OpenKvDb、ParseSNBT、Nbt、SnbtToBinary |
| 经济 | Money、AddMoney、TransferMoney、OnMoneyBefore、OnMoneyAfter |
| 跨模组 | BusPublish、BusPublishVetoable、BusSubscribe、RegisterService、CallService、ServiceCaller;见跨模组通信 |
| 网络 | RegisterPacketHook、RegisterPacketHookIDs、RegisterConnHook |
| 区域 | ScanRegion、ScanRegionIndexed、SetBlocks |
| 维度和模拟玩家 | AddDimension、AddGeneratedDimension、DimensionRule、SimSpawn、SimDo |
| 客户端 | RegisterKey |
宿主没有对应槽位的调用会返回 *NotProvidedError,可以用 IsNotProvided 判断;宿主答不上来的读取是 error,不会是 0 或 false。
表里的每个槽位 Go 都能调用,只有 Lane 的两个除外:Lane 按设计只连接同一工具链构建的模组,而发布 Lane 的模组同时也提供服务,Go 模组用的就是那个服务。
值得知道的规则¶
- 线程。 宿主大部分功能只能在服务器线程上调用。生命周期、事件和命令回调本来就在那里运行;
goroutine 要回到服务器线程,用
Schedule。 - 任务属于模组。 模组卸载时还没运行的任务会被宿主丢弃,所以不会调用进已经卸载的 DLL。
- 载荷是副本。
Event.SNBT和Invocation.Args都是 Go 字符串,回调返回后仍然有效。 载荷里的字段见事件载荷。 - 每个 DLL 一份运行时。 每个 Go 模组都带一整份 Go 运行时。一个服务器装多个 Go 模组,就是一个进程里跑多个运行时, 这是 Go 没有测试过的用法;依赖它之前,先在你的服务器上确认可行。