麦斯科技 · 2023年01月07日 · 四川

GB Interceptor:值得拥有的Game Boy 捕捉器

https://there.oughta.be/a/game-boy-capture-cartridge

我向你介绍:GB Interceptor。它是一个适配器,连接在未修改的Game Boy和盒式磁带之间,并通过USB提供游戏视频流。

微信图片_20230107172018.png

B站链接:https://www.bilibili.com/video/BV1h8411K7c4/

YouTube链接:https://www.youtube.com/watch?v=6mOJtrFnawk

上面的视频应该能让你很好地了解它的功能、工作原理以及它的局限性。本文将详细介绍其工作原理的技术细节。如果您对如何订购和构建自己的GB Interceptor感兴趣,请查看github(https://github.com/Staacks/gbinterceptor)和访问订购和构建视频(https://www.youtube.com/watch?v=Lg92tVkEE98)。

我们为什么需要这个?

解释我为什么开发和建造GB Interceptor的最好方法是解释我试图用它解决的问题。几个月前,一位俄罗斯方块爱好者就这个问题与我取得了联系:一场在线俄罗斯方块锦标赛,参赛者在比赛中展示了他们的游戏。

今天,Game Boy的视频流并没有什么异常。模拟器可以很容易地做到这一点,而现代Game Boy变体,如Analogue Pocket,提供可以捕获的HDMI输出。还有一些MOD可以将HDMI添加到Game Boy的原始硬件中,因此从Game Boys获取视频流是一个长期以来需要被解决的挑战。

在俄罗斯方块比赛中做这件事的一个不同寻常的细节是,玩家必须依靠他们在个人Game Boys上训练的肌肉记忆。将他们换成不熟悉的现代设备或模拟器将严重阻碍他们的竞争能力。此外,你可以想象,一场比赛要求每个参赛者先把他们心爱的Game Boys通过mod做视频流支持改造,这样的比赛不会受到欢迎。

因此,我们需要一种方法,在不修改正在播放的游戏的情况下,从未经修改的Game Boys获取视频。理想的形式是任何人都可以使用,而无需复杂的软件或额外的硬件,如HDMI抓取器。

工作原理的基本概念

最后,在Game Boy上没有mod的情况下,唯一可以访问带有游戏数据的连接器是cartridge插槽。毕竟,整个游戏数据都要经过那里。因此,我们的想法是创建一个适配器,将cartridge直接连接到Game Boy,并且只添加拦截传输数据副本的功能。

tetris_004.jpg
GB Interceptor连接到笔记本电脑,该笔记本电脑以VLC显示其视频流。

然而,这意味着我们无法随机访问感兴趣的数据,也无法在RAM中看到Game Boy的CPU从cartridge的原始指令中合成的数据。特别是,我们看不到视频RAM,但这非常不错,因为它将包含在屏幕上绘制图像所需的所有内容。我们需要创建自己的VRAM副本。

为此,我必须编写一个仿真器,从cartridge总线向其提供数据。为此,我使用了rp2040(树莓派Pi Pico的微控制器),并将其内核划分为Game Boy的两个主要处理部分。一个内核模拟CPU以重新创建VRAM副本,另一个内核则模拟Game Boy的图形单元PPU4。

CPU仿真实际上是这里最棘手的部分,因为它必须跟上以大约1MHz的速率推出事件的内存总线。如果PPU仿真落后,它会导致像flicker一样的短暂glitch,但如果CPU仿真落后,最终会错过内存总线上的事件。不仅RAM的模拟副本可能永远不同步,而且仿真器甚至无法解释后续指令。总线上的事件并不总是下一条指令,因为Game Boy的CPU可能需要几个周期来执行某些指令,而其他指令则在一个周期内完成。因此,仿真器必须跟踪在某个特定指令之后,在某个事件再次被视为指令之前,需要忽略多少个周期。如果我们只错过了其中的一个,就几乎不可能再次得到正确的答案。

这加上在32位CPU上模拟8位CPU的开销,使rp2040有必要从其默认的125 MHz超频到225 MHz。rp2040通常可以轻松处理这个问题,但我还是很想看看是否可以提高我的代码的效率。

由于PPU仿真并不是那么关键,实际上在Game Boy的vblank期间,当没有绘制图像时,它会定期获得一些空闲时间,因此它还可以处理USB通信。

硬件

实现这一功能的实际硬件是一个树莓派Pico,带有一些总线收发器,将其GPIO端口连接到cartridge总线。从该总线的32个引脚中,两个用于+5V和接地,一个用于模拟音频,一个用来控制Game Boy的复位状态。其他28个引脚连接到rp2040,因此rp2040可以访问16个地址引脚、8个数据引脚和4个总线控制引脚时钟、读取、写入和芯片选择。由于这些都使用5V逻辑,我使用的WiFi Game Boy cartridge(https://there.oughta.be/a/wifi-game-boy-cartridge)已经支持将信号转换为rp2040的3.3V。

还有两个GPIO未使用。一个观察用于+5V线上的电压,以检查Game Boy是否打开,另一个控制状态LED并读取模式按钮。

pcb_004.jpg

GB Interceptor的PCB。

其余的cartridge基于树莓派rp2040的最小硬件设计示例(https://datasheets.raspberrypi.com/rp2040/hardware-design-with-rp2040.pdf)。这包括一个振荡器、闪存、一个电压转换器和一个USB端口,我用Type C替换了它。

一个树莓派 Pico连接到Game Boy cartridge的内存总线的转换器就差不多了。原理图和PCB设计可以在项目的github存储库(https://github.com/Staacks/gbinterceptor/tree/main/pcb)中找到。

实现

真正让GB Interceptor工作的是它的软件,当然也可以在github(https://github.com/Staacks/gbinterceptor/tree/main/firmware)上找到。在下文中,我将介绍下它的一些细节。

USB video class

GB Interceptor使用TinyUSB的USB video class实现流式传输生成的图像,因此理论上不需要驱动程序,它应该只显示为网络摄像头。从理论上讲。不幸的是,这只能在Linux上按预期工作,我可以在VLC、OBS、Zoom或ffmpeg中直接使用GB Interceptor。在Windows和Android上,许多应用程序似乎在视频流的格式上有问题。例如,在Windows上,VLC(尽管在Linux上工作)提示没有找到合适的格式,而OBS在不需要任何设置或驱动程序的情况下工作得很好。在Windows上,这是一个好消息,因为您可以使用OBS作为虚拟网络摄像头,将GB Interceptor流转发给任何对格式挑剔的软件。在github上可以找到一个测试过的主机软件列表(https://github.com/Staacks/gbinterceptor/wiki/Host-software-compatibility)。

不幸的是,在撰写本文时,我无法在MacOS上获得任何视频,我还不知道为什么。由于某些原因,它甚至不会触发TinyUSB来启用视频流,因此我并不完全相信它是这种格式。记住,我还没有在MacOS上做过很多测试,TinyUSB中的 video class实现是最近才开始的,也是实验性的,我希望将来能解决这个问题。即使我不能让 video class在这里工作,也应该可以通过USB总线上的UART传送图像,并使用简单的Python脚本将其转换为系统上的视频流。您可以在github上查看此Issue的当前状态(https://github.com/Staacks/gbinterceptor/issues/1)。

那么,这种不寻常的格式是什么?很明显,这是从Game Boy 160x144像素的分辨率开始的,我可以想象,这可能会让一些期待现代1080p流的软件感到惊讶。但是,当我们研究rp2040的全速USB端口及其对TinyUSB实现的同步传输的影响所产生的限制时,它会变得更加复杂。这种组合意味着此端点的最大缓冲区大小为1023字节,由于同步传输每1ms发生一次,因此每秒可获得1023000字节。

如果我们只看Game Boy的原始图像,这就绰绰有余了。Game Boy的“颜色深度”为2位,因此一个图像帧为5760字节。大约每秒60帧,我们只需要345600字节,这就是为什么如果其他所有的都失败了,我认为自定义UART协议是MacOS上一个有趣的替代方案。

然而,我们不希望需要驱动程序或其他软件。我们想要的是一种能正常工作的格式,但遗憾的是,目前还没有被广泛接受的2位彩色格式。相反,有很多压缩格式,我们没有足够的计算能力,还有一些被认为是广泛支持的未压缩彩色格式,其中大多数使用16位像素。相反,我们使用了一种据称也被广泛支持的稍微更高效的格式:每像素12位的NV12。12位由亮度(灰度亮度)的每像素8位和颜色信息的四个像素共享的16位(因此每像素多4位)组成。

好消息是,整个帧的颜色数据存储在最后,因此我们可以将其设置为灰色或绿色,并可以忽略它。事实上,我们可以将之前的数据视为具有8位灰度数据的简单160x144像素缓冲区,这对于我们的目的来说或多或少是理想的。

当然,坏消息是,它所占用的数据仍然是原始2位图像所需数据的6倍。我们的每秒1023000字节现在限制在29fps。

因此,总的来说,我们有一个分辨率为160x144的29fps NV12流。并非所有这些视频会议工具都支持。

顺便说一句,虽然GB Interceptor因此只推出了29fps,但它仍然以60fps的速度在内部工作,并混合这些帧以模拟旧LCD的延迟。它只是在USB总线调用时推出最新的混合帧。

可编程IO

现在,在我解释了如何从GB Interceptor中获取结果之后,让我们来谈谈另一端:如何将cartridge总线上的通信连接到rp2040。

当我试着在我的WiFi Game Boy Cartridge用ESP8266监听一个事件时,非常痛苦。中断太慢,而让CPU 轮询的方式观察时钟线也不可行。rp2040有一个诀窍:可编程IO。这些是简单的状态机,可以直接访问GPIO引脚以及CPU的FIFO缓冲区。

我们需要做的就是等待时钟线变低,然后同时读取连接到Game Boy内存总线的剩余27个GPIO引脚,并将结果写入FIFO。为此,我们只需要一个PIO,它只执行四条指令:

    wait 1 pin 28 ;Wait for CLK to go high
    wait 0 pin 28 ;Wait for falling flank of CLK
    mov isr pins  ;Read all GPIO pins to the input shift register
    push          ;Push the ISR to the FIFO

这样,CPU很方便就可以从FIFO中提取这些事件中的一个,并将其打包成一个32位整数。

仿真器部分

现在我们看看如何处理这些事件。我希望你对Game Boy的工作原理有一个基本的了解。对于那些不熟悉Game Boy开发的人,我总是推荐Michael Steil的《Ultimate Game Boy Talk》(https://www.youtube.com/watch?v=HyzD8pNlpwI)。

如上所述,基本思想是rp2040的一个内核解释传入的总线事件,使其遵循与Game Boy的CPU相同的指令。也就是说,它模拟Game Boy CPU,以便重新创建VRAM(和OAM)的精确副本。然后,第二个内核充当PPU,并从VRAM副本中渲染图像。这主要是一个基本Game Boy模拟器的实现,但我想谈谈(或写一下)一些不同之处。

条件跳转和IO

首先,在这个场景中,有几个事情变得简单得多。想想程序计数器和条件跳转。我们不必执行这些。真正的Game Boy无论如何都会获取下一条指令。无论它是递增PC的下一条指令,还是跳到完全不同的地址,都无关紧要。真正的Game Boy将获取下一条指令,我们不必担心指令来自何处。

这解决了一个看似最大的问题:我们看不到任何硬件I/O寄存器。特别是,我们看不到来自游戏板的输入!如果我们看不到玩家的输入,我们应该如何模拟游戏?好吧,几乎所有存在的代码都会比较游戏板输入以检查按下了哪个按钮,并对按钮触发的代码进行条件跳转。我们的模拟器将简单地遵循这些相同的指令,而不必关心它是否由按下按钮触发。

你可以说GB Interceptor是一个模拟器。

只有当来自I/O寄存器的数据最终到达VRAM时,这才成为问题。想象一下,将gamepad的值添加到一个基地址,以计算显示D-Pad当前状态的图像的tile
索引。CPU将获得获取游戏板寄存器值的指令,并向其添加一个数字,我们的仿真器将不知道该操作的正确结果。然后将此结果写入VRAM,我们不知道该位置中的内容。

然而,这些应仅适用于较小的视觉差异。我不知道有哪个例子是用gamepad I/O完成的,但我有一个DIV寄存器的例子。在俄罗斯方块中,它被用作随机数的来源,大多数时候它通过条件跳转来分支代码,以选择下一个不同的块,或者在游戏模式B中生成初始的垃圾块堆。这也决定了模式B中垃圾堆的一个块是空的还是满的,所以我们也得到了相同的垃圾堆布局。但这些垃圾块也有一种随机的视觉样式,它不是基于分支代码,而是添加到基本tile索引中的随机数。

结果是,我们在GB Interceptor上看到了相同的垃圾堆栈布局,但各个块的外观不同。这是无害的,只有当你将图像与Game Boys屏幕进行比较时,你才会注意到。

tetris-stack_004.jpg

左:原始Game Boy屏幕在俄罗斯方块模式B下的图像。右:与GB Interceptor渲染的场景相同。方块的布局是相同的,但各个块有不同的设计。

只有当整个准备好的数据流从一个I/O寄存器写入VRAM时,我们才会遇到真正的麻烦。我所知道的(而且我能想到的)唯一的例子是连接电缆。在这里,我们可以看到B模式方块的相同示例,但在俄罗斯方块的双人模式中。问题是,两个玩家应该拥有相同的方块样式。因此,首先启动游戏的Game Boy将生成方块并通过链接电缆将其发送给第二个堆栈。第二个将数据直接写入VRAM,没有任何检查或条件跳转,我们看不到任何内容。

tetris_2player_004.jpg

俄罗斯方块为双人模式。左:首先启动带有GB Interceptor的Game Boy Color,方块呈现为1人模式。右:另一个Game Boy首先启动,我们无法看到方块,因为它是通过链接电缆接收的。

因此,在双人俄罗斯方块中,如果是在Game Boy中首先开始游戏,GB Interceptor可以正常工作(除了各个区块的不同视觉风格),但如果是在第二个Game Boy中,它会产生无法使用的输出。

时钟、DIV寄存器和暂停指令

说到DIV寄存器,这实际上是一个我们可以模拟的I/O寄存器。由于我们从Game Boy获得了准确的时钟,我们可以计算模拟寄存器与真实寄存器同步,而不会有任何偏离的危险。只有两个问题:

1.初始值是未知的-至少对我来说是未知的。当执行盒带中的代码时,DIV寄存器的状态取决于Game Boy模型,在某些情况下,它还取决于该模型引导序列期间的用户交互。例如,如果在引导序列期间更改Game Boy color的颜色模式,DIV寄存器将在开始时具有不同的值。我不确定Interceptor在引导序列期间是否在总线上看到足够的动作来弥补这一点,但我也不完全排除这种情况。

2.当Game Boy进入暂停状态时,我们失去了参考时钟,对于大多数游戏,每帧至少发生一次。在这里,rp2040的时钟必须精确接管,如果我们有更多的计算空间,这应该是可能的。(比如,如果有人可以优化我的代码)

问题是,我们实际上测量了在实际游戏开始之前的引导序列中,每个Game Boy时钟周期出现了多少rp2040时钟周期。在这里,我们可以观察数千个周期,并且应该能够从rp2040中获得非常精确的替代时钟。不幸的是,出于性能原因,我只使用两个时钟的整数比,通常为每个Game Boy时钟对应225 rp2040时钟。这意味着在暂停状态期间,舍入误差将导致每100个周期大约一个周期的误差。

所以,也许我们可以进行分数时钟计数,但目前,因为它只影响div寄存器,无论如何我都无法正确初始化,所以这没有实现。

同步CPU和PPU

当我们正在将模拟器与真实Game Boy同步时……我们当然也需要将PPU与真实GameBoy同步。否则,任何需要更改VRAM中间帧的效果都会导致故障,至少在VRAM中随机更新数据时,我们会看到一些撕裂效果。

问题是,在内存总线上找不到PPU的踪迹。我们必须通过游戏的行为来推断PPU的状态,这也必须与PPU同步——至少要知道它何时可以写入VRAM。这里的大问题是,游戏可以使用许多不同的方式来做到这一点。

最常见的方法是vsync中断。大多数游戏只是让Game Boy在达到vsync时触发一个中断,我们可以看到这个中断的代码何时被执行,因此我们可以简单地调整我们自己的仿真PPU的定时,以便在同一时刻进入vsync。

不幸的是,有很多其他选择可以做到这一点。对于需要挤出更多VRAM访问权限的游戏(例如在Donkey Kong Land中实现),另一个常见的方法是以紧密循环的方式读取LY寄存器,并定期将其与特定的行号进行比较。条件跳转返回到LY读取,直到到达正确的行,并且代码仅超出条件跳转。幸运的是,开发人员可以在未到达时通过jump指令来节省几个周期,所以许多游戏都是这样做的,这允许在Interceptor中简单检测这些紧密的循环。

然而,会有不同方法的游戏(比如我的Wifi cartridge),在检测到这些其他方法之前,GB Interceptor的输出会出现故障。

检测中断

哦,虽然中断是同步PPU的福音,但它们最初并不容易检测。我们需要跟踪每一条指令,以及Game Boy为每条指令需要多少周期,以确定内存总线上的哪个事件将是下一条指令。Game Boy在执行过程中跳到另一个点,并花费几个额外的周期来执行,这在这里并没有什么帮助。

看看《The Legend of Zelda - Link’s Awakening》在原版Game Boy上的第一次vsync中断:

Address Data Instruction
   01a2    fb    EI 
   01a3    c3    JP a16    
   01a4    bd    
   01a5    03    
   81a5    71    
   03bd    3e    IRQ    
   82bd    01    
   82bd    01    
   dffe    81    
   dffd    a5 
   0040    c3    JP a16    
   0041    25    
   0042    05    
   8042    24 

当忽略中断时,我们会错误地将第7行中的0x3e解释为操作码。要或多或少地确定我们正在看到中断,唯一的方法是实现GB Interceptor,使其在当前事件被误解为指令之前读取几个周期以识别中断,而实际上它只是内存总线上的垃圾,而CPU需要一点时间进入中断。

幸运的是,Game Boy在中断时跳转到几个固定地址,所以我们要注意这些地址。但由于这些地址理论上也可以从常规代码中调用,因此我们混合了更多的指示符,特别是堆栈指针的行为。在中断调用期间,当前PC被推到堆栈上,因此SP寄存器被递减两次,Game Boy写入两个递减的地址。通常这些地址并不指向属于盒带的地址,但这些地址在内存总线上仍然可见,因此这增加了我们检测中断的信心。

唯一的问题是,Game Boy实际上不需要一直这样做,也不需要在内存总线上显示SP地址,因为没有任何游戏cartridge关心这些操作。因此,我们可以在这里看到不同设备之间的一些差异并不奇怪。以下仅为原始GameBoy(DMG)、GameBoy Color和Analogue Pocket的中断调用:

     DMG                 GBC                Pocket
  Address Data        Address Data        Address Data
   03bd    3e          03bd    3e          03bd    3e
   82bd    01          83be    00          dfff    00
   82bd    01          dfff    00          dffe    01
   dffe    81          dffe    80          dffd    9b
   dffd    a5          dffd    00          0040    c3
   0040    c3          0040    c3          0040    c3    <   Next instruction

如果我们仔细观察,我们会发现一些细微的差异:DMG在递减SP地址之前也会显示SP地址,GBC只显示它实际写入的两个递减地址,Pocket则会提前一个周期执行此操作。考虑到所有这些情况,当然会降低中断检测的可靠性,并且目前它无法与Pocket的变体一起正常工作。

意图和构建说明

我认为这些是实现过程中最有趣的部分。如果你已经读到了这里,那你是一个真正的8bit极客!

如果你想了解更多细节,你现在必须深入github上的代码(https://github.com/Staacks/gbinterceptor),在那里你还可以找到硬件设计文件和案例材料。我希望在代码和硬件设计方面都会有一些社区贡献,所以如果这篇文章发表后几个月过去了,这也将主要发生在github上。

如果你想建立自己的GB Interceptor,你也应该观看订购和构建视频。

B站链接:https://www.bilibili.com/video/BV17G4y1y7Yg/

Youtube链接:https://youtu.be/Lg92tVkEE98

我希望你喜欢这个项目!

致谢

如果没有许多人在我之前研究、测试和推动Game Boy,并且(最重要的是,也是我自己写这些文章的原因)记录了他们的工作,这个项目就不可能存在。以下是我最重要的一些资源:
1,gbdev.io(https://gbdev.io/),尤其是它的Pan Docs是我了解Game Boy工作原理的主要来源。
2,在Joonas Javanainen的网页上(https://gekkio.fi/)可以找到许多硬件细节和一些复杂的细节。
3,虽然有很多网站都有Game Boy的opcode表,但我发现Megan Sullivan的网站是最方便的(https://meganesulli.com/blog/game-boy-opcodes/),我一直都在访问它。

推荐阅读
关注数
5758
内容数
525
定期发布Arm相关软件信息,微信公众号 ArmSWDevs,欢迎关注~
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息