今更ながらNESエミュレーターを作成した話 PPU編
はじめに
WED株式会社でレシート買取アプリONEのバックエンドエンジニアをしているsorakoroです。
前回のCPU編に続き、NESの画面の状態をレンダリングするコンポーネントであるPPU(Picture Processing Unit)について話をしたいと思います。
メモリマップ
まずNESのPPUは独自のメモリ空間を持っており、メモリマップは以下のようになっています。
アドレス | サイズ | 用途 |
---|---|---|
0x0000-0x1FFF | 0x2000 | Pattern Tables |
0x2000-0x2FFF | 0x1000 | Name Tables |
0x3000-0x3EFF | 0x0F01 | 未使用 |
0x3F00-0x3F0F | 0x0010 | palette Tables |
0x3F10-0x3F1F | 0x0010 | Sprite Palette Tables |
0x3F20-0x3FFF | 0x00E0 | 未使用 |
0x4000-0xFFFF | 0xC000 | 未使用 |
また、CPUはMMIO(Memory-Mapped I/O)を採用しており、特定のメモリアドレスにアクセスすることで周辺のデバイス(PPUやAPU)にアクセスすることが可能となっています。
アドレス | 名称 | 用途 |
---|---|---|
0x2000 | PPUCTRL | PPU 制御 |
0x2001 | PPUMASK | 描画の有効化・色制御 |
0x2002 | PPUSTATUS | PPU の状態取得 |
0x2003 | OAMADDR | OAM (スプライト) 書き込みアドレス |
0x2004 | OAMDATA | OAM 書き込みデータ |
0x2005 | PPUSCROLL | スクロール設定 |
0x2006 | PPUADDR | VRAM アドレス指定 |
0x2007 | PPUDATA | VRAM データ読み書き |
PPUレジスタ
最初にPPUの各レジスタを実装するところから始めました。全てを紹介するのは大変なので、ここではPPUADDRレジスタの実装に絞って話をしたいと思います。
レジスタの仕様はこちらを参照してください。
CPUとPPUは独立したメモリ空間を持つため、直接相手方のメモリにアクセスすることはできません。CPUがVRAMにデータを書き込む際はPPUADDRに書き込み先のアドレスをロードして、その後PPUDATAにデータを書き込むことで実現しています。
PPUADDRは16bit幅を持っており、1回目の書き込みで上位8bitが設定され、2回目の書き込みで下位8bitが設定されるようになっています。また、読み書きが発生するたびにPPUADDRが増分するようになっているため、PPUADDRには1度書き込みするだけで済むようになっています。増分する数はPPUCTRLに設定されています。
pub struct AddrRegister {
value: (u8, u8),
hi_ptr: bool,
}
impl AddrRegister {
pub fn new() -> Self {
AddrRegister {
value: (0, 0),
hi_ptr: true,
}
}
fn set(&mut self, data: u16) {
self.value.0 = (data >> 8) as u8;
self.value.1 = (data & 0xFF) as u8;
}
pub fn update(&mut self, data: u8) {
if self.hi_ptr {
self.value.0 = data;
} else {
self.value.1 = data;
}
if self.get() > 0x3FFF {
self.set(self.get() & 0b11111111111111);
}
self.hi_ptr = !self.hi_ptr;
}
pub fn increment(&mut self, inc: u8) {
let lo = self.value.1;
self.value.1 = self.value.1.wrapping_add(inc);
if lo > self.value.1 {
self.value.0 = self.value.0.wrapping_add(1);
}
if self.get() > 0x3FFF {
self.set(self.get() & 0b11111111111111);
}
}
pub fn reset_latch(&mut self) {
self.hi_ptr = true;
}
pub fn get(&self) -> u16 {
((self.value.0 as u16) << 8) | (self.value.1 as u16)
}
}
各レジスタの実装が終わった後は、PPUの実装を進めていきます。PPUのデータ構造は以下のように定義して、VRAMに読み書きをする処理を実装していきます。ここで気を付けなければいけない点があって、VRAMに読み取りを行う際、前回読み取ったデータを返す必要があるということです。なぜこのようになっているかというと、前述した通りCPUとPPUは独立したメモリ空間を持っているため、CPUの読み取り要求に対して即座にデータ返すことができません。そのためPPU内部のバッファにデータを保持してCPUが再度読み取り命令を実行してデータを取得するようになっているためです。
pub struct PPU {
pub chr_rom: Vec<u8>,
pub palette_table: [u8; 32],
pub vram: [u8; 2048],
pub oam_data: [u8; 256],
pub mirroring: Mirroring,
pub addr: AddrRegister,
internal_data_buf: u8,
}
impl PPU {
pub fn new(chr_rom: Vec<u8>, mirroring: Mirroring) -> Self {
PPU {
chr_rom,
palette_table: [0; 32],
vram: [0; 2048],
oam_data: [0; 64 * 4],
mirroring,
addr: AddrRegister::new(),
internal_data_buf: 0,
}
}
pub fn read_data(&mut self) -> u8 {
let addr = self.addr.get();
self.increment_vram_addr();
match addr {
0x0000..=0x1FFF => {
let result = self.internal_data_buf;
self.internal_data_buf = self.chr_rom[addr as usize];
result
}
0x2000..=0x3EFF => {
let result = self.internal_data_buf;
self.internal_data_buf = self.vram[self.mirror_vram_addr(addr) as usize];
result
}
0x3F00..=0x3FFF => {
let result = self.internal_data_buf;
self.internal_data_buf =
self.palette_table[self.mirror_palette_addr(addr) as usize];
result
}
_ => panic!("unexpected access to mirrored space {}", addr),
}
}
pub fn write_to_data(&mut self, value: u8) {
let addr = self.addr.get();
self.increment_vram_addr();
match addr {
0x0000..=0x1FFF => {
panic!(
"addr space 0x0000..0x1FFF is not expected to be used, requested = {}",
addr
)
}
0x2000..=0x3EFF => {
self.vram[self.mirror_vram_addr(addr) as usize] = value;
}
0x3F00..=0x3FFF => {
self.palette_table[self.mirror_palette_addr(addr) as usize] = value;
}
_ => panic!("unexpected access to mirrored space {}", addr),
}
}
}
ミラーリング
次にミラーリングの実装を進めていきます。NESには4種類のミラーリングがあります。ゲームソフトがどのミラーリングを使っているのかは、カートリッジのヘッダーを読み取れば知ることができるようになっています。例えばゲームソフトが水平ミラーリングを使っている場合は、0x2400-0x27FFを1番目の1KiBにマップし、0x2C00-0x2FFFを4番目の1KiBにマップする必要があります。
Horizontal | Vertical | Single-Screen | 4-Screen |
---|---|---|---|
![]() |
![]() |
![]() |
![]() |
impl PPU {
fn mirror_vram_addr(&mut self, addr: u16) -> u16 {
let mirrored_vram_addr = addr & 0b10_1111_1111_1111;
let vram_index = mirrored_vram_addr - 0x2000;
let nametable = vram_index / 0x400;
match (&self.mirroring, nametable) {
(Mirroring::VERTICAL, 2) => vram_index - 0x800,
(Mirroring::VERTICAL, 3) => vram_index - 0x800,
(Mirroring::HORIZONTAL, 1) => vram_index - 0x400,
(Mirroring::HORIZONTAL, 2) => vram_index - 0x400,
(Mirroring::HORIZONTAL, 3) => vram_index - 0x800,
_ => vram_index,
}
}
}
おわりに
次回はPPUの割り込み処理について記事を書きたいと思います。最後までご覧くださりありがとうございます。
Discussion