YDiary

メモ的な

ブログを移行した

ブログをこのはてなブログからDocusaurusを使ったセルフホストに移行しました。
新しいブログのURLは次の通りです。

https://blog.0ko.me/

ブログ移行に関する記事も書いてるので良ければご覧ください。

blog.0ko.me

こちらのブログ記事は時間があればそのうち新しいブログに移行するかも。

Rustでファミコンエミュレータを作った その3

ずいぶん間があいてしまいましたが、

ydkk.hateblo.jp

の続きです。

前回の記事ではPPUの実装を進め、何とかスーパーマリオブラザーズ(SMB)の冒頭部分を動作させることができるようになりました。

今回の記事では、マップを右に進んだ際にスプライト0ヒットが発生せずにハングしてしまう理由や、APUの実装についてです。

スプライト0ヒットが発生せずにハングする理由

スプライト0ヒットが発生しなくなる理由は、前回の記事のラストに書いた通りPPUのメインスクリーンが変更され、スプライト0を含むスコア部分が描画されなくなってしまうからです。 SMBのプログラムの挙動から推測すると、どうやら毎回のVBlank後にメインスクリーンが0に戻っていることを期待しているようです。

そこで、試しにエミュレータの実装を変更してVBlank時にメインスクリーンを0にセットするという処理を加えてみると、スコア表示部分のチラつきが発生する*1ものの問題なくハングが発生していた箇所よりも先に進むことができるようになりました。

このことから「SMBのプログラムは何らかの方法でPPUのメインスクリーンを0にセットしているものの、エミュレータがその挙動を再現できていない」ということが推測されます。

PPUの内部レジスタ

ファミコンでは、PPUのメインスクリーンは$2000(PPUCTRL)レジスタの下位2ビットを通してCPUから設定できるようになっています。 そこで、まずはこのPPUCTRLレジスタへの書き込み周りについて実装の確認やデバッグなどを行いました。

結果として、PPUCRTLレジスタの実装に特に問題は見つかりませんでした。 レジスタへの書き込みは正しく処理されていますし、そもそもSMBのプログラムがPPUCTRLレジスタを通してメインスクリーンを0にセットしている形跡がありません。

つまり、PPUCTRLレジスタ以外に書き込む以外の方法でメインスクリーンを変更することができる方法が存在するということです。

そこで、改めてPPUについて詳しく調査を行いました。 そこで見つけたのが、PPUの内部レジスタに関する情報です。

ファミコンのPPUはCPUからPPUの様々な状態(メインスクリーン、OAM(スプライトメモリ)アドレス、スクロール位置など)を外部レジスタ*2から設定できるようになっています。 一方で、PPUはその状態を v, t, x, w と呼ばれている4つの内部レジスタで保持しています。

この4つの内部レジスタは合計で15+15+3+1の34ビットしかなく、CPUから設定できる状態を全て保持できません。つまり、CPUから書き込まれる複数の状態を同じレジスタで重複して管理しており、状態の設定に副作用が存在するのです。

これは「なるべく少ない内部レジスタでPPUを実装する必要がある」もしくは「ハードウェア的にその方が都合が良い」といったハードウェア側の都合によるものだと考えられますが、とにかく、エミュレータ実装時にこの内部レジスタによる副作用を考慮せずに外部から見えるPPUの状態をそのまま保持するように実装してしまうと、今回のような問題が発生します。

副作用の具体例

PPUの内部レジスタによる副作用の具体例を挙げます。

まず、PPUCTRLレジスタの書き込み時の動作を次に示します。

t: ...GH.. ........ <- PPUCTRL: ......GH

先ほどメインスクリーンはPPUCTRLレジスタの下位2ビットを通して設定できると書きましたが、このように設定されたメインスクリーンの値( GH の2ビット)はPPU内部のtレジスタの4, 5ビット目に格納されます。

次に、CPUやDMAからPPUのメモリにアクセスする際のアドレスを指定する$2006(PPUADDR)レジスタの書き込み時の動作を次に示します。 PPUADDRレジスタへの書き込みは1回目と2回目で動作が異なるのですが、ここでは1回目の書き込み時の動作を示します。

t: .CDEFGH ........ <- PPUADDR: ..CDEFGH

なんと、tレジスタの4, 5ビット目に設定されていたメインスクリーン値がPPUADDRレジスタに書き込まれた値の5, 6ビット目( EF の2ビット)によって上書きされてしまいました。 PPUメモリのアドレスを書き込んだと思ったら、実はメインスクリーン値も書き換えていたわけです。

SMBの処理を改めて確認したところ、実際にVBlank時にこのPPUADDRレジスタを通してメインスクリーンの値が0にセットされていることが確認できました。

副作用の実装

PPU実装の再現度を高めるには、本物のPPUと同様にこの内部レジスタを使用するように実装を変更するのがベストでしょう。 一方で、この内部レジスタによる状態の表現は複雑で、既存の実装部分への影響も大きいものでした。 そこで、ひとまず内部レジスタを再現するのではなく、PPUレジスタへの書き込み時の内部レジスタによる副作用を再現する実装を加えることにしました。

具体的には次のような実装です。

//PPUレジスタへの書き込み
pub fn write(&mut self, addr: u8, value: u8) {
  match addr {
    0x00 => self.registers.control_register.write(value),
    0x01 => self.registers.control_register2.write(value),
    //..省略..
    0x06 => match self.state {
      State::Idle => {
        self.v_ram_addr_h = value;
        self.scroll_vertical &= 0b0011_1011;
        self.scroll_vertical |= (value & 0b11) << 6; //★内部レジスタによる副作用★
        self.registers.control_register.main_screen = (value & 0b00001100) >> 2; //★内部レジスタによる副作用★
        self.state = State::Writing;
      }
      State::Writing => {
        self.v_ram_addr_l = value;
        self.scroll_horizontal &= 0b00000111;
        self.scroll_horizontal |= (value & 0b11111) << 3; //★内部レジスタによる副作用★
        self.state = State::Idle;
      }
    },
    //..省略..
    _ => panic!(),
  }
}

★内部レジスタによる副作用★ とコメントした部分が追加で実装した副作用です。 addr == 0x06 のPPUADDRへの書き込み時にメインスクリーン値を書き換えていることが確認できます。

この実装を行ったことでSMBでのハングが発生しなくなり、無事ステージの先に進めるようになりました。

無理やりメインスクリーン値を0にセットした場合のスコア表示のチラつきもなく、とても良好なエミュレーション結果です。

APUの実装

ここまででCPUとPPUが満足に動作するようになったので、続いて音を出すモジュールであるAPUの実装に入ります。

ファミコンのAPUには2つの矩形波チャンネルとそれぞれ1つの三角波、ノイズ、DMCチャンネルがあります。 DMCチャンネルはDPCMデータなら何でも再生することが可能ですが、ファミコンカセットのROMの容量は限られている(SMBでは40KBしかない)ので容量をバカ食いするDPCMを贅沢に使用するのは難しかったようです。 DPCMは主にドラム、ベース、パーカッションなどの一部として効果的に使用されていたようです。

余談ですが、ファミコンはAPUの各チャンネルの出力がミキシングされた結果の最終的な出力(マスターアウトとでも呼ぶべきでしょうか)が一度カセットを通ってから再びファミコン内に戻されたのちにTVに出力されるようになっています。 これによって、カセット側で出力音声にエフェクトをかけたり、拡張音源の出力をさらにミキシングしたりといったことが可能になっています。 良く出来ています。

今回はひとまずSMBで使用されている矩形波、三角波、ノイズチャンネルを実装しました。

APUの実装もPPUと同様にNesdev Wikiの資料などをもとにひたすら実装するのみです。 実装量はそれほど多くないので、CPUやPPUよりも比較的簡単に実装することができました。 チャンネルごとに完全に分離されているため、1つのチャンネルを実装すればそれだけで動作確認やデバッグが行えるようになるのも実装を進めやすいポイントです。

APU実装の進め方

まずは適当なチャンネル(矩形波チャンネルがおススメ)を一通り適当に実装し、ファイルにPCM出力をダンプして聞いてみるとこんな感じでそれっぽい音が聞こえてきます。この時点で大分楽しいです。

あとは別のエミュレータや実機のプレイ動画などをリファレンスにしながら少しずつ音が違う箇所を直していく作業になります。 エミュレータによってはAPU出力の有効無効をチャンネルごとに切り替えたりできるので、そうしたエミュレータを使用するとデバッグが捗ります。 また、 SNDTEST.NES というAPUのレジスタをセットしながら出音を確認することができるROMがある*3のでそれを使うのもおすすめです。

フロントエンド側の音声出力実装

APUの実装時を進める際には、なるべく早い時点でフロントエンド側(エミュレータを動作させるGUIプログラム)に音声出力機能を実装するのがおススメです。 ファイルへのPCMのダンプを通したデバッグはAPUの実装初期なら良いのですが、本格的に実装を進める際には実際のゲームなどを動かしながら音を出す方が開発効率がとても高いです。

フロントエンド側の音声出力の実装には、当初はGUIに使用していたwindows-rsからそのまま使えるXAudio2を使用して実装していました。 しかし、音途切れを防ぐためにバッファの供給タイミングを正確にコントロールしようとしたときに、RustからCreateSourceVoiceに渡すIXAudio2VoiceCallbackインスタンスを通してコールバックを受け取るのが難しそうだったので、後にSDL2に乗り換えました。

SDLはクロスフォーム対応なので、将来的にブラウザ(WASM)対応を行う際などに同じような実装で音を鳴らせるんじゃないかといった目論見もあったりします。まだ全然調べてないので実際はどうなのか分かりませんが。

ファミコンのAPUはCPUクロックと同期して動いているため、毎クロックごとに音声信号を生成して出力するとサンプリングレートがめちゃくちゃ高くなります。 具体的には1.789MHzとかになります。現代のゲーム機なんかよりもよっぽどハイレゾですね。 さすがにそのままでは扱いにくいので、雑に10分の1に間引いてからSDLのリサンプラを介して鳴らしています。

APUの実装を進め、最終的には次のような音を鳴らせるようになりました。

ノイズチャンネルの出音が若干怪しい気もしますが、まずまずいい感じにエミュレーション出来ているのではないでしょうか。

次回予告

Rustの特徴などについても書きたかったのですが、結構長くなってしまったので今回はここまでにします。 気が向いたら続きを書きます。

SMBをちゃんと動かすというのをファミコンエミュレータ実装開始時の目標にしていたのですが、達成できたんじゃないかと思います。 あとはもう少し実装を整理したうえでソースコードを公開したり、最適化やマルチスレッド対応、ブラウザ(WASM)対応なんかができたら良いなーと思ってます。

その他

エミュレータ実装の作業ログを適当なハッシュタグをつけてツイートしたりしておくと後から見返せて良いです。 今回の一連の記事を書く際の動画やスクショなどもそこから拾ってきています。

*1:このチラつきの原因は良く分かっていません。

*2:CPUからアクセス可能なレジスタ

*3:どこで入手したのか忘れてしまいましたが、探せば出てくると思います。

Rustでファミコンエミュレータを作った その2

ydkk.hateblo.jp

の続きです。

前回の記事ではCPUと最低限のPPUを実装してnestestの公式命令テストを完走することができました。

今回の記事ではいよいよスーパーマリオブラザーズ(SMB)を動作させてみます。

ROMの入手

さて、動作させるにはまず初めにROMを入手しなければいけません。

手元にSMBのカセットは無かったので中古で入手しました。

カセットの溝にマイナスドライバーを差し込んで力を加えるとカパッと殻が外れるので、あとは基板に実装されているマスクROMを読み込むだけです。

マスクROMを読み出す様子

SMBのカセットのマスクROMには特に技術的保護手段が施されていないので、ユニバーサルプログラマなどで普通に読み込むことが可能です。

PRG とマーキングされている方がプログラムROMで、 CHR とマーキングされている方がキャラクターROMです。

読み出すROMのサイズやハッシュ値などの情報はNesCartDBというサイトで確認することが可能なので、読み出したデータの整合性確認に使用すると良いでしょう。

SMBの場合は32KBのプログラムROMと8KBのキャラクターROMで、合わせてわずか40KBです。 この40KBの中にゲームのロジックからキャラクターや背景の絵、BGMや効果音などがすべて含まれています。

こうしてROMを入手することができたので、いよいよ実装したエミュレータに読み込ませてみます。

初回起動

実装したファミコンエミュレータで初めてSMBを起動させた時の様子が次の通りです。

まぁ案の定ボロボロです。 オリジナルの画面を知っていれば何となく何が写ってるんだろうなという想像ができる程度でしょうか。

CPUはnestestを完走するぐらいなので問題なく動いていそうですが、Hello, world!ロム程度でしか動作確認をしていないPPUがまともに動作する訳がありません。

ここから先は表面的な理解だけで実装を進めるのは難しいので、本格的にNesdev Wikiのお世話になることになります。

恐らくいきなりNesdev Wikiを見ても内容を理解するのは難しいと思いますが、一度Hello, world!ロムを動かす程度まで実装したうえで見れば割と理解しやすいのではないかと思います。

あとはNesdev Wikiに記載されている仕様と自分の実装とを見比べたうえで、不足している部分を実装したり誤っている部分を修正していくことになります。

まずはPPUの実装を修正してSMBを問題なく描画することができるようになることを目指しました。

PPU実装・修正の過程

細かい実装や修正の内容まで書くと長くなってしまうのでダイジェストで行きます。

それっぽい絵が表示されるようになる

少しPPUの実装を修正するとそれっぽい絵が表示されるようになりました。

空の色が黒いですが、これは自作ファミコンエミュレータあるある現象のようで、VRAMの特定のメモリアドレスのミラーリングを正しく実装できていないとこのように空が黒くなります。

Addresses $3F10/$3F14/$3F18/$3F1C are mirrors of $3F00/$3F04/$3F08/$3F0C. Note that this goes for writing as well as reading. A symptom of not having implemented this correctly in an emulator is the sky being black in Super Mario Bros., which writes the backdrop color through $3F10.

わざわざ↑のようにNesdev Wikiにも書かれるぐらいで、相当多くのファミコンエミュレータ実装者がこの黒い空を拝んでいるようです。

自分も以前からこの現象のことは何となく知っており、それなりに意識して実装していたにもかかわらず間違えました。

空が明るくなる

さて、当該の箇所を修正することで無事に空が明るくなりました。 ところが、全体的に色がおかしいうえに肝心のマリオの姿が見当たりません。

マリオ登場、しかし動きが…

DMAを実装してBGとスプライトのテーブルアドレス選択を修正することでついにマリオが登場しました。 しかし、どうも動きがぎこちないです。

というのも、この時点ではスプライトの描画位置が正確ではなく、ピクセル単位ではなく8*8ピクセルのタイル単位までしか実装できていません。そのため、カクカクとしたぎこちない動きになってしまっています。

また、スクロールの実装も正しくありません。

マリオの血色と動きが良くなるも、右に進むとハング

さらにPPUの実装を修正するとマリオや世界の色が正しく表示されるようになりました。 スプライトの描画もより正確になり、一気にそれらしい見た目になります。

ところが、動画の終盤に見て取れるようにある一定以上ステージを右に進むとハングして操作不能になってしまいます。 この現象は偶発的なものではなく、ステージのある地点まで画面がスクロールすると必ず発生する再現性のある現象です。

このハングが発生した際にCPUが何の処理をしているのかを調べたところ、どうやらひたすらスプライト0ヒットを待ち続けているようでした。

スプライト0ヒットとハングの原因

スプライト0ヒットはファミコンのPPUに存在する機能で、スプライトメモリ(OAM)上の0番目に存在するスプライトを描画する際に、スプライト内の不透明なピクセルがBGの不透明なピクセルと重なった場合にPPUの特定のレジスタのフラグを立てるという機能です。 スプライト0ヒットを使用することによって、プログラムが厳密なCPUクロックを数えなくてもPPU上で画面がどこまで描画されたのかを知ることができます。

ファミコンのPPUは、効率的なスクロール描画を行うために内部で2画面分の描画領域を持っており、PPUのスクロールレジスタの値に応じて描画領域のうちのどの部分を画面に出力するかを決定しています。

https://www.nesdev.org/w/images/default/b/bf/NTS_scrolling_seam.gif
PPUのスクロール処理(Nesdev Wikiより引用)

一方で、SMBには画面上部にスコア表示があります。 そのため、単純に画面全体をスクロールさせてしまうとスコア部分も一緒にスクロールしてしまいます。 スコアをスプライトとして表示することができれば画面のスクロールと両立させることができますが、ファミコンの仕様上スコアの文字を全てスプライトとして表示させることはできません。*1

そこで、SMBではスコアをBGとして描画したうえで、スコアの表示を終えたラインから画面をスクロールさせるという手法を取っています。*2

図にすると次のような感じです。

画像全体で2画面分の内部描画領域を表しており、赤や緑色の枠は画面の出力範囲です。

  1. まず、最初の時点ではスクロール値を変化させずにBGにあるスコアを描画させます(赤枠部分)
  2. スコアの描画が終了したら、スクロール値を適切な値に設定して画面をスクロールさせます(緑枠部分)
  3. 画面を最後まで描画し終えたら、再びスクロール値を0に戻して次のフレームでのスコア描画に備えます

この、2. のスコアの描画が終了したかどうかの判定に先ほど説明したスプライト0ヒットを使用しています。

SMBのスプライト0はスコア表示のコインアイコンの下部2ピクセルほどの部分です。

 

ジャンプ等でマリオのスプライトを重ねると、スプライト0だけ前面に描画されるのでスプライト0を簡単に確認することができます。

エミュレータがハングした際、内部では次のような状態になっていました。

スクロール範囲が内部描画領域の半分を超えたため、折り返しを行うためにメインスクリーン*3が変更されます。

これによって、スクロール値0がもはや内部描画領域の左側を指さなくなります。結果としてスプライト0ヒットに必要なスコアのコインアイコンが描画されなくなりスプライト0ヒットも発生しなくなりますが、SMBのプログラムはいつまでも発生することのないスプライト0ヒットを待ち続けるためハングしてしまいます。

さて、ハングしてしまう原因はすぐに分かりましたが、どうしてこのような状態になってしまうのかはすぐに分からず、しばらくの間悩み続けました。

ワークアラウンドとして、エミュレータの実装で毎フレームの描画が始まる前に強制的にメインスクリーンを0に切り替えるようにすることでハングを回避することはできますが、スコア表示がちらつく上にSMB以外のスクロールを使用するゲームで問題が発生すると思われるため、そうしたワークアラウンドによる対処は避けました。

次回予告

今回の記事はここまでです。

次回は、このスプライト0ヒットが発生せずにハングしてしまう問題の解決編やAPU(音声処理ユニット)の実装、Rustの特徴についてなどを書きたいなと思います。

その3を書きました。

ydkk.hateblo.jp

*1:8*8ピクセルのスプライトを画面全体で64枚まで&同一水平ライン上に8枚までしか表示させることができない

*2:ファミコンは、画面を上から下に1水平ラインずつ描画しています。

*3:内部描画領域のうちの、描画を始める基準となる位置

Rustでファミコンエミュレータを作った その1

はじめに

前回の記事の終わりに新年の抱負として書いていた通り、Rustを書けるようになりたいなーと思っていたので勉強も兼ねてファミコンエミュレータを作ってみました。

新しい技術を身に着けるには実際に使ってみるのが一番、ということで別に作るものは何でもよかったのですが、なんかRustというと皆ファミコンエミュレータとかGBエミュレータとかを作ってるイメージがあったので自分も真似してみました。

身の回りでいうと、少し前にkamiyaさんがRustでファミコンエミュレータを作っている様子がTLに流れてたのを見て、楽しそうだなーと感じたのも大きいです。

確かにCPUやらメモリやらといったハードウェアの低レベルな処理をエミュレートするというのは、基礎的な処理やらデータ構造やらを扱えるという意味でプログラミング言語を学ぶのに向いているように思います。 それに、実際に絵が出て音が出るのは楽しいのでモチベーションの維持にも繋がりますしね。

取っ掛かり→Hello, world!ロムを動かすまで

いきなりファミコンエミュレータを作るといっても何も分からないので、まずは調べて出てきた次の記事を参考にしました。

qiita.com

基本的な部分のみですが、Hello, world!ロムを動かすまでに必要なことが一通りまとまっていて、とても良い記事です。

記事にもある通り、Hello, world!ロムはNES研究室のサイトから入手可能です。

↑の記事を読めばファミコンのアーキテクチャやスプライトのデータ構造などは何となく理解できます。 が、それだけでは肝心のプログラムが動かないのでまずはCPUの実装が必要になります。

CPU実装

CPUの命令セットについてはNES on FPGAのサイトに日本語で詳しくまとめられているので、その資料を基にひたすら実装していきました。

何も動かない状態でひたすら実装をし続けないといけないので、このCPU実装フェーズが最初の難関になるでしょう。 命令セットはトータルで百数十種類ありますが、命令自体はそれほど種類があるわけではなく、豊富なアドレッシングモード×命令の組み合わせでトータルの種類が増えている感じです。 非公式命令もかなりの数が存在しますが、普通に動作させる分には必要ないと思うので自分は実装していません。

PPU実装

CPU実装が完了したら、今度は出力する画像を生成するPPUの実装に着手します。 PPUはスクロール周りも実装するとそこそこ大変ですが、Hello, world!ロムを動かす分には必要ないのでいったん無視してしまって問題ありません。PPUの基本的な部分は、↑のQiitaの記事を見れば実装できると思います。 PPUの実装はCPUに比べれば大分楽で、実際に出力される絵を見ながら作業できるので楽しみながら進められると思います。

Hello, world!ロムの動作!

CPUとPPUが実装できればいよいよHello, world!ロムが動きます。 初めて動かした際の様子が次の通りです。

f:id:YDKK:20220307001509p:plain

この時点ではまだファミコン内部の色表現と実際に出力される色との変換を行っていませんが、まぁそのまま適当に出力するだけでもそれっぽく見えます。 HELLO, WORLD!が2行にわたって出力されているのはVRAMアドレス計算のミスによるバグです。

とまぁ本当に最低限の処理で表示もバグっていますが、この段階で自分の実装したCPUとPPUでプログラムが動いて絵が出ている訳で、めちゃくちゃテンションが上がります。

そのまま勢いで実装を修正しながら色変換を実装して、ロムを書き換えて好きなメッセージを出力してみます。

f:id:YDKK:20220307001538p:plain

ここまでくればチュートリアルは完了といって良いでしょう。

自分の場合はここまで来るのに、Rustの勉強と合わせて0から始めて大体2, 3日程度でした。

本当のCPU実装

さて、Hello, world!ロムが動いたら次に行うべきはnestestを完走させることです。

nestestはファミコンのCPU命令が正しく実装されているかどうかをテストできるロムのことで、これが本当に良く出来ています。

エントリーポイントを変更することでPPUやPAD入力が未実装でも動作し、またその動作を記録したリファレンスとなる有名なCPUログが存在するため自分のCPU実装と比べながらデバッグを進めることが可能です。

nestestやリファレンスとなるCPUログはNesdev wikiのページにあるリンクから入手可能です。

まずはCPUにリファレンスのログと同じ形式のログ出力機能を実装し、リファレンスのログとのdiffを取ることで徹底的にCPUのバグを洗い出していきます。

f:id:YDKK:20220307001606p:plain
実装したCPUログ出力

f:id:YDKK:20220307003146p:plain
リファレンスログとの比較

普通ならここでCPUのバグがそれはもうボロボロと出てくると思うので、地道にデバッグしていきます。

nestestは公式命令→非公式命令の順でテストが走るので、非公式命令を実装していない場合は非公式命令に当たる部分までのCPUログが一致していれば問題ないです。

f:id:YDKK:20220307003222p:plain
デバッグの結果、非公式命令に当たるまでログが一致するようになる

ちなみにnestestはPAD入力とPPUのVBlankでのフラグセットとNMIを実装すれば実際に操作できる形でも動かせます。が、↑でCPUの修正が済んでいれば当然ここでも問題なく動作するのでまぁおまけみたいなものです。*1

次回予告

長くなるので今回の記事はここまで。
次回はいよいよスーパーマリオブラザーズを動作させてみます、が……。

f:id:YDKK:20220307001220p:plain:w256

その2を書きました。

ydkk.hateblo.jp

*1:ちなみに、APUも実装すると効果音も鳴るようになっていて意外と凝っています。