YDiary

メモ的な

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:どこで入手したのか忘れてしまいましたが、探せば出てくると思います。