Skip to content

编译器内部实现

这是一份面向贡献者和高级用户的 RedScript 到 Minecraft 数据包的实现地图。内容基于当前编译器源码,重点参考 src/emitsrc/mirsrc/lirsrc/optimizersrc/events 以及相关标准库和测试文件。

它适合用来判断生成结果、运行时成本,以及修改编译器前应该先看哪些模块。

流水线总览

当前 src/emit/compile.ts 中的 compile() 路径大致如下:

  1. 预处理导入。 字符串导入会按源文件相对路径、stdlib、include dirs 解析。声明为 module library 的文件会单独保留,这样未使用的库函数仍可被裁剪。
  2. 解析与类型检查。 Lexer/parser 之后执行类型检查;@config 全局量会转成整数常量;事件运行时资源会在类型检查前规划。
  3. 降低到 HIR。 HIR 阶段处理类型化结构、泛型单态化、deprecated 调用检查,并裁剪不可达的注入库。
  4. 收集运行时元数据。 @load@tick@on@watch@schedule@coroutine@function_tag@memoize 和 profiling 相关装饰器会在后续 lowering 丢失装饰器语法前被收集。
  5. 降低到 MIR。 src/mir/lower.ts 把结构化代码转成显式控制流图和临时值。
  6. 优化 MIR。 默认 MIR 流水线会先做内联规划,再执行循环展开、LICM、NBT/scoreboard 读取批处理、常量折叠、CSE、复制传播、分支简化、DCE、基本块合并等固定 pass。
  7. 转换协程。 @coroutine 函数在 MIR 层被改写为 continuation 函数和 tick dispatcher。
  8. 降低到 LIR 并优化。 src/lir/lower.ts 把 MIR 转成面向 scoreboard/storage 的 LIR。LIR 优化执行 dead-slot 清理、peephole 和常量立即数折叠;局部 copy/RMW 重写仍是显式 opt-in。
  9. 收尾运行时 LIR。 注入 singleton helper、重命名 memoize/benchmark 的实现函数,并执行静态 tick budget 检查。
  10. 生成数据包文件。 src/emit/index.ts 写出 pack.mcmeta.mcfunction、Minecraft function tag、装饰器 wrapper 和可选 source map sidecar。

数据包入口

每次编译都会生成 data/<namespace>/function/load.mcfunction,用于初始化命名空间 scoreboard objective。minecraft:load tag 总会包含这个生成的 load 函数,用户的 @load 函数会追加到同一个 tag。

@tick 函数会写入 data/minecraft/tags/function/tick.json。在本次检查的活跃编译路径中,parser 接受 @tick(rate=N),但运行时元数据收集器只记录 tick 函数名,没有消费 rate 参数。若目标版本需要依赖 rate 降频,请先检查实际生成输出。

@schedule(ticks=N) 不会把原函数加入 tick.json。它生成 _schedule_<name>.mcfunction,内容是 Minecraft 的 schedule function <namespace>:<name> Nt 命令。调用 _schedule_<name>() 会调度原函数。

@function_tag("namespace:path") 会生成普通 function tag 文件,并把被装饰函数引用加入其中。minecraft:loadminecraft:tick 会合并进已有 load/tick tag,避免重复文件。

@on(EventType) 由事件 manifest 驱动。编译器把 handler 映射到对应事件的 handler function tag,并针对使用到的事件类型注入 src/stdlib/events.mcrs。该运行时库用一个 @tick 函数轮询 tag/scoreboard,再执行 function #rs:on_... 分发。

@watch("objective") 会创建 previous-value scoreboard objective,生成 tick 注册的 __watch_<name> dispatcher,并在玩家的被观察 objective 发生变化时以该玩家上下文运行用户函数。

其他 wrapper 类装饰器在 src/emit/index.ts 中生成:@throttle 使用每函数 tick 计数器,@retry 使用 retry 计数器和 tick dispatcher,@memoize 把原函数改名为 <name>_impl 并生成 LRU-1 scoreboard 缓存 wrapper,debug profiling/benchmark 装饰器会添加计时 objective 和辅助函数。

生成模型

MIR 是控制流图。LIR 是扁平且贴近 Minecraft 的指令序列。一个 LIR function 对应一个 .mcfunction;带 :: 的方法名会转成路径片段,例如 Player::heal 会生成 player/heal.mcfunction

scoreboard 临时值是模块 objective 中的 fake player,通常 objective 为 __<namespace>。LIR lowering 会给 temp 加上当前函数名前缀,所以 t0 之类的临时值会变成类似 $fn_t0 __ns 的 slot。函数参数使用 $p0$p1,返回值使用 $ret。结构体返回字段使用 $ret_x 这类 return-field slot。

LIR 层算术是二地址形式。MIR 中的 dst = a + b 会先把 a copy 到 dst,再用 scoreboard operation 把 b 加到 dst。比较通常先把目标设为 0,再用 execute if/unless score 条件设为 1

控制流会变成函数和调用。分支目标和循环块可能被抽成 helper .mcfunction。分支生成使用 execute if score ... run return run function ... 处理 then 路径,之后 fall through 到 else 路径。循环因此会在调用图中表现为环,这也是静态 budget 分析能基于 LIR 调用图工作的原因。

对 selector 的 foreach 会把循环体抽成 helper,并生成 call_context,最终变成 execute as <selector> ... run function <helper>。在 helper 内,绑定变量在命令上下文中会被视为 @sexecute { ... } 块对已支持的上下文子命令采用同样的 helper 模型,例如 asatpositionedrotatedinanchored

最终 emitter 会把每条 LIR 指令映射为 Minecraft 命令,并执行 flattenExecute。它会在语义等价时把嵌套的 execute if ... run execute if ... 合并成一条 execute 命令。

存储与计算

普通 intbool 使用 scoreboard。scoreboard 立即数受 Minecraft 有符号 32 位范围限制。

fixed/float 使用 x10000 的整数缩放并存储在 scoreboard 上。两个 fixed 相乘后会除以 10000 修正缩放;两个 fixed 相除前会先把被除数乘以 10000

double 存在 rs:d NBT storage。double 字面量和变量通过 data modify storage 写入;读取为 fixed-point 时使用 data get storage ... 10000.0。double 算术会通过 rs:d __dp0__dp1 传递操作数,并调用 math_hp:double_adddouble_subdouble_muldouble_div

数组是 NBT-backed,位于 <namespace>:arrays。字面量数组可以用一条 data modify storage ... set value [...] 初始化。常量索引降低为直接 NBT 读写。动态索引会把索引和值写入 rs:macro_args,再用 with storage 调用生成的 function macro,因此依赖 Minecraft function macro 支持。

字符串变量存放在 rs:strings,不是通用 scoreboard 表达式。say(f"...") 会通过生成的 macro helper 和 rs:macro_args 实现;普通 say("...") 保持为内联命令。tellrawtitlesubtitleactionbarannounce 等文本 builtin 可以把 f-string 转成带 scoreboard 组件的 JSON text。Display::to_string 会内联到 f-string 上下文,而不是作为独立运行时调用生成。

全局变量读写 scoreboard slot。@singleton 结构体会生成每字段 objective,并注入合成的 Struct::getStruct::set 函数,在这些 objective 与普通返回/参数 slot 之间复制。

运行时成本模型

主要成本倍数不是 RedScript 源码行数,而是生成的 Minecraft 命令数,再乘以 selector 和 tick root 的触发频率。

模式运行时形态成本信号
@tickfunction tag 每游戏 tick 运行成本每秒重复 20 次。
execute as @a 或 selector foreach每个匹配实体调用一次 helper成本随选中的玩家/实体数量增长。
@on(...)注入事件轮询 tick,并通过 handler function tag 分发一旦注入事件运行时,就有轮询成本;handler 成本随事件触发增长。
@watch每个 watch 一个 tick dispatcher,通常遍历 @a成本随玩家数和 watched objective 数量增长。
@schedule调用 wrapper 时执行一条 schedule 命令除非被调度函数再次调度自己,否则没有每 tick 轮询。
@coroutinetick dispatcher、continuation 函数和持久 scoreboard 状态把循环工作分散到多个 tick;batch 控制每 tick 工作量。
动态数组访问rs:macro_args 加 function macro 调用明显重于 scoreboard 算术或常量索引 NBT。
set_int helperNBT list load/store 加线性扫描适合小集合;避免大集合热路径扫描。
heap stdlibNBT int array,sift-up/sift-down 循环,容量 64逻辑上 O(log n),但每步都触碰 NBT-backed 数组槽。
scheduler_tick()固定 scoreboard 命令组,包含 per-player execute as @a 槽位从全局 tick root 调用一次,不要放进 per-player 循环。

静态 budget pass 会从 LIR 估算命令数。超过 Minecraft 默认 maxCommandChainLength 的一半时给 warning,超过默认完整限制时给 error。@coroutine 函数会被排除在该单函数检查外,因为它的工作被刻意切分到多个 tick。

实用建议

  • 当行为或成本重要时,直接检查生成的 data/<namespace>/function 输出。它是 selector、函数切分和 wrapper 的最可靠真相。
  • 保持热 @tick root 短小。较慢工作放到 @throttle@schedule、显式计数器、setInterval@coroutine 后面。
  • 避免在嵌套循环中使用宽 selector。一个经 execute as @a 调用的 helper 再执行 foreach @e[...],会把两层 selector 成本相乘。
  • 热路径计数器、标志位和小型数字状态优先使用 scoreboard 变量。
  • 数组、集合、heap 和 double precision 在需要表达能力时很有用,但应按 storage-backed 操作看待。
  • 如果固定索引或固定大小结构体字段能解决问题,就不要在紧密 tick 循环里使用动态数组索引。
  • 小的纯 helper 保持小即可。优化器会自动内联非常小、非递归、非 raw 的 helper;@inline 更适合极小的热路径 helper。
  • 手动 /function 入口请加 @keep;否则注入库函数和私有 helper 在不可达时都可能被裁剪。
  • 谨慎使用 raw 命令。它们作为副作用会被保留,也会成为优化屏障;这通常正确,但可能阻止进一步简化。
  • 若现有事件运行时能表达需求,事件式逻辑优先使用 @on;只有条件无法表达时再写原始 tick 轮询。

Released under the MIT License.