Technical Document (NES Emulator)

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;
}
}