上班族

 找回密码
 立即注册
查看: 1041|回复: 14

NES 模拟器开发教程

[复制链接]

76

主题

122

帖子

991

积分

高级会员

Rank: 4

积分
991
发表于 2022-9-25 07:57:41 | 显示全部楼层 |阅读模式
本帖最后由 gameboy 于 2022-9-25 12:02 编辑

NES 模拟器开发教程 00 - 总览
从开始开发 NES 模拟器 tsnes 到现在已经近一个月时间了。目前已经实现了 NES 必备的组件:CPU、PPU、APU、手柄、卡带、部分常用 Mapper。由于 NES mapper 数量实在太多,目前只实现了 mapper0 到 4 以及我喜欢的几个汉化游戏的 mapper。不过仅靠这些 mapper 也能运行大多数游戏了
由于我的模拟器使用 typescript 开发,所以可以直接在浏览器运行,在线演示地址
效果如图:
22600211-b80891bc11a44406.png

回想到一个月前,自从有了开发 NES 模拟器想法后,开始在网上寻找相关教程,结果一个完整的教程都没找到,能找到的教程的基本都是烂尾状态。无奈之下只能硬啃 nesdev,然后下载好几个模拟器源码参考着看
由于 nesdev 对新手极其不友好,仅仅适合作为参考资料使用,所以为了后来也想开发 NES 模拟器的人能更轻松地入门,于是萌生了写下这一系列教程的想法
注:因为篇幅限制,我不会涉及到 NES 所有的细节(否则可以写本书了)。但是会讲清楚每个部分工作原理,设计思路,以及关键部分如何实现,有了一定了解后再去看 nesdev 上的资料就会轻松许多
路线图
我开发模拟器大致仅过了这么几个阶段,后续文章我也会按照这样的顺序来介绍:
1. NES 系统结构
介绍 NES 基本组成,各个部分功能。以及模拟器对外提供的 API 设计
2. NES 文件结构及 NES 开发和调试
介绍 NES 文件的基本结构以及如何自己编译运行一个 NES 程序
3. NES 卡带和 Mapper
介绍卡带的内存映射和 Mapper 的作用,以及最简单的 Mapper:Mapper0
4. CPU
介绍 NES CPU 的工作原理和设计
5. PPU(Picture Processing Unit)
介绍 NES 图像显示部分的工作原理和设计(较为复杂,可能会分成几篇文章)
6. 输入设备(手柄)
介绍 NES 输入设备的工作原理和设计
输入设备不一定要完全放在 PPU 之后开发,因为 PPU 开发到一半,你可能需要开发输入设备以实现人物的运动来验证 PPU 是否正确工作
7. APU(Audio Processing Unit)
介绍 NES 音频的工作原理和设计
8. Mapper4
介绍大型游戏常用的 Mapper4 的工作原理和设计
TIPS
这里有一些建议能帮助你的开发
  • NESDoc
    这篇文档写得比较简单,可以先浏览一遍
  • nesdev
    这里有最完善的 NES 相关资料
  • fceux
    该模拟器具有非常强大的调试功能,能够调试 CPU 指令,修改内存,查看 PPU,生成 log 等等,对于理解 NES 各部分的工作原理有非常大的帮助
  • Log 比较
    善用 fceux 的 log 功能,也可以下载一些较为简单的 NES 模拟器修改源码生成 log,与自己模拟器的 log 比较,能快速定位开发中的问题

NESDoc.rar

584.24 KB, 下载次数: 0

回复

使用道具 举报

76

主题

122

帖子

991

积分

高级会员

Rank: 4

积分
991
 楼主| 发表于 2022-9-25 07:59:29 | 显示全部楼层
本帖最后由 gameboy 于 2022-9-25 08:17 编辑

NES 模拟器开发教程 01 - NES 系统结构
NES 有以下硬件
  • CPU
    想必它的作用不用介绍了。它的型号是 2A03,8bit,工作频率 1.7897725 MHz。它有 16bit 地址总线,所以它的寻址范围为 0x0000 - 0xFFFF,即 64KB。CPU 支持三种中断:RESET(复位),NMI(不可屏蔽中断),IRQ(可屏蔽中断)
  • APU(Audio Processing Unit)
    APU 集成在了 2A03 里面,用于声音的产生和输出,所以它没有专门的芯片。它有 5 个通道,可以产生方波,三角波,噪声,PCM(你问为什么 4 种波形占了 5 个通道?因为有 2 个通道可以用于产生方波)。
  • PPU(Picture Processing Unit)
    PPU 型号为 2C02,用于产生图像。NES 中图像分两种
    • Background(背景)
      顾名思义,用于背景的显示,比如游戏的天空,草地,建筑
    • Sprite(精灵)
      用于前景的显示,例如游戏里的人物,子弹等
    最终上述两种图像组合后,输出到屏幕
  • Cartridge(卡带)/ Mapper
    这是我们最熟悉的硬件了,就是这个:
    22600211-0a2afb9ce28e258b.png

    卡带中包含了 NES 程序和图像的信息,所以不同游戏就会生产不同的卡带,CPU 和 PPU 都能直接访问卡带读取内容,至于读取的时候返回什么样的数据,这就是 Mapper 需要做的事情。有的 Mapper 扩大了程序的容量,有的 Mapper 植入了额外的芯片提升音频等等。。。
  • RAM(内存)
    NES 主机中一共有 2 个 2KB RAM,一个给 CPU 用,另一个给 PPU 用
    NES 卡带中可能不带任何 RAM,也可能带 8KB 的额外用于 CPU 的 RAM(也就是我们常见的带了纽扣电池的卡带),甚至还有的卡带还带了 2KB 的额外用于 PPU 用的 VRAM
    • 主机中 2KB RAM
      用于游戏运行数据存储
    • 主机中 2KB VRAM(Video RAM)
      看名字就知道它干嘛了,该 RAM 用于显示数据的存储,CPU 将要显示的数据写入 VRAM,然后 PPU 会读取这里面的数据解码后输出到屏幕
    • 卡带上 8KB RAM
      用于游戏额外的数据存储,最重要的是它能够保存游戏存档。这也说明了为什么我们拔了卡带上的电池后存档就没了
    • 卡带上 2KB VRAM
      PPU 工作在 4-Screen 的时候才会用到,现在不需要了解它
NES 总线
这里有一张原理图:
22600211-4717d138a5a16961.png
图中左上为 CPU,左下为 PPU,右上为卡槽
能够观察到图中有两条粗的蓝色的线,一个在上方,一个在下方
  • 上方的蓝线
    这是 CPU 的地址总线,它有 16bit,寻址范围 0x0000 - 0xFFFF
  • 下方的蓝线
    这是 PPU 的地址总线,它有 14bit,寻址范围 0x0000 - 0x3FFF
总结下来,我们知道了 NES 有 CPU 和 PPU 两类总线,他们分别有自己的寻址空间。由于卡带上既有图像数据又有程序数据,所以卡带同时接入了两根总线
NES 内存映射
了解了 NES 有两条总线之后,我们需要关注 NES 的内存映射,也就是总线上哪些地址对应了哪些数据
1. CPU 内存映射
22600211-5beca677bb85f64a.png
0x0000 - 0x0800 ( RAM )
  • 这是主机中 2KB RAM 的数据,分成了 3 块
    • 0x0000 - 0x00FF ( Zero page )
      前 256 字节划分为 Zero page,这块内存相比其他区域不同点在于能让 CPU 以更快的速度访问,所以需要频繁读写的数据会优先放入此区域
    • 0x0100 - 0x01FF ( Stack )
      这一块区域用于栈数据的存储,SP(栈指针) 从 0x1FF 处向下增长
    • 0x0200 - 0x07FF ( 剩余 RAM )
      这是 2KB 被 Zero page 和 Sack 瓜分后剩余的区域
  • 0x0800 - 0x2000 ( Mirrors )
    你可能会感觉到奇怪这个 Mirror 到底是干什么的。实际上它是 0x0000 - 0x07FF 数据的镜像,总共重复 3 次
    例如:0x0001, 0x0801, 0x1001, 0x1801 都指向了同样的数据,用程序来解释的话,就是:
    address &= 0x07FF
    对应到硬件上的话,就是 bit11 - 13 的线不接
    至于为什么任天堂要这样设计?我猜可能是考虑到成本原因,2KB RAM 够用了,不需要更大的 RAM,但是地址空间得用完啊,所以才有了 Mirror 效果
  • 0x2000 - 0x401F ( IO Registers )
    这里包含了部分外设的数据,包括 PPU,APU,输入设备的寄存器。比如 CPU 如果想读写 VRAM 的数据,就得靠 PPU 寄存器作为中介
  • 0x4020 - 0x5FFF ( Expansion ROM )
    Nesdev 的论坛上有篇解释这块区域的帖子,简单来讲,该区域用于一些 Mapper 扩展用,大部分情况用不到
  • 0x6000 - 0x7FFF ( SRAM )
    这就是之前说过的带电池的 RAM 了,该区域位于卡带上
  • 0x8000 - 0xFFFF ( Program ROM )
    这里对应了程序的数据,一般 CPU 就在这块区域中执行指令,该区域位于卡带上
2. PPU 内存映射
22600211-48219508e51ac08c.png

这个图简单看看就好了,后面介绍 PPU 的时候再详细看看
  • 0x0000 - 0x1FFF ( Pattern Tables )
    这里存放了 8KB 的图像数据,该区域位于卡带上,由 Mapper 管理着。它的作用是用来 PPU 渲染图像的时候作为参考。有的游戏里面这块区域是 RAM,由 CPU 写入图像数据
  • 0x2000 - 0x2FFF ( Name Tables )
    这里一共 4KB 数据,其中 2KB 为主机 VRAM,另外 2KB 根据游戏配置为前 2KB 的 Mirror 或者卡带上的 VRAM。这里面存放着 Pattern Table 的偏移量,以此控制屏幕显示的内容,具体在后面的 PPU 章节讨论
  • 0x3000 - 0x3EFF ( Mirrors )
    同 CPU 的 Mirror 一样
  • 0x3F00 - 0x3F1F ( Palettes )
    这里是 NES 调色板数据,用于控制图像上每个像素的颜色,具体在后面的 PPU 章节讨论
  • 0x3F20 - 0x3FFF ( Mirrors )
    同 CPU 的 Mirror 一样
NES 游戏运行大致流程
了解了各个硬件作用和内存映射后,下面可以来探讨 NES 游戏运行时的流程了,其实只是很简单的循环:
首先系统上电或者 RESET 按钮按下后,会触发 RESET 中断,CPU 从 0xFFFA 和 0xFFFB 存储的地址处(2byte)开始取指令运行(具体在 CPU 章节讨论),之后 CPU 会一直运行 0x8000 - 0xFFF9 区间的指令。在每一帧渲染之前,CPU 会读取输入设备,然后通过 PPU 寄存器往 PPU 总线上的 VRAM 写数据,同时往 APU 写数据,最终反馈到了屏幕和声音上
回复 支持 反对

使用道具 举报

76

主题

122

帖子

991

积分

高级会员

Rank: 4

积分
991
 楼主| 发表于 2022-9-25 08:01:50 | 显示全部楼层
本帖最后由 gameboy 于 2022-9-25 08:31 编辑

NES 模拟器开发教程 02 - NES 文件结构
现在几乎所有 ROM 都是 INES 文件格式,后缀名 .nes。它还有个 2.0 版本,但是我目前发现几乎所有 ROM 都是 1.0 版本,为了简单起见我们只研究 1.0
结构

INES 分为下列四个区域
22600211-b58dbed766aebb69.png
  • Header
    前 16 个字节,包含了 ROM 相关的所有信息
  • Trainer
    如果 Header 中 Trainer 的 flag 为 1,则此区域为 512 字节,否则为 0。这块区域的作用这里有讨论:http://forums.nesdev.com/viewtopic.php?t=3657,总之我们没有必要模拟它,忽略掉就好了
  • PRG
    这里存放了 NES 程序数据,即 CPU 总线上 0x8000 - 0xFFFF 的数据,具体大小在 Header 中给出
  • CHR
    这里存放了 NES 图像数据,即 PPU 总线上 0x0000 - 0x2000 的数据,具体大小在 Header 中给出
Header
Header 总共 16 字节,每个字节定义如下:
  • 0 - 3:NES
    这里是恒定的 4E 45 53 1A,对应着 ASCII 码的 'NES␚',可以用来检测是否为 NES 文件
  • 4:PRG 块数量,一块大小为 16KB
  • 5:CHR 块数量,一块大小为 8KB
  • 6:Flag
  • 76543210
  • ||||||||
  • ||||||| - Mirroring: 0: 水平镜像(PPU 章节再介绍)
  • |||||||              1: 垂直镜像(PPU 章节再介绍)
  • |||||| -- 1: 卡带上有没有带电池的 SRAM
  • ||||| --- 1: Trainer 标志
  • |||| ---- 1: 4-Screen 模式(PPU 章节再介绍)
  • ----- Mapper 号的低 4 bit
  • 7:Flag
    该 Flag 只有高 4 bit 有用,其他位暂时不需要了解
  • 76543210
  • ||||||||
  • ||||||| - VS Unisystem,不需要了解
  • |||||| -- PlayChoice-10,不需要了解
  • |||| --- 如果为 2,代表 NES 2.0 格式,不需要了解
  • ----- Mapper 号的高 4 bit
  • 8-15:不常用,不需要了解
从 Header 中可以看出,我们只需要其中的 PRG,CHR,Mapper,Mirror,Trainer 等信息,所以解析也很简单
了解了 NES 文件结构之后,就可以开始卡带的模拟了,不过模拟卡带之前,下一章会讲解如何进行 NES 开发,以便加深对 NES 的理解
回复 支持 反对

使用道具 举报

76

主题

122

帖子

991

积分

高级会员

Rank: 4

积分
991
 楼主| 发表于 2022-9-25 08:07:45 | 显示全部楼层
本帖最后由 gameboy 于 2022-9-25 11:58 编辑

NES 模拟器开发教程 03 - NES 开发和调试
了解 NES 文件结构之后,我们需要尝试 NES 的开发,以便从开发者的角度来加深对于 NES 系统的理解
注:本文当中会涉及到汇编的知识,在这里我假设你对汇编已经有一定的了解,不过即使不熟悉汇编也没有关系,只要知道它长什么样有什么用就行了
工具链
开发之前需要安装对应的工具链,由于 NES CPU 采用 6502 指令集,所以采用 cc65 作为开发工具,下载地址
安装代码工具链安装好后可以开始写代码了。首先要明确目的:目前想要在屏幕上显示 hello world 太难了,所以首先我们仅仅写一条指令:ADC #$1,它的作用是将 CPU 的寄存器 A 自增 1,只需要在调试器中看看运行效果就行了
首先,需要编写链接脚本,表明文件的内存布局:
文件 nes.cfg
MEMORY {
    RAM: start = $00, size = $800;
    NES_FILE_HEADER: start = $00, size = $10, file = %O, fill = yes;
    PRG: start = $8000, size = $7ffa, file = %O, fill = yes;
    INTERRUPT_VECTOR: start = $fffa, size = $6, file = %O, fill = yes;
}
SEGMENTS {
    NES_FILE_HEADER: load = NES_FILE_HEADER, type = ro;
    CODE: load = PRG, type = ro;
    INTERRUPT_VECTOR: load = INTERRUPT_VECTOR, type = ro;
}
这个文件不需要完全看懂,总之它定义了 NES 文件结构:
  • NES_FILE_HEADER:16 个 Header 字节
  • CODE:程序数据
  • INTERRUPT_VECTOR:中断向量表
文件 test.asm
.segment "NES_FILE_HEADER"
.byte "NES", $1A, 2

.segment "CODE"
forever:
    ADC #$1
    JMP forever

.segment "INTERRUPT_VECTOR"
    .word forever, forever, forever
可以看到 test.asm 中定义了三个段:
  • .segment "NES_FILE_HEADER":
    .byte "NES", $1A, 2 表示文件开头的 5 个字节,分别为 'N', 'E', 'S', 0x1A, 0x02,前面 4 个为 NES 文件标识,最后一个 2 定义了 PRG 大小为 2 x 16K = 32K
  • .segment "CODE":
    这里非常简单,一条死循环的 ADC 指令
  • .segment "INTERRUPT_VECTOR":
    这里定义了中断向量表,分别对应了三个中断:RESET,NMI,IRQ,这里的值表示了所有的中断都会跳转到 ADC 指令处
编译
ca65 test.asm -o test.o # 编译
ld65 -C nes.cfg test.o -o test.nes # 链接
最终会生成 test.nes,hexdump 查看编译后文件如下
$ hexdump test.nes
0000000 4e 45 53 1a 02 00 00 00 00 00 00 00 00 00 00

000000010 69 01 4c 00 80 00 00 00 00 00 00 00 00 00 00
000000020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
*
0008000 00 00 00 00 00 00 00 00 00 00 00 80 00 80 00
800008010
第一行刚好对应了 'N', 'E', 'S', 0x1A 和 0x02 的 PRG Bank 数量
第二行的 69 01 对应了 ADC #$1,4C 00 80 对应了 JMP forever
第三行最后 6 个 byte 00 80 00 80 00 80 对应中断向量表,由于 6502 是小端模式,低地址在前,所以 0x8000 在内存中存储为 00 80,这里 3 个 00 80 表示所有中断都跳转到 0x8000 处(也就是 ADC 所在位置)
调试
打开 Fceux,加载刚刚编译的文件,打开 Debugger,在 0x8000 处添加一个断点
22600211-48d92db910eaf71f.png

然后点击 POWER 模拟开机
22600211-aed59ea6b85a413a.png
之后可以看到程序运行到了断点处,点击 Step Into 就能看见寄存器 A 在自增
22600211-5e80a024156cf802.png
至此我们已经掌握了 NES 的开发和调试,相信你对 NES 系统和文件结构有了更深刻的理解

cc65-snapshot-win32.zip

10.99 MB, 下载次数: 0

回复 支持 反对

使用道具 举报

76

主题

122

帖子

991

积分

高级会员

Rank: 4

积分
991
 楼主| 发表于 2022-9-25 10:01:29 | 显示全部楼层
本帖最后由 gameboy 于 2022-9-25 10:12 编辑

NES 模拟器开发教程 04 - NES 卡带(Cartridge)
04 章的时候了解了 NES 文件结构,NES 文件本质上只是从卡带上 dump 下来的信息,所以首先需要实现卡带的模拟,把程序等数据加载进内存后,才能进行下一步操作
Mapper

Mapper 已经在前面的文章出现过很多次了,现在来详细了解一下
03 篇文章提到过,NES 总线上,PRG 的寻址范围为 0x8000 - 0xFFFF,CHR 寻址范围为 0x0000 - 0x2000,他们大小分别为 32K 和 8K,对于大型游戏这么点空间是远远不够的,显然任天堂在设计的时候也考虑到了这一点,所以采用 Mapper 来进行各种扩展
Mapper 并不位于主机中,而位于卡带上。每一张卡带都对应了一种 Mapper,在 NES 1.0 格式中,可以表示多达 256 种 Mapper
每种 Mapper 行为都不一样,比如有的 Mapper 增加了音频芯片提高音频性能,有的 Mapper 增加了寄存器扩展程序大小,有的还有中断计数的功能
比如 Mapper2,增加了 Bank 选择寄存器,以控制不同的 Bank 映射到 0x8000 - 0xFFFF,这样就扩充了游戏容量
Mapper0

了解 Mapper 作用之后,首先看看最简单的 Mapper - Mapper0,这个 Mapper 没有任何的扩容能力,所以它的 ROM 最大只有 32K 8K = 40K
唯一需要注意的是:
  • 如果游戏容量只有 16K,那么 PRG 位于 0xC000 - 0xFFFF,同时 0x8000 - 0xBFFF 为 0xC000 - 0xFFFF 的镜像
  • 如果容量为 32K,那么 RPG 则会塞满 0x8000 - 0xFFFF
API

了解了 Mapper 作用后,就可以开始定义 API 了
首先定义 ROMInfo 接口,表示从 NES 文件解析的 Header 信息
export enum Mirror {
  HORIZONTAL,
  VERTICAL,  FOUR_SCREEN,
  SINGLE_SCREEN_LOWER_BANK,
  SINGLE_SCREEN_UPPER_BANK,
}

export interface IROMInfo {
  prg: number; // 16KB unit
  chr: number; // 8KB unit
  mapper: number; // mapper number
  mirror: Mirror;
  hasBatteryBacked: boolean;
  isTrained: boolean;
Mirror 先不需要理解它的意思,后面介绍 PPU 时才会用到
另外还需要一个 Mapper 对象,用来表示不同的 Mapper。对于 CPU 来讲,只需要在意 Mapper 的读写能力,所以需要定义 write 和 read 接口
export interface IMapper {
  read(address: uint16): uint8;
  write(address: uint16, data: uint8): void;
}
最后,Cartridge 当中只需要 Info 和 Mapper 两个实例就够了
export interface ICartridge {
  readonly info: IROMInfo;
  readonly mapper: IMapper;
}
Cartridge 模拟

在这里举个例子讲一下 Cartridge 如何模拟(只列举核心逻辑,其他简单的逻辑就等你自己完成了)
一共需要两个类,一个 Cartridge,一个 Mapper0
文件 cartridge.ts
export class Cartridge implements ICartridge {
  public readonly mapper: IMapper;
  public readonly info: IROMInfo = {} as any;

  constructor(
    data: Uint8Array, // NES 文件数据
    sram: Uint8Array, // 卡带上的 8K SRAM,用于游戏存档,前期模拟简单游戏时可以不用实现它
  ) {
    ... // 解析 NES 文件头到 info 中

    prg: Uint8Array = ... // 解析 PRG 数据
    chr: Uint8Array = ... // 解析 CHR 数据

    switch (this.info.mapper) {
      case 0:
        this.mapper = new Mapper0(this, sram, prg, chr);
        break;
      default:
        throw new Error(`Unsupported mapper: ${this.info.mapper}`);
    }
  }
文件 mapper0.ts
export class Mapper0 implements IMapper {
  private readonly isMirrored: boolean;
  constructor(
    private readonly ram: Uint8Array, // 卡带上的 8K SRAM,用于游戏存档,前期模拟简单游戏时可以不用实现它
    private readonly prg: Uint8Array, // 程序数据
    private readonly chr: Uint8Array, // 图像数据
  ) {
    this.isMirrored = prg.length === 16 * 1024; // 程序大小只有 16K 则需要镜像
    if (chr.length === 0) {
      // 如果 CHR 为 0 的话,说明卡带上的 CHR 为 RAM 而非 ROM
      this.chr = new Uint8Array(0x2000);
    }
  }

  public read(address: uint16): uint8 {
    address &= 0xFFFF;
    if (address < 0x2000) {
      // CHR
      return this.chr[address];
    } else if (address >= 0x8000) {
      // PRG
      return this.prg[(this.isMirrored ? address & 0xBFFF : address) - 0x8000];
    } else if (address >= 0x6000) {
      // SRAM
      return this.ram[address - 0x6000];
    }
    return 0;
  }
  public write(address: uint16, data: uint8): void {
    address &= 0xFFFF;
    if (address < 0x2000) {
      // CHR
      this.chr[address] = data;
    } else if (address >= 0x8000) {
      // PRG
      this.prg[(this.isMirrored ? address & 0xBFFF : address) - 0x8000] = data;
    } else if (address >= 0x6000) {
      // RAM
      this.ram[address - 0x6000] = data;
    }
  }
}
回复 支持 反对

使用道具 举报

76

主题

122

帖子

991

积分

高级会员

Rank: 4

积分
991
 楼主| 发表于 2022-9-25 10:12:45 | 显示全部楼层
本帖最后由 gameboy 于 2022-9-25 10:15 编辑

NES 模拟器开发教程 05 - Emulator
API 定义API 定义
后面马上就要进行 CPU 的开发了,在那之前先定义一下顶层 API 的行为
首先想象一下模拟器需要的组件:
  • 控制输入设备
  • 画面输出设备
  • 音频输出设备
  • 文件载入
通过这些定义,就可以构造出这样的 API(为了简单起见先不定义控制器):
export interface IOptions {
  sampleRate: number;
  onSample: (volume: number) => void;
  onFrame: (frame: Uint8Array) => void; // frame: [R, G, B, R, G, B, ...] 256 * 240 * 3 = 184320 byes
}

export interface IEmulator {
  clock(): void;
}
首先声音输出用 onSample 实现,根据传入的采样率,每隔固定时间调用 onSample 以改变音量
其次画面输出用 onFrame,当 NES 渲染完一帧之后,调用 onSample 并传入一个 RGB 数组,用于图像绘制
最后 clock 函数为一个 CPU 时钟,调用一次则喂给 CPU 一个时钟
实现
NES 文件数据和 Option 可以由构造函数传入,并且在构造函数中初始化各个组件,例如:
export class Emulator implements IEmulator {
  constructor(nesData: Uint8Array, options?: IOptions) {
    ... // 解析 options

    ... // 初始化各个组件
  }

  public clock(): void {
    ... // 调用 CPU,PPU,APU 的 clock
  }
}
回复 支持 反对

使用道具 举报

76

主题

122

帖子

991

积分

高级会员

Rank: 4

积分
991
 楼主| 发表于 2022-9-25 10:16:26 | 显示全部楼层
本帖最后由 gameboy 于 2022-9-25 10:21 编辑

NES 模拟器开发教程 06 - CPU BUS
API
之前讲过 NES 有 CPU 和 PPU 两条总线,总线使 CPU 或 PPU 具备了与其他模块通信的能力,所以设计 CPU 之前首先需要设计 CPU 总线,好在它并不复杂,不管是 CPU 还是 PPU 总线,只需要 读 和 写 两个接口
export interface IBus {
  writeByte(address: uint16, data: uint8): void;
  writeWord(address: uint16, data: uint16): void;
  readByte(address: uint16): uint8;
  readWord(address: uint16): uint16;
}
这里设计区分了 byte 和 word 主要是为了调用的时候方便,其实 word 的 api 完全可以用 byte 的 api 进行封装,比如:
public readWord(address: uint16): uint16 {
  return (this.readByte(address 1) << 8 | this.readByte(address)) & 0xFFFF;
}
实现
NES 模拟器开发教程 01 - NES 系统结构 中,已经介绍过了 CPU 内存映射。CPU BUS 的实现,就是根据内存映射去读写不同的硬件
export class CPUBus implements IBus {
  public cartridge: ICartridge;

  private readonly ram = new Uint8Array(2048);

  public writeByte(address: uint16, data: uint8): void {
    if (address < 0x2000) {
      // RAM
      this.ram[address & 0x07FF] = data;
    } else if (address < 0x6000) {
      // IO Registers, 暂时不实现
    } else {
      // Cartridge
      this.cartridge.mapper.write(address, data);
    }
  }

  public writeWord(address: uint16, data: uint16): void {
    this.writeByte(address, data & 0xFF);
    this.writeByte(address 1, (data >> 8) & 0xFF)
  }
  public readByte(address: uint16): uint8 {
    if (address < 0x2000) {
      // RAM
      return this.ram[address & 0x07FF];
    } else if (address < 0x6000) {
      // IO Registers, 暂时不实现
      return 0;
    } else {
      // ROM
      return this.cartridge.mapper.read(address);
    }
  }

  public readWord(address: uint16): uint16 {
    return (this.readByte(address 1) << 8 | this.readByte(address)) & 0xFFFF;
  }
}

初始化
Cartridge 和 CPU BUS 都实现后,需要在 Emulator 构造函数中初始化:
const cartridge = new Cartridge(nesData, new Uint8Array(8192));
const cpuBus = new CPUBus();
cpuBus.cartridge = cartridge; // 将 Cartridge 和 CPU BUS 关联起来
这样初始化之后,就可以通过 CPU BUS 读取总线上的任意数据了,例如读取 RESET 中断向量指向的地址:
const resetAddress = cpuBus.readWord(0xFFFA);
回复 支持 反对

使用道具 举报

76

主题

122

帖子

991

积分

高级会员

Rank: 4

积分
991
 楼主| 发表于 2022-9-25 10:22:52 | 显示全部楼层
本帖最后由 gameboy 于 2022-9-25 10:33 编辑

NES 模拟器开发教程 07 - CPU
1. CPU 基本原理

CPU 的本质,只有两件事:
  • 什么时候执行什么指令
  • 什么时候读写什么地址的数据
CPU 有一个时钟作为输入源,该时钟实际上只是一个频率很高的脉冲波,一般几 M 到 几 GHZ。传统的 CPU 会在一个到多个时钟期间执行完一条指令,然后再执行下一条指令。如果某一时刻产生了中断,CPU 会读取中断向量表对应中断地址,等到当前指令执行完后切换到该地址处继续执行
2. NES CPUNES CPU 为 6502 指令集,型号为 RP2A03,CPU 时钟 1.79 MHz,CPU 内存映射已经在 NES 模拟器开发教程 01 讲过了,这里就不再赘述了
2.1 寄存器

2A03 有 6 个寄存器:A,X,Y,PC,SP,P,除了 PC 为 16bit 以外,其他全都是 8bit
  • A
    通常作为累加器
  • X,Y
    通常作为循环计数器
  • PC
    程序计数器,记录下一条指令地址
  • SP
    堆栈寄存器,其值为 0x00 ~ 0xFF,对应着 CPU 总线上的 0x100 ~ 0x1FF
  • P
    标志寄存器比较麻烦,它实际上只有 6bit,但是我们可以看成 8bit
    BIT
    名称
    含义
    0
    C
    进位标志,如果计算结果产生进位,则置 1
    1
    Z
    零标志,如果结算结果为 0,则置 1
    2
    I
    中断去使能标志,置 1 则可屏蔽掉 IRQ 中断
    3
    D
    十进制模式,未使用
    4
    B
    BRK,后面解释
    5
    U
    未使用,后面解释
    6
    V
    溢出标志,如果结算结果产生了溢出,则置 1
    7
    N
    负标志,如果计算结果为负,则置 1
    刚说过标志寄存器只有 6 bit,这是因为 B 和 U 并不是实际位,只不过某些指令执行后,标志位 push 到 stack 的时候,会附加上这两位以区分中断是由 BRK 触发还是 IRQ 触发,下面是详细解释
    指令或中断
    U 和 B 的值
    push 之后对 P 的影响
    PHP 指令
    11
    BRK 指令
    11
    I 置 1
    IRQ 中断
    10
    I 置 1
    MNI 中断
    10
    I 置 1
    可能光看表格也看不懂,下面用伪代码举个例子
    function brk() {
      // 保存 PC
      push(PC & 0xFF);
      push(PC >> 8 & 0xFF);
      
      // 保存 P
      push(P | Flag.B | Flag.U);

      // 设置 P
      P |= Flag.I;

      // 从中断向量表获取对应中断地址
      PC = ...
    }
2.2 中断
2A03 支持 3 种中断:
  • RESET
    复位中断,RESET 按钮按下后或者系统刚上电时产生
  • NMI
    不可屏蔽中断,该中断不能通过 P 的 I 标志屏蔽,所以它一定能触发。比如 PPU 在进入 VBlank 时就会产生 NMI 中断
  • IRQ
    可屏蔽中断,如果 P 的 I 标志置 1,则可以屏蔽该中断,同时也可以通过 BRK 指令由软件自行触发
产生中断时,CPU 会将 PC 和 P 压栈,之后 CPU 读取中断向量表对应地址,赋给 PC,同时设置相应的标志位。当程序执行 RTI(中断返回) 后,CPU 将 P 和 PC 出栈,恢复 P 和 PC,从中断产生前的地址处继续执行
中断可以理解为一个函数调用,区别在于该函数可以通过其他硬件随时通过中断来调用
中断向量表:

中断向量表位于 0xFFFA ~ 0xFFFF,共 6 字节,分别对应 3 个中断:
  • 0xFFFA, 0xFFFB: NMI 中断地址
  • 0xFFFC, 0xFFFD: RESET 中断地址
  • 0xFFFE, 0xFFFF: IRQ 中断地址
2.3 指令集

指令集可以参考这两个地址:
指令阵列如图:
22600211-f62dabb39d607b64.png
里面包含了 56 种官方指令和一些非官方指令
每个方块包含了指令,寻址模式,执行周期,例如:
22600211-28e49277b91dc679.png
表示:LDY 指令,立即寻址,执行完需要 2 个 CPU 时钟
有些方块执行周期后面有个 * 号,这说明该指令在某些情况下需要额外增加 1 ~ 2 个时钟才能完成,具体等介绍完寻址模式再解释
2.4 寻址模式

2A03 支持 13 种寻址模式:
寻址模式
含义
数据长度
例子
说明
Implicit
特殊指令的寻址方式
0
CLC
清除 C 标志
Accumulator
累加器 A 寻址
0
LSR A
A 右移一位
Immediate
指定一个字节的数据
1
ADC #$1
A 增加 1
Zero Page
指定 Zero Page 地址
1
LDA $00
将 0x0000 的值写入 A
Zero Page,X
指定 Zero Page 地址加上 X
1
STY $10,X
将 0x0010 X 地址上的值写入 Y
Zero Page,Y
指定 Zero Page 地址加上 Y
1
LDX $10,Y
将 0x0010 X 地址上的值写入 X
Relative
相对寻址
1
BEQ LABEL
如果 Z 标志置位则跳转到 LABEL 所在地址,跳转范围为当前 PC 的 -128 ~ 127
Absolute
绝对寻址
2
JMP $1234
跳转到地址 0x1234
Absolute,X
绝对寻址加上 X
2
STA $3000,X
将 0x3000 X 地址值写入 A
Absolute,Y
绝对寻址加上 Y
2
STA $3000,Y
将 0x3000 Y 地址值写入 A
Indirect
间接寻址(只有 JMP 使用)
2
JMP ($FFFC)
跳转到 0xFFFC 地址上的值表示的地址处
Indexed Indirect
变址间接寻址
1
LDA ($40,X)
首先 X 的值加上 0x40,得到一个地址,再以此地址上的值作为一个新地址,将新地址上的值写入 A
Indirect Indexed
间接变址寻址
1
LDA ($40),Y
首先获取 0x40 处存储的值,将该值与 Y 相加,得到一个新地址,然后将该地址上的值写入 A
2.5 额外的时钟

前面提到过在指令阵列图中带 * 的需要额外的时钟,有两种情况会额外增加时钟
  • 分支指令进行跳转时
    分支指令比如 BNE,BEQ 这类指令,如果检测条件为真,这时需要额外增加 1 个时钟
  • 跨 Page 访问
    新地址和旧地址如果 Page 不一样,即 (newAddr & 0xFF00) !== (oldAddr & 0xFF00),则需要额外增加一个时钟。例如 0x1234 与 0x12FF 为同一 Page,但是与 0x1334 为不同 Page
以上两种情况可以同时存在,所以一条指令可能会额外增加 1 ~ 2 个时钟
2.6 BUG
NES 硬件存在一些 BUG,这里讲讲间接寻址时的 BUG,因为有些测试 ROM 会测试该行为
间接寻址时,设地址为 x,page 起始地址为 n(即 n = x & 0xFF00),若 x 低 8 位为 0xFF,则寻址地址高位为 n 上的值,低位为 x 上的值,一句话概括,就是地址无法跨 Page
下面举个例子:
Address: 0x0200 0x0201 ... 0x02FF
Value:    0x01   0x02  ...  0x03
针对上述内存,执行 JMP (0x02FF),则会跳转到 0x0103 处
正常情况下,执行 JMP (0x0200),则会跳转到 0x0201 处
3. API 设计
CPU 工作时,以时钟作为输入源,每个几个时钟执行一条指令,同时有三个中断的输入源:RESET,NMI,IRQ,所以 CPU 一共只需要 4 个方法:
export interface ICPU {
  clock(): void;
  reset(): void;
  irq(): void;
  nmi(): void;
}
CPU 有 6 个寄存器,还需要定义寄存器接口:
export enum Flags {
  C = 1 << 0, // Carry
  Z = 1 << 1, // Zero
  I = 1 << 2, // Disable interrupt
  D = 1 << 3, // Decimal Mode ( unused in nes )
  B = 1 << 4, // Break
  U = 1 << 5, // Unused ( always 1 )
  V = 1 << 6, // Overflow
  N = 1 << 7, // Negative
}

export interface IRegisters {
  readonly PC: uint16;
  readonly SP: uint8;
  readonly P: uint8;
  readonly A: uint8;
  readonly X: uint8;
  readonly Y: uint8;
}
4. 实现
CPU 对外获取数据的通道为 CPU BUS,所需 CPU 类中需要存储 CPU BUS 实例
CPU 执行指令所需周期数不等,所以需要 deferCycles 表示 CPU 还需要多少个 clock 才能执行下一条指令
下面以最简单的 JMP 举一个例子:enum InterruptVector {
  NMI = 0xFFFA,
  RESET = 0xFFFC,
  IRQ = 0xFFFE,
}

export class CPU implements ICPU {
  public bus: IBus;

  private deferCycles = 0;
  private readonly registers = new Registers();

  public reset(): void {
    this.registers.A = 0;
    this.registers.X = 0;
    this.registers.Y = 0;
    this.registers.P = 0;
    this.registers.SP = 0xfd;
    this.registers.PC = this.bus.readWord(InterruptVector.RESET);

    this.deferCycles = 8;
    this.clocks = 0;
  }

  public clock(): void {
    if (this.deferCycles === 0) {
      this.step();
    }

    this.deferCycles--;
  }

  public irq(): void {
    if (this.isFlagSet(Flags.I)) {
      return;
    }

    this.pushWord(this.registers.PC);
    this.pushByte((this.registers.P | Flags.U) & ~Flags.B);

    this.setFlag(Flags.I, true);

    this.registers.PC = this.bus.readWord(InterruptVector.IRQ);

    this.deferCycles = 7;
  }

  public nmi(): void {
    // 和 IRQ 大体相同,这里就不重复写了
  }

  private step(): void {
    const opcode = this.bus.readByte(this.registers.PC );

    switch (opcode) {
      case 0x4C:
        // JMP abs 3
        const address = this.bus.readWord(this.registers.PC);

        this.registers.PC = address;
        this.deferCycles = 3;
        break;
      default:
        throw new Error(`Invalid opcode ${opcode}`);
    }
  }
}
用 switch case 虽然在编译器优化的情况下效率不会降低,但是代码看起来不简洁,这里为了解释起来简单采用了 switch case,实际上项目中最好用数组或对象存储映射关系
5. TIPS

CPU 介绍得差不多了,其他指令也都千篇一律,参考 6502 指令集就能写出来了,实在不清楚的参考下其他模拟器代码就行
最后提醒下有个测试文件对于 CPU 开发非常有帮助:
下载地址:http://www.qmtpro.com/~nes/misc/
下载这 2 个文件:nestest.log 和 nestest.nes
开发 CPU 时,先将 PC 设置为 0xC000,再让 CPU 运行,每运行一条指令后,和它的 nestest.log 对照一下各个寄存器状态,这样能按照它的 log 顺序逐条开发出正确的指令,比一口气开发完再去 debug 好得多
回复 支持 反对

使用道具 举报

76

主题

122

帖子

991

积分

高级会员

Rank: 4

积分
991
 楼主| 发表于 2022-9-25 10:34:57 | 显示全部楼层
本帖最后由 gameboy 于 2022-9-25 10:35 编辑

NES 模拟器开发教程 08 - PPU 简介
PPU 是 Picture Processing Unit 的简称,是 NES 中最复杂的一部分。顾名思义,PPU 用来处理和图像相关的内容,同时负责图像输出到电视上
1. 图像分类

在 NES 中,图像分为两大类:背景(background)和 精灵(sprite)
背景

如同字面的意义,游戏中的蓝天,草地,房子都可以做为背景,背景就是一张图像
精灵

精灵一般是游戏角色,怪物等运动的图像,比如马里奥中的主角,炮弹,蘑菇头
2. 编码

由于 NES 年代比较久远,那时候内存价格昂贵,cpu 计算能力也不行,所以 PPU 有一套自己的编码格式,并非简单的颜色点阵数据或者 JPG 编码等格式。同时这也是模拟 PPU 的难点所在
图像布局

NES 图像分辨率为 256 x 240,假设每个像素以 RGB 存储,则一张图像需要 256 * 240 * 3 = 180 KB。这已经远远超出 PPU 和 CPU 的寻址空间(64KB)了。但是 NES 却把一张图像编码到了 1KB 大小,不得不感叹那个资源匮乏的年代硬件工程师们的能力
PPU 提供了 4KB(实际上只有 2KB,另外 2KB 是镜像,只有少部分特殊的卡带才会额外提供 2KB) 的 RAM 做为图像存储,如下图
22600211-733899bf140979a3.png
他们组成一个田字形的布局,外加上 PPU 提供的滚动寄存器,就可以实现背景运动的效果了
调色板(palette)

NES 为了节省内存,并非支持所有颜色,而是有一套自己的映射,一共 64 种,如下图
22600211-087cce984f44bd1a.png
但是每个像素点不会直接使用颜色数据,而是使用了调色板做为映射
  • 背景调色板位于 0x3F00 - 0x3F0F
  • 精灵调色板位于 0x3F10 - 0x3F1F
背景和精灵的调色板分别占 16 个字节,每个字节对应了颜色的映射,每个像素点又索引调色板,这样每个像素点只需要 4bit,相比直接索引颜色会少用 2bit,而相比 RGB 的 24bit,那更少了。
使用调色板之后,一副图像被压缩到了 256 * 240 * 0.5 = 30KB,但是还不够小。所以 PPU 并没有直接编码图像的调色板偏移数据,而是将图像数据的偏移也编码了起来,听起来很绕,具体在 PPU 背景章节再详细讲
3. 时序

以前的电视都用的电子枪,图像的输出都是电子从左到右,从上到下扫描的结果,所以这也是为什么现在屏幕坐标都是左上为坐标原点,x 轴向右增长,y 轴向下增长的原因。
NES 每帧有 0 - 261 共 262 条扫描线,每条扫描线有 0 - 340 共 341 个时钟,每一时钟对应一个像素点。由于该范围大于 NES 输出的分辨率(256 x 240),所以只有扫描线 0 - 239 的 1 - 256 周期可见(每条扫描线的 0 时钟为空闲时钟)。
为什么扫描范围要大于 NES 分辨率?因为(1, 241)到 (1,261)期间为垂直消隐(VBlank),这期间可以给 CPU 留出空隙更新显示数据,(1,241)时会设置 VBlank 标志,(1,261)时清除。
下图为 PPU 的时序:
22600211-c6354bef317c9757.png
看不清的话直接访问原地址:https://wiki.nesdev.com/w/images/4/4f/Ppu.svg
当然,也正式因为这种扫描方式,给 PPU 模拟造成了很多麻烦。对于该时序的实现程度也会影响到模拟器对游戏的兼容性
回复 支持 反对

使用道具 举报

76

主题

122

帖子

991

积分

高级会员

Rank: 4

积分
991
 楼主| 发表于 2022-9-25 10:36:44 | 显示全部楼层
本帖最后由 gameboy 于 2022-9-25 14:05 编辑

NES 模拟器开发教程 09 - PPU 背景
前一节讲过 PPU 分为背景和精灵两个部分,这一节介绍 PPU 背景渲染方法
1. 概念
在了解渲染方式之前,需要接触 PPU 中的几个概念:
  • Pattern table
    位于 PPU 总线的 0x0000 - 0x1FFF,一共 8KB 的数据,
    它保存了图像数据,同时可以分为两个 4KB 的区块,可以由程序控制 backgroud 或者 sprite 使用前 4KB 还是后 4KB
  • Name table
    位于 PPU 总线的 0x2000 - 0x2FFF,之前讲过了,它控制着背景图像,一共 4KB,分 4 块,每块 1KB。其中 2KB 由 NES 主机提供,另外 2KB 可以由卡带提供,或者做为主机提供 2KB RAM 的 mirror。比如,垂直镜像如图所示:
    22600211-d32f16f41bf49e0e.png
  • Name table 数据做为 Pattern table 的索引,以此达到压缩数据的目的
    另外需要注意的是,每一块 Name table 只有 960 字节,而非 1KB,剩下的 64 字节为下面要介绍的 Attribute table
  • Attribute table
    Attribute table 跟在每个 Name table 之后,每个 Attribute table 有 64 字节
    Attribute table 控制着当前图像高 2bit 的 palette 偏移量,而 Name table 索引到 Pattern table 数据之后,控制着低 2bit 的 palette 偏移量。两个加起来刚好 4bit,前一章节讲过,背景和精灵分别有 16 个 palette ,刚好对应了 4bit
2. 渲染
我们之前了解过,PPU VRAM 一共 4KB,每 1KB 就控制着一张图像,也就是说,另外 3KB 图像始终是不会输出到屏幕上的。同时这 4 张图像以田字格的形式组合,再结合 PPU 的滚动功能,就能实现背景画面的上下左右移动
下面针对每一帧图像(每一块 VRAM),看看 PPU 是如何渲染的:
NES 以 8 x 8 像素为单位,将图片分成了 32 x 30 个小块,每一个小块称为一个瓦片(tile),同时,每 16 个 tile 组成一个大块,如下图:
22600211-67b07f68878756a8.png
每一个 tile 在 vram 中用一个 byte 表示,该字节表示了当前 tile 在 pattern table 中的偏移量,也就表示像素的数据。pattern table 以 16 bytes 为单位,每 16 bytes 中,有分前 8 bytes 和后 8 bytes。前后每 8 bytes 表示 tile 中每一行的 像素所处 palette 的低 2 bit,前 8 bytes 为 bit0,后 8 byts 为 bit 1
颜色高 2bit 由 attribute table 表示,attribute table 中每一个字节能管理 16 个 tile 的颜色,如上图中红色的大块。16 个 tile 中,每 4 个 tile 整合到一起,再和另外 12 个 tile 组成田字形格局,分别为 左上,右上,左下,右下。每个占 2 bit,刚好 8 bit
这里也能看到 NES 画面表现力不足,即每 4 个 tile 组成的部分田字格中,他们只能有 4 种不同的颜色,因为高 2 bit 固定了,剩下可变了只有低 2 bit
现在总结一下:图像分成了 32 x 30 = 960 个 tile,每个 tile 在 name table 占前 960 字节。同时 tile 在 vram 中表示 pattern table 0 - 255 的偏移量,pattern table 又以 16 bytes 为一个单位,那么总共需要 256 * 16 = 4KB 大小的 pattern table。前面讲过,pattern table 一共 8KB,可分为两个 4KB 分别给 background 或者 sprite 使用,是不是刚刚好?另外 16 个 tile 组成的大的 tile 中,每个由 attribute table 的一个字节表示,一共需要 8 x 8 = 64 bytes。加上 name table 的 960,刚好 64 960 = 1024 字节,即 1KB VRAM
通过如此巧妙的设计,硬生生的将一个 320 x 240 的画面压缩到了 1KB,不得不服!
3. 举例
前面说这么多,不如找个例子看看,以 马里奥 1 为例:
点开 Name table viewer:
22600211-c422d8b5fea11f96.png
以左上角的 'M' 为例,看看内存中的数据
注意 fceux 默认情况下会去除顶部和底部的 8 个像素,具体可以在显示里面设置关掉,或者直接在 Name table viewer 中查看
左上角的 'M' 数一下,大概是第 3 行第 3 列 tile,那么它在 VRAM 中 offset 为 32 * 2 3 = 0x43。并且位于左上角的 VRAM,那么 Name table 起始地址为 0x2000,最终 'M' 的地址为 0x2043,查看下改地址的数据:
22600211-3fbe85b73bdbe6ad.png
数据为 0x16,那么对应 pattern table offset 为 0x16 * 16 = 0x0160。下面需要计算 pattern table 的起始地址,之前讲过,background 和 sprite 可以分别设置为 pattern table 的前 4k 或者后 4k,这个通过 PPU 寄存器 PPUCTRL(0x2000)的 bit 3-4 控制,background 则为 bit4,具体可见:http://wiki.nesdev.com/w/index.php/PPU_registers#PPUCTRL
。那么要知道起始地址,则要先读取 PPUCTRL 寄存器的值,切换到 NES memory,跳转到 0x2000:
22600211-ab60d815f7abad86.png
看到值是 0x90,bit 4 为 1,根据寄存器定义:
22600211-7174b54177e139d8.png
得知 background 使用的是后 4KB,则 'M' 在 pattern table 中真实起始地址为:0x1000 0x0160 = 0x1160,切换回 PPU memory,跳转到该地址看看:
22600211-69eefb6c5f93ecd4.png

之前说过 pattern table 以 16 bytes 为一组,所以 1160 这一排都是 M 的数据,我们分为 2 个 8 byes 写成二进制看看:
# 前 8 bytesc6: 1 1 0 0 0 1 1 0ee: 1 1 1 0 1 1 1 0fe: 1 1 1 1 1 1 1 0fe: 1 1 1 1 1 1 1 0d6: 1 1 0 1 0 1 1 0c6: 1 1 0 0 0 1 1 0c6: 1 1 0 0 0 1 1 0# 后 8 bytes00: 0 0 0 0 0 0 0 000: 0 0 0 0 0 0 0 000: 0 0 0 0 0 0 0 000: 0 0 0 0 0 0 0 000: 0 0 0 0 0 0 0 000: 0 0 0 0 0 0 0 000: 0 0 0 0 0 0 0 000: 0 0 0 0 0 0 0 0
前面 8 bytes 的 1 组合起来是不是有点像 M?
之前说过,前 8 bytes 表示 bit 0 的颜色数据,后 8 bytes 表示 bit 1 的颜色数据,前 2 bit 则需要从 attribute table 中获取,第 2 行第 3 列刚好位于 attribute 0 的右下,attribute table 紧跟在 name table 的 960 bytes 之后,则 attribute 0 数据为 0x2000 960 = 0x23c0,具体数据为 0xAA:
22600211-8d709ea5eb688a08.png
右下角为 bit 0-1,则高 2 bit 为 10
那么整合起来,M 中的背景色在 palette 中偏移量为 0b1000 = 8,前景色在 palette 中偏移量为 0b1001 = 9
之前讲过,background palette 位于 0x3F00
注意:palette 以 4 个 bytes 为单位,0 号 byte 是共用的,比如 0x3F00 为 0x12,则 0x3F04, 0x3F08, 0x3F0C 都是 0x12,所以只要遇到 palette 地址 % 4 为 0 的时候,直接取 0x3F00 或者 0x3F10 的值就行了。同时 0 号 byte 也表示透明色,这个在后面介绍 sprite 优先级的时候会遇到
那么对于 M 的颜色,内存中数据为:
22600211-e46172da3f3367d6.png

  • 背景色:0x22
  • 前景色:0x30
    对照这张表看看:
    22600211-16b28c287f0f2813.png

    刚好是蓝色底,白色字,和 'M' 的显示一模一样
4. PPU 滚动
之前介绍的都是静态的情况,实际上游戏过程中画面都是运动的,这就靠 PPU 滚动来完成
之前说过,PPU 一共 4 个 1KB 的 VRAM,他们组成田字布局,把屏幕想像成窗口,PPU 滚动的时候就相当于窗口在田字格上滑动,类似于这种效果:
22600211-b816ee5c803d7aa7.gif

以 NTFS 为例,图像刷新率为 60fps,PPU 会在 1s 内产生 60 次中断告知 CPU 刷新图像(这对应了之前介绍的垂直消隐), CPU 会设置 PPUSCROLL 寄存器以更新图像位置,一次达到图像运动的目的。具体可以在 fceux 中打开 Name table viewer 很形象地看到
具体在 PPU 还有 v,t,x,w 几个内部的寄存器用于滚动,这里做为一个抛砖引玉,具体要介绍起来篇幅就太长了,建议直接看 NDEDEV 上的这篇文章:http://wiki.nesdev.com/w/index.php/PPU_scrolling
里面详细介绍了 PPU 滚动机制,甚至伪代码都帮你写好了
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

QQ|Archiver|手机版|小黑屋|shangbanzu.xyz

GMT+8, 2025-5-9 05:09 , Processed in 0.090499 second(s), 24 queries .

Powered by Discuz! X3.4

© 2001-2013 Comsenz Inc.. 技术支持 by 巅峰设计

快速回复 返回顶部 返回列表