Goでファミコンエミュレータをつくる -Hello, World編-

序章

就活やら研究やらをしているうちに,何か…何か作りたい…という心の奥の自分と対峙する日々が続いていました. そこで,ちょうど2020のseccampで作りたい!とか言ったのに一切着手していなかったファミコンエミュについて思いだしてしまいました.

今必要なものと,過去の経験が点となり結びつく,まさにコネクティング・ドットというやつです. (言いかっただけ)

この天啓により,今やるしかねえという脅迫観念とも取れるような自信を得た僕はジョブズをも倒せると思い込めるほどの気迫に満ち溢れていました.

しかし,ここまで引っ張ったことからもわかるように,一人だと飽きたタイミングで失踪する可能性が非常に高い自信もありました. そこで,研究室の同期を囲い込み,辞めたら(冷たい目線で)刺されるという背水の陣を敷いて万全の体制でNESエミュに対戦を挑んだのです.

第一章 Go言語,なんもわからん

第一節 Go言語 入門

世界一の自信家となった僕は,書いたことも読んだこともないGo言語を選定しました.今の僕に書けない言語はありません. 実は最初のROMの読み込みくらいまではC++で書いていたんですが,なんか別の言語を書きたい欲がでてきて,最初から書き直しました.


………Go言語,なんもわからん! -完-


と早速辞めたくなりましたが,僕の硝子の心はこれから毎日冷たい目で見られる生活には耐えられないのでがんばるしかありません. そう,書いたことも読んだこともない言語を書けるはずなどなかったのです.多分ジョブズでも無理でしょう.

ということで,まず,ROMを読み込むコードの実装のため,Goの仕様を調べることから始めました.

調べてみると,なんとGoについての神資料が Gopher道場 なるところに置いてあるらしいじゃないですか. しめしめと早速Slackに加入すると,大変わかりやすい大量のスライドにありつけました.

(追記:このとき,seccmap2022でまさか作成者の方の講義があるとは思ってもなくて,講師一覧で上田さんの名前を見つけたときはびっくりしたのを覚えています)

第二節 ROMの読み込み


………ファイルの読み込み,なんもわからん! -完-


負けてはなりません.その先は地獄だとわかっておきながら,歩き続けるものにしか願いを叶えることはできません.

マジで挫けそうになりながら2時間近くGoogleと戦い,ついに ioutil.ReadAll() という関数でファイルの読み込みに成功します. 長男じゃなければこの戦いは耐えられなかったでしょう.親に感謝しかありません.

ちなみにここから先は以下の神サイトを参考を読み進めながら実装しました.これらのサイトには毎日3回,感謝の祈りを捧げています.

言い忘れていましたが,本記事で使用したROMは以下のNES研究室から拝借しました.このROMを使用してHello Worldの表示をするまでが本記事のゴールとなっています.

ファミコンエミュレータの創り方 - Hello, World!編 -

ファミコンの仕組みとおおまかな作成の流れが書かれています.基本的にこのサイトを参考にしながら実装していきます.

NES研究室

ファミコンの仕様について日本語で詳しくまとめられているサイトです.上のサイトでわからないことは基本このサイトに書いてあります.

Nes Dev

ファミコンの"すべて"がここ載っています.上の2つのサイトでわからないことがあれば,ここで調べます.

第三節 CPU実装

ファイルの読み込みさえわかれば,あとはROMの命令を片っぱしから読みこんで,それを実行するCPUを作ってあげればよいはずです.

CTFのおかげでアセンブリには抵抗はなかったのでこのへんはスムーズに実装できました.

実装の手順としては,とりあえず1byteごとにプログラムROMから読み込んでくる部分を作成して,その1byteを命令として読み込み,各命令のcase文に飛ぶ分岐を最初に実装しました.

この次に進むためには,読み込んだ命令のアドレッシングモードを判別して,それぞれの命令のoperandを取ってくる必要があります. 多分このタイミングで各命令の写経大会が開幕しますが,がんばりましょう.あきらめたらそこで試合終了です.

LDA 命令の immediate のアドレッシングモードで次のPCではなくPCの中身をoperandにしていたせいで後々バグらせまくったのはここだけの話ですよ)

無事この大会で優勝すると,Hello WorldのROMを読み終えた後に JMP 命令で無限ループします.するはずです.

……….

BRK 命令でループする?おかしいね.

これは, relative のアドレッシングモードで,負数を考慮していなかったのが問題でした.基本的にunsigned型で実装を進めてきたので突然の負数の出現に困りました.

とにかくこれをクリアしないことには僕のエミュレータは一生ソフトウェア割り込みを入れ続ける謎の挙動をするヤバいエミュレータになってしまいます. このままだと冷たい目線だけじゃ済まなさそうです.

しかしながら流石にCSを学んできた身,こんなところで挫けるわけにはいきません. コンピュータでは,基本的に整数の扱いには2の補数が利用されているため,unsigned型の数値をnot演算し,1を足してあげると,負数に変換することができます. (パタヘネ本読んでてよかった…)

こんなところで詰まる方がいるかはわかりませんが,一応変換のところの実装を載せておきます.

case "REL":
tmp = uint16(fetchPC())
      reg.PC++
      if (tmp >> 7 & 1) == 1 {
      // 負数なら,正の数に変換して計算
	      operand = uint16(reg.PC - (^tmp+0b1)&0xFF)
      } else {
      // 正の数なら,そのまま計算
	      operand = uint16(reg.PC + tmp)
      }

さて,問題も解決したところでもう一度実行しましょう.わくわく.

おー.無事 JMP でループした!

……….

あれ,でも何も条件満してないのに分岐してるね.おかしいね.

これはステータスレジスタ回りを一切実装しないで喜んでた愚か者は体験するはずです.……みんな体験してるよね?

ステータスレジスタ回りもしっかり実装してやると,しっかり条件を満した上で JMP でループします.やったね!

ここまでできれば,次はPPUの実装に移りましょう!わーい.

第二章 OpenGL,なんもわからん

第一節 PPU実装

ファミコンではPPUからのみ,画面描画に使用するVRAM触ることができます.

しかしそれでは,ゲームの状態が何も画面に反映されない悲しいゲーム機になってしまうので,CPUからもVRAMを操作できるように,PPUレジスタなるものが用意されています.

これはCPUがマッピングしているメモリに対応しているので,このレジスタを通してVRAMに書き込むことができます. このため,まずこのレジスタの実装が最優先事項となるはずです.多分.

ここがうまく実装できれば,VRAMをダンプしてみると, 0x2000 番地から 0x23bf 番地にかけてHello, world!の画面のスプライト番号が書き込まれているはずです.

あと 0x3f00 からのパレット情報も.

ここで軽率で自信家な僕はほくそ笑みました.PPU,ちょろいな.

第二節 画面描画ってなに?

………….


え?


どうやったら画面に描画できるんですか?GUIプログラミングしたことのない僕は完全に詰みました.

ほんとうに詰みました.自作NESエミュの記事を書いてるプロたちは誰も画面描画の話なんて書いていません.書けてあたりまえみたいな雰囲気さえ感じます.


え?


ここから長い戦いがはじまりました.浅井長政に裏切られた信長もこんな気持ちだったのでしょう.

まず画面描画の仕方から調べました.OpenGLなるものがあるらしい.ふーん.

………OpenGL,なんもわからん!

はい,ほんとにわかりません.初日に放っていたジョブズをも倒す気迫も,いまや希薄すぎて加治木先輩ですら僕を見つけられないかもしれません.

しかしながら,流石日本,どこにでも神様はいます.

○○くんのために一所懸命書いたものの 結局○○くんの卒業に間に合わなかった GLFW による OpenGL 入門

という 一瞬ふざけてんのか?と思う 神資料により理解を深めた僕は,早速ググりながらOpenGLを使用してプログラムを書こうとしたのですが,サイトごとに違う関数を使用しているようです.なんで?

調べたところ,OpenGLは version 3.0 で区切りがあり,3.0以降は固定機能パイプラインなるものが廃止され,プログラマブルパイプラインのみ使用可能となっているようです.

流石に先達方の実装を参考にするかと思い調べたところ,OpenGLを使用しているNESエミュは,基本固定機能パイプラインを使用しているようです.

しかしながら,こいつはもはや非推薦となっています.困りました.困りましたが,上記の神資料で理解を深めた僕は, 言語化できない謎の自信を持ち直していました.

いける.

謎の自信に支えられ,プログラマブルパイプラインを用いた実装に取り組むことを決意したのです.

第二節 シェーダってなに?


………シェーダ,なんもわからん!


はい,なにもわかりません.というかなんで俺はNESエミュ書いてるはずがシェーダ書いてるんだというひしひしと湧き上がる気持ちを必死に抑えつつ,今日も笑顔で実装に取り組みます. うーん,冷たい目線が怖い!

といっても上記の神資料でシェーダについても触れられていて,それと適当にググったサンプルを組み合わせればなんとなく理解はできてきました. というかこんだけ引っ張って実は自分で書いたシェーダは3行くらいです.ゆるして.

シェーダの書き方がわからないというよりはOpenGLのアーキテクチャがよくわかっていませんでした.

簡単に説明すると, vao と呼ばれる頂点配列オブジェクトなるものに,描画したい座標やらその色の情報やらを詰めこんで,これをGPU側に転送します.

そして,その vao に格納されている描画したい情報を描画命令で叩くとその点と色が描画されるようです.

(実は,最初の実装では毎回 vao を作成し,1ドットを描画するごとに vao を転送していましたが,メモリが耐えられずプログラムがクラッシュする事例に見舞われたため,それを解決する上で上記の理解に達しました)

というかここまでで一番僕を救ってくれたサイトを紹介するのを忘れていました.

https://kylewbanks.com/blog/tutorial-opengl-with-golang-part-1-hello-opengl

GoでOpenGLを使用したサンプルを紹介してくれているサイトなのですが,なんと,このプログラムでは正方形を画面に敷き詰めています. あれ,これ…使えるのでは…?胸が高鳴ります.

実は, 256*240 のファミコン実機の解像度でそのままウィンドウを作成すると,かなり画面が小さくなってしまいます. そこで,なんとかして大きい画面でエミュレータを起動できないかと密かに企んでいたのですが,まさに使えそうな感じです.

上記のサイトのプログラムを参考に,ウィンドウにマスを作成してみます. これは 10*10 のマスの (2,3) のマスを表示したものです.

この一つの正方形を一つのドットとみなしてやり, 大きいウィンドウに 256*240 で正方形を敷き詰めるとうまくいきそうです.

…うまくいきました. この瞬間は間違いなくジョブズを越えていました.いや,越えていたことにしといてください.じゃないと心が持ちません…

こちらが 256*240 のマスの (2,3) のマスを表示してみた結果です.

うん,いい感じ. こうやっていろんなものの力を借りながら人は窮地を脱するんですよね.また一歩,信長さんに近づきました.

せっかくなので,少し描画周りのコードの説明も載せておきます.

※これはHello, Worldまで実装し終わったやつにコメントを入れてみました.

func draw(dots [][]*dot) {
      vertexarray := make([]float32, 0)
      for x := range dots {
	      for _, dot := range dots[x] {
		      // この関数で各ドットごとに色の情報を設定
		      dot.setColor(ppu.Palettes[dot.palette][dot.sprite][:])

		      // {頂点,色,頂点,色,・・・}となるようにスライスを作成
		      pointarray := make([]float32, 0)
		      for i := 0; i < len(dot.points)/3; i++ {
			      pointarray = append(pointarray, dot.points[i*3:(i+1)*3]...)
			      pointarray = append(pointarray, dot.colorpoints...)
		      }
		      vertexarray = append(vertexarray, pointarray...)
	      }
      }

      drawable := makeVao(vertexarray)
      gl.BindVertexArray(drawable)

      // すべてのドットを描画
      for i := 0; i < 256*240; i++ {
	      // 4頂点(ドット1つ)ごとに描画
	      gl.DrawArrays(gl.TRIANGLE_FAN, int32(4*i), 4)
      }
}

func makeVao(points []float32) uint32 {
      // vaoの作成(正直このへんはまだあまり理解していない)
      var vbo uint32
      gl.GenBuffers(1, &vbo)
      gl.BindBuffer(gl.ARRAY_BUFFER, vbo)
      gl.BufferData(gl.ARRAY_BUFFER, 4*len(points), gl.Ptr(points), gl.STATIC_DRAW)

      var vao uint32
      gl.GenVertexArrays(1, &vao)
      gl.BindVertexArray(vao)

      // ここでシェーダに宣言したattribute変数(上から0,1,…となる)を使うぞという宣言(今回は0と1)
      gl.EnableVertexAttribArray(0)
      gl.EnableVertexAttribArray(1)

      gl.BindBuffer(gl.ARRAY_BUFFER, vbo)

      // それぞれのattribute変数にvaoから座標3つと色3つごとに取り出すように設定
      // 5つめの引数には,6つごとに別の図形が割り当てられていること,6つめの引数には,4つめから色の情報が入っているという情報をあげる.(*4はfloat型の大きさ)
      gl.VertexAttribPointer(0, 3, gl.FLOAT, false, 6*4, gl.PtrOffset(0))
      gl.VertexAttribPointer(1, 3, gl.FLOAT, false, 6*4, gl.PtrOffset(3*4))

      return vao
}

ということで,なんとか大きい画面で任意のドットを表示することができました. あとはスプライト番号からスプライトの情報を取得してそれぞれのドットに対応したパレット番号を属性テーブルで指定されたパレットから引いてきてその色情報をOpenGLの規格に合わせて vao を作成して描画するだけです! (白目)

第三節 パレットでパレード

スプライト番号からスプライトの情報を取得してそれぞれのドットに対応したパレット番号を属性テーブルで指定されたパレットから引いてきてその色情報をOpenGLの規格に合わせて vao を作成して描画します.

スプライト番号からスプライト情報を取得してパレット番号を各ドットごとに設定する部分に関して,忘れると後々めんどくそうなのでまとめてみました.

以下では, HELLO, WORLD! のスプライト番号が格納されている 0x21c0 番地の行をピックアップしています.

おお,こんな感じで図形が4色で表現できるんだ….かがくのちからってすげー!

あとはパレットテーブルの設定ですが,これもbokuwebさんのサイトに詳しく書いてあるので参考にしながら進めると理解が深まると思います. 実装は,上のやつと似たような感じでループ回してやればこっちもうまくいくと思います.多分.


ふう.実装できました.さあ,いよいよ Hello, World! とご対面です.

ここまで長かったな….周りの目線に怯えながらひきつった笑顔で実装に励んだこの一週間を思い返すとちょっと涙ぐみます.よくがんばった.俺.

辛いかった 楽しかった刻(トキ)を振り返ったところで,実行する覚悟も決まりました.

よし,いくぞ!!!

…………………………….


え?


いや,絶妙におしいのがまたなんとも言えない味でてて泣ける.思いっ切り出鼻を挫かれたところで少し冷静になって実行結果を眺めると,なんとなく原因が見えてきます. 落ち着くの大事.

大方スプライトの一番上の部分がすべてに適用されてしまっていることが原因のようです.ということで,しっかりスプライトのループを修正して,はい.

できた.感動です.思わず10分くらい小躍りしてしまいました.いやー,がんばった実装が形になった瞬間はやっぱり嬉しいですね.





……まだHello, Worldしただけ?その通りです….


終章

Hello, world!まで遠かったです.Hello, worldのROMを動作させることだけを目標にここまでエミュレータを書いてきたので,CPUやPPUで実装できていない部分がたくさんあります. 最終的なゴールはSuper MARIO Bros.を動作させることなのですが,まだ実装できていない部分は多いです.

とりあえず,忘れる前にやったことまとめたかったのと,Hello, world!までで躓いている方の助けになればと思い執筆しました.

……….いや,助けになるような記述は自分で見返しても見つからないですが,自由を求めるその強い意思さえあれば,多分なんとかなります.

次はドンキーコングかSMB.あたりを動作させてから,書きたいと思います.……更新なくても,冷たい目で見るのは,止マレ!ですからね.

GitHub: https://github.com/siva0410/emu

mc4nf
mc4nf

軽率にFollow me!:)