Pokegb:一个游戏博伊仿真器,只播放神奇宝贝蓝,在68行的C ++行

2021-06-05 02:56:46

Pokegb:一个只在C ++的62行中播放神奇宝贝蓝色的Gameboy仿真器。来源:https://t.co/agt7rutr9h pic.twitter.com/0buumznot

- Ben Smith(@Binjimint)5月28日,2021年

在视频中,我展示了一些看起来像3个Pokéballs的源代码,编译它的GCC,并运行它来播放神奇宝贝蓝色。游戏是可控的,但已经讨厌。它使图形W / 12颜色(红色和蓝色色调)。我选择了我们英雄的名字,并成为他的竞争对手的名字。奥克教授何时阻止我走进高草的视频。

很多人让我要做一件关于这一切的作品,所以让'潜水!

(TL; DR:这是一个很长的写作,如果您愿意,请查看UNOBFUSCATEDCODE!)

但首先,让'谈论一些统计数据。 Thatweet中的最终版本实际上是68行代码行(我发布了错误的号码!),每个线的长度超过150个字符,总共9956个字节。如果您忽略了空间和评论,它会到4720字节。那个'太大了,对于国际混淆的C代码比赛,但非常接近。

在格式化源代码之前看起来像Pokéballs,源是188lines,总共7786个字节,其中5954个字节的非空白源。

它' LL很有用来了解一下Gameboy如何工作。这是' t Gointo所有细节(因为你可以阅读Pandocs),但是会足以了解这个代码的工作原理。在十六进制中,许多值更容易,所以我' ll写出领先$的人,例如, $ fe00。

GameBoy CPU有点奇怪 - 它'那种类似于英特尔8080和Zilog Z80,但与您的ZiLog Z80也不一样。它具有8位读数器A和3 16位寄存器对,BC,DE和HL,其单独访问为8位寄存器B,C,D,E,H和L.它还具有16 -bit Stack Pointer SP,一个16位程序计数器PC。

访问AF寄存器时(只能通过PUSH / PINALUSTIONS完成),标志存储在F的最高有效位中:

Gameboy指令集具有〜500个指令,每个指令长达3个字节。 Forexample,NOP指令只是一个字节(00),但JPInstruction是3个字节:$ C3,后跟16位地址$ 50 $ 01。

说明可以分组如下(取自桃子和#39;仿制发展不和谐的优秀文件)。我包括所有指示,但是神奇宝贝蓝色不需要击中Onesthat。在Firstread上随意跳过这一点,我稍后会更详细地进行详细信息:

如您所见,有许多指令,但我们可以将它们分组为〜40个等级的类别,其中5个areN' thed' thed' t使用的神奇宝贝蓝(据Inkow)使用。 (结果我错了,Rra被使用了;看看Pokered来源的尝试职能)。

一种额外的复杂性是每条指令何地提出的时间。这是测量的" t-states",在CPU的频率下运行,〜4.19MHz。所有指令都在4个T状态的倍数上运行,这是截止的机器循环或" m-cycles"

它很重要,可以跟踪每条指令的每个指令有多少,而且其他硬件组件(如图形渲染和音频)拍摄时间,我们希望确保CPU和这些组件保持同步。幸运的是,大多数指令的M循环计数相对简单 - 每个内存访问成本为1 M-周期(包括读取INSTRIONSTRIONSTRIONSTROCTIONSTRIONSTIONSTRIONSTIONSTRIONSTIONSTION)。有些说明采取额外的m-cycles,但我们可以明确处理案例。

GameBoy CPU具有16位地址空间,分为有多样的目的区域。这里的简化布局:

GameBoy墨盒通常也使用内存银行控制器(MBC),它在运行时允许在运行时以及其他特征处开出ROM和RAM的区域。否则这是不可能访问超过32768个字节的ROM。

神奇宝贝蓝色使用MBC3,该MBC3能够将64个不同的16kiB银行(总共1MIB)交换64个不同的14个,以及4个不同的8kib ofternerram银行(总共32kib)。神奇宝贝蓝色墨盒还支持面时钟,但我没有实施支持。

您与MBC沟通的方式是通过写入各种地址romions rom(即,在0000- $ 7FFF的范围内)。有关详细信息,请参阅下表.Pokegb未实现的功能已被击中:

可以启用和禁用读取和写入外部RAM。我相信在丢失功率时,CPU可以将垃圾数据写入存储器(例如,切断时)。我们不在乎,所以我们' Llskip实施它。幸运的是,据我所知,游戏并在禁用时从或写入这个区域。

如前所述,墨盒是1MIB,但CPU地址空间仅为64kib。因此,要访问其余的墨盒数据,我们必须重新映射ROM。唯一可以重新映射银行1; ROM Bank 0始终映射到墨盒' sfirst 16kib块。

ROM Bank 1中的映射存储区域可以通过将valuebetwe 0和63写入到2000-$ 3FFF的任何地址中来改变。例如,如果我们编写价值5到2000年,则映射到8000美元的数据,将参考墨盒的第六个16kib ROM银行(范围为14000美元的字节)。一个例外是,如果银行0发出,则选择墨盒' S第二个ROM银行而不是漏斗。

同样,外部RAM地址空间仅为8kiB,但可用的RAMIS 32kib。在0到3到$ 4000- $ 5FFF中写入值将选择一个rambank。

GameBoy有80个字节的内存映射I / O,从地址$ FF00到$ FF7F.这些地址有时被称为寄存器,不与CPURYGISTERS混淆。并非所有这些地址都由GameBoy使用,而不是神奇宝贝蓝色使用的所有有效地址,而不是神奇宝贝蓝色的所有地址都是在Pokegb中实现的!

以下是原始GameBoy使用的寄存器列表,使用了TheUnimplemented的reply:

不幸的是,声音完全跳过了Pokegb。我觉得很有趣,但需要更多的代码,我想。

其中几个寄存器用作完整的8位值(div,scy,scx,ly,wy,wx)。其他人对单个位提供意义:

从表中可以看出,从JoyPAD读取提供了不同的值,它是关于joyp的位4或5的不同价值。通常,程序将单独读取方向和按钮的程序,然后将其组合成一个字节。此外,按钮标有一个领先的〜因为它们倒置了;例如当位2为0时按下向上方向。

LCDC具有几个位,可启用/禁用各种渲染图层:背景,窗口和对象(即精灵)。它还具有用于映射和瓦片数据的录像RAM的一些比特:

IF和IE请求和启用各种中断源。如果请求和启用给定的中断,并且启用了中断主enableFlag(IME),则触发中断。这将导致CPU TOSTOP正常执行并跳转到&#34的地址;中断处理程序",Thecode,它将响应中断,然后返回主编程。在所有可能的源中,Pokegb只支持Vblankintrupt。定时器和串行中断也由神奇宝贝蓝,Butare未实现。

最后,BGP,OBP0和OBP1寄存器为背面和精灵选择调色板颜色。呈现图形时,GameBoy可以从4Color值中挑选,每个值都是相应调色板中的索引。然后选择这些颜色值的意思:

对于Pokegb,我决定使用带有不同颜色的更有趣的调色板,用于BGP,OBP0和OBP1。

GameBoy有一个160x144像素LCD屏幕。图形硬件(也知道图片处理单元或PPU)的能力能够绘制一个32x32crollable背景层,一个32x32"窗口"层,最多40 8x8-or8x16像素精灵。它有8192个字节的视频RAM,用于存储瓷砖和MapData。每个图块存储8x8块的像素颜色。 GameBoy仅具有4种颜色,因此每个像素' s颜色可以用2位,或每个字节为4μl表示。结果,每个瓦片是16个字节。该地图是32x32索引的数组,进入区块数据,每个图块一个字节。所以32x32背景和窗口映射每个使用1024字节。

事实证明,神奇宝贝蓝色使用背景,窗口和精灵层,但只使用8x8像素精灵。

如上所示,可以选择BG和窗口地图的地址,可以选择9800 - $ 9BFF或9C00-$ 9FFF。类似地,BG / Window Diets RegionCan被选为8000- $ 8FFF或$ 8800- $ 97FF。有趣的是,如果选择了8800- $ 97FF,则瓷砖索引从9000美元开始并缠绕。因此,瓷砖索引127是在地址97F0的地址,而且图块索引255是ATADDRESS $ 8FF0。

Gameboy实际渲染屏幕的方式相当复杂(见Pandocs的Pixelfifo),但我们可以作弊。大多数游戏(包括Pokémonblue)Don' t在扫描线绘制时更改任何图形设置。所以我们将每个扫描线结束,因为它完成了每个扫描线。

每个扫描线都需要456个状态(或114米循环)呈现。此扫描线的Currature-State有时被称为A"点",具有0-455范围的值。共有144个扫描线和10行"垂直空白"每个屏幕总共154行。因此,渲染全屏的时间量是70224 T状态(或17556米循环)。

垂直坯料(或VBLANK)扫描线是来自CRT屏幕的阻滞,这在此期间将电子枪重置为截至屏幕的左上角。这也恰好是GameBoy的有用功能,即使ITUSES屏幕,因为它是PPU不访问的时段,因为PPU不访问Video RAM或其他视频硬件。这为软件提供了机会TOUPDATE VIDEO RAM而不影响屏幕视觉效果。

窗口层是在背景层上显示的区域。它可以使用或禁用,并定位在WX和WY寄存器中。启用ITIS时,它始终始终从位置(WX,WY-7)启动,并将矩形区域的背面覆盖在屏幕的右下角。它是由SCX和SCY寄存器滚动的。

窗口用于神奇宝贝蓝色,以显示标题信用,挑例,菜单等。

Gameboy硬件每帧最多可满足40个精灵,最多可俯冲10个精灵扫描线。每个Sprite都在OAM中定义并使用4个字节。这些字节如下所示:

Sprite的X和Y坐标分别偏移8和16像素。这是这样做的,因此可以从屏幕的左侧或俯角显示秀精灵。如果X坐标处于Therange 0-167,则将显示SPRITE,并且Y坐标在0-161范围内,包括。

与BG / Window不同,Sprite磁贴数据总是8000美元的$ 8FFF,否则地显示平铺数据。

优先级位指定此精灵是否在(0)或后面(1)后面的背景/窗口。你可以在神奇宝贝蓝色的效果中看到你的球员走在高大的草地上。它有效,因为背景颜色0被认为是透明的,因此精灵将显示通过。

x-flip和y-flip位将水平地,垂直或两者翻转显示的精灵,但否则不会影响Sprite如何显示。

背景层,窗口层和精灵层全部由8×88T1构成。每个图块使用2位按像素为16字节。对于每个像素,其中有低位和高位。低位和高位存储在统计中,其中每个字节存储一行:

例如,如果我们想绘制灰烬的Pokéball正在折腾Thetitle屏幕:

然后我们将图块分开到两个位平面中,其中洛比特平面仅具有值0或1,并且高比特平面仅具有Values 0或2.如果我们将它们添加到每个像素,我们&#39 ipageabove:

然后,我们将这些位平面交错,如上所述,以获得此磁贴的16个字节:

$ 1c $ 1c $ 2a $ 32 $ 4d $ 73 $ 41 $ 7f $ 21 $ 5d $ 22 $ 22 $ 5d $ 22 $ 22 $ 0c $ 1c $ 00 $ 00 $ 00 $ 00

随着那个方式,我喜欢谈谈我如何制作的发货。 Istarted于5月24日星期一工作。我已经写了一个GameBoy仿真器(BinJGB),所以我知道我有什么事。但我没有知道乐众神奇宝贝蓝色' s代码。 GameBoy实际上有很多功能,而且iDidn' t知道使用了多少神奇宝贝蓝色。我希望我能够避免他们很多,以保持代码很小。

我决定首先在binjgb中加载游戏并追踪指令。跟踪输出看起来如下所示:

答:01 F:Z-HC BC:0013 DE:00D8 HL:014D SP:FFFE PC:0100(CY:0)PPU:+ 0 | [00] 0x0100:00 Nopa:01 F:Z-HC BC:0013 DE:00D8 HL:014D SP:FFFE PC:0101(CY:4)PPU:+0 | [00] 0x0101:C3 50 01 JP $ 0150A:01 F:Z-HC BC:0013 DE:00D8 HL:014D SP :FFFE PC:0150(CY:20)PPU:+ 0 | [00] 0x0150:FE 11 CP A,17A:01 F:-NC BC:0013 DE:00D8 HL:014D SP:FFFE PC:0152(CY: 28)PPU:+ 0 | [00] 0x0152:28 0x0152:28 03 JR Z,+ 3A:01 F:-NC BC:0013 DE:00D8 HL:014D SP:FFFE PC:0154(CY:36)PPU:+0 | [00] 0x0154:AF XOR A,AA:00 F:Z --- BC:00D8 HL:014D SP:FFFECC:0155(CY:40)PPU:+ 0 | [00] 0x0155:18 02 JR + 2A:00 F:Z --- BC:0013 DE:00D8 HL:014D SP:FFFE PC:0159(CY:52)PPU:+ 0 | [00] 0x0159:EA 1A CF LD [$ CF1A], AA:00 F:Z --- BC:0013 DE:00D8 HL:014D SP:FFFE PC:015C(CY:68)PPU:+ 0 | [00] 0x015C:C3 54 1F JP $ 1F54 ...

当我开始发货时,我在最前几天运行了CPU.EVERY时间我击中了那些已经实施的指令,I'如果它进一步运行,我会实现它和它们。我制作了Pokegb写了一个跟踪输出类似的tobinjgb,所以我可以看出它们是否发散。令人遗憾的是,Pokegb非常令人想到的是,我不能只是差异两种跟踪输出。相反,我要发现它们,看看输出是否导致关注。

几天后,CPU似乎状况良好。游戏在没有失败的情况下跑了数百万千万条指令,并密切匹配binjgb。在该点,我开始渲染图形,并添加了一些输入。对于MySurprise,一切似乎很快就会在那一点上很快!离子有更多的实施指示,我能够走来走去,有谈话,并有战斗。我'我承认我哈登' t播放非常乱游戏,所以有很可能的虫子!

Let'潜水进入混淆的代码。我' ve做了一个unobfuscedversionversion,其中我' ve给出了变量更好的名字和使用clang格式的格式化,但否则没有改变它。

如上所述,大多数指令可以分组为〜40类中的一个。为了使其更容易分组它们,我制作了10种不同的宏:OP4_NX8,OP4_NX16_REL,OP5_FLAG,OP8_NX8_REL,OP7_PTR,OP7_NX8,OP7_NX8_PTR,OP49_REL,OP56_PTR_REL,OP9_IMM_PTR。

PTR:此宏还设置PTR8变量,该变量指向8位源寄存器的地址。

Rel:此宏还设置OPCode_REL变量,它是其组中给定操作码的偏移量(例如,OP4_NX16_REL将具有0,16,32或48的OPCode_relvalues)。这有助于索引到ExternarAray中。

标志:此宏定义使用条件代码作为其无条件版本的条件代码的指令,例如,致电NZ,U16并致电U16。

首先是op5_flag。此宏定义4个条件说明,作为无条件版本。在这里,可变携带是重新呈现的,无论是否应执行操作:

#定义OP5_FLAG(_,始终)\ op4_nx8(_)\ case alware:\ carry =(opcode ==始终)|| (f& f_mask [(opcode - _)/ 8])== f_equals [(操作码 - _)/ 8];

f_mask和f_equals阵列定义为上面的条件表,f是标志寄存器:

例如,如果未设置ZFLAG,则条件NZ表示执行指令。 z标志是标志寄存器的第7位,或128 endecimal。因此,我们可以判断是否未计算(f& 128)== 0来设置z。

接下来是op9_imm_ptr。此宏定义9个指令,一个用于上面的R8表的每个条目,以及直接指令(始终是70的偏移量)。这些指令中的每一个都在8位操作数:7个寄存器(A,B,C,D,E,H或L)中的一个,字节atAddress HL或立即值U8。

READ8_PC()函数读取PC(即,下一个字节INTHE指令流)的下一个字节。 READ8()函数可以在任何地址读取一个字节,但默认为HL读取字节。如上所述,PTR8 POINSTO 8位寄存器中的一个。

#定义OP9_IMM_PTR(_)\ case _ + 6:\ case _ + 70:\ op7_ptr(_)\操作数=(opcode == _ + 6)? read8()\ :( opcode == _ + 70)? read8_pc()\:* ptr8;

它&#39是方便能够访问大多数寄存器,因为它们的16位与其8位部分。因此,所有CPU寄存器都在数组中定义,以及初始值。

这些定义为C,B,E,D,L,H,SP高,SP低电平。这允许我们直接读取16位寄存器对,只要HOST机器很少 - ENDIAN即可。请注意,SP永远不会被访问为8位放弃,因此此处不需要定义它;这可能是一个可拆卸保存一些额外字节的地方。

A和F寄存器直接用于许多指令,因此请按名称引用它们。

uint8_t reg8 [] = {19,0,216,0,77,1,176,1,254,255},& f = reg8 [6],& a = reg [7];

上面描述的R8组使用订单B,C,D,E,F,H,L,(HL),a。reg8_group阵列与此匹配,但使用& f代替。这不再删除了,所以它可能已经0而不是保存字节。

UINT8_T * REG8_GROUP [] = {REG8 + 1,REG8,REG8 + 3,REG8 + 2,REG8 + 5,REG8 + 4,& F,& a}

上面存在三个R16组,并且每个R16在此表示,Reg16 R16(第3组)。第2组实际上是BC,DE,HL +,HL-,其中HL +和HL-使用HL的当前值,然后分别增量占主人心。此行为不包括在此处,而是在指令中使用指令'通过HL_ADD数组实现。

与A和F一样,HL和SP通常直接使用,所以我们' VE按名称创建给他们。

UINT16_T PC = 256,* REG16 =(UINT16_T *)REG8,* REG16_GROUP1 [] = {REG16,REG16 + 1,& HL,& SP},* REG16_GROUP2 [] = {REG16,REG16 + 1,& HL ,& hl},& hl = reg16 [2],& sp = reg16 [4]; int hl_add [] = {0,0,1, - 1};

最后,有一个辅助功能,用于设置CPU标志。屏蔽ParamEterMasks out应该由此指令设置的任何标志,然后z,n,h,c参数将其设置为其新值。作为尺寸优化,此处否定Z标志。那个' s是因为许多指令为z标志如果指令产生0结果,所以它'方便的逻辑不在这里,而不是调用set_flags()的所有位置。

void set_flags(uint8_t掩码,int z,int n,int h,int c){f =(f& mask)| (!Z * 128 + N * 64 + H * 32 + C * 16); } ROM0:指向第一个16kib of ROM的指针(地址0000- $ 3FFF)。这是在程序开头的内存映射。 含有外部RAM的整个32kib的指针。 这是在程序开头的中映射的。 如果,LCDC,LY和DIV的寄存器是足够的 ......