この記事は「WACUL Advent Calendar 2017」の18日目です。
WACULでフロントエンドエンジニアをしている@bokuwebと申します。
本記事ではファミコンのエミュレータの実装について解説していきたいと思います。

はじめに

以前ファミコンエミュレータをJSで実装した記事を書きました。
開発過程の雰囲気はこちらを参照していただけると掴めるかと思います。

http://blog.bokuweb.me/entry/nes

上記の記事では技術的な内容にはほぼ触れなかったため順に解説していこうと思います。

今回はまずは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研究室の以下のページより入手が可能です。後述しますが、アセンブラも含まれているのこちらも参照しながら進めるといいかもしれません。

http://hp.vector.co.jp/authors/VA042397/nes/sample.html

以下が描画されるまでがゴールです。黒い背景にHello, World!を表示させるだけのものですが、そこまでのステップは多いです。これだけ大変なHello, World!はなかなかないと思います。

image.png

スペック

以下がファミコンのスペックです。日本での発売は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, スタート, セレクト

簡易ハードウェアブロック図

かなり簡素化したものですが、おそらく以下のような構成になっているんじゃないかと思います。

Slice 1.png

①の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ヘッダー

詳細は以下に記述があります。

https://wiki.nesdev.com/w/index.php/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(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のスプライト情報を画像化したものです。

image.png

スプライトは1スプライトにつき16Byte、1ピクセルにつき2bitで表現されます。以下はスプライト番号2のハートのスプライトを画像に変換する例です。

スプライト番号2なのでキャラクターROMの0x0020番地から0x66 0x7F 0xFF 0xFF 0xFF 07E ... と16Byte分データが格納されており、以下のように2値のスプライトが2つ取り出せます。この2つを加算することで1枚のスプライトが取り出せます。

この2bitはパレット内の色番号を表しており、割り当てられたパレットの4色対に応する色をマッピングすることになります。パレットはPPUが管理していますので、これについては後述します。

また、設定によってはスプライトのサイズが8*16になりますが、今回は省略します。自分もまだ未実装です。

image.png

仕様を把握するため*.nesからスプライトデータをpngに書き出すのみのシンプルなツールを書いたので、参考にしてください。

https://github.com/bokuweb/nes-sprites2png

カセットの内容は以上です。カセットによってはバッテリバックアップされた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が参考になると思います。

http://pgate1.at-ninja.jp/NES_on_FPGA/nes_cpu.htm#interrupt

エミュレータとしてはひとまず、起動時/リセット時に0xFFFC/0xFFFDから開始アドレスをリードしてプログラムカウンタPCにセットしてやる必要があります。擬似コードですが、以下のようなイメージです。リセット時にレジスタの初期化とPCのセットを行っています。

擬似コード
class Cpu {

  ...  ...

  reset() {
        // レジスタの初期化
    this.registers = {
      ...defaultRegisters,
      P: { ...defaultRegisters.P }
    };
    this.registers.PC = this.readWord(0xFFFC);
  }

  ...  ...

}

実際のコードはこのへん

https://github.com/bokuweb/flownes/blob/master/src/cpu/index.js#L80-L88

命令セット

6502のオペコードは8bitで命令の種類とアドレッシングモードが表現されています。命令の種類はADCSUBのような種類で、アドレッシングモードによって演算の対象が決定されます。

以下がオペコード表です。上段に命令、下段にアドレッシング・モードが記載してあります。例えばzpgZero 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 *

各命令の挙動は以下を参照してください。

http://pgate1.at-ninja.jp/NES_on_FPGA/nes_cpu.htm#instruction

* となっている箇所は未定義の箇所です。実は隠し命令が定義されている箇所もあるのですが、今回は省略します。具体的には以下のものが隠し命令です。

https://github.com/bokuweb/flownes/blob/master/src/cpu/opcode.js#L273-L378

命令のサイクル数

実機はさまざまな条件でサイクル数が変動する命令がありますが、エミュレータレベルではひとまず気にしなくて良さそうです。自分のコードにはそのあたりもちゃんと考慮しようと思いつつも途中で妥協した痕跡があります。サイクル数の微妙な違いにより実機と挙動がことなる可能性は当然発生し得ますがほとんどのケースでは考慮する必要がなさそうです。

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,
];

自分の場合は以下で命令とサイクル数をセットにした辞書を作製しています。

https://github.com/bokuweb/flownes/blob/master/src/cpu/opcode.js

アドレッシングモード

アドレッシング・モードの種類と概要を以下に記載します。

略称 名前 概要
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 indexed Absolute Addressingで得られる番地に格納されている値を下位アドレス、その次の番地に格納されている値を上位アドレスとした番地を演算対象とする

はっきり言って上記のように書いても全くわからないので、頻出のImmediateとZero pageについて具体例を書いてみます。また以下のリンクと具体的なコードも合わせて参照した方が分かりやすいかもしれません。

http://pgate1.at-ninja.jp/NES_on_FPGA/nes_cpu.htm#addressing

Immediate

LDA #というアドレッシング・モードがImmediateな命令LDAが0x8000に格納されているものと仮定します。CPUは0x8000から命令をフェッチしてPC(プログラムカウンタ)をインクリメントします。

フェッチ結果が0xA9であるため、LDA命令でアドレッシングモードがImmediateであることがわかります。するとCPUはPC(現時点では0x8001)から0xA5をフェッチし、その値をLDA命令の実行対象とします。

具体的にはLDAはAレジスタに何らかの値をロードする命令ですのでこの場合Aレジスタに0xA5が格納されることになります。PCはインクリメントされ、0x8002の次の命令を指します。

image.png

Zero Page

LDA zpgという命令が0x8000に格納されているものと仮定します。CPUは0x8000から命令をフェッチしてPC(プログラムカウンタ)をインクリメントします。

フェッチ結果が0xA5であるため、LDA命令でアドレッシングモードがZero Pageであることがわかります。次にCPUはPC(現時点では0x8001)から0xA5をフェッチし、その値を下位アドレス、0x00を上位アドレス(すなわち0x00A5番地)としたアドレスのデータを実行対象とします。

この場合Aレジスタに0xDEが格納されることになります(0x00A5番地からのリードは発生しますがその際にPCはインクリメントされないことに注意してください)。PCはインクリメントされ、0x8002の次の命令を指します。

image.png

他にもいくつものアドレッシングモードがありますが、基本的には上記の応用です。フェッチしてくるバイト数を8から16bitにしてみたり、フェッチして来た値にXレジスタを加算したり、Yレジスタを加算したりして、実行対象とする番地を算出しているだけです。

実装イメージ

ここまでの説明でCPUは基本的には以下の手順を繰り返せばいいことが分かります。

  1. PC(プログラムカウンタ)からオペコードをフェッチ(PCをインクリメント)
  2. 命令とアドレッシング・モードを判別
  3. (必要であれば)オペランドをフェッチ(PCをインクリメント)
  4. (必要であれば)演算対象となるアドレスを算出
  5. 命令を実行
  6. 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領域(一部異なる)でその中にネームテーブルが含まれています。ネームテーブルは画面に対してどのように背景タイルを敷き詰めるかを決めるテーブルです。

画面は256*240ピクセルですので8*8ピクセルのタイルが32*30(すなわち960枚)で敷き詰められることになります。ネームテーブルのサイズが0x3C0=960なのはそのためです。

以下が今回目標とするHello, World!サンプル出力ですが、0x1C9の箇所にHが表示されています。すなわち、0x21C9番地にHを表すスプライト番号を書き込めばこの位置にHが表示されるということです。

image.png

以下のキャラクターROMのダンプを見るとHは73番目に配置されています(文字コードに合わせてあるので当然なんですが)。つまり上記の位置にHを表示させたければ0x21C9番地に0x72を格納すればいいということになります。

image.png

属性テーブル

属性テーブルという名前が紛らわしいですが、このテーブルは背景にどのパレットを適用するかを決定します。注意点としてはパレットは16*16ピクセル、すなわち(2*2タイル)に1パレット(4色)適用されるという点です。背景用パレットは4つ持つことができ、その中から1パレット選択するので1ブロックにつき2bitの情報を持つことになります。1画面256 * 240なので16 * 15ブロック = 240ブロック、1ブロックにつき2bitなので60バイトの領域が必要となります。

仮に0x23C00xE4であれば1110 0100bですので以下のようにブロックに適用されるパレットが決定します。(以下は1マス8*8ピクセルです)

image.png

あと、なぜネームテーブルと属性テーブルのペアが4セットあるのかと言うと以下のような配置で4画面分の領域を確保しておくことで背景の縦・横スクロールが可能となります。

image.png

言葉では表現しにくいのですが、以下のNesDevのgifを見るのが一番イメージが掴みやすいかもしれません。
ネームテーブル1と2の2画面分の領域に背景を用意しておき背景のx座標スクロール値1を変更することで背景をスクロールさせています。

SMB1_scrolling_seam.gif
* https://wiki.nesdev.com/w/index.php/PPU_scrollingより

今回の例ではスクロールは不要ですので、0x2000~の領域に1画面分書き込まれるという認識があればひとまず問題ないです。スクロールについてはまた別の機会に書きます。

また、ネームテーブル、属性テーブルについては以下の記事がかなり分かりやすかったです。この方式によってどれほどのメモリが節約できているかの考察もあって良いです。

http://postd.cc/nes-graphics-part-1/

パレット

0x3F00~0x3F0Fはバックグラウンドパレット、0x3F100x3F1F`はスプライトパレットです。それぞれ0x10のサイズで4色のパレットを4枚もつことができます。

ただし、0x3F10,0x3F14,0x3F18,0x3F1C0x3F00,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色のパレットになることを意味します。

https://github.com/bokuweb/flownes/blob/master/src/ppu/palette.js

PPUのバージョンなどで色が微妙に異なったりするようで必ずしも以下のような色になるわけではないようですが、だいたい以下のような色が使用できるようです。2

famipalette.gif

パターンテーブル

0x0000~0x01FFFはパターンテーブルとありますが、これはカセットのキャラクターROM(またはRAM)へのアクセスとなります。パターンテーブルを2面持っているのは、0x0000~を背景用と0x1000~をスプライト用の領域となるようにPPU内のレジスタで設定することができるんですが、今回は背景のみかつ、設定はデフォルトのままなので0x0000~から背景用のスプライトが格納されているという認識があれば問題ないです。

PPUADDR/PPUDATAレジスタ

ここまでで画面に何かを表示するには、0x2000~0x24000のネームテーブルと属性テーブル、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ラインの次にはまた先頭のラインから描画を始めます。

image.png

基本的に、VRAMを変更するのはVblankの間にすることになっています。これは描画中にVRAMの内容を変更してしまうと画面が崩れてしまう可能性があるからです。ただ、今回サンプルは最初に書き込んだきり変更しないため、この制約は無視しています。

実装イメージ

ここまでの内容をつなげると実装すべきPPUの流れが見えてくるかと思います。ざっくりとですが、自分の場合は以下のようなイメージで実装しています。

  1. 実行サイクル数を受け取ってサイクル数を加算
  2. 341クロック以上であれば1ライン加算
  3. 8ラインごとに背景スライトとパレットのデータを格納
    1. x, y座標からネームテーブル、属性テーブルの該当アドレスを算出する
    2. ネームテーブルに格納されたスプライト番号でキャラクターROMからスプライト情報をリードする
    3. 属性に格納されたパレット番号で背景パレットテーブルからパレット情報をリードする
    4. 2, 3のデータをセットにして格納
    5. 1~4を1ライン分繰り返す
  4. 1~3を1画面分繰り返す
  5. 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のサンプルは以下のリンクからダウンロードすることができます。

http://hp.vector.co.jp/authors/VA042397/nes/sample.html

これにはアセンブラも含まれているので中身を覗いてみます。3

sample1.asm

以下がアセンブラです。
少しずつ解説していきます。

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サイズなどの設定が記載されています。

sample1.asm
.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, 0x10を連続してライトしているので、PPUアドレスは0x3f10の背景パレット領域ですね。

sample1.asm
.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ラベルの付いている箇所ですね。

sample1.asm
copypal:
    lda palettes, x
    sta $2007
    inx
    dey
    bne copypal

以下では、0x2006に0x21, 0xC9を格納していますね。これは、PPUアドレスを0x21C9に設定しています。つまりは、ネームテーブルですね。ネームテーブルの0x1C9の位置から stringラベルのデータ、すなわちHELLO, WORLD!ですね。ネームテーブルの項に記載した内容です。

sample1.asm
    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も背景をイネーブルにする設定を行っているだけなので、ひとまず無視(常時イネーブルとみなしていい)でしょう。その後は無限ループに入るだけです。

sample1.asm
    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などで気軽に声をかけていただければと思います。

また他の機能については需要がありそうでしたらまとまり次第書いてみようと思います。


  1. 背景スクロール値はPPUの0x2005にライトすることで設定できますが今回は無視します 

  2. PPUの型式やエミュレータなどで色合いが微妙に違うらしいですが、ファミコンミニは言わば公式エミュレータなので、そこで採用されている色が公式の色では?という話もある 

  3. C言語バージョンもあるので合わせて参照してみてください