使用Python从超级马里奥兄弟中获取关卡


引言


对于一个新项目,我需要从1985年经典的视频游戏《 超级马里奥兄弟》(SMB)中提取关卡数据。 更具体地说,我想在没有界面,移动精灵等的情况下提取游戏各个级别的背景图形。

当然,我只是可以粘贴游戏中的图像,并可能使用机器视觉技术使过程自动化。 但是在我看来,以下描述的方法更有趣,该方法使您可以探索无法使用屏幕截图获得的那些关卡元素。

在项目的第一阶段,我们将学习6502汇编语言和用Python编写的仿真器。 完整的源代码在这里

源代码分析


如果您有程序的源代码,则对任何程序进行逆向工程都非常简单,并且我们有SMB源代码,形式是doppelganger发布的17,000 行汇编代码6502(NES处理器) 。 由于Nintendo从未发布过正式的源代码发布,因此该代码是通过分解SMB机器代码,痛苦地解密每个部分的含义,添加注释和有意义的符号名称来创建的。

对文件执行快速搜索后,我发现了一些与我们所需的级别数据类似的东西:

;level 1-1
L_GroundArea6:
.db $50, $21
.db $07, $81, $47, $24, $57, $00, $63, $01, $77, $01
.db $c9, $71, $68, $f2, $e7, $73, $97, $fb, $06, $83
.db $5c, $01, $d7, $22, $e7, $00, $03, $a7, $6c, $02
.db $b3, $22, $e3, $01, $e7, $07, $47, $a0, $57, $06
.db $a7, $01, $d3, $00, $d7, $01, $07, $81, $67, $20
.db $93, $22, $03, $a3, $1c, $61, $17, $21, $6f, $33
.db $c7, $63, $d8, $62, $e9, $61, $fa, $60, $4f, $b3
.db $87, $63, $9c, $01, $b7, $63, $c8, $62, $d9, $61
.db $ea, $60, $39, $f1, $87, $21, $a7, $01, $b7, $20
.db $39, $f1, $5f, $38, $6d, $c1, $af, $26
.db $fd


如果您不熟悉汇编程序,那么我将进行解释:所有这一切仅意味着“将这样的字节集插入已编译的程序,然后允许程序的其他部分使用L_GroundArea6符号来引用它”。 您可以将此片段作为一个数组,其中每个元素都是一个字节。

您可以注意到的第一件事是数据量非常小(大约100个字节)。 因此,我们排除了所有类型的编码,使您可以随意在该级别放置块。 经过一番搜索,我发现在AreaParserCore中读取了此数据(经过几次间接寻址操作)。 该子过程继而调用许多其他子过程,最终为场景中允许的每种对象类型调用特定的子过程(例如StaircaseObjectVerticalPipeRowOfBricks ):


AreaParserCore调用图

该过程将写入MetatileBuffer :一个13字节的内存部分,它是级别中块的一列,每个字节代表一个单独的块。 一个metatile是一个16x16的块,用于构成SMB游戏的背景:


围绕在分位数周围的矩形水平

它们被称为元文件,因为每个文件都由四个8x8像素的图块组成,但在下面进行了介绍。

解码器可与预定义对象一起工作的事实说明了级别的小规模:级别数据应仅指对象的类型及其位置,例如,“将管道放置在点(20,16),将多个块放置在点(10,5),... ”。 但是,这意味着需要大量代码才能将原始级别的数据转换为元文件。

移植大量代码以创建自己的级别解包器将花费太多时间,因此让我们尝试另一种方法。

py65emu


如果我们在Python和6502汇编语言之间建立了接口, AreaParserCore以为每个级别列调用AreaParserCore子过程,然后使用更易理解的Python将块信息转换为所需的图像。

然后py65emu出现在场景中-具有Python界面的简洁6502仿真器。 这是在py65emu中配置与NES中相同的内存配置的方式:

  from py65emu.cpu import CPU from py65emu.mmu import MMU #  ROM  (..  ) with open("program.bin", "rb") as f: prg_rom = f.read() #   . mmu = MMU([ #  2K ,    0x0. (0x0, 2048, False, []), #  ROM   0x8000. (0x8000, len(prg_rom), True, list(prg_rom)) ]) #     ,       0x8000 cpu = CPU(mmu, 0x8000) 

之后,我们可以使用cpu.step()方法执行个别指令,使用cpu.step()检查内存,使用cpu.racpu.r.pc等研究机器寄存器。 另外,我们可以使用mmu.write()写入内存。

值得注意的是,这只是一个NES处理器仿真器:它不仿真其他硬件,例如PPU(图片处理单元),因此不能用于仿真整个游戏。 但是,调用解析子过程应该足够了,因为它不使用CPU和内存以外的任何其他硬件设备。

计划是如上所述配置CPU,然后对于每个级别列,使用AreaParserCore所需的输入值初始化内存分区,调用AreaParserCore ,然后读回列数据。 完成这些操作后,我们使用Python将结果组合成一个完整的图像。

但是在此之前,我们需要将汇编语言中的清单编译为机器代码。

x816


如源代码中所示,汇编程序是使用x816编译的。 x816是Homebrew社区用于NES和ROM黑客的MS-DOS汇编程序6502。 它在DOSBox上很好用。

x816汇编程序连同py65emu必需的程序ROM一起创建了一个字符文件,该文件将字符映射到其在CPU地址空间中的内存位置。 这是该文件的一个片段:

AREAPARSERCORE = $0093FC ; <> 37884, statement #3154
AREAPARSERTASKCONTROL = $0086E6 ; <> 34534, statement #1570
AREAPARSERTASKHANDLER = $0092B0 ; <> 37552, statement #3035
AREAPARSERTASKNUM = $00071F ; <> 1823, statement #141
AREAPARSERTASKS = $0092C8 ; <> 37576, statement #3048


在这里我们看到源代码中的AreaParserCore函数可以在0x93fc0x93fc

为了方便起见,我编写了一个符号文件解析器来匹配符号名称和地址:

 sym_file = SymbolFile('SMBDIS.SYM') print("0x{:x}".format(sym_file['AREAPARSERCORE'])) #  0x93fc print(sym_file.lookup_address(0x93fc)) #  "AREAPARSERCORE" 

子程序


如上面的计划所述,我们想学习如何从Python调用AreaParserCore子过程。

为了了解子过程的机制,让我们研究一个简短的子过程及其相应的挑战:

 WritePPUReg1: sta PPU_CTRL_REG1 ;  A   1 PPU sta Mirror_PPU_CTRL_REG1 ;    rts ... jsr WritePPUReg1 

jsr (跳转到子例程,“跳转到子例程”)指令jsr PC寄存器压入堆栈,并为其分配WritePPUReg1引用的地址值。 PC寄存器告诉处理器要加载的下一条指令的地址,以便在jsr指令之后执行的下一条指令是WritePPUReg1的第一行。

在子例程的末尾,将执行rts语句(从子例程返回,“从子例程返回”)。 该命令从堆栈中删除存储的值,并将其存储在PC寄存器中,这将强制CPU在jsr调用之后执行指令。

子过程的一个重要功能是您可以创建内联调用,即子过程中的子过程调用。 返回地址将被推入堆栈并以正确的顺序弹出,其方式与高级语言中的函数调用相同。

这是从Python执行子程序的代码:

 def execute_subroutine(cpu, addr): s_before = cpu.rs cpu.JSR(addr) while cpu.rs != s_before: cpu.step() execute_subroutine(cpu, sym_file['AREAPARSERCORE']) 

该代码保存堆栈指针寄存器( s )的当前值,模拟jsr调用,然后执行指令,直到堆栈返回其原始高度(仅在返回第一个子过程之后才发生)。 这将很有用,因为现在我们有了一种从Python直接调用6502子例程的方法。

但是,我们忘记了一些事情:如何为该子过程传递输入值? 我们需要告诉该过程我们要呈现的级别以及需要解析的列。

与高级语言中的函数不同,汇编语言6502的子例程无法接收明确指定的输入数据。 而是通过在调用之前的某个位置指定存储位置来传输输入,然后在子过程调用内部读取这些存储位置。 给定AreaParserCore的大小,只需查看源代码即可对所需输入进行反向工程,这将非常复杂且容易出错。

Valgrind是否适合NES?


为了找到确定AreaParserCore输入值的AreaParserCore ,我以Valgrind的memcheck工具为例。 Memcheck通过与实际分配的内存的每个片段并行存储影子内存来识别对未初始化内存的访问操作。 影子存储器记录是否对相应的实际存储器进行了记录。 如果程序读取到从未写入的地址,则输出未初始化的存储器错误。 我们可以使用一个工具来运行AreaParserCore ,该工具告诉我们在调用子过程之前需要设置哪些输入。

实际上,为py65emu编写一个简单的memcheck版本非常容易:

 def format_addr(addr): try: symbol_name = sym_file.lookup_address(addr) s = "0x{:04x} ({}):".format(addr, symbol_name) except KeyError: s = "0x{:04x}:".format(addr) return s class MemCheckMMU(MMU): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._uninitialized = array.array('B', [1] * 2048) def read(self, addr): val = super().read(addr) if addr < 2048: if self._uninitialized[addr]: print("Uninitialized read! {}".format(format_addr(addr))) return val def write(self, addr, val): super().write(addr, val) if addr < 2048: self._uninitialized[addr] = 0 

在这里,我们包装了py65emu的内存管理单元(MMU)。 此类包含一个_uninitialized数组,该数组的元素告诉我们是否曾经将其写入模拟RAM的相应字节。 如果未初始化读取,则会显示无效读取操作的地址和相应字符的名称。

这是调用execute_subroutine(sym_file['AREAPARSERCORE'])execute_subroutine(sym_file['AREAPARSERCORE']) MMU的结果:

Uninitialized read! 0x0728 (BACKLOADINGFLAG):
Uninitialized read! 0x0742 (BACKGROUNDSCENERY):
Uninitialized read! 0x0741 (FOREGROUNDSCENERY):
Uninitialized read! 0x074e (AREATYPE):
Uninitialized read! 0x075f (WORLDNUMBER):
Uninitialized read! 0x0743 (CLOUDTYPEOVERRIDE):
Uninitialized read! 0x0727 (TERRAINCONTROL):
Uninitialized read! 0x0743 (CLOUDTYPEOVERRIDE):
Uninitialized read! 0x074e (AREATYPE):
...


通过查看代码,您可以看到许多这些值是由InitializeArea子过程设置的,因此让我们再次运行脚本,首先调用此函数。 重复此过程,我们进行以下呼叫顺序,该呼叫顺序仅需要世界号码和区域号码:

 mmu.write(sym_file['WORLDNUMBER'], 0) #    1 mmu.write(sym_file['AREANUMBER'], 0) #    1 execute_subroutine(sym_file['LOADAREAPOINTER']) execute_subroutine(sym_file['INITIALIZEAREA']) metatile_data = [] for column_pos in range(48): execute_subroutine(sym_file['AREAPARSERCORE']) metatile_data.append([mmu.read_no_debug(sym_file['METATILEBUFFER'] + i) for i in range(13)]) execute_subroutine(sym_file['INCREMENTCOLUMNPOS']) 

该代码使用IncrementColumnPos子过程来增加World 1-1级别的前48列到metatile_data ,以增加跟踪当前列所需的内部变量。

这是metatile_data的内容,叠加在游戏的屏幕截图上(未显示值为0的字节):


显然, metatile_data显然与背景信息匹配。

元图形


(要查看最终结果,您可以立即进入“将所有内容连接在一起”部分。)

现在让我们弄清楚如何将接收到的许多元文件转换为真实图像。 下面描述的步骤是通过分析源代码以及使用令人惊叹的Nesdev Wiki阅读文档而发明的。

要了解如何渲染每个元数据,我们首先需要讨论NES调色板。 NES控制台PPU通常可以渲染64种不同的颜色,但是黑色会重复多次(有关详细信息,请参阅Nesdev ):


每个Mario级别只能使用这64种颜色中的10种作为背景,分为4个四色调色板;每个色阶都可以使用。 第一种颜色总是相同的。 这是World 1-1的四个调色板:


现在,让我们看一个元文件编号的二进制示例。 这是破裂的石瓦的分位数,是世界1-1级的土地:


调色板索引告诉我们在渲染metatile时要使用哪个调色板(在我们的示例中为调色板1)。 调色板索引也是以下两个数组的索引:

MetatileGraphics_Low:
.db <Palette0_MTiles, <Palette1_MTiles, <Palette2_MTiles, <Palette3_MTiles

MetatileGraphics_High:
.db >Palette0_MTiles, >Palette1_MTiles, >Palette2_MTiles, >Palette3_MTiles


这两个数组的组合为我们提供了一个16位地址,在我们的示例中,该地址指向Palette1_Mtiles

Palette1_MTiles:
.db $a2, $a2, $a3, $a3 ;vertical rope
.db $99, $24, $99, $24 ;horizontal rope
.db $24, $a2, $3e, $3f ;left pulley
.db $5b, $5c, $24, $a3 ;right pulley
.db $24, $24, $24, $24 ;blank used for balance rope
.db $9d, $47, $9e, $47 ;castle top
.db $47, $47, $27, $27 ;castle window left
.db $47, $47, $47, $47 ;castle brick wall
.db $27, $27, $47, $47 ;castle window right
.db $a9, $47, $aa, $47 ;castle top w/ brick
.db $9b, $27, $9c, $27 ;entrance top
.db $27, $27, $27, $27 ;entrance bottom
.db $52, $52, $52, $52 ;green ledge stump
.db $80, $a0, $81, $a1 ;fence
.db $be, $be, $bf, $bf ;tree trunk
.db $75, $ba, $76, $bb ;mushroom stump top
.db $ba, $ba, $bb, $bb ;mushroom stump bottom
.db $45, $47, $45, $47 ;breakable brick w/ line
.db $47, $47, $47, $47 ;breakable brick
.db $45, $47, $45, $47 ;breakable brick (not used)
.db $b4, $b6, $b5, $b7 ;cracked rock terrain <--- This is the 20th line
.db $45, $47, $45, $47 ;brick with line (power-up)
.db $45, $47, $45, $47 ;brick with line (vine)
.db $45, $47, $45, $47 ;brick with line (star)
.db $45, $47, $45, $47 ;brick with line (coins)
...


当您将元位数索引乘以4时,它将成为此数组的索引。 数据的格式为每行4条记录,因此我们的示例metatile引用了第二十行,并标有cracked rock terrain注释。

该行的四个条目实际上是图块标识符:每个metatile由四个8x8像素图块组成,这些图块按以下顺序排列-左上,左下,右上和右下。 这些标识符直接传递到NES PPU控制台。 标识符引用CHR-ROM控制台中的16个字节的数据,并且每个记录0x1000 + 16 * < >地址0x1000 + 16 * < >开头:

0x1000 + 16 * 0xb4: 0b01111111 0x1000 + 16 * 0xb5: 0b11011110
0x1001 + 16 * 0xb4: 0b10000000 0x1001 + 16 * 0xb5: 0b01100001
0x1002 + 16 * 0xb4: 0b10000000 0x1002 + 16 * 0xb5: 0b01100001
0x1003 + 16 * 0xb4: 0b10000000 0x1003 + 16 * 0xb5: 0b01100001
0x1004 + 16 * 0xb4: 0b10000000 0x1004 + 16 * 0xb5: 0b01110001
0x1005 + 16 * 0xb4: 0b10000000 0x1005 + 16 * 0xb5: 0b01011110
0x1006 + 16 * 0xb4: 0b10000000 0x1006 + 16 * 0xb5: 0b01111111
0x1007 + 16 * 0xb4: 0b10000000 0x1007 + 16 * 0xb5: 0b01100001
0x1008 + 16 * 0xb4: 0b10000000 0x1008 + 16 * 0xb5: 0b01100001
0x1009 + 16 * 0xb4: 0b01111111 0x1009 + 16 * 0xb5: 0b11011111
0x100a + 16 * 0xb4: 0b01111111 0x100a + 16 * 0xb5: 0b11011111
0x100b + 16 * 0xb4: 0b01111111 0x100b + 16 * 0xb5: 0b11011111
0x100c + 16 * 0xb4: 0b01111111 0x100c + 16 * 0xb5: 0b11011111
0x100d + 16 * 0xb4: 0b01111111 0x100d + 16 * 0xb5: 0b11111111
0x100e + 16 * 0xb4: 0b01111111 0x100e + 16 * 0xb5: 0b11000001
0x100f + 16 * 0xb4: 0b01111111 0x100f + 16 * 0xb5: 0b11011111

0x1000 + 16 * 0xb6: 0b10000000 0x1000 + 16 * 0xb7: 0b01100001
0x1001 + 16 * 0xb6: 0b10000000 0x1001 + 16 * 0xb7: 0b01100001
0x1002 + 16 * 0xb6: 0b11000000 0x1002 + 16 * 0xb7: 0b11000001
0x1003 + 16 * 0xb6: 0b11110000 0x1003 + 16 * 0xb7: 0b11000001
0x1004 + 16 * 0xb6: 0b10111111 0x1004 + 16 * 0xb7: 0b10000001
0x1005 + 16 * 0xb6: 0b10001111 0x1005 + 16 * 0xb7: 0b10000001
0x1006 + 16 * 0xb6: 0b10000001 0x1006 + 16 * 0xb7: 0b10000011
0x1007 + 16 * 0xb6: 0b01111110 0x1007 + 16 * 0xb7: 0b11111110
0x1008 + 16 * 0xb6: 0b01111111 0x1008 + 16 * 0xb7: 0b11011111
0x1009 + 16 * 0xb6: 0b01111111 0x1009 + 16 * 0xb7: 0b11011111
0x100a + 16 * 0xb6: 0b11111111 0x100a + 16 * 0xb7: 0b10111111
0x100b + 16 * 0xb6: 0b00111111 0x100b + 16 * 0xb7: 0b10111111
0x100c + 16 * 0xb6: 0b01001111 0x100c + 16 * 0xb7: 0b01111111
0x100d + 16 * 0xb6: 0b01110001 0x100d + 16 * 0xb7: 0b01111111
0x100e + 16 * 0xb6: 0b01111111 0x100e + 16 * 0xb7: 0b01111111
0x100f + 16 * 0xb6: 0b11111111 0x100f + 16 * 0xb7: 0b01111111


CHR-ROM是只读存储器,只有PPU可以访问。 它与存储程序代码的PRG-ROM分开。 因此,以上数据在源代码中不可用,必须从游戏ROM的转储中获取。

每个图块16字节组成一个2位8x8图块:第一位是前8个字节,第二个是后8个字节:

21111111 13211112
12222222 23122223
12222222 23122223
12222222 23122223
12222222 23132223
12222222 23233332
12222222 23111113
12222222 23122223

12222222 23122223
12222222 23122223
33222222 31222223
11332222 31222223
12113333 12222223
12221113 12222223
12222223 12222233
23333332 13333332


将此数据绑定到选项板1:


...并结合各个部分:


最后,我们得到了一个渲染图块。

全部放在一起


对每个图元文件重复此过程,我们将获得一个完全渲染的级别。


多亏了这一点,我们能够使用Python提取SMB级图形!

Source: https://habr.com/ru/post/zh-CN416241/


All Articles