Technical Document (NES Emulator) Last Update: 2014 11/26 Contact: [email protected] Author: Ryoma Kawaguchi (B1, Keio University) Jun Murai Lab., ARCH(Internet Architecture Research Team) @ Delta North. 動機とエミュレータの概要 計算機科学者の間では通称パタヘネ本として有名なDavid A.Patterson, John L.Hennessy氏による「コンピュータの構成と設計第4版(Computer Organization and Design: The Hardware/Software Interface)」を夏期休業中に読み、コンピュータアーキテクチャへの理解を単なる知識としてで はなく、さらに実践という形で学ぶ事によってさらなるスキルアップを計りたいと考え、秋学期ではファミコン(NES)エミュレータの開発を行う 事にした。 ファミコンを選択した理由としては、近年コンピュータを構成しているチップの集積化や統合、並列処理による高速化などの著しい成長には目を 見張るものがあるが、本質的なコンピュータアーキテクチャは数十年前からほとんど変化していないため比較的に単純に構成されており、且つ多 くの人間に作業の成果を分かりやすく伝えるため親しみやすいファミコンをテーマとした。 エミュレータとは"emulate"という英語から来ている言葉であり、中国語では模拟器(mo2 ni3 qi4)とも呼ばれている。中国語の言葉から何かを再現 するためのものだという推測ができる。エミュレータは他のハードウェアをソフトウェア上で再現するプログラムの事でありPC上で動作するファ ミコンのエミュレータと言えば「パソコン上でファミコンのゲーム機自体をソフトウェア上で再現するプログラム」という意味を指す。簡単に言 えばパソコン上でファミコンのゲームソフトを遊べるようにするプログラムという事になる。 当然ゲーム機というものはゲームカートリッジがなければゲームは動かないため、ファミコンエミュレータでゲームを動かす場合にもゲームカー トリッジと同等のものをソフトウェア上で用意する必要がある。これがROMイメージであり、通常は実際にゲームカートリッジから専用の機器を 使ってコンピュータ上にプログラム形式でコピーする必要がある。 しかし、現状では専用の機器が高価な事とゲームカートリッジを持っていない人間が大多数のため多くの人はインターネット上に違法にアップロ ードされたROMイメージをダウンロードしてそれをエミュレータに読み込ませてゲームを楽しんでいる。今回は先ほど専用の機器と書いたROM イメージをゲームカートリッジからコピーする機械(以下吸い出し機)を自作し違法行為無しにゲームを動作させる事にする。 ゲームハードとエミュレータの構成 ファミコンは分解して基板を取り出すと大体次のようになっている。カートリッジ部分はバスでCPUなどと直接接続されており、その他にもグラ フィックを処理するための専用チップであるPPU(Picture Processing Unit)などが見られる。 この構造を真似てエミュレータを作っていく事になる。 UI部とキーボード入力などを実際に受け付ける部分はSDLに担当させる。つまりエミュレート処理と実際のPCからの外部入力/出力とは完全に分 離した形での実装を行う。 当然ながらエミュレータ開発とはROMイメージに記述されている機械語命令やグラフィックデータを実機と同じ通りに操作/実行する事で実機の ゲームを別の環境で仮想的に動作させるプログラムを記述する作業のため、ファミコンに使われているアーキテクチャの解析が必要なり、それら の仕様を元にROMイメージが正常動作可能なコードを記述する必要がある。 今回のエミュレータ開発ではCPUの命令解釈やPPUによるグラフィック処理などを仮想機械であるVMクラスに統括してオブジェクト指向による 抽象化を計っている。 今回の目標 ファミコンのエミュレータをC++を用いて開発を行い、加えてAVRマイコンを利用してスーパーマリオブラザーズのROMイメージを抽出する吸い 出し機の制作とROMダンププログラムの開発をAVR-gcc上でCを用いて開発を行う。 最終的には自作のエミュレータ上で自作の吸い出し機とプログラムで抽出したスーパーマリオブラザーズのROMを動作させ、正常にプレイさせる 事を目標とする。なおエミュレータ実装はスーパーマリオブラザーズが動作する最低限の実装を行う事にする。 ROMに関する情報: http://datacrystal.romhacking.net/wiki/Super_Mario_Bros. iNES 1.0 Format iNESフォーマット(*.nes)に対応させておけば大抵のネット上にあるROMイメージは解釈する事はできる。 ファミコン/NES向けのROMには様々なマッパータイプが存在しており、現在解析が進みiNESヘッダに定義されているビット幅ではマッパー番号 を表現できなくなってきており、新しいバイナリフォーマットの提案などがなされている。 基本的に野良フォーマットのため標準化などは行われ ておらず個々のエミュレータ実装ごとに独自の拡張がなされ、様々な亜種が存在するがここではnesdevにあったものに従う。 概観 +-------------------+ + iNES Header (16) + +-------------------+ + Trainer patch + + (0 or 512 byte) + +-------------------+ + + + PRG-ROM + + (0x4000*x bytes) + + + +-------------------+ + + + CHR-ROM + + (0x2000*y bytes) + + + +-------------------+ + PlayChoice ROM + + (INST-ROM/PROM) + ~~~~~~~~~~~~~~~~~~~~~ -> CPU -> PPU PlayChoiceとTrainerは今回の目的を達成する上で必ずしも必要な部分ではないため実装ではPlayChoiceは元からパースせずに切り離し、Trainerは ヘッダのビットが立っていた場合512byte分seekして読み飛ばしを行っている。 ヘッダ構造体 0 1 2 3 4 +--------------+---------------+---------------+--------------+ 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + File Signature + + ("NES" + 0x1a) + +--------------+---------------+---------------+--------------+ + PRG-ROM Page + CHR-ROM Page + Flag-0 + Flag-1 + + (PRG/0x4000) + (CHR/0x2000) + (PPU Flags) + (Mapper No.) + +--------------+---------------+---------------+--------------+ + SRAM Size + Flag-2 + Flag-3 + Reserved + + + (NTSC/PAL) + (Mirroring) + (zero-field) + +--------------+---------------+---------------+--------------+ + Reserved + + (zero-field) + +-------------------------------------------------------------+ 各フラグのビット割り当てを示す。 太字になっている部分はエミュレータを開発する上で特に必要になる情報となっている。 Flag-0 bit0: bit[0]=1の時垂直ミラーリング、bit[0]=0の時水平ミラーリング bit1: bit[1]=1の時SRAM有効(0x6000 ~ 0x7fff(拡張RAM領域)に配置) bit2: bit[2]=1の時Trainer patch有効 (0x7000 ~ 0x71ff (拡張RAM領域)に配置) bit3: 4画面VRAM bit4-7: マッパー番号の下位ニブル Flag-1 bit0-1: ゼロフィールド (Vs unisystem, playchoiceを考慮しないため) bit2-3: iNESフォーマットでは00となっているがこのビットが0b10=2になっている時NES2.0と関係があるらしい bit4-7: マッパー番号の上位ニブル Flag-2 bit0: bit[0]=1の時PAL形式、bit[0]=0の時NTSC形式 bit1-7: ゼロフィールド Flag-3 (非公式) 正式なiNESフォーマットには存在していないがいくつかのエミュレータがこのフラグを利用している。 いわゆる拡張フラグ。 bit0-1: bit[0,1]=1の時PAL形式、bit[1,1]=3の時NTSC/PAL両形式に対応、bit[0,0]=0の時NTSC形式 カートリッジコネクタ(KEL-4630-060-038) 北米などで発売されていたNES(Nintendo Entertainment System)とファミコンは基本的にはカートリッジの物理的な互換性が保たれていない。 NESカートリッジでは72ピンなのに対してファミコンカートリッジでは60ピン構成となっている。 nesdevの資料はテキストとアスキーアートで構成されており、開発を行う上で効率が悪かったため上の画像を作成した。 負論理ピン CPU R/WのうちのR: PRG-ROM(もしくは拡張RAM)への出力(H=R,L=W) IRQ: IRQ割り込み信号の入力 PPU RD/WE: CHR-ROMもしくは拡張RAMへの制御出力 /WE=L,/RD=H: W /WE=H,/RD=L: R /WR=H,/RD=H: Disable ROMSEL: 負論理出力によってPRG-ROMが選択される CIRAM/CE (VRAM CS): VRAMのChip-Selectピンへの入力 PPU A13: CHR-ROMへのアドレス出力 (PPU A13は正論理入力と負論理入力の2つが存在する) パソファミ回路とのピン配置対応表 (Front:F, Back:B) 回路URL: http://www.emusta.net/KAIRO.PDF F25+F13=A0 (PPU A0+CPU A0) F24+F12=A1 (PPU A1+CPU A1) F23+F11=A2 (PPU A2+CPU A2) F22+F10=A3 (PPU A3+CPU A3) B20+F06=P50+P06=A7 (PPU A7+CPUA7) F19+F07=A6 (PPU A6+CPU A6) F20+F08=A5 (PPU A5+CPU A5) F21+F09=A4 (PPU A4+CPU A4) B21+F05=P51+P05 (PPU A8+CPU A8) B22+F04=P52+P04 (PPU A9+CPU A9) B23+F03=P53+P30 (PPU A10+CPU A10) B24+F02=P54+P02 (PPU A11+CPU A11) B05 = P35 (CPU A14) B04 = P34 (CPU A13) B25+B03=P55+P33 (PPU A12+CPU A12) F27+B12=P27+P42 (PPU D1+CPU D1) F29+B10=P29+P40 (PPU D3+CPU D3) B29+B08=P59+P38 (PPU D5+CPU D5) B27+B06=P57+P36 (PPU D7+CPU D7) B28+B07=P58+P37 (PPU D6+CPU D6) B30+B09=P60+P39 (PPU D4+CPU D4) F28+B11=P28+P41 (PPU D2+CPU D2) F26+B13=P26+P43 (PPU D0+CPU D0) B02=P32 (M2(CPU clock output) = CLK) B17=P47 (PPU/WR = CWE(CHR-ROM WRITE信号)) B26=P56 (PPU A13 = CHR(CHR-ROM CE(Chip-Enable)信号)) F17 (PPU/RD = COE(CHR-ROM OE(Output-Enable)信号)) B14=P44 (/ROMSEL(/A15+M2) = PRG(PRG-ROM CE(Chip-Enable)信号)) F14 (CPU R/W = PWR(PRG-ROM WRITE信号)) B19 (PPU /A13 = CIN) F01, F16 (GND) F30, B01(P31) (Vcc (+5V)) ROMダンパ 準備物 74HC393AP (4bitカウンタ x2) x2 AVRマイコン(ATmega328P-PU) x1 MAX232C(ADM209 (TTL/CMOS電位レベル変換IC)) x1 LED x2 抵抗(510Ω) x2 RS232Cポート(Dsub 9-pinメス) x1 USB<->RS232C(Dsub 9-pinオス) x1 ファミコンカートリッジコネクタ(KEL-4630-060-038) x1 データシート 74HC393AP: http://www.semicon.toshiba.co.jp/info/lookup.jsp?pid=TC74HC393AP&lang=ja ATmega328P-PU: http://www.avr.jp/user/DS/PDF/mega88A.pdf 具体的なダンプ手順 Mapper-0(NROM)カートリッジには大きく分けてアドレスバス、データバス、各Enable信号の3つが存在する。 アドレスバスは15本存在し、これで15bit長(=32KB空間)のアドレスを表現できる。データバスはアドレスバスで渡したアドレスに格納されている ROMの内容を8本、つまり8bit(=1byte)で転送される。 ゲームを動作させるにはPRG-ROMとCHR-ROMをダンプしなくてはならない。そこで各Enable信号の組み合わせによってCHR-ROMを読みだす かCHR-ROMを読みだすか、といった指定を行う事ができる。 つまり、Enable信号でどちらのROMを読むかを指定し、アドレスバスにアドレスを流せばそれに対応した1バイトのROMデータが取得できるとい う非常に単純な作りとなっている。 アドレスの指定をAVRとカートリッジ側で直接行ってしまうとAVR側のI/Oピンが不足してしまうため、74HC393APを利用する. 74HC393APには 4bitカウンタが1つのICに二つ存在しているため、1つ目のMSB側出力をもう一つのカウンタのクロックと接続する事によって8bitカウンタが構成さ れ、同様に2個のICを利用する事によって16bitカウンタが構成でき、これらをカートリッジコネクタのアドレスバスへ接続する。これによってア ドレスバスに関するものでAVRのI/Oピンへ接続されるピンの本数はCLR(共通)と/CLKの2本となった。 (Bsch3vにて作成) アドレスのカウントアップをAVR側から送り、データバスの8本,各Enable信号の4つは直接カートリッジ<->AVRで受け渡しを行う。 AVR(ATmega328P-PU)側の接続図 ピンクはAVRへの接続ピン、緑はAVRからファミコンカートリッジコネクタへの接続を表している 吸い出しプログラム(PC) [Rk@20:55:40]~% cd /dev && ls -la crw-rw-rw- 1 root wheel 18, crw-rw-rw- 1 root wheel 18, crw-rw-rw- 1 root wheel 18, crw-rw-rw- 1 root wheel 18, crw-rw-rw- 1 root wheel 18, [Rk@20:55:50]~% tty.* 4 Oct 6 Oct 0 Oct 2 Oct 8 Oct 30 30 30 30 30 02:47 02:47 02:47 02:47 20:55 tty.Bluetooth-Modem tty.Bluetooth-PDA-Sync tty.Bluetooth-Serial-1 tty.Bluetooth-Serial-2 tty.usbserial-FTHFZEZC RS232C<->USBケーブルのドライバを認識した後、USBシリアルのttyをscreenコマンドなどで叩く。 AVR側のプログラムは以下のプロトコルで吸い出すべきROMの選択(PRG-ROM/CHR-ROM)と ROMのロード,中止を行う。 PRG-ROM: CPU R/W(1,OE) + /ROMSEL(1,CS) + M2(0) + PPU /RD(1) CHR-ROM: CPU R/W(0,OE) + /ROMSEL(0,CS) + M2(1) + PPU /RD(1) 今回PC側のプログラムはRubyを用いて開発し、ruby-serialportというライブラリを利用した。 CPU (6502,RP2A03) (CPUの動作を確認するためのデバッグ出力(1~10の総和)。左:CPU実行トレース, 右上:メモリダンプ(0x37=55),右下:6502バイナリ) MOS Technology社の6502プロセッサをRICOHがカスタムして10進数モードを削除し、音源機能(2A03音源)を追加したカスタムチップがNESのプ ロセッサに採用されている。 カートリッジの中にあるバイナリを動作させCPU動作をトレースしてみた所、命令が処理されその過程でROM内のバイナリにアクセスしたりレジ スタの値を変更している事が可視化できた。 メモリマップ 16bitCPUアドレス空間 64Kbyte, ROM32Kbyte。Memory-Mapped I/Oになっている。 エミュレータの実装ではミラーリングの部分は同じ部分が繰り返されて邪魔なので余剰で対応する事が多い。 メモリアクセス違反の時は0を返し たほうが無難。 0x0000 ~ 0x07ff : RAM (2KB) (ゼロページ: 0x0000 ~ 0x00ff, スタック: 0x0100 ~ 0x01ff) 0x0800 ~ 0x1fff : RAMミラーリング 0x2000 ~ 0x2007 : PPU向けI/Oポート (PPUレジスタ等) 0x2008 ~ 0x3fff : 0x2000 ~ 0x2007のミラーリング 0x4000 ~ 0x401f : APU,その他のI/Oポート(スプライトDMA/ジョイスティック) 0x4020 ~ 0x5fff : 拡張RAM 0x6000 ~ 0x7fff : バッテリーバックアップRAM 0x8000 ~ 0xbfff : PRG-ROM Low 0xc000 ~ 0xffff : PRG-ROM High レジスタ 8bit :A,X,Y,S(Processor Status Register), P(Stack pointer) 16bit :PC (Program counter) Pレジスタ レジスタ名[N]はそのレジスタのNビット目を表している事にする。 ただしP[X]はPレジスタのXフラグを表している事にする。 NVRBDIZC N Negative flag .. A[7] == 1の時にセット V oVerflow flag .. 結果がオーバーフローしている時にセット R Reserved flag .. 予約ビット。常にビットが立っている。 B Break flag .. BRK命令(ソフトウェア割り込み)が発生した時にセット。IRQ発生時にクリア。 D Deciaml flag .. P[D]=1の時にBCDモード。しかしRP3A02では実装されていない。 I Interrupt flag .. P[I]=1の時にIRQ禁止, P[I]=0の時はIRQを受け付ける。 Z Zero flag .. 演算結果がゼロだった場合にP[Z]=1 C Carry flag .. キャリーが発生した時にP[C]=1 割り込み NMI(vector:0xfffa ~ 0xfffb): P[I]の値に関係なく割り込む。PPUのVBlank発生直前に呼び出される。 - P[I]=1に変化させP[B]=0に変化させる RESET(vector:0xfffc ~ 0xfffd): 電源投入時やソフトウェアリセット時 - P[I]=1に変化 IRQ/BRK(vector:0xfffe~0xffff): ハードウェア信号/BRK命令 - P[I]=1に変化 - 割り込みベクタがIRQとBRKで同じのためISR上でP[B]=1となっていたらBRK、そうでなければIRQという風にチェックを入れる必要がある .bank 1 .org $fffa .dw NMIProc .dw RESETProc .dw IRQProc 割り込みテーブルはROMの中(Bank1)に格納されている。 CPU初期化時の設定 電源オン時 各種レジスタの初期化 P(Processor Status Register) = 0x34 S(Stack pointer) = 0xfd PC(Program counter) = (read(0xfffc) | read(0xfffd << 8))、つまりRESET割り込みベクタからの値 A=X=Y=0 メモリの初期化 0x0008 = 0xf7,0x0009 = 0xef,0x000a = 0xdf,0x000f = 0xbf 0x4015(チャンネル全無効) = 0x00 0x4017(フレームIRQ有効) = 0x00 0x4007~0x400f = 0x00 各種フラグの変更 IRQ/NMIフラグに変更なし 電源オン後のリセット時 各種レジスタの初期化 A,X,Yは変化なし S(Stack pointer)は現在の値から3だけデクリメント(しかしスタックへは何も書き込まれていない) PC(Program counter) = (read(0xfffc) | (read(0xfffd) << 8))、RESET割り込みベクタの指す値 メモリの初期化 0x4015 = 0x00 各種フラグの変更 Interruptフラグを立てる アドレッシング u8 .. unsigned 8-bit, u16 .. unsigned 16-bit s8 .. signed 8-bit, Implied (Op)..Op Accumlator (Op).. Immediate (Op #u8)..u8 Zero-page (Op u8).. vm.load(u8) Zero-page index-X (Op u8X).. vm.load((u8 + X) % 0x100) Zero-page index-Y (Op u8,Y)..vm.load((u8 + Y) % 0x100) Relative (Op s8).. Absolute (Op u16).. Absolute index-X (Op u16,X).. Absolute index-Y (Op u16,Y).. Indirect (Op u16).. Indirect X (Op u8,X).. Indirect Y (Op u8,Y).. バグ JMP命令時のIndirectアドレッシングバグ 0xc100: 0xc1ff: 0xc200: .. 0xd02c: 0xd02c: 0x4c 0x00 0x23 0x6c 0xff 0xc1 ; jmp (0xc1ff -> 0x2300 (想定される実効アドレス)) 0x6c 0xff 0xc1 ; jmp (0xc1ff -> 0x4c00 (高位バイトに256byte前を参照)) 6502では256byte単位のページが256byteあるという認識を行って16bit長、64KBのCPUアドレス空間を持っている。Indirectアドレッシングはjmp 命令にのみ存在しており、オペランドで渡された16bitアドレスが示す8bit値から2つ, つまり2byteが実効アドレス(little-endianになっている事に注 意)になる。さて、この命令にはバグが存在し渡されたオペランド(Op l h)がl=0xffの時繰り上がらずに結果として256byte前のメモリを参照するバ グがある。バグ再現実装は下。 uint16_t addrIndirect() { uint16_t src = vm.read(pc++); // 0xff src = src | (vm.read(pc++) << 8); // 0xc1ff // read(0xc1ff) | (read(0xc100 | 0x0000) << 8) = 0x00 | (0x4c << 8) = 0x4c00 return vm.read(src) | (vm.read((src & 0xff00) | ((src + 1) & 0x00ff)) << 8); } PHP命令のバグ inline void CPU::opPHP() { this->stack.push(this->psr & pow(2,4)); } スタックに対する命令でPレジスタ(Processor Status Register)をpushする命令。これに対してPLA命令というものが存在し、スタックからpopし てAレジスタに格納する命令になるのだが、PHPを実行した後にPLAを実行するとかならずBreakフラグが立った状態でpopされるようになってい る。PレジスタはNVRBDIZCという順になっていてRは予約されているため常に1なのだが、Bが1になっている。 命令セット http://nesdev.com/opcodes.txtより。 ブランチ命令等が抜けていたものを個人的に修正した。 ただしIndirectアドレッシングのJMP命令の記述が抜けている。オペコードは0x6c(Indirect)。 none imm zero abs zerox zeroy absx absy indx indy LDA LDX LDY STA STX STY ___ ___ ___ ___ ___ ___ 0xa9 0xa2 0xa0 ___ ___ ___ 0xa5 0xa6 0xa4 0x85 0x86 0x84 0xad 0xae 0xac 0x8d 0x8e 0x8c 0xb5 ___ 0xb4 0x95 ___ 0x94 ___ 0xb6 ___ ___ 0x96 ___ 0xbd ___ 0xbc 0x9d ___ ___ 0xb9 0xbe ___ 0x99 ___ ___ 0xa1 ___ ___ 0x81 ___ ___ 0xb1 ___ ___ 0x91 ___ ___ TXA TYA TXS TAY TAX TSX 0x8a 0x98 0x9a 0xa8 0xaa 0xba ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ PHP PLP PHA PLA 0x08 0x28 0x48 0x68 ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ADC SBC CPX CPY CMP ___ ___ ___ ___ ___ 0x69 0xe9 0xe0 0xc0 0xc9 0x65 0xe5 0xe4 0xc4 0xc5 0x6d 0xed 0xec 0xcc 0xcd 0x75 0xf5 ___ ___ 0xd5 ___ ___ ___ ___ ___ 0x7d 0xfd ___ ___ 0xdd 0x79 0xf9 ___ ___ 0xd9 0x61 0xe1 ___ ___ 0xc1 0x71 0xf1 ___ ___ 0xd1 AND EOR ORA BIT ___ ___ ___ ___ 0x29 0x49 0x09 ___ 0x25 0x45 0x05 0x24 0x2d 0x4d 0x0d 0x2c 0x35 0x55 0x15 ___ ___ ___ ___ ___ 0x3d 0x5d 0x1d ___ 0x39 0x59 0x19 ___ 0x21 0x41 0x01 ___ 0x31 0x51 0x11 ___ ASL LSR ROL ROR 0x0a 0x4a 0x2a 0x6a ___ ___ ___ ___ 0x06 0x46 0x26 0x66 0x0e 0x4e 0x2e 0x6e 0x16 0x56 0x36 0x76 ___ ___ ___ ___ 0x1e 0x5e 0x3e 0x7e ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ INX INY INC DEX DEY DEC 0xe8 0xc8 ___ 0xca 0x88 ___ ___ ___ ___ ___ ___ ___ ___ ___ 0xe6 ___ ___ 0xc6 ___ ___ 0xee ___ ___ 0xce ___ ___ 0xf6 ___ ___ 0xd6 ___ ___ ___ ___ ___ ___ ___ ___ 0xfe ___ ___ 0xde ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ CLC CLI CLV CLD SEC SEI SED 0x18 0x58 0xb8 0xd8 0x38 0x78 0xf8 ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ NOP BRK 0xea 0x00 ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ JSR JMP RTI RTS ___ ___ 0x40 0x60 ___ ___ ___ ___ ___ ___ ___ ___ 0x20 0x4c ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ ========================================================================= rel BCC 0x90 ___ ___ ___ ___ ___ ___ ___ ___ ___ BCS 0xb0 ___ ___ ___ ___ ___ ___ ___ ___ ___ BEQ 0xf0 ___ ___ ___ ___ ___ ___ ___ ___ ___ BMI 0x30 ___ ___ ___ ___ ___ ___ ___ ___ ___ BNE 0xd0 ___ ___ ___ ___ ___ ___ ___ ___ ___ BPL 0x10 ___ ___ ___ ___ ___ ___ ___ ___ ___ BVC 0x50 ___ ___ ___ ___ ___ ___ ___ ___ ___ BVS 0x70 ___ ___ ___ ___ ___ ___ ___ ___ ___ 値によってサイクルが変化する命令/アドレッシング アドレッシング: Absolute index-X, Absolute index-Y, Indirect-Y 繰り上がっていたら元の命令コードのサイクル+1のサイクル 命令: BCC BCS BEQ BNE BVC BVS BPL BMI (Branch命令) 通常 元の命令コードのサイクル+1のサイクル、繰り上がっていたら元の命令コードのサイクル+2のサイクルになる。 クロックサイクルテーブル 7, 2, 6, 2, 6, 2, 6, 2, 2, 2, 2, 2, 2, 2, 2, 2, 6, 5, 6, 5, 6, 5, 6, 5, 6, 5, 6, 5, 6, 5, 6, 5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 2, 8, 8, 8, 8, 8, 8, 8, 8, 6, 6, 6, 5, 8, 8, 8, 8, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 5, 6, 5, 6, 5, 6, 5, 6, 3, 4, 3, 4, 5, 6, 5, 6, 5, 6, 5, 6, 5, 6, 5, 6, 3, 4, 3, 4, 5, 6, 5, 6, 3, 2, 4, 2, 3, 2, 4, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 4, 2, 4, 2, 4, 2, 4, 2, 4, 2, 4, 2, 4, 2, 4, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 7, 2, 7, 2, 7, 2, 7, 2, 5, 2, 4, 2, 7, 2, 7, 4, 4, 4, 4, 3, 4, 5, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 6, 6, 6, 6, 6, 6, 6, 4, 5, 4, 4, 6, 6, 6, 6, 6, 7, 6, 7, 6, 7, 6, 7, 4, 5, 4, 4, 6, 7, 6, 7 PPU (Picture Processing Unit) PPUレジスタ CPUアドレス空間のメモリマップ 0x2000 ~ 0x2007 の8バイトの部分。 ここにPPUの設定が詰まっている。 VRAMの初期化時は必ず0x20001のbit3,4を0にする事 読み込む用 0x2002 - PPU Status Register : - bit0-4: 無効 - bit5: スキャンラインスプライト数(0:8個以下, 1:9個以上) - bit6: スプライトヒット(ヒット時に1) - bit7: VBlank時に1, 読み込みでクリア 0x2007 - PPU Memory Data : PPUのメモリをバッファ経由で読む。 書き込む用 0x2000 - PPU Control Register1 : - bit0-1: 表示するネームテーブルアドレス番号 (0b00=0x2000, 0b01=0x2400, 0b10=0x2800, 0b11=0x2c00) - bit2: PPUアドレスインクリメント (0:+1, 1:+32) - bit3: スプライトパターンアドレス (0:0x0000, 1:0x1000) - bit4: 背景パターンテーブルアドレス (0: 0x0000, 1:0x1000) - bit5: スプライトサイズ(0:88, 1:816) - bit6: PPU Master/slaveモード (0: Master) - bit7: VBlank時にNMIを実行 (0:しない, 1:する) 0x20001 - PPU Control Register2 : - bit0: ディスプレイタイプ (0:カラー, 1:モノクロ) - bit1: 背景クリップ (0:画面の左8ドットを表示しない, 1:クリップなし) - bit2: スプライトクリップ (bit1と同様) - bit3: 背景表示 (0:非表示, 1:表示) - bit4: スプライト表示 (0:非表示, 1:表示) - bit7-5: 000(無し), 001(Green), 010(Blue), 100(Red) 0x2003 - Sprite Memory (OAM) Address :0x2004を経由してOAMへ書き込むときのアドレスを指定 0x2004 - Sprite Memory (OAM) Data :0x2002を経由して得たOAMアドレスへデータを書き込む 0x2005 - Background Scroll Offset : 0x2006 - PPU Memory Address :0x2007経由でPPUへ書き込むアドレスを指定(h,lの順で書き込む) 0x2007 - PPU Memory Data 0x2006経由で受け取ったPPUアドレスへデータを書き込む(書き込むたびにVRAMのpc値をインクリメントする(0x2000のbit2を見て判断しながら)) OAM(Object Attribute Memory)メモリマップ 一つのスプライトに対して4byteが割当てられていてそれが64個格納できる。という事で256byteのメモリがOAMには存在し、スプライトが表現で きる最大数は64という事になる。 0byte : スプライトのY座標 1byte : 何番目のスプライトを表示するか 2byte : ビットフラグ bit7: 上下反転 (0:なし, 1:反転) bit6: 左右反転 (0:なし, 1:反転) bit5: 背景とスプライトどちらを優先するか (0:スプライト優先, 1:背景優先) bit2-4: 予約 (0埋め) bit0-1: カラーパレット上位2ビット 3byte : スプライトのX座標 VRAMメモリマップ 0x0000 ~ 0x0fff : パターンテーブル1 (背景) 0x1000 0x2000 0x23c0 0x2400 0x27c0 ~ ~ ~ ~ ~ 0x2800 0x2bc0 0x2c00 0x2fc0 0x3000 0x3f00 ~ ~ ~ ~ ~ ~ 0x3f20 ~ 0x3f20 ~ 0x1fff : 0x23bf : 0x23ff : 0x27bf : 0x27ff : パターンテーブル2 (スプライト) ネームテーブル1 属性テーブル1 ネームテーブル2 属性テーブル2 0x2bbf : ネームテーブル3 (mirror: 0x2000 ~ 0x23bf) 0x2bff : 属性テーブル3 (mirror: 0x23c0 ~ 0x23ff) 0x2fbf : ネームテーブル4 (mirror: 0x2400 ~ 0x27bf) 0x2fff : 属性テーブル4 (mirror: 0x27c0 ~ 0x2fff) 0x3eff : 0x2000 ~ 0x2effのミラーリング 0x3f0f : 背景パレットテーブル 0x3f1f : スプライトパレットテーブル 0x3fff : 背景+スプライトパレットテーブルのミラーリング(0x3f00~0x3f1f) PPU初期化時の設定 電源オン時 内部VRAM(2048byte), OAM(256byte), パレット(32byte)を全て0で初期化 PPUCTRL(0x2000) = 0x2003 = PPUSCROLL(0x2005) = PPUADDR(0x2006) = 0 電源オン後のリセット時 PPUCTRL(0x2000) = 0x2003 = PPUSCROLL(0x2005) = PPUADDR(0x2006) = 0 Toggle-flag = false パレット (http://hlc6502.web.fc2.com/NesPal2.html にRGBパレットの情報が公開されているので実装する際にはそちらを参考にしてルックアップテーブル を作るとよい。) ファミコンのパレットの色の指定はHueとBrightnessを利用した6bit(64通り)で記述。 エミュ側の実装ではこの形のRGB配列を作っておいてアドレスを通してアクセスさせ(VRAMパレットテーブルにはこれらの色ではなく色の位置が 書き込まれているため)、該当するカラーコードをSDLなどで表示するような形になる。 スプライトとパレットで各12色が利用でき、スプライト と背景の透明色が重なっているときに特別な色が使えるため、合計12*2+1=25色利用できる。 背景/ネームテーブル スプライトと違い、背景には制限ない。 背景パターンテーブル(0x0000 ~ 0x0fff)に敷き詰めたキャラクタ情報(88)256=4KBのキャラクタ番号を0x2000 ~ 0x23bf, 0x24000 ~ 0x27bfのネーム テーブルに敷き詰めて画面を作る。(ネームテーブル1つのサイズが960byteなのはキャラクタが3230=960で構成されているから)。22画面の4画面 で構成されているように見えるがうち2つはミラーで垂直ミラーリングと水平ミラーリングがある。このミラーリングはカートリッジ毎に異な り、iNESヘッダを見て判断するしかない。 背景/属性テーブル 属性テーブルは64byteの領域が確保されている。これは2*2の4つのキャラクタをグループに見立てて0-1の2bitが左上、2-3の2bitが右上、4-5の2bit が左下、6-7の2bitが右下のキャラクタの指定パレットの上位2bitと対応している。 スプライト/パターンテーブル キャラクタを256byte個集めたものをパターンテーブルと読んで、1キャラクタあたり16byte。 16byteで8*8を作るので2bitの情報量。つまり4色の色が使える(このうち一つは透明色で固定)。この2bitの値は上の図のように表され、コードでは ベースアドレスをaddrとすると、 p(x,y) = ((((read(addr + y + 8) >> (7 - x)) & 1) << 1) | ((read(addr + y) >> (7 - x)) & 1)) & 3; と表す事ができる。 以下のコードはパターンテーブルの内容を上の式に基づいて表示するC++のメインのアルゴリズム部分のソースコードになる。 screen_bufferは SDLコントローラに渡される二次元配列。 void PPU::pattern_table_debug() { uint16_t base = 0x0000; uint8_t nespal[4] = { 0x31, 0x21, 0x11, 0x01 }; int w = 0, h = 0; for(int k = 1; k <= (0x2000 / 16); k++) { for(int y = 0; y < 8; y++) { for(int x = 0; x < 8; x++) { uint8_t c = ((((vm.get_chr(base + y + 8) >> (7 - x)) & 1) << 1) | ((vm.get_chr(base + y) >> (7 - x)) & 1)) & 3; screen_buffer[(NES_SCREEN_HEIGHT * (y + h)) + (x + w)] = nespal[c]; } } w += 8; if((k & 0x1f) == 0) h += 7; base += 16; } } 実行結果は次のような形になる。 スプライトDMA スプライトDMA用の領域の先頭アドレスを0x4014(スプライトDMAレジスタ)にstoreする事でスプライトデータを一気にDMA転送する事ができ る。スプライトDMA用の領域はゼロページとスタック領域にかぶらないように指定する必要がある。 0x0000 ~ 0x00ffは6502がゼロページが利用 しており、0x0100 ~ 0x01ff区間も6502がスタックとして利用しているため0x0200 ~ ぐらいからのアドレスで利用するのが良い。 スプライトは4byte=(16bit)で構成されており、ファミコンはOAMを見れば分かる通り64個(4*64=256byte)の領域が確保されているため最大64個の スプライトを配置できる。スプライトDMAではこの256個のスプライトを一気に指定したアドレスへDMA転送する。 ただ0x4014のDMAレジスタ に叩き付ける値はDMA対象開始アドレスの上位8bitになる。 0爆弾と縦スクロール 0番スプライトは特殊なスプライトとして内部で扱われ、0番スプライトに描画を行うと0x20002のビット6が立つような仕様になっている。 そのタイミングでスクロールレジスタ0x2005を変更する事によってラスタスクロールのような技法を利用できる。 CPUクロックとの同期の必要性 NTSC準拠のファミコンのマスタークロックは21.47MHzであり、CPUクロックはそれをプリスケーラが12分周した1.48MHzであり、PPUクロッ クはマスタークロックを3分周した値だ。しかし、エミュレーションを実行するPCは自分自身の環境では2.13GHz@Intel Core2 Duo (4GB DDR3 RAM)とかなりの差が生じている。つまり、そのままタイミングの制御を行わずエミュレーションを全力で実行させるととんでもない速度で処理 が終了し、まともにエミュレータ上でゲーム動作を再現できなくなる。 One scanline is EXACTLY 1364 cycles long. In comparison to the CPU's speed, one scanline is 1364/12 CPU cycles long. (ref: http://web.textfiles.com/games/ppu.txt) そこで上のコードのような実装を行っている。まず、1/60秒待機してから262スキャンライン分を描画->1/60秒待機->スキャンライン描画を繰り返 す。これによってファミコンとエミュレーション環境の処理時間のギャップを補っている。 PPUのレンダリング実装に関して PPUのレンダリングに関しての実装は今まで紹介してきたグラフィックメモリやそのデータ配置などを駆使しながら実装しなければならないた め、実際にコーディングを行う時にはじめてエミュレータを開発するエミュレータ開発者にとっては長い時間を要する事になる。この項目は開発 者向けにその理解を支援するためのものとして書かれている。 全体の描画 最初に各スキャンライン毎に次のスキャンラインで描画すべきスプライトの探索を行った後、背景の描画を行い、その後にスプライトの描画を行 う。 0 ~ 240スキャンライン目まで実際のゲーム画面のレンダリングを行い、それ以降の241~261スキャンラインまでは仮想のスキャンラインと なっている。この間にまずVMに対してVBlank(垂直帰線時間)に突入したという情報を送り、VMからCPUに大してNMI割り込みを掛けさせ、I/O側 にはコントローラの状態を更新させるように処理を行う。 そして、それと共にスクリーンバッファ(裏画面)に対して次のゲーム画面を描画していく。262スキャンラインに到達したらそのスクリーンバッフ ァに241スキャンライン目までに描画しておいた内容を前のゲーム画面と反転させてその繰り返しによってゲームの描画を行う。 スプライトの描画 まず、スプライトを描画するにあたってOAM(Object Attribute Memory)に関する新しいレジスタ群が2つ登場する。それがスプライトテンポラリメ モリ(以下STM)とスプライトバッファメモリ(以下SBM)だ。 0~240スキャンラインの間において、各スキャンライン毎にOAMに格納されている256個のスプライト情報について1個ずつ線形探索を行う。横に 最大8個までスプライトは並べる事ができるためスキャンライン毎に上限8個までの中で該当するものをSTMへ転送する。これでスプライトの探索 は完了する。 この後に背景の描画を行い、その描画が完了した時からスプライトの描画が開始される。 その時にSTMに存在している該当スプライト情報をSBMへ転送する。SBMへ転送された情報が次のスキャンラインにて描画される。 その後にスプライト情報の中にあるキャラクタのインデックスを参照しながらパターンテーブルにアクセスして、指定された座標に横8px分描画 を行う。 背景の描画 まず、スプライト0にヒットしているかを確認する。確認した後はPPUCTRLに記録されているネームテーブルベースアドレスを基にキャラクタ番 号を取得し、パターンテーブルと属性テーブルからパレットインデックス番号を取得。 それを基に横8px(キャラクタサイズ)ごとに更新を行い、PPUSCROLLに代入した座標(0 ~ 7)を引いて描画する。 I/O ここでは、最低限動作させるためにゲームパッドの仕様について書いていく。 CPUアドレス空間の0x4016,0x4017がそれぞれ1P,2Pコントローラに対応している。 この機能を利用して入力を検知するためには、初期化として0x4016/0x4017に対し 1 と 0 をストアする必要がある。これらのストアはファミコン のコントローラのためのリセット機能を働かせるために必要となる。 また、0x4016/0x4017の中で利用するのはbit0のみで、読み込みの時はボタンの入力情報(1:入力有),書き込みの時は入力情報のセット(0:クリア,1:リ セット)となっている。他のビットにはZapperなどの拡張周辺機器向けのビットとなっている。 次に0x4016/0x4017にあるデータを読み込むと各ビットが押されているボタンと対応付けられているので、ビットシフトを行って入力を検知する 事になる。 つまり、0x4016/0x4017のbit0を1->0と相互にセット/クリアを行い入力をチェックしたいボタンになるまでI/Oレジスタを連続で読み込 み、読み込んだ値のbit0が1なら入力ありという事になる。 R1: A R2: B R3: SELECT R4: START R5: UP R6: DOWN R7: LEFT R8: RIGHT この検知はPPUによるレンダリングが行われている242スキャンライン目にI/O側にVBlankに突入した事を伝え、キーの入力を状態として保存す る。その状態は0x4016,0x4017のCPU空間中に存在するI/Oポートをreadする事によって得る事ができる。 カートリッジコントローラ ファミコンにはMMC(Memory Management Controller)というカートリッジメモリコントローラが存在しており、PRG-ROM 32KB, CHR-ROM 8KB に収まらなくなった場合にPRG-ROMのバンクを切り替えて実アドレスと仮想アドレスの対応を作りゲームを動作させているものが存在する。こ のタイプをMapper(マッパー)といい、数多くのマッパーが存在しているが今回はMapper-0(NROM)と呼ばれているPRG-ROM32KB, CHRROM8KBの最小構成のROMのみを対象とする。しかし、Mapper-0のみを実装するに辺りMMCを実装を必ずしも行わなくても良いが統一されたコ ントローラインタフェースを持たせる事にした。 まず、Mapper-0には16KB ROMと32KB ROMが存在しているため、iNESファイルフォーマットパーサがPRG-ROMのサイズを確認した後にマスク する必要がある。(16KB ROM 0x3fff, 32KB ROM 0x7fff) NROM::NROM(VM& nes_vm, INES nes_ines) : vm(nes_vm), ines(nes_ines) { this->mask = ines.prg_page() > 1 ? 0x7fff : 0x3fff; } uint8_t NROM::readPT(uint16_t addr) { return chr_ram ? CHR_RAM[addr & 0x1fff] : ines.readCHR(addr & 0x1fff); } uint8_t NROM::readBank(uint16_t addr) { return ines.readPRG(addr & mask); } uint8_t NROM::readNT(uint16_t addr) { return mirroing[(addr >> 10) & 3][addr & 0x3fff]; } void NROM::writeNT(uint16_t addr, uint8_t v) { mirroring[(addr >> 10) & 3][addr & 0x3fff] = v; } 電源投入時にファミコンはそのROMのミラーリングタイプに合うようにVRAMミラーリングの設定を行う。 enum MirroringType { Vertical, Horizontal }; void NROM::setMirrorType(MirroringType mt) { switch(mt) { case Vertical: mirroring[0] = &vram[0]; mirroring[1] = &vram[0x400]; mirroring[2] = &vram[0]; mirroring[3] = &vram[0x400]; break; case Horizontal: mirroring[0] = &vram[0]; mirroring[1] = &vram[0]; mirroring[2] = &vram[0x400]; mirroring[3] = &vram[0x400]; break; } }
© Copyright 2024 ExpyDoc