featured.svg

上一篇我們寫了 NES 的 CPU——一顆 cycle-accurate 的 6502 模擬器。但 CPU 只是 NES 的一半。真正讓畫面動起來的是 PPU(Picture Processing Unit)——一顆跑在 CPU 3 倍速度的圖形處理器,每秒畫 60 幀、每幀 256×240 像素。

PPU 的難度不在「畫像素」,而在時序。CPU 和 PPU 的時鐘比是 1:3,任何一個 cycle 的偏差都可能讓畫面撕裂、NMI 中斷丟失、或觸發只有在特定 cycle 才會出現的硬體競態條件。這個專案的目標是通過 blargg 的 ppu_vbl_nmi 測試套件——NES 模擬器界最刁鑽的 VBlank/NMI 時序驗證。

專案結構

nes-ppu/
├── Cargo.toml
└── src/
    ├── lib.rs            # 模組匯出
    ├── ppu.rs            # PPU 核心:暫存器、渲染管線、NMI 邊緣偵測(1056 行)
    ├── bus_io.rs          # CPU↔PPU 橋接:catch-up ticking(80 行)
    ├── system.rs          # System:CPU/PPU 協調器(156 行)
    ├── main.rs            # blargg 測試 harness(97 行)
    └── demos/
        └── render/main.rs # 即時渲染視窗(minifb)

依賴:nes-cpu(CPU 模擬器)、nes-mapper(卡匣 mapper)、anyhow、optional minifb(渲染 demo)。

NES 畫面是怎麼生出來的

PPU 用 scanline 逐行畫面:

Scanline 0-239:   可見畫面(256 像素/行)
Scanline 240:     idle
Scanline 241:     VBlank 開始 → NMI 中斷 → 遊戲邏輯更新
Scanline 242-260: VBlank 持續
Scanline 261:     Pre-render → 清除 flags、準備下一幀

每條 scanline 有 341 個 dot(PPU cycle)。整幀 = 262 scanlines × 341 dots = 89,342 個 PPU cycle。因為 PPU 跑 CPU 的 3 倍速,一幀大約等於 ~29,781 個 CPU cycle。

VBlank 期間(scanline 241-260)PPU 不畫東西,遊戲程式趁這段空檔更新 VRAM——這就是為什麼 NMI 中斷對遊戲來說如此重要。

1. PPU 狀態:一堆暫存器和 Shift Register

PPU 的狀態比 CPU 複雜很多:

pub struct Ppu {
    // 控制暫存器
    pub ctrl: u8,    // $2000: NMI enable, sprite size, pattern table...
    pub mask: u8,    // $2001: background/sprite enable, color emphasis
    pub status: u8,  // $2002: VBlank, sprite 0 hit, overflow

    // Loopy scroll 暫存器
    pub v: u16,      // 當前 VRAM 位址 (15-bit)
    pub t: u16,      // 暫存 VRAM 位址 (15-bit)
    pub fine_x: u8,  // X fine scroll (0-7 pixels)
    pub addr_latch: bool, // 寫入對切換

    // 記憶體
    pub vram: [u8; 2048],    // 2 個 nametable
    pub palette: [u8; 32],   // 調色盤
    pub oam: [u8; 256],      // Sprite 記憶體 (64 sprites × 4 bytes)

    // 時序
    pub scanline: i16,  // 0-261
    pub dot: u16,       // 0-340
    pub odd_frame: bool,

    // NMI(edge-triggered)
    pub nmi_output: bool,      // $2000 bit 7
    pub nmi_occurred: bool,    // VBlank flag
    pub nmi_pending: bool,     // Edge-detected NMI
    pub nmi_line: bool,        // 前一次 NMI line 狀態
    pub nmi_pending_age: u16,  // NMI 設定後經過的 PPU ticks
    pub nmi_write_delay: u8,   // Register-write 觸發的延遲

    // 背景 shift registers
    bg_pattern_lo: u16,
    bg_pattern_hi: u16,
    bg_attr_lo: u16,
    bg_attr_hi: u16,

    // 渲染輸出
    pub framebuffer: Box<[u32; 256 * 240]>,
    // ...
}

其中最微妙的是 NMI 相關的 5 個欄位——光是「要不要觸發 NMI 中斷」這件事就需要 edge detection、age tracking 和 write delay 三種機制。

2. CPU/PPU 同步:Catch-Up 模式

NES 的 PPU 跑 CPU 的 3 倍速。最直覺的模擬方式是「CPU 跑一步、PPU 跑三步」,但這忽略了一個關鍵問題:CPU 讀寫 PPU 暫存器時,PPU 需要先追上到正確的時間點

這就是 catch-up ticking。System 用兩個共享計數器追蹤 CPU 和 PPU 的進度:

pub struct System {
    pub cpu: Cpu,
    pub ppu: Rc<RefCell<Ppu>>,
    cpu_cycles: Rc<Cell<u64>>,
    ppu_cycles: Rc<Cell<u64>>,
    // ...
}

每一步的流程:

graph TD A[檢查 NMI pending] -->|是| B[CPU 執行 NMI handler] A -->|否| C[處理 OAM DMA] C --> D[預讀 opcode,設定 cpu_cycles] D --> E[CPU 執行一條指令] E --> F[PPU catch-up
補跑剩餘 dots] F --> G[檢查 mapper IRQ] G --> A
pub fn step(&mut self) -> u64 {
    if self.ppu.borrow_mut().take_nmi() {
        self.cpu.nmi();
        self.cpu_cycles.set(self.cpu.cycles);
    }

    let dma_cycles = self.handle_dma();

    // 預讀指令的 base cycle cost,這樣 PPU catch-up
    // 在 bus read 時能看到正確的 CPU cycle 數
    let opcode = self.cpu.bus.peek(self.cpu.pc);
    let instr = nes_cpu::opcodes::get_opcodes()[opcode as usize];
    let base_cost = instr.cycles as u64;
    self.cpu_cycles.set(self.cpu.cycles + base_cost);

    let prev_cycles = self.cpu.cycles;
    let _ = self.cpu.step();
    let elapsed = self.cpu.cycles - prev_cycles;

    self.cpu_cycles.set(self.cpu.cycles);

    // 補跑 PPU 尚未 tick 的部分
    let target_ppu = self.cpu.cycles * 3;
    let current_ppu = self.ppu_cycles.get();
    if target_ppu > current_ppu {
        let remaining = target_ppu - current_ppu;
        let mut ppu = self.ppu.borrow_mut();
        for _ in 0..remaining {
            ppu.tick();
        }
        self.ppu_cycles.set(target_ppu);
    }

    elapsed + dma_cycles
}

關鍵在 cpu_cycles 的預設值:在 CPU 執行指令之前就設定為 cycles + base_cost。這樣如果指令中途讀寫 PPU 暫存器(透過 bus),PPU 的 catch-up 就能看到正確的時間點。

3. $2002 讀取:VBlank 競態條件

PPU 最刁鑽的硬體行為之一:如果 CPU 剛好在 VBlank 被設定的前一個 cycle$2002,VBlank flag 就不會被設定——整幀都不會。

這個 race condition 在 bus_io.rs 裡用 split catch-up 實作:

impl BusIo for PpuBusIo {
    fn read(&mut self, addr: u16) -> u8 {
        let target = self.cpu_cycles.get() * 3;

        if addr == 0x2002 {
            // 先 catch-up 到 target-1
            let split_target = target.saturating_sub(1);
            self.catch_up_to(split_target);

            let mut ppu = self.ppu.borrow_mut();

            // 如果 PPU 現在在 (241, 0),下一個 tick 就要設 VBlank
            // 此時讀 $2002 會抑制 VBlank
            if ppu.scanline == timing::VBLANK_LINE && ppu.dot == 0 {
                ppu.suppress_vbl = true;
            }

            let val = ppu.read_register(addr);
            ppu.tick(); // 最後一個 tick
            self.ppu_cycles.set(target);
            val
        } else {
            self.catch_up_to(target);
            self.ppu.borrow_mut().read_register(addr)
        }
    }
}

為什麼要「分兩段」catch-up?因為我們需要在最後一個 PPU tick 之前檢查 PPU 是否正好站在 VBlank 即將觸發的位置 (scanline 241, dot 0)。如果是的話,設定 suppress_vbl flag,讓下一個 tick 跳過 VBlank 設定。

tick() 裡是這樣檢查的:

if self.scanline == timing::VBLANK_LINE && self.dot == timing::EVENT_DOT {
    if self.suppress_vbl {
        self.suppress_vbl = false; // 消費 flag,不設 VBlank
    } else {
        self.status |= status::VBLANK;
        self.nmi_occurred = true;
        self.update_nmi(true);
    }
}

4. NMI Edge Detection:不是 Level-Triggered

6502 的 NMI 是 edge-triggered,不是 level-triggered——只有在 /NMI 線從高到低的瞬間才會觸發,持續為低不會重複觸發。這在模擬器裡代表你必須追蹤 NMI 線的前後狀態:

fn update_nmi(&mut self, from_tick: bool) {
    let active = self.nmi_output && self.nmi_occurred;
    if active && !self.nmi_line {
        // Rising edge: NMI 線剛從 inactive → active
        self.nmi_pending = true;
        self.nmi_pending_age = 0;
    } else if !active && self.nmi_line {
        // Falling edge: 只有 tick 產生的才能取消 pending NMI
        if from_tick && self.nmi_write_delay == 0 {
            self.nmi_pending = false;
        }
    }
    self.nmi_line = active;
}

這裡的 from_tick 參數區分了兩種 NMI 狀態變化的來源:

  • PPU tick 產生的(如 pre-render 清 VBlank):可以取消 pending NMI
  • 暫存器寫入產生的(如 $2000 關閉 NMI enable):不能取消已經被 CPU latch 的 NMI

CPU 何時「看到」NMI?take_nmi() 有一個 age-based 的機制:

pub fn take_nmi(&mut self) -> bool {
    if self.nmi_write_delay > 0 {
        self.nmi_write_delay -= 1;
        if self.nmi_write_delay == 0 && self.nmi_pending {
            self.nmi_pending = false;
            return true;
        }
        return false;
    }
    // NMI 必須存在夠久(≥3 PPU ticks),CPU 才能在
    // penultimate cycle 偵測到
    if self.nmi_pending && self.nmi_pending_age >= 3 {
        self.nmi_pending = false;
        return true;
    }
    false
}

3 PPU ticks = 1 CPU cycle——剛好是 CPU 在指令的倒數第二個 cycle 做 NMI polling 的時間。如果 NMI 在最後一刻被設定又馬上被取消(不到 3 PPU ticks),CPU 就「來不及看到」。

5. 背景渲染管線

PPU 每 8 個 dot 取一個 tile 的資料,透過一連串精確時序的 fetch:

if (self.dot >= 1 && self.dot <= 256) || (self.dot >= 321 && self.dot <= 336) {
    self.update_bg_shifters();
    match self.dot % 8 {
        1 => self.fetch_nametable_byte(),   // Tile ID
        3 => self.fetch_attribute_byte(),   // 調色盤 bits
        5 => self.fetch_pattern_lo(),       // Pattern bitplane 0
        7 => self.fetch_pattern_hi(),       // Pattern bitplane 1
        0 => self.load_bg_shifters(),       // 載入 16-bit shift register
        _ => {}
    }
}

每個像素從 16-bit shift register 取出,用 fine_x 選擇位移量實現 pixel-level 捲動:

fn render_pixel(&mut self) {
    let x = (self.dot - 1) as usize;
    let y = self.scanline as usize;

    let (bg_pixel, bg_palette) = if self.mask & mask::SHOW_BG != 0 {
        let bit_select = 15 - self.fine_x as u16;
        let lo = (self.bg_pattern_lo >> bit_select) & 1;
        let hi = (self.bg_pattern_hi >> bit_select) & 1;
        let pixel = ((hi << 1) | lo) as u8;
        let attr_lo = (self.bg_attr_lo >> bit_select) & 1;
        let attr_hi = (self.bg_attr_hi >> bit_select) & 1;
        let palette = ((attr_hi << 1) | attr_lo) as u8;
        (pixel, palette)
    } else {
        (0, 0)
    };

    // Sprite compositing + priority multiplexer...
}

16-bit shift register 同時持有兩個 tile 的資料,每個 dot 左移一位。這讓 PPU 能在不同 tile 邊界之間平滑捲動——fine_x 決定從哪一個 bit 開始取。

6. Loopy Scroll Register

NES PPU 的捲動機制用了一對 15-bit 暫存器 v(current)和 t(temporary),被稱為 Loopy registers

yyy NN YYYYY XXXXX
│││ ││ │││││ │││││
│││ ││ │││││ └────── Coarse X (0-31) tile column
│││ ││ └─────────── Coarse Y (0-29) tile row
│││ └────────────── Nametable select (0-3)
└────────────────── Fine Y (0-7) pixel row within tile

捲動邏輯分散在渲染的不同時間點:

// Dot 256: 增加 fine Y → coarse Y(換到下一行)
if self.dot == 256 {
    self.increment_scroll_y();
}
// Dot 257: 從 t 複製水平捲動位元到 v
if self.dot == 257 {
    self.copy_horizontal_bits();
}
// Pre-render scanline, dots 280-304: 從 t 複製垂直捲動位元到 v
if prerender && self.dot >= 280 && self.dot <= 304 {
    self.copy_vertical_bits();
}

increment_scroll_y() 特別有趣——Y 到 29 就 wrap 到 0 並切換 nametable(因為 NES 的 nametable 是 30 rows × 32 columns),但 Y=31 只是 wrap 到 0 而不切換(允許遊戲利用這個行為做特殊效果)。

7. Sprite 系統

PPU 支援 64 個 sprite,每個 4 bytes(Y, tile index, attributes, X)。每條 scanline 最多顯示 8 個:

fn evaluate_sprites(&mut self) {
    let sprite_height: i16 = if self.ctrl & ctrl::SPRITE_SIZE != 0 { 16 } else { 8 };

    self.sprite_count = 0;
    self.sprite_zero_on_line = false;

    for i in 0..64 {
        let y = self.oam[i * 4] as i16;
        let diff = self.scanline - y;
        if diff < 0 || diff >= sprite_height { continue; }
        if self.sprite_count >= 8 {
            self.status |= status::SPRITE_OVERFLOW;
            break;
        }

        // 載入 tile pattern,處理垂直/水平翻轉
        let flip_v = attr & 0x80 != 0;
        let flip_h = attr & 0x40 != 0;
        // ...取 pattern bitplane、reverse_bits() 做水平翻轉

        if i == 0 { self.sprite_zero_on_line = true; }
        self.sprite_count += 1;
    }
}

Sprite 0 Hit 是很多遊戲用來做 split-screen 的技巧——當 sprite 0 的非透明像素和背景的非透明像素重疊時設定 flag,遊戲可以 poll 這個 flag 來判斷「畫到哪一行了」。

渲染時的 priority multiplexer 決定最終顏色:

Background Sprite Priority bit 顯示
透明 透明 - 背景色
透明 非透明 - Sprite
非透明 透明 - Background
非透明 非透明 0(前景) Sprite
非透明 非透明 1(後景) Background

8. blargg 測試套件

blargg 的 ppu_vbl_nmi 測試是一組 10 個 ROM,每個測試針對不同的 VBlank/NMI 邊界行為。測試用一個簡單的記憶體協定回報結果:

$6000    = status: $80=running, $00=pass, $01+=failure code
$6001-03 = signature: $DE $B0 $61 (表示結果有效)
$6004+   = null-terminated 結果訊息
loop {
    total_cycles += sys.step();

    let sig0 = sys.cpu.bus.read(0x6001);
    let sig1 = sys.cpu.bus.read(0x6002);
    let sig2 = sys.cpu.bus.read(0x6003);

    if sig0 == 0xDE && sig1 == 0xB0 && sig2 == 0x61 {
        let status = sys.cpu.bus.read(0x6000);
        if status != 0x80 {
            let message = read_result_message(&mut sys);
            if status == 0x00 {
                println!("PASS");
            } else {
                println!("FAIL: {}", message);
            }
        }
    }
}

10 個測試覆蓋的場景:

測試 驗證內容
01 基本 VBlank 時序
02 $2002 讀取抑制 VBlank
03 基本 NMI 時序
04 NMI + 抑制時序
05 NMI 狀態轉換精確偏移
06 完整 VBlank 抑制 pattern
07 VBlank 期間啟用 NMI 的時序
08 VBlank 期間關閉 NMI 的時序
09 奇偶幀基本行為
10 Pre-render 結束時的奇偶幀時序

9. Odd-Frame Skip

NES PPU 有一個小巧的優化:在奇數幀,pre-render scanline 會跳過一個 dot,讓畫面更新稍微提前。

if self.scanline == timing::PRERENDER_LINE
    && self.dot == timing::ODD_SKIP_DOT  // dot 339
    && self.odd_frame
    && self.rendering_enabled()
{
    self.dot = timing::LAST_DOT; // 跳到 340
    // 下一個 tick 會增到 341,觸發 scanline wrap
}

這個行為只在 rendering enabled 時發生。實際效果是奇數幀比偶數幀少一個 PPU cycle(89,341 vs 89,342),這讓 NTSC 的畫面更新率精確到 60.0988 Hz。

10. 即時渲染 Demo

專案包含一個用 minifb 寫的即時渲染器,可以實際載入 NES ROM 看到畫面:

let mut sys = System::from_rom(rom)?;

window.set_target_fps(60);

while window.is_open() && !window.is_key_down(Key::Escape) {
    sys.run_until_frame();
    let ppu = sys.ppu.borrow();
    window.update_with_buffer(&*ppu.framebuffer, WIDTH, HEIGHT)?;
}

run_until_frame() 反覆呼叫 step() 直到 PPU 設定 frame_ready(VBlank 開始),然後把 framebuffer 丟給視窗。256×240 的 NES 畫面用 3 倍 scale 顯示。

總結

模組 行數 角色 核心 Pattern
ppu.rs ~1056 PPU 核心:暫存器、渲染、NMI tick() state machine + edge detection
bus_io.rs ~80 CPU↔PPU 橋接 Split catch-up for $2002 race
system.rs ~156 CPU/PPU 協調器 Shared Rc<Cell<u64>> cycle counters
main.rs ~97 blargg 測試 harness Memory-mapped test protocol
demos/render ~55 即時渲染視窗 run_until_frame() + minifb

PPU 模擬的核心挑戰不在渲染演算法(那部分其實很直覺),而在 CPU/PPU 同步的精確時序。VBlank 抑制、NMI edge detection、age-based cancellation——這些都是真實硬體上 CPU 和 PPU 之間電氣信號傳播時間造成的行為,要在軟體裡精確重現這些 race condition,比畫像素難多了。

參考資源