ファミコンエミュレータ実装の感想
とりあえずスーパーマリオが動いて一段落したので覚えているうちに感想書いていく。
(この記事の情報量は、デバッグは大変、以上)
動機
単に好奇心。ただ、ファミコンのエミュレータに着手したのはこれで3回目になる。
1度目は10年前の身内ハッカソンのとき。このときはC言語で実装してて強引にHELLO, WORLD!を表示するだけで終わった。
実装の続きをしたかったけど、この後は忙しくなってしまって挫折している。
2度目は2年前で、過去の心残りを精算するためにGo言語で着手したのだけど、CPUの実装が終わった後ぐらいからまた忙しくなって挫折している。
今回は2年前のGoコードの続きからコミットを積んでここまで来たので、一応リベンジ成功....と言って良いんじゃないかな、たぶん。
過程
PPUの実装は最初からinternal register(v,t,x,w)を使う方法にした(PPU scrolling)。
hello world表示するだけでも地味にデバッグが大変になってしまって、最初は少し後悔した。
これは見切れているhello world。
見切れるbugは修正したけど、次は左寄りになってるhello world。
これはppu shift registersのbufferが1tile分足りなくて、1tileずれて表示されていた。
https://www.nesdev.org/wiki/PPU_rendering#Cycles_1-256
Note: At the beginning of each scanline, the data for the first two tiles is already loaded into the shift registers (and ready to be rendered), so the first tile that gets fetched is Tile 3.
正常なhello worldを確認して意気揚々とnestest.nesを読み込ませたらメモリアクセス違反でクラッシュしたんだけど、無視して強引に表示させた様子。
めっちゃバグってて草。画面表示なしだとnestest.nesのCPUテストはクリアするので、ここで少しデバッグに苦しんだ。
画面表示ありでnestest.nesが正常に動いた様子。
原因としては以下の一文を見落としており、メモリアクセス違反が発生していた。
https://www.nesdev.org/wiki/PPU_scrolling#PPU_internal_registers
Note that while the v register has 15 bits, the PPU memory space is only 14 bits wide. The highest bit is unused for access through $2007.
sprite実装に着手したがきもい色になった。
ここからギコ猫でもわかるファミコンプログラミングの各種ROMにお世話になる。
正常なgiko猫さん
背景動いている
sprite 0 hit の実装も(bugがあるが)比較的にすんなり進んでしまってgiko猫のラスタスクロールは動いた。ただ、この後の実装でこのgiko猫ラスタスクロールは動かなくなる、下記に従った実装すると動かなくなってしまった(今も)。
これと、
https://www.nesdev.org/wiki/PPU_registers#OAM_data($2004)%3C%3E_read/write
Writes to OAMDATA during rendering (on the pre-render line and the visible lines 0-239, provided either sprite or background rendering is enabled) do not modify values in OAM
これ
https://www.nesdev.org/wiki/PPU_registers#OAM_address($2003)%3E_write
Values during rendering
OAMADDR is set to 0 during each of ticks 257-320 (the sprite tile loading interval) of the pre-render and visible Scanlines.
公開されている2048.nes。このときはまだコントローラ実装が雑だったので操作感が微妙だったが、ぎり遊べた。
ここからは公開されているPPU系のテストROMと格闘することになる。
bug取り作業で印象に残っているのは3つ。
1. CPUとPPUのタイミング実装を変えたこと
まず、実機だとCPUとPPUは独立して動作し、clock差は1:3の関係になっている。対してエミュ実装の基本的な方針としては並列ではなくCPUを動かしてその後にPPUを動かす。
自分の実装でも方針は同じだけど、CPU命令を1つ実行したらその命令にかかったclock数に応じて最後にPPUを動かしていた。
各命令毎のclock数の表はこことか: https://www.nesdev.org/wiki/6502_cycle_times
例えばLDA ABSなら4clockなので、その命令の処理が全部終わってからPPU側を12clock動かす。
この実装のエミュだと、実機の挙動と比較してCPUとPPUの状態に差があるのでrace conditionの再現度がいまいち。それが原因でどうにもvbl flag timing系のテストが通らなかった。
https://www.nesdev.org/wiki/PPU_frame_timing#VBL_Flag_Timing
再現度を上げるためにも各命令の実行途中で良い感じにPPUを進ませたい (良い感じとは?)。
なんとかしてえ〜って思って各命令にかかるclock数に着目していたら、最低でもメモリの読み/書きする回数以上であることが分かった。
CPUから1回メモリを読み/書きするときにPPU 3clock進ませる方針にして、余ったclock数は最後に調節する設計に切り替えた。
これが良い感じにハマって多くのvbl flag timingu系のテストを通すことが出来た。
その後になって命令毎にどのタイミングでCPU 1clock相当の処理になるのか詳細に書かれた資料を見つけてさらにハッピー。
https://www.nesdev.org/6502_cpu.txt
dummy read/write はこの辺から来てるのか〜となった。
2. CPUから見たときのPPUの状態の定義
メモリの読み書き時にPPUを動かすようにしても一部のtiming testが通らなかった。デバッグするとテストROMが期待する状態に対してPPU 1clock分ずれていた。 CPU 1clock分ずれるのは分かるけどPPU 1clockずれることに心当たりがなくて???だった。 自分の最初の実装だと、「PPUのscanline=241,cycle=1のときにvblankが発生する」というのを、cycle 1 ~ 2 の間で発生すると解釈して実装していた。これだとCPUから見てPPUの状態がちょうどscanline=241,cycle=1のときはまだvblankが発生していないことになっていた。対してテストROMが期待していたのはvblankが発生済み、というものだったので、自分の解釈が間違っていた。 cycle 1 ~ 2 の間で発生する〜実装をやめて、CPUから見てPPU cycle=1状態だとcycle=1で発生するPPU側のイベントは全部終わっているように修正した。 これで当初こけてたテストも通った。
3. sprite 0 hitのbug
めっちゃハマった。上記はこのbugにハマっていたときのもの。
このbugに向き合う前は散々cpuとppuのタイミングのずれと格闘していたので、絶対それ関連だろうと憶測を立ててしまったのが誤り。
不幸なことにこの時点でsprite_hit_tests_2005.10.05のテストは全部OKだったので、既存のテストROMで検知出来ない部分のsprite 0 hit処理がバグってると考えるのにかなり時間がかかってしまった。
wikiの通りにoamはprimaryとsecondaryを用意する実装にしていたんだけど、"0 index"の対象をsecondary oam側にしてしまっていたのが原因だった。普通にbugである。
RasterDemoの画面の動きを観察したときに、背景を1本の軸で回転させてるなぁ〜 => ラスタスクロールはタイミングを捉えるのが重要だからこの1本の軸のところがsprite 0 hitだろう => 常に固定だろう => あれ、なんでsprite 0 hitが固定なのに画面がちらつくんだ? という思考による気付きだった。観察は重要。
primary oam側の0 indexを見るように修正して解決。
他にも色んなデバッグ苦労話があるがだいたいこんなところ
テストROMがある程度通したところで、FCダンパー購入
メルカリで購入したスーパーマリオをダンパーに指した様子
そしていざROM吸い出し....
これはカセットとROM吸い出しの接触不良によって生み出された謎のゲーム。
めっちゃわくわくして自作エミュに読み込ませたらこれだったので「えっ、自作エミュばぐってる?... 念入りにテスト通してきたのに?....」ってなった。
急遽別のエミュを落としてきて同ROMを読み込ませたら同じ現象になって安堵した。
カセットを抜き差しして何度か試すものの、毎回ハッシュ値が違ってて笑ってしまった。
念願のハッシュ値で吸い出し成功したら平和が訪れた。
だめだと分かってても懐かしさに負けてカセットをふーふーしてしまった。反省してます。
その他
実装の骨格はfogleman/nesを参考にさせていただいた。シンプルに書かれててすごい。自分のエミュではAPUは未実装、サウンドは...仕組みにあまり興味がないのでたぶん実装しない。vbl_nmi_timing/7.nmi_timing.nes
とppu_vbl_nmi/05-nmi_timing.nes
はNMI発生をppu 2clock delayさせるhackをしてしまった。今の自作エミュだとcpuを動かした後にppuを動かす実装なので、ppuの動作が実際より遅れることはあっても早くなることはない、という理解なのだけど、テストROMが期待するよりもNMI発生が早い...という状態っぽくて ??? 状態。よくわかってない。あと、decay ppu openbusがよくわからず未実装。ppu openbusのテスト通しておきたい気持ちはある。ぽよよ
現時点で自分が主に使ったテストROMたち
Test | SingleRom | Result |
---|---|---|
blargg_ppu_tests_2005.09.15b | palette_ram.nes | OK |
blargg_ppu_tests_2005.09.15b | sprite_ram.nes | OK |
blargg_ppu_tests_2005.09.15b | vbl_clear_time.nes | OK |
blargg_ppu_tests_2005.09.15b | vram_access.nes | OK |
branch_timing_tests | 1.Branch_Basics.nes | OK |
branch_timing_tests | 2.Backward_Branch.nes | OK |
branch_timing_tests | 3.Forward_Branch.nes | OK |
cpu_dummy_reads | cpu_dummy_reads.nes | OK |
cpu_dummy_writes | cpu_dummy_writes_oam.nes | OK |
cpu_dummy_writes | cpu_dummy_writes_ppumem.nes | OK |
cpu_exec_space | test_cpu_exec_space_apu.nes | Failed |
cpu_exec_space | test_cpu_exec_space_ppuio.nes | OK |
cpu_timing_test6 | cpu_timing_test.nes | OK |
instr_misc | 01-abs_x_wrap.nes | OK |
instr_misc | 02-branch_wrap.nes | OK |
instr_misc | 03-dummy_reads.nes | OK |
instr_misc | 04-dummy_reads_apu.nes | Failed |
instr_test-v5 | 01-basics.nes | OK |
instr_test-v5 | 02-implied.nes | OK |
instr_test-v5 | 03-immediate.nes | OK |
instr_test-v5 | 04-zero_page.nes | OK |
instr_test-v5 | 05-zp_xy.nes | OK |
instr_test-v5 | 06-absolute.nes | OK |
instr_test-v5 | 07-abs_xy.nes | OK |
instr_test-v5 | 08-ind_x.nes | OK |
instr_test-v5 | 09-ind_y.nes | OK |
instr_test-v5 | 10-branches.nes | OK |
instr_test-v5 | 11-stack.nes | OK |
instr_test-v5 | 12-jmp_jsr.nes | OK |
instr_test-v5 | 13-rts.nes | OK |
instr_test-v5 | 14-rti.nes | OK |
instr_test-v5 | 15-brk.nes | OK |
instr_test-v5 | 16-special.nes | OK |
nestest | nestest.nes | OK |
oam_read | oam_read.nes | OK |
oam_stress | oam_stress.nes | OK |
ppu_open_bus | ppu_open_bus.nes | Failed |
ppu_read_buffer | test_ppu_read_buffer.nes | OK |
ppu_vbl_nmi | 01-vbl_basics.nes | OK |
ppu_vbl_nmi | 02-vbl_set_time.nes | OK |
ppu_vbl_nmi | 03-vbl_clear_time.nes | OK |
ppu_vbl_nmi | 04-nmi_control.nes | OK |
ppu_vbl_nmi | 05-nmi_timing.nes | OK |
ppu_vbl_nmi | 06-suppression.nes | OK |
ppu_vbl_nmi | 07-nmi_on_timing.nes | OK |
ppu_vbl_nmi | 08-nmi_off_timing.nes | OK |
ppu_vbl_nmi | 09-even_odd_frames.nes | OK |
ppu_vbl_nmi | 10-even_odd_timing.nes | Failed |
sprite_hit_tests_2005.10.05 | 01.basics.nes | OK |
sprite_hit_tests_2005.10.05 | 02.alignment.nes | OK |
sprite_hit_tests_2005.10.05 | 03.corners.nes | OK |
sprite_hit_tests_2005.10.05 | 04.flip.nes | OK |
sprite_hit_tests_2005.10.05 | 05.left_clip.nes | OK |
sprite_hit_tests_2005.10.05 | 06.right_edge.nes | OK |
sprite_hit_tests_2005.10.05 | 07.screen_bottom.nes | OK |
sprite_hit_tests_2005.10.05 | 08.double_height.nes | OK |
sprite_hit_tests_2005.10.05 | 09.timing_basics.nes | OK |
sprite_hit_tests_2005.10.05 | 10.timing_order.nes | OK |
sprite_hit_tests_2005.10.05 | 11.edge_timing.nes | OK |
sprite_overflow_tests | 1.Basics.nes | OK |
sprite_overflow_tests | 2.Details.nes | OK |
sprite_overflow_tests | 3.Timing.nes | Failed |
sprite_overflow_tests | 4.Obscure.nes | Failed |
sprite_overflow_tests | 5.Emulator.nes | OK |
vbl_nmi_timing | 1.frame_basics.nes | OK |
vbl_nmi_timing | 2.vbl_timing.nes | OK |
vbl_nmi_timing | 3.even_odd_frames.nes | OK |
vbl_nmi_timing | 4.vbl_clear_timing.nes | OK |
vbl_nmi_timing | 5.nmi_suppression.nes | OK |
vbl_nmi_timing | 6.nmi_disable.nes | OK |
vbl_nmi_timing | 7.nmi_timing.nes | OK |