精灵宝可梦 黄 TAS ACE 演示说明

本文是对视频投稿 原版精灵宝可梦黄可以怎么玩?TAS代码执行演示 的说明,因为我是先投的文章再投的视频所以可能各位看到文章的时候视频还没上线,请稍等一段时间。

本文是对 http://tasvideos.org/5384S.html 内容的翻译。本文同样发布于cnVintage古董电子论坛。

视频转载在遵从Creative Commons Attribution 2.0协议的前提下转载,并符合TASVideo的要求:

  1. 图像和音频内容未作修改

  2. 视频清晰标明TAS(工具辅助运行)以避免一切可能的混淆

  3. 注明实际的玩家

整个视频的内容使用了原版的 英文 精灵宝可梦 黄 GameBoyColor游戏ROM完成,且没有使用作弊工具或者预先准备的存档,整个过程使用lsnes模拟器完成。

在开始之前,介绍几个术语:

TAS:Tool-Assisted Speedrun,工具辅助操作。简单讲就是利用脚本来控制模拟器来运行游戏。或者可以大致理解为用按键精灵玩游戏。TAS的目的并非是为了降低游戏难度或者是类似的作弊目的,而是为了演示游戏操作所可能达到的极限。

ACE:Arbitrary Code Execution,任意代码执行。通常在游戏时,游戏机的处理器只能运行游戏本身的代码。而通过利用游戏的漏洞则有可能可以执行玩家输入的任意机器代码。这种行为被称为ACE。

提示,以下内容含有大量剧透,如果你还没有看过视频,强烈推荐先观看视频。如果对GameBoy的硬件感兴趣,建议观看视频:

= 正文开始 =

导言

我一直想试一试做一些有关ACE的作品,然而要做一些有意义的事情并不简单。毕竟,ACE允许执行任意代码,也就是可以做任何事情,这也就导致人们对ACE作品通常会有更高的要求。除了技术上要有新意,观赏性也是需要考虑的一大部分。

为此我已经准备了几个月的时间,而且已经慢慢对这个失去了兴趣。然而AGDQ 2017的时候,我看到了其中一个TASBot的环节,类似的概念,但是不同的目标。我觉得,如果现在不做,以后也就不可能再做了。所以感谢所有准备和参与TASBot的人们,不然这个坑可能就根本填不完了。

目标

这个作品的目标是为了尽可能多的展现ACE能实现的效果。我觉得ACE经常被误解为“可以用来直接跳到结尾”或者说“可以让游戏出现奇怪的效果”。毕竟能做任何事的这种概念本身就很难理解,而且大部分的应用中,ACE的用途都是受限于游戏本身的目的的。

这个作品的一个目的同样也是探索GameBoy硬件的限制。所以这个作品不单单是尝试利用游戏,也包括游戏运行于的硬件。

目标的选择

最开始的目标只是,利用ACE在一个游戏内玩另外一个游戏。于是显然需要是同一平台上的两款游戏,毕竟我也不想用ACE来写一个模拟器,而且在更强大的平台上运行较差系统的模拟器本身就不那么cool。

具体来说,我就考虑选择在第一世代精灵宝可梦中运行第二世代的精灵宝可梦游戏。原因是,我对GameBoy系统和游戏已经有了一定的了解,第一世代的ACE设置起来非常简单,所以很快就能看到效果。这样黄版基本也就是最佳的选择了,因为它是唯一一款原生支持GBC的第一世代精灵宝可梦游戏。

然而还是有更多问题。GameBoy的卡带并不只是单纯的ROM,里面还有独立的控制器和其它的扩展硬件。比如说大部分的游戏都有RAM用来保存存档或者高分记录。第二世代Pokemon还有RTC,用于在游戏中确定时间。第一世代的游戏并没有这些东西,用的是完全不同的控制器,导致第二世代的代码根本就不可能在第一世代的卡带上运行。

但是既然整个TAS是预先定义的输入文件,也就是并不会有玩家去操作游戏,那其实根本不需要去运行完整的游戏,只要能输出和原游戏一样的音视频效果就可以了。

所以这就是整个演示的关键,也就打开了更多的可能性:并不一定需要是某个其它的游戏,可以是其它游戏的hack,或者是不同游戏的混合,或者是其它平台的游戏,或者干脆是任意的效果。这也就使得整个游戏的目标不再是在一个游戏中运行另外一个游戏,而是尽可能发挥GameBoy硬件的极限。

游戏的选择

基础游戏已经不那么重要了,毕竟主要的目的就是运行ACE进行演示。我最后还是选了精灵宝可梦 黄,因为ACE设置很方便,而且也支持GBC。但是所有其它支持ACE的游戏也是可以的。

模拟器的选择

这次演示选用了lsnes。lsnes有一个很重要的特性,就是它可以支持sub-frame输入。游戏是可以在任意的时刻,以任意的频率获取按键输入的,然而大部分的模拟器都限制了输入频率为每帧一个输入。也就是这一帧内,游戏每次读取按键输入,得到的都是同样的结果。通常来说,这种程度的模拟已经足够好了,毕竟大部分的游戏一帧内也就循环一次。

但是实际上并不够。因为对于帧的概念也是不确定的。游戏循环的帧和输入读取经常并不是同步的,所以就可能丢失一些输入信息。

一个更好的解决办法,也就是在lsnes中使用的,就是允许每次游戏读取输入的时候,都允许提供不同的值。这样就能保证能在模拟器上复现一切可能的输入。虽然还是有一些尴尬,因为这种情况下,帧的概念变成了输入文件。尽管确实可以为每一次扫描定义不同的输入,然而当一帧内需要有很多不同的输入的时候,还是需要精确知道输入是发生在哪个时钟周期,这样才能把输入关联到对应的输入帧上。

当运行自己的代码(ACE)的时候,一帧内进行多次输入的做法可以大幅度提高通过按键输入数据的速度。因为按键输入是唯一的数据源,这种方法就可以大幅度提高ACE设置速度,也可以实现如这个演示中做到的实时动画播放。实际运行时,每帧通过按键输入的数据达到了数千字节。

ACE设置

ACE设置通常是分多个阶段进行的,也就是逐渐获得更高控制权。你可以把它理解成为一个操作系统的启动引导器。

这个演示使用了三个启动阶段来载入最终的演示代码(Payload)。

所以为什么不直接用一个直接来载入最终的代码呢?问题就在于最开始的情况下能够改动的东西很少,而且需要花费很长的时间,所以最好的办法就是写一个很简短但是可以更快载入其它内容的载入程序,再用它来载入剩下的东西。

第一阶段是9字节长,使用的是在游戏中操作物品的方法写入的,写入每个字节都需要几秒钟时间。第二阶段是13字节长,使用第一阶段的载入程序载入,速度是一帧一个字节。第二阶段的载入程序就可以实现在一帧内载入多个字节了,也就使得剩下的载入变得非常迅速。

第一阶段的载入和FractalFusion’s Pi Movie中用到的方法很接近,只是进行了一些小幅度的改进。使用了相同的代码,但是在操作上做了一些修改,使得速度稍快了一些。代码的作用就是每帧往$d350的位置写入一个字节,且会被立即执行。具体说明可见 https://tasvideos.org/forum/viewtopic.php?p=342003。

以下是具体的代码:

D366:

LDL (HL) A

NOP

HALT

NOP

LDH A (FFF5)

CALL NC D350

使用第一阶段,第二阶段的代码被写入到$d350的位置:

LD [$FF00+C], A ;启用按键输入

JR .START ;跳过循环部分

LD A, [$FF00+C] ;读取4bit按键输入

SWAP A ;交换低四位和高四位

LD D, A ;暂存在D寄存器当中

LD A, [$FF00+C] ;读取另外4bit

XOR D ;组合成一个字节

LD [HLI], A ;把结果写入内存

.START

XOR E ;与E=$5d进行异或运算

JR NZ, .LOOP  ;如果结果不为0继续循环

因为在这个阶段,寄存器C是0,所以说[FF00+C]就刚好是指向FF00,也就是按键输入。

在GameBoy中,输入并不是一次性读出的。每次只能读取一半的输入,要么是方向要么是按键,一次4位。另外4位永远是固定内容。为了能读出完整的一个字节,按键需要被读取两次,结果通过XOR来组合在一起。

最终的一个XOR E只是为了用于结束条件。因为代码中必然会出现0,所以用0来作为结束条件并不可取。而和$5d进行异或也就使得5d成了结束条件,而5d刚好也是一个可用的值。

第三阶段已经不用担心大小问题了,因为第三阶段的代码已经可以被非常快速地载入了。所以主要的目的就是完成设置,准备好最终演示代码的执行环境。

CALL $1E96 ;调用 GBFadeOutToWhite,让屏幕渐变到全白

DI ;禁用所有中断

LD [$FF00+C], A ;重新启用按键输入

LD [rLCDC], A ;禁用LCD

INC A

LD [rKEY1], A

STOP ;启用双倍速模式

LD HL, $C000 ;把Payload载入到C000

.LOOP

LD A, [$FF00+C]

SWAP A

LD D, A

LD A, [$FF00+C]

XOR D

LD [HLI], A

XOR E

JR NZ, .LOOP

JP $C000 ;跳转到写好的代码

首先调用了精灵宝可梦黄自带的函数GBFadeOutToWhite,可以让屏幕渐变到白色,为游戏场景和ACE场景提供了一个很好的衔接。在渐变之后禁用屏幕(这样才能访问特定的内存区域,以及进行精确的帧计时),随后启用系统的双倍速模式,把主频从4MHz提高到8MHz。

演示代码

注意:这个地方只是大体介绍一下GameBoy中相关的部分。需要更加深度的解读,请参考PanDocs(http://bgb.bircd.org/pandocs.htm)以及GameBoy CPU Manual(http://marc.rawer.de/Gameboy/Docs/GBCPUman.pdf)。

所有的图形就是基于8x8像素,2bpp的tile产生的。这些tile可以以 背景、窗口和精灵的方式绘制到屏幕上。背景是一个32x32的tile网格(总共有两个,可以选择其中一个显示),可以被平滑得在屏幕上移动。窗口使用了和背景同样的tile网格,但是并不能移动,被渲染在背景之上。通常被用来显示菜单、对话框、全屏图片等等。最后,精灵的大小可以是一个tile(8x8)或者是两个tile(8x16)大小,可以被放在屏幕的任何位置,也可以半透明。除了tile之外,还有调色板,也就是定义每个tile的4个颜色分别是什么(总共有15bit RGB的色彩可用)。调色板并不是和每个单独的tile一一对应的,而是和tile所在的背景、窗口或者精灵相对应的。所以一个tile可以在不同位置搭配不同的调色板使用。

GameBoy逐行渲染屏幕,每一行的渲染都是独立进行的。屏幕一共有144行,每一行160像素。每一行上所使用的时间是固定的,刚好是912个时钟(所有这里的时钟计数都是在双倍速时钟下进行的)。这912个时钟被分成三个阶段,称为模式。开始是模式2,LCD控制器寻找需要渲染的精灵,需要160时钟的时间。紧接着是模式3,需要渲染的数据被送到LCD控制器,需要的时间在344到592的时钟周期之间。剩下的时间就是Mode0,也被称为行消隐,这个时候LCD控制器什么事情都不做。在144行这样的循环之后,接着还有10行空行,这9120时钟期间,LCD控制器就一直处于Mode1,被称为场消隐。因此,每一帧的时间是912*154=10448周期,最终的帧率也就是8388608 Hz / 140448 = ~59.72fps。

当LCD控制器在读取数据的时候,CPU是没有办法访问显存的。也就是,写入tile、调整背景、调整窗口等等的操作只能在模式0-2内完成,而精灵数据只能在模式0-1之间操作。游戏通常来说都会在渲染画面的时候执行游戏逻辑,然后使用场消隐阶段来刷新下一帧的屏幕内容。

这个演示的一个主要部分就是提供一个可以实时显示各种GameBoy画面的框架。为了实现这点,主要做了以下处理:

从游戏里录制源脚本,记录下相关的内存写入。结果就是产生了一个包含了写入时间 写入地址 和 写入数值 的日志文件。

通过这个日志,也就可以知道在任一给定时刻,任一地址的数据。这也就可以用于确定在每一帧的每一行,哪些tile在这行内被渲染到了背景、窗口和精灵。收集到了这些信息,就可以重新整理出一份效果和原始脚本一样的操作列表。

因为可以使用一些原始游戏没有使用的优化手段,所以效率会比直接用原始数据更高。比如说,它只会绘制这个场景中某个时刻可见的tile,而游戏通常会把所有的tile一次性全部渲染好。另外,大部分的游戏都会按块来一次性载入和更新tile和调色板,即使最后可能只用到了没几个。而现在的演示代码只会载入最终会被渲染到的tile和调色板,所以在整个场景中tile和调色板都只被载入了一次。因为可以预知之后需要显示的场景,也就可以分散载入以后会用到的tile和调色板。

为了能够执行操作来重现场景,操作列表需要被序列化成一条条的命令,可以在正确的时间执行。其实也就是一个带有很多限制的规划问题。因为每个操作不单单是需要在不同的时间执行,这些操作本身需要花费的时间也是不同的,各种操作还只能在特定的时段内完成(也就是没有被LCD控制器使用的时候)

使用的命令就是汇编函数,作为ACE payload的一部分载入,可以进行特定的操作(比如把tile载入进内存),从按键输入读取必要的信息(比如tile的像素,应该被存储在什么位置)。对于每个操作,得知道它们需要多少的周期,以及什么周期它会去读取按键输入,以及在什么时候会写入输出内容。这样才能保证显存是可以被访问的。整个执行过程是对CPU时钟进行精确计时的。

比如说演示中用到的其中一个命令, 可以往HRAM中写入一个字节:

WriteHByteDirect:: ; 88个时钟,4个输入时钟(12,28,40,56),在64时钟输出结果

LD HL, $FF00 ; 12

LD A, [HL] ; 8

SWAP A ; 8

XOR [HL] ; 8

LD C, A ; 4

LD A, [HL] ; 8

SWAP A ; 8

XOR [HL]; 8

LD [$FF00+C], A ; 8

RET ; 16

为了能够定义单个指令运行的顺序,其中一个命令可以把需要执行的命令按照顺序压入栈(从按键读取)。这也是在ACE payload被载入后执行的第一个命令,而在命令栈当中的最后一个指针永远是指向这个函数,所以当命令全部被执行完成后,就会回到这里,写入新的命令栈继续执行。写入新的指令栈和执行指令是以固定间隔交替进行的,因为指令栈的容量有限。

游戏音乐的处理手法也是和图形类似的:日志文件包括了所有到音频子系统的写入,所以复现这一写入我们也就能重现游戏的声音。声音并不是和任何视频帧绑定的,内存也是一直可访问的。最终这些声音操作也被混入到了视频命令中一同被执行。

在ACE播放的GB游戏画面中,声音一直是个次要部分。我也就开始好奇GameBoy的声音硬件能做到什么效果,以及具体能做到什么效果。

结果是,GameBoy的音频硬件能力十分有限。它有4个音频通道,接到两个输出终端。前两个通道可以产生不同频率不同幅度的方波,而最后一个通道只能产生杂波。

第三个通道比较有趣,可以产生任意的波形。然而存储波形的RAM只能存下32个采样不断循环,而且每个采样只能有4bits的深度。设计上是用来产生特殊的重复波形的(比如三角波)。典型的应用比如精灵宝可梦黄的标题界面,那个非常粗糙的声音就是用这个通道产生的,虽然能听清楚单词,但是并不好听。

然而你还是可以让第三个通道来播放更长的音频片段,方法就是在声音播放的时候更新RAM。这当然也就要求精确的计时来更新寄存器,保证每个采样都会被播放一次且仅被播放一次。采样的播放频率只能是2097152/x Hz,而x是一个1到2048之间的整数。而为了能和GameBoy的帧频正好对齐,只有特定的x值可用,也就57的整倍数。这次演示使用的是x=114,也就每912周期2个采样,最终的采样频率大约是18396Hz。

然而这样的话,每个通道还是只有4bit的深度。不过还有一个东西是可以调整的,那就是音量控制。音量控制可以提供8级的线性音量调整。通过对每个采样调整音量,就可以提高每个采样可能达到的分辨率,从16提高到了大约100(因为有些采样/音量组合是重复的)这些可用的幅度并不是均匀分布的,小的幅度附近有更多值可用。

所以,每912个周期正好写入2个采样,与此同时同步调整音量控制来微调结果的幅度。这两个过程需要有32个采样的移位,也就是说当前写入的音量控制会直接影响当前在播放的采样,而当前写入的采样会在32个采样后才会播放到。

这部分通过一开始载入的Payload中的一个特殊的汇编函数来实现的。在两个采样之间的空闲时间,代码会刷新屏幕上的tile来显示歌词和相应的图片,当然这些也需要在内存可用的时候写入。

结尾的话,我打算干脆挑战下在GB上只使用键盘输入到底能够做到多好的音视频效果。

一部分是想要展示一下GameBoy的“高色深”图像。GameBoy只能存储8个调色板,每个调色板可以保存4种颜色,所以每一帧可以使用的颜色数通常是32,而且每个8x8的tile同时只能用到其中的4个。而所谓的“高色深”技术就是通过在每一行调整调色板来实现更多的颜色。这样,每一行都能使用不同的颜色,甚至是在同一个8x8的方块内。实际上也有一些商业游戏用到了这个做法。这个做法的问题就是,在下一行开始渲染前,可以用来更新调色板的时间很少。想要每一行更新所有的调色板是不可能的,所以大部分游戏都只会更新一部分,通常是4个,这样也就是每帧最多2304种颜色。然而还是有很多的限制,比如tile还是指向同样的调色板索引,所以哪个tile使用哪个调色板每行是固定的。而且修改调色板需要非常精确的计时,也就导致游戏同时没有办法做什么其它的事情。而且,哪怕屏幕画面并没有改变,整个调色板调整过程每帧都需要进行,也就会额外消耗很多电量。

可以计算一下总体能做到多高的质量。其实也就是一个质量和帧率的权衡。如果每个视频帧刷新整个20x18 tile的屏幕,那也就是20x18x16=5760字节的数据,也就需要至少5760x36=207360个周期才能从按键中读取完(36个周期只是一个下限,只读取字节,什么都不做)。以及我还需要载入144*4的调色板来产生高色深的效果,也就需要额外的165888个周期来载入。与此同时,还需要不停切换调色板来保持高色深效果,需要大约62784个周期,也就是每个GameBoy帧我只剩下77664个空闲周期来做其它事情,比如写入tile数据。在最理想情况下每6个GameBoy帧我能显示一个视频帧,也就是最终大约10fps的帧率,可能太低了。

于是我选择降低一点视频质量来提高帧率。两个妥协就是每次不刷新所有的tile,而且每行只更新2个调色板而不是4个,也就减少了维护开支。这样整体的帧率就可以达到15fps,总共同屏960种颜色,还能维持高质量的音频。

因为对于CPU计时有着很高的要求,不像之前的GameBoy内容播放那样,这次的代码不是一个个函数组合起来的,而是一整段汇编。演示中用到了两个背景网格来实现双缓冲的切换。每一行都需要进行两个操作,一个是之前描述过的更新音频采样,另外一个是写入下一行会用到的两个调色板来产生高色深的图像,同时载入1/2个tile和3/8个调色板。场消隐阶段就用来载入属性(比如调色板映射之类的),并且准备新的一帧

在效果可接受的情况下把视频转换成需要的格式本身就是一件很有挑战性的事情。尽管有很多颜色可用,然而那些颜色并不是想在哪用就可以在那里用的。因为每一行只更新两个调色板,一个特定的8x8 tile使用的调色板每4行才会更新一次,也就是说在4x8个方格内还是只能使用同样的4种颜色。一行总共有20个tile,但是只有8个调色板,所以显然有一些tile需要共享调色板。于是确定最佳的调色板以及分配就成了一个存在很多限制的难题。我用了一些现有的算法来确定每个tile最佳的调色板(Medium Cut和K-means clustering),然后再使用了一些简化的假设来分配调色板,最后再用了一些抖动来平滑过渡。

而且,同样的RGB值在电脑屏幕和GBC屏幕上会产生不同的颜色。不过好在我可以看模拟器的代码来确认对应关系,我需要做的只是逆向做一下处理。一个矩阵求逆就可以把视频的颜色转换成GBC的颜色了。

在ACE完成后,我跳转回了原始的游戏。演示的内容就是在这一切都发生了之后,原始的游戏依然可以运行。我选择直接跳转到通关的制作人员名单,因为不需要额外的输入就可以继续下去,而且也是遵从了ACE最后都要通关游戏的惯例吧,虽然此刻通关的意义已经不那么明确了。

故事情节设计

整个演示中总共出现了6个游戏:精灵宝可梦 黄、精灵宝可梦 金 、精灵宝可梦 水晶、俄罗斯方块、塞尔达传说:林克的觉醒DX 以及 超级马里奥兄弟DX。

以下是对各个场景的注释,简单讲讲我在设计这些场景时的想法。

这个就是在一个玩家都熟悉的,即将进入通关画面的场景,插入了一句不同寻常但是大家都又很熟悉的马里奥的话,也就说明了这并不是一次正常的通关,也就预示着,在下一个城堡中,我们将会见到第二世代的精灵宝可梦。

精灵宝可梦 金 的开场动画播放了很长一段时间,就是为了让玩家有机会意识到刚刚发生了什么事情,现在已经不是同一个游戏了,也就为之后的瞬间切换游戏做了铺垫。存档游戏载入是一个过场动画,但是存档的统计信息和之后出现的场景完全不符。尽管要做成一样很简单,但是我还是想故意留这样一个问题等着观众去发现。

这个场景的位置每一个玩过第二世代宝可梦游戏的玩家都会熟悉,就在培养屋(育て屋)之后,小金市( コガネシティ)之前。一开始就是在地图里乱逛,然后遇到并捕捉了一只雪拉比,然而实际拥有的是一只梦幻,也就是提醒这里仍然是ACE而不是真实的游戏,也顺便取笑一下对于所谓的稀有的概念是多么肤浅。而下一幕出现的玩GameBoy的男孩则是作为一个转场,展现之后的效果。

选择俄罗斯方块是因为这个游戏的名气和可辨识性,无论是画面还是声音,片段也很简短。展示的脚本是我两年前做的一个俄罗斯方块TAS。胜利画面被明显加速了,一个是展示这个框架能做到什么样的效果,另外也是一个小的供观众去发现的点。

这一场景中游戏之间的切换很快,首先是水晶,接着是林克的觉醒,回到水晶,最后是黄。主角最后坐在SNES之前(重命名成了NES),也就是开始的地方。一部分也是结束了之前的一大圈游历,另外一部分也是准备到下一部分的过度。

选择1-1的场景也是因为很多观众都会一下子认出来,而且GB版本的超级马里奥的GB版本也确实和NES版本的很像。而脚本本身只是我自己玩1-1的录像而已。每次碰到方块就会加快一些完全是后来才想到加上的东西。

继续是保持使用其他游戏内容的主题,这里选择了传送门的通关场景来展示GameBoy的高音质音频输出能力。而且对话框也刚刚好可以展示文字。

这个视频片段的选取就有些困难了。一个是需要端,不是因为不能播放更长,而是因为对于输入来说真的非常烧资源(因为就是通过4bit的输入来传送未压缩的视频)而且也需要是有可辨识性的,也得和其它的场景有关联的。我最后选取的是海绵宝宝“Shanghaied”这一集中的“How does he do that?”这一场景,因为我觉得算是个合适的结尾,因为观众可能已经搞不清楚在发生什么事情也不知道这是怎么做到的了。而且这也是技术上最震撼的一部分吧。

源代码

https://github.com/MrWint/gb-tas-gen

结论

我对最后的结果很满意,不单单是学到了很多关于GameBoy内部机理的东西,也学到了很多我一开始不懂得的音视频处理技术:动态范围压缩、色彩量化、抖动、容器格式等等。

做这个演示非常有趣,我也希望你喜欢这个作品。

译者(UP主)后记

这是第一次翻译demo的解读,难免存在疏漏,希望大家发现能及时指出。虽然我知道这类内容可能没有多少的观众,但是我相信总还是会有人感兴趣的,所以还是抽了一点时间做了这样一个翻译。以后可能还会有类似内容,但是我也没法做保证。B站要求专栏文章长度不能超过10000字,所以做了一些删减。祝大家看得开心吧。

本文禁止转载或摘编

-- --
  • 投诉或建议
评论