从零开始的nes模拟器-[2] 卡带和Mapper
Nes-卡带
nes
游戏机本身是不附带游戏的,如果想玩某款游戏,就要买对应的游戏卡带。然后再把卡带插到机器上,才能玩。电脑不能插卡带,但是我们可以通过.nes
文件来加载游戏。
nes
文件本质上只是从卡带上 dump
下来的信息,所以我们可以通过读取nes
文件的信息来模拟一个卡带。
Nes文件格式
ref: NES 模拟器开发教程 02 - NES 文件结构
INES 分为下列四个区域
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 等信息,所以解析也很简单
API 卡带( 部分 )
看完上面header的解析之后,我们知道了模拟卡带需要的信息。
// 墨盒对外暴露的接口只有连接cpu 和 连接ppu 的接口
// 墨盒内部存储的信息有 Program Memory 和 Pattern Memory (又称 character memory)
class Cartridge
{
public:
explicit Cartridge(const std::string& sFileName);
~Cartridge();
public:// 辅助函数,判断帧是否渲染完成
bool ImageValid();
private:
bool bImageValid = false;
private:
// 存储mirror 方式 , 这个 enum 定义在 mapper.h
MIRROR hw_mirror = HORIZONTAL;
// 记录 nes 文件信息
uint8_t nMapperID = 0; // Mapper 类型
uint8_t nPRGBanks = 0; // 程序的 bank 数
uint8_t nCHRBanks = 0; // CHR rom bank 数
// 用不定长数组存储 progrom rom 和 charatic rom
std::vector<uint8_t> vPRGMemory;
std::vector<uint8_t> vCHRMemory;
}
Mapper
上面只是讲解了卡带信息的主要部分。如果要模拟构建一个完整的卡带,还要了解一下Mapper的简单原理。
Mapper 并不位于主机中,而位于卡带上。每一张卡带都对应了一种 Mapper,在 NES 1.0 格式中,可以表示多达 256 种 Mapper
每种 Mapper 行为都不一样,比如有的 Mapper 增加了音频芯片提高音频性能,有的 Mapper 增加了寄存器扩展程序大小,有的还有中断计数的功能
比如 Mapper2,增加了 Bank 选择寄存器,以控制不同的 Bank 映射到 0x8000 - 0xFFFF,这样就扩充了游戏容量
首先回顾一下cpu
和 ppu
( ppu会在后面的博文详细介绍 ) 的内存布局 (左cpu , 右ppu)。
简单来说就是当cpu 和 ppu 访问到 红框地址范围的数据时候,它们实际访问的是卡带里面的数据。
而mapper相当于一个“隔离层" ,cpu/ppu访问这些地址的时候, 并不直接取数据,而是告知mapper要访问这个地址,然后mapper会在内部执行一下处理(不同的mapper有不用的处理,cpu/ppu是不知道mapper的行为的),帮助cpu/ppu得到想要的目标数值。
API Mapper ( 部分 )
这里只写了mapper的基本接口(如果是玩坦克大战,这几个接口也够了),后续可以丰富细节。
这里写成虚基类可以方面实现mapper多态。
class Mapper
{
public:
Mapper(uint8_t prgBanks, uint8_t chrBanks);
~Mapper();
public:
// 将 CPU 总线地址转换为 PRG ROM 偏移量
virtual bool cpuMapRead(uint16_t addr, uint32_t& mapped_addr, uint8_t& data) = 0;
virtual bool cpuMapWrite(uint16_t addr, uint32_t& mapped_addr, uint8_t data = 0) = 0;
// 将 PPU 总线地址转换为 CHR ROM 偏移量
virtual bool ppuMapRead(uint16_t addr, uint32_t& mapped_addr) = 0;
virtual bool ppuMapWrite(uint16_t addr, uint32_t& mapped_addr) = 0;
protected:
// 许多映射器都需要这两个变量, 这些信息可以通过读取卡带提取
// 这两个量通过构造函数初始化
uint8_t nPRGBanks = 0;
uint8_t nCHRBanks = 0;
};
API 卡带 ( 部分 )
构建了mapper后,我们把mapper放进卡带类里面
class Cartridge
{
//指向mapper对象的指针
std::shared_ptr<Mapper> pMapper;
public:
// 获取加载的mapper对象
std::shared_ptr<Mapper> GetMapper();
public:
// 连接 Cpu 总线的
bool cpuRead(uint16_t addr, uint8_t &data);
bool cpuWrite(uint16_t addr, uint8_t data);
// 连接 PPU 总线的
bool ppuRead(uint16_t addr, uint8_t &data);
bool ppuWrite(uint16_t addr, uint8_t data);
}
补充: 卡带构造函数
上面并没有包含完整的卡带类的信息( 因此我在后续讲解 ppu 会继续补充)
最后附上卡带类的构造函数,这个构造函数是完整的。通过阅读这个构造函数可以更清晰地知道怎么通过读取nes文件来模拟卡带信息
Cartridge::Cartridge(const std::string& sFileName) {
// NES游戏文件的文件头 一共16字节
// ref: https://www.jianshu.com/p/994f1663475a
//一个临时变量
struct sHeader {
char name[4]; // 4E 45 53 1A,对应着 ASCII 码的 'NES␚',可以用来检测是否为 NES 文件
uint8_t prg_rom_chunks; //一块chunk的大小为 16KB
uint8_t chr_rom_chunks; //一块chunk大小为 8KB
uint8_t mapper1; // flag 位
/* mapper1 各个信息位的含义
76543210
||||||||
|||||||+- Mirroring: 0: 水平镜像(PPU 章节再介绍)
||||||| 1: 垂直镜像(PPU 章节再介绍)
||||||+-- 1: 卡带上有没有带电池的 SRAM
|||||+--- 1: Trainer 标志
||||+---- 1: 4-Screen 模式(PPU 章节再介绍)
++++----- Mapper 号的低 4 bit
*/
uint8_t mapper2;
/*
mapper2 各个位含义
76543210
||||||||
|||||||+- VS Unisystem,不需要了解
||||||+-- PlayChoice-10,不需要了解
||||++--- 如果为 2,代表 NES 2.0 格式,不需要了解
++++----- Mapper 号的高 4 bit
*/
uint8_t prg_ram_size;
uint8_t tv_system1;
uint8_t tv_system2;
char unused[5];
}header;
bImageValid = false;
if (nCHRBanks == 0)
{
// Create CHR RAM
vCHRMemory.resize(8192);
}
else
{
// Allocate for ROM
vCHRMemory.resize(nCHRBanks * 8192);
}
//以二进制形式打开文件
std::ifstream ifs;
ifs.open(sFileName, std::ifstream::binary);
if (ifs.is_open()) {
// Read file header 读文件头
ifs.read((char*)&header, sizeof(sHeader));
// 紧接着的512byte 是 training information ,没有什么用,直接忽略了
if (header.mapper1 & 0x04)
ifs.seekg(512, std::ios_base::cur);
//计算出 mapper id
nMapperID = ((header.mapper2 >> 4) << 4) | (header.mapper1 >> 4);
mirror = (header.mapper1 & 0x01) ? VERTICAL : HORIZONTAL;
//根据不同的nes文件类型做不同的操作
uint8_t nFileType = 1;
if (nFileType == 0) {
}
// 这种是最常见的
if (nFileType == 1) {
//读入程序数据
nPRGBanks = header.prg_rom_chunks;
// 计算有程序有多大
vPRGMemory.resize(nPRGBanks * 16384); // 16 * 1024 = 16384
// 读入 vector
ifs.read((char*)vPRGMemory.data(), vPRGMemory.size());
//读入 pattern 数据,同上
nCHRBanks = header.chr_rom_chunks;
vCHRMemory.resize(nCHRBanks * 8192); // 8 * 1024 = 8192
ifs.read((char*)vCHRMemory.data(), vCHRMemory.size());
}
if (nFileType == 2)
{
}
switch (nMapperID) {
case 0: pMapper = std::make_shared<Mapper_000>(nPRGBanks, nCHRBanks); break;
//case 2: pMapper = std::make_shared<Mapper_002>(nPRGBanks, nCHRBanks); break;
//case 3: pMapper = std::make_shared<Mapper_003>(nPRGBanks, nCHRBanks); break;
//case 66: pMapper = std::make_shared<Mapper_066>(nPRGBanks, nCHRBanks); break;
}
ifs.close();
}
}