Edited at
WACULDay 18

ファミコンエミュレータの創り方 - Hello, World!編 -

この記事は「WACUL Advent Calendar 2017」の18日目です。

WACULでフロントエンドエンジニアをしている@bokuwebと申します。

本記事ではファミコンのエミュレータの実装について解説していきたいと思います。


2018/11/21 追記

重複しますが以前発表した資料も合わせて参照してください。

https://speakerdeck.com/bokuweb/huamikonemiyuretafalsechuang-rifang


はじめに

以前ファミコンエミュレータを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(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のスプライト情報を画像化したものです。

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 Indirect
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)から0x25をフェッチし、その値を下位アドレス、0x00を上位アドレス(すなわち0x0025番地)としたアドレスのデータを実行対象とします。

この場合Aレジスタに0xDEが格納されることになります(0x0025番地からのリードは発生しますがその際に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番地に72を格納すればいいということになります。

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/


パレット

0x3F000x3F0F はバックグラウンドパレット、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, 0x00を連続してライトしているので、PPUアドレスは0x3f00の背景パレット領域ですね。


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言語バージョンもあるので合わせて参照してみてください