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 都是一個微型硬體設計,在有限的資源內榨出最大的可能性。

參考資源