Simply Patrick

NES Mapper 模擬器:Bank Switching、MMC1 串列暫存器與 MMC3 Scanline IRQ

featured.svg

NES 的 CPU 只有 16-bit 的位址線——最多定址 64KB。扣掉 RAM、PPU 暫存器和 I/O,留給遊戲程式碼的只剩 32KB($8000-$FFFF)。但 Super Mario Bros. 3 有 384KB、Final Fantasy III 有 512KB——這些遊戲怎麼塞進去的?

答案是卡匣上的 Mapper 硬體。Mapper 是焊在卡匣 PCB 上的晶片,負責在 CPU 和 ROM 之間做 bank switching——把 ROM 的不同「分頁」切換到 CPU 看得到的位址空間。不同的 Mapper 用不同的切換策略,從最簡單的「固定不切」到最複雜的「8 個可獨立切換的 bank + scanline IRQ 計數器」。

這個專案實作了 6 種 Mapper,覆蓋了 NES 遊戲庫約 60% 的遊戲。

專案結構

nes-mapper/
├── Cargo.toml
└── src/
    ├── lib.rs     # Factory function + 模組匯出(24 行)
    ├── nrom.rs    # Mapper 0:無 bank switching(140 行)
    ├── mmc1.rs    # Mapper 1:5-bit 串列暫存器(307 行)
    ├── uxrom.rs   # Mapper 2:16KB PRG 切換(118 行)
    ├── cnrom.rs   # Mapper 3:8KB CHR 切換(110 行)
    ├── mmc3.rs    # Mapper 4:細粒度切換 + scanline IRQ(499 行)
    ├── axrom.rs   # Mapper 7:32KB PRG + 單畫面鏡像(114 行)

依賴只有 nes-cpu(Mapper trait 定義和 iNES ROM 格式)。

Mapper Trait:統一的卡匣介面

所有 Mapper 都實作同一個 trait:

pub trait Mapper {
    fn read_prg(&self, addr: u16) -> u8;      // CPU 讀程式碼/資料
    fn write_prg(&mut self, addr: u16, val: u8); // CPU 寫入(觸發 bank switch)
    fn read_chr(&self, addr: u16) -> u8;       // PPU 讀圖形資料
    fn write_chr(&mut self, addr: u16, val: u8); // PPU 寫 CHR-RAM
    fn mirroring(&self) -> Mirroring;          // Nametable 鏡像模式
    fn as_irq(&mut self) -> Option<&mut dyn MapperIrq> { None }
}

Factory function 根據 iNES header 的 mapper 編號建立對應的實作:

pub fn from_rom(rom: &INesRom) -> anyhow::Result<Box<dyn Mapper>> {
    match rom.mapper {
        0 => Ok(Box::new(nrom::Nrom::new(rom))),
        1 => Ok(Box::new(mmc1::Mmc1::new(rom))),
        2 => Ok(Box::new(uxrom::UxRom::new(rom))),
        3 => Ok(Box::new(cnrom::Cnrom::new(rom))),
        4 => Ok(Box::new(mmc3::Mmc3::new(rom))),
        7 => Ok(Box::new(axrom::AxRom::new(rom))),
        n => anyhow::bail!("Unsupported mapper: {n}"),
    }
}

Box<dyn Mapper> 讓 CPU 和 PPU 不需要知道具體是哪一種 Mapper——runtime polymorphism 在這裡是最自然的選擇。

1. Mapper 0(NROM):最簡單的開始

NROM 沒有 bank switching。PRG ROM 16KB 或 32KB 直接映射到 CPU 位址空間:

pub struct Nrom {
    prg_rom: Vec<u8>,
    prg_ram: [u8; 8192],  // $6000-$7FFF
    chr: Vec<u8>,
    chr_ram: bool,
    mirroring: Mirroring,
}

impl Mapper for Nrom {
    fn read_prg(&self, addr: u16) -> u8 {
        match addr {
            0x6000..=0x7FFF => self.prg_ram[(addr - 0x6000) as usize],
            0x8000..=0xFFFF => {
                let offset = (addr - 0x8000) as usize % self.prg_rom.len();
                self.prg_rom[offset]
            }
            _ => 0,
        }
    }
}

關鍵是那個 % self.prg_rom.len()——如果 PRG 只有 16KB,$8000-$BFFF$C000-$FFFF 會映射到同一份資料。這就是硬體鏡像,不需要 if-else,一個 modulo 就搞定。

NROM 約佔 NES 遊戲庫的 10%,包括初代 Super Mario Bros.、Donkey Kong、Excitebike 等早期經典。

2. Mapper 1(MMC1):5-bit 串列暫存器

MMC1 是 NES 最常見的 Mapper 之一(~28% 的遊戲),用了一個很特別的串列通訊協議來設定暫存器——每次寫入只傳 1 bit,5 次寫入完成一個 5-bit 的值。

pub struct Mmc1 {
    prg_rom: Vec<u8>,
    prg_ram: [u8; 8192],
    chr: Vec<u8>,
    chr_ram: bool,

    // 5-bit 串列 shift register
    shift: u8,
    shift_count: u8,

    // 內部暫存器
    control: u8,     // 鏡像模式 + PRG/CHR 模式
    chr_bank_0: u8,  // CHR 低 4KB bank
    chr_bank_1: u8,  // CHR 高 4KB bank
    prg_bank: u8,    // PRG bank 選擇
}

寫入協議的實作:

fn write_prg(&mut self, addr: u16, val: u8) {
    match addr {
        0x8000..=0xFFFF => {
            if val & 0x80 != 0 {
                // 高位元 = reset
                self.shift = 0;
                self.shift_count = 0;
                self.control |= 0x0C; // 重置到 PRG mode 3
                return;
            }

            // 每次寫入送 1 bit,LSB first
            self.shift |= (val & 1) << self.shift_count;
            self.shift_count += 1;

            if self.shift_count == 5 {
                let value = self.shift;
                // 用位址的 bits 14-13 決定目標暫存器
                match (addr >> 13) & 0x03 {
                    0 => self.control = value,    // $8000-$9FFF
                    1 => self.chr_bank_0 = value, // $A000-$BFFF
                    2 => self.chr_bank_1 = value, // $C000-$DFFF
                    _ => self.prg_bank = value,   // $E000-$FFFF
                }
                self.shift = 0;
                self.shift_count = 0;
            }
        }
        // ...
    }
}

為什麼 Nintendo 要用這麼迂迴的串列協議?因為 NES 的 CPU data bus 只有 8 bits,但卡匣的寫入信號實際上只有一根線/ROMSEL)。MMC1 用 5 次寫入湊出 5 bits,巧妙地突破了硬體限制。

Control register 控制了 PRG 和 CHR 的 bank switching 模式:

PRG Mode (bits 3-2) $8000-$BFFF $C000-$FFFF
0, 1 32KB 切換(偶數 bank) 同上(奇數 bank)
2 固定第一個 bank 可切換
3(預設) 可切換 固定最後一個 bank
fn read_prg(&self, addr: u16) -> u8 {
    match addr {
        0x8000..=0xBFFF => {
            let bank = match self.prg_mode() {
                0 | 1 => (self.prg_bank as usize & 0xFE) % self.prg_bank_count,
                2 => 0,                                          // 固定第一個
                _ => (self.prg_bank as usize) % self.prg_bank_count,
            };
            let offset = bank * 0x4000 + (addr - 0x8000) as usize;
            self.prg_rom[offset % self.prg_rom.len()]
        }
        0xC000..=0xFFFF => {
            let bank = match self.prg_mode() {
                0 | 1 => ((self.prg_bank as usize & 0xFE) + 1) % self.prg_bank_count,
                2 => (self.prg_bank as usize) % self.prg_bank_count,
                _ => self.prg_bank_count - 1,                    // 固定最後一個
            };
            let offset = bank * 0x4000 + (addr - 0xC000) as usize;
            self.prg_rom[offset % self.prg_rom.len()]
        }
        // ...
    }
}

MMC1 還能動態切換 nametable 的鏡像模式——四種模式用 control register 的低 2 bits 選擇:

fn mirroring(&self) -> Mirroring {
    match self.control & 0x03 {
        0 => Mirroring::SingleScreenLower,
        1 => Mirroring::SingleScreenUpper,
        2 => Mirroring::Vertical,
        _ => Mirroring::Horizontal,
    }
}

使用 MMC1 的知名遊戲:The Legend of Zelda、Metroid、Mega Man 2、Final Fantasy。

3. Mapper 2 & 3(UxROM / CNROM):一個管 PRG、一個管 CHR

UxROM 和 CNROM 是最直覺的 bank switching mapper:

UxROM(Mapper 2):16KB PRG 切換,CHR 用 RAM

  • $8000-$BFFF:可切換的 16KB bank
  • $C000-$FFFF:固定最後一個 bank
  • 寫入 $8000+ 的值 = 目標 bank 編號

CNROM(Mapper 3):PRG 固定,8KB CHR 切換

  • PRG 永遠固定(16KB 鏡像或 32KB)
  • 寫入 $8000+ 的值 = CHR bank 編號
  • CHR 是 ROM,不是 RAM

兩者加起來不到 250 行,但覆蓋了不少經典:Castlevania(UxROM)、Solomon’s Key(CNROM)。

4. Mapper 7(AxROM):32KB 一次切

AxROM 是最豪邁的 mapper——每次切 32KB,整個 $8000-$FFFF 一口氣換掉:

fn read_prg(&self, addr: u16) -> u8 {
    match addr {
        0x8000..=0xFFFF => {
            let offset = self.prg_bank * 0x8000 + (addr - 0x8000) as usize;
            self.prg_rom[offset % self.prg_rom.len()]
        }
        _ => 0,
    }
}

特別的是 AxROM 可以切換 single-screen mirroring——用寫入值的 bit 4 選擇使用 VRAM 的上半還是下半:

fn write_prg(&mut self, addr: u16, val: u8) {
    if let 0x8000..=0xFFFF = addr {
        self.prg_bank = (val as usize & 0x07) % self.prg_bank_count;
        self.mirroring = if val & 0x10 != 0 {
            Mirroring::SingleScreenUpper
        } else {
            Mirroring::SingleScreenLower
        };
    }
}

使用 AxROM 的遊戲:Battletoads、Rare 的早期作品。

5. Mapper 4(MMC3):最複雜的 Mapper

MMC3 是 NES 上最精密的 Mapper 之一——8 個可獨立設定的 bank register、兩種 PRG 模式、CHR inversion、加上一個 scanline IRQ 計數器。約 10% 的 NES 遊戲使用 MMC3。

pub struct Mmc3 {
    prg_rom: Vec<u8>,
    prg_ram: [u8; 8192],
    chr: Vec<u8>,
    chr_ram: bool,

    bank_select: u8,       // 目標暫存器 + 模式控制
    bank_regs: [u8; 8],    // 8 個 bank 暫存器
    mirroring: Mirroring,

    // Scanline IRQ
    irq_latch: u8,
    irq_counter: u8,
    irq_reload: bool,
    irq_enabled: bool,
    irq_pending: bool,
}

Bank Register 的雙重角色

$8000 的寫入同時控制目標選擇模式設定

bit 7: CHR inversion(交換 pattern table 的高低 bank 對應)
bit 6: PRG mode(交換 $8000 和 $C000 的 bank 來源)
bit 2-0: 目標 bank register(R0-R7)
fn write_prg(&mut self, addr: u16, val: u8) {
    match addr {
        0x8000..=0x9FFF => {
            if addr & 1 == 0 {
                self.bank_select = val;        // 選擇目標 + 設定模式
            } else {
                let target = (self.bank_select & 0x07) as usize;
                self.bank_regs[target] = val;  // 寫入選中的暫存器
            }
        }
        // ...
    }
}

8 個 bank register 的角色分配:

Register 正常模式 反轉模式
R0 CHR 2KB @ $0000-$07FF CHR 2KB @ $1000-$17FF
R1 CHR 2KB @ $0800-$0FFF CHR 2KB @ $1800-$1FFF
R2-R5 CHR 1KB @ $1000-$1FFF CHR 1KB @ $0000-$0FFF
R6 PRG 8KB @ $8000(mode 0)或 $C000(mode 1)
R7 PRG 8KB @ $A000(固定)

PRG Bank 映射

MMC3 的 PRG 切換以 8KB 為單位,比 MMC1 的 16KB 更細:

fn prg_offset(&self, addr: u16) -> usize {
    let second_last = self.prg_bank_count - 2;
    let last = self.prg_bank_count - 1;
    let r6 = (self.bank_regs[6] as usize) % self.prg_bank_count;
    let r7 = (self.bank_regs[7] as usize) % self.prg_bank_count;

    let bank = match addr {
        0x8000..=0x9FFF => if self.prg_mode() { second_last } else { r6 },
        0xA000..=0xBFFF => r7,
        0xC000..=0xDFFF => if self.prg_mode() { r6 } else { second_last },
        _ => last, // $E000-$FFFF 永遠是最後一個 bank
    };

    bank * 0x2000 + (addr & 0x1FFF) as usize
}

Mode 0 和 Mode 1 的差別只是 $8000$C000 對調——R6 可以出現在前面或後面。$A000(R7)和 $E000(最後一個 bank)則不受模式影響。

CHR Bank 映射:2KB 和 1KB 混合

CHR 的映射最精巧——8KB 的 CHR 空間被分成 8 個 1KB slot,但 R0 和 R1 各管 2KB(強制對齊到偶數 bank),R2-R5 各管 1KB:

fn chr_offset(&self, addr: u16) -> usize {
    let slot = (addr >> 10) as usize; // 0-7

    let bank = if !self.chr_inversion() {
        match slot {
            0 => (self.bank_regs[0] & 0xFE) as usize, // R0 low
            1 => (self.bank_regs[0] | 0x01) as usize,  // R0 high
            2 => (self.bank_regs[1] & 0xFE) as usize, // R1 low
            3 => (self.bank_regs[1] | 0x01) as usize,  // R1 high
            4 => self.bank_regs[2] as usize,           // R2
            5 => self.bank_regs[3] as usize,           // R3
            6 => self.bank_regs[4] as usize,           // R4
            _ => self.bank_regs[5] as usize,           // R5
        }
    } else {
        // 反轉:R2-R5 和 R0-R1 的位置交換
        match slot {
            0 => self.bank_regs[2] as usize,
            1 => self.bank_regs[3] as usize,
            2 => self.bank_regs[4] as usize,
            3 => self.bank_regs[5] as usize,
            4 => (self.bank_regs[0] & 0xFE) as usize,
            5 => (self.bank_regs[0] | 0x01) as usize,
            6 => (self.bank_regs[1] & 0xFE) as usize,
            _ => (self.bank_regs[1] | 0x01) as usize,
        }
    };

    let bank = bank % self.chr_bank_count;
    bank * 0x0400 + (addr & 0x03FF) as usize
}

& 0xFE| 0x01 是巧妙的 bit manipulation——把 bank 編號強制對齊到偶數(R0 的 low half)然後 +1 得到 high half。

Scanline IRQ:畫到哪一行就中斷

MMC3 獨有的殺手功能是 scanline counter IRQ。PPU 每畫完一條 scanline 就呼叫 clock_scanline(),mapper 遞減計數器,到 0 時觸發 IRQ 中斷:

impl MapperIrq for Mmc3 {
    fn clock_scanline(&mut self) {
        if self.irq_counter == 0 || self.irq_reload {
            self.irq_counter = self.irq_latch;
            self.irq_reload = false;
        } else {
            self.irq_counter -= 1;
        }

        if self.irq_counter == 0 && self.irq_enabled {
            self.irq_pending = true;
        }
    }

    fn take_irq(&mut self) -> bool {
        let pending = self.irq_pending;
        self.irq_pending = false;
        pending
    }
}

遊戲用這個功能做 split-screen 特效

  1. 設定 IRQ latch = 目標 scanline 數
  2. 啟用 IRQ
  3. PPU 畫到那一行時觸發 IRQ
  4. IRQ handler 裡切換 scroll、bank 或其他設定
  5. 結果:畫面上半和下半可以顯示不同的捲動位置或圖案

Super Mario Bros. 3 用這個做狀態列固定在畫面頂部、同時下方的關卡地圖自由捲動。

控制 IRQ 的暫存器:

位址 功能
$C000(偶) 設定 latch 值(目標 scanline 數)
$C001(奇) 設定 reload flag
$E000(偶) 關閉 IRQ + 清除 pending
$E001(奇) 啟用 IRQ

6. Mapper 覆蓋率

六種 Mapper 覆蓋的遊戲數量:

Mapper 名稱 遊戲比例 代表作
0 NROM ~10% Super Mario Bros., Donkey Kong
1 MMC1 ~28% Zelda, Metroid, Mega Man 2
2 UxROM ~11% Castlevania, Contra
3 CNROM ~6% Solomon’s Key, Gradius
4 MMC3 ~10% SMB3, Kirby’s Adventure
7 AxROM ~3% Battletoads
合計 ~68%

剩下的 32% 分散在幾十種少見的 mapper 中,每種只有幾款遊戲使用。

7. 測試策略

每個 Mapper 都有完整的單元測試,用「填充 pattern」來驗證 bank 映射:

fn make_mmc3(prg_8k_banks: usize, chr_1k_banks: usize) -> Mmc3 {
    let mut prg_rom = vec![0u8; prg_8k_banks * 0x2000];
    // 每個 8KB bank 填充自己的 bank 編號
    for bank in 0..prg_8k_banks {
        for i in 0..0x2000 {
            prg_rom[bank * 0x2000 + i] = bank as u8;
        }
    }
    // ...
}

這樣讀出來的值就是 bank 編號——assert_eq!(m.read_prg(0x8000), 5) 直接驗證「$8000 映射到 bank 5」。

六種 Mapper 合計超過 60 個測試案例,涵蓋:

  • 各種 bank switching 模式
  • Bank wrapping(modulo 溢出)
  • 固定 bank vs 可切換 bank
  • CHR ROM 唯讀 vs CHR RAM 可寫
  • 鏡像模式動態切換
  • IRQ 計數器的完整生命週期

總結

Mapper 行數 核心機制
NROM (0) ~140 Address modulo 鏡像
MMC1 (1) ~307 5-bit 串列 shift register
UxROM (2) ~118 直接寫入 = bank 編號
CNROM (3) ~110 直接寫入 = CHR bank 編號
MMC3 (4) ~499 8 register 間接尋址 + scanline IRQ
AxROM (7) ~114 32KB 整塊切換 + 鏡像控制

Mapper 是 NES 架構裡最能體現「硬體創意」的部分。在 CPU 定址空間固定的限制下,卡匣廠商發明了各種巧妙的 bank switching 機制——從 NROM 的「不切」到 MMC1 的「5 次寫入才能設定一個值」到 MMC3 的「8 個 register + scanline 中斷」。每一種 Mapper 都是一個微型硬體設計,在有限的資源內榨出最大的可能性。

參考資源


NES Joypad 模擬器:Strobe 協議、Input Mapping 與完整系統整合

featured.svg

前兩篇我們完成了 NES 的 CPUPPU——能跑指令、能畫畫面,但玩家還沒辦法操作。NES 的控制器看似簡單(就八個按鈕),但它用了一個很優雅的串列協議:CPU 透過 strobe 信號鎖存按鈕狀態,然後逐 bit 讀取——八次讀取拿到八個按鈕,就像一個 8-bit shift register。

這個專案不只實作了 joypad 的硬體協議,還負責把整個 NES 系統串起來——CPU、PPU、Mapper、Joypad 全部接在同一條 bus 上,最後做出一個可以真的玩遊戲的 NES 模擬器。

專案結構

nes-joypad/
├── Cargo.toml
└── src/
    ├── lib.rs              # 模組匯出
    ├── joypad.rs           # Joypad 核心:strobe/shift-register 協議(178 行)
    ├── input.rs            # 輸入映射:鍵盤 + 手把(99 行)
    ├── composite_io.rs     # 複合 Bus I/O 分發器(38 行)
    ├── system.rs           # 完整 NES 系統整合器(147 行)
    └── demos/
        └── play/main.rs    # NES 播放器 demo(98 行)

依賴:nes-cpunes-ppunes-mapper,加上 optional 的 minifb(視窗)和 gilrs(手把)。

NES 控制器的硬體協議

NES 控制器只有 8 個按鈕——A、B、Select、Start、上、下、左、右——但 CPU 不是一次讀到所有按鈕的。它用了一個串列通訊協議

sequenceDiagram participant CPU participant Joypad CPU->>Joypad: 寫入 $4016 = 1(strobe on) Note over Joypad: 持續從 button_state 載入 CPU->>Joypad: 寫入 $4016 = 0(strobe off) Note over Joypad: 鎖存!凍結 shift register CPU->>Joypad: 讀取 $4016 → A CPU->>Joypad: 讀取 $4016 → B CPU->>Joypad: 讀取 $4016 → Select CPU->>Joypad: 讀取 $4016 → Start CPU->>Joypad: 讀取 $4016 → Up CPU->>Joypad: 讀取 $4016 → Down CPU->>Joypad: 讀取 $4016 → Left CPU->>Joypad: 讀取 $4016 → Right CPU->>Joypad: 讀取 $4016 → 1(open bus)

為什麼要這樣設計?因為 NES 的 CPU bus 是 8-bit,但控制器只用了每次讀取的 bit 0。串列協議讓一個 I/O 位址就能讀完 8 個按鈕,節省了寶貴的位址空間。

1. Joypad 核心:四個欄位搞定一切

Joypad 的狀態非常精簡:

pub struct Joypad {
    /// 即時按鈕狀態(bit N = Button N 被按下)
    button_state: u8,
    /// 鎖存快照——strobe 下降沿時凍結
    shift_register: u8,
    /// 下一次讀取要回傳的 bit 位置(0-7,之後回傳 open bus)
    read_index: u8,
    /// strobe 信號狀態
    strobe: bool,
}

按鈕的定義用 enum 對應 shift register 的讀取順序:

#[repr(u8)]
pub enum Button {
    A = 0, B = 1, Select = 2, Start = 3,
    Up = 4, Down = 5, Left = 6, Right = 7,
}

#[repr(u8)] 確保 enum 的值就是 shift register 裡的 bit 位置——A 最先被讀出、Right 最後。

2. Strobe 協議:鎖存的藝術

Strobe 協議的核心在 write_strobe()read_bit()

pub fn write_strobe(&mut self, val: u8) {
    let new_strobe = val & 1 != 0;
    if self.strobe && !new_strobe {
        // Falling edge: 鎖存並重置
        self.shift_register = self.button_state;
        self.read_index = 0;
    }
    self.strobe = new_strobe;
    if self.strobe {
        self.shift_register = self.button_state;
    }
}

pub fn read_bit(&mut self) -> u8 {
    if self.strobe {
        return self.button_state & 1; // strobe 開著時永遠回傳 A 鍵
    }
    if self.read_index < 8 {
        let bit = (self.shift_register >> self.read_index) & 1;
        self.read_index += 1;
        bit
    } else {
        1 // open bus
    }
}

幾個關鍵行為:

狀態 行為
Strobe = 1 持續從 button_state 載入,讀取永遠回傳 A 鍵
Strobe 1→0 鎖存:凍結當前按鈕狀態到 shift register
讀取第 1-8 次 依序回傳 A、B、Select、Start、上、下、左、右
讀取第 9+ 次 回傳 1(open bus,標準控制器行為)

為什麼 strobe 開著時只回傳 A 鍵? 因為在 strobe 模式下,shift register 每次讀取前都會被重新載入——等於 read_index 永遠停在 0,而 bit 0 就是 A 鍵。

3. 雙控制器匯流排:一個 Strobe 控兩隻

NES 支援兩個控制器,但 strobe 信號是共享的——CPU 寫入 $4016 時,兩個控制器同時被 strobe。讀取時則分開:$4016 讀 P1,$4017 讀 P2。

CompositeBusIo 把所有 I/O 設備統一到一條 bus 上:

pub struct CompositeBusIo {
    pub ppu_io: PpuBusIo,
    pub joypad1: Rc<RefCell<Joypad>>,
    pub joypad2: Rc<RefCell<Joypad>>,
}

impl BusIo for CompositeBusIo {
    fn read(&mut self, addr: u16) -> u8 {
        match addr {
            addr::JOYPAD1 => self.joypad1.borrow_mut().read_bit(),
            addr::JOYPAD2 => self.joypad2.borrow_mut().read_bit(),
            _ => self.ppu_io.read(addr),
        }
    }

    fn write(&mut self, addr: u16, val: u8) {
        match addr {
            addr::JOYPAD1 => {
                // Strobe 同時鎖存兩個控制器
                self.joypad1.borrow_mut().write_strobe(val);
                self.joypad2.borrow_mut().write_strobe(val);
            }
            _ => self.ppu_io.write(addr, val),
        }
    }
}

這個 38 行的模組是整個系統的「交通警察」——CPU 的每一次 I/O 讀寫都經過這裡,然後被路由到正確的設備(PPU 或 Joypad)。

4. 輸入映射:鍵盤與手把

模擬器需要把實際的輸入裝置映射到 NES 的 8 個按鈕。鍵盤映射很直覺:

pub fn keyboard_to_buttons(window: &minifb::Window) -> u8 {
    let mut state = 0u8;
    if window.is_key_down(Key::Up)    { state |= 1 << Button::Up as u8; }
    if window.is_key_down(Key::Down)  { state |= 1 << Button::Down as u8; }
    if window.is_key_down(Key::Left)  { state |= 1 << Button::Left as u8; }
    if window.is_key_down(Key::Right) { state |= 1 << Button::Right as u8; }
    if window.is_key_down(Key::Z)     { state |= 1 << Button::A as u8; }
    if window.is_key_down(Key::X)     { state |= 1 << Button::B as u8; }
    if window.is_key_down(Key::A)     { state |= 1 << Button::Select as u8; }
    if window.is_key_down(Key::S)     { state |= 1 << Button::Start as u8; }
    state
}

手把映射多了一個 deadzone 處理——類比搖桿在中心附近的微小偏移要過濾掉,避免誤觸:

// 左搖桿 deadzone = 0.5
if let Some(axis) = gp.axis_data(Axis::LeftStickX) {
    if axis.value() < -0.5 { state |= 1 << Button::Left as u8; }
    if axis.value() > 0.5  { state |= 1 << Button::Right as u8; }
}

最終的 state 是一個 u8 bitfield,可以直接用 |= 合併鍵盤和手把的輸入——兩者任一按下就算按下。

5. 完整 NES 系統整合

System 把所有元件串在一起:

pub struct System {
    pub cpu: Cpu,
    pub ppu: Rc<RefCell<Ppu>>,
    pub joypad1: Rc<RefCell<Joypad>>,
    pub joypad2: Rc<RefCell<Joypad>>,
    cpu_cycles: Rc<Cell<u64>>,
    ppu_cycles: Rc<Cell<u64>>,
}

初始化的接線圖:

graph TD ROM[iNES ROM] --> Mapper[Mapper
bank switching] Mapper --> Bus[CPU Bus] Mapper --> PPU[PPU] Bus --> CPU[6502 CPU] subgraph CompositeBusIo PpuBusIo[PPU Bus I/O
catch-up ticking] JP1[Joypad 1] JP2[Joypad 2] end Bus -->|I/O delegate| CompositeBusIo PpuBusIo --> PPU CPU_CYC[cpu_cycles
Rc Cell u64] -.->|shared| CPU CPU_CYC -.->|shared| PpuBusIo PPU_CYC[ppu_cycles
Rc Cell u64] -.->|shared| PpuBusIo

from_rom() 的關鍵是共享狀態的接線——Mapper、PPU、Joypad 都用 Rc<RefCell<>> 包裝,因為多個元件需要同時存取它們。CPU 和 PPU 的 cycle counter 則用 Rc<Cell<u64>> 做零成本的共享。

初始化結束後,還要把 PPU「追上」CPU 的 reset cycle 數:

cpu.reset();
cpu_cycles.set(cpu.cycles);
let reset_ppu_dots = cpu.cycles * 3;
{
    let mut p = ppu.borrow_mut();
    for _ in 0..reset_ppu_dots {
        p.tick();
    }
}
ppu_cycles.set(reset_ppu_dots);

CPU reset 會消耗 7 個 cycle,PPU 需要追上 7 × 3 = 21 個 dot,確保兩者從一開始就同步。

6. 主迴圈:每幀一次的節奏

Demo 的主迴圈非常乾淨——每幀做三件事:讀取輸入、跑一幀模擬、更新畫面:

window.set_target_fps(60);

while window.is_open() && !window.is_key_down(Key::Escape) {
    // 1. 讀取輸入
    let mut p1_state = keyboard_to_buttons(&window);
    #[cfg(feature = "gamepad")]
    if let (Some(ref mut gilrs), Some(gp_id)) = (&mut gilrs_ctx, gamepad_id) {
        while gilrs.next_event().is_some() {} // drain events
        p1_state |= gamepad_to_buttons(gilrs, gp_id);
    }
    sys.joypad1.borrow_mut().set_buttons(p1_state);

    // 2. 跑一幀
    sys.run_until_frame();

    // 3. 更新畫面
    let ppu = sys.ppu.borrow();
    window.update_with_buffer(&*ppu.framebuffer, WIDTH, HEIGHT)?;
}

run_until_frame() 會一直呼叫 step() 直到 PPU 設定 frame_ready(VBlank 開始),然後把 256×240 的 framebuffer 丟給 minifb 視窗,用 3 倍 scale 顯示(768×720)。

gilrs 的 gamepad 輸入有個小細節:每次讀取前要先 drain 所有待處理的事件(while gilrs.next_event().is_some() {}),否則 gilrs 的內部狀態不會更新。

7. 測試:驗證硬體協議的每個角落

Joypad 的協議雖然簡單,但邊界情況不少。測試覆蓋了所有行為:

#[test]
fn read_order_matches_button_enum() {
    let mut jp = Joypad::new();
    jp.set_buttons(1 << Button::Start as u8);
    jp.write_strobe(1);
    jp.write_strobe(0);

    assert_eq!(jp.read_bit(), 0); // A
    assert_eq!(jp.read_bit(), 0); // B
    assert_eq!(jp.read_bit(), 0); // Select
    assert_eq!(jp.read_bit(), 1); // Start ← 只有這個是 1
    assert_eq!(jp.read_bit(), 0); // Up
    assert_eq!(jp.read_bit(), 0); // Down
    assert_eq!(jp.read_bit(), 0); // Left
    assert_eq!(jp.read_bit(), 0); // Right
}

#[test]
fn open_bus_after_8_reads() {
    let mut jp = Joypad::new();
    jp.write_strobe(1);
    jp.write_strobe(0);
    for _ in 0..8 { jp.read_bit(); }
    assert_eq!(jp.read_bit(), 1); // open bus
    assert_eq!(jp.read_bit(), 1); // 一直是 1
}
測試 驗證行為
strobe_latches_all_buttons 全部按下後 strobe,8 個 bit 都是 1
read_order_matches_button_enum 讀取順序:A→B→Select→Start→↑→↓→←→→
open_bus_after_8_reads 第 9 次以後讀取回傳 1
strobe_high_returns_a_button strobe 開著時只回傳 A 鍵
no_buttons_reads_all_zero 沒按任何鍵,8 次讀取全是 0
multiple_buttons_simultaneously 同時按 A 和 Right,正確回傳

總結

模組 行數 角色 核心 Pattern
joypad.rs ~178 Strobe/shift-register 協議 Falling edge latch + serial read
input.rs ~99 鍵盤/手把映射 Bitfield OR 合併多輸入源
composite_io.rs ~38 Bus I/O 分發器 Address-based routing
system.rs ~147 完整 NES 系統整合 Rc<RefCell<>> 共享 + cycle sync
demos/play ~98 NES 播放器 60 FPS frame loop

Joypad 本身的協議很簡單——四個欄位、幾十行核心邏輯。但這個 crate 真正的意義是把所有東西串起來CompositeBusIo 讓 CPU、PPU、Joypad 共享一條 bus;System 協調它們的時序;最後 demos/play 把這一切變成一個可以拿手把玩的 NES 模擬器。

下一步是 nes-mapper——NES 卡匣上的 bank switching 硬體,讓 32KB 的 CPU 定址空間能跑 512KB 甚至更大的遊戲。

參考資源


NES PPU 模擬器:Catch-Up 同步、VBlank 競態與 Edge-Triggered NMI

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,比畫像素難多了。

參考資源


用 Rust 寫一顆 6502 CPU:Cycle-Accurate NES 模擬器

featured.svg

NES 的心臟是一顆跑在 1.79 MHz 的 6502 CPU——同樣的處理器也驅動了 Apple II、Commodore 64、Atari 2600。這顆 8-bit 處理器只有 3 個通用暫存器、256 bytes 的 stack、64KB 的定址空間,但當年幾乎所有經典遊戲都在這麼小的硬體上跑出來。

這個專案用 Rust 實作了一個 cycle-accurate 的 6502 CPU 模擬器,通過了模擬器界的黃金標準 nestest——包含所有官方和非官方指令的驗證。最後還附帶一個用 Iced 寫的 Snake 小遊戲,讓你看到 6502 組合語言真的在你的桌面上跑。

專案結構

nes-cpu/
├── Cargo.toml
└── src/
    ├── lib.rs            # 模組匯出 + nestest 整合測試
    ├── cpu.rs            # CPU 核心:暫存器、fetch-decode-execute、中斷
    ├── bus.rs            # 記憶體匯流排:RAM 鏡像、I/O 路由、mapper 委派
    ├── opcodes.rs        # 256 格指令表:op! macro + OnceLock
    ├── mapper.rs         # Mapper trait(卡匣硬體抽象)
    ├── ines.rs           # iNES ROM 格式解析器
    └── demos/
        └── snake/main.rs # Snake 遊戲 demo(Iced GUI)

核心依賴非常精簡——只有 anyhow 用在測試,icedrand 是 demo 的 optional feature。CPU 模擬本身零外部依賴。

6502 CPU 101

開始看程式碼之前,先快速認識一下 6502 的基本架構:

┌─────────────────────────────────────────┐
│              6502 CPU                   │
├─────────────────────────────────────────┤
│  A  (Accumulator)     8-bit  算術主力   │
│  X  (Index X)         8-bit  索引/計數  │
│  Y  (Index Y)         8-bit  索引/計數  │
│  SP (Stack Pointer)   8-bit  → $0100-$01FF │
│  PC (Program Counter) 16-bit 下一條指令 │
│  P  (Status Flags)    8-bit  NV-BDIZC  │
└─────────────────────────────────────────┘

Status flags 用 bitmask 表示,在 Rust 裡這樣定義:

pub mod flags {
    pub const C: u8 = 0b0000_0001; // Carry
    pub const Z: u8 = 0b0000_0010; // Zero
    pub const I: u8 = 0b0000_0100; // IRQ Disable
    pub const D: u8 = 0b0000_1000; // Decimal (ignored on 2A03)
    pub const B: u8 = 0b0001_0000; // Break
    pub const U: u8 = 0b0010_0000; // Unused (always 1)
    pub const V: u8 = 0b0100_0000; // Overflow
    pub const N: u8 = 0b1000_0000; // Negative
}

整個 CPU 狀態就這麼一個 struct:

pub struct Cpu {
    pub a: u8,
    pub x: u8,
    pub y: u8,
    pub pc: u16,
    pub sp: u8,
    pub p: u8,
    pub cycles: u64,
    pub bus: Bus,
}

注意 cyclesu64——追蹤從開機以來執行了多少個 cycle,這對 PPU 同步至關重要。

1. Opcode 表:用 Macro 填滿 256 格

6502 的 opcode 是一個 byte(0x00-0xFF),所以剛好 256 種可能。每個 opcode 對應一組資訊:指令類型、定址模式、基本 cycle 數、是否有 page-crossing penalty。

#[derive(Clone, Copy, Debug)]
pub struct Instruction {
    pub kind: OpKind,
    pub mnemonic: &'static str,
    pub mode: AddrMode,
    pub cycles: u8,
    pub page_cross_penalty: bool,
}

OnceLock 做 lazy initialization,確保整個表只建一次:

pub fn get_opcodes() -> &'static [Instruction; 256] {
    use std::sync::OnceLock;
    static TABLE: OnceLock<[Instruction; 256]> = OnceLock::new();
    TABLE.get_or_init(build_table)
}

填表用了一個 op! macro,讓定義看起來非常乾淨:

let mut t = [KIL; 256]; // 預設全部填 KIL(CPU halt)

macro_rules! op {
    ($byte:expr, $kind:expr, $mne:expr, $mode:expr, $cyc:expr) => {
        t[$byte] = Instruction::new($kind, $mne, $mode, $cyc, false);
    };
    ($byte:expr, $kind:expr, $mne:expr, $mode:expr, $cyc:expr, page) => {
        t[$byte] = Instruction::new($kind, $mne, $mode, $cyc, true);
    };
}

// ── LDA ──
op!(0xA9, Lda, "LDA", Immediate,  2);
op!(0xA5, Lda, "LDA", ZeroPage,   3);
op!(0xBD, Lda, "LDA", AbsoluteX,  4, page); // page-crossing +1
op!(0xB1, Lda, "LDA", IndirectY,  5, page);
// ...256 行填完所有指令

這個設計很務實——先把 256 格全部初始化為 KIL(CPU halt),然後逐一覆寫有效的 opcode。未定義的 opcode 自然就是 KIL,不需要額外處理。151 個官方指令加上 105 個非官方指令,剛好把 256 格全部填滿。

2. Fetch-Decode-Execute:CPU 的心跳

整個 CPU 模擬的核心就是 step() 函式——每呼叫一次,執行一條指令:

graph LR A[Fetch Opcode] --> B[查表
256 entries] B --> C[解析地址
12 種模式] C --> D[計算 Cycles
+ page cross] D --> E[執行指令
match OpKind] E --> A
pub fn step(&mut self) -> String {
    let log_line = self.log_state();

    let opcode_byte = self.bus.read(self.pc);
    self.pc = self.pc.wrapping_add(1);

    let instr = get_opcodes()[opcode_byte as usize];
    let (addr, page_crossed) = self.resolve_addr(instr.mode);

    let extra = if instr.page_cross_penalty && page_crossed { 1u64 } else { 0 };
    let cost = instr.cycles as u64 + extra;

    // Add cycle cost BEFORE execute so that PPU catch-up during
    // bus reads sees the correct CPU cycle count.
    self.cycles += cost;

    self.execute(instr, addr);

    log_line
}

幾個值得注意的細節:

  • 先加 cycle 再執行:這不是隨便決定的順序。PPU 在 CPU 讀寫 bus 時會做 catch-up 同步,它需要看到「這條指令已消耗的 cycle 數」才能正確追上。如果先執行再加 cycle,PPU 的時序就會差一點
  • wrapping_add:6502 是 8/16-bit 系統,溢出要 wrap around,不能 panic
  • 查表 O(1):直接用 opcode byte 當 index,不需要 if-else 或 HashMap

3. 定址模式:同一條指令,12 種方式找資料

6502 最有趣的地方之一就是它的 12 種定址模式。同一個 LDA 指令可以從立即值、零頁、絕對位址、甚至間接尋址取得資料。resolve_addr() 負責根據定址模式算出實際地址:

fn resolve_addr(&mut self, mode: AddrMode) -> (u16, bool) {
    match mode {
        AddrMode::Implied | AddrMode::Accumulator => (0, false),

        AddrMode::Immediate => {
            let addr = self.pc;
            self.pc = self.pc.wrapping_add(1);
            (addr, false)
        }

        AddrMode::AbsoluteX => {
            let lo = self.bus.read(self.pc) as u16;
            let hi = self.bus.read(self.pc.wrapping_add(1)) as u16;
            self.pc = self.pc.wrapping_add(2);
            let base = (hi << 8) | lo;
            let addr = base.wrapping_add(self.x as u16);
            let page_crossed = (base & 0xFF00) != (addr & 0xFF00);
            (addr, page_crossed)
        }

        // ...其他 9 種模式
    }
}

回傳值是 (u16, bool) 的 tuple——地址加上「是否跨頁」。跨頁偵測的邏輯很簡單:比較高 byte 有沒有變。

Indirect JMP 的 Page-Wrap Bug

6502 有一個著名的硬體 bug:JMP ($xxFF) 做間接跳躍時,如果指標的低 byte 是 0xFF,高 byte 不是從下一頁($(xx+1)00)讀,而是從同一頁的開頭$xx00)讀(wrap around)。

AddrMode::Indirect => {
    // 6502 page-wrap bug: if low byte of pointer is 0xFF,
    // high byte is read from the SAME page (wraps around).
    let lo = self.bus.read(self.pc) as u16;
    let hi = self.bus.read(self.pc.wrapping_add(1)) as u16;
    self.pc = self.pc.wrapping_add(2);
    let ptr = (hi << 8) | lo;
    let lo2 = self.bus.read(ptr) as u16;
    let hi2 = self.bus.read((ptr & 0xFF00) | ((ptr + 1) & 0x00FF)) as u16;
    ((hi2 << 8) | lo2, false)
}

關鍵在這行:(ptr & 0xFF00) | ((ptr + 1) & 0x00FF)——高 byte 強制保持在同一頁。這不是 bug fix,而是忠實地重現硬體 bug。如果不這樣做,某些依賴這個行為的遊戲就會出錯。

4. Cycle Accuracy:差一個 cycle 就會出 bug

NES 的 PPU 跑在 CPU 的 3 倍速度。如果 CPU 的 cycle 計數不精確,畫面就會撕裂或閃爍。Cycle accuracy 來自三個地方:

基本 cycle 數:每條指令有固定的 cycle 消耗,直接從表裡查。

Page-crossing penalty:當定址跨越 256-byte 的頁邊界時,load/compare 類指令額外花 1 cycle。但 RMW(Read-Modify-Write)指令不受影響——它們的 cycle 數是固定的。

Branch penalty:分支指令的 cycle 計算最精緻:

fn branch(&mut self, condition: bool, offset: u16) {
    if condition {
        let offset_signed = offset as i8 as i32;
        let new_pc = (self.pc as i32 + offset_signed) as u16;
        self.cycles += if (self.pc & 0xFF00) != (new_pc & 0xFF00) { 2 } else { 1 };
        self.pc = new_pc;
    }
}

短短 7 行,包含了三條規則:

狀況 Cycle 數
分支未觸發 2(基本)
分支觸發,同頁 2 + 1 = 3
分支觸發,跨頁 2 + 2 = 4

5. Memory Bus:2KB RAM 變 8KB

NES 只有 2KB 的內部 RAM,但映射到 $0000-$1FFF 整個 8KB 的空間——透過 address masking 做硬體鏡像:

graph TB subgraph "CPU Address Space ($0000-$FFFF)" A["$0000-$07FF
2KB RAM"] B["$0800-$1FFF
RAM 鏡像 ×3
(mask: 0x07FF)"] C["$2000-$3FFF
PPU Registers
(mask: 0x0007)"] D["$4000-$401F
APU + I/O"] E["$6000-$7FFF
PRG-RAM (Mapper)"] F["$8000-$FFFF
PRG-ROM (Mapper)"] end

Bus 的實作用 pattern matching 做 address routing:

pub fn read(&mut self, addr: u16) -> u8 {
    match addr {
        0x0000..=0x1FFF => self.ram[(addr & 0x07FF) as usize],
        0x2000..=0x3FFF => {
            if let Some(io) = &mut self.io {
                io.read(0x2000 + (addr & 0x0007))
            } else {
                0xFF
            }
        }
        0x6000..=0xFFFF => {
            if let Some(m) = &self.mapper {
                m.borrow().read_prg(addr)
            } else {
                0xFF
            }
        }
        _ => 0xFF, // open bus
    }
}

PPU 暫存器只有 8 個($2000-$2007),但映射到 $2000-$3FFF 整個 8KB——同樣是用 mask 0x0007 處理。PPU 和 joypad 的具體讀寫透過 BusIo trait 委派出去,讓 CPU 模組不需要知道 PPU 的實作細節。

另外還有一個 peek() 方法——不觸發副作用的讀取,專門給 log 和 disassembly 用。這很重要,因為讀 PPU status register($2002)會清除 VBlank flag,如果 log 時也觸發這個副作用,就會改變程式行為。

6. Nestest:模擬器界的 Gold Standard

nestest 是一個專門驗證 CPU 正確性的 ROM。它測試所有官方和非官方指令,執行結果會寫入記憶體 $02(官方)和 $03(非官方)——0 代表全部通過。

驗證方式是和一份 reference log 逐行比對:

#[test]
fn nestest() {
    let rom = INesRom::load("roms/nestest.nes").expect("failed to load nestest ROM");
    let bus = Bus::from_rom(rom);
    let mut cpu = Cpu::new(bus);

    let reference_log = std::fs::read_to_string("roms/nestest.log")
        .expect("failed to load nestest log");
    let ref_lines: Vec<&str> = reference_log.lines().collect();
    let mut our_lines: Vec<String> = Vec::new();

    loop {
        let log_line = cpu.step();
        our_lines.push(log_line);
        if cpu.pc == 0xC66E { break; }
    }

    // 檢查通過條件
    let official = cpu.bus.read(0x0002);
    let unofficial = cpu.bus.read(0x0003);
    assert_eq!(official, 0, "official opcodes failed: ${official:02X}");
    assert_eq!(unofficial, 0, "unofficial opcodes failed: ${unofficial:02X}");

    // 逐行比對 log
    for (i, (ours, reference)) in our_lines.iter().zip(ref_lines.iter()).enumerate() {
        assert!(cpu_columns_match(ours, reference),
            "log mismatch at line {}:\n  OURS: {}\n  REF:  {}", i + 1, ours, reference);
    }
}

Log 的格式長這樣——和 nestest 官方格式完全相同:

C000  4C F5 C5  JMP $C5F5                       A:00 X:00 Y:00 P:24 SP:FD PPU:  0, 21 CYC:7
C5F5  A2 00     LDX #$00                        A:00 X:00 Y:00 P:24 SP:FD PPU:  0, 30 CYC:10

每行包含 PC、raw bytes、反組譯、暫存器值、PPU 座標和 cycle 數。比對時忽略 PPU 欄位(因為精確的 PPU timing 是 nes-ppu 的責任)。

7. Snake Demo:6502 Assembly 在桌面跑

為了證明 CPU 模擬器真的能跑東西,專案附了一個 Snake 遊戲——309 bytes 的 6502 機器碼,用 Iced GUI 框架顯示:

// 309 bytes 的 Snake 遊戲機器碼
const SNAKE_ROM: [u8; 309] = [
    0x20, 0x06, 0x06, 0x20, 0x38, 0x06, 0x20, 0x0d, 0x06, ...
];

遊戲透過 memory-mapped I/O 運作:

  • $FF:最後一次按鍵的 ASCII 碼(w/a/s/d)
  • $FE:亂數產生器(host 每個 tick 寫入)
  • $0200-$05FF:32×32 的螢幕記憶體,每個 byte 代表一個像素的顏色

Game loop 很簡單——每個 tick 寫入輸入和亂數,跑 100 步 CPU:

self.cpu.bus.write(0xFF, last_key);
self.cpu.bus.write(0xFE, rng.gen_range(1..16));

for _ in 0..STEPS_PER_TICK {
    self.cpu.step();
    if self.cpu.bus.read(self.cpu.pc) == 0x00 { // BRK = game over
        self.game_over = true;
        break;
    }
}

然後從 $0200-$05FF 讀出每個像素的值,畫到 Iced 的 Canvas 上。這個 demo 完美展示了模擬器的本質——host 提供 I/O 介面,guest code(6502 assembly)透過記憶體映射和 host 溝通。

總結

模組 行數 角色 核心 Pattern
cpu.rs ~825 CPU 核心:暫存器、fetch-decode-execute、中斷 match OpKind dispatch
opcodes.rs ~490 256 格指令表 OnceLock + op! macro
bus.rs ~230 記憶體匯流排:RAM 鏡像、I/O 路由 BusIo trait + address masking
ines.rs ~160 iNES ROM 解析器 Header validation + bank extraction
mapper.rs ~35 Mapper trait 定義 Mapper + MapperIrq traits
demos/snake ~210 Snake 遊戲 demo Memory-mapped I/O + canvas::Program

下一步:PPU 與完整的 NES

CPU 只是 NES 的一半。下一個專案 nes-ppu 會實作 PPU(Picture Processing Unit),然後用一個 System struct 把 CPU 和 PPU 串起來——PPU 跑 CPU 的 3 倍速度,每次 CPU 讀寫 PPU 暫存器時要做 catch-up 同步。那才是真正刺激的部分。

參考資源


用 Iced 打造漫畫閱讀器:Arc<dyn Trait>、LRU 快取與非同步預載

featured.svg

最近用 Iced 實作了一個漫畫閱讀器——能打開 CBZ、CB7 格式的漫畫壓縮檔,支援鍵盤翻頁,還有背景預載機制讓翻頁幾乎感受不到延遲。

這篇文章聚焦在幾個有趣的設計決策:trait object 搭配 Arc 的多格式抽象、以「距離」驅逐的 LRU 快取、以及 smol::unblock 把同步 I/O 推進執行緒池的技巧。

專案概述

comic-viewer 支援的功能:

  • 開啟 CBZ(ZIP)、CB7(7-Zip)格式漫畫檔
  • CBR(RAR)格式顯示友善的錯誤訊息(需外部函式庫,目前為 stub)
  • 鍵盤翻頁(方向鍵、PageUp/PageDown)
  • 頁面快取(最多 7 頁),快取命中時翻頁瞬間完成
  • 背景預載:目前頁面前後各 2 頁
  • Tokyo Night Storm 主題的深色 UI

專案結構很清楚:

comic-viewer/
├── Cargo.toml
└── src/
    ├── main.rs          # Iced 應用程式主體(App、Message、PageCache)
    └── reader/
        ├── mod.rs       # ComicReader trait + open() 工廠函式
        ├── cbz.rs       # ZIP 格式實作
        ├── cb7.rs       # 7-Zip 格式實作
        └── cbr.rs       # RAR stub

1. Arc<dyn ComicReader>:多格式的 Trait Object 抽象

不同壓縮格式的解壓縮方式差異很大,但對應用程式來說只需要「給我第 N 頁的圖片」。ComicReader trait 完美地封裝這個差異:

pub trait ComicReader: Send + Sync + std::fmt::Debug {
    fn title(&self) -> &str;
    fn page_count(&self) -> usize;
    fn extract_page(&self, index: usize) -> Result<iced::widget::image::Handle, String>;
}

幾個設計細節值得注意:

  • Send + Sync:因為 App.comicOption<Arc<dyn ComicReader>>,而 Arc<T> 要求 T: Send + Sync 才能跨執行緒共享。背景預載任務會 clone 這個 Arc 並在 smol 的執行緒池跑,所以必須是 Sync
  • &self 而非 &mut selfextract_page 刻意設計成不可變借用,這樣就能從多個執行緒同時呼叫,不需要加鎖
  • std::fmt::Debug:讓整個 App struct 可以 derive Debug(Iced 有時會要求)

工廠函式用副檔名做格式分派:

pub fn open(path: &Path) -> Result<Box<dyn ComicReader>, String> {
    match path
        .extension()
        .and_then(|e| e.to_str())
        .map(str::to_lowercase)
        .as_deref()
    {
        Some("cbz") => cbz::CbzReader::open(path).map(|r| Box::new(r) as Box<dyn ComicReader>),
        Some("cbr") => cbr::CbrReader::open(path).map(|r| Box::new(r) as Box<dyn ComicReader>),
        Some("cb7") => cb7::Cb7Reader::open(path).map(|r| Box::new(r) as Box<dyn ComicReader>),
        ext => Err(format!("Unsupported format: {}", ext.unwrap_or("none"))),
    }
}

呼叫端接到 Box<dyn ComicReader> 後立刻包進 Arc::from(box),讓後續可以廉價 clone 給多個背景任務:

Task::perform(
    async move { smol::unblock(move || reader::open(&path).map(Arc::from)).await },
    Message::ComicLoaded,
)

Arc::from(box)Box<dyn ComicReader> 轉成 Arc<dyn ComicReader>——這個轉換是零成本的,只是改變了「誰擁有這塊記憶體的所有權計數器」。

2. CBZ 與 CB7 的不同解壓策略

ZIP 和 7-Zip 的壓縮原理不同,導致兩種 reader 的實作策略截然不同。

CBZ(ZIP):隨機存取,按需解壓

ZIP 格式的每個檔案是獨立壓縮的,可以不看前面的 entry 直接跳到任意 entry 開始解壓。CbzReader 利用這個特性,只在記憶體中保留整份 .cbz 的原始位元組,每次 extract_page 再用 Cursor 包起來重新開一個 ZipArchive

#[derive(Debug)]
pub struct CbzReader {
    title: String,
    pages: Vec<String>,      // 排序後的圖片檔名列表
    archive_bytes: Vec<u8>,  // 整份 .cbz 的原始位元組
}

impl ComicReader for CbzReader {
    fn extract_page(&self, index: usize) -> Result<image::Handle, String> {
        let filename = self.pages.get(index)
            .ok_or_else(|| format!("Page index {index} out of bounds"))?;
        let cursor = std::io::Cursor::new(&self.archive_bytes);
        let mut archive = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
        let mut file = archive.by_name(filename).map_err(|e| e.to_string())?;
        let mut bytes = Vec::new();
        file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
        Ok(image::Handle::from_bytes(bytes))
    }
}

每次 extract_page 都重新建立 ZipArchive,看似浪費,但因為 ZipArchive::new 只是 parse ZIP 的 central directory(在檔案末尾,很快),解壓單頁的成本主要在 inflate,整體還是很快的。

CB7(7-Zip):區塊壓縮,開檔時全部解壓

7-Zip 使用 LZMA/LZMA2 區塊壓縮,整個壓縮包的資料是相互依賴的,沒辦法隨機存取單一 entry。Cb7Reader 乾脆在 open() 時一次解壓全部圖片,把位元組存進記憶體:

#[derive(Debug)]
pub struct Cb7Reader {
    title: String,
    pages: Vec<Vec<u8>>,  // 每頁的原始圖片位元組,開檔時全部解壓好
}

impl Cb7Reader {
    pub fn open(path: &Path) -> Result<Self, String> {
        let mut reader = SevenZReader::open(path, Password::empty())
            .map_err(|e| e.to_string())?;

        let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
        reader.for_each_entries(|entry, reader| {
            if entry.is_directory() || !super::is_image_file(entry.name()) {
                return Ok(true); // 跳過,繼續
            }
            let mut bytes = Vec::new();
            reader.read_to_end(&mut bytes).map_err(sevenz_rust::Error::from)?;
            entries.push((entry.name().to_owned(), bytes));
            Ok(true)
        }).map_err(|e| e.to_string())?;

        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
        // ...
    }
}

開檔比 CBZ 慢(要解壓全部內容),但 extract_page 就只是 .clone() 一個 Vec<u8>,非常快。

這是一個典型的「把開銷前移」設計:把慢的動作(解壓縮)移到啟動時,換取後續每次存取都接近零成本。

CBR(RAR):誠實的 Stub

RAR 格式需要 unrar native library,不是純 Rust 可以輕鬆搞定的。CbrReader 選擇一個直白但很務實的做法:

impl CbrReader {
    pub fn open(_path: &Path) -> Result<Self, String> {
        Err(
            "CBR (RAR) format is not yet supported. \
             Consider converting to CBZ using Calibre or 7-Zip."
                .to_string(),
        )
    }
}

open() 時就立刻回傳清楚的錯誤和建議,不讓使用者走到一半才踩雷。這樣的 stub 還有個好處:整個程式的型別系統是完整的,未來只需要填充 CbrReader::open 的實作就好,其餘不用改。

3. 以「距離」驅逐的 LRU 快取

傳統 LRU 快取驅逐最久未使用的項目,但對漫畫閱讀器來說「最久未使用」不是最好的策略——你更想保留目前頁面周圍的頁面,而不是剛才看完的頁面。

PageCache 改用「距離目前頁面最遠的項目優先驅逐」:

const CACHE_CAPACITY: usize = 7;

struct PageCache {
    entries: HashMap<usize, image::Handle>,
}

impl PageCache {
    fn insert(&mut self, index: usize, handle: image::Handle, current_page: usize) {
        if !self.entries.contains_key(&index) && self.entries.len() >= CACHE_CAPACITY {
            // 找距離 current_page 最遠的 key
            let evict = *self
                .entries
                .keys()
                .max_by_key(|&&k| k.abs_diff(current_page))
                .expect("cache is non-empty");
            self.entries.remove(&evict);
        }
        self.entries.insert(index, handle);
    }
}

usize::abs_diff 計算兩個 usize 的絕對差值,不需要轉成有號整數,在 Rust 1.60 加入標準函式庫。

有一個借用技巧值得注意:

// usize is Copy,所以可以在可變借用前先把 key 複製出來
let evict = *self
    .entries
    .keys()
    .max_by_key(|&&k| k.abs_diff(current_page))
    .expect("cache is non-empty");
self.entries.remove(&evict);  // 這裡才開始可變借用

如果寫成 let evict_ref = self.entries.keys().max_by_key(...) 然後直接傳給 remove,借用檢查器會拒絕——因為 keys() 返回的 iterator 持有對 self.entries 的不可變借用,而 remove 需要可變借用。提前 * 解引用並把 usize(Copy 型別)複製出來,借用就在那一行結束了。

為什麼 capacity 選 7?前後各 2 頁(4 頁)加上目前頁面(1 頁)= 5,多留 2 頁緩衝,讓快速翻頁時舊頁面還在快取裡。

持有 image::Handle 的好處是:Iced 會把解碼後的圖片上傳到 GPU 紋理,而 Handle 是對那份紋理的引用(Arc 語義)。只要 Handle 活著,GPU 上的資料就不會被回收——快取命中時翻頁是真的零解碼。

4. Iced 的 MVU 架構與 Task 系統

Iced 採用 Elm 架構(Model-View-Update,MVU)。整個應用程式的運作流程是:

graph LR A[用戶操作 / 事件] -->|產生| B[Message] B -->|傳入| C[update 函式] C -->|修改| D[App 狀態] C -->|回傳| E[Task] E -->|完成後產生| B D -->|傳入| F[view 函式] F -->|產生| G[Element UI 樹] G -->|渲染到螢幕| A

Message enum 是所有事件的完整清單:

#[derive(Debug, Clone)]
enum Message {
    OpenFile,
    FileSelected(Option<PathBuf>),
    ComicLoaded(Result<Arc<dyn reader::ComicReader>, String>),
    NextPage,
    PrevPage,
    PagePreloaded(usize, Option<image::Handle>),
}

Task<Message> 是 Iced 的非同步工作單元。update 函式回傳 Task,Iced 的 runtime 會執行它,完成後把結果包成 Message 再餵回 update。這讓狀態轉換邏輯永遠是同步、純粹的,而非同步副作用完全由 Task 處理。

開檔的流程就是兩個 Task 串接:

// 第一步:呼叫原生對話框
Message::OpenFile => {
    Task::perform(
        async {
            rfd::AsyncFileDialog::new()
                .add_filter("Comic Book Archive", &["cbz", "cbr", "cb7"])
                .pick_file()
                .await
                .map(|f| f.path().to_owned())
        },
        Message::FileSelected,  // 完成後產生 FileSelected(Option<PathBuf>)
    )
}
// 第二步:在執行緒池解壓縮並建立 Reader
Message::FileSelected(Some(path)) => Task::perform(
    async move { smol::unblock(move || reader::open(&path).map(Arc::from)).await },
    Message::ComicLoaded,  // 完成後產生 ComicLoaded(Result<Arc<...>, String>)
),

5. smol::unblock:把同步 I/O 推進執行緒池

Iced 的 async runtime 是基於 futures-executor(smol 生態系),不是 Tokio——從 Cargo.lock 可以確認整個依賴樹裡根本沒有 tokio。但不管底層是哪套 executor,async 執行緒都不適合跑耗時的同步運算(如解壓縮)。如果直接在 async task 裡呼叫 zip::ZipArchive::new(),會阻塞整個執行緒,造成 UI 卡頓。

smol::unblock 是解法:把一個同步的閉包包起來,丟進 smol 的阻塞執行緒池(blocking thread pool)跑,並回傳一個 Future

// 背景預載的單頁提取
let comic = Arc::clone(comic);
Task::perform(
    async move {
        let handle = smol::unblock(move || comic.extract_page(idx).ok()).await;
        (idx, handle)
    },
    |(idx, handle)| Message::PagePreloaded(idx, handle),
)

smol::unblock 的原理很直觀:它維護一個獨立的執行緒池,專門用來跑阻塞工作。move || 閉包在那個執行緒上同步執行,.await 等它完成,結果就像一個普通的 Future

這裡 Arc::clone(comic) 非常關鍵——每個預載任務需要獨立持有對 ComicReader 的引用,才能在自己的執行緒上呼叫 extract_page,而且這份 clone 是 O(1) 的引用計數遞增,不是深複製。

6. 預載窗口與 Task::batch

preload_adjacent 計算出需要預載的頁面集合,然後用 Task::batch 同時啟動所有任務:

fn preload_adjacent(&self, around: usize) -> Task<Message> {
    let Some(comic) = &self.comic else { return Task::none(); };
    let page_count = comic.page_count();

    let start = around.saturating_sub(PRELOAD_LOOKAHEAD);  // 避免 usize 下溢
    let end = (around + PRELOAD_LOOKAHEAD).min(page_count - 1);

    let candidates: Vec<usize> = (start..=end)
        .filter(|&i| i != around && !self.page_cache.contains(i))
        .collect();

    if candidates.is_empty() {
        return Task::none();
    }

    let tasks: Vec<Task<Message>> = candidates
        .into_iter()
        .map(|idx| {
            let comic = Arc::clone(comic);
            Task::perform(
                async move {
                    let handle = smol::unblock(move || comic.extract_page(idx).ok()).await;
                    (idx, handle)
                },
                |(idx, handle)| Message::PagePreloaded(idx, handle),
            )
        })
        .collect();

    Task::batch(tasks)  // 並行啟動所有預載任務
}

saturating_sub 是個小細節:aroundusize,如果 around < PRELOAD_LOOKAHEAD 直接相減會 panic(或在 debug 模式 panic,release 模式則 wrap around)。saturating_sub 讓結果最低為 0,不需要額外的 if around >= PRELOAD_LOOKAHEAD 判斷。

Task::batch 把多個 Task 打包成一個,Iced 會並行執行它們。這意味著如果需要預載頁面 4 和頁面 6,兩個解壓縮任務會同時在執行緒池跑,而不是串行。

7. on_press_maybe:條件式按鈕啟用

Iced 提供了一個很方便的 API 來處理「條件式可按」的按鈕:

let can_prev = self.current_page > 0;
let can_next = self
    .comic
    .as_ref()
    .is_some_and(|c| self.current_page + 1 < c.page_count());

let prev_btn = button(text("◄ Previous").size(14))
    .on_press_maybe((has_comic && can_prev).then_some(Message::PrevPage));

let next_btn = button(text("Next ►").size(14))
    .on_press_maybe((has_comic && can_next).then_some(Message::NextPage));

on_press_maybe 接受 Option<Message>Some(msg) 代表按鈕可按,None 代表禁用。搭配 bool::then_sometrue 回傳 Some(value)false 回傳 None),可以非常簡潔地表達「有符合條件才能按」。

禁用狀態的視覺樣式也透過 nav_button_style 函式裡的 button::Status::Disabled 分支處理:

fn nav_button_style(_theme: &Theme, status: button::Status) -> button::Style {
    let bg = match status {
        button::Status::Hovered | button::Status::Pressed => iced::color!(0x565f89),
        button::Status::Disabled => iced::color!(0x292e42),  // 暗色,表示禁用
        _ => iced::color!(0x414868),
    };
    let text_color = match status {
        button::Status::Disabled => iced::color!(0x3d4168),  // 文字也變暗
        _ => iced::color!(0xc0caf5),
    };
    // ...
}

8. Subscription:鍵盤事件的全域監聽

Subscription 是 Iced 中持續監聽某個事件來源的機制,不同於 Task(一次性任務),Subscription 會在應用程式整個生命週期都保持活躍。

鍵盤翻頁用 iced::keyboard::on_key_press 建立訂閱:

fn subscription(&self) -> Subscription<Message> {
    iced::keyboard::on_key_press(|key, _modifiers| match key.as_ref() {
        iced::keyboard::Key::Named(Named::ArrowRight)
        | iced::keyboard::Key::Named(Named::PageDown) => Some(Message::NextPage),
        iced::keyboard::Key::Named(Named::ArrowLeft)
        | iced::keyboard::Key::Named(Named::PageUp) => Some(Message::PrevPage),
        _ => None,
    })
}

閉包接受按鍵和修飾鍵,回傳 Option<Message>Some(msg) 代表這個按鍵要觸發對應的 message,None 代表忽略。這讓翻頁鍵(方向鍵 + PageUp/PageDown)不需要點擊按鈕就能使用,閱讀體驗更自然。

key.as_ref() 是必要的——iced::keyboard::Key 包含 owned 的字元資料,.as_ref() 轉成借用版本才能做 pattern matching 而不移動所有權。

總結

概念 在專案中的應用
Arc<dyn Trait> ComicReader 跨執行緒共享,零成本 clone 給預載任務
Send + Sync bound 確保 trait object 可安全跨執行緒使用
&self on trait method 允許多執行緒並發呼叫 extract_page,不需鎖
按需解壓 vs 全量預載 ZIP 隨機存取 vs 7-Zip 區塊壓縮,各自最佳策略
距離驅逐快取 比傳統 LRU 更適合線性翻頁場景
abs_diff + Copy 借用技巧 規避借用檢查器限制的慣用寫法
Task + Task::batch Iced 的非同步副作用模型,並行預載
smol::unblock 把同步阻塞 I/O 推進獨立執行緒池
on_press_maybe 條件式按鈕啟用,搭配 then_some 很簡潔
Subscription 全域鍵盤事件監聽,生命週期同整個應用程式

漫畫閱讀器是一個很好的 Rust GUI 練習專案:需要處理多種檔案格式(trait object 的最佳用武之地)、需要快取和預載(資料結構設計)、需要在不卡 UI 的前提下做 I/O(非同步設計)。如果你正在學 Iced 的 MVU 架構,這個專案的規模剛好——不會太小而沒有學習點,也不會大到難以消化。

參考資源