動作するファミコンを約1GBのQwenウェイトで実現
A Working Nintendo in ~1GB of Qwen Weights (github.com)
要約
このプロジェクトでは、大規模言語モデル(LLM)の重み内に、ファミリーコンピュータ(NES)のCPU、RAM、およびカートリッジROMをコンパイルして組み込むことに成功しました。モデルはCPUの実行ログをトークンごとに予測することでゲームを動作させ、ホストプログラムがグラフィック表示やコントローラー入力を担当します。この技術は、LLMの計算能力をハードウェアエミュレーションに応用する新しいアプローチを示しています。
全文翻訳
NES CPUを標準的なQwen3.5モデルのウェイトにコンパイル。
NESの6502 CPU(2A03チップの中核)、2KBのワークRAM、およびカートリッジのプログラムROMが、標準的なQwen3_5ForCausalLMチェックポイント内で動作します。これらのウェイトは学習されたものではなく、コンパイラによって計算されました。ゲームをプレイする際、モデルはCPUの実行ログを1トークンずつ予測します。小さなホストプログラムがコンソールの残りの部分(描画、コントローラー入力、vblank割り込みの発火)を処理します。
nesformerは、Divyansh AgrawalによるQwenputerを基盤としています。Qwenputerは、CHIP-8とRISC-V CPUをQwen3.5-4Bにコンパイルしました。nesformerはQwenputerのコンパイラとその中心的なトリックを再利用しています:モデルのGated DeltaNet状態がマシンのメモリとなります。
| スーパーマリオブラザーズ(ワールド1-1) | ウェイトによってプレイされたフルゲーム(タイトル画面と最初のレベル) |
|---|---|
| [画像: ウェイトによってプレイされるスーパーマリオブラザーズ] | [画像: ウェイトによってプレイされるフルゲーム] |
これらの実行におけるCPUトークンはすべてモデルから生成されました。バンドルされているテストゲームでは、すべてのフレームが従来の6502エミュレータの出力と完全に一致します(どのようにチェックされたかを参照)。
クイックスタート
1. フォルダレイアウト
nesformer.zipとnesformer-weights.zipを同じフォルダに解凍してください。ウェイトはnesformer/パッケージの隣に配置し、その中には入れないでください。
nesformer/ <- ここからコマンドを実行
├── nesformer/ <- Pythonパッケージ
├── chipformer/ <- Qwenputerのコンパイラ
├── roms/ <- テストROM(自分のROMもここに追加可能)
├── examples/ <- サンプルGIFとフレームシート
├── tests/
├── LICENSE
├── LICENSE.chipformer
├── README.md
├── pyproject.toml
└── weights/ <- nesformer-weights.zip から取得
├── config.json
├── generation_config.json
├── model.safetensors
├── nesformer.json
└── SHA256SUMS
CLIはnesformer/パッケージの隣にあるweights/を探します。ウェイトを別の場所に置きたい場合は、run、check、またはtraceに--weights /path/to/weightsを渡すか、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名(ここではthwaite.gif)でアニメーションGIFをカレントフォルダに保存します。
遅くなることが予想されます。最初の実行では、numbaによるエンジンのコンパイルに約20秒かかります。その後、1フレームあたり1〜6秒かかるため、600フレーム(NES時間で10秒)には約25分かかります。
独自のROMをプレイする
runコマンドを、NROMボード(iNESマッパー0)を使用する.nesファイル(例: Super Mario Bros., Donkey Kong, Excitebike)に向けます。ROMは自分で用意してください。nesformerは、ライセンスフリーのテストROM3つのみをroms/に同梱しています。
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"
このスクリプトはゲームを開始し、その後マリオを右に移動させ、約1秒に1回ジャンプさせます。CLIは起動時にROMをモデルの状態にロードするため、同じウェイトで再ビルドなしに任意のNROMゲームを実行できます。他のボードのゲームはメッセージとともに停止します。詳細は制限事項を参照してください。
コントローラースクリプト
--buttonsは、カンマ区切りのエントリリストを受け取ります。各エントリは、1つ以上のボタン名を+で結合し、その後に@、そして1つ以上のフレーム範囲を+で結合したものです。
start@60-65 フレーム60から65の間でStartボタンを押す
right@200-600 フレーム200から600までRightボタンを押し続ける
a@250-265+300-315 Aボタンを2回押す
right+b@200-600 RightボタンとBボタンを同時に押し続ける(スーパーマリオブラザーズではダッシュ)
ボタンはa、b、select、start、up、down、left、rightです。フレームは電源投入時(毎秒60フレーム)からカウントされるため、フレーム600は10秒後です。ゲームは移動できない画面では入力を無視するため、ボタンを少し早く押し始めても問題ありません。スーパーマリオブラザーズでは、Aボタンを長く押すとより高くジャンプします。
コマンド
| コマンド | 説明 |
|---|---|
python -m nesformer run ROM | ROMをウェイトでプレイし、GIFを保存する |
python -m nesformer check ROM | ウェイトとリファレンスCPUを並行して実行し、最初の違いで停止する |
python -m nesformer trace ROM | モデルのトークンストリームを表示する(デフォルト40トークン) |
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(標準トランスフォーマー、torchが必要、約10トークン/秒) |
--no-skip | オフ | アイドルウェイトループのすべてのイテレーションを実行する(遅くなる、同一フレーム) |
--max-tokens N | なし | Nトークン後に停止する |
--trace FILE | なし | トークンIDを保存する |
run、check、traceはすべて--weights、--engine、--buttons、--no-skipを受け付けます。check --tokens Nは比較する距離を設定します(デフォルト50,000)。trace --tokens Nは表示するトークン数を設定します(デフォルト40)。build --out DIRはチェックポイントをweights/以外の場所に書き出します。
仕組み
ウェイトに含まれるもの、含まれないもの
| モデル内(ウェイト + DeltaNet状態) | ホスト内(nesformer/host.py) |
|---|---|
| 6502コア:公式オペコード151個、すべてのアドレッシングモード、フラグ、スタック、BRKおよびNMIエントリ | PPU:スキャンラインレンダラーとパレット |
| 2KBワークRAM | コントローラー、APUレジスタ(書き込みはされるが、それ以外は無視) |
| 32KB PRG-ROM、2回格納:命令フェッチ用コピー1つ、データ読み取り用コピー1つ | vblank時のNMIタイミング、NMIトークンとして注入 |
OAM DMA読み取り(256バイトはDMトークンとして表示) | I/O読み取りへの応答、INトークンとして注入 |
この分割は、QwenputerのCHIP-8ビルドに従っています。そのホストはタイマーティックを注入し、トレースからディスプレイを描画します。ホストはCPUのレジスタやRAMをトレース経由でしか認識しません。
メモリトリックは次のように機能します。各DeltaNetヘッドは、トークンごとに更新される128×128の状態行列を保持します。ゲートを開いた状態とワンホットキーを使用すると、1つの更新がその行列の1つの行だけを上書きし、ワンホットクエリがそれを読み取ります。したがって、各行はメモリセルであり、カートリッジ、RAM、レジスタはすべて行に格納されます。
チェックポイント
weights/は通常のHugging Faceフォルダです。Qwen3_5ForCausalLM.from_pretrainedでロードでき、カスタムコードは不要です。
| アーキテクチャ | Qwen3_5ForCausalLM、Qwen3.5-4BのDeltaNetヘッド形状(キーヘッド16個、バリューヘッド32個、次元128)、MLP幅(9,216) |
| レイヤー | 7つのGated DeltaNetレイヤー(回路)、およびHugging Faceのキャッシュに必要なゼロ埋めアテンションレイヤー1つ |
| 隠れサイズ | 768 |
| ボキャブラリ | 263,808トークン |
| パラメータ | bf16で466.6M(933MB)、そのうち9.3Mが非ゼロなので、ウェイトは3.3MBに圧縮される |
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でのオペコードと2つのオペランドバイト | オペコードデコード、オペランドビット、インデックス付き合計、分岐テストとターゲット |
| 4 | — | アドレスモードとレコードステージごとの実効アドレス、プッシュが格納するバイト |
| 5 | 実効アドレスでのRAMおよびPRGデータ読み取り | オペランド、ADC/SBC加算器、比較、BIT、アドレスデコード |
| 6 | — | 結果バイト、キャリー、オーバーフロー、フラグ、ジャンプターゲット |
| 7 | — | 次のトークン |
トレース
各命令はレコードになり、レコードの最後のトークンがそれをコミットします。QwenputerのRISC-Vコアと同様に、PCは明示的に書き込まれるのではなく、暗黙的に示されます。各最終トークンは命令の長さを持ち、3つの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) <- ホストがINを注入
BPL $8017 J($8017)
JSR $8279 PSH($FE) PSH($FD) J($8279) <- リターンアドレスがインサーキットでプッシュされる
RTS PUL($FC) LO($7B) PUL($FD) J($827C)
STA ($72),Y LO($08) HI($03) ST($00 a7=0 L2) <- ポインタバイトがラッチされ、その後ストアされる
STA $4014 IOW($4014=$02 L0) DM(0:$FF) … DM(255:$00) NT(L3) <- OAM DMA
ほとんどの命令は1つのトークンで済みますが、実際のゲームコードは命令あたり平均1.1〜1.3トークンです。nesformer/spec.pyはすべてのトークンファミリーを文書化しています。
チェック方法
-
リファレンスCPU(
nesformer/cpu.py)は、3,348命令でnestestのゴールデンログに一致し、ゲームコード570万命令でpy65エミュレータに一致します。 -
正確な回路は、Qwenputerのエバリュエータでbf16の正確性チェックとともに実行され、nestestと2つのゲームのブートシーケンスの283,610ポジションすべてでリファレンストークンを予測します。
-
標準トランスフォーマー(bf16、CPU)のチェックポイントは、ミスマッチなしでnestestとFull Gameの2,651ポジションを予測します。
-
フリーランニングのウェイトは、リファレンスCPUと同一のトレースを生成します:
実行 トークン フレーム NES時間 結果 nestest(公式オペコード) 4,586 — — 同一、期待されるHALTで終了 Thwaite, check700,000 271 4.5秒 同一トレースとフレーム Thwaite, run1,916,527 600 10.0秒 保存された全300フレームが一致 Full Game, run2,645,885 420 7.0秒 保存された全210フレームが一致 -
アイドルループスキップは、目に見える変化をもたらしません。フレームとI/O書き込み、サイクルタイムスタンプを含め、スキップなしの実行と一致します。
スピード
デフォルトエンジンは、numbaでコンパイルされ、ゼロ埋めブロックをスキップしたmodel.safetensorsからのQwen3.5のフォワードパスを計算します。ビルドされたマシンのCPUコア1つで約1,650トークン/秒で動作しました。プレーンなnumpyエンジンは約210、標準トランスフォーマーは約10です。実際のNESは約700,000トークン/秒を必要とします。
vblankを待つループでフレームの大部分が消費されます。ホストがループ本体の繰り返しを検出し、RAMやI/Oの書き込みがない場合、次のPPUイベントの直前までクロックを進め、残りのイテレーションも実行します。これにより、トークン数が2.6倍から4.2倍削減されます。全体として、nesformerは実際のコンソールよりも150〜290倍遅いです。
制限事項
- NROMカートリッジのみ(iNESマッパー0)。CPUは一度に32KBのプログラムROMしかアドレス指定できません。より大きなゲームは、CPUが見るROMの部分を切り替えるバンクスイッチチップ(マッパー、例: MMC1またはMMC3)を搭載しています。nesformerのROM読み取りはモデル内で行われ、固定の32KBテーブルから読み取るため、まだバンクスイッチを追跡できません。マッパーをサポートするには、ROM全体を状態に格納し、バンクレジスタもそこに格納し、各CPUアドレスをROMアドレスに変換するステージを追加する必要があります。
- 公式オペコードのみ。非公式オペコードは
HALTを発行します。 - カートリッジROMからコードを実行する必要があります。RAMへのジャンプは
HALTを発行します。 - IRQなし(APUフレームIRQ、DMC)。BRKとNMIは機能します。
- I/Oレジスタに対するRead-modify-write命令はI/O書き込みを生成せず、$4020〜$7FFFは0として読み取られます(PRG-RAMなし)。
- PPUとAPUはウェイトに含まれていません。PPUは1スキャンラインずつレンダリングするため、スキャンライン中間でのエフェクトは再現されません。スプライト0ヒットとスプライトオーバーフローは近似であり、サウンドはありません。
テスト
pip install pytest py65
python -m pytest -q tests
テストには数分かかります。リファレンスCPUとnestestのログ、回路とリファレンス、フリーランニングのウェイトとリファレンス、およびアイドルループスキップをチェックします。
標準トランスフォーマーでチェックポイントを実行する
pip install "torch>=2.6" "transformers>=5.22"
python -m nesformer run roms/thwaite.nes --frames 3 --engine hf
nesformer/hfcheck.pyは、チェックポイントのロード、パディングトークンのデコード、マシン状態のキャッシュのDeltaNet状態への書き込み、そしてトークンを1つずつフィードする全手順を示しています。
ファイル
| パス | 内容 |
|---|---|
nesformer/spec.py | トークンファミリーとレコード |
nesformer/cpu.py | トレースを書き出すリファレンス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 | 標準トランスフォーマーでウェイトを実行する |
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はトップに表示されたスーパーマリオブラザーズの実行 |
tests/test_nesformer.py | テストスイート |
クレジット
- Qwenputerとその
chipformerコンパイラ:MIT、© 2026 Divyansh Agrawal(LICENSE.chipformer)。このライセンスは、nesformer/core.pyおよびnesformer/weights.pyから適応されたコードにも適用されます。その最後の段落は、QwenputerのCHIP-8 ROMに言及していますが、このリポジトリにはそれらは含まれていません。 - テストROM:nestest by Kevin Horton、Full Game by Doug Fraker(MIT)、Thwaite by Damian Yerrick(GPL-3.0、ソースはpinobatch/thwaite-nes)。
ライセンス
nesformerはMITライセンス(LICENSEを参照)でリリースされています。roms/内のROMは、roms/SOURCES.mdに記載されている独自のライセンスを保持します。