こんちは、GWに暇があったんで、ファミコンのエミュレータをRustで作ろうとしましたが、結果めちゃくちゃ苦しみながらまだCPUの命令セットしか実装できませんでした。
ハマりどころや困った点が大量にあったので一旦まとめます。
何をしたの?
ファミコンのエミュレータをRustで作ろうとしました。
ゲームのエミュレータなんて全く作ったことないので、個人で公開されているWriting NES Emulator in Rustというサイトの内容をもとに実装を進めました。
今はチャプター3まで終わっている段階です
- 環境
- Mac
- Mac mini (M1, 2020) Mac OS Monterey
- Rust
- rustc 1.62.0-nightly (4c60a0ea5 2022-05-04)
- nightly-aarch64-apple-darwin
- Mac
以下、ハマりどころを書いていきます
ハマったところ・困ったこと
実装が正しいか正しくないかが判断しにくい
このサイトでは、2つ3つの命令セットの仕様が丁寧に解説されていますが、その他の全ての命令セットに対する仕様は一切書かれていません。また、テストもほとんど書かれていません。
なので最初のうちはゴリゴリ進めれていたのですが、途中で急に、あとはわかるやろ?頑張れ!みたいな感じで突き放してきますw。著者自身がサイトでこのmemeを載せていて思わず笑いました
結局ネットでいろんなサイトから断片的に情報を収集するしかありませんでした。
主に以下のサイトが参考になりました。
- http://hp.vector.co.jp/authors/VA042397/nes/6502.html
- https://pgate1.at-ninja.jp/NES_on_FPGA/nes_cpu.htm
- https://wentwayup.tamaliver.jp/e166485.html
また、実装し終わった後に知ったのですがこのサイトが一番参考になりそうです。
http://vdmgr.g2.xrea.com/module_6502.html
テストがないのでいざ動かした時にどこがエラーか全く判断がつかない
前述の通り、数十もの命令セットを一切のテストなしで実装し、テスト用のROMを読み込むと当然必ずどこかでエラーが出ます。
しかもターミナルに丁寧にエラーの内容が出るわけではないので地道にエラー内容を特定する必要があるのですが、テストがない分どこが原因かめちゃくちゃ調査が大変です
僕は各命令実行前にレジスタなど出力する関数を仕込んでおいて、その標準出力をログに保存しておき、参考実装にも同様の関数を仕込んでログ保存し、それらを比較して地道にデバッグしました、
fn debug(&mut self, label: String) {
println!("{:20}... code: {:#06x} a: {:#06x} x: {:#06x} y: {:#06x} pc: {:#06x} sp: {:#06x} status: {:#10b}", label, self.memory_read(self.program_counter), self.register_a, self.register_x, self.register_y, self.program_counter, self.stack_pointer, self.status);
}
let mut i = 0;
cpu.run_with_callback(move |cpu| {
cpu.debug(format!("loop {}", i).to_string()); // here
handle_user_input(cpu, &mut event_pump);
VSCodeのdiffを使うと差分がとてもわかりやすく、捗ります
実行時にsdl2
が存在しないとエラーが出る
チャプター3.4で発生した問題です。このガイドの中でsdl2
をプロジェクトの依存関係に加えて画面表示する手順があったのですが、sdl2
が存在しないという旨のエラーが出てしまいました。
以下のようにbrewでインストールし、なおかつパスを通す必要があります
brew install sdl2
# for zsh
echo 'export LIBRARY_PATH="$LIBRARY_PATH:/opt/homebrew/lib"' >> ~/.zshrc
もしかしたらM1Macだけの問題かもしれません
スネークゲームのプログラムがGitHubとサイトで微妙に差異がある
let game_code = vec![
// ...
0x91, 0x00, 0x60, 0xa6, 0x03, 0xa9, 0x00, 0x81, 0x10, 0xa2, 0x00, 0xa9, 0x01, 0x81, 0x10,
+ 0x60, 0xa6, 0xff, 0xea, 0xea, 0xca, 0xd0, 0xfb, 0x60,
- 0x60, 0xa2, 0x00, 0xea, 0xea, 0xca, 0xd0, 0xfb, 0x60,
];
比較してみたら、最初のボタン入力があるまでヘビの動きが遅くなるかどうかの違いがありました。
参考実装と各ループで実行されるオペコードを比較すると急に差分が出るので注意が必要です
スタックポインタとプログラムカウンタの初期値がわからない
スタックポインタとプログラムカウンタの初期値は記載がないですが、
プログラムカウンタは処理が進むとインクリメントされていきます、プログラムは0x0600
から配置されるため、先頭から舐める必要があるのでプログラムカウンタは0x0600
になります。
また、スタックポインタは逆にデクリメントするため、スタックポインタの領域0x0100
~0x1FF
のうちの0x100
が最初にスタックポインタに指定される必要があります。
このゲームのプログラムで最初にスタックにポップされるのは、先頭の命令0x20
(=JSR)です。これはメモリアドレスを2つ消費するため、0x01FF-2=0x01FD
最初にスタック領域として指定されるのは0x01FD
領域です。しかし、スタックレジスタは8bitであるため0x00~0xFF
しか表現できません。なのでスタックポインタには末尾の2桁しか格納されません。
したがって、スタックポインタの初期値は0x00FD
となる訳です。ちなみにプログラムカウンタは16bitであるため気にする必要がありません
ちなみに、ch3.4のスネークゲームの場合のRAMの中身はこのような配置になっています。
アドレス | 内容 |
---|---|
00FE | 乱数ジェネレータ |
0xFF | 最後に押されたボタンのコード |
0x0100 ~ 0x01FF | スタック領域 |
0x0200 ~ 0x05FF | 画面の状態 |
0x0600 ~ 0x0x?? | プログラムコード |
JSR命令でメモリに格納するアドレスはpc+2
ではなくpc+1
JSR命令では、ジャンプ後に元の箇所に戻るために次の命令のアドレスをスタック領域に保存します。なので、オペランド分だけプログラムカウンタを+2ずらしてアドレスをスタックに保存しますが、正確には+2ではなくその前のアドレスをスタックに保存しておき、サブルーチンから戻るRTS命令時にポップしたアドレスに+1するみたいです。未定義動作と言われるものらしく、公式のデータシートには記載がないようです。
ジャンプサブルーチン命令(JSR)によってスタックに格納する復帰アドレスは、 次の命令の一つ前のアドレス(JSRの最後のバイト)であり、 リターンサブルーチン命令(RTS)によってインクリメントします。
NESの正確な動作の仕様は上記ですが、JSR命令では+2で保存しRTS命令では+1しないという実装にしてもプログラム自体は問題なく動きます。
ですが、参考実装では前者の実装方法を取っており、知らないと参考実装との比較時にスタックに保存される値が異なり混乱します
[[追記]] この後のnestest.rom
でのテスト時に差分もでてしまうので注意
ブランチする場合はプログラムカウンタを+1しなくて良い
そのままです。BNE命令やBVS命令では特定の条件に一致した際にブランチしますが、この時はオペランド分だけプログラムカウンタをインクリメントしてはいけません。
条件に一致しない場合はインクリメントする必要があります。
CMPなどでのキャリーフラグの付け方
CMPやCPX,CPY命令などではA/X/Yレジスタとオペランドを減算し、その結果によってキャリーフラグをつける必要があります。
このとき以下の点に注意する必要があります。
- A/X/Yレジスタ から オペランド を減算する
- 減算時にキャリーフラグがつくのは、繰り下がりが起こらなかった場合
なので結局は、 A/X/Yレジスタ >= オペランド
であればキャリーフラグがつき、そうでなければキャリーフラグがつきません。等価だった場合にもキャリーフラグがつくことは注意です
ブランチ時、プログラムカウンタへは加算だけではなく減算を行う場合もある
ブランチに関しては以下の記事が分かりやすかったです。
ただ、メモリから読み取った値を元に加算だけでなく減算もするので注意が必要です。
例えば何も考えずに以下のように実装すると、target
は0~255の値しか取り得ず、プログラムカウンタは加算しかされません。
let target = self.memory_read(self.program_counter);
let target_addres = self
.program_counter
.wrapping_add(1)
.wrapping_add(target as u16);
self.program_counter = target_addres;
以下のようにキャストして0~255を-128~127に変換する必要があります
- let target = self.memory_read(self.program_counter);
+ let target = self.memory_read(self.program_counter) as i8;
所感
- スネークゲームを正常に動作させるまでは終わりが見えない戦いだったので精神的に疲れた
- 実行しても即座にpanicするので進捗がある感じが全くしなかった
- 日本語での6502に関する記事が少なくてエミュレータ作成の敷居が高く感じた
- 1の補数や2の補数やキャリーなど普段気にしないことを気にする必要があり頭を使う感じがする…
- 特に減算時にキャリーするかどうかは毎回頭が混乱していた
- このサイトですっきり理解できた
- 特に減算時にキャリーするかどうかは毎回頭が混乱していた