Appearance
设计取舍记录
这里记录做过的具体设计决策:决策本身、为什么这么选、放弃了哪些备选方案。持续更新,越往下越新。轻量的 ADR(Architecture Decision Record)风格,不追求完整论文式的论证。
1. C++ 桥接按领域拆分模块,不再挤在一个文件里
决策:把原先集中在 BridgeApi.cpp 一个文件里的全部实现,按领域拆成 src/bridge/ 下的多个文件(Common/Api.h/ApiTable/LogScheduler/Events/Commands/Server/World/Players),只有 ApiTable.cpp 一处需要关心字段顺序。
原因:加一个新 API 之前要先在几百行无关代码里定位插入点,容易改错地方;拆分后每个文件职责单一,新增能力时改动范围可预测。
备选:保持单文件,靠更细的注释分区组织——放弃,因为文件仍然会持续膨胀,注释分区不能像独立文件那样被工具(IDE 跳转、diff)真正区分开。
2. ABI 只做追加式演进,never 重排/删除字段
决策:LeviRsApi 结构体新增能力时只在表尾追加字段,abi_version 才升级大版本;用 struct_size 做前向兼容检查。
原因:一个函数指针表如果重排字段,任何仍按旧偏移量访问的调用方都会读到错误的函数指针——这是直接的内存安全问题,不只是"版本不匹配报个错"那么轻。追加式演进 + 大小检查,能让旧模组在新加载器下继续工作,新模组在旧加载器下能提前拒绝而不是越界读取。
涉及:架构与 ABI 设计。
3. 版本敏感的写操作走原版命令行,而不是直接调原生方法
决策:set_block、teleport_player、set_player_gamemode、set_time、set_weather 这些桥接实现,内部都是拼一条 vanilla 命令字符串(/setblock、/tp、/gamemode……)交给命令系统执行,而不是直接调用 BlockSource::setBlock、Player 的游戏模式设置器等底层引擎方法。
原因:这些底层方法的签名在引擎版本之间改动相对频繁(比如 BlockSource::setBlock 的参数列表就换过形状),命令行语法则由 Mojang 自己保持向后兼容——对一个要跨引擎版本尽量稳定工作的桥接层来说,命令行是更可靠的落点。
代价:多一层字符串拼接和命令解析的开销,且错误信息是命令系统给出的文本而不是强类型的错误码;对 modding API 的调用频率来说可以接受。
4. 文档分类参考 LSE,但方法真实性以 LeviLamina 原生头文件为准
决策:API 参考的分类结构(Event/Player/Entity/Block/Item/Container/World/...)模仿 LSE,但每一页列出的具体方法必须能在真实的 LeviLamina/BDS C++ 头文件里找到对应,不是照搬 LSE 已有的方法名清单。
原因:这本来就是 Rust 原生绑定,不是脚本引擎;"底层方法必须有"这条要求,只有对照原生头文件才能兑现。LSE 的分类习惯(简单、按对象分组)值得借鉴,但它的具体 API 面是另一套运行时(JS/Lua)针对脚本场景设计的,直接照抄会导致文档描述一堆本项目实际不存在的方法。
备选:逐一对照 LSE 方法名去找原生等价物——尝试过,但发现有相当一部分 LSE 方法背后没有直接对应的单一原生方法(比如经验/等级在原生里是走通用 Attribute 系统而非专门字段),生搬硬套反而会写出不准确的文档;改为"以原生方法为准,用 LSE 的简单命名风格去包装"。
5. 命名用 snake_case,不用 LSE 的 camelCase/PascalCase
决策:Category::method() / object.method() 全部用 Rust 惯用的 snake_case,例如 LSE 的 pl.setGameMode() 对应本项目的 player.set_gamemode()。
原因:这是给 Rust 用的 API,跟随 Rust 生态的命名习惯,而不是照抄宿主脚本语言的习惯。
6. 每个对象页用"常用表 + 附录"两层结构
决策:Entity/Player/Block/Item/Container 这类页面,把真实存在的原生方法分成两部分:挑出的常用方法表(附带原生方法名以便核对)+ 附录(该类里其余确实存在、但还没封装的原生方法全名单)。
原因:这些原生类的公开方法数量很大(Actor 两百多个、Player 一百多个),逐一详细描述所有方法性价比很低、可读性也差;但只挑"常用"的又会显得"文档说没有、其实原生有"。两层结构让读者一眼看到高频操作,又能在附录里查到任何一个真实存在但还没细讲的方法名,不遗漏。
代价:附录目前只有方法名,没有说明——这是当前已知的欠账,计划按页逐步补上简要说明(一次性写完所有页的附录说明工作量很大,采取逐层推进的方式)。
7. 自定义命令枚举选运行时枚举,不用底层枚举注册接口
决策:命令参数化设计里,自定义枚举/软枚举走 CommandRegistrar::tryRegisterRuntimeEnum/tryRegisterSoftEnum(名字 → 数值的简单列表),不用更底层的 tryRegisterEnum。
原因:tryRegisterEnum 需要额外提供一个 C++ 侧的类型解析函数指针,这种签名很难合理地跨 FFI 边界暴露给 Rust 模组;tryRegisterRuntimeEnum 只需要一份简单的名字/数值表,模组友好,且原生本来就是为这类场景设计的运行时版本。
8. 不设立独立的全局 mc:: 对象
决策:拿到 Player/Entity/Block/Item 等句柄的"工厂"方法,不放进一个独立的全局命名空间(早期草稿里叫 mc::),而是并入已有的 Server(需要"活的服务器/世界"上下文的场景,如查在线玩家、生成实体)或该值类型自己的关联函数(不需要活跃上下文的纯值构造,如 Item::new(...)、Nbt::parse(...))。
原因:mc:: 这种全局函数命名空间是从 LSE(JS/Lua 脚本引擎,全局函数很自然)照搬过来的组织方式,和 Rust "要用什么就该有个明确持有者/入口"的习惯不符;而且 ABI 层本身只有一张函数表、一个不透明句柄,从没有"多个平行全局对象"这个概念,安全层平白无故多出一个新的全局入口,找不到与之对应的底层设计依据。
备选:保留 mc:: 作为纯文档组织标签,不代表真实类型——放弃,因为容易被误读成"这是一个独立于 Server 的东西",不如干脆按实际归属摆放清楚。
涉及:架构与 ABI 设计的"为什么没有独立的 mc:: 全局对象"一节。
9. 句柄是可重新解析的标识符,不是缓存的原生指针
决策:Player/Entity/Block 句柄内部只存"标识符"(名字/ActorUniqueID/坐标),每次方法调用都在 C++ 桥接侧按标识符重新查一次活的对象,而不是缓存一个原生指针供以后直接解引用。
原因:这是现有代码里已经验证过的模式(事件的"玩家身份识别"安全门——只信任出现在当前在线集合里的指针)往前推一步的自然结果:与其每次用之前验证指针,不如从一开始就不持有指针。这样悬垂指针在架构上就不可能出现,用一次线性查找的开销换取这个保证,对 modding API 的调用频率完全划算。
代价:性能上不是 O(1) 直接解引用,而是每次都要重新查找;对绝大多数 modding 场景(人工触发的操作、tick 级别的逻辑)这个开销可以忽略。真正的高频路径(如 scan_region 遍历一个区域)本来就是一次性把数据整体拷出来,不走这条"逐次重新解析"的路径。
涉及:内存安全与生命周期。
10. Container 页保留原生 virtual 方法,不像 Entity 页那样排除
决策:Entity/Player/Block 页把原生头文件里 $ 前缀的虚函数插桩和普通的 virtual 生命周期钩子都排除在外,只列非虚的公开方法;但 Container 页反过来,把原生的 virtual(甚至纯虚)方法当作主要内容列出。
原因:Container 本身是一个抽象接口,getItem/setItem/getContainerSize 等本来就必须通过虚函数分发来实现"箱子/物品栏/末影箱共用一套调用方式"——这些虚函数就是模组应该调用的方法,不是需要绕开的引擎内部生命周期钩子。判断标准不是"是不是 virtual",而是"这个方法是设计给外部调用者用的,还是给引擎自己在特定时机回调用的"。