一个 NES CPU 被编译进了一个现成 Qwen3.5 模型的权重中。
NES 的 6502 CPU(其 2A03 芯片的核心)、它的 2 KB 工作 RAM,以及卡带的程序Qwen3_5ForCausalLMROM 都在一个标准 checkpoint 内运行。没有人训练这些权重;一个编译器计算出了每一个权重。当你玩游戏时,模型一次一个 token 地预测 CPU 的执行日志。一个小型宿主程序在它周围运行主机的其余部分:它绘制画面、读取手柄,并触发 vblank 中断。
nesformer 建立在Qwenputer之上,由Divyansh Agrawal开发,后者将一个 CHIP-8 以及随后一个 RISC-V CPU 编译进了 Qwen3.5-4B。它复用了 Qwenputer 的编译器及其核心技巧:模型的 Gated DeltaNet 状态就是机器的内存。
| Super Mario Bros.(World 1-1) | 完整游戏,作者 Doug Fraker(标题画面和第一关) |
|---|---|
![]() |
![]() |
这些运行中的每一个 CPU token 都来自模型。对于随附的测试游戏,每一帧也都与常规 6502 模拟器的输出完全一致(见如何验证)。
快速开始
1. 文件夹结构
解压nesformer.zip和nesformer-weights.zip到同一个文件夹。权重放在旁边,而不是nesformer/包内部:
nesformer/ <- run commands from here
├── nesformer/ <- the Python package
├── chipformer/ <- Qwenputer's compiler
├── roms/ <- test ROMs (put your own here too)
├── examples/ <- sample GIFs and frame sheets
├── tests/
├── LICENSE
├── LICENSE.chipformer
├── README.md
├── pyproject.toml
└── weights/ <- from nesformer-weights.zip
├── config.json
├── generation_config.json
├── model.safetensors
├── nesformer.json
└── SHA256SUMS
CLI 会在weights/旁边查找nesformer/包。若要将权重放在别处,请将--weights /path/to/weights传给run,check或trace,或提供info的路径。
要确认权重解压正确,请从顶层文件夹运行shasum -a 256 -c weights/SHA256SUMS。
2. 安装
你需要 Python 3.11 或更新版本。
python3 -m venv .venv source .venv/bin/activate #Windows:.venv\Scripts\activate pip install numpy scipy numba pillow
3. 玩一个游戏
python -m nesformer run roms/thwaite.nes --frames 600 --show \
--buttons "start@90-95+200-205,a@230-233+250-253+270-273+290-293+310-313+330-333+350-353+370-373+390-393+410-413+430-433+450-453+470-473+490-493+510-513+530-533+550-553+570-573+590-593"--show会在模型生成每一帧时将其绘制在你的终端中。运行结束时,CLI 会在当前文件夹中保存一个以 ROM 命名的动画 GIF(此处为thwaite.gif)。
预计它会很慢。首次运行会花大约 20 秒用 numba 编译引擎。之后,一帧需要 1–6 秒,因此 600 帧(10 秒的 NES 时间)大约需要 25 分钟。
玩你自己的 ROM
将run指向任何.nes使用 NROM 板(iNES mapper 0)的文件,例如 Super Mario Bros.、Donkey Kong 或 Excitebike。ROM 由你提供;nesformer 只随附roms/中的三个自由许可的测试 ROM。
python -m nesformer run roms/smb.nes --frames 600 --show \
--buttons "start@60-65,right@200-600,a@250-265+300-315+360-375+420-435+480-495+540-555"该脚本启动游戏,然后让 Mario 向右走,并大约每秒跳一次。CLI 在启动时把 ROM 加载到模型的状态中,因此同一套权重无需重新构建即可运行任何 NROM 游戏。其他板卡上的游戏会停止并给出消息:见限制。
控制器脚本
--buttons接受一个以逗号分隔的条目列表。每个条目命名一个或多个按钮,用+连接,然后是@,然后是一个或多个帧范围,用+连接:
start@60-65 press Start during frames 60 to 65
right@200-600 hold Right from frame 200 to frame 600
a@250-265+300-315 press A twice
right+b@200-600 hold Right and B together (run, in Super Mario Bros.)
按钮包括a、b、select、start、up、down、left和right。帧从开机起以每秒 60 帧计数,因此第 600 帧是第 10 秒。在无法移动的屏幕上游戏会忽略输入,所以稍微提前按住按钮没有坏处。在 Super Mario Bros. 中,按住 A 键更久会跳得更高。
命令
| 命令 | 作用 |
|---|---|
python -m nesformer run ROM |
在权重上运行 ROM 并保存 GIF |
python -m nesformer check ROM |
并排运行权重和参考 CPU;在第一个差异处停止 |
python -m nesformer trace ROM |
打印模型的 token 流(默认 40 个 token) |
python -m nesformer build |
编译电路并写入weights/(约 10 秒) |
python -m nesformer info |
描述检查点 |
以下命令的选项run:
| 选项 | 默认值 | 含义 |
|---|---|---|
--frames N |
120 | 在 N 帧后停止 |
--buttons SCRIPT |
无 | 控制器输入(见上文) |
--show |
关闭 | 在终端中绘制帧(需要 24 位色,例如 iTerm2 或 Windows Terminal) |
--gif PATH |
ROM 名称 | 保存动画 GIF 的位置 |
--png DIR |
关闭 | 将每个保留的帧保存为 DIR 中的 PNG。仅使用--png时,不保存 GIF。添加--gif可同时获得两者。 |
--every N |
1 | 每 N 帧保留一帧 |
--scale N |
2 | 将保存的帧放大 N 倍 |
--engine |
fast |
fast(numba)、numpy,或hf(原版 transformers;需要 torch,约 10 tokens/s) |
--no-skip |
关闭 | 运行空闲等待循环的每次迭代(更慢;帧相同) |
--max-tokens N |
无 | 在 N 个 token 后停止 |
--trace FILE |
无 | 保存 token id |
run、check和trace都接受--weights、--engine、--buttons和--no-skip。check --tokens N设置比较的距离(默认 50,000)。trace --tokens N设置打印多少个 token(默认 40)。build --out DIR将检查点写到weights/之外的其他位置。
工作原理
权重中有什么,没有什么
| 在模型中(权重 + DeltaNet 状态) | 在主机中(nesformer/host.py) |
|---|---|
| 6502 核心:全部 151 个官方操作码、每种寻址模式、标志、栈、BRK 和 NMI 入口 | PPU:扫描线渲染器和调色板 |
| 2 KB 工作 RAM | 控制器;APU 寄存器(写入,否则忽略) |
| 32 KB PRG-ROM,存储两份:一份用于取指令,一份用于数据读取 | vblank 时的 NMI 时序,作为NMItoken 注入 |
OAM DMA 读取(256 字节以DMtoken 出现) |
对 I/O 读取的应答,作为INtoken 注入 |
这个拆分遵循 Qwenputer 的 CHIP-8 构建,其宿主注入定时器 tick,并从 trace 中绘制显示。宿主除了通过 trace 之外,永远看不到 CPU 的寄存器或 RAM。
内存技巧是这样工作的。每个 DeltaNet 头维护一个 128 × 128 的状态矩阵,delta 规则在每个 token 处更新它。在门保持打开且键为 one-hot 的情况下,一次更新恰好覆盖该矩阵的一行,而 one-hot 查询会将其读回。因此每一行都是一个内存单元,卡带、RAM 和寄存器都存在于行中。
检查点
weights/是一个普通的 Hugging Face 文件夹。它通过以下方式加载Qwen3_5ForCausalLM.from_pretrained且不需要自定义代码。
| 架构 | Qwen3_5ForCausalLM,采用 Qwen3.5-4B 的 DeltaNet 头形状(16 个键头、32 个值头、维度 128)和 MLP 宽度(9,216) |
| 层数 | 7 个 Gated DeltaNet 层(电路),外加 1 个全零注意力层,Hugging Face 的缓存需要它 |
| 隐藏大小 | 768 |
| 词表 | 263,808 个 token |
| 参数量 | bf16 下为 466.6M(933 MB),其中9.3M 为非零,因此权重压缩后为 3.3 MB |
nesformer.json记录机器的每个部分由哪一层和哪个头持有。一次运行会将卡带、寄存器和 RAM 写入那些 DeltaNet 状态,然后进行贪心解码。
层计划
| 层 | 注意力(状态) | MLP |
|---|---|---|
| 1 | PC 锚点;指令计数器;A、X、Y、SP;标志 N V Z C I D;指针锁存器;NMI 标志 | 计数器温度计;SP ± 1;LO + Y |
| 2 | — | PC = 锚点 + 自那以后执行的字节数;PC + 1、PC + 2 |
| 3 | PRG 取指:PC 处的操作码和两个操作数字节 | 操作码解码;操作数位;索引和;分支测试和目标 |
| 4 | — | 有效地址,按寻址模式和记录阶段;push 存储的字节 |
| 5 | 在有效地址处读取 RAM 和 PRG 数据 | 操作数;ADC/SBC 加法器、比较、BIT;地址解码 |
| 6 | — | 结果字节、进位、溢出、标志、跳转目标 |
| 7 | — | 下一个 token |
trace
每条指令变成一条记录,记录的最后一个 token 提交它。与 Qwenputer 的 RISC-V 核心一样,PC 是隐含的而非写出的:每个最终 token 携带其指令的长度,三个 DeltaNet 计数器统计自上次跳转以来每种长度的指令数,PC 就是跳转目标加上该偏移量。摘自 Full Game:
SEI P(000100 px0 L1)
LDX #$40 X($40 c0 v0 px0 L2)
STX $4017 IOW($4017=$40 L3)
TXS S($FF L1)
BIT $2002 IOR($2002) IN($00) P(000110 px0 L3) <- the host injects IN
BPL $8017 J($8017)
JSR $8279 PSH($FE) PSH($FD) J($8279) <- the return address is pushed in-circuit
RTS PUL($FC) LO($7B) PUL($FD) J($827C)
STA ($72),Y LO($08) HI($03) ST($00 a7=0 L2) <- pointer bytes latched, then the store
STA $4014 IOW($4014=$02 L0) DM(0:$FF) … DM(255:$00) NT(L3) <- OAM DMA
大多数指令只占一个 token;真实游戏代码平均每条指令 1.1–1.3 个 token。nesformer/spec.py记录了每个 token 家族。
如何检查的
-
参考 CPU(
nesformer/cpu.py)与 nestest 的黄金日志在 3,348 条指令上匹配,py65 模拟器在 570 万条游戏代码指令上匹配。 -
精确电路在 Qwenputer 的评估器中运行,并带有 bf16 精确性检查,预测出每一个参考 token:包括 nestest 的全部内容,以及两款游戏启动序列的 283,610 个位置。
-
原生 transformers 中的检查点(bf16,CPU)预测了 nestest 的 2,651 个位置以及 Full Game,没有出现不匹配。
-
权重在自由运行下生成的轨迹与参考 CPU 完全一致:
运行 Token 数 帧数 NES 时间 结果 nestest(官方操作码) 4,586 — — 完全一致,以预期的 HALT 结束 Thwaite, check700,000 271 4.5 s 轨迹和帧完全一致 Thwaite, run1,916,527 600 10.0 s 全部 300 个保存帧均匹配 Full Game, run2,645,885 420 7.0 s 全部 210 个保存帧均匹配 -
空闲循环跳过不会改变任何可见内容:帧和 I/O 写入(包括周期时间戳)都与未启用该优化时一致。
速度
默认引擎计算 Qwen3.5 的前向传播,基于model.safetensors,使用 numba 编译并跳过全零块。在构建它的机器的一个 CPU 核心上,它运行速度约为 1,650 tokens/s。纯 numpy 引擎约为 210,原生 transformers 约为 10。真实的 NES 每秒需要约 700,000 个 token。
游戏在每一帧的大部分时间里都花在一个等待 vblank 的循环中。当宿主看到循环体完全重复、且没有 RAM 或 I/O 写入时,它会把时钟推进到下一个 PPU 事件之前,剩余的迭代仍会执行。这将 token 数减少了 2.6× 到 4.2×。总体而言,nesformer 比真实主机慢 150–290×。
限制
- 仅支持 NROM 卡带(iNES mapper 0)。CPU 一次只能寻址 32 KB 的程序 ROM。更大的游戏带有存储体切换芯片(即mapper,例如 MMC1 或 MMC3),用于切换 CPU 所看到的 ROM 部分。nesformer 的 ROM 读取发生在模型内部,来自一个固定的 32 KB 表,因此它目前无法跟随存储体切换。支持 mapper 意味着将整个 ROM 存储在状态中,同时把存储体寄存器也保存在那里,并增加一个阶段,将每个 CPU 地址转换为 ROM 地址。
- 仅支持官方操作码。非官方操作码会发出
HALT。 - 代码必须从卡带 ROM 运行。跳转到 RAM 会发出
HALT。 - 无 IRQ(APU 帧 IRQ、DMC)。BRK 和 NMI 正常工作。
- 对 I/O 寄存器的读-改-写指令不会产生 I/O 写操作,且 $4020–$7FFF 读为 0(无 PRG-RAM)。
- PPU 和 APU 不在权重中。PPU 一次渲染一条扫描线,因此扫描线中途的效果无法复现。Sprite-0 命中与 sprite 溢出为近似实现,且没有声音。
测试
pip install pytest py65 python -m pytest -q tests
测试需要几分钟。它们将参考 CPU 与 nestest 的日志对比,将电路与参考实现对比,将自由运行的权重与参考实现对比,并检查空闲循环跳过。
在原生 transformers 中运行检查点
pip install "torch>=2.6" "transformers>=5.22" python -m nesformer run roms/thwaite.nes --frames 3 --engine hf
nesformer/hfcheck.py展示了完整流程:加载检查点,解码一个填充 token,将机器状态写入缓存的 DeltaNet 状态,然后逐个输入 token。
文件
| 路径 | 内容 |
|---|---|
nesformer/spec.py |
token 族与记录 |
nesformer/cpu.py |
写入 trace 的参考 6502 |
nesformer/host.py |
CPU 周边的一切:PPU、控制器、NMI 时序、周期计数、空闲跳过 |
nesformer/core.py |
电路与状态快照 |
nesformer/weights.py |
将电路转换为Qwen3_5ForCausalLM检查点 |
nesformer/engine.py,fastengine.py |
运行权重(numpy / numba) |
nesformer/hfcheck.py |
在原生 transformers 中运行权重 |
nesformer/run.py,check.py,cli.py,__main__.py |
驱动程序、教师强制检查、CLI |
chipformer/ |
Qwenputer 的 q35 编译器(q35/发射器与原语,dsl/图) |
weights/ |
编译后的检查点及其 SHA-256 校验和 |
roms/ |
测试 ROM;roms/SOURCES.md列出其作者与许可证 |
examples/ |
运行生成的 GIF 与帧表;demo.gif顶部展示的是 Super Mario Bros. 的运行 |
tests/test_nesformer.py |
测试套件 |
致谢
- Qwenputer及其
chipformer编译器:MIT, © 2026 Divyansh Agrawal(LICENSE.chipformer)。该许可证同样涵盖改编自 Qwenputer 的代码,位于nesformer/core.py和nesformer/weights.py。其最后一段提及 Qwenputer 的 CHIP-8 ROM,本仓库不包含这些内容。 - 测试 ROM:nestest 由 Kevin Horton 编写;Full Game 由 Doug Fraker 编写(MIT);Thwaite 由 Damian Yerrick 编写(GPL-3.0,源码位于pinobatch/thwaite-nes)。
许可证
nesformer 以 MIT 许可证发布(见LICENSE)。roms/中的 ROM 保留其各自的许可证,列于roms/SOURCES.md。

