Minecraft 1.13.x 中 Function 嵌套执行的顺序问题
(在开始本文的阅读前,让我们首先在心中默念四字真言:MJSB)
本文基于版本 1.13.2,不过相关问题据说 1.14 也有。
引言
引发我研究这件事的动机是素学姐(指 MCBBS 用户 @Vinogradov )在做一个不知道什么奇奇怪怪的东西的时候触发的一个 BUG 啊不一个特性。不过,这个特性已经有人发现并汇报 Mojang 了(MC-126946),虽然目前的情况显示这个特性仍处三不管地带:
这件事吸引了我从代码角度分析这件事发生的原因,并进一步了解了更多的东西,从而便有了本文。
java.lang.StackOverflowError?
在 mcfunction 文件里引用其他 mcfunction 文件,或者说在一个 Function 里嵌套执行另一个 Function,看起来似乎是一个非常明显的需求,明显到一般人可能都意识不到这样的需求,实现起来是否需要有额外注意的地方。
实际上,需要注意的地方是有的——如果我们只是单纯地通过在一段代码里调用另一段代码这种方式实现,那么随着嵌套层数的加深,代码的递归层数就会变大,那么如果我们期望的嵌套层数比 JVM 允许的嵌套层数深,游戏就可能会在我们不希望的地方抛出 java.lang.StackOverflowError。
解决办法自然是有的。作为示例,我们以以下四个 Function 作为示例演示 Mojang 是怎么解决问题的。
- # data/test/functions/a.mcfunction
- say 1
- say 2
- function test:b
- say 4
- say 5
- function test:c
- say 0
- # data/test/functions/b.mcfunction
- say 3
- # data/test/functions/c.mcfunction
- say 6
- function test:d
- say 9
- # data/test/functions/d.mcfunction
- say 7
- say 8
对命令稍有了解的玩家应该都可以看出,如果我们执行 function test:a,接下来会发生什么。
自己动手,丰衣足食
既然 Java 递归本身不靠谱,那么我们就需要自己实现一个栈了。事实上 Mojang 的做法正是维护这样一个栈,存储接下来所有将要执行的 Function,如果出现嵌套调用,就在执行到嵌套调用的地方展开。
在执行一个 Function 前,Mojang 会将所有待执行的 Function 压入栈,并在执行命令时逐一弹出:
- 准备执行 function test:a,将其所有命令展开并压入栈:
[] => [say 1, say 2, function test:b, ..., say 0] - 准备执行 say 1,将栈顶命令弹出:
[say 1, say 2, function test:b, ..., say 0] => [say 2, function test:b, ..., say 0] - 准备执行 say 2,将栈顶命令弹出:
[say 2, function test:b, say 4, ..., say 0] => [function test:b, say 4, ..., say 0]
现在我们需要执行 function test:b 了,执行时为避免递归,该 Function 将被自动展开,然后逐一执行:
- 准备执行 function test:b,将其所有命令展开并压入栈:
[function test:b, say 4, say 5, ..., say 0] => [say 3, say 4, ..., say 0] - 准备执行 say 3,say 4,和 say 5,将栈顶命令弹出:
[say 3, say 4, say 5, ..., say 0] => [say 4, say 5, function test:c, say 0]
| => [say 5, function test:c, say 0]
| => [function test:c, say 0]
执行 function test:c 需要两层嵌套,但在栈上的展开也是自然的:
- 准备执行 function test:c 及其中所有命令:
[function test:c, say 0] => [say 6, function test:d, say 9, say 0]
| => [function test:d, say 9, say 0]
| => [say 7, say 8, say 9, say 0]
| => [say 8, say 9, say 0]
| => [say 9, say 0]
| => [say 0]
| => []
我们不需要使用递归的方式,就可以完成 Function 的嵌套问题,而且我们可以任意控制嵌套层数,只要游戏内存足够多。
这里我们需要注意到一件事:由于先执行的命令需要先弹出栈,因此游戏代码本身处理 mcfunction 文件时,是倒着压入栈的——也就是说最后一条命令最先入栈,而第一条命令直到最后才入栈。稍后我们就会看到这一特殊处理方式带来的隐患。
问题的出现
如果我们只允许使用 Function 的方式一次执行多条指令,那么上面的设计是毫无问题的。可问题就出在一次执行多条指令的方式不止 Function 一种——我们还有 execute 呢。
我们现在重新复现一下 MC-126946 中提到的方式。MC-126946 首先生成了三个盔甲架:
- summon minecraft:armor_stand ~ ~ ~ {CustomName:""Main"",CustomNameVisible:1b,Tags:["foo"]}
- summon minecraft:armor_stand ~1 ~ ~ {CustomName:""One"",CustomNameVisible:1b,Tags:["bar"]}
- summon minecraft:armor_stand ~2 ~ ~ {CustomName:""Two"",CustomNameVisible:1b,Tags:["bar"]}
然后以 Main 的位置为基点执行 execute 命令:
- # data/test/functions/foo.mcfunction
- execute as @e[tag=foo] at @s as @e[tag=bar,sort=nearest] run function test:say
- # data/test/functions/say.mcfunction
- say Hello
如果我们执行 function test:foo,那我们相当于一次性为 One 和 Two 分别执行了两个 Function,按照 sort=nearest 规则,选择器列表中 One 应位于 Two 的前面。但是,由于我们是在 Function 中嵌套执行 Function,问题发生了。
- 游戏会首先将针对 One 的压入栈:
[] => [(One) say Hello] - 然后将针对 Two 的压入栈:
[(One) say Hello] => [(Two) say Hello, (One) say Hello]
在执行时,由于先进栈的后出栈,因此针对 Two 的会相较针对 One 的先执行,导致执行顺序和预期相反。但以下两种情况却是正常的:
- 直接在控制台执行 execute 命令:
这是因为此时待执行的两个 Function 并不在某个 Function 下,因此是分立的,因此会分别入栈出栈。 - execute 后的 run 不执行 Function,而是直接执行命令:
这是因为 execute 命令会直接按次执行两个命令,而不会将其交给 Function 系统,更不会有多余的入栈出栈。
于是这一特性就产生了(于是 MJSB,Q.E.D.)。
解决方案
单纯使用命令的解决方案我也不清楚,但是这一特性经过证实是稳定的,于是可能真的可以拿来放心大胆直接用?
(据说素学姐最后把 nearest 和 furthest 对调解决了问题,这理论上听着可行,实际我没试,毕竟我只是个云玩家)
使用代码修复的解决方案我也想到过,但是我又不是 Mojang 员工,我才不会操心这种破事,这里也不提出来了。
以上です。