最近好多人问我跳略器和切门的东西,于是就去认真研究了一下顺便DIY了一版跳略器。过程中我终于会看了一点点反混淆,发现之前有挺多东西的理解都是有偏差的,整理了一下准备分享出来。不过这个文字量实在懒得做视频,尝试一下专栏吧。观前TIPS:
(资料图)
本文基于1.19.2 yarn反混淆完成,不保证其他版本可用(当然更新栈相关内容22w11a之前显然不可用)
本文所提的方块更新就是大家喜欢说的NC,状态更新就是大家喜欢说的PP,个人比较喜欢这个直观的称呼
本文旨在从逻辑源头开始分析更新机制和跳略器设计,觉得前面的内容过于抽象的话也可以先尝试看看后面应用相关的几节。
更新栈
直接反混淆出来的代码逻辑错综复杂比较辣眼睛,这里是手动整理后的核心逻辑(只求大意进行了改写简化,原文可以在这里找到`net/minecraft/world/block/ChainRestrictedNeighborUpdater.java`)
`ChainRestrictedNeighborUpdater`类将处理方块更新与状态更新的方法都重写为调用`enqueue`,主要依靠`enqueue`和`runQueuedUpdates`实现更新的逻辑。在`enqueue`时会通过当前栈是否为空决定处理更新的方式。
在连锁更新的场景中,最初的更新单元在`enqueue`时`depth`为0,因此会直接进入`queue`中并执行`runQueuedUpdates`。由于`depth`已经自增,执行期间触发的其他连锁更新将在总深度未溢出前将先进入`pending`,在当前更新(这里并不总是一个完整的更新单元,而是单次的方块更新或状态更新)处理完成后再逆序入栈`queue`(逆序应该是考虑到栈的性质,将最早的更新摆到栈顶来维持原有更新顺序)所以为什么一个栈要用双端队列实现
总而言之,这一模块实现了一个人工栈来模拟递归的过程,从而尽可能保留了原本深度优先搜索的模式,使连锁更新受到`maxChainDepth`(默认为1,000,000)而非JVM栈的限制。在栈未满的情况下,基本可以采用旧的递归思路去分析更新。而在更新栈深度溢出的情况下,也仅是后续更新无法进栈而不会中断已有的更新,更新的原子性更加明显,也就解决了很多数据完整性的问题上香物品分身
到这里就可以解释很多人都喜欢问的问题:为什么更新跳略器的bud链总是要连接地狱门而不能连接黑曜石呢?还有为什么跳略器破的门的数量会更多呢?
主动挖掉黑曜石时更新栈为空,因此所有的更新在入栈时都将立即由`runQueuedUpdates`处理,直到清空栈并重置`depth`才会执行下一个更新。黑曜石的方块更新被首先处理,`depth`被跳略器占满后清空,接下来真正导致地狱门破碎的状态更新则毫不受影响。反过来说,bud链连接地狱门方块(下称为"目标")条件下,跳略器在目标的状态更新处理过程中发挥作用,此时更新栈中必定有目标自身的状态更新,所有更新(包括对bud链的方块更新以及对目标毗邻的状态更新)都会逆序压入栈中而非立即处理,此时方块更新会先于状态更新执行,并且直接连锁占满全部深度。由于深度已满,最后执行的目标毗邻状态更新将无法连锁更多状态更新,但是他们自己还是会破碎。
上文中笼统的使用了更新单元来指代`ChainRestrictedNeighborUpdater.Entry`,这一概念可以简单理解成“某方块接收到的一次更新”但存在例外,以下展开讨论。
更新单元
更新背景知识
这部分内容老生常谈,Fallen_Breath的专栏https://www.bilibili.com/read/cv4565671 介绍的非常详细,推荐大家阅读,其中大部分内容直到目前版本都还是兼容的,这里基于高版本情况进行一点补充。
众所周知方块间互相更新的方式主要为两种:方块更新和状态更新
方块更新:响应方块更新的主要是红石元件,经历这么多版本更新的也不多,在Fallen列表的基础上补充一些:
漏斗更新锁定状态
钟响起
大型垂滴叶复原
结构方块工作(即时且不发出状态更新)
铁轨、红石粉、中继器和比较器检查附着情况(较为特殊)
举个例子,如图结构中,铁轨(以及红石粉中继器比较器)在你开关活板门(状态更新)时不会掉落,在活板门打开后给中继器换挡(方块更新)就掉落了;如果换成火把等其他附着方块则恰恰相反,mojang这么写一定有他的深意
状态更新:能响应状态更新的主要是各类附着类方块(尤其是各种多方块的植物),同样在原列表基础上补充一些:
床的两半同步占据情况
连接情况(如钟)、附着情况(如竹子)、含水方块的补充,大同小异恕不赘述
竹笋长成竹子,大型垂滴叶长成藤(更新源点为新长出来的方块)
蜂箱检查是否被火影响
营火检查是否成为信号火
可能是考虑到这一更新极其普遍而且极易连锁,mojang为状态更新的响应添加了一些限制:很多响应状态更新的方块(经典的如脚手架)都带有延迟,成片的脚手架并不能瞬间产生大量的更新;此外,状态更新具有方向参数,大部分对状态更新的响应都具有方向检查。
在更新传递的过程中还有两种情况较为特殊:
首先是红石粉的`prepare`更新。注意这里并不是在说红石粉广为人知的二阶毗邻方块更新,而是红石粉的状态更新也有着更大的范围。大部分情况下方块发出的状态更新都是向六个方向的,而红石粉则会在六向状态更新的前后各加入一次`prepare`更新,这种更新将会按照北东南西的顺序依次检查与当前红石粉有连接的方向,分别对该方向下一层和上一层的红石粉进行状态更新,方向为水平从源连向被更新的红石粉。其本意应该是考虑红石粉具有跨层连接的能力,而彼此连接的红石粉之间应该可以连锁进行状态更新,但是这无疑让红石粉的更新范围更加重量级了。别人状态更新都是固定6次,红石粉最高可以有22次。
另外是比较器的更新,现在容器变化不会直接发出方块更新了,而是使用特殊的方法`updateComparators`。在一个具有比较器输出的方块发出的普通的六向方块更新后,将会按照北东南西的顺序寻找比较器或是隔着实体方块的比较器(不检查朝向)并对它额外发出一次方块更新。
从更新到更新单元
说回到新的更新逻辑上,在`ChainRestrictedNeighborUpdater`中更新单元`Entry`共有四种实现:
`StateReplacementEntry`对应状态更新,这一类又简单又好用。其来源为方法`AbstractBlock.updateNeighbors`和红石粉`prepare`更新。前者是对一个方块的6个毗邻进行状态更新,也就是每当有方块的状态发生(非FORCE的,反例如点燃地狱门)变化时,都会稳定的产生6个`StateReplacementEntry`,红石粉在合适的条件下则会产生更多。
`SimpleEntry`与`StatefulEntry`均对应方块更新,即`ServerWorld.updateNeighbor`的两种重载,功能上没有区别,Stateful提供的额外参数记录本次更新是否为活塞移动,用于控制部分方块在移动时的不同表现
`SixWayEntry`是最奇怪的单元,对应`ServerWorld.updateNeighborsAlways`和`ServerWorld.updateNeighborsExcept`两个方法,功能是提供五向更新和六向更新(均为方块更新)。也就是方块更新在很多情况下虽然会向着六个方向扩展,但是在更新栈中只占据一个深度。
更让人头疼的是这一单元在栈上执行的逻辑非常晦涩,每次执行都只能处理一个方向,若这并不是最后一个方向则不退栈,将这一方向连锁产生的其他更新进栈并优先执行(考虑到栈的性质),直到所有方向都完成自身才退栈。确实是模拟了低版本递归的过程,但写的真臭。
相比状态更新,方块更新的产生方式复杂的多,常常出现多套更新组合,但是大部分更新都是五合一六合一的打包出售。因此在跳略器设计中,状态更新应该是优先考虑的主力输出。
更新跳略器
任何一个可以稳定提供一百万次连锁更新的装置或许都可以称为更新跳略器,而从工程的角度来看,耗材、体积、鲁棒性、实装可行性都是比较重要的考虑因素。
如果不考虑耗材问题,堆上密密麻麻高低错落的红石粉和铁轨就是不错的方案。使用推探测铁轨的方式bud这些元件,玩家的更新引起它们的下边沿将创造出数量可观的更新。由于结构十分简单,这一方案的鲁棒性相当不错。月饼服下界就做了一台这东西,拿来雕过花、切过猪人塔恶魂塔和各种双维度收集的门。唯一的问题就是六七万的耗材后勤听了比较高兴。
接下来重点介绍的“墙式”跳略器一般成品的方块数都在一万上下,相比而言已经是相当小的规模了。
“活板门-墙面”更新倍增 from @Igna778
乍听起来比较奇怪,区区活板门也敢和红石粉比卡顿?(笑)前面已经分析过,红石粉的状态更新也是遥遥领先其他方块的,但是墙面极好的堆叠性提供了一种极低成本倍增更新的方案。只要对红石布线有所了解,应该都听说过“墙电”是一种优秀的无延迟向下传递信号的方式。在一格墙连接“不平”(东西/南北的连接情况不同)时,它和正下方所有连续的墙都会被更新状态`up=true`
既然如此,在高处通过活板门对整面墙面状态进行大量的即时更新,就可以达到倍增更新的目的。这里可以简单做一次估算,实际的机器可以分为上方控制活板门的头和下方大量墙组成的尾巴,上方的“头”可以制造72次墙面更新(这么离谱的数要归功于下一节介绍的活板门控制),那么下方的尾巴的每一格墙所贡献的更新深度是多少呢?每次状态变化都会向六个方向发出状态更新,因此答案是`72*6=432`,在目前14宽堆叠的设计下,108高的尾巴的耗材恰好为一盒材料,而它能贡献的总深度已经超过了六十万,如此高性价比的拓展能力可以说是这种设计的跳略器的核心。
活板门控制模块设计 from @Igna778 & @enokilovin
实际上Igna778的v6设计已经大致给出了这个设计的基础,enokilovin则是相当有创意的改进了这一结构。
更新单元可以分为三个BUD模块:被侦测器BUD的铁轨、被红石粉转向BUD的活板门、被探测铁轨BUD的红石粉。在理想的工作时序中,铁轨应该首先受到方块更新,铁轨熄灭后会更新自身下方方块的毗邻,也就是下方两格处的活板门,活板门激活上边沿并向墙面发出状态更新,这一状态更新将一路向下传递到底。在这之后更新能量为15的红石粉,这将引发一系列更新并导致所有红石粉能量降为0,由于红石粉有着二阶毗邻的更新范围,这一更新再次传递到活板门并引起活板门的下边沿,再次更新整个墙面。也就是在一次更新的连锁反应里,活板门硬是经历了一个上边沿一个下边沿,有点“二阶BUD”的感觉。你也是最短脉冲?
插一嘴:虽然道理讲出来很浅显,但这确实是很精密的过程,作者的创造力令人叹服。别说聊阶段的微时序基本理论了,这个时序关系比依靠多个方块事件进行排列的BED还微观得多:在同一gt的同一阶段的同一次连锁更新之内。
复位过程里BUD铁轨自不必说,这个红石粉倒是很有意思。在探测铁轨推出时,由于熄灭后压线的活塞收回,高低错落的红石粉彼此连接,无法充能靠近活板门的实体方块,因此活板门并没有收到上边沿信号。接下来压线活塞开始推出、探测铁轨收回,二者分别制造了活板门和红石粉的BUD状态。
这整个结构是3高可堆叠的(并不是严格独立的堆叠,存在标靶QC串线的情况因此复位需要调节时序),在墙的两面对称制作即可保证贴近墙的方块不干扰更新的传递,同时使得这小小的3高里塞下了4次对整个墙面的更新。关于堆叠性顺便提一下在BUD铁轨那一侧,虽然活板门会和下一层的铁轨直接毗邻,但是活板门的状态变化只产生状态更新不产生方块更新,状态更新可以影响墙但不会影响铁轨的亮灭,妙。
在这一基础上enokilovin进行了一些改进。首先是单元化的自动复位,BUD红石用非门连接了探测铁轨的活塞,接收到更新后红石粉熄灭,从而控制探测铁轨推出使得每个单元都可以独立地进行复位。
另外是一个非常巧妙的铁轨(图中熄灭的那一个),它与活板门一样是被红石转向bud的,同样也会在靠墙的BUD铁轨熄灭时被更新到,并且传递给下方的红石粉。由于倾斜铁轨更新上方毗邻(剩余BUD铁轨)先于更新下方(这个独立铁轨),从递归的思路来说在这个独立的铁轨被更新时上方都已经更新完毕,活板门都已经完成上边沿。这样只加一格铁轨就在不改变原有时序的情况下将每个单元的更新入口合二为一,甚妙。
fix:这边稍微有点图文不符,由于螺旋结构的关系这个单元的工作顺序不太一样,在下一节有解释
单元间更新传导逻辑
enokilovin给出的单元单入口模式实际上已经大大简化跳略器设计的难度,不过大佬实在太卷了,发布的版本极致压缩了体积,或者说进行了极致的炫技。虽然BUD设计确实赏心悦目,但是真的太复杂了。
有人说这个生存实装难度高,但其实创造不粘贴实装难度也很高。建议任何不熟悉BUD的玩家在计划实装这个版本前在创造对着投影一格一格试着摆一遍。
其复杂一是体现在不拘一格的BUD设计上,作者使用的结构相当灵活,不同方向之间甚至互相穿插,可以说创造摆一遍就是对铁轨BUD的习题课;二则是其更新传导的逻辑采用了螺旋下降的方式。用类似AB片堆叠的方式将更新一圈一圈往下转,往下转的过程中触发一轮活板门更新,在回溯(之前说过大部分时候用递归的思维分析就是对的)的过程中触及之前提到的单格铁轨就将触发那一层的红石粉熄灭,额外更新一次活板门。
细心的话可能发现这里有点问题:enokilovin的设计中,更新红石粉的单格铁轨都是在同一侧的,既然又是螺旋下降,这一侧既是BUD铁轨更新的起点,又是BUD铁轨更新的末端。在末端更新这格铁轨的话前面说的精密时序就寄了。
实际上整机运行情况反而要比前面单个单元的描述宽松一些,在更新BUD铁轨时是可以更新到上方单元的活板门的(而且向上的优先级极高),因此在这个架构中无需担心单个单元的顺序问题。可能也是出于这个考虑,虽然只有18层但是这个架构的BUD铁轨绕了19圈,最后一圈兜底将18层的活板门都优先更新好。
螺旋下降的更新传递模式带来了极大的实装难度。用最直观的比喻,就好比造绿荫的时候看到一大排互相检测的侦测器,其中每一层都还有一个脸朝上的。建造过程中不仅需要各种技巧解决铁轨方向问题还要处理很多BUD铁轨熄灭的问题,可以说是非常坐牢。红石粉部分相对简单,但因为上下层之间存在QC的串线问题,修复的时候也需要从上面慢慢往下检查。
主要是出于优化这两个问题的目的,我制作了一个简化的架构:使用垂直BUD直接连接所有更新单元,或者可以称为“鱼骨形”的更新架构。此外牺牲了一些体积,把压线活塞的线接到外侧避免了QC。
在我的这个架构中,更新从下方入口开始向上传导,由于方块更新y方向的优先级高于z,它会先走完鱼骨的主通道。接下来按照递归回溯的思路,一条一条地遍历子通道,并且正如前面提到的架构,在一个子通道的BUD铁轨更新完毕后将会有一个铁轨将更新引至红石粉,进而完成整个更新单元的全部工作。
个人认为这种架构相较于各个单元互相影响的螺旋架构而言有着更强的鲁棒性,铁轨都可以在起点处直接点亮,告别了掏下水道的坐牢体验。
复位逻辑(尚有疑问)
这节本来没计划写的,测试中遇到了一个很蠢的问题临时加上
基本的复位是很简单的,红石部分前面已经有所涉及,垂直bud部分也只是一个简单的红石方向bud,而最可能被跳略吃掉的墙更新可以通过一次顶部往下传全更新来把之前被跳略的部分给修复好。
那么问题来了:如果恰好活板门的位置被跳略的话会出现墙与未打开的活板门连接的奇怪情况,这时候非常蛋疼的就是从别的方向(比如刚刚说的上方)发出的状态更新都无法让这个墙复原,只能更新一次活板门,但是此时的活板门是关闭的状态,而这个架构下红石粉部分复位是不会给活板门上边沿更新的,因此唯一可行的办法就是跑一遍全流程,以铁轨作为更新源给活板门一次方块更新,进而状态更新到这个bud态的墙。
但是这样的话整个跳略器的工作周期直接翻倍,而这个问题的出现又是位置性的,似乎和红石粉的更新顺序有关。
我暂时还没搞明白前人的设计怎么处理这问题的。只能说如果我的设计如果发现了这一问题(特征为正常工作一次后有墙凸起来了),那么你可以尝试在底部拆掉一两个墙,随着更新数量改变,跳略的位置很有可能就不是活板门了(吗?)
更新跳略器的应用
上面都太硬了,来点轻松简单的吧
地狱门更新路径
在规划切门的时候只需记住一个规律:地狱门破碎的更新是状态更新,遵循-x+x-z+z-y+y的优先级进行递归更新。
从切门的每一刀来看,我们都需要一个起点(被更新的点位,比如挖掉毗邻的黑曜石或是摆一个毗邻的方块)一个终点(连接BUD链的点位),根据上面的规律就可以得到一条唯一的路径,在这条路径上以及毗邻这条路径的所有地狱门都会破碎。
恭喜你学会了切门,现在我们来看几个例子(都不是唯一解):
【约定:绿色为更新的路径,需注意这些点位不能与BUD直接相连,不然就被截胡了;黄色为路径的终点,也就是连接BUD的位置;黑色为连带破碎的位置;白色为最后剩下想要的。】
切底(常见于收集、海货塔),最简单的一类。让更新路径恰好通过最底下一整排,就会切掉底下2排的所有地狱门。
切框切顶(常见于刷怪塔),跳略器暂时还没有换底换框的办法。按照前面的思路换底换框需要在一次更新内放置出要换的方块,但是似乎并没有更新能做到这个功能。
切单片(常见于装逼)
?(不常见)倒过来也能用
切门实用BUD链推荐
这一块有点离题了,因为经常被问就放进来,不打算展开讲,如果还有疑问的话或许可以在评论区问。
BUD活塞推一下重置的那种似乎普及度很高,就不赘述了。
一个低耗材易建造全点位高鲁棒的BUD链,自己就是信号源不依赖末端激活,我基本上能用这个都会用这个,缺点是更新点位不平不方便接可移动,另外要注意几点:跳略时要让更新路径通过这个被侦测的铁轨,还有接侧面BUD的时候要小心侦测器对屁股毗邻的更新(尤其是4gt时的下边沿)。
佛冷牌垂直BUD链,简单好造不坐牢,跳略器里用的就是这个。以0-NU的更新计算复位在6-TE,边沿在3-TE,直接把更新接到复位线上的话微妙的刚好与侦测器兼容。
红石粉附着性的临时BUD这就是前面提到的mojang的深意结构简单制作方便,发的还是二阶更新可以隔着门更新到BUD链,缺点是一次性,一般用来最末端延长一段,雕花的时候因为灵活方便香的一批。
按照实际的切门规划,可以制作一些特殊的BUD链,关于这个最近还在做工程,先卖个关子。