Build dossier · 20
Game Mapper 适配记录:从 Bank Switching 到 IRQ
记录 Rust NES 模拟器如何从固定映射扩展到 16 个 Mapper 编号,以及 bank switching、镜像和 IRQ 在代码里怎么落地。
NES 模拟器最早只支持 Mapper 0 时,ROM 里的 CPU 程序和图块数据几乎可以直接按地址读取。换一批游戏以后,同样的 CPU 地址会指向不同 ROM bank,PPU 看到的 CHR 也会变化,有些卡带还会自己产生 IRQ。
当前项目最后实现了 Mapper 0、1、2、3、4、7、21、23、34、64、66、67、69、73、87 和 140。适配过程不是给 match 多加十五个编号,而是把卡带从一块静态 ROM 改成一个参与 CPU、PPU 和时钟运行的设备。
这篇先从 Mapper 为什么存在讲起,再记录 bank switching、nametable mirroring 和 IRQ 怎么接进核心。
卡带芯片扩展有限的地址空间
最早的 NROM 卡带比较简单。CPU 能看到最多 32 KiB PRG ROM,PPU 能看到 8 KiB CHR 图块数据,地址基本可以直接对应到 ROM 下标。
后来游戏越来越大,但 NES 的 CPU 和 PPU 地址窗口没有跟着变大。解决办法不是更换主机,而是在卡带里增加电路:CPU 写一个寄存器,卡带就把另一段 ROM 接到当前地址窗口。这个动作就是 bank switching。
可以把它想成一本很厚的书和一扇只能显示一页的窗口。书没有变小,窗口也没有变大;Mapper 负责决定现在把哪一页放到窗口下面。
NESdev 对 Mapper 的定义也是把卡带硬件映射到 CPU 和 PPU 地址空间,其中最常见的能力就是 PRG 与 CHR bank switching。NESdev Mapper 说明
PRG、CHR 与 nametable mirroring
PRG ROM 主要保存 6502 程序和游戏数据,由 CPU 读取。CHR ROM 或 CHR RAM 保存背景与精灵使用的图块,由 PPU 读取。
当前项目关心的几个窗口是:
- CPU 从 0x8000 到 0xFFFF 读取 PRG ROM;
- CPU 常通过 0x6000 到 0x7FFF 访问卡带 RAM;
- PPU 从 0x0000 到 0x1FFF 访问 CHR;
- PPU 后面的 nametable 区域保存背景图块的排列。
Nametable mirroring 这个名字容易让人误以为是把画面左右翻转。它实际描述的是 PPU 多个逻辑 nametable 地址如何复用有限的物理 RAM。水平、垂直和单屏映射会影响卷轴跨越边界时看到哪块背景。
有些卡带把 mirroring 焊死,有些 Mapper 可以在运行中切换。因此 Mapper 状态不仅决定 ROM bank,也会改变 PPU 的 nametable 地址换算。
代码里先把卡带状态独立出来
Cartridge 保存 ROM/RAM 数据、解析出的 RomInfo、mirroring 和一个 MapperState enum。每种 Mapper 只保存会变化的寄存器状态,例如 bank number、shift register、IRQ counter 和 enable flag。
enum MapperState {
Nrom,
Uxrom { bank: u8 },
Mmc1 { shift: u8, control: u8, prg_bank: u8, /* ... */ },
Mmc3 { bank_select: u8, banks: [u8; 8], /* ... */ },
Vrc4 { prg_banks: [u8; 2], chr_banks: [u16; 8], /* ... */ },
// ...
}
CPU 和 PPU 不知道具体 Mapper 编号。它们只调用 Cartridge 的读写、tick 和 IRQ 接口。这样 CPU 指令实现不需要到处出现 Mapper 分支,差异集中在地址映射、寄存器写入和 IRQ 计数三个位置。
最简单的 bank switching
Mapper 2,也就是 UxROM,只有一个可切换 PRG bank。CPU 的 0x8000 到 0xBFFF 指向选中的 16 KiB bank,0xC000 到 0xFFFF 固定为最后一个 bank。
固定最后一个 bank 很重要,因为 CPU 复位向量和公共跳转代码通常放在那里。游戏切换前半窗口时,后半窗口仍有一段稳定代码可以继续执行。
Mapper 3,也就是 CNROM,反过来保持 PRG 基本固定,只切换 PPU 使用的 8 KiB CHR bank。Mapper 7、34、66 和 140 则是不同粒度的 PRG、CHR 与 mirroring 组合。
代码最终都归结为两步:
- 根据 CPU 或 PPU 地址确定窗口 slot;
- 根据 Mapper 寄存器选择 bank,再计算 ROM 内部 offset。
这里不能简单相信 bank number 一定在范围内。当前实现会按实际 bank count 取模或限制,避免污染头和不完整 ROM 直接造成越界访问。
Mapper 34 需要进一步区分板型
Mapper 34 可能是 BNROM,也可能是 NINA-001,两者寄存器和 CHR 行为不同。当前项目优先使用 NES 2.0 submapper 区分;旧 iNES 没有 submapper 时,再结合 CHR 容量判断。
BNROM 使用 CHR RAM,主要切换 32 KiB PRG;NINA-001 还可以分别选择两个 4 KiB CHR bank。只按 mapper 等于 34 套一个实现,会让其中一类游戏读到错误图块。
这也是解析 ROM header 不能和 Mapper 实现分开的原因。NES 2.0 增加 submapper,本来就是为了表达旧 iNES 编号无法区分的卡带差异。NES 2.0
MMC1 使用五次串行写入
MMC1 为了减少芯片引脚,使用一个串行 shift register 接收配置。游戏要写入 5 bit 值,需要向 0x8000 到 0xFFFF 连续写五次,每次只提交最低位。bit 7 为 1 时重置 shift register。
完成五次写入后,目标地址范围决定更新 control、两个 CHR bank 或 PRG bank。control 还决定 PRG bank 模式、CHR bank 粒度和 mirroring。
真正踩坑的是 6502 的 read-modify-write 指令。它会在相邻 CPU cycle 连续写两次,真实 MMC1 会忽略第一笔之后的连续周期写入。当前实现把 CPU cycle 一起传进 mapper_write(),记录 lastWriteCycle:
if last_write_cycle.is_some_and(|last| cycle == last.wrapping_add(1)) {
return;
}
如果 Mapper 只收到地址和值,不知道写入发生在哪个 cycle,就无法还原这个行为。NESdev 的 MMC1 资料也记录了 consecutive-cycle writes,以及它与 read-modify-write 指令的关系。MMC1
这个问题反过来推动了 CPU 总线时序调整:每次真实读写都推进对应 cycle,而不是执行完一条指令后一次性补总周期。
IRQ 是卡带主动打断 CPU
IRQ 是 interrupt request,也就是中断请求。平时 CPU 按程序计数器顺序执行指令;IRQ 到来时,CPU 保存现场并跳到预先设置的处理程序。
游戏可以用 IRQ 在一帧画到特定位置时修改滚动、图块 bank 或其他寄存器。例如画面上半部分滚动游戏场景,下半部分固定显示状态栏。时机差几个 PPU dot,就可能出现抖动、错行或整块画面错误。
不同 Mapper 产生 IRQ 的方法并不统一。有的数 CPU cycle,有的观察 PPU 地址变化,有的两种模式都支持。因此“实现 Mapper”经常不是完成 bank switching 就结束了。
MMC3 不只是更多 bank
MMC3 把 PRG 划成 8 KiB bank,把 CHR 划成两个 2 KiB 和四个 1 KiB bank。bankSelect 的 bit 6、bit 7 还会改变固定 bank 与可切换 bank 的位置。
更麻烦的是 scanline IRQ。MMC3 观察 PPU 地址线 A12。A12 保持低电平足够长后出现上升沿,IRQ counter 才计数。当前实现不是每条扫描线直接减一,而是在每次 PPU 访问卡带时调用 observePpuAddress(),记录:
- 上一次 A12 是否为高;
- A12 已经保持低电平多少个 PPU tick;
- IRQ latch、counter、reload、enabled 和 pending。
满足过滤条件的 A12 上升沿才推进 IRQ counter。这样才能支持依赖 MMC3 IRQ 在一帧中切换滚动或图块 bank 的画面。NESdev 对 MMC3 的说明也指出其 scanline counter 实际由经过过滤的 PPU A12 上升沿驱动。MMC3 IRQ
项目里的《忍者龟 2》回归用例会运行到实际关卡,再比较分屏画面的帧哈希。只测标题画面,覆盖不到这类 IRQ 行为。
VRC、RAMBO 和 Sunsoft 的 IRQ 又不一样
实现 MMC3 后,并不能抽出一个通用 IRQ counter 解决剩余型号。
当前项目里几种典型差异是:
- VRC4 可以按 CPU cycle 或近似 scanline 的 prescaler 计数,Mapper 21 和 23 的寄存器地址线排列还不同;
- RAMBO-1 既可以观察 PPU A12,也可以使用 CPU cycle 模式;
- Sunsoft 3 使用自己的 16 bit counter 和分两次写入的 latch;
- FME-7 的 IRQ counter 每个 CPU cycle 递减,从 0x0000 回绕到 0xFFFF 时触发 IRQ;
- VRC3 还有 8 bit、16 bit 模式和 ack 后是否继续 enable 的状态。
例如 FME-7 文档明确描述了 CPU-cycle counter 的递减与回绕触发方式。Sunsoft FME-7 IRQ
因此当前代码只共享最外层接口,不强行把不同硬件塞进一套计数器。每种 MapperState 保留自己的 latch、counter、prescaler 和控制位,tickCpu() 再按型号推进。
Mapper 状态也必须进入存档
bank register 和 IRQ counter 会影响下一条 CPU 读取以及下一帧画面。如果即时存档只保存 CPU、PPU 和 RAM,载入后 Mapper 回到初始值,游戏可能立即跳到错误代码 bank,或者在错误时间触发 IRQ。
当前 MapperState 与 Cartridge RAM 一起参与 Nes 序列化。ROM 本体不重复写进存档;恢复时从当前 Cartridge 把 PRG、CHR ROM 数据重新接回去,并检查状态 envelope 中的 ROM SHA-256。
这也是回滚联机能够工作的基础。checkpoint 必须包含当前 bank 和 IRQ 状态,否则从旧帧重放不会得到原来的结果。
现在的兼容范围
当前支持的 16 个 Mapper 编号覆盖了项目中 476 个 ROM 的现有集合,并通过当前的启动、存档恢复、确定性重放和帧哈希回归。
这不等于完整 NES 兼容:
- 还有大量 Mapper 没实现;
- 同一 Mapper 编号下可能存在更多 board revision 和 submapper 差异;
- 当前回归只覆盖有限输入路径;
- 一些硬件边沿行为仍可能需要测试 ROM 或与成熟模拟器做差分验证;
- 项目中个别兼容处理绑定特定 ROM SHA-256,不能推广成通用硬件规则。
从 Mapper 0 扩展到现在,最大的变化不是 match 变长,而是 Cartridge 变成了一个真正有状态、有时钟、能发 IRQ 的总线设备。bank switching 解决“现在读哪一段 ROM”,mirroring 影响 PPU nametable,IRQ 决定“什么时候打断 CPU”。这三部分都进入确定性状态后,存档、回归测试和回滚联机才有可靠基础。