GB/游戏卡带/MBC1
MBC1 是 Game Boy 的第一款 MBC 芯片. 任何后续的升级型 MBC 芯片其工作原理都与 MBC1 相似, 因此将游戏程序从一个 MBC1 芯片升级到另一个 MBC 芯片相对容易, 甚至可以使同一个游戏与几种不同类型的 MBC 兼容. MBC1 的内存是非平坦的, 它的程序指令和数据并不像 ROM Only 芯片一样被包含在相同的地址空间中.
MBC1 地址空间划分
MBC1 芯片理论上最大可拥有 128 个 ROM 存储体, 每个 ROM 存储体大小是 16 KB; 同时拥有 4 个 RAM 存储体, 每个 RAM 存储体大小是 8 KB. MBC1 芯片将 Game Boy 分配给游戏卡带的系统地址空间 0x0000...0x7FFF 和 0xA000...0xBFFF 划分为以下几个独立区间, 详细介绍如下.
1) 0000-3FFF ROM 存储体 00
只读区域. 该区域总是映射到 ROM 的前 16 KB 字节物理存储, 也就是第一个 ROM 存储体.
2) 4000-7FFF ROM 存储体 01-7F
只读区域. 该区域可以映射为第 0x01 到 0x7F 编号的 ROM 存储体. 注意的是其中编号为 0x20, 0x40 与 0x60 的存储体不能被使用, 因此 MBC1 芯片实际上最大可支持 125 个 ROM 存储体, 最大数据存储容量为 125 * 16 KB ~= 2 MB.
MBC1 芯片内部拥有一个 Bank Number 寄存器来控制哪个存储体会被映射到此区域.
3) A000-BFFF RAM 存储体 00-03
读写区域. 该区域用于读写游戏卡带中的外部 RAM(如果有的话). 外部 RAM 通常需要使用电池做持久化存储, 允许存储游戏存档或历史高分列表. 即使关闭游戏机或者从游戏机中移除了卡带, RAM 中保存的数据依然不会丢失. 但如果卡带中的电池电量耗尽, 或者插拔了电池, 所有数据将化为乌有. 注意, 如果游戏卡带不支持外部 RAM 的话, 对此区域的任何读操作均返回 0x00 值.
4) 0000-1FFF RAM 启用/禁用标志
只写区域. 在试图读取或写入 RAM 之前, 必须通过在该地址空间写入特定值来启用外部 RAM. Game Boy 的游戏开发手册上建议游戏开发者在访问外部 RAM 后立即禁用外部 RAM, 以保护其内容免受游戏机断电期间的损坏. 通常使用以下值:
- 0x00: 禁用 RAM, 这是默认值.
- 0x0A: 启用 RAM.
实际上, 只要低 4 位是 0x0A 的任何值都会启用 RAM, 比如 0x1A, 0x2A 等, 而任何其它值都会禁用 RAM.
5) 2000-3FFF Bank Number 第 0-4 位
只写区域. 该区域被映射到 Bank Number 寄存器的第 0-4 位, 用于存储当前的 ROM Bank Number. 如果写入的值是 0x00, 由于第一个分组已经被永久映射到 0x0000-0x3FFF 地址区间, 因此 MBC1 将把 0x00 当作 0x01 处理. 同时, 当试图写入 0x20, 0x40 和 0x60 时也会发生同样的情况: MBC1 将把这些值翻译为 Bank 0x21, 0x41 和 0x61. 这就是为什么不存在编号为 0x20, 0x40 与 0x60 的 ROM 存储体的原因.
6) 4000-5FFF Bank Number 第 5-6 位
只写区域. 该区域被映射到 Bank Number 寄存器的第 5-6 位, 用于存储当前的 RAM Bank Number, 或者与 ROM Bank Number 组合成一个 7 位数字以完整表达 0x00-0x7F.
7) 6000-7FFF Bank 模式选择
只写区域. 该区域被映射到 Bank Number 寄存器的第 7 位, 用于表示当前的 Bank Number 应该被表达为 ROM Bank Number 还是 RAM Bank Number. 它只有两个可选值:
- 0x00 ROM Bank Number 模式, 默认
- 0x01 RAM Bank Number 模式
游戏程序可以在两种模式之间自由切换, 同时并不意味着使用了 ROM Bank Number 模式就无法再访问 RAM. 其唯一的限制是在 ROM 模式期间只能使用第一个 RAM 存储体, 相应的在 RAM 模式期间只能使用 ROM 存储体 0x00-0x1F.
Bank Number 寄存器再探
Bank Number 寄存器的作用相对来讲不是那么容易理解, 我们可以辅助图形来加深理解. 我们已经知道: 一个完整的 Bank Number 寄存器可被表示为以下形式:
Bank Mode RAM Bank Bits ROM Bank Bits
1 bit 2 bit 5 bit
当 Bank Mode 置为 0 时, 当前卡带为 ROM Bank Number 模式, 此时
- ROM Bank Number = RAM Bank Bits + ROM Bank Bits
- RAM Bank Number = 0x00
当 Bank Mode 置为 1 时, 当前卡带为 RAM Banking Mode 模式, 此时
- ROM Bank Number = ROM Bank Bits
- RAM Bank Number = RAM Bank Bits
SRAM 的数据持久化
在真实的 Game Boy 卡带中, 外部 RAM , 也就是 MBC1 中系统地址为 0xA000-0xBFFF 的区间, 通常使用一块 3 V 的纽扣电池来保证数据不丢失. 对于这种构造的卡带, 可以使用一个新的名词来指代其外部 RAM: 静态随机存取存储器(Static Random Access Memory, SRAM). SRAM 是随机存取存储器的一种. 所谓的"静态", 是指这种存储器只要保持通电, 里面储存的数据就可以恒常保持. 相对之下, 动态随机存取存储器(DRAM)里面所储存的数据就需要周期性地更新. 然而, 当电力供应停止时, SRAM 储存的数据还是会消失(被称为 Volatile Memory), 这与在断电后还能储存资料的 ROM 或 Flash Memory 是不同的. Game Boy 游戏卡带的外部 RAM 绝大部分都是 SRAM.
在一般玩家的正常使用下, Game Boy 中的纽扣电池能保证持续放电 2 到 3 年, 因此可以保证卡带 2 年内不会掉档. 但也有例外, 比如在《精灵宝可梦》系列中, 卡带中额外存在一个时钟电路, 此时纽扣电池除了要供电 SRAM 还要供电时钟电路, 耗电量的增加导致了电池寿命的严重下降, 虽然现在不是很确定, 但在笔者幼时的印象中, 《精灵宝可梦》系列的游戏存档大概只能保持 1 年左右. 但令人苦恼的是, 许多游戏卡带为了防止纽扣电池接触不良造成意外的掉电, 会选择将电池焊在电路上的. 这意味着, 一旦电池电量耗尽, 玩家几乎无法自己手动更换它. 不过某些店主可能会感谢这种设计, 在笔者居住的小镇中就有一家提供电池更换服务的钟表店, 它在孩子们之间口口相传...
回忆是美好的事情, 但现在必须将思绪拉回正题了. 我们要在仿真器上实现 SRAM! 我们无法仿真一块电池, 并且这个主意糟透了. 我们所需要做的事情并不困难:
- 在合适的时候, 将 RAM 中的所有内容写入到本地文件作为存档文件.
- 在仿真器启动的时候, 读取该存档文件中的内容到 RAM.
合适的写入时机有两个:
- 一是在关闭仿真器的时候.
- 二是在 RAM 启用/禁用标志从 True 转变为 False 的时候, 这意味着 CPU 已经完成了读取/改写 RAM 内容的工作.
两种选择各有优缺点. 第一种方式会在突然断电, 或者仿真器崩溃等异常情况下无法正常写入文件导致存档丢失; 第二种选择则是写文件的次数可能会过于频繁, 比如笔者在测试《精灵宝可梦-水晶》的时候发现每秒写文件的次数就达到了两位数(当然这和具体游戏有关, 有的游戏就非常节约). 关于时机的选择, 本书会使用第一种, 因为相对而言这样会使得代码结构更加清晰.
代码实现
万事具备! MBC1 游戏卡带的所有技术细节均已介绍完毕, 下面开始 MBC1 的仿真实现.
// 由于不是全部的卡带类型都带有 SRAM, 因此使用一个泛型 Stable 表示拥有 SRAM 的
// 卡带, Stable 泛型可以将游戏 卡带的内存数据以文件形式通过 sav 函数保存到本地
// 硬盘上.
pub trait Stable {
fn sav(&self);
}
// MBC1 拥有两种 Bank Mode 类型, 因此在代码中使用一个枚举类型 BankMode 进行表
// 示.
enum BankMode {
Rom,
Ram,
}
// MBC1 主结构体.
pub struct Mbc1 {
rom: Vec<u8>,
ram: Vec<u8>,
bank_mode: BankMode,
bank: u8,
ram_enable: bool,
sav_path: PathBuf,
}
impl Mbc1 {
pub fn power_up(rom: Vec<u8>, ram: Vec<u8>, sav: impl AsRef<Path>) -> Self {
Mbc1 {
rom,
ram,
bank_mode: BankMode::Rom,
bank: 0x01,
ram_enable: false,
sav_path: PathBuf::from(sav.as_ref()),
}
}
fn rom_bank(&self) -> usize {
let n = match self.bank_mode {
BankMode::Rom => self.bank & 0x7f,
BankMode::Ram => self.bank & 0x1f,
};
n as usize
}
fn ram_bank(&self) -> usize {
let n = match self.bank_mode {
BankMode::Rom => 0x00,
BankMode::Ram => (self.bank & 0x60) >> 5,
};
n as usize
}
}
// 为 MBC1 实现 Memory 泛型, 使得 CPU 可以通过内存地址读写这个对象.
impl Memory for Mbc1 {
fn get(&self, a: u16) -> u8 {
match a {
0x0000...0x3fff => self.rom[a as usize],
0x4000...0x7fff => {
let i = self.rom_bank() * 0x4000 + a as usize - 0x4000;
self.rom[i]
}
0xa000...0xbfff => {
if self.ram_enable {
let i = self.ram_bank() * 0x2000 + a as usize - 0xa000;
self.ram[i]
} else {
0x00
}
}
_ => 0x00,
}
}
fn set(&mut self, a: u16, v: u8) {
match a {
0xa000...0xbfff => {
if self.ram_enable {
let i = self.ram_bank() * 0x2000 + a as usize - 0xa000;
self.ram[i] = v;
}
}
0x0000...0x1fff => {
self.ram_enable = v & 0x0f == 0x0a;
if !self.ram_enable {
self.sav();
}
}
0x2000...0x3fff => {
let n = v & 0x1f;
let n = match n {
0x00 => 0x01,
_ => n,
};
self.bank = (self.bank & 0x60) | n;
}
0x4000...0x5fff => {
let n = v & 0x03;
self.bank = self.bank & 0x9f | (n << 5)
}
0x6000...0x7fff => match v {
0x00 => self.bank_mode = BankMode::Rom,
0x01 => self.bank_mode = BankMode::Ram,
n => panic!("Invalid cartridge type {}", n),
},
_ => {}
}
}
}
// 最后, 为 MBC1 实现 Stable 泛型. 当调用 sav 函数时, 如果 sav_path 路径下已经
// 存在数据文件, 则覆盖旧的文件; 否则新建一个文件. 被保存的数据则是整个 RAM 中
// 存储的内容.
impl Stable for Mbc1 {
fn sav(&self) {
if self.sav_path.to_str().unwrap().is_empty() {
return;
}
File::create(self.sav_path.clone())
.and_then(|mut f| f.write_all(&self.ram))
.unwrap()
}
}