65.9K
CodeProject 正在变化。 阅读更多。
Home

TypeNESs - 用 TypeScript 实现的 NES 模拟器

starIconstarIconstarIconstarIconstarIcon

5.00/5 (17投票s)

2017年1月24日

GPL3

14分钟阅读

viewsIcon

19718

downloadIcon

203

本文介绍了在 TypeScript 中实现 NES 模拟器。

引言

NES,即 Nintendo Entertainment System 的缩写,是一款由任天堂公司开发的 8 位卡带式游戏机,于1983年在日本首次发布,两年后在美国上市。一经上市,它就因以相对较低的成本提供了多媒体游戏体验而风靡一时,这得益于彩色电视机的普及。虽然它并非市面上第一款电视游戏机,但它具有超越同类产品的特性,例如垂直/水平屏幕滚动、5个音轨以及通过卡带系统实现的高度可扩展性,甚至支持特定卡带映射器类型的定制电脑键盘和鼠标。即使在今天,游戏行业发展了30多年之后,NES 游戏机在电视游戏界仍然占有一席之地,其经典版本在亚马逊上非常受欢迎。

模拟器是对硬件的软件模拟,它能让你的目标软件在不同、不兼容的平台上运行。从高层次来看,你可以将模拟器视为你的目标软件和你的计算机系统之间的中间层。它在目标软件和计算机操作系统之间进行指令转换。

TypeScript 是 JavaScript 的增强版本,支持严格的类型检查和面向对象的类编程,因此比纯 JavaScript 更适合开发大型前端 Web 应用程序。它是一个开源语言,由 Microsoft 开发和维护。

背景

编写 NES 模拟器是一个充满挑战但非常有益的过程。它之所以具有挑战性,是因为你需要深入了解硬件细节并对其架构有深入的理解。NES 游戏机由不同的部分(CPU/PPU/Mapper 等)组成,其中任何一个部分的微小错误都可能导致游戏无法正常运行。它还考验你的编码技能,因为你需要用你选择的语言来实现 NES 硬件。然而,它也是一个非常有益的过程。NES 虽然是一台 8 位计算机,但其运行原理与现代计算机相似,只是更为原始。它促使你去探索 CPU 如何与外围设备交互,或者音频处理单元如何生成数字 MIDI 风格的音乐等主题。

TypeNESs 模拟器的工作原理

在真实的 NES 系统中,CPU(中央处理单元)、PPU(图像处理单元)和 APU(音频处理单元)并行运行,并且它们各自有自己的时钟速度。PPU 和 APU 将它们的寄存器映射到 CPU 的地址空间(“总线”),因此 CPU 可以通过读写它们映射的寄存器来控制这些单元。另一方面,PPU 和 APU 可以通过触发中断来主动通知 CPU 任何状态变化。NES 6502 CPU 具有一个 16 位地址总线,范围从 0x0000 - 0xffff(总共 65536 字节)。理论上,NES 可以从这 65536 字节中的任何一个读取或写入值。实际上,NES RAM 只有 2KB 的容量,地址范围为 (0x0000 - 0x07ff),因此剩余的地址空间为 NES 系统中的其他单元提供了空间。例如,PPU 有 8 个映射到 $2000 - $2007 的寄存器,NES 程序可以通过从这些地址读取值来获取 PPU 的状态,或者通过向这些地址写入值来设置 PPU 的状态。

Sample Image

为了模拟,你可能希望使用多线程来模拟这三个单元,因为它们是并行运行的。然而,由于代码是在浏览器中使用 JavaScript 运行(由 TypeScript 编译后),对模拟器使用多线程运行 NES 模拟器的尝试施加了限制。HTML5 支持多线程,但有两个主要缺点:

  • Web Worker 不允许访问 DOM 元素,也不允许访问 HTML5 音频。
  • Web Worker 与你的 JavaScript 主线程不共享相同的内存。在 Web Worker 和主线程之间传递大量数据可能会非常慢(例如,如果你在一个 Web Worker 中运行 PPU,并且每秒需要返回 0x4000 字节的视频 RAM 数据 60 次)。

由于 HTML5 中对多线程的这些限制,我们需要找到一种方法来模拟这些单元的并行运行。6502 CPU 以 1.789773 MHz(NTSC)运行,PPU 以 5.369318 MHz(NTSC)运行。如果你仔细检查,你会发现 PPU 的运行速度是 CPU 的三倍。对于一个 CPU 指令,我们可以计算出它需要多少个周期来执行。将这个周期数乘以 3,我们就能得到该 CPU 指令执行后 PPU 可以执行多少步的结果。

    while(true){
        // Execute one CPU instruction and return cycles it takes.
        cycles = cpu.step();
        cycles *=3;
        ppu.incrementCycle(cycles)
    }

这样,我们只需要一个线程,就可以尽可能精细地模拟 CPU 和 PPU 的并行运行。

6502 CPU

MOS Technology 6502 是一款 8 位微处理器,于 1975 年首次推出。它为 Apple II 和 Atari 等一系列早期计算机和游戏机提供动力。NES 采用 Ricoh 制造的 6502 处理器变体,名为 RP2A03(NTSC)或 RP2A07(PAL)。

将 CPU 想象成一个机械臂,它不断地从一张纸带上获取命令。它从这张纸带的一个单元读取一个命令,执行接收到的命令,将结果写在一个临时便签上,然后移动到下一个单元读取新的命令。这个过程以闪电般的速度持续不断地进行。有了这个概念,我们就可以轻松解释一些描述 CPU 如何工作的术语。例如,CPU 指令就是写在纸带上的命令,对应我们所说的“内存”。“寻址”是 CPU 如何定位纸带单元。而“寄存器”则是帮助 CPU 暂时保存其命令执行结果的部件。

在模拟中,每个 CPU 指令都与其寻址模式、命令长度和 CPU 周期相关联。通过寻址模式,我们可以找出指令应该操作的值,我们将这个值称为指令的“操作数”。通过命令长度,我们不会将操作数误认为是 CPU 指令。而 CPU 周期则有助于控制我们模拟器的速度。

6502 CPU 有 6 个寄存器,包括一个累加器、2 个索引寄存器、一个程序计数器、一个堆栈指针和一个状态寄存器。除程序计数器(16 位,用于寻址 0 - 0xffff 的内存空间)外,所有寄存器均为 8 位。在 TypeNESs 代码中,状态寄存器被拆分为标志位以加快处理速度。

CPU 类有一个 step() 函数,该函数一次执行一条 CPU 指令。其工作流程如下:

  1. 检查是否有 CPU 中断。如果有,则处理。
  2. 根据程序计数器获取指令
  3. 根据寻址模式准备操作数
  4. 执行指令
  5. 更新程序计数器并返回其占用的 CPU 周期。
     export class CPU {
        private REG_A: number;
        private REG_X: number;
        private REG_Y: number;
        private REG_PC: number;
        private REG_S: number;
        private FLAG_N: number;
        private FLAG_V: number;
        private FLAG_B: number;
        private FLAG_D: number;
        private FLAG_I: number;
        private FLAG_Z: number;
        private FLAG_C: number;
	  public step(): number {
	            if (this.INT_requested) {
			...
	            }
	
			…
		var inst = this.read8(this.REG_PC);
			…
	            switch (addrMode) {
	               ...
	            }
	
	            switch (opcode) {
	    		...
	            }
	            this.REG_PC += oplenth;
	            var totalCycles = opcycles + extraCycle;
	            return totalCycles;
	 }
   }

Mapper (映射器)

Mapper 是理解 NES 作为卡带系统的一个关键概念。早期,电子游戏以卡带的形式发布,卡带中包含存储游戏二进制文件的 ROM 芯片。如前所述,6502 CPU 通过读取/写入其 16 位内存空间与其他 NES 单元进行交互。Mapper 随后扮演的角色是决定游戏 ROM 的内容如何映射到 CPU 可访问的内存空间(总线)。使 Mapper 对 NES 系统至关重要的一个更重要的问题是,NES 游戏可能太大而无法容纳有限的内存空间。例如,一个 NES 游戏可能高达 1MB,而整个 NES 内存空间为 64KB(只有 32KB,从 $8000 - $FFFF,可以映射游戏 ROM 内容)。为了解决这个问题,Mapper 采用了一种称为“バンク切换”(bank switching)的方式,将游戏 ROM 的一部分(称为“bank”)映射到内存空间,并在游戏执行期间动态地将映射内容更改为另一个游戏 ROM bank。

Mapper 的类型超过 100 种。游戏主要根据游戏大小选择 Mapper。作为模拟器,TypeNESs 读取游戏镜像文件而不是 ROM 卡带。游戏 ROM 文件的头部(描述该 ROM)如下所示:

Sample Image

字节 6 和字节 7 的高 4 位组合在一起形成 8 位 Mapper 号。在模拟中,Mapper 是通过工厂方法实现的,因此一旦我们从 ROM 文件中获得 Mapper 号,我们就可以根据 Mapper 类型生成相应的 Mapper 对象。

    public createMapper(): IMapper{
        switch (this.mapperType) {
            case 0:
                return new Mapper0(this.machine);
            break;
            case 1:
                return new Mapper1(this.machine);
            break;
        …
        }
    }

这里的 IMapper 是一个接口,它抽象了所有 Mapper 类型的通用方法,因此模拟器不必关心游戏的 ROM 格式或 Mapper 类型,而是通过 IMapper 接口间接读写游戏 ROM。请注意,“写入” ROM,如果操作合法,通常会被解释为写入游戏卡带包含的特定寄存器,而不是直接修改 ROM。也就是说,“写入”在这里并不违反 ROM(Read Only Memory)的含义。

    export interface IMapper {
        reset(): void;
        write(address: number, value: number): void;
        load(addr: number): number;
        regLoad(address: number): number;
        regWrite(address: number, value: number): void;
        loadROM(): void;
    }

PPU

NES 具有一个 PPU(Picture Processing Unit),名为 2C02,用于生成电视机的视频信号,共有 240 行像素。如前所述,NES 允许游戏程序员平滑地滚动游戏屏幕,无论是水平还是垂直,这一特性使 NES 游戏机区别于其前代产品。一个真实的 2C02 PPU 芯片包含的细节远远超出了本文的范围,因此我们在这里从高层次的角度关注游戏的滚动和图像渲染,我相信这是一个理解 NES PPU 系统的良好切入点。

在 PPU RAM 中,电视屏幕图像被划分为 30 行,每行包含 32 个称为 tile(图块)的单元。也就是说,一个电视屏幕有 32*30=960 个图块。根据 PPU 的镜像类型,我们最多可以在 PPU 内存中准备 4 个屏幕图像,并且当我们控制 PPU 在电视上显示图像 1 的一部分和图像 2 的一部分时(参见图 4)。我们逐渐地(在 NES 中,是在像素级别)将黄色框从屏幕 1 移动到屏幕 2,这样我们就会在电视屏幕上看到游戏图像在“滚动”。

Sample Image

Sample Image

PPU 拥有自己的 16KB 内存,每秒扫描 60 次以刷新图像。如前所述,CPU 不能直接访问 PPU 内存来更新图片,只能通过映射到 CPU 可访问内存地址 $2000 - $2007 的 PPU 寄存器进行访问。通过向这些地址写入值,CPU 能够控制 NES PPU 的行为并更新其内容。假设你可以(尽管是间接的)写入 PPU 内存的任何位置,那么你将如何控制屏幕上显示什么以及显示在哪里?为了回答这个问题,让我们检查 PPU 内存的布局,它被分为 3 个区域:pattern table(图案表)、name table(名称表)和 palettes(调色板)。

Sample Image

从 0x0 - 0x2000 的 Pattern table 定义了图像的“图案”。请记住,NES PPU 屏幕由图块组成。每个图块都是一个 8x8 像素的图片。如果我们使用 1 位表示一个像素,那么这个图块需要 8x8=64 位(因此需要 8 字节)来表示一个图块。在 pattern table 中,系统使用 2 位来表示一个像素,所以一个图块占用 16 字节的 PPU 内存空间。如果你使用一些工具转储《超级马里奥》游戏的 pattern table,你将得到图 7 中的结果。

Sample Image

请注意,pattern table 实际上是游戏的图像“库”,它决定了屏幕上可以显示什么。你可能已经发现 pattern table 转储的颜色不正确,你说得对。这是因为 pattern table 描述了图像的外观,但并非确切的颜色。

Name table 决定了图块图像在屏幕上的放置位置。请记住,我们在 PPU 内存中有 4 个屏幕,因此在 0x2000 - 0x3F00 的 name table 区域中有 4 个 name table。对于每个 name table,都有一个对应的表称为“attribute table”,它为 name table 中的每 4 个图块分配 2 位。来自 attribute 的这 2 位将与 pattern table 中的 2 位结合形成一个 4 位数字,这个 4 位数字将指向 NES PPU 内存的最后一个区域——palette table 中的一个值。下面是《超级马里奥》游戏中 palette table 的一个示例(图 8)。在 pattern table 中的每个图像图块像素,一旦被 name table 指向,我们就可以通过查找 palette table 来确定其颜色。

Sample Image

要列出 PPU 模拟的完整代码太长了,因此在这篇文章中,我只举一个例子说明如何将 nametable 绘制到 HTML5 canvas 元素上。这个例子在你调试模拟器时会非常有用,它涵盖了我描述的从 PPU 内存到屏幕渲染图像的大部分要点。

    public DrawNametable(canvasID: string, patterntableStartAddr: number, nametableStartAddr: number) {
        var screen = <HTMLCanvasElement>
        document.getElementById(canvasID);

        var canvasContext = screen.getContext('2d');

        var r = this.machine.ppu.imgPalette[0] & 0xff;
        var g = (this.machine.ppu.imgPalette[0] >> 8) & 0xff;
        var b = (this.machine.ppu.imgPalette[0] >> 16) & 0xff;
        canvasContext.fillStyle = "rgb(" + r + "," + g + "," + b + ")";

        // set alpha to opaque
        canvasContext.fillRect(0, 0, 256, 240);

        var canvasImageData = canvasContext.getImageData(0, 0, 256, 240);

        // Set alpha
        for (var i = 3; i < canvasImageData.data.length - 3; i += 4) {
            canvasImageData.data[i] = 0xFF;
        }

        // We have 32*30=960 tiles for an screen image
        for (var i = 0; i < 960; i++) {
            var tableValue = this.machine.ppu.vramMem[nametableStartAddr + i];

            var tileStartAddr = patterntableStartAddr + tableValue * 16;
            var chars = [];

            // Every tile occupies 16 bytes in the pattern table
            for (var j = 0; j < 16; j++) {
                var num = this.machine.ppu.vramMem[tileStartAddr];
                tileStartAddr++;
                chars.push(num);
            }
            var attrByteIndex = Math.floor(i / 128) * 8 + Math.floor((i % 32) / 4);
            var attrByte = this.machine.ppu.vramMem[nametableStartAddr + 960 + attrByteIndex];
            var attrValue = (attrByte >> (((Math.floor((i % 128) / 64) << 1) | Math.floor((i % 4) / 2)) * 2)) & 0x3;
            this.Draw8x8(canvasContext, i % 32, Math.floor(i / 32), chars, attrValue);
        }
    }

APU

APU 是 Audio Processing Unit(音频处理单元)的缩写。它是游戏机中的 RP2A03(NTSC)或 RP2A07(PAL)芯片。APU 单元将它的寄存器映射到 CPU 内存空间中的 $4000 到 $4013、$4015 和 $4017。APU 可以通过五个通道生成声波:两个脉冲波通道、一个三角波通道、一个噪音通道和一个增量调制通道。TypeNESs 目前没有对增量调制通道的实现。

以方波为例。NES 方波通道有 4 种模式,由以下二维二进制数组定义。当使用以下 4 种模式之一生成方波时,想象有一个指针从第一个数字循环到第八个数字,然后再返回。当数字为 0 时,方波通道输出 0,1 表示输出为 1。

[0,1,0,0,0,0,0,0] [0,1,1,0,0,0,0,0] [0,1,1,1,1,0,0,0] [1,0,0,1,1,1,1,1]

如果我们选择第二种模式,那么我们从方波通道获得的波形将是以下波形的重复:

Sample Image

在模拟中,如果我们想用方波生成中央 C 音,该怎么做?NES APU 的采样率为 44.1 KHz,这意味着在 1 秒内,有 44,100 个“点”来描述方波声音的形状。6502 CPU 的运行速度为 1.789773MHz(NTSC),因此每隔 1,789,773/44,100 = 40.5844 个 CPU 周期,APU 就需要提供一个采样值。对于中央 C 音符,频率为 261Hz,因此中央 C 音符的一个周期持续 1,789,773/261 = 6,857.3678 个 CPU 周期。考虑到 4 种方波模式,每种模式有 8 个“步”,因此每隔 1,789,773/261/8 = 857.171 个 CPU 周期,我们就通知 APU 方波通道现在需要查找模式表以获取采样输出值。

    public addCycles(cpuCycles: number) {
        this.apuCycleCounter += cpuCycles;
        if (this.apuCycleCounter >= this.SAMPLING_CYCLES) {
            this.apuCycleCounter -= this.SAMPLING_CYCLES;
            this.sample(cpuCycles); // Generate sample values by collecting values from different channels
        }

        ……
        this.square1.addCycles(cpuCycles); //update the square channel counter. 
                                            //If enough cycles added, update the channel value for sampling
        ……
    }

键盘

NES 手柄通过映射到内存总线连接到 NES 系统,因此 6502 CPU 可以通过读取字节值来检测按钮按下。确切的映射取决于 Mapper 类型。对于模拟中的手柄,手柄 1 映射到 0x4016,手柄 2 映射到 0x4017(对于当前支持的 Mapper 类型)。请注意,一个手柄至少有 8 个按钮操作,那么你可能会想,1 个字节如何服务 8 个按键。答案是,NES 维护着一种状态机来记录读取顺序。第一次读取 0x4016(或 0x4017)时,卡带提供按键 A 的状态;第二次在同一地址读取时,它提供按键 B 的状态;第三次是 SELECT 键。在读取这个字节 24 次之后,它会回到提供按键 A(有些读取操作没有意义,只是为了“驱动”状态机)。

关注点

在浏览器中工程化一个 NES 模拟器是一个充满挑战但令人兴奋的过程。在编写模拟器之前,你可能对自己的计算机架构知识和汇编语言知识非常自信;编写模拟器才能让你深入回顾对计算机系统的理解。你会在编程/调试过程中遇到许多“豁然开朗”的时刻。TypeNESs 是一个开源项目。你可以 Fork 并将你的代码贡献给这个项目。工作项可以包括添加更多的 ROM 格式支持、性能改进、错误修复、音频增强等。

Github 上的 TypeNESs

享受模拟的乐趣!

© . All rights reserved.