ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JSNES 开发指南:JavaScript NES 模拟器的架构、硬件精度与测试体系深度解析

JSNES 开发指南:JavaScript NES 模拟器的架构、硬件精度与测试体系深度解析
  • 游戏开发

【免费下载链接】jsnes

A JavaScript NES emulator.

项目地址:https://gitcode.com/gh_mirrors/js/jsnes
点击查看免费下载

本指南以 JSNES 仓库的开发指引文档(CLAUDE.md)为主线,系统讲解这款 JavaScript NES 模拟器的核心架构、常用开发命令、硬件级精度实现与自动化测试体系。读完本文,你将掌握 JSNES 的组件划分与事件驱动设计、CPU/PPU/PAPU 的底层工作原理(Open Bus、Dummy Read、PPU Catch-up 等硬件行为),以及如何运行测试、基准测试与构建发行包,为二次开发或深入理解模拟器实现打下基础。

一、仓库定位与开发总则

JSNES 是一个纯 JavaScript 编写的 NES 模拟器库,同时支持浏览器与 Node.js 环境,入口模块位于 src/index.js,对外导出NES、Browser、Controller、GameGenie四个类。

开发指引文档对代码风格提出了一个明确的总原则:

这份代码库应当让不熟悉 NES 或模拟器的人也能读懂,因此要比平时写更多注释与文档,并解释必要的上下文。

具体落实到开发行为上包括:

  • 对不直观的代码(例如为什么某条指令要多一次总线周期)要尽量注释,并附上 nesdev wiki 等参考资料链接;
  • 高层的架构决策写入 CLAUDE.md,属于单点代码或测试的细节则直接以注释形式写在对应位置;
  • 公共 API(方法、选项、导出)发生变化时,同步更新 README.md,因为其中包含NES与Browser两个类的用法示例和 API 参考。

二、必备命令一览

开发指引文档整理了仓库的 6 个核心命令,全部定义在 package.json 的scripts中:

命令作用对应 npm script
npm test格式检查 + 类型检查 + 全部测试,提交代码前必须通过typecheck && prettier --check && node --test ./test/*.spec.js
npm run build构建发行文件dist/jsnes.js与dist/jsnes.min.jswebpack
npm run format用 Prettier 自动格式化全部代码(核心库与 web 前端)prettier --write '**/*.{js,jsx,css}'
npm run test:watch监听模式下运行测试,适合开发迭代node --test --watch ./test/*.spec.js
npm run typecheckTypeScript 类型检查(校验.d.ts文件)tsc --noEmit
node bench.js运行性能基准测试—(脚本见 bench.js)

其中node bench.js的用法为node bench.js [frames] [rom],例如:

node bench.js # 默认 1000 帧,跑全部 ROM node bench.js 5000 # 5000 帧,全部 ROM node bench.js 1000 croom # 1000 帧,仅 croom

脚本会依次对roms/croom/croom.nes、roms/lj65/lj65.nes、roms/nestest/nestest.nes执行预热(20 帧让 JIT 编译热点路径)、reloadROM()复位,再计时运行指定帧数并统计帧率。文档记录的基准线约为1800 fps——建议在重大改动或怀疑性能回归/提升时运行它作为对照。

三、代码架构:镜像真实 NES 硬件的组件化设计

JSNES 采用组件化架构,每个子系统对应一个独立的类,全部位于src/目录:

核心组件

组件文件职责
主协调器src/nes.js中央调度类,协调所有模拟组件;接收帧渲染、音频输出、状态更新等回调
CPUsrc/cpu.js6502 处理器,64KB 地址空间,指令执行与中断处理(NMI、IRQ、reset)
PPUsrc/ppu/index.jsPicture Processing Unit,256×240 图形渲染、VRAM 管理、背景/精灵渲染与卷轴
PAPUsrc/papu/index.jsAudio Processing Unit,实现 NES 的 5 路音频通道(2 路方波、三角波、噪声、DMC),44.1kHz/48kHz 采样生成
内存映射器src/mappers/卡带内存映射器(编号 0-240 不等),采用继承层次,全部继承自 Mapper 0 并覆写特定的 bank/内存映射行为
ROM 加载器src/rom.js解析 iNES 格式 ROM 文件,提取 PRG-ROM/CHR-ROM,并确定适用的 mapper
Tile 渲染器src/tile.js8×8 像素 tile 渲染,支持精灵翻转(水平/垂直)、alpha 混合优先级与逐扫描线渲染
手柄控制器src/controller.js2 个手柄、每柄 8 键(A、B、SELECT、START、UP、DOWN、LEFT、RIGHT)的状态管理与串行 strobe 协议
工具函数src/utils.jstoJSON()/fromJSON()状态序列化辅助,CPU、PPU、PAPU、mapper 均通过它支持存档

关键架构模式

  • 事件驱动设计:主NES类通过回调对外输出,包括onFrame、onAudioSample、onStatusUpdate、onBatteryRamWrite;
  • 组件分离:每个 NES 子系统是独立类,接口清晰;
  • mapper 继承:通过继承复用公共代码,同时支持不同卡带的专属行为;
  • 帧级执行:60 FPS 时序,配合精确的 CPU 周期计数。

从源码看,NES构造器在 src/nes.js 中聚合创建了CPU、PPU、PAPU、GameGenie与两个Controller实例,默认开启音频模拟(emulateSound: true),采样率默认 48000 Hz;帧循环则在frame()(src/nes.js)中逐指令执行,并在每条指令后批量推进 APU 时钟。

基本使用模式

开发指引文档给出了最典型的集成方式:

var nes = new jsnes.NES({ onFrame: function(frameBuffer) { /* render 256x240 pixels */ }, onAudioSample: function(left, right) { /* play audio */ } }); nes.loadROM(romData); nes.frame(); // Execute one frame nes.buttonDown(1, jsnes.Controller.BUTTON_A); // Handle input

注意两点集成约定:ROM 应以二进制字符串或字节数组形式加载;60 FPS 的帧时序管理由集成方负责,模拟器本身不内置定时器。

四、测试体系:从单元测试到硬件精度验证

测试使用 Node 内置测试运行器(node:test+node:assert/strict),无需额外测试框架。

测试文件与覆盖范围

测试文件覆盖内容
test/nes.spec.js基本初始化、ROM 加载、帧生成;使用croom.nes测试 ROM 做回归测试,含帧缓冲校验与错误处理
test/cpu.spec.js全面的 CPU 指令测试(源自上游 wedNESday 与 nintengo 测试套件)
test/mappers.spec.jsMapper 功能测试(ROM 保护、SRAM 写入、RAM 镜像)
test/nestest.spec.js完整的 nestest.nes ROM 测试框架,为全部 CPU 指令与寻址模式提供详细错误码映射
test/accuracycoin.spec.jsAccuracyCoin 测试 ROM 框架,共 134 项硬件精度测试

测试 ROM

  • roms/croom/:用于自动化测试的简单测试 ROM;
  • roms/AccuracyCoin/:综合精度测试 ROM(134 项测试,覆盖 CPU、PPU、APU 行为),其源码 AccuracyCoin.asm 也一并收录;
  • roms/nestest/:CPU 指令测试 ROM(含官方与非官方指令);
  • local-roms/:供人工测试的 ROM 集合。

AccuracyCoin 测试的运作机制

从 test/accuracycoin.spec.js 的源码看,测试结果以固定编码写入内存地址:0x00表示未运行,(n << 2) | 1表示通过(低 2 位为 01,高位编码行为变体编号),(n << 2) | 2表示失败(n 为失败的子测试编号),0xFF表示跳过。测试流程为:加载 ROM → 运行 60 帧等待菜单初始化 → 模拟按下 Start 触发 "run all tests" → 轮询地址$35的运行标志直到归零。

当前已知失败项统一记录在KNOWN_FAILURES对象中(test/accuracycoin.spec.js),按类别标注了未实现的原因,例如:

  • 中断延迟类:Interrupt flag latency、NMI overlap BRK等(未做到周期级精确);
  • DMA 类:DMA + $2002 Read、DMC DMA bus conflicts等(DMA 原子化处理,缺少总线级交错);
  • PPU 渲染类:Stale BG shift registers、BG serial in等(需要 dot 级精确的 PPU 渲染管线)。

一个重要的调试原则在文档中被特别强调:

AccuracyCoin 和 nestest 是绝对正确的——它们能在真实 NES 上通过。不要把错误归咎于测试 ROM。

这意味着当测试失败时,应当检查模拟器实现,而不是怀疑测试本身。

五、NES 硬件精度实现细节

JSNES 实现了一系列由 AccuracyCoin 测试 ROM 验证过的硬件级精确行为,这是本文档最具技术深度的部分。

5.1 Open Bus(开放总线)

NES 数据总线会保留最近一次读/写的值。当 CPU 从未映射地址或只写地址读取时,返回的就是这个 "open bus" 残留值。CPU 在 src/cpu.js 中用cpu.dataBus追踪该值,并在每次 load、write、push、pull、取指和中断向量读取时更新。

各子系统对 open bus 的处理规则:

  • CPU open bus 区域:$4018-$5FFF(未映射的扩展区)、$4000-$4014(只写 APU 寄存器);
  • PPU open bus:PPU 有自己的内部 I/O 锁存ppu.openBusLatch(src/ppu/index.js),在每次 PPU 寄存器写入时更新;只写寄存器($2000、$2001、$2003、$2005、$2006)读取时返回该锁存值;$2002的高 3 位(bit 7-5)返回状态,低 5 位(bit 4-0)返回锁存值;
  • 手柄 open bus:$4016/$4017只驱动 bit 0-4,bit 5-7 来自 CPU 数据总线——这一逻辑在 src/mappers/mapper0.js 中可见:(this.joy1Read() & 0x1f) | (this.nes.cpu.dataBus & 0xe0);
  • $4015bit 5:不由 APU 驱动,来自 CPU 数据总线。

5.2 Dummy Reads / Dummy Writes(哑读与哑写)

6502 在寻址期间会执行 "dummy reads"——在 CPU 计算最终有效地址的过程中,额外从中间/错误地址读取的总线周期。这些是真实可见的总线操作:它们会更新数据总线并触发 I/O 副作用(例如读取$4015会清除中断标志)。因此在 src/cpu.js 的寻址模式实现中,模拟器严格保留了这些周期,注释明确写道"跳过它们将是正确性 bug 而非优化"。

哑读发生的场景(对应 src/cpu.js 中的 case 编号):

  • 绝对索引寻址(case 8、9):发生跨页时,先从高字节未修正的地址读取;
  • 零页索引寻址(case 6、7):加 X/Y 之前,先从未索引的零页地址读取;
  • 先索引间接(case 10):加 X 之前,先从指针地址读取;
  • 后索引间接(case 11):跨页行为与绝对索引相同;
  • 存储指令(STA/STX/STY)与 RMW 指令:即使未跨页也会执行索引哑读。

RMW 指令(ASL、LSR、ROL、ROR、INC、DEC,以及非官方指令 SLO、SRE、RLA、RRA、DCP、ISC)还会执行一次dummy write——在写入修改后的值之前,先把原始值写回目标地址。ASL 的实现(src/cpu.js)完整展示了这一周期模式:索引哑读 → 读取有效地址 → 写回原值(dummy write)→ 写入修改值。注释特别指出:向$2007这样的 I/O 寄存器写两次会有可见副作用,因此不能省略。

5.3 PPU Catch-up(PPU 追赶)

真实硬件上 CPU 与 PPU 是锁步推进的(每个 CPU 周期对应 3 个 PPU dot)。但模拟器为性能考虑将 CPU 指令作为原子单元执行、之后再推进 PPU,这会导致指令中途读取 PPU 寄存器(例如读取$2002的 VBlank 状态)时看到过期的 PPU 状态。

解决方案的演进在源码中清晰可见:早期实现中cpu.load()/cpu.write()会在访问 PPU 寄存器($2000-$3FFF)前调用_ppuCatchUp(),按instrBusCycles * 3个 dot 推进 PPU(即该指令已完成的总线操作数 × 3),帧循环再从总量中减去已推进的 dot。而当前实现(src/cpu.js)已将 PPU 推进内联到每个总线操作中——load()、loadDirect()、load16bit()、write()、push()、pull()每次调用后都执行this.nes.ppu.advanceDots(3),且采用"先读后推进"(step-after)顺序,与旧_ppuCatchUp()的语义完全一致:读取看到的是此前所有总线周期推进后的 PPU 状态,但不包含当前周期的 dot。主帧循环(src/nes.js)则负责在指令结束后检查 VBlank 是否触发,并批量推进 APU。

5.4 JSR 周期顺序

真实 6502 在压入返回地址之后才读取 JSR 目标地址的高字节,使其成为进入目标前的最后一次总线操作。这很重要:当 JSR 跳转到未映射地址时,入口处的数据总线值正是目标地址的高字节。模拟器严格遵循此顺序。

5.5 APU 帧中断

帧中断标志(frameIrqActive)在 4 步帧计数器序列的第 4 步置位,但仅当 IRQ 抑制标志被清除(frameIrqEnabled为 true)时才会置位。写入$4017并设置 bit 6 会完全阻止该标志被置位——它不只是抑制 IRQ 发出,而是阻止标志出现在$4015的 bit 6 中。

源码实现位于 src/papu/index.js:bit 7 选择序列模式(0=4 步,1=5 步),bit 6 控制 IRQ 抑制;写$4017还会重置帧计数器内部的分频器(真实硬件上重置有延迟,延迟取决于写入时的 APU 时钟相位:奇数相位 3 个 CPU 周期、偶数相位 4 个周期,代码通过将计数器初始化为负值来补偿);5 步模式下写入周期会立即触发一次 quarter-frame 与 half-frame 时钟。

5.6 DMC DMA 总线劫持

DMC 通道的 DMA 传输会劫持 CPU 总线周期,这与部分非官方指令(SHx 家族)产生交互。DMA 期间数据总线上的值会影响这些指令的输出——因为它们会将要写入地址的高字节与累加器/寄存器做 AND 运算。

由于模拟器无法真正将 DMA 读取与指令执行交错(音频在每条指令后才批量时钟),代码采用了近似方案(src/cpu.js):

  1. emulate()开始时快照_dmcFetchCycles(距下一次 DMC DMA 读取还有多少个 CPU 周期,由_cyclesToNextDmcFetch()计算);
  2. 每个 SHx 指令检查 DMA 是否会在其总线周期内触发(_dmcFetchCycles <= instrBusCycles),若触发则从存储值中丢弃& (H+1)因子——例如 SHY 正常情况下存储Y & (H+1),被 DMA 劫持后只存储Y;
  3. 存储指令总是执行索引哑读(即使未跨页),因此instrBusCycles计数对重叠时机的判断是准确的;
  4. DMC 初始加载($4015写入)在缓冲区为空时立即触发nextSample(),匹配真实硬件时序,测试 ROM 依赖这一点同步其 DMA 时序循环。

5.7 非官方指令(Unofficial Opcodes)

CPU 实现了通过 nestest 所需的全套非官方 6502 指令:ALR、ANC、ARR、AXS、LAX、SAX、DCP、ISC、RLA、RRA、SLO、SRE、SKB、IGN、SHA、SHX、SHY、SHS、LAE、ANE、LXA,以及 DUMMY(周期暂停)。其中 SHx/SHA 家族具有复杂的总线交互,代码中有专门文档说明(src/cpu.js)。test/nestest.spec.js则为这些指令提供了按错误码连续区间划分的测试分组(如 "Unofficial NOP tests"、"Unofficial LAX tests"、"Unofficial SLO tests" 等,见 test/nestest.spec.js)。

六、存档与电池备份数据

JSNES 通过NES类上的toJSON()/fromJSON()提供完整的存档支持,可序列化/反序列化 CPU、PPU、PAPU 与 mapper 的完整状态。PPU的toJSON()(src/ppu/index.js)展示了典型做法:基于utils.js的通用序列化辅助,再递归处理 nametable 与 tile 数据。

电池备份的 SRAM 数据通过onBatteryRamWrite(address, value)回调对外暴露,集成方应在该回调中持久化存档数据(例如写入 localStorage 或服务端)。

七、Zapper(光线枪)支持

NES 类对外暴露三个光线枪输入方法:

  • zapperMove(x, y):移动光线枪到指定像素坐标(内部写入 mapper 的zapperX/zapperY,见 src/nes.js);
  • zapperFireDown()/zapperFireUp():按下/释放扳机。

光线检测由 PPU 的isPixelWhite(x, y)方法完成(src/ppu/index.js):触发渲染后检查帧缓冲中对应像素是否为纯白0xffffff——这是光线枪命中判断的基础。

八、错误恢复机制

NES 维护一个crashed标志:帧执行期间发生异常后,后续调用frame()会直接抛出"Game has crashed. Call reset() or loadROM() to restart."(见 src/nes.js 与 src/nes.js 的异常捕获)。集成方必须调用reset()或loadROM()才能恢复运行。

九、构建流程与质量要求

Webpack 构建

Webpack 配置生成兼容浏览器与 Node.js 的 UMD 模块:

  • 入口:src/index.js(导出NES和Controller类,实际还包含Browser与GameGenie);
  • 输出:dist/jsnes.js(常规)与dist/jsnes.min.js(压缩);
  • 构建过程包含 ESLint 检查与 source map 生成;
  • 浏览器全局变量名为jsnes;
  • TypeScript 类型定义:src/nes.d.ts与src/controller.d.ts提供公共 API 类型;
  • CI:GitHub Actions 在 push/PR 时以 Node.js 22.x 运行构建与测试。

代码质量门槛

  • 所有代码必须符合 Prettier 格式(由测试套件强制执行——格式不合格,npm test直接失败);
  • ESLint 规则在构建时强制检查;
  • 提交前测试必须通过;
  • 帧缓冲回归测试用于防止渲染回归。

十、开发注意事项与资源

工程层面

  • example/目录包含一个基础 Web 实现,但文档明确指出其存在缺陷,Web 集成优先推荐 jsnes-web 仓库;
  • 仓库同时提供一个完整功能的 React 前端(web/目录),包含 web/package.json、web/src/Emulator.jsx 等组件;
  • ROM 应以二进制字符串或字节数组加载;
  • 帧时序(60 FPS)由集成应用负责管理;
  • 手柄输入是简单的按键状态管理,每柄 8 键(Controller类还额外提供BUTTON_TURBO_A/BUTTON_TURBO_B涡轮键,见 src/controller.js);
  • 修改公共 API 后务必同步更新 README 中的用法示例与 API 参考。

参考资源

文档推荐在实现新功能前先查阅 nesdev wiki 的 NES 参考指南(NES reference guide、Open bus behavior、CPU addressing modes、Catch-up、APU Frame Counter、APU DMC 等页面),并强调在代码中尽量链接这些参考资料。

结语

JSNES 的开发指引文档不仅是一份命令手册,更是理解模拟器内部如何逼近真实 NES 硬件行为的地图:从组件化的架构划分,到 Open Bus、Dummy Read、PPU Catch-up、DMC DMA 劫持等硬件精度细节,再到 AccuracyCoin 与 nestest 驱动的严格测试体系。结合源码逐行对照,开发者可以清晰地看到"为了通过硬件测试 ROM,模拟器在周期级与总线级做了哪些努力",这也是任何希望深入 NES 模拟器实现或参与 JSNES 开发的读者最值得反复研读的部分。

  • 游戏开发

【免费下载链接】jsnes

A JavaScript NES emulator.

项目地址:https://gitcode.com/gh_mirrors/js/jsnes
点击查看免费下载
上一篇:golang-jwt/jwt v5 深度指南:在 buildkit 中实现 JWT 的生成、解析与校验
下一篇:手机号码定位工具:一键查询电话号码地理位置的开源解决方案

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进