🦔

今更ながらNESエミュレーターを作成した話 PPU編

2025/02/28に公開

はじめに

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に設定されています。

addr.rs
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が再度読み取り命令を実行してデータを取得するようになっているためです。

ppu.rs
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
ppu.rs
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の割り込み処理について記事を書きたいと思います。最後までご覧くださりありがとうございます。

WED Engineering Blog

Discussion