花了两个月,我写了一款 NES 模拟器

38 阅读6分钟

小时候玩红白机(或者更准确地说,是各种兼容机),很多画面我到现在都记得:电视机的雪花点、手柄线在地上绕来绕去、开机那一声“哔——”,还有卡带吹两口气再插回去的玄学仪式。
放学回家第一件事不是写作业,是趁家里人还没管到,先打一会儿《马里奥》或者《魂斗罗》,死了就重来,能多过一关都觉得自己厉害得不行。

后来长大了,我突然意识到一件事:我们记忆里的那些画面和声音,其实是硬件在极其有限的条件下“挤出来”的结果。
这也让我一直有个执念:亲手做一个 NES 模拟器——把一台三十多年前的小机器的 CPU/PPU/APU 和各种“怪癖”一点点复刻出来,让它重新跑起来,像复活一样。


一次放弃与一次回头:从 Kotlin 到 Rust × Flutter

其实我在 2022 年就尝试过一次,用 Kotlin 写。那会儿我对 NES 的整体架构不太理解,基本属于“照着别人的项目懵着写”,CPU 部分写了一点点就卡住了,最后没写完就放弃了。

但最近不知道怎么又上头了。于是我重新开坑,这次选了我更喜欢的组合:Rust + Flutter。Rust 的性能和“写对了就很踏实”的感觉很对我胃口;Flutter 的 UI 也更现代,我想做一个自己愿意每天打开、愿意长期打磨的模拟器,而不是一个“能跑就完事”的 demo。

这个项目我叫它 Nesium:一个追求周期精度(cycle-accurate) 的 NES 模拟器。


第一次看到画面:兴奋到有点不真实

我必须承认,当我第一次在自己写的模拟器上看到画面出来的时候,是无比兴奋的。哪怕那张画面有 bug:颜色不对、滚屏怪怪的、偶尔还会抖一下……但那一刻的感觉非常明确——“它活了”。

尤其是跟我 2022 年那次尝试比起来,这几乎是跨越式的进步:那次我连“成果的影子”都看不到,而这次我至少真的把 CPU 和 PPU 推到了能出画面的阶段。

模拟器最折磨人的地方也在这儿:它的正反馈来得太慢
你写普通业务代码,可能改几行 UI 就能立刻看到效果;但模拟器不行——你至少得把 CPU 跑对一大半、PPU 的时序和寄存器行为也像样,才能看到“最基本的成果”。在此之前,很多时候你面对的只有一堆日志、一个黑屏、或者一个永远不变的死循环。


新手最容易踩的坑:nesdev 文档太“全”,但不“带路”

很多人一开始会去读 nesdev,上面资料确实权威,也非常详细,但对新手来说,它常常是一种灾难:
它不是 step-by-step 的指南,更像一本“硬件百科全书”。

你会在里面看到海量细节:寄存器位含义、open bus、精确到周期的状态机、各种边界行为……信息量大到你很容易被淹没,然后陷入一种状态:

“我好像什么都懂了一点点,但不知道该从哪里开始写。”

我自己也经历过这个阶段。后来我才慢慢明白:做模拟器需要在“足够正确”和“先跑起来”之间找到节奏——先搭一条最小闭环,让 CPU 能跑、PPU 能出画面,再一点点把精度往上拧,用测试 ROM 去逼近真实硬件。


为什么网上一堆 NES 模拟器,我还要重写一个?

原因其实挺“任性”的,但也很现实:

1)兴趣 + 综合工程训练场

模拟器是非常综合的工程:指令级实现、时序同步、音频混音、图形渲染、Mapper 兼容、测试套件回归……每一步都细到能把人逼疯,但也正因为这样,它特别锻炼工程能力。

2)“能跑起来”很快,“写好”很难

写一个能跑起来的 NES 模拟器并不难:CPU 指令能跑、画面出来、声音响了,就能自称“完成”。

但想把它写好,是完全不同的难度:你得尽可能准确还原硬件细节,还要被各种刁钻的测试 ROM 反复拷打。我在项目里专门集成了大量测试 ROM 套件,用来验证 CPU / PPU / APU / Mapper 行为,并维护了通过情况。

我属于那种做事情“既然做了就尽可能做好”的人,所以不太想停在 demo。也因此,我已经写了两个多月,依然还有不少测试没过——这很正常,也说明它大概率会是个要打磨数年的长期项目。

3)我不爱 C/C++,也想要更现代的 UI

很多老牌模拟器是 C 或 C++ 写的。对我这种不怎么爱 C/C++ 的人来说,读代码和折腾构建系统都很痛苦;再加上一些模拟器的 UI 很“工具味”,不太符合我对美观/现代的追求。

所以这次我想用 Rust 把内核做稳,用 Flutter 把体验做漂亮。


Nesium 的整体架构(目前的样子)

我把项目拆成了「内核」和「前端」两层,并且让同一套 Rust 内核可以被不同前端复用。仓库本身是一个 Rust workspace,顶层有 apps/(前端)和 crates/(核心库/胶水层/周边库)这样的组织方式。

一句话概括:

Rust 负责准确模拟硬件;UI 只是“驾驶舱”。

1)Rust 内核:以周期精度为目标

Nesium 的定位是 cycle-accurate,重点在 6502 CPU、PPU、APU 的精确模拟。实现过程中我参考了不少成熟项目的资料与实现思路(尤其是时序、open-bus 行为、音频混音等),少踩了很多坑。

2)两套 UI 前端:一个轻量调试,一个现代体验

目前仓库里提供两种前端:

  • apps/nesium-egui:轻量桌面前端,适合开发期快速调试。
  • apps/nesium_flutter:Flutter 前端,通过 Rust 的胶水层生成/加载原生动态库驱动模拟器。

你可以把它理解成:

[ Rust 内核 ]  <--(FFI/胶水层)-->  [ Flutter UI ]
        |
        +--> [ egui UI(开发调试用)]

3)Web 版:Flutter Web + Web Worker + WASM

仓库也提供 Web 构建方式:Flutter Web + Web Worker + WASM,浏览器里可直接运行(需要自带 ROM)。

4)Mapper 支持:先把“常见的坑”铺开

Mapper 决定兼容性上限。我目前优先覆盖常见 Mapper,并且会在后续持续补齐更刁钻的细节与边界行为。


我真正想把它做到什么程度?

Nesium 的长期方向更偏「精度 + 工具化 + 可扩展」:

  • 精度:挑战更多测试 ROM,持续对齐硬件细节。
  • 调试工具:反汇编、断点、RAM/VRAM/OAM、各种图像可视化等。
  • Lua 脚本:做 TAS/自动化回归/玩法扩展。
  • Netplay:网络联机。

这些都不“短平快”,但我觉得它们才是让模拟器从“播放器”变成“放大镜”的关键。


最后:项目在这儿(欢迎围观/提 Issue)

如果你也对模拟器、时序、或者 Rust × Flutter 这种组合感兴趣,欢迎来交流。这个坑我大概率会填很久——毕竟“写出来”和“写好”,从来不是同一件事。