
没接触过FC模拟器DIY的人可能觉得这玩意儿早就被各路成熟模拟器做烂了自己动手纯属重复造轮子。但真把ROM加载、Mapper切换、手柄轮询、CPU和PPU时序对齐这一条链路走下来你会发现那些免费开源模拟器之所以能跑起来背后全是看不见的细节。这篇是这个系列的第四篇前几篇把6502 CPU、PPU渲染、APU音频的基本框架都搭完了这篇集中解决一个核心问题怎么让模拟器真正吃进一款游戏并且跑得有实机手感——从ROM文件的字节级解析到Mapper逻辑对号入座再到输入与存档的精雕细琢最后聊一聊硬件交叉验证的重要性。1. ROM文件格式解析自制模拟器要过的第一道坎1.1 iNES文件头的结构与陷阱FC模拟器最早能百花齐放iNES格式功不可没。这个由老牌模拟器社区确立的ROM封装格式说白了就是给原始卡带数据套了一个16字节的文件头告诉模拟器我是谁、我有多大、我要用什么Mapper。真去读这个头的时候坑比想象中多。标准iNES文件头长这样typedef struct { uint8_t magic[4]; // 0x4E 0x45 0x53 0x1ANES\x1A uint8_t prg_rom_size; // PRG ROM大小单位16KB uint8_t chr_rom_size; // CHR ROM大小单位8KB uint8_t flags6; // Mapper低4位、镜像、电池、训练信息标志 uint8_t flags7; // Mapper高4位、VS/PlayChoice标志 uint8_t flags8; // Mapper扩展位、PRG RAM大小 uint8_t flags9; // TV制式PAL/NTSC uint8_t padding[7]; // 填充字节 } iNESHeader;大部分教程会告诉你读前4个magic字节然后按偏移4和5读出PRG和CHR大小就完事了。但实际封装游戏时我踩过几个坑magic校验必须严格。市面有些工具导出的ROM文件头并不是标准NES\x1A如果你只判断前三个字节很容易把垃圾文件加载进来。从dumped卡带转存时偶尔会出现文件头损坏的情况校验失败时宁可直接报错也别硬着头皮解析否则后面就是无休止的花屏。PRG ROM大小是0时不代表没有ROM。有些特殊卡带比如部分自杀电池卡带或开发版ROM会写0这时得结合文件总大小反推。这点做ROM管理工具时尤其重要不能只信文件头。flags9的PAL/NTSC判定。flags9第0位标识制式但有些dump工具根本不写这个字段全零表示NTSC。判定制式影响的是你模拟器跑多少帧——NTSC每秒60.0988帧PAL是50.0070帧。我之前图省事统一按NTSC跑结果挂了PAL游戏之后音乐全部变调角色动作也发飘。后来在加载阶段就把制式信息存进系统状态音频定时器、帧同步全部按这个来。1.2 从文件头到内存映射CPU侧与PPU侧各看到什么文件头解析完了紧接着要处理的是数据放哪。这是不少新手模拟器开发者第一次蒙圈的地方卡带里的PRG ROM和CHR ROM在CPU和PPU的地址空间里并不是一键全映射的。PRG ROM映射到CPU寻址空间。6502的地址线是16位共64KB空间其中0x8000-0xFFFF这段32KB一般用来放PRG ROM。如果卡带PRG只有16KB那么它会同时映射到0x8000-0xBFFF和0xC000-0xFFFF两个区域这个叫PRG镜像是由NES硬件固化的行为不需要Mapper参与。CHR ROM映射到PPU寻址空间。PPU自己有独立的VRAM寻址0x0000-0x1FFF这8KB就是留给CHR ROM的图案表。如果CHR ROM大于8KB就必须靠Mapper在里面切换bank每切换一次PPU就能看到不同的图案块。写代码时我会把ROM数据加载进两个独立数组CPU侧用prg[prgSize]PPU侧用chr[chrSize]然后通过一个mapperRead(addr)统一入口做地址转换。这个统一入口是后面所有Mapper实现的基础设计得好后面加MMC1、MMC3的时候能省一大堆麻烦。1.3 自建ROM库管理命名规范与PAL/NTSC识别项目做到第四篇手里已经攒了各种不同来源的ROM文件。散装文件管理是个特别琐碎但影响体验的工作尤其在不用现成前端、自己做加载器的情况下。我个人的习惯是建一个games.xml清单记录每款游戏的以下信息字段说明name游戏名称用英文缩写或中文均可fileROM文件路径mapper强制指定Mapper号忽略文件头mirroring强制镜像模式H/V/四位regionNTSC/PAL/强制帧率battery是否有电池存档controller标准手柄/四人扩展/Zapper光枪这样一个清单文件的好处是当某款游戏在文件头里Mapper字段写错或者镜像模式标识有歧义时我可以手动在清单里指定覆盖值。做模拟器到后期能跑的ROM比全自动识别更重要。并不是所有ROM的dump质量都完美文件头标识和卡带实际硬件不一致的情况历史上很常见人工维护一个修正清单是完全值得的。2. Mapper模拟同一份ROM在不同硬件上表现天差地别的根源2.1 NROM之外的Mapper逻辑MMC1、MMC3、UNROM为什么要特殊处理FC早期卡带就是NROMPRG和CHR都是固定的没有切换。但这种卡带容量上限太小后来游戏越做越大厂商就开始在卡带里加额外的Mapper芯片。模拟器里如果只支持NROM好多经典游戏根本没法跑。常见Mapper可以按容量增长逻辑分成几类MMC1Mapper 1任天堂自家芯片支持PRG 32KB切换或16KB切换、CHR 4KB切换、支持64KB-256KB的PRG ROM。最典型的游戏是《塞尔达传说》初代。MMC3Mapper 4这是FC后期最主流的Mapper支持CHR 1KB/2KB细粒度切换支持IRQ计数器。动作游戏用这种Mapper可以做到很精细的分块动态生成典型如《超级马里奥3》。UNROM/UOROMMapper 2结构非常简单只做PRG 16KB的切换CHR固定。但要做8KB、16KB切换时规则略有区别。CNROMMapper 3PRG固定只切换CHR bank。Dizzy系列的某些作品就是这种。2.2 常见Mapper的内存切换时序与实现要点以MMC3为例拿下这个Mapper之后其他复杂Mapper基本就一通百通了。MMC3的关键有两点bank切换的写法以及IRQ计数器。MMC3内部有两个bank选择寄存器$8000选择PRG bank的模式和CHR的A/B面$8001用于设定对应bank的具体编号。实际写实现的时候不能简单把它当普通RAM因为多个地址会映射到同一个寄存器void mapper4_write(uint16_t addr, uint8_t value) { if (addr 0x9FFF) { if (!(addr 1)) { // 偶数地址写bank选择寄存器 m_bankSelect value 0x07; m_prgMode (value 6) 0x01; m_chrMode (value 7) 0x01; } else { // 奇数地址写bank数据寄存器 uint8_t bank value 0x3F; switch (m_bankSelect) { case 0: chrBanks[0] bank 0xFE; chrBanks[1] bank | 0x01; break; case 1: chrBanks[2] bank 0xFE; chrBanks[3] bank | 0x01; break; // ... 其他case } } } else if (addr 0xBFFF) { // 镜像控制 if (!(addr 1)) setMirroring(value 0x01 ? MIRROR_H : MIRROR_V); } else if (addr 0xDFFF) { // PRG RAM保护 } else if (addr 0xFFFF) { // IRQ相关 if (!(addr 1)) m_irqLatch value; else m_irqReload true; } }重点聊聊IRQ计数器这部分。MMC3的IRQ是给PPU用的——它让游戏可以在某个特定扫描线触发CPU中断比如《超级马里奥3》的天空层滚动效果、部分游戏的关卡状态切换。模拟器实现时要掌握好这个每渲染一条扫描线计数器减一的节奏减到0就触发一次CPU中断然后是否重载由游戏控制。这个如果时序错了画面会在特定关卡发生撕裂、闪烁。2.3 没有硬件手册时如何靠测试ROM和游戏场景反推做模拟器最头疼的不是查手册而是手册上和实际卡带行为不一致。很多Mapper芯片厂商会出变种官方文档只覆盖典型行为。我遇到过一次很惨的案例一款游戏在实机上正常在我的模拟器上刚出门遇到敌人的排布全部错位排查到最后发现是MMC3的CHR bank切换在第一个bank pair的使用上和标准实现有偏差。怎么反推我的方法是三步走跑已知正确的测试ROM。社区里有针对Mapper的详细测试集比如nes-test-roms先把这些ROM全跑一遍看哪一步开始输出和预期不符。用调试器追踪bank切换行为。在每次CHR读写、每次IRQ触发时打印日志结合游戏画面出现花屏的时机回推是哪次切换太晚或太早。对照实机dump数据。如果有条件用逻辑分析仪抓真实卡带的bank切换信号跟模拟器日志做对比定位差异点。这类问题光靠读代码几乎看不出来只能说手上有点实机数据、有点耐心一步步缩小范围。3. 手柄与存档最容易忽视却又决定体验的两个细节3.1 标准手柄协议的轮询时序模拟FC标准手柄的读取协议很简单CPU写$4016的bit 0为1时锁存按键状态写为0时允许移位读出然后CPU依次读$40161P和$40172P读8次就能拿到8个按键状态。模拟器实现时比较常见的做法是维护一个8bit的键状态寄存器读取时从低位到高位依次弹出uint8_t read_controller(uint16_t addr) { if (addr 0x4016) { // 返回当前shift寄存器最低位然后右移 uint8_t value (m_controller1 m_shiftCount) 1; if (m_shiftCount 8) m_shiftCount; return value; } return 0; }但时序上有讲究。游戏程序往往会在连续读取8次时要求每次读到的位是稳定的。真实硬件上读取是逐次移位输出的如果你模拟器在两次读取之间没有正确管理shift寄存器会导致输入错位表现为按A键跳起来却变成了按B或者方向键偶尔失灵。我自己的经验是在$4016写入0再写1这个锁存再读取的过程里每次CPU执行到读$4016时就取当前状态寄存器的最低位然后移位。简单来说就是状态锁存发生在写入序列结束之后而读取时移位。3.2 电池存档与即时存档的设计取舍电池存档battery RAM在很多RPG里是命根子即时存档则是模拟器自己提供的便利功能。两者在设计上有本质区别。电池存档游戏通过$6000-0x7FFF地址访问PRG RAM如果ROM文件头里battery标志置位模拟器就需要把这部分数据持久化到磁盘。失败会导致存档消失是模拟器体验的重大扣分项。我一般在加载ROM时就判断battery标志游戏正常退出时一次性把RAM数据写回sav文件。即时存档需要保存整个系统的运行状态包括CPU寄存器、RAM、PPU内部状态、APU音频状态、mapper内部寄存器。这个完整状态其实就是一个很大的struct可以序列化成文件。我的建议是加入存档命名空间不同游戏、不同时间的即时存档互不干扰。有种比较隐蔽的情况有些游戏会在检测到PRG RAM可写后用它做临时计算如果没有电池存档但mapper里有RAM也照样要给它分配内存。检查ROM头文件时flags6的bit 1表示存在电池存档但flags8里又声明了PRG RAM的大小。这两者别搞混——即使没有电池只要mapper声明了RAM空间就要给它分配空间否则游戏写这个区域时会出错。3.3 输入延迟对模拟器手感的影响做FC模拟器不像PC模拟器那样需要高精度对抗但输入延迟照样是个硬指标。不少人以为模拟器帧率跑到60fps就完事了实际玩起来却总感觉慢半拍多半是输入延迟没控制好。输入延迟的根源其实在CPU和PPU的配合方式上。如果你的模拟器是跑完一帧CPU再渲染一帧PPU这种粗粒度同步那么从玩家按键到画面变化之间可能隔着整整一帧的时间差。而实机是边跑指令边渲染手柄状态是在每帧的特定时刻锁存。我的做法是在PPU渲染到第241条扫描线也就是VBlank前后时锁存当前手柄键状态。这样就能模拟实机游戏在VBlank期间读取手柄的时序玩家按键到游戏逻辑看到键值之间的延迟最接近真实硬件。4. CPU与PPU协同6502与PPU之间的同步时机4.1 为什么不能把CPU跑完再跑PPU早期做模拟器最容易犯的错误就是把CPU和PPU当成两个独立的模块先跑完整帧的CPU再跑完整帧的PPU。这样写起来简单但画面在动态场景下会有撕裂、精灵闪烁音乐也会偶尔卡顿。根源在于FC实机上CPU和PPU是并行工作的。PPU按扫描线渲染画面渲染过程中需要从VRAM取数据、判断精灵命中、产生中断而CPU在执行游戏代码时会读取PPU的状态寄存器、写PPU控制寄存器。两者在时间上是缠在一起的——游戏代码里经常有这样的模式等PPU到VBlank然后写一坨调色板数据再等下一个VBlank。如果模拟器不让PPU按扫描线推进CPU就永远等不到正确的VBlank标志。4.2 扫描线级别的同步方式与实现我采用的同步策略可以概括为以扫描线为单位推进PPU以CPU周期为单位推进CPU每个扫描线内让两者保持相对顺序。具体做法是在一个扫描线内先把PPU时钟拨到对应周期的事件点然后按CPU周期跑指令每条指令执行完后检查PPU扫描线是否有需要触发的事件比如某个精灵命中标志、水平滚动重载、IRQ计数器递减。这样既不丢失CPU指令的连续性又能让PPU的事件按正确时刻插入。这个方案的代价是状态管理变得复杂。比如PPU的$2002状态寄存器的VBlank标志必须在CPU读取它之前、由PPU的渲染逻辑在正确的扫描线先置位。如果同步粒度太粗VBlank标志的置位时机就会偏晚游戏就会漏掉VBlank窗口显示异常。4.3 音频采样的节奏APU与CPU周期的对应关系音频模块往往是模拟器优化到后期才去碰的部分但它跟CPU协同的紧密程度一点也不比PPU低。FC的APU产生声音的节奏不是独立的它是在CPU访问$4000-0x4017这些寄存器时实时更新的。如果你的模拟器采用跑完整帧后再把声波一次性生成的策略那么音频会天然带有周期性爆音和延迟感。更合理的做法是在CPU执行每一条写APU寄存器的指令时记录当前时间点按CPU周期计算然后让APU状态机推进到那个时间点生成一段采样再继续执行下一条指令。实现时我会在音频模块里维护一个apuClock(cycles)方法每次CPU写寄存器后调用一次。采样率可以用44100Hz或48000Hz但帧率要跟NTSC/PAL匹配——这是我在1.3小节提到制式判断重要性的原因之一。如果制式不对累积的音频采样量会逐渐偏多或偏少听起来就是背景音乐越跑越快或越拖越慢。5. 从模拟器到硬件DIY逻辑分析仪与示波器的交叉验证5.1 纯软件模拟器的盲区软件模拟器做到能跑大部分游戏后会进入一个让人自我怀疑的阶段明明测试都过了但有些游戏在特定特效、特定敌人的行动模式下就是跟实机感觉不一样。问题到底出在哪里答案往往是模拟器只遵循了文档里写的标准行为而卡带硬件、主机主板、芯片之间的交互比文档复杂得多。举一个例子FC的PPU在渲染过程中访问VRAM是有限制的普通卡带在渲染期间有些bank是不能切换的。但我在实机上测过一款游戏它确实在渲染中间执行了CHR写操作这种在标准时序下属于非法访问的行为在某些批次的卡带上产生了ppu bus conflict特有的画面效果。模拟器如果不模拟这类行为在另一些游戏上就会出现该花屏的不花屏或花屏方式不对。要想发现这些差异唯一的途径是跟实机做交叉验证。5.2 一个真实案例为什么某个游戏在模拟器上正常但烧录后花屏去年我拿一块自制的烧录卡跑一款比较冷门的FC游戏结果出现了很诡异的画面标题正常进关卡后特定位置的背景块出现随机花点。我把同一份ROM放进自己的模拟器里跑几百遍都正常。排查过程很辛苦。先用逻辑分析仪抓卡带在实机上运行时CPU访问CHR数据的时序再让模拟器打印同一帧里同样的CPU周期序列。一对比发现游戏在某个关卡启动时会连续写CHR数据中间跨了一条扫描线的边界。在实机上PPU在扫描线边界附近会优先处理渲染取数对CHR写的响应会延迟几个周期而我的模拟器在同步的时候刚好把这次写操作安排在了扫描线事件之后导致后面的精灵图案读取错位。这个坑让我意识到模拟器能玩跟精确还原是两回事。如果目标是做一台能吃进各种奇怪游戏卡带的DIY硬件模拟器的行为就必须再往底层靠一层——PPU总线的仲裁规则、CPU和PPU并发访问VRAM的优先级全都要纳入考虑。5.3 硬件验证让模拟器更精确回到系列主题FC模拟器DIY。这里的DIY如果只停留在PC软件层面多少有点不够尽兴。我自己后期的做法是把写好的模拟器逻辑移植到一块FPGA开发板上配合外置SDRAM和串口加载ROM做成一个能插手柄、能接电视输出的迷你FC。这样一来模拟器代码里的CPU/PPU核心就被固化成了硬件逻辑它能跑什么游戏基本就等于模拟器的精度水平能覆盖什么游戏。硬件验证对纯软件模拟器的反哺是双向的板子跑不出来的游戏反过来会提醒我模拟器的哪个模块太理想化而模拟器跑出正确结果的游戏到了板子上往往也能很快过关因为两者现在共享的是同一套总线时序模型。如果手头没有FPGA用逻辑分析仪抓实机卡带数据再配合软件模拟器做对比同样能发现大量隐藏问题。这类工作比单纯刷游戏ROM列表要值得多——它会推着你把模拟器从能玩大部分游戏推向能玩所有合法卡带。这个系列的下一部分我打算把FC扩展音源芯片如VRC6、MMC5的模拟细节展开聊聊。那部分涉及到音频混音和总线读写时序的更多坑等实践完再回来补一篇。