Writing NES Emulator in Rustをやった
Writing NES Emulator in Rustというサイトがある。これはRustでファミコンエミュレータ(通称: NES)を実装する方法をステップバイステップで解説してくれる親切サイトだ。NES初心者でも比較的容易に取り組めるので自力でNESエミュレータの実装するのはちょっと...といった人に特におすすめ。自分は数年前にNESの実装にチャレンジしたが途中で挫折した経験もあったのだけど、このサイトで言われた通り少しずつ進めていくことでなんとか一通り実装をし終えることができた(まぁまだバグだらだけど...)。
今回はその中でも初心者が事前に知っておいたら良さそうと思った知識や概念なんかを雑に書いてみる。
ちなみに各構成要素の詳細な解説や仕様などについては日本語・英語問わず先人の遺産がネット上に大量に存在しているのでググってほしい。とりあえず自分が何回も参照したサイトだけ下記に列挙しておく。
最低限知っておきたい知識
NESのアーキテクチャなどの全体像を理解する前に最低限知っておきたい知識がいくつかある。内容的には正直当たり前すぎるかもしれないがコンピュータサイエンスについての包括的な学習をしてこなかった人にとってはここら辺から躓きそうなので念の為。
基本的なコンピュータの中身に関する知識
NESではバイナリを直接読み解いてCPUに命令を実行させたり直接レジスタやメモリに読み書きしたりといったコンピュータの低レイヤー寄りの挙動をプログラムで表現することになる。流石に論理回路を設計したりするところまでの知識はいらないけどコンピューターの中でプログラムがどのように実行されているのかとかレジスタとかメモリとか何?みたいなことは流石に知っておかないと厳しい。とはいえNESにはOSのレイヤーが存在しないのでOSに関する知識はなくても困らない。
適当なサイトを読んで学んでも問題無いが自分は動かしてわかる CPUの作り方10講で簡単なCPUエミュレータを書いたりした経験がNES実装で生きた感じがしたのでおすすめ。コンピュータシステムの理論と実装 ―モダンなコンピュータの作り方もおすすめできるがNESを作るのに必要な知識は実際5章くらいまでで十分だと思われる。
進数/ビット/バイト
エミュレータのコードではコンピュータの都合もあり2進数表記や16進数表記が一級市民。Rustだと例えば177は0b1011_0001
で16進数だと0xB1
と表現される。2進数と16進数の表記は1011 = B
で0001 = 1
と対応している。
NESのCPUは8bitなので基本は8桁の2進数記法がよく使われる。アドレスに関しては2バイトなので16進数で表記されることが多い。
ビット演算
エミュレータの実装をしていく上でビット演算は避けては通れないので最低限Wikipediaのビット演算に書かれている内容くらいは覚えていたほうが良い。
例えば8bitのCPUのステータスレジスタ(仮に0b1100_1011
)の4bit目をセットしたい場合は下記のように論理和を取ればいいとか、
self.status = self.status | 0b0001_0000;
3bit目だけ0にしたい場合は0b1111_0111
と論理積を取ればいいみたいな感じ。
self.status = self.status & 0b1111_0111;
あとは特定のbit(例えば6bit目)がセットされているか確認したい場合などは、下記のように左シフトさせて1かどうかチェックしたりすればよい、等々。
if status >> 6 == 1 {
// some codes
}
この手のコードやより複雑なテクニックは各所で散見されるが、一回慣れてしまえば簡単なので手を動かすしかない。
Rust
Writing NES Emulator in RustではRustでNESエミュレータを実装していくので最低限のRustの知識は必要になる。とはいえThe Rust Programming Languageの10章くらいまでの知識があれば大丈夫なはず。
NESの全体像
NESエミュレータの実装に入る前に全体像を俯瞰しておくと何かと役に立つと思うのでざっくりと理解しておくと良い。ここでは先にも述べたように各種構成要素の詳細には立ち入らない。最低限知っておきたい主な登場人物だけ簡潔に書いておく。
CPU
- MOS 6502から10進数演算や小数演算機能を取り除きオーディオ機能を搭載したカスタム8bit CPU
- カセットからプログラムを読み込んで命令を実行していく役割
- 6つのレジスタを持つ(アキュムレータ(A)/インデックスレジスタ(X)/インデックスレジスタ(Y)/スタックポインタ(S)/ステータスレジスタ(P)/プログラムカウンタ(PC))
- 各種レジスタの役割はプログラムの演算の結果を一時的に配置しておいたり次に読み込むメモリの位置を保持しておいたり現在の状態を保持しておいたり等々のお仕事を行う
- 音声出力を行うAPUも内部に含まれている
WRAM
- CPUからアクセスされるSRAMのメモリ
- メモリは場所ごとに用途が異なる。CPUの演算で使う領域、PPUのレジスタにアクセスするための領域、IO用の領域、PRG ROMを配置する領域など。
PPU
- 画面を描画する役割
- 8bitのPPUレジスタを1つ持つ
- これは主にCPUからVRAMにアクセスするために使われる
- OAM(スプライト画像用(背景ではない)のメモリ)とパレット(色管理用)の内部メモリを持つ
VRAM
- PPUからアクセスされるSRAMのメモリ
- メモリの場所ごとに用途が異なり下記の領域に分けられる
- パターンテーブル: CHR ROMのデータ(いわゆる背景やキャラの画像データ)
- ネームテーブル: 背景のどこにどの画像データを敷き詰めるかを定めた情報
- 属性テーブル: 背景にどのパレットの色を設定するかを定めた情報
- パレット: 色の情報
Cartridge(カセット)
- いわゆるゲームのカセット
- 主にPRG ROM(プログラムデータ)とCHR ROM(画像データ)を持つ
Pad
- いわゆるプレイヤーがキャラを動かしたりするために使うコントラローラー(ジョイコン等)の役割
これらのCPU/WRAM/PPU/VRAM/Cartridge/Pad達がCPUを中心として協調し合いながらNES全体を構成している。全体像としては下記の図のような感じになる。
参照: https://forums.nesdev.org/viewtopic.php?t=20685&start=75
どうやって画面が描画されるのか
各種構成要素と全体像をざっくり見たので、次はこれらがどのようにして画面にゲームの内容を描画していくのかを書く。NESの実装を進めているとHELLO,WORLDの静止画を表示するだけも一苦労で結構地道な作業が続くので、事前にどういう仕組みで画面に背景とかキャラクターが表示されるんじゃ??というのがざっくりとイメージできていると苦痛が減る。ここでもまた詳細には立ち入らずざっくりとどういう仕組みで描画されていくのかに焦点を絞る。最低限理屈だけでもイメージできるようになれると良い。
画面の描画の基礎知識
画面を描画を理解するのに必要な知識は下記。色々端折ってもだいぶややこしい...。
ピクセル単位の描画の話
参照: https://qiita.com/bokuweb/items/1575337bef44ae82f4d3
- 画面サイズは256x240(256*240=61440ピクセル)で固定
- 左から右へ1pixelずつピクセルを描いていく、これを横1行分続けて出来た線をラインと呼ぶ
- ちなみにPPUはこの1ラインを引くのに341クロック使う
- このラインを240回描画すると1画面分が描画されたことになる
- ちなみにPPUはさらに目に見えない部分に20ライン描画する。一見謎な挙動に見えるがこの期間(Vblank)に次の描画へ備えてVRAMをいじったりする。
スプライト画像や背景画像の描画の話
参照: https://youtu.be/7Co_8dC2zb8?t=672
- 先ほどのピクセル単位の話とは別に、実際に表示されるゲーム画面はスプライト画像をパズル+貼り絵のような要領で画面に敷き詰めていくことで構成される。これはその方法についての話。
- 8x8ピクセルの塊を1タイルと呼ぶ
- 画面サイズは256x240なのでタイルは32x30(=960枚)敷き詰めることが出来る
- 背景・スプライト(前景)の画面は別々に管理されていて、これらを一つに合わせることで1画面として出来上がるイメージ
- 背景の場合
- タイルに何をどのように配置するかは下記の情報を基にして決める
- パターンテーブル: CHR ROMのデータ(いわゆる背景やキャラの画像データ)の情報
- ネームテーブル: 背景のどこにどの画像データを敷き詰めるかを定めた情報(背景の設計図みたいな感じ)
- 属性テーブル: 背景にどのパレットの色を設定するかを定めた情報
- パレット: 色の情報
- タイルの位置ごとにどのネームテーブル・属性テーブルを参照するべきか決まっていてその情報を基に各タイルを決められたパレットの色で塗っていく流れ
- これを全てのタイル分行うと背景画面が完成する
- タイルに何をどのように配置するかは下記の情報を基にして決める
- スプライト(主にキャラクターなど)の場合
- PPUは内部にOAM(256bytes)というスプライト描画専用のメモリを持っている
- スプライトは一つ4バイトなので同時に1画面に64タイル分のスプライトを表示可能
- スプライトはユーザー入力(ジャンプとかダッシュ)などによって動いたりするので256x240のどこにでも描画できる
- PPUは内部にOAM(256bytes)というスプライト描画専用のメモリを持っている
スクロールの描画の話
スクロールに関してはだいぶ込み入った説明が必要なので下記のGIFアニメを見てふんわりイメージしてもらうに留めたい。理屈としては2つ(以上)の画面(メモリ領域)を用意しておき、見えてる部分以外の外側を良い感じに書き換えていく流れになる。
参照: https://www.nesdev.org/wiki/PPU_scrolling
実装へ...
CPUなどの最低限の知識とNESの全体像をざっくり見渡したらあとはWriting NES Emulator in Rustに取り掛かるだけ。
最初の山としてはCPUの命令を200個以上実装してテスト用のnesファイルを通すところだが実際ここはまだまだ序盤。そのあと半分以上の労力はクロックサイクルの同期調整とPPUの実装に注がれることになる。途中で立ち往生してしまうこともあるかもしれないがその際はNesDevやファミコンエミュレータの創り方 - Hello, World!編 -などを読んだりして調べればなんとかなる。何より参照実装もあるのでこちらを参考にしながら実装すれば必ずなんとかなるようになっているので安心。
最後に
Writing NES Emulator in Rustに従ってNESエミュレータの実装に取り掛かる前に知っておくと良さそうなことなんかを書いてみた。
NESエミュレータの実装に関してはすでに20年以上前から世界中で盛んに行われており、言語問わずWeb上に大量に情報がある。そんな恵まれた中でも数年前に挑戦した際、途中で挫折してしまったのは自分の実力不足なのは当然として、どこから手をつけたらいいのかわからなくなって途方に暮れてしまったというのが大きい。
Writing An Interpreter In Goやゼロから作るDeep Learning ―Pythonで学ぶディープラーニングの理論と実装で感じたステップバイステップでレールに沿って進めば形あるものができて実装のイロハも得られるというあの体験がWriting NES Emulator in Rustを読むとNESでも出来るという点が良かった。
解説されるのがiNES1.0版の実装である点やAPUの実装が省かれている点ではイマイチと感じる人もいるかもしれないが、パックマンやスーパーマリオブラザーズのようなちゃんとしたゲームが最低限動くエミュレータを書けるだけでも中々楽しい経験になるし低レイヤーにより興味を持つきっかけにもなると思うのでおすすめしたい。
自分が実装中の時のメモも一応貼っておく。乱雑なので役には立たないかもしれないがここでは省いた話題や便利サイトリンクも記載している。
最後にこの記事に関する疑問や修正依頼などは@razokuloverにお願いします。
Discussion