この記事は「WACUL Advent Calendar 2017」の18日目です。
WACULでフロントエンドエンジニアをしている@bokuwebと申します。
本記事ではファミコンのエミュレータの実装について解説していきたいと思います。
2018/11/21 追記
重複しますが以前発表した資料も合わせて参照してください。
はじめに
以前ファミコンエミュレータをJSで実装した記事を書きました。
開発過程の雰囲気はこちらを参照していただけると掴めるかと思います。
上記の記事では技術的な内容にはほぼ触れなかったため順に解説していこうと思います。
今回はまずはHello, World!までに焦点をあてて解説してみたいと思います。ファミコン関連の解説は検索すると結構ヒットはするのですが、ファミコン本体の解説が多く、エミュレータを実装するにあたり、どのような手順で進めてくのが、どのような点に気をつけるべきなのかという解説は見当たらなかったため、そのあたりを中心に他の解説とは別の切り口で書ければいいなと思ったのも、この記事の動機になります。
少しでも参考になれば幸いです。
参考サイト
仕様について調べたい場合は以下のサイトを参考にしてみるのがいいと思います。本記事でも以下のサイトを参照しながら解説します。
- NesDev http://nesdev.com/
- 困ったらここを見ればよいです。ただ、情報が多く英語のため、最初に概要を知るには以下の日本語のサイトを参照するのがオススメです
- NES on FPGA http://pgate1.at-ninja.jp/NES_on_FPGA/
- FPGAにエミュレータを実装された方のサイトです。説明も多く分かりやすいので日本語で概要をつかみたい場合は一番おすすめです
- NES研究室 http://hp.vector.co.jp/authors/VA042397/nes/index.html
- NES on FPGAより情報量は少ないですが、図も多く分かりやすいです。今回動作させるHello, World!のサンプルROMもここからダウンロード可能です
- ギコ猫でもわかるファミコンプログラミング http://gikofami.fc2web.com/
- エミュレータではなくファミコンプログラミングですが、非常に有用です。サンプルも多く、Hello, World!以降はここのステップを順に踏んでいくのがおすすめです。
この記事のゴール
本記事ではサンプルROMを動作させてHello, World!を表示させるまでを解説したいと思います。ROMはNES研究室の以下のページより入手が可能です。後述しますが、アセンブラも含まれているのこちらも参照しながら進めるといいかもしれません。
以下が描画されるまでがゴールです。黒い背景にHello, World!を表示させるだけのものですが、そこまでのステップは多いです。これだけ大変なHello, World!はなかなかないと思います。
スペック
以下がファミコンのスペックです。日本での発売は1983年です。
CPUはリコー製の6502カスタムで、APU(オーディオプロセッシングユニット)と呼ばれるユニットやDACなどが実装されています。
- CPU 6502(RP2A03), 8bit 1.79MHz
- PPU ピクチャープロセッサユニット RP2C02
- ROM 最大プログラムROM:32KiB + キャラクタROM:8KiB
- WRAM(ワーキングRAM) 2KiB
- VRAM(ビデオRAM) 2KiB
- 最大発色数 52色
- 画面解像度 256x240ピクセル
- サウンド 矩形波1, 矩形波2, 三角波, ノイズ, DPCMの5チャンネル
- コントローラ 上, 下, 左, 右, A, B, スタート, セレクト
簡易ハードウェアブロック図
かなり簡素化したものですが、おそらく以下のような構成になっているんじゃないかと思います。
①のCPUには1.79MHzのクロックが入力されています。また、8bitバスにPPU、カセット、WRAMが接続されており、コントローラやスピーカもCPUから制御されています。
前述のように、本来MOS6502には音声制御の機構がないのですが、ファミコンに搭載されているCPUはMOS6502にAPU(オーディオプロセッシングユニット)とDACを実装したカスタムモデルですのでこのような構成になっていると思われます。
②のPPU(ピクチャープロセッシングユニット)は描画を司るユニットでCPU以上に重要なチップとなります。PPUはCPUの3倍の周波数、約5.37MHzのクロックが入力されています。また、CPUから独立した自身の8bitバスを持っておりVRAM、カセットに接続されています。
PPUがCPUの3倍の速度で動作すること、カセットに直接バスが接続されていることは重要なポイントです。
③はカセットです。この中には主にプログラムとスプライトデータが格納されています。そのため、CPU、PPUの双方からバスが接続されており、CPUからはプログラムの読み出し、PPUからはスプライトの読み出しが行われます。
④は2KiBのワーキング用のRAMです。このRAMに変数、スタックなどが格納されます。
⑤は2KiBの描画用のVRAMです。このRAMに背景情報、スプライト情報などを格納します。VRAMにはCPUからは直接アクセスすることができませんが、PPUのレジスタを介してアクセス可能となります。
以下順に詳細を解説していきます。
カセット
主にプログラムとスプライト情報が格納されています。また、エミュレータで使用する*.nes
ファイルにはカセットの情報を表現するため16ByteのiNESヘッダー
が付加されています。
iNESヘッダー
詳細は以下に記述があります。
色々書いてありますが、最低限必要なのは以下のByte4,5の値です。Byte4はプログラムROMのページ数でByte5はスプライト情報が格納されているキャラクターROMのページ数です。
以下の記載あるようにプログラムROMの単位は16KiB、キャラクターROMは8Kibです。
これで各ROMのサイズがわかるため、*.nes
ファイルからプログラムROMとキャラクターROMを切り出すことができます。
0-3: Constant $4E $45 $53 $1A ("NES" followed by MS-DOS end-of-file)
4: Size of PRG ROM in 16 KB units
5: Size of CHR ROM in 8 KB units (Value 0 means the board uses CHR RAM)
エミュレータを起動するためにまず、必要なことは*.nes
ファイルを読んで、プログラムROMとキャラクターROMを切り出すことになります。疑似コードですが以下のように*.nes
ファイルをfetch後、プログラムROMとキャラクターROMを切り出しています。
fetch('./sample1.nes')
.then((res) => res.arrayBuffer())
.then((nesFile: ArrayBuffer) => {
const nes = new NES();
nes.load(parse(nesFile));
nes.start();
});
function parse(buf) {
const characterROMPages = buf[5];
const characterROMStart = 0x0010 + buf[4] * 0x4000;
const characterROMEnd = characterROMStart + buf[5] * 0x2000;
return {
programROM: buf.slice(NES_HEADER_SIZE, characterROMStart - 1),
characterROM: buf.slice(characterROMStart, characterROMEnd - 1),
}
}
実際のコードは以下
https://github.com/bokuweb/flownes/blob/master/src/index.js
キャラクターROM
スプライト情報が格納されています。具体的には以下のようなスプライト情報が含まれています。以下はSuper Mario Brosのスプライト情報を画像化したものです。
スプライトは1スプライトにつき16Byte、1ピクセルにつき2bitで表現されます。以下はスプライト番号2のハートのスプライトを画像に変換する例です。
スプライト番号2なのでキャラクターROMの0x0020番地から0x66 0x7F 0xFF 0xFF 0xFF 07E ...
と16Byte分データが格納されており、以下のように2値のスプライトが2つ取り出せます。この2つを加算することで1枚のスプライトが取り出せます。
この2bitはパレット内の色番号を表しており、割り当てられたパレットの4色対に応する色をマッピングすることになります。パレットはPPUが管理していますので、これについては後述します。
また、設定によってはスプライトのサイズが8*16になりますが、今回は省略します。自分もまだ未実装です。
仕様を把握するため*.nes
からスプライトデータをpngに書き出すのみのシンプルなツールを書いたので、参考にしてください。
カセットの内容は以上です。カセットによってはバッテリバックアップされたRAMを持っていたりROMをがんばって拡張していたりするんですが、今回は省略します。
CPU
レジスタ
まずはレジスタ一覧です。プログラムカウンタ(以下PC)以外は8bitです。スタックポインタも16ビットのアドレス空間を指す必要があるのですが、上位8bitは0x01に固定されています。スタックは256バイトが使用可能で、WRAMのうち0x0100~0x01FFが割り当てられます。すなわち、スタックポインタレジスタが0xA0の場合、スタックポインタは0x01A0になります。
演算はAレジスタで行われ、X,Yレジスタはインデックスに使用されます。
ステータスレジスタはCPU状態を示すフラグが詰まっており、次項で記載します。
名称 | サイズ | 詳細 |
---|---|---|
A | 8bit | アキュムレータ |
X | 8bit | インデックスレジスタ |
Y | 8bit | インデックスレジスタ |
S | 8bit | スタックポインタ |
P | 8bit | ステータスレジスタ |
PC | 16bit | プログラムカウンタ |
ステータス・レジスタ
ステータスレジスタの詳細です。bit5は常に1で、bit3はNESでは未実装です。
IRQは割り込み、BRKはソフトウエア割り込みです。
bit | 名称 | 詳細 | 内容 |
---|---|---|---|
bit7 | N | ネガティブ | 演算結果のbit7が1の時にセット |
bit6 | V | オーバーフロー | P演算結果がオーバーフローを起こした時にセット |
bit5 | R | 予約済み | 常にセットされている |
bit4 | B | ブレークモード | BRK発生時にセット、IRQ発生時にクリア |
bit3 | D | デシマルモード | 0:デフォルト、1:BCDモード (未実装) |
bit2 | I | IRQ禁止 | 0:IRQ許可、1:IRQ禁止 |
bit1 | Z | ゼロ | 演算結果が0の時にセット |
bit0 | C | キャリー | キャリー発生時にセット |
レジスタは以下のように内部的に持っていて、命令の実行やフェッチなどに伴い書き換えています。
this.registers = {
A: 0x00,
X: 0x00,
Y: 0x00,
P: {
negative: false,
overflow: false,
reserved: true,
break: true,
decimal: false,
interrupt: true,
zero: false,
carry: false,
},
SP: 0x01FD,
PC: 0x0000,
};
メモリマップ
CPUのメモリマップです。拡張ROM/RAMやミラーと書いてある箇所はひとまず無視してもいいかと思います。プログラムROMが0x8000から配置されていること、WRAMが0x0000~0x07FFにマッピングされていること、PPUのレジスタが0x2000~にマッピングされていることは重要な情報です。
アドレス | サイズ | 用途 |
---|---|---|
0x0000~0x07FF | 0x0800 | WRAM |
0x0800~0x1FFF | - | WRAMのミラー |
0x2000~0x2007 | 0x0008 | PPU レジスタ |
0x2008~0x3FFF | - | PPUレジスタのミラー |
0x4000~0x401F | 0x0020 | APU I/O、PAD |
0x4020~0x5FFF | 0x1FE0 | 拡張ROM |
0x6000~0x7FFF | 0x2000 | 拡張RAM |
0x8000~0xBFFF | 0x4000 | PRG-ROM |
0xC000~0xFFFF | 0x4000 | PRG-ROM |
割り込みベクタ
以下に割り込みベクタを記載します。割り込みベクタとは割り込みハンドラーのアドレスが格納された場所のことです。
たとえば、リセット(ファミコンの四角のボタンですね)を押した場合、プログラムの先頭から再開する必要があります。リセットも割り込みの一種ですので、リセットがかかった場合、CPUはまず0xFFFC、0xFFFD番地をリードしにいき、そこから組み立てたアドレスをPCにセットし、その番地から実行します。
たとえば、リセット後はプログラムROM領域の先頭、すなわち0x8000番地から開始するケースが多いと思うのですが、その場合、0xFFFCから0x00が0xFFFDから0x80がリードされ、PCに0x8000がセットされ、ROMの先頭である0x8000からプログラムが開始することになります。
割り込み | 下位バイト | 上位バイト |
---|---|---|
NMI | 0xFFFA | 0xFFFB |
RESET | 0xFFFC | 0xFFFD |
IRQ、BRK | 0xFFFE | 0xFFFF |
-
NMI
- ノンマスカブル割り込みといってCPU側でマスクできない割り込みです。PPUの割り込み出力信号が接続されていますが、今回のサンプルでは不要なので無視します -
RESET
- リセットボタンが押されたときや電源投入時、(たぶん)電源降下時などにかかる割り込みです。 -
BRK
- ソフトウェア割り込みです。BRK命令を実行したときに発生します。今回のサンプルでは不要なので無視します。 -
IRQ
- APUなどに接続されています。今回のサンプルでは不要なので無視します。
細かい挙動については以下のURLが参考になると思います。
エミュレータとしてはひとまず、起動時/リセット時に0xFFFC/0xFFFDから開始アドレスをリードしてプログラムカウンタPCにセットしてやる必要があります。擬似コードですが、以下のようなイメージです。リセット時にレジスタの初期化とPCのセットを行っています。
class Cpu {
... 略 ...
reset() {
// レジスタの初期化
this.registers = {
...defaultRegisters,
P: { ...defaultRegisters.P }
};
this.registers.PC = this.readWord(0xFFFC);
}
... 略 ...
}
実際のコードはこのへん
命令セット
6502のオペコードは8bitで命令の種類とアドレッシングモードが表現されています。命令の種類はADC
、SUB
のような種類で、アドレッシングモードによって演算の対象が決定されます。
以下がオペコード表です。上段に命令、下段にアドレッシング・モードが記載してあります。例えばzpg
はZero page Addressing
を表します。
一例をあげると、オペコードが0xA5
の場合LDA
というAレジスタに値をロードする命令の種類で、アドレッシングモードがZero page Addressing
となります。「Aレジスタに値はロードするのはわかったけど、どの値をロードするのよ」ってのがアドレッシングモードにより決定されます。
Zero page Addressing
の場合、まずプログラムカウンタの値をフェッチしその値を下位バイト、上位バイトを0x00とした番地からリードを行います。そこで得られた値がAレジスタにロードされます。
0x00 | 0x01 | 0x02 | 0x03 | 0x04 | 0x05 | 0x06 | 0x07 | 0x08 | 0x09 | 0x0A | 0x0B | 0x0C | 0x0D | 0x0E | 0x0F | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0x00 | BRK impl | ORA X,ind | * | * | * | ORA zpg | ASL zpg | * | PHP impl | ORA # | ASL A | * | * | ORA abs | ASL abs | * |
0x10 | BPL rel | ORA ind,Y | * | * | * | ORA zpg,X | ASL zpg,X | * | CLC impl | ORA abs,Y | * | * | * | ORA abs,X | ASL abs,X | * |
0x20 | JSR abs | AND X,ind | * | * | BIT zpg | AND zpg | ROL zpg | * | PLP impl | AND # | ROL A | * | BIT abs | AND abs | ROL abs | * |
0x30 | BMI rel | AND ind,Y | * | * | * | AND zpg,X | ROL zpg,X | * | SEC impl | AND abs,Y | * | * | * | AND abs,X | ROL abs,X | * |
0x40 | RTI impl | EOR X,ind | * | * | * | EOR zpg | LSR zpg | * | PHA impl | EOR # | LSR A | * | JMP abs | EOR abs | LSR abs | * |
0x50 | BVC rel | EOR ind,Y | * | * | * | EOR zpg,X | LSR zpg,X | * | CLI impl | EOR abs,Y | * | * | * | EOR abs,X | LSR abs,X | * |
0x60 | RTS impl | ADC X,ind | * | * | * | ADC zpg | ROR zpg | * | PLA impl | ADC # | ROR A | * | JMP ind | ADC abs | ROR abs | * |
0x70 | BVS rel | ADC ind,Y | * | * | * | ADC zpg,X | ROR zpg,X | * | SEI impl | ADC abs,Y | * | * | * | ADC abs,X | ROR abs,X | * |
0x80 | * | STA X,ind | * | * | STY zpg | STA zpg | STX zpg | * | DEY impl | * | TXA impl | * | STY abs | STA abs | STX abs | * |
0x90 | BCC rel | STA ind,Y | * | * | STY zpg,X | STA zpg,X | STX zpg,Y | * | TYA impl | STA abs,Y | TXS impl | * | * | STA abs,X | * | * |
0xA0 | LDY # | LDA X,ind | LDX # | * | LDY zpg | LDA zpg | LDX zpg | * | TAY impl | LDA # | TAX impl | * | LDY abs | LDA abs | LDX abs | * |
0xB0 | BCS rel | LDA ind,Y | * | * | LDY zpg,X | LDA zpg,X | LDX zpg,Y | * | CLV impl | LDA abs,Y | TSX impl | * | LDY abs,X | LDA abs,X | LDX abs,Y | * |
0xC0 | CPY # | CMP X,ind | * | * | CPY zpg | CMP zpg | DEC zpg | * | INY impl | CMP # | DEX impl | * | CPY abs | CMP abs | DEC abs | * |
0xD0 | BNE rel | CMP ind,Y | * | * | * | CMP zpg,X | DEC zpg,X | * | CLD impl | CMP abs,Y | * | * | * | CMP abs,X | DEC abs,X | * |
0xE0 | CPX # | SBC X,ind | * | * | CPX zpg | SBC zpg | INC zpg | * | INX impl | SBC # | NOP impl | * | CPX abs | SBC abs | INC abs | * |
0xF0 | BEQ rel | SBC ind,Y | * | * | * | SBC zpg,X | INC zpg,X | * | SED impl | SBC abs,Y | * | * | * | SBC abs,X | INC abs,X | * |
各命令の挙動は以下を参照してください。
*
となっている箇所は未定義の箇所です。実は隠し命令が定義されている箇所もあるのですが、今回は省略します。具体的には以下のものが隠し命令です。
命令のサイクル数
実機はさまざまな条件でサイクル数が変動する命令がありますが、エミュレータレベルではひとまず気にしなくて良さそうです。自分のコードにはそのあたりもちゃんと考慮しようと思いつつも途中で妥協した痕跡があります。サイクル数の微妙な違いにより実機と挙動がことなる可能性は当然発生し得ますがほとんどのケースでは考慮する必要がなさそうです。
const cycles = [
/*0x00*/ 7, 6, 2, 8, 3, 3, 5, 5, 3, 2, 2, 2, 4, 4, 6, 6,
/*0x10*/ 2, 5, 2, 8, 4, 4, 6, 6, 2, 4, 2, 7, 4, 4, 6, 7,
/*0x20*/ 6, 6, 2, 8, 3, 3, 5, 5, 4, 2, 2, 2, 4, 4, 6, 6,
/*0x30*/ 2, 5, 2, 8, 4, 4, 6, 6, 2, 4, 2, 7, 4, 4, 6, 7,
/*0x40*/ 6, 6, 2, 8, 3, 3, 5, 5, 3, 2, 2, 2, 3, 4, 6, 6,
/*0x50*/ 2, 5, 2, 8, 4, 4, 6, 6, 2, 4, 2, 7, 4, 4, 6, 7,
/*0x60*/ 6, 6, 2, 8, 3, 3, 5, 5, 4, 2, 2, 2, 5, 4, 6, 6,
/*0x70*/ 2, 5, 2, 8, 4, 4, 6, 6, 2, 4, 2, 7, 4, 4, 6, 7,
/*0x80*/ 2, 6, 2, 6, 3, 3, 3, 3, 2, 2, 2, 2, 4, 4, 4, 4,
/*0x90*/ 2, 6, 2, 6, 4, 4, 4, 4, 2, 4, 2, 5, 5, 4, 5, 5,
/*0xA0*/ 2, 6, 2, 6, 3, 3, 3, 3, 2, 2, 2, 2, 4, 4, 4, 4,
/*0xB0*/ 2, 5, 2, 5, 4, 4, 4, 4, 2, 4, 2, 4, 4, 4, 4, 4,
/*0xC0*/ 2, 6, 2, 8, 3, 3, 5, 5, 2, 2, 2, 2, 4, 4, 6, 6,
/*0xD0*/ 2, 5, 2, 8, 4, 4, 6, 6, 2, 4, 2, 7, 4, 4, 7, 7,
/*0xE0*/ 2, 6, 3, 8, 3, 3, 5, 5, 2, 2, 2, 2, 4, 4, 6, 6,
/*0xF0*/ 2, 5, 2, 8, 4, 4, 6, 6, 2, 4, 2, 7, 4, 4, 7, 7,
];
自分の場合は以下で命令とサイクル数をセットにした辞書を作製しています。
アドレッシングモード
アドレッシング・モードの種類と概要を以下に記載します。
略称 | 名前 | 概要 |
---|---|---|
impl | Implied | レジスタを操作するため、アドレス操作無し |
A | Accumulator | Aレジスタを操作するため、アドレス操作無し |
# | Immediate | オペコードが格納されていた次の番地に格納されている値をデータとして扱う |
zpg | Zero page | 0x00を上位アドレス、PC(オペコードの次の番地)に格納された値を下位アドレスとした番地を演算対象とする |
zpg,Xまたはzpg,Y | Zero page indexed | 0x00を上位アドレス、PC(オペコードの次の番地)に格納された値にXレジスタまたはYレジスタを加算した値を下位アドレスとした番地を演算対象とする |
abs | Absolute | PC(オペコードの次の番地)に格納された値を下位アドレス、PC(オペコードの次の次の番地)に格納された値を上位アドレスとした番地を演算対象とする |
abs,Xまたはabs,Y | Absolute indexed | Absolute Addressingで得られる値にXレジスタまたはYレジスタを加算した番地を演算対象とする |
rel | Relative | PC(オペコードの次の番地)に格納された値とその次の番地を加算した番地を演算対象とする |
X,Ind | Indexed Indirect | 0x00を上位アドレス、PC(オペコードの次の番地)に格納された値を下位アドレスとした番地にレジスタXの値を加算、その番地の値を下位アドレス、その次の番地の値を上位アドレスとした番地を演算対象とする |
Ind,Y | Indirect indexed | 0x00を上位アドレス、PC(オペコードの次の番地)に格納された値を下位アドレスとした番地の値を下位アドレス、その次の番地の値を上位アドレスとした番地にレジスタYを加算した番地を演算対象とする |
Ind | Absolute Indirect | Absolute Addressingで得られる番地に格納されている値を下位アドレス、その次の番地に格納されている値を上位アドレスとした番地を演算対象とする |
はっきり言って上記のように書いても全くわからないので、頻出のImmediateとZero pageについて具体例を書いてみます。また以下のリンクと具体的なコードも合わせて参照した方が分かりやすいかもしれません。
Immediate
LDA #
というアドレッシング・モードがImmediate
な命令LDA
が0x8000に格納されているものと仮定します。CPUは0x8000から命令をフェッチしてPC(プログラムカウンタ)をインクリメントします。
フェッチ結果が0xA9であるため、LDA
命令でアドレッシングモードがImmediateであることがわかります。するとCPUはPC(現時点では0x8001)から0xA5をフェッチし、その値をLDA命令の実行対象とします。
具体的にはLDAはAレジスタに何らかの値をロードする命令ですのでこの場合Aレジスタに0xA5が格納されることになります。PCはインクリメントされ、0x8002の次の命令を指します。
Zero Page
LDA zpg
という命令が0x8000に格納されているものと仮定します。CPUは0x8000から命令をフェッチしてPC(プログラムカウンタ)をインクリメントします。
フェッチ結果が0xA5であるため、LDA
命令でアドレッシングモードがZero Pageであることがわかります。次にCPUはPC(現時点では0x8001)から0x25をフェッチし、その値を下位アドレス、0x00を上位アドレス(すなわち0x0025番地)としたアドレスのデータを実行対象とします。
この場合Aレジスタに0xDEが格納されることになります(0x0025番地からのリードは発生しますがその際にPCはインクリメントされないことに注意してください)。PCはインクリメントされ、0x8002の次の命令を指します。
他にもいくつものアドレッシングモードがありますが、基本的には上記の応用です。フェッチしてくるバイト数を8から16bitにしてみたり、フェッチして来た値にXレジスタを加算したり、Yレジスタを加算したりして、実行対象とする番地を算出しているだけです。
実装イメージ
ここまでの説明でCPUは基本的には以下の手順を繰り返せばいいことが分かります。
- PC(プログラムカウンタ)からオペコードをフェッチ(PCをインクリメント)
- 命令とアドレッシング・モードを判別
- (必要であれば)オペランドをフェッチ(PCをインクリメント)
- (必要であれば)演算対象となるアドレスを算出
- 命令を実行
- 1に戻る
class Cpu {
/* ...略... */
read(addr) {
return this.bus.read(addr);
}
fetch() {
return this.read(this.register.PC++);
}
// アドレッシング・モードに従い演算対象を算出する
fetchOpeland(addressing) {
switch (addressing) {
case 'accumulator': return;
case 'implied': return;
case 'immediate': return this.fetch(),
case 'zeroPage': return this.fetch(),
case 'zeroPageX': {
const addr = this.fetch();
return (addr + this.registers.X) & 0xFF,
}
case 'zeroPageY': {
const addr = this.fetch();
return (addr + this.registers.Y & 0xFF);
}
case 'absolute': return this.fetchWord();
case 'absoluteX': {
const addr = this.fetchWord();
return (addr + this.registers.X) & 0xFFFF;
}
case 'absoluteY': {
const addr = this.fetchWord();
return (addr + this.registers.Y) & 0xFFFF;
}
case 'preIndexedIndirect': {
const baseAddr = (this.fetch() + this.registers.X) & 0xFF;
const addr = this.read(baseAddr) + (this.read((baseAddr + 1) & 0xFF) << 8);
return addr & 0xFFFF;
}
case 'postIndexedIndirect': {
const addrOrData = this.fetch();
const baseAddr = this.read(addrOrData) + (this.read((addrOrData + 1) & 0xFF) << 8);
const addr = baseAddr + this.registers.Y;
return addr & 0xFFFF;
}
case 'indirectAbsolute': {
const addrOrData = this.fetchWord();
const addr = this.read(addrOrData) + (this.read((addrOrData & 0xFF00) | (((addrOrData & 0xFF) + 1) & 0xFF)) << 8);
return addr & 0xFFFF;
}
}
}
// 命令と演算対象を受け取り演算を実行する
exec(baseName, opeland, mode) {
switch (baseName) {
case 'LDA': {
this.registers.A = mode === 'immediate' ? opeland : this.read(opeland);
this.registers.P.negative = !!(this.registers.A & 0x80);
this.registers.P.zero = !this.registers.A;
break;
}
case 'STA': {
this.write(opeland, this.registers.A);
break;
}
case /* ...略... */
/* 残りの命令を実装 */
}
}
/* ...略... */
// CPUの実行
// 後述する実行タイミング調整のために実行にかかったサイクル数を返す
run() {
const opecode = this.fetch();
const { baseName, mode, cycle } = this.opecodeList[opecode];
const opeland = this.fetchOpeland(mode);
this.exec(baseName, opeland, mode);
return cycle;
}
}
また、実際にはCPUからリード・ライトが発生した際にはメモリマップに従った領域にアクセスする必要があるので別のBusをインジェクトして、処理させています。
class Cpu {
constructor(bus) {
this.bus = bus;
this.register = {/* ...略... */ }
}
read(addr) {
return this.bus.read(addr);
}
write(addr, data) {
this.bus.write(addr, data);
}
/* ...略... */
}
自分のケースではBusは以下のようにメモリマップに従ってアクセスを振るようなモジュールにしています。
import Rom from '../rom';
import Ram from '../ram';
import Ppu from '../ppu';
import Keypad from '../keypad';
import Dma from '../dma';
import Apu from '../apu';
class CpuBus {
constructor(ram, programROM, ppu, keypad, dma, apu) {
this.ram = ram;
this.programROM = programROM;
this.ppu = ppu;
this.apu = apu;
this.keypad = keypad;
this.dma = dma;
}
readByCpu(addr: Word): Byte {
if (addr < 0x0800) {
return this.ram.read(addr);
} else if (addr < 0x2000) {
// mirror
return this.ram.read(addr - 0x0800);
} else if (addr < 0x4000) {
// mirror
const data = this.ppu.read((addr - 0x2000) % 8);
return data;
} else if (addr === 0x4016) {
// TODO Add 2P
return +this.keypad.read();
} else if (addr >= 0xC000) {
// Mirror, if prom block number equals 1
if (this.programROM.size <= 0x4000) {
return this.programROM.read(addr - 0xC000);
}
return this.programROM.read(addr - 0x8000);
} else if (addr >= 0x8000) {
// ROM
return this.programROM.read(addr - 0x8000);
} else {
return 0;
}
}
writeByCpu(addr, data) {
// log.debug(`cpu:write addr = ${addr}`, data);
if (addr < 0x0800) {
// RAM
this.ram.write(addr, data);
} else if (addr < 0x2000) {
// mirror
this.ram.write(addr - 0x0800, data);
} else if (addr < 0x2008) {
// PPU
this.ppu.write(addr - 0x2000, data);
} else if (addr >= 0x4000 && addr < 0x4020) {
if (addr === 0x4014) {
this.dma.write(data);
} else if (addr === 0x4016) {
// TODO Add 2P
this.keypad.write(data);
} else {
// APU
this.apu.write(addr - 0x4000, data);
}
}
}
}
以上でCPUの解説は終了です。ここまで正しく実装できていれば今回のゴールであるHello, World!のサンプルROMのプログラムROMの内容をCPUに食わせれば順に命令を実行していくと思います。(ただし最後は無限ループになります。)
PPU
レジスタ一覧
PPUのレジスタはCPUから見て0x2000~0x2007番地に配置されています。
以下がその一覧です。
0x2002はリードオンリー、0x2004、0x2007がリードライト可能で、その他はライトオンリーなレジスタとなっています。今回最低限実装しなければならないのは0x2006のPPUADDRと0x2007のPPUDATAですのでこれらについては後述します。
アドレス | 略称 | RW | 名称 | 内容 |
---|---|---|---|---|
0x2000 | PPUCTRL | W | コントロールレジスタ1 | 割り込みなどPPUの設定 |
0x2001 | PPUMASK | W | コントロールレジスタ2 | 背景イネーブルなどのPPU設定 |
0x2002 | PPUSTATUS | R | PPUステータス | PPUのステータス |
0x2003 | OAMADDR | W | スプライトメモリデータ | 書き込むスプライト領域のアドレス |
0x2004 | OAMDATA | RW | デシマルモード | スプライト領域のデータ |
0x2005 | PPUSCROLL | W | 背景スクロールオフセット | 背景スクロール値 |
0x2006 | PPUADDR | W | PPUメモリアドレス | 書き込むPPUメモリ領域のアドレス |
0x2007 | PPUDATA | RW | PPUメモリデータ | PPUメモリ領域のデータ |
メモリマップ
PPUのメモリマップです。レジスタが0x2000~0x2007に配置されていると記載しましたが、これはCPUのメモリマップ上0x2000~配置されているのであり、PPUから見た場合0x2000~はVRAM領域であることに注意してください。
アドレス | サイズ | 用途 |
---|---|---|
0x0000~0x0FFF | 0x1000 | パターンテーブル0 |
0x1000~0x1FFF | 0x1000 | パターンテーブル1 |
0x2000~0x23BF | 0x03C0 | ネームテーブル0 |
0x23C0~0x23FF | 0x0040 | 属性テーブル0 |
0x2400~0x27BF | 0x03C0 | ネームテーブル1 |
0x27C0~0x27FF | 0x0040 | 属性テーブル1 |
0x2800~0x2BBF | 0x03C0 | ネームテーブル2 |
0x2BC0~0x2BFF | 0x0040 | 属性テーブル2 |
0x2C00~0x2FBF | 0x03C0 | ネームテーブル3 |
0x2FC0~0x2FFF | 0x0040 | 属性テーブル3 |
0x3000~0x3EFF | - | 0x2000-0x2EFFのミラー |
0x3F00~0x3F0F | 0x0010 | バックグラウンドパレット |
0x3F10~0x3F1F | 0x0010 | スプライトパレット |
0x3F20~0x3FFF | - | 0x3F00-0x3F1Fのミラー |
このメモリマップだけを見ると謎のテーブルがたくさん出てきて、わけがわかりませんが、1つずつ記載していきます。
ネームテーブル
0x2000~0x03FFFはVRAM領域(一部異なる)でその中にネームテーブルが含まれています。ネームテーブルは画面に対してどのように背景タイルを敷き詰めるかを決めるテーブルです。
画面は256240ピクセルですので88ピクセルのタイルが32*30(すなわち960枚)で敷き詰められることになります。ネームテーブルのサイズが0x3C0=960
なのはそのためです。
以下が今回目標とするHello, World!
サンプル出力ですが、0x1C9
の箇所にH
が表示されています。すなわち、0x21C9
番地にH
を表すスプライト番号を書き込めばこの位置にH
が表示されるということです。
以下のキャラクターROMのダンプを見るとH
は73番目に配置されています(文字コードに合わせてあるので当然なんですが)。つまり上記の位置にH
を表示させたければ0x21C9
番地に72
を格納すればいいということになります。
属性テーブル
属性テーブルという名前が紛らわしいですが、このテーブルは背景にどのパレットを適用するかを決定します。注意点としてはパレットは1616ピクセル、すなわち(22タイル)に1パレット(4色)適用されるという点です。背景用パレットは4つ持つことができ、その中から1パレット選択するので1ブロックにつき2bitの情報を持つことになります。1画面256 * 240なので16 * 15ブロック = 240ブロック、1ブロックにつき2bitなので60バイトの領域が必要となります。
仮に0x23C0
が0xE4
であれば1110 0100b
ですので以下のようにブロックに適用されるパレットが決定します。(以下は1マス8*8ピクセルです)
あと、なぜネームテーブルと属性テーブルのペアが4セットあるのかと言うと以下のような配置で4画面分の領域を確保しておくことで背景の縦・横スクロールが可能となります。
言葉では表現しにくいのですが、以下のNesDevのgifを見るのが一番イメージが掴みやすいかもしれません。
ネームテーブル1と2の2画面分の領域に背景を用意しておき背景のx座標スクロール値1を変更することで背景をスクロールさせています。
今回の例ではスクロールは不要ですので、0x2000~の領域に1画面分書き込まれるという認識があればひとまず問題ないです。スクロールについてはまた別の機会に書きます。
また、ネームテーブル、属性テーブルについては以下の記事がかなり分かりやすかったです。この方式によってどれほどのメモリが節約できているかの考察もあって良いです。
パレット
0x3F00
~ 0x3F0F
はバックグラウンドパレット、0x3F10
~ 0x3F1F
はスプライトパレットです。それぞれ0x10のサイズで4色のパレットを4枚もつことができます。
ただし、0x3F10
,0x3F14
,0x3F18
,0x3F1C
は0x3F00
,0x3F04
,0x3F08
,0x3F0C
のミラーとなっていることに注意です。これはエミュレータを実装するにあたりよくはまる箇所です。このミラーをちゃんと実装しないとSuper Mario Bros.の空が黒くなります。みんな嵌まるのか、NesDevにも以下の記載があります。もちろん自分も黒い空を拝みました。
Addresses $3F10/$3F14/$3F18/$3F1C are mirrors of $3F00/$3F04/$3F08/$3F0C.
Note that this goes for writing as well as reading.
A symptom of not having implemented this correctly in an emulator is the sky being black in Super Mario Bros., which writes the backdrop color through $3F10.
また、0x3F04
,0x3F08
,0x3F0C
はユニークな値を持つんですが、PPUには使用されず、0x3F00
の値が適用されます。また0x3F10
,0x3F14
,0x3F18
,0x3F1C
は背景色として取り扱います。なので実際に使用できる色はバックグラウンドは13色、スプライトは12色ということになります。
この領域に書き込まれた値は以下のような色と紐付けられています。
const colors = [
[0x80, 0x80, 0x80], [0x00, 0x3D, 0xA6], [0x00, 0x12, 0xB0], [0x44, 0x00, 0x96],
[0xA1, 0x00, 0x5E], [0xC7, 0x00, 0x28], [0xBA, 0x06, 0x00], [0x8C, 0x17, 0x00],
[0x5C, 0x2F, 0x00], [0x10, 0x45, 0x00], [0x05, 0x4A, 0x00], [0x00, 0x47, 0x2E],
[0x00, 0x41, 0x66], [0x00, 0x00, 0x00], [0x05, 0x05, 0x05], [0x05, 0x05, 0x05],
[0xC7, 0xC7, 0xC7], [0x00, 0x77, 0xFF], [0x21, 0x55, 0xFF], [0x82, 0x37, 0xFA],
[0xEB, 0x2F, 0xB5], [0xFF, 0x29, 0x50], [0xFF, 0x22, 0x00], [0xD6, 0x32, 0x00],
[0xC4, 0x62, 0x00], [0x35, 0x80, 0x00], [0x05, 0x8F, 0x00], [0x00, 0x8A, 0x55],
[0x00, 0x99, 0xCC], [0x21, 0x21, 0x21], [0x09, 0x09, 0x09], [0x09, 0x09, 0x09],
[0xFF, 0xFF, 0xFF], [0x0F, 0xD7, 0xFF], [0x69, 0xA2, 0xFF], [0xD4, 0x80, 0xFF],
[0xFF, 0x45, 0xF3], [0xFF, 0x61, 0x8B], [0xFF, 0x88, 0x33], [0xFF, 0x9C, 0x12],
[0xFA, 0xBC, 0x20], [0x9F, 0xE3, 0x0E], [0x2B, 0xF0, 0x35], [0x0C, 0xF0, 0xA4],
[0x05, 0xFB, 0xFF], [0x5E, 0x5E, 0x5E], [0x0D, 0x0D, 0x0D], [0x0D, 0x0D, 0x0D],
[0xFF, 0xFF, 0xFF], [0xA6, 0xFC, 0xFF], [0xB3, 0xEC, 0xFF], [0xDA, 0xAB, 0xEB],
[0xFF, 0xA8, 0xF9], [0xFF, 0xAB, 0xB3], [0xFF, 0xD2, 0xB0], [0xFF, 0xEF, 0xA6],
[0xFF, 0xF7, 0x9C], [0xD7, 0xE8, 0x95], [0xA6, 0xED, 0xAF], [0xA2, 0xF2, 0xDA],
[0x99, 0xFF, 0xFC], [0xDD, 0xDD, 0xDD], [0x11, 0x11, 0x11], [0x11, 0x11, 0x11],
];
0x3F00
,0x3F01
,0x3F02
,0x3F03
,にそれぞれ0x00
,0x01
,0x02
, 0x03
を書き込んだ場合パレット0は0x808080, 0x003DA6, 0x0012B0, 0x440096
の4色のパレットになることを意味します。
PPUのバージョンなどで色が微妙に異なったりするようで必ずしも以下のような色になるわけではないようですが、だいたい以下のような色が使用できるようです。2
パターンテーブル
0x0000~0x01FFFはパターンテーブルとありますが、これはカセットのキャラクターROM(またはRAM)へのアクセスとなります。パターンテーブルを2面持っているのは、0x0000~を背景用と0x1000~をスプライト用の領域となるようにPPU内のレジスタで設定することができるんですが、今回は背景のみかつ、設定はデフォルトのままなので0x0000~から背景用のスプライトが格納されているという認識があれば問題ないです。
PPUADDR/PPUDATAレジスタ
ここまでで画面に何かを表示するには、0x2000~0x2400のネームテーブルと属性テーブル、0x3F00~0x3F1Fのパレットテーブルに設定すればいいことがわかりました。
これらの領域はPPUのバスに接続されたVRAM領域であり、CPUから直接アクセスすることができません。それを解決するのがPPUADDR
/PPUDATA
レジスタです。
まずCPUからPPUADDR(0x2006)
に2回書き込みを行います。例えば0x3F
,0x00
のライトを行うことでアクセスするPPUメモリ空間のアドレスが0x3F00
となります。その後PPUADDR(0x2006)
にリード、もしくは、ライトを行うことで0x3F00
(この場合バックグラウンドパレットテーブルですね。)に対して読み書きができるようになります。
PPUADDR(0x2006)
に一回書き込みを行うと自動的に0x01または0x20インクリメントされます。この値は0x2000のレジスタで設定できますが、今回はデフォルトの0x01固定で問題ないです。
リードしただけなのに、レジスタの状態が変わるというのはなんとも気持ち悪いですが、こういったハードウェアの作りはよくあります。
また、PPUDATA
レジスタ経由でリードする場合、PPU内部にバッファを持っているため、1リードサイクル前のデータがリードされることに注意が必要です。なので初回のリードデータは読み捨てないといけないと思います。そして更にわかりづらいのが、パレットテーブルの領域だけは適用外という点です。
パレットテーブルの値は即時リードされ、代わりにネームテーブルのミラーがバッファリングされるようです。つまり、パレットテーブルは実際はVRAMではなく、PPU内部に配置されているということでしょうか。
ちなみにですが、一段バッファが入っているのは、CPUのバスとPPUのバスという異なるクロックのバスの同期をとるためじゃなかと思います。異なるクロックのバス間でデータをやり取りするのにFIFOやDualPort RAMを介するのはよくある方法だと思います。
PPUの動作
PPUは1サイクルで1ドット描画します。前述しましたが、PPUはCPUクロックの3倍のクロックが入力されているため、PPUの3サイクルがCPUの1サイクルであることに注意してください。
PPUは341クロックで1ライン描画します。描画領域は256ピクセルですが、(おそらく)Hblankといって、次のラインを描画するための準備を行ったり、1ラインの描画を終えたことを同期したりする期間です。
これを描画領域である240ライン分繰り返すわけですが、その後もHblank
と同様20ライン分Vblank
という期間が設けられています。正確には、Vblank
の前後に post-render/pre-render scanline
というアイドル状態が存在するため、262ライン分の描画期間が必要となります。262ラインの次にはまた先頭のラインから描画を始めます。
基本的に、VRAMを変更するのはVblank
の間にすることになっています。これは描画中にVRAMの内容を変更してしまうと画面が崩れてしまうからです。ただ、今回サンプルは最初に書き込んだきり変更しないため、この制約は無視しています。
実装イメージ
ここまでの内容をつなげると実装すべきPPUの流れが見えてくるかと思います。ざっくりとですが、自分の場合は以下のようなイメージで実装しています。
- 実行サイクル数を受け取ってサイクル数を加算
- 341クロック以上であれば1ライン加算
- 8ラインごとに背景スライトとパレットのデータを格納
- x, y座標からネームテーブル、属性テーブルの該当アドレスを算出する
- ネームテーブルに格納されたスプライト番号でキャラクターROMからスプライト情報をリードする
- 属性に格納されたパレット番号で背景パレットテーブルからパレット情報をリードする
- 2, 3のデータをセットにして格納
- 1~4を1ライン分繰り返す
- 1~3を1画面分繰り返す
- 1画面分のデータを
ImageData
に変換しcanvas
に描画
以下がPPUの主要な部分を簡素化した擬似コードです。ほかにもレジスタへのアクセスなど諸々ありますが、背景のデータを作成するところに着目しています。
class Ppu {
... 略 ...
run(cycle){
this.cycle += cycle;
if(this.line === 0) {
this.background.length = 0;
}
// 1ライン分のサイクル
if (this.cycle >= 341) {
this.cycle -= 341;
this.line++;
if (this.line <= 240 && this.line % 8 === 0) {
this.buildBackground();
}
if (this.line === 262) {
this.line = 0;
return {
background: this.background,
palette: this.getPalette(),
};
}
}
}
buildSprite(spriteId) {
const sprite = new Array(8).fill(0).map(() => [0, 0, 0, 0, 0, 0, 0, 0]);
for (let i = 0; i < 16; i = i + 1) {
for (let j = 0; j < 8; j = j + 1) {
const addr = spriteId * 16 + i;
const ram = this.readCharacterRAM(addr);
if (ram & (0x80 >> j)) {
sprite[i % 8][j] += 0x01 << ~~(i / 8);
}
}
}
return sprite;
}
buildTile(tileX, tileY) {
const blockId = this.getBlockId(tileX, tileY);
const spriteId = this.getSpriteId(tileX, tileY);
const attr = this.getAttribute(tileX, tileY);
const paletteId = (attr >> (blockId * 2)) & 0x03;
const sprite = this.buildSprite(spriteId);
return {
sprite,
paletteId,
};
}
buildBackground() {
const clampedTileY = this.tileY % 30;
for (let x = 0; x < 32; x = x + 1) {
const clampedTileX = x % 32;
const nameTableId = (~~(x / 32) % 2);
const tile = this.buildTile(clampedTileX, clampedTileY);
this.background.push(tile);
}
}
}
PPUで作成されたデータを以下のようなrendererに渡しています。ここではスプライトのデータをImageData
に変換して、putImageData
でcanvasに描画しています。スプライトの形式はキャラクターROMで解説しました。
class Renderer {
... 略 ...
render(data) {
const { background, palette } = data;
this.renderBackground(background, palette);
this.ctx.putImageData(this.image, 0, 0);
}
renderBackground(background, palette) {
this.background = background;
for (let i = 0; i < background.length; i += 1) {
const x = (i % 32) * 8;
const y = ~~(i / 32) * 8;
this.renderTile(background[i], x, y, palette);
}
}
// キャラクターROMで記載したSpriteに記載されてあるパレット番号に基づき各ピクセルを着色
renderTile({ sprite, paletteId }, tileX, tileY, palette) {
const { data } = this.image;
for (let i = 0; i < 8; i = i + 1) {
for (let j = 0; j < 8; j = j + 1) {
const paletteIndex = paletteId * 4 + sprite[i][j];
const colorId = palette[paletteIndex];
const color = colors[colorId];
const x = tileX + j;
const y = tileY + i;
if (x >= 0 && 0xFF >= x && y >= 0 && y < 224) {
const index = (x + (y * 0x100)) * 4;
data[index] = color[0];
data[index + 1] = color[1];
data[index + 2] = color[2];
data[index + 3] = 0xFF;
}
}
}
}
}
実際のコードは以下
https://github.com/bokuweb/flownes/blob/master/src/renderer/canvas.js
タイミング
エミュレータを何も考えずに実行してしまうと、おそらく実機のファミコンより高速で動作してしまいます。なので実機と同様のタイミングで描画されるよう工夫が必要となります。
ファミコンは60FPSですので、逆算すると16msに1枚画像が更新できればいいことになります。すわなち、16ms周期で、PPUを262ライン分のサイクルの実行が完了するまでCPUを走らせればいいことになります。自分の場合はJSを使用してブラウザで描画することを前提としてるので、requestAnimationFrame
で1画面分の画像データが完成するまでCPUを走らせています。
class NES {
frame() {
while (true) {
let cycle: number = 0;
// 命令実行にかかったサイクル数を返す
cycle += this.cpu.run();
// PPUはCPUの3倍の速度で動作するのでCPUの実行サイクルの3倍のサイクル数を渡す
const renderingData = this.ppu.run(cycle * 3);
if (renderingData) {
// 1画面分のデータが完成していたらcanvasに描画する
this.renderer.render(renderingData);
break;
}
}
requestAnimationFrame(this.frame);
}
start() {
requestAnimationFrame(this.frame);
}
}
Hello, World!
長かったですが、ここまでの内容が実装されていればHello, World!
が描画されると思います。冒頭にも記載しましたが、Hello, Worldのサンプルは以下のリンクからダウンロードすることができます。
これにはアセンブラも含まれているので中身を覗いてみます。3
sample1.asm
以下がアセンブラです。
少しずつ解説していきます。
.setcpu "6502"
.autoimport on
.segment "HEADER"
.byte $4E, $45, $53, $1A ; "NES" Header
.byte $02 ; PRG-BANKS
.byte $01 ; CHR-BANKS
.byte $01 ; Vetrical Mirror
.byte $00 ;
.byte $00, $00, $00, $00 ;
.byte $00, $00, $00, $00 ;
.segment "STARTUP"
.proc Reset
sei
ldx #$ff
txs
lda #$00
sta $2000
sta $2001
lda #$3f
sta $2006
lda #$00
sta $2006
ldx #$00
ldy #$10
copypal:
lda palettes, x
sta $2007
inx
dey
bne copypal
lda #$21
sta $2006
lda #$c9
sta $2006
ldx #$00
ldy #$0d
copymap:
lda string, x
sta $2007
inx
dey
bne copymap
lda #$00
sta $2005
sta $2005
lda #$08
sta $2000
lda #$1e
sta $2001
mainloop:
jmp mainloop
.endproc
palettes:
.byte $0f, $00, $10, $20
.byte $0f, $06, $16, $26
.byte $0f, $08, $18, $28
.byte $0f, $0a, $1a, $2a
string:
.byte "HELLO, WORLD!"
.segment "VECINFO"
.word $0000
.word Reset
.word $0000
.segment "CHARS"
.incbin "character.chr"
詳細
以下はiNESヘッダです。前述した、ROMサイズなどの設定が記載されています。
.segment "HEADER"
.byte $4E, $45, $53, $1A ; "NES" Header
.byte $02 ; PRG-BANKS
.byte $01 ; CHR-BANKS
.byte $01 ; Vetrical Mirror
.byte $00 ;
.byte $00, $00, $00, $00 ;
.byte $00, $00, $00, $00 ;
プログラムは以下から開始します。
割り込みを禁止にしてからPPUの初期化を行っています。
lda #$00 sta $2000 sta $2001
でPPUのレジスタ0x2000, 0x2001に0x00を設定しています。今回言及していませんが、Hello, World!を表示するには無視して問題ないです。
lda #$3f
以降ですが、PPUの0x2006にアクセスしてPPUアドレスを設定しています。0x3f, 0x00を連続してライトしているので、PPUアドレスは0x3f00の背景パレット領域ですね。
.segment "STARTUP"
.proc Reset
sei
ldx #$ff
txs
lda #$00
sta $2000
sta $2001
lda #$3f
sta $2006
lda #$00
sta $2006
ldx #$00
ldy #$10
ラベルの通りここではパレット領域にパレットデータをコピーしています。Yレジスタがデクリメントして0になるまでコピーしています。コピー元はpalettesラベルの付いている箇所ですね。
copypal:
lda palettes, x
sta $2007
inx
dey
bne copypal
以下では、0x2006に0x21, 0xC9を格納していますね。これは、PPUアドレスを0x21C9に設定しています。つまりは、ネームテーブルですね。ネームテーブルの0x1C9の位置から string
ラベルのデータ、すなわちHELLO, WORLD!
ですね。ネームテーブルの項に記載した内容です。
lda #$21
sta $2006
lda #$c9
sta $2006
ldx #$00
ldy #$0d
copymap:
lda string, x
sta $2007
inx
dey
bne copymap
残りは今回は無視していい内容ばかりです。0x2005は背景スクロールのオフセット値ですが、今回のサンプルでは不要です。0x2000/0x2001も背景をイネーブルにする設定を行っているだけなので、ひとまず無視(常時イネーブルとみなしていい)でしょう。その後は無限ループに入るだけです。
lda #$00
sta $2005
sta $2005
lda #$08
sta $2000
lda #$1e
sta $2001
mainloop:
jmp mainloop
.endproc
多少のアセンブラとPPUのレジスタの内容が頭に入っていれば難しくないと思います。ですが、このサンプルを提供されている方の仰っているとおり、この内容がPPUのコアな部分になります。
おわりに
駆け足ではありますが、Hello, World!までの概要を説明しました。最低限以上の内容が実装されていればHello, World!を描画することが可能となります。
この記事で少しでも興味を持っていただければ幸いです。
もし間違いや不明点などありましたらTwitterなどで気軽に声をかけていただければと思います。
また他の機能については需要がありそうでしたらまとまり次第書いてみようと思います。