【我帮鹰角修bug】一堆bug和“bug”的...异客

前言
继安洁之后,第二个成功做到包场修bug系列的干员来了。没错,他就是异客。

根据目前的发现,异客身上一共有4个bug或“bug”,分别为:
异客未携带模组时,3技能辉煌裂片造成停顿时间异常。
异客携带模组时,1技能电能之触造成停顿时间异常。
异客3技能辉煌裂片,缓存攻击力机制(快照机制)未能按设定正常生效。
法术伤害通用bug:受到的理论法术伤害刚好等于剩余生命值时,单位不会死亡。
其中前两个是非常明确的bug;第三个我不清楚算不算bug;最后一个是优化底层优化出来的,其实不能算是异客的bug,很多干员都有这个问题,不过既然异客也有那就放一起说好了(其实是懒得再开一期)。

异常的停顿时间
第一个bug:异客未携带模组时,3技能辉煌裂片造成的停顿时间存在异常。
实际上就是,不带模组的异客的辉煌裂片的停顿时间,只有0.2s,而不是普攻的0.5s。这个bug可以在游戏中简单地被测试出来。例如此视频的1:06处,拳击手鳄鱼被辉煌裂片连续命中时,移速时快时慢(辉煌裂片攻击间隔为0.5s,若停顿也持续0.5s,停顿应当是无缝的,移速应当是均匀的)。
有印象的朋友们应该还记得,0.2s的停顿是异客在2021年8月3日更新前的数据,而0.5s的停顿是更新后的。而这个bug的成因也很简单,鹰角给异客做加强的时候,忘了改了...(虽然比起鹰角忘了改我更想吐槽这bug居然3个月没人发现...)
那么为什么只有辉煌裂片出现了忘了改的问题而其它技能/其他角色没有呢?
简单来说,辉煌裂片的停顿时间,和其它技能的停顿时间,不是写在一起的。
辉煌裂片,从比较好理解的角度来看,可以算是瞬发技能(Skill里直接搭载的RangedAttack),而不同于聚焦指令初雷这样的切换模式类技能(Skill里搭载的是切换模式和加攻减间隔buff)。作为非普通攻击,辉煌裂片无法像切换模式后的普攻那样读取attack blackboard里记录的停顿时间。为了设置辉煌裂片的停顿时间,鹰角在skill blackboard里额外进行了设置,像这样:


鹰角在加强链术师的时候只改动了特性写在attack blackboard里,被普攻读取的停顿时间,而把辉煌裂片这个不算普攻的特例给忘了。
但是三个月之后,给链术师加专属模组时,他们又把这事想起来了。异客的专属模组,专门对辉煌裂片进行了调整,像这样:

专属模组拥有较高的优先级,它提供的数据可以覆盖掉其它来源的数据,而异客的专属模组,为skill blackboard提供了可供辉煌裂片读取的0.8s停顿时间。因此,携带专属模组时,辉煌裂片的停顿时间是正常的,和普攻一致的0.8s。
然而,鹰角仍然忘了2件事:
1. 他们仍然没想起来去看看辉煌裂片的本体。不携带模组的异客,辉煌裂片停顿时间仍然是0.2s。
2. 他们忘了,异客的1技能电能之触,也是个瞬发型的技能,需要从skill blackboard里读数据。而专属模组写在skill blackboard的,本意是提供给辉煌裂片的停顿时间,因为没做名称上的区分,也把1技能电能之触的停顿时间给覆盖了。
这就是第二个bug,携带模组的异客,1技能电能之触的停顿时间是0.8s,而不是描述中的1.5s。这个各位可以自行测试,0.8s和1.5s差别还是挺大的。
修复方法:
第一个bug:把0.2改0.5就行。
第二个bug:想办法加一个标志来区别两个不同的技能/能力/停顿buff。停顿buff的durationKey好像在buff_table里被写死成sluggish了,通过buff区分估计不太行。可以试试断罪者那种,给ability加blackboardPrefix来区分。

异常的缓存/快照机制
首先声明:这个我不知道能不能算bug,我只说一下我知道的事实和原理,不会给出解决方案,最终判断权归鹰角所有。
2021年4月的遗尘漫步版本,实装了干员异客,同时增加了一个新机制:始终使用缓存攻击力的,造成有来源伤害的弹道 (_useCachedAtkOnly + _transferSource)。
首先需要解释一下什么是缓存攻击力/快照机制。一般情况下,弹道存在期间,每次造成伤害时,都会获取来源的攻击力,换言之,一般情况下弹道的攻击力会随着本体的攻击力实时发生变化。但是使用缓存攻击力则不同,弹道的攻击力会在弹道生成时被确定,之后不随本体发生变化。(玩过原神的可以理解为香菱大和行秋大的区别)
缓存攻击力机制开服就有,用于应对弹道命中时来源不存在的问题(大概就是弹道生成后把人撤了,再命中就是使用的就是弹道生成时缓存的攻击力,常用于保证火山最后一个火球吃到加攻)。而强制弹道使用缓存攻击力的机制,也在之后实装(具体时间记不清了,印象里是一周年前夕)。使用此机制时,_useCachedAtkOnly被设置为true。最典型的例子是干员W的3技能D12,技能攻击力在炸弹安装瞬间就确定了,而不是倒数结束造成伤害的时刻。但同时,这个强制使用缓存攻击力的机制的机制存在一个问题:因为是照搬的应对来源不存在问题的方案,导致之前版本强制使用缓存攻击力时,弹道始终会造成无来源伤害。还是W的D12为例,因为伤害始终无来源,不会因为D12自身的倍率跳红字,目标不会有受到伤害的变红特效,吃不到防空符文的加成,打进化的本质12阶段始终不会被方向减免伤害,如果有反甲也不会被反伤。
由于无来源伤害的问题,在2021年4月的更新中,加入了新的机制,允许弹道强制使用缓存攻击力,并造成有来源的伤害(使用此机制时,_useCachedAtkOnly和_transferSource同时被设置为true)。而这个机制,截至当前版本,在且仅在异客的3技能辉煌裂片中被使用。

然而,事实上,辉煌裂片的攻击力是随异客的攻击力实时变化的,这和使用的设置明显不符。这是为什么?
首先需要了解的是,任何攻击/弹道导致的伤害,都是通过造成伤害的行为(Action)执行的。一般情况下,用于执行弹道攻击的能力,会在初始化时将供弹道使用的行为(例如造成伤害)配置好(始终依赖缓存攻击力的有来源伤害的设定就是在此时配置的)。而在生成弹道时,会将这些行为进行预设置(PreprocessActionsForProjectile,例如缓存来源的攻击力),并导入到弹道的设定中。
一般情况下,弹道生成后,其搭载的行为就不会发生变化,生成时缓存的攻击力会被忠实地被保存到最后。然而链术师弹道的连锁攻击行为(ChainLightningHitBehaviour)是个例外。
由于链术师的连锁弹道每经过一次弹射攻击倍率都会衰减,因此每次弹射时,都要创建新的伤害行为,并替换掉旧的,以此更新伤害倍率。随后,会再次对新生成的伤害行为进行预设置,并重新导入到弹道的设定中。而对伤害行为进行预设置时,会立刻获取来源当前的攻击力并储存为缓存攻击力。因此,异客的辉煌裂片虽然设定上是使用的缓存攻击力,但因为每次造成伤害前缓存攻击力都会更新,所以辉煌裂片的攻击力实际上是实时更新的。
有意思的是,鹰角早在迷迭香实装时就注意到了类似的问题。迷迭香的攻击也有着和链术师类似的在造成伤害前创建新伤害行为的机制。可能是为了避免迷迭香撤回后弹道无伤害的问题,他们给迷迭香的2技能添加了一个名为_deliveryParaWhenReplaceActionNode的机制(上图中第一个变量)。这个机制会在新创建的伤害行为替换掉旧伤害行为前,将旧伤害行为中缓存的攻击力传递到新伤害行为中。而干员撤回后,能力不会对弹道中搭载的行为再次进行预设置(缓存来源攻击力)。因此即使迷迭香撤回后,2技能弹道也可以正常造成伤害。然而这个机制目前并不能在异客身上使用,因为辉煌裂片实时更新攻击力的问题是在异客存活时出现的,这时预设置仍然会在传递缓存攻击力后进行,并覆盖掉来自于旧行为的缓存攻击力。

异常的伤害数值
最后一个bug,准确来说不是异客的,很多干员/敌人都有类似的问题:
受到的理论法术伤害刚好等于剩余生命值时,单位不会死亡。
这个bug可以参见视频BV1ub4y117iB。此视频中,2400血量0法抗的风笛,在受到2发深池塑能术师队长0距离的火球(每发600法术伤害)和一次灼燃毁损(1200法术伤害)后,显示剩余0血,但并未死亡。
这个bug的成因其实很简单,概括地说就是二进制小数的精度问题。
在铅封行动版本更新后,为了解决之前由于浮点精度导致的一系列问题(部分可参考专栏cv7887116),在大部分的游戏数值计算中,32bits的单精度浮点数被替换成了64bits的定点数。
定点数,顾名思义,就是将小数点固定在某个位置的数。以方舟运算中使用的定点数为例,一共是64位,其中高32位用于表示一个数的整数部分,而低32位用于表示这个数的小数部分。相比于浮点数,定点数的精度是确定的,不会因为需要表示的数字的绝对值大小的增加而精度减小,但相对的,定点数的值域也会比相同位数的浮点数小上许多。
而二进制,大家应该都很清楚。举几个例子,1200用二进制表示就是100 1011 0000,而1000.5是11 1110 1000.1。
然而,如果要表示的数是0.01呢?按一下计算器,会发现,0.01这个在十进制中可以被有限位数精确表示的小数,在二进制中却是一个无限循环的小数:0.00 00001010001111010111 00001010001111010111 ... 而方舟使用的定点数,理所当然地无法准确表示这样一个小数,只能记录小数点后的32位,也就是.0000 0010 1000 1111 0101 1100 0010 1000
而这样一个数,就存在于法术伤害的计算公式里。法术伤害的计算公式为(来源于prts,自己也拆着验证过):
基本法术伤害 = MAX(0.01 * 攻击力 * 攻击力倍率 * MAX(0, 100 - (1- 百分比无视法抗) * MAX(0, 目标法术抗性 - 数值无视法抗)), 0.05 * 攻击力 * 攻击力倍率)
由于法术伤害计算公式中的0.01,无法被有限位数的二进制小数精确表示,储存在定点数中的0.01并不是精确值(因为后续位数无法保存,实际比0.01略小)。因此,最后计算出的实际结果略小于理论伤害值。而最终结果的绝对误差,是远大于定点数最小精度的(根据误差传播的原理,变量与常数做乘法时相对误差保持不变,换言之乘了多大的常数绝对误差就扩大了多少倍。后面乘的一堆攻击力和100-法抗乘起来肯定不止2,完全足够让这个2^-33级别的绝对误差扩大到2^-32以上,然后被精度为2^-32的定点数记录下来了)。
这样一来,就可以解释这个问题了。这是因为法术伤害计算公式中的0.01,无法被二进制定点数精确表示,最终导致实际法术伤害略小于理论值(例如理论上为1000的法术伤害,实际上可能只有999.99999)。而物理伤害,由于计算中不存在这样不精确的常数,所有并不存在这个问题。同样的问题也出现在其它需要使用,无法被二进制精确表示的小数,进行运算的地方。例如贝娜2技能根据最大生命值的流血。
不过最后,还剩一个问题。为什么之前版本使用单精度浮点数进行运算的时候,并没有出现这样的不精确问题?浮点数不也是基于二进制小数的吗?
关于这个问题,简单来说,浮点数确实和定点数一样,无法准确表示0.01这样的小数。但是因为浮点数的精度是变化的,所以0.01导致的误差无法被记录在最终的计算结果中。具体的,我试着写了一些数学论证,有兴趣的朋友可以看下。


论证过程。格式不严谨,思维比较跳跃,欢迎提出更好的方案
修复方案:
1. 尝试在比较时加一个可容忍的误差。
2. 再改一下数据类型?不过我目前也不太清楚改成啥样可以既保留较高的运算速度同时保留必要的精度。还要再研究+测试一下。