Simply Patrick

Bevy 與 Rust 的共舞:所有權如何塑造遊戲引擎的設計

featured.svg

寫完 NES 模擬器之後,我一直在想一件事:Bevy 用起來跟我過去用過的任何遊戲引擎都不一樣。不是 API 風格的差異——是思考方式根本不同。

用 Unity 寫 C#,你把腳本掛到 GameObject 上,裡面 GetComponent<T>() 隨便抓、隨便改。用 Godot 寫 GDScript,一切都是 Node 樹,$Child.property = value 直覺到不行。但 Bevy?你連「把兩個系統同時存取同一個資源」都得在型別層面交代清楚,不然編譯器直接擋下來。

這不是 Bevy 故意刁難你。是 Rust 的所有權系統逼著 Bevy 必須這樣設計——而這個「被逼的」結果,意外地產出了一個非常乾淨的架構。

這篇文章用 NES 模擬器前端(~2600 行 Rust)作為實例,聊聊 Rust 的所有權、借用和型別系統是怎麼一路影響 Bevy 的 ECS 設計,以及在實際專案裡會碰到哪些有趣的 pattern。

ECS 不只是 Pattern,是 Rust 的必然

傳統 OOP 遊戲引擎的痛

在傳統 OOP 引擎裡,物件之間到處互相引用:

Player → Weapon → Inventory → Player (循環引用)
Enemy → Player (觀察目標)
GameManager → Player, Enemy, UI, ... (上帝物件)

C# 和 Java 有 GC 幫你收拾,循環引用不是問題。但 Rust 沒有 GC,Player 不能隨便持有 &mut Weapon 的引用——因為 Rust 的借用規則說:同一時間只能有一個可變引用

所以你如果在 Rust 裡用 OOP 寫遊戲引擎,馬上就會遇到一堆 lifetime 地獄:

struct Player<'a> {
    weapon: &'a mut Weapon,  // ← lifetime 要標
}

struct World<'a> {
    player: Player<'a>,
    weapons: Vec<Weapon>,     // ← player.weapon 借自這裡?
    // 🔴 weapons 和 player 不能同時存在於 World 裡
}

ECS 的出現剛好解決了這個問題——不是因為它是什麼偉大的設計理念,而是因為它天然地符合 Rust 的所有權模型

ECS 如何繞開所有權地獄

ECS 的資料佈局長這樣:

World
├── 實體表 (Entity): [0, 1, 2, ...]
├── 元件儲存 (Components):
│   ├── Position:  [Some(pos0), None, Some(pos2), ...]
│   ├── Velocity:  [Some(vel0), None, None, ...]
│   └── Health:    [None, Some(hp1), Some(hp2), ...]
└── 資源 (Resources):
    ├── Time
    ├── InputState
    └── ...

每種元件是一個獨立的陣列。系統(System)透過 Query 宣告它需要哪些元件的存取權限:

fn movement_system(mut query: Query<(&mut Position, &Velocity)>) {
    for (mut pos, vel) in &mut query {
        pos.x += vel.x;
        pos.y += vel.y;
    }
}

對 Rust 來說,這個 Query 等於是說「我要可變借用 Position 陣列,同時不可變借用 Velocity 陣列」——沒有重疊,沒有衝突,borrow checker 開心,世界和平。

Bevy 的排程器利用這些 Query 的存取宣告來自動判斷哪些系統可以平行執行。如果系統 A 只讀 Position,系統 B 只寫 Velocity,它們不衝突,可以丟到不同執行緒上跑。

這就是 Rust 的所有權模型帶來的附加價值:你為了滿足編譯器而寫的型別宣告,同時也成了排程器的平行化依據。

當所有權撞上硬體模擬

NES 的硬體元件之間有大量的共享狀態——CPU 要讀寫 PPU 的暫存器、APU 的暫存器、Joypad 的暫存器,PPU 和 CPU 共用同一個 Mapper(卡匣晶片)。

用 OOP 的思維畫出來大概是:

graph LR CPU -->|讀寫暫存器| PPU CPU -->|讀寫暫存器| APU CPU -->|讀寫暫存器| JOY[Joypad] CPU -->|讀寫 ROM/RAM| MAP[Mapper] PPU -->|讀取 CHR| MAP APU -->|DMC 取樣| CPU

在 C/C++ 的模擬器裡,這種關係用指標隨便串就好。但在 Rust 裡?CPU 不能同時持有 PPU、APU、Mapper 的 &mut 引用——因為它們都被 System struct 擁有,而 Rust 不允許對同一個 struct 的多個欄位同時取得可變引用(透過方法呼叫時)。

Rc<RefCell<>>:單執行緒的共享擁有權

解法是 Rc<RefCell<>>——Rust 標準庫提供的「引用計數 + 執行期借用檢查」組合:

pub struct System {
    pub cpu: Cpu,                           // CPU 獨佔擁有
    pub ppu: Rc<RefCell<Ppu>>,              // 共享擁有
    pub apu: Rc<RefCell<Apu>>,              // 共享擁有
    pub joypad1: Rc<RefCell<Joypad>>,       // 共享擁有
    cpu_cycles: Rc<Cell<u64>>,              // 共享計數器
    ppu_cycles: Rc<Cell<u64>>,              // 共享計數器
}

為什麼 CPU 是直接擁有,其他都是 Rc<RefCell<>>?因為 CPU 的 Bus(記憶體匯流排)裡面也需要持有 PPU、APU 的引用——當 CPU 執行 STA $2000 這種指令時,Bus 要把資料轉發給 PPU 暫存器。所以 PPU 同時被 System Bus 持有——這就是共享擁有權:

System ──owns──→ Rc<RefCell<Ppu>> ←──clone────┐
                                              │
CPU ──owns──→ Bus ──owns──→ PpuBusIo ────owns─┘

Rc::clone() 不複製資料,只增加引用計數。RefCell 在你 borrow_mut() 時做執行期檢查——如果已經有人借了,直接 panic。

Drop-and-Reborrow:和借用檢查器跳舞

最精彩的 pattern 出現在 APU 的 DMC(Delta Modulation Channel)。DMC 播放聲音時需要從 CPU 的記憶體匯流排讀取 sample data——但此時 APU 已經被借用了:

fn step(&mut self) {
    // ... 執行 CPU 指令 ...

    // 借用 APU 來 tick
    let mut apu = self.apu.borrow_mut();
    for _ in 0..total_cpu_cycles {
        apu.tick();
    }

    // DMC 需要從 CPU bus 讀資料
    if let Some(addr) = apu.dmc_sample_request() {
        drop(apu);  // ← 先還回去!
        let val = self.cpu.bus.read(addr);  // 現在可以用 Bus 了
        let mut apu = self.apu.borrow_mut();  // 重新借
        apu.dmc_load_sample(val);
    }
}

看到那個 drop(apu) 了嗎?如果不手動 drop,apu 的可變借用還活著,而 self.cpu.bus.read(addr) 裡面 Bus 的 ApuCompositeBusIo 也會嘗試 borrow_mut() APU——兩個可變借用同時存在,RefCell 直接 panic。

這種「先還再借」的模式在 C++ 裡完全不需要——你隨時都能透過指標存取任何東西。但 Rust 強迫你明確管理每一段借用的生命週期,結果是每個存取路徑都清清楚楚,不會有「誰在什麼時候改了什麼」的幽靈 bug。

Cell vs RefCell:Copy 型別的捷徑

注意 cycle counter 用的是 Rc<Cell<u64>> 而不是 Rc<RefCell<u64>>

// Cell — 直接 get/set,沒有借用追蹤
self.cpu_cycles.set(self.cpu.cycles);
let ppu_cycles = self.ppu_cycles.get();

// RefCell — 需要 borrow/borrow_mut,有執行期開銷
let mut ppu = self.ppu.borrow_mut();
ppu.tick();

Cell<T> 只能用在實作 Copy 的型別上(數字、bool 等),但好處是完全沒有借用追蹤的開銷——直接覆寫值。在每 CPU cycle 都要更新的計數器上,這個小優化累積起來是有意義的。

Bevy 的型別系統魔法

NonSendMut:「我知道我不安全」

NES 模擬器核心用了 Rc<RefCell<>>,而 Rc 不是 Send(不能安全地跨執行緒傳遞)。Bevy 預設會在多執行緒上排程系統——所以直接把 NesSystem 塞進 Res<> 會編譯失敗:

// 🔴 編譯錯誤:NesSystem 不是 Send
fn my_system(nes: ResMut<NesSystem>) { ... }

解法是 NonSendMut——Bevy 提供的特殊存取器,告訴排程器「這個系統只能在主執行緒上跑」:

// ✅ 編譯通過:Bevy 保證只在主執行緒執行
fn run_emulation_frame(
    mut nes: NonSendMut<NesSystem>,
    nes_input: Res<NesInput>,
    audio_buf: Res<AudioBuffer>,
    // ...
) {
    nes.sys.joypad1.borrow_mut().set_buttons(nes_input.0);
    nes.sys.run_until_frame();
}

這是 Rust 型別系統最美妙的地方之一:限制不是用文件或約定來表達,而是用型別。你不需要在 README 寫「注意:這個函式只能在主執行緒呼叫」——如果有人把它改成 Res<NesSystem>,編譯器就會阻止。

跨執行緒的音訊:Arc<Mutex<>>

音訊 buffer 需要在模擬執行緒(producer)和 cpal 音訊執行緒(consumer)之間共享。這裡不能用 Rc<RefCell<>>——必須用跨執行緒安全的 Arc<Mutex<>>

#[derive(Resource, Clone)]
pub struct AudioBuffer {
    pub buffer: Arc<Mutex<VecDeque<f32>>>,
}

Rust 不會讓你把 Rc<RefCell<>> 傳給另一個執行緒——如果你試著這樣做,編譯器會直接告訴你「Rc 不是 Send」。所以你被迫根據使用情境選擇正確的共享方式:

場景 型別 原因
NES 核心(單執行緒) Rc<RefCell<>> 不需要原子操作的開銷
音訊 buffer(跨執行緒) Arc<Mutex<>> 需要 Send + 互斥鎖
cycle 計數器(單執行緒、Copy) Rc<Cell<>> 最低開銷

在 C++ 裡,你可能會「為了安全」統一用 std::shared_ptr + std::mutex,但代價是不需要鎖的地方也付了鎖的成本。Rust 的型別系統讓你精確地只在需要的地方付出代價。

Exclusive System:獨佔整個世界

初始化模擬器時,我們需要把 NesSystem(非 Send)插入 Bevy 的 World——這需要直接存取 World:

pub fn setup_emulation(world: &mut World) {
    let rom_path = world.resource::<RomPath>();
    let rom = std::fs::read(&rom_path.0).expect("Failed to read ROM");
    let sys = System::new(rom, sample_rate);

    world.insert_non_send_resource(NesSystem {
        sys,
        paused: false,
        accumulator: 0.0,
    });
}

&mut World 是一個 exclusive system——它獨佔整個 World 的存取權限,不能和其他系統平行跑。Bevy 用這個來處理「需要完全控制權」的初始化邏輯。

在函式簽名裡你就能看出意圖:

  • world: &mut World → 「我要獨佔一切,請讓所有人都先停下來」
  • nes: NonSendMut<NesSystem> → 「我只要這一個資源,但我只能在主執行緒」
  • input: Res<NesInput> → 「我只讀這個資源,隨便誰跟我同時跑都行」

型別本身就是系統的平行化說明書。

Plugin:模組化的秘密武器

Bevy 的 Plugin trait 讓你把相關的系統、資源、事件打包成一個模組:

pub struct CrtPlugin;

impl Plugin for CrtPlugin {
    fn build(&self, app: &mut App) {
        load_internal_asset!(app, CRT_SHADER_HANDLE,
            "../assets/shaders/crt.wgsl", Shader::from_wgsl);
        app.add_plugins(Material2dPlugin::<CrtMaterial>::default());
    }
}

NES 模擬器的 main.rs 只有 58 行,因為每個功能都是一個獨立的 Plugin 或系統,各自負責自己的資源和生命週期:

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin { ... }))
        .add_plugins(CrtPlugin)
        .add_plugins(DebugUiPlugin)
        .insert_resource(AudioBuffer { ... })
        .insert_resource(NesInput(0))
        .add_systems(Startup, (setup_emulation, setup_video, setup_audio).chain())
        .add_systems(Update, (read_input, run_emulation_frame).chain())
        .run();
}

.chain() 保證系統依序執行(先讀輸入,再跑模擬),沒有 chain 的系統 Bevy 會自動判斷是否可以平行。

這種聲明式的架構跟 React 的理念很像——你描述「什麼東西存在」和「它們之間的關係」,框架負責排程和執行。差別在於 Bevy 的描述是用 Rust 的型別系統寫的,所以所有約束都在編譯期就被驗證了。

AsBindGroup:用 Derive Macro 寫 Shader

CRT 效果的 shader 材質用 Bevy 的 AsBindGroup derive macro 自動生成 GPU bind group:

#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct CrtMaterial {
    #[uniform(0)]
    pub params: LinearRgba,
    #[texture(1)]
    #[sampler(2)]
    pub source_texture: Option<Handle<Image>>,
}

impl Material2d for CrtMaterial {
    fn fragment_shader() -> ShaderRef {
        CRT_SHADER_HANDLE.into()
    }
}

#[uniform(0)]#[texture(1)]#[sampler(2)] 這些 attribute 對應到 WGSL shader 裡的 binding:

@group(2) @binding(0) var<uniform> params: vec4<f32>;
@group(2) @binding(1) var source_texture: texture_2d<f32>;
@group(2) @binding(2) var source_sampler: sampler;

Rust 端改了 struct 欄位,WGSL 端沒改?你會在執行期看到明確的錯誤訊息。雖然不是完美的編譯期驗證,但比起手寫 bind group layout 再偷偷打錯一個 binding index,好太多了。

BusIo Trait:Rust 的 Trait 如何取代 Virtual Function Table

NES 的 CPU 透過記憶體映射 I/O 跟各個硬體元件溝通——寫入 $2000 是 PPU 控制暫存器,寫入 $4015 是 APU 狀態暫存器。在 C++ 裡你可能用 virtual function:

class BusDevice {
public:
    virtual uint8_t read(uint16_t addr) = 0;
    virtual void write(uint16_t addr, uint8_t val) = 0;
};

在 Rust 裡用 trait:

pub trait BusIo {
    fn read(&mut self, addr: u16) -> u8;
    fn write(&mut self, addr: u16, val: u8);
}

然後用組合模式把各個設備串起來:

pub struct ApuCompositeBusIo {
    pub inner: CompositeBusIo,       // PPU + Joypad
    pub apu: Rc<RefCell<Apu>>,
}

impl BusIo for ApuCompositeBusIo {
    fn read(&mut self, addr: u16) -> u8 {
        match addr {
            0x4015 => self.apu.borrow_mut().read_register(addr),
            _ => self.inner.read(addr),
        }
    }
}

跟 C++ 的 virtual dispatch 不同,Rust 的 trait 實作在編譯期就被確定了(除非你用 dyn Trait)。在模擬器這種每 cycle 都要呼叫的 hot path 上,靜態分派比動態分派快不少——因為編譯器可以 inline 整個呼叫鏈。

回顧:Rust 的限制帶來了什麼?

Rust 的限制 被逼出來的設計 意外的好處
沒有 GC,所有權唯一 ECS 架構(元件分離儲存) 自動平行化排程
同時只有一個 &mut Rc<RefCell<>> 共享擁有權 每個借用明確可追蹤
Send/Sync trait bound NonSendMut 標記主執行緒資源 執行緒安全在編譯期驗證
沒有繼承 Trait + 組合模式(BusIo) 靜態分派、可 inline
嚴格的型別系統 系統參數即存取宣告 排程器自動推導依賴

Bevy 不是「剛好用 Rust 寫的遊戲引擎」——它是一個被 Rust 的型別系統塑造出來的遊戲引擎。每個 API 設計都能追溯到某個 Rust 的語言特性或限制。

寫 NES 模擬器前端的過程讓我深刻體會到這一點:當你和借用檢查器搏鬥的時候,其實是在和它合作設計一個更清晰的架構。那些讓你抓狂的編譯錯誤,往往指向的是你的設計裡真正模糊的地方——誰擁有什麼、誰能改什麼、什麼時候能改。

把這些問題在編譯期就解決掉,比在凌晨三點 debug 一個跨執行緒的 race condition 好太多了。

參考資源


NES 模擬器完成:Bevy 前端、CRT Shader 與 Debug UI

featured.svg

CPUPPUJoypadMapperAPU——五個獨立的 crate,各自負責 NES 硬體的一個部分。現在是把它們全部接在一起的時候了。

這個專案是 NES 模擬器的前端——用 Bevy 做渲染、cpal 做音訊輸出、egui 做 debug UI,加上一個 CRT post-processing shader 讓畫面看起來像真的接在老電視上。整個前端不到 2600 行(含自動生成的 FlatBuffer schema),但麻雀雖小,五臟俱全:即時遊玩、存檔讀檔、CPU 反組譯、PPU 調色盤檢視、APU 波形顯示、手把視覺化——全都有。

專案結構

nes-emu/
├── Cargo.toml
└── src/
    ├── main.rs             # Bevy app 設定(58 行)
    ├── emulation.rs        # 模擬核心:主迴圈、時序同步(176 行)
    ├── video.rs            # Framebuffer → GPU 材質(64 行)
    ├── audio.rs            # cpal 音訊串流(53 行)
    ├── input.rs            # 鍵盤/手把輸入對應(97 行)
    ├── crt.rs              # CRT shader 材質定義(61 行)
    ├── debug_ui.rs         # egui 除錯面板(747 行)
    ├── save_state.rs       # FlatBuffer 存檔/讀檔(113 行)
    ├── generated/          # Planus 自動生成的 schema(1226 行)
    └── assets/
        └── shaders/
            └── crt.wgsl    # CRT 後處理 shader(84 行)

依賴的五個 NES crate:nes-cpunes-ppunes-apunes-joypadnes-mapper。前端框架:Bevy 0.18 + bevy_egui + cpal + planus(FlatBuffers)。

系統架構

所有模擬元件透過 nes-apuSystem struct 串接在一起——它擁有 CPU、PPU、APU、Joypad 的所有權,負責它們之間的 cycle-level 同步:

graph TB subgraph "Bevy App" MAIN[Main Loop
60 FPS] --> EMU[Emulation System] MAIN --> VID[Video System] MAIN --> AUD[Audio System] MAIN --> INP[Input System] MAIN --> DBG[Debug UI] end subgraph "NES System" EMU --> SYS[nes_apu::System] SYS --> CPU[CPU
6502] SYS --> PPU[PPU
Framebuffer] SYS --> APU[APU
5 Channels] SYS --> JOY[Joypad
2 Players] CPU --> MAP[Mapper
Bank Switch] PPU --> MAP end PPU -- "256×240 pixels" --> VID APU -- "PCM samples" --> AUD INP -- "button state" --> JOY

CPU 和 PPU 共享 Mapper(透過 Rc<RefCell<>>),APU 的 DMC 也需要存取 CPU 的記憶體匯流排來讀取 sample data。這種多方共享的擁有權結構在 Rust 裡用 interior mutability 來處理——Rc<RefCell<>> 在單執行緒的模擬器裡是最自然的選擇。

主迴圈:Accumulator-Based Timing

模擬器的時序和螢幕的更新率是解耦的——用 accumulator 模式確保不管你的螢幕是 60Hz、144Hz 還是不穩定的幀率,NES 都以精確的 NTSC 速度運行:

const NTSC_FRAME_SECS: f64 = 1.0 / 60.0988;

pub fn run_emulation_frame(
    mut nes: NonSendMut<NesSystem>,
    time: Res<Time>,
    audio_buf: Res<AudioBuffer>,
    // ...
) {
    if nes.paused {
        nes.accumulator = 0.0;
        return;
    }

    nes.accumulator += time.delta_secs_f64();
    // 防止 spiral of death:最多追趕 4 幀
    nes.accumulator = nes.accumulator.min(NTSC_FRAME_SECS * 4.0);

    let mut frames_run = 0;
    while nes.accumulator >= NTSC_FRAME_SECS {
        nes.sys.joypad1.borrow_mut().set_buttons(nes_input.0);
        nes.sys.run_until_frame();

        // 把 APU 產生的 sample 送到音訊 ring buffer
        let samples = nes.sys.apu.borrow_mut().drain_samples();
        if let Ok(mut buf) = audio_buf.buffer.lock() {
            if buf.len() < 8192 {
                buf.extend(samples.iter());
            }
        }

        nes.accumulator -= NTSC_FRAME_SECS;
        frames_run += 1;
    }

    if frames_run > 0 {
        // 把 PPU framebuffer 複製到 GPU 材質
        copy_framebuffer_to_texture(&nes, &mut images, &fb_handle);
    }
}

幾個關鍵設計:

  • 4 幀上限:防止 alt-tab 回來後模擬器瘋狂追趕累積的時間
  • 暫停時歸零:避免取消暫停瞬間跑一大堆幀
  • 只在有新幀時更新材質:避免無意義的 GPU 上傳

NonSendMut:Rust 所有權的巧妙運用

NesSystem 裡面有 Rc<RefCell<>>——這些型別不是 Send(不能跨執行緒傳遞)。Bevy 預設會在多個執行緒上排程系統,所以我們用 NonSendMut<NesSystem> 告訴 Bevy:「這個資源只能在主執行緒上存取。」

pub fn setup_emulation(world: &mut World) {
    // exclusive world access,只在初始化時呼叫一次
    let sys = System::new(rom, sample_rate);
    world.insert_non_send_resource(NesSystem {
        sys,
        paused: false,
        accumulator: 0.0,
    });
}

這是一個很實用的 pattern——當你的核心邏輯本質上是單執行緒的(模擬器幾乎都是),不需要硬把它改成 thread-safe,只要告訴框架「我知道這不能跨執行緒,請安排在主執行緒跑」就好。

影像:從 Framebuffer 到 CRT

Framebuffer 上傳

PPU 每幀輸出 256×240 個 pixel(每個 pixel 是 NES palette 的一個顏色值),前端把它轉成 RGBA8 上傳到 GPU:

fn copy_framebuffer_to_texture(nes: &NesSystem, images: &mut Assets<Image>, handle: &FramebufferHandle) {
    let ppu = nes.sys.ppu.borrow();
    let fb = ppu.framebuffer();
    if let Some(image) = images.get_mut(&handle.0) {
        for (i, &color) in fb.iter().enumerate() {
            let (r, g, b) = PALETTE[color as usize];
            image.data[i * 4]     = r;
            image.data[i * 4 + 1] = g;
            image.data[i * 4 + 2] = b;
            image.data[i * 4 + 3] = 255;
        }
    }
}

256×240×4 = 245,760 bytes per frame——不到 250KB,完全不是瓶頸。

CRT 後處理 Shader

CRT shader 是整個專案最有趣的視覺效果——84 行 WGSL 程式碼模擬老式 CRT 電視的四種特效:

1. Barrel Distortion(桶狀扭曲)

真的 CRT 螢幕是微微凸出的,畫面邊緣會向外彎:

fn barrel_distort(uv: vec2<f32>, curvature: f32) -> vec2<f32> {
    let centered = uv - 0.5;
    let dist = dot(centered, centered);
    let distorted = centered * (1.0 + dist * curvature);
    return distorted + 0.5;
}

原理是把 UV 座標從中心算距離,距離越遠的像素被推得越遠——產生凸面鏡的效果。

2. Scanlines(掃描線)

CRT 的電子槍一行一行掃描,行與行之間有暗線:

let scanline = 1.0 - scanline_intensity * 0.5 * (1.0 - cos(uv.y * 240.0 * 3.14159));

cos(y × 240 × π) 產生 240 條暗帶,剛好對齊 NES 的 240 條掃描線。

3. Shadow Mask(蔭罩)

真的 CRT 每個「像素」其實是 RGB 三個磷光粉點,相鄰像素的 R/G/B 交錯排列:

let mask_pos = i32(uv.x * 768.0) % 3;
var mask = vec3<f32>(0.8, 0.8, 0.8);
if mask_pos == 0 { mask.r = 1.0; }
else if mask_pos == 1 { mask.g = 1.0; }
else { mask.b = 1.0; }

每 3 個水平像素循環一次 R-G-B 增亮,模擬磷光粉的排列。

4. Vignette(暗角)

CRT 螢幕邊緣比中心暗:

let vignette = 1.0 - vignette_intensity * dot(centered, centered) * 2.0;

距離中心越遠,亮度衰減越多。

三種顯示模式可以在 debug UI 即時切換:Nearest(像素完美)、Linear(雙線性)、CRT(完整後處理)。

音訊:跨執行緒 Ring Buffer

音訊是模擬器裡最需要即時性的部分——如果 buffer underflow,你會聽到爆音。架構上用 Arc<Mutex<VecDeque<f32>>> 做 producer-consumer:

模擬執行緒(producer)    cpal 音訊執行緒(consumer)
     │                          │
     │   push samples           │   pop samples
     └──────→ Ring Buffer ←─────┘
              (VecDeque)
              max 8192
pub fn setup_audio(mut commands: Commands) {
    let buffer = Arc::new(Mutex::new(VecDeque::with_capacity(4096)));
    let buf_clone = buffer.clone();

    let stream = device.build_output_stream(
        &config,
        move |data: &mut [f32], _| {
            let mut buf = buf_clone.lock().unwrap();
            for frame in data.chunks_mut(channels) {
                let sample = buf.pop_front().unwrap_or(0.0);
                for ch in frame.iter_mut() {
                    *ch = sample;
                }
            }
        },
        |err| eprintln!("Audio error: {err}"),
        None,
    ).unwrap();
    // ...
}
  • 模擬端每幀 drain APU 的 sample(~735 samples @ 44100Hz / 60fps)
  • 音訊端每次 callback pop 需要的量
  • 8192 上限防止跑太快時記憶體無限增長
  • Underflow 靜默填 0.0——比爆音好

輸入:鍵盤 + 手把

NES 的 joypad 就是 8 個按鈕,用一個 u8 bitfield 就能完整表示:

pub fn read_input(
    keyboard: Res<ButtonInput<KeyCode>>,
    gamepads: Query<&Gamepad>,
    mut nes_input: ResMut<NesInput>,
) {
    let mut state = 0u8;

    // 鍵盤
    if keyboard.pressed(KeyCode::ArrowUp)   { state |= 1 << 4; }
    if keyboard.pressed(KeyCode::ArrowDown) { state |= 1 << 5; }
    if keyboard.pressed(KeyCode::KeyZ)      { state |= 1 << 0; } // A
    if keyboard.pressed(KeyCode::KeyX)      { state |= 1 << 1; } // B

    // 手把(第一支)
    if let Some(gamepad) = gamepads.iter().next() {
        if gamepad.pressed(GamepadButton::DPadUp) { state |= 1 << 4; }
        // 類比搖桿 → 方向鍵(deadzone 0.5)
        if let Some(axis) = gamepad.get(GamepadAxis::LeftStickY) {
            if axis > 0.5 { state |= 1 << 4; }  // up
            if axis < -0.5 { state |= 1 << 5; }  // down
        }
        // ...
    }

    nes_input.0 = state;
}

每幀讀一次,bitfield OR 合併鍵盤和手把的狀態——兩個輸入來源可以同時使用,不衝突。

Debug UI:747 行的除錯面板

Debug UI 是這個前端最大的檔案——它在畫面右側開一個 250px 的 egui 面板,塞滿了各種即時資訊:

CPU 反組譯器

暫停時顯示 PC 附近的 6 條指令,每條顯示十六進位機器碼和助記符:

$8000: A9 10    LDA #$10
$8002: 8D 00 20 STA $2000     ← PC
$8005: A9 3F    LDA #$3F

加上暫存器狀態(A、X、Y、PC、SP)和 status flags(N、V、B、D、I、Z、C),每個 flag 用顏色標示 on/off。

PPU 調色盤檢視

即時顯示 NES 的 8 組子調色盤(4 組背景 + 4 組精靈),每組 4 個顏色方塊:

BG0: ██ ██ ██ ██    BG1: ██ ██ ██ ██
BG2: ██ ██ ██ ██    BG3: ██ ██ ██ ██
SP0: ██ ██ ██ ██    SP1: ██ ██ ██ ██
SP2: ██ ██ ██ ██    SP3: ██ ██ ██ ██

配合捲軸位置(X, Y, nametable)、rendering flag、精靈統計(數量、8×8 或 8×16)。

APU 波形顯示

egui_plot 即時繪製 5 個聲道的波形——Pulse 1(綠)、Pulse 2(藍)、Triangle(橘)、Noise(紫)、DMC(紅),每個聲道顯示最近 256 個 sample 的 ring buffer 資料。

手把視覺化

畫面上顯示一個 NES 手把的圖示,按下的按鈕會變紅——在 debug 輸入問題時非常直觀。

存檔 / 讀檔:FlatBuffers

Save state 用 FlatBuffers(透過 Rust 的 Planus 實作)序列化整個模擬器狀態:

pub fn save(nes: &NesSystem, path: &Path) -> Result<Vec<u8>> {
    let mut cpu_state = Vec::new();
    nes.sys.cpu.save_state(&mut cpu_state);

    let mut ppu_state = Vec::new();
    nes.sys.ppu.borrow().save_state(&mut ppu_state);

    let mut apu_state = Vec::new();
    nes.sys.apu.borrow().save_state(&mut apu_state);

    // ... mapper, joypad ...

    let state = NesState {
        version: 2,
        cpu: &cpu_state,
        ram: &ram_data,
        mapper: &mapper_state,
        ppu: &ppu_state,
        apu: &apu_state,
        // ...
    };

    let mut builder = planus::Builder::new();
    let offset = state.prepare(&mut builder);
    builder.finish(offset, None);
    Ok(builder.as_slice().to_vec())
}

FlatBuffers 的好處是 zero-copy deserialization——讀檔時不需要把整個 blob 解碼成 Rust struct,可以直接從 buffer 讀取欄位。對於 PPU 的 VRAM(幾 KB)和 APU 的狀態這種大小的資料,效能差異不大,但序列化格式本身很緊湊。

存檔策略:

  • F5 存檔:序列化後同時寫入磁碟({rom名}.sav)和記憶體快取
  • F7 讀檔:優先從記憶體讀取,沒有再從磁碟讀
  • 一個 slot:簡單粗暴,一個存檔位就夠

快捷鍵

按鍵 功能
方向鍵 D-pad
Z / X A / B 按鈕
A / S Select / Start
Esc 暫停 / 繼續
F3 顯示 / 隱藏 debug 面板
F5 存檔
F7 讀檔
F12 截圖(PNG,含時間戳)

總結

模組 行數 負責
main.rs 58 Bevy plugin 註冊
emulation.rs 176 主迴圈、時序同步
video.rs 64 Framebuffer → GPU
audio.rs 53 cpal 音訊串流
input.rs 97 鍵盤/手把對應
crt.rs 61 CRT shader 材質
debug_ui.rs 747 egui 除錯面板
save_state.rs 113 FlatBuffer 存檔
crt.wgsl 84 CRT 後處理 shader

整個 NES 模擬器系列到這裡告一段落。回顧一下各個零件:

Crate 負責 文章
nes-cpu 6502 CPU + 記憶體匯流排 CPU 文章
nes-ppu 圖形處理器 + 256×240 渲染 PPU 文章
nes-joypad 手把串列輸入 Joypad 文章
nes-mapper 卡匣 bank switching Mapper 文章
nes-apu 音效處理器 + 5 聲道混音 APU 文章
nes-emu 前端整合 本文

六個 crate、六篇文章、幾千行 Rust——從單一指令的 fetch-decode-execute,到像素一條一條掃出來,到方波三角波噪音混在一起,到卡匣的 bank 一塊一塊切換,最後全部透過 Bevy 顯示在螢幕上、cpal 從喇叭放出來。NES 的硬體設計在 1983 年是工程奇蹟,用現代語言重新實作一遍,你會更深刻地體會到——那些工程師在 8-bit 的限制裡,真的榨出了不可思議的東西。

參考資源


NES APU 模擬器:方波、三角波、噪音與 DMC 取樣

featured.svg

NES 的聲音來自一顆叫 2A03 的 APU(Audio Processing Unit)——整合在 CPU 晶片裡的音效硬體。它只有 5 個聲道:兩個方波、一個三角波、一個噪音、一個 DMC(差分取樣)。聽起來很陽春?但 Super Mario Bros. 的地上主題、Mega Man 2 的 Dr. Wily Stage、Castlevania 的 Vampire Killer——這些經典旋律全都是從這 5 個聲道榨出來的。

這個專案用 Rust 實作了完整的 NES APU 模擬器,包含 frame counter 時序、envelope 衰減、sweep 掃頻、非線性混音,以及 DMC 的 DPCM 解碼。設計上是純 DSP——不依賴任何音訊輸出函式庫,只負責產生 PCM 樣本。

專案結構

nes-apu/
├── Cargo.toml
└── src/
    ├── lib.rs            # 模組匯出(12 行)
    ├── apu.rs            # APU 核心:tick、取樣、暫存器 I/O(468 行)
    ├── pulse.rs          # 方波聲道:duty cycle + timer(149 行)
    ├── triangle.rs       # 三角波聲道:線性計數器(164 行)
    ├── noise.rs          # 噪音聲道:LFSR 偽隨機(136 行)
    ├── dmc.rs            # DMC 聲道:1-bit DPCM 取樣播放(257 行)
    ├── envelope.rs       # 音量包絡產生器(124 行)
    ├── sweep.rs          # 頻率掃頻單元(140 行)
    ├── length_counter.rs # 長度計數器(126 行)
    ├── frame_counter.rs  # Frame counter:quarter/half-frame 時序(351 行)
    ├── mixer.rs          # 非線性混音器 + 查表(74 行)
    ├── composite_io.rs   # Bus I/O 轉接器(43 行)
    └── system.rs         # 整合系統(CPU + PPU + APU + Joypad)

依賴只有 nes-cpu(CPU 核心 + state 序列化工具),整個 APU 沒有外部音訊依賴。

NES 音訊架構

先看全貌——5 個聲道各自獨立產生波形,最後混合成一個單聲道輸出:

graph LR P1[Pulse 1
方波] --> MIX[Mixer
非線性混音] P2[Pulse 2
方波] --> MIX TRI[Triangle
三角波] --> MIX NOI[Noise
噪音] --> MIX DMC[DMC
差分取樣] --> MIX MIX --> HPF[High-Pass
Filter] --> OUT[Audio
Output] FC[Frame Counter] -.-> P1 FC -.-> P2 FC -.-> TRI FC -.-> NOI

每個聲道內部都有自己的 timer(控制頻率)和各種修飾單元(envelope、sweep、length counter),由 frame counter 定期觸發更新。

1. Pulse 聲道:方波的藝術

兩個 Pulse 聲道幾乎完全相同,差別只在 sweep 的 negation 模式。每個方波聲道由四個子系統組成:

Timer → Duty Sequencer → 輸出 (0 或 1)
         × Envelope 音量
         × Length Counter 閘控
         × Sweep 掃頻

Duty Cycle:四種波形

方波不一定是 50/50,NES 提供了四種 duty cycle:

Duty 0 (12.5%):  ░░░░░░░█  → 極窄脈衝,薄而尖的音色
Duty 1 (25%):    ░░░░░░██  → 較窄,清脆
Duty 2 (50%):    ░░░░████  → 標準方波,圓潤
Duty 3 (75%):    ██████░░  → 等同 25% 反相

實作上就是 8 步的序列查表:

const DUTY_TABLE: [[u8; 8]; 4] = [
    [0, 0, 0, 0, 0, 0, 0, 1], // 12.5%
    [0, 0, 0, 0, 0, 0, 1, 1], // 25%
    [0, 0, 0, 0, 1, 1, 1, 1], // 50%
    [1, 1, 1, 1, 1, 1, 0, 0], // 75%
];

Timer 每到 0 就把 sequencer 推進一步,duty table 決定這一步是 1(有聲)還是 0(靜音)。Timer 的 period 就是頻率的倒數——period 越小,頻率越高。

Sweep 掃頻:自動滑音

Sweep 單元每個 half-frame 自動調整 timer period——可以讓音高漸漸升高或降低,遊戲裡的「嗶嗶嗶」滑音效果就是這麼做的:

pub fn tick(&mut self, timer_period: u16) -> Option<u16> {
    let target = self.target_period(timer_period);
    let muted = timer_period < 8 || target > 0x7FF;

    if self.divider == 0 && self.enabled && self.shift > 0 && !muted {
        Some(target)
    } else {
        if self.divider == 0 || self.reload {
            self.divider = self.period;
            self.reload = false;
        } else {
            self.divider -= 1;
        }
        None
    }
}

目標頻率的計算是 current ± (current >> shift)——右移 shift 位再加或減。當目標超過 $7FF 或原始 period 小於 8 時,聲道被 mute。

Pulse 1 和 Pulse 2 的 sweep negation 不同是個有趣的硬體細節:

pub enum NegateMode {
    OnesComplement,  // Pulse 1:-change - 1
    TwosComplement,  // Pulse 2:-change
}

差 1 的原因是 Pulse 1 用一補數(ones’ complement)做減法,而 Pulse 2 用二補數(two’s complement)。這意味著 Pulse 1 sweep 到最低頻時會比 Pulse 2 多出一個微小的偏移。

2. Triangle 聲道:不需要音量控制

三角波是 NES 聲道裡最特別的——它沒有 envelope(音量控制),波形永遠是滿振幅。但它有一個 linear counter(線性計數器),配合 length counter 一起閘控聲音的開關。

波形是 32 步的固定序列:

15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0,
 0,  1,  2,  3,  4,  5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15

上去再下來,形成一個三角形。注意 0 出現了兩次——這讓波形微微不對稱。

一個重要的實作細節——三角波的 output 永遠跟著 sequencer 位置,不管計數器是否為零:

pub fn output(&self) -> u8 {
    TRIANGLE_TABLE[self.sequence_pos as usize]
}

計數器只控制 sequencer 要不要繼續推進:

pub fn tick(&mut self) {
    if self.length_counter.value() > 0 && self.linear_counter > 0 {
        self.sequence_pos = (self.sequence_pos + 1) % 32;
    }
}

為什麼這樣設計?因為如果在 sequencer 不在 0 或 15 的位置上突然停止,輸出會卡在一個中間值,造成 DC offset。真實硬體就是這樣——你在某些遊戲裡會聽到三角波聲道留下一個輕微的「嗡」聲。

另一個差異是三角波的 timer 在每個 CPU cycle 都 tick,不像 Pulse 和 Noise 是每兩個 cycle 才 tick 一次。所以三角波能達到的最高頻率是方波的兩倍。

3. Noise 聲道:LFSR 偽隨機

Noise 聲道用一個 15-bit 的 LFSR(Linear Feedback Shift Register) 產生偽隨機噪音:

pub fn tick(&mut self) {
    if self.timer == 0 {
        self.timer = self.timer_period;

        let bit = if self.mode {
            (self.shift & 1) ^ ((self.shift >> 6) & 1)  // 短模式
        } else {
            (self.shift & 1) ^ ((self.shift >> 1) & 1)  // 長模式
        };

        self.shift >>= 1;
        self.shift |= bit << 14;
    } else {
        self.timer -= 1;
    }
}

LFSR 的運作:每次 timer 歸零,把 bit 0 和另一個 bit XOR,結果塞進 bit 14,整個暫存器右移一位。長模式 XOR bit 0 和 bit 1(週期 32767),短模式 XOR bit 0 和 bit 6(週期 93)。

短模式聽起來像什麼? 像金屬撞擊或電子鼓的 hi-hat——因為短週期讓噪音產生明顯的音調感。長模式則是白噪音。

輸出很直接:bit 0 為 1 時靜音,為 0 時輸出 envelope 音量:

pub fn output(&self) -> u8 {
    if self.shift & 1 == 1 || self.length_counter.value() == 0 {
        0
    } else {
        self.envelope.output()
    }
}

16 種 period 對應 16 種「音高」的噪音,period 越長聲音越低沉:

const NOISE_PERIOD_TABLE: [u16; 16] = [
    4, 8, 16, 32, 64, 96, 128, 160,
    202, 254, 380, 508, 762, 1016, 2034, 4068,
];

4. Envelope:音量衰減產生器

Pulse 和 Noise 聲道共用同一個 Envelope 設計。它在每個 quarter-frame(約 240Hz)被觸發一次:

pub fn tick(&mut self) {
    if self.start {
        self.start = false;
        self.decay_level = 15;
        self.divider = self.divider_period;
    } else if self.divider == 0 {
        self.divider = self.divider_period;
        if self.decay_level > 0 {
            self.decay_level -= 1;
        } else if self.loop_flag {
            self.decay_level = 15;  // 循環:回到最大音量
        }
    } else {
        self.divider -= 1;
    }
}

兩種模式:

  • Constant volume:直接用 divider_period 當音量(0-15)
  • Decay envelope:從 15 開始遞減,每 divider_period + 1 個 quarter-frame 減 1

Loop flag 讓衰減的音量在到 0 之後重新回到 15——可以做出持續的「脈動」效果。

5. DMC:差分取樣播放

DMC(Delta Modulation Channel)是 NES 唯一能播放「真實」音訊樣本的聲道——但它用的是 1-bit DPCM(Differential Pulse-Code Modulation),每個 bit 只表示「+2」或「-2」:

pub fn tick(&mut self) -> Option<u16> {
    let mut sample_request = None;

    if self.timer == 0 {
        self.timer = self.timer_period;

        if self.bits_remaining > 0 {
            if self.shift_register & 1 == 1 {
                if self.output_level <= 125 {
                    self.output_level += 2;  // bit=1: 音量 +2
                }
            } else {
                if self.output_level >= 2 {
                    self.output_level -= 2;  // bit=0: 音量 -2
                }
            }
            self.shift_register >>= 1;
            self.bits_remaining -= 1;
        }

        // 需要新的 sample byte
        if self.bits_remaining == 0 && self.bytes_remaining > 0 {
            sample_request = Some(self.current_address);
            // ...
        }
    } else {
        self.timer -= 1;
    }

    sample_request
}

DMC 的取樣機制很巧妙:

  1. CPU memory 的 $C000-$FFFF 區間存放 sample data
  2. DMC 發出位址請求,外部系統從 CPU memory 讀取 1 byte
  3. 每個 byte 的 8 個 bit 逐一處理,每個 bit 讓 7-bit output level 加 2 或減 2
  4. Output level 被 clamp 在 0-127,不會溢出

16 種播放速率(NTSC,CPU cycles per bit):

const DMC_RATE_TABLE: [u16; 16] = [
    428, 380, 340, 320, 286, 254, 226, 214,
    190, 160, 142, 128, 106, 84, 72, 54,
];

最慢 428 cycles/bit ≈ 4.2 kHz 取樣率,最快 54 cycles/bit ≈ 33.1 kHz。雖然只有 1-bit 差分,但因為取樣率夠高,DMC 可以播放出可辨識的語音和音效——像 Mike Tyson’s Punch-Out!! 裡的裁判喊「TKO!」就是 DMC。

DMC 還有兩個特殊功能:

  • Loop flag:播完後自動從頭開始——用來播放循環的底鼓或低音
  • IRQ flag:播完時觸發 CPU 中斷——遊戲可以在中斷裡載入下一段 sample

6. Frame Counter:APU 的心跳

Frame counter 是 APU 的「指揮」——它定期發出 quarter-frame 和 half-frame 事件,驅動 envelope、length counter、sweep 等子系統的更新。

兩種模式:

4-step mode(~60 Hz,對齊到 NTSC 的每幀):

Step    APU Cycles    動作
  1       3729       Quarter-frame(envelope、linear counter)
  2       7457       Quarter + Half-frame(+ length counter、sweep)
  3      11186       Quarter-frame
  4      14914       IRQ flag set
  5      14915       Quarter + Half-frame + IRQ set,歸零

5-step mode(~48 Hz,沒有 IRQ):

Step    APU Cycles    動作
  1       3729       Quarter-frame
  2       7457       Quarter + Half-frame
  3      11186       Quarter-frame
  4      14915       (空步)
  5      18641       Quarter + Half-frame,歸零

4-step mode 的 IRQ 讓遊戲不需要用 NMI(PPU vblank)來計時音樂——可以獨立於畫面更新率。5-step mode 則刻意避開 IRQ,寫 $4017 = $80 就切換過去。

實作上用一個 cycle counter 搭配 match:

pub fn tick(&mut self) -> FrameEvent {
    self.cycle += 1;

    let event = if self.mode == 0 {
        // 4-step mode
        match self.cycle {
            3729 => FrameEvent::quarter(),
            7457 => FrameEvent::quarter_and_half(),
            11186 => FrameEvent::quarter(),
            14914 => { /* set IRQ if not inhibited */ }
            14915 => {
                self.cycle = 0;
                FrameEvent::quarter_and_half()
            }
            _ => FrameEvent::none(),
        }
    } else {
        // 5-step mode
        match self.cycle {
            3729 => FrameEvent::quarter(),
            7457 => FrameEvent::quarter_and_half(),
            11186 => FrameEvent::quarter(),
            18641 => {
                self.cycle = 0;
                FrameEvent::quarter_and_half()
            }
            _ => FrameEvent::none(),
        }
    };

    event
}

7. Mixer:非線性混音

NES 的混音不是簡單的加法——真實硬體用的是電阻式 DAC,會產生非線性的混音曲線。模擬器用兩張查表來近似這個行為:

impl Mixer {
    pub fn new() -> Self {
        let mut pulse_table = [0.0f32; 31];
        for n in 1..31 {
            pulse_table[n] = 95.52 / (8128.0 / n as f32 + 100.0);
        }

        let mut tnd_table = [0.0f32; 203];
        for n in 1..203 {
            tnd_table[n] = 163.67 / (24329.0 / n as f32 + 100.0);
        }

        Self { pulse_table, tnd_table }
    }
}

混音公式:

output = pulse_table[pulse1 + pulse2]
       + tnd_table[3 × triangle + 2 × noise + dmc]

Pulse 的索引最大 30(兩個聲道各最大 15),TND 的索引最大 202(3×15 + 2×15 + 127)。查表 O(1),避免了每個 sample 都做除法。

為什麼三角波的權重是 3、噪音是 2?這反映了真實硬體裡各聲道的電阻值比例——三角波聲道的輸出阻抗比噪音低,所以貢獻更大。

8. Downsampling 和高通濾波

NES CPU 跑在 1.789773 MHz,但音訊輸出通常是 44100 Hz。APU 在每個 CPU cycle 都 tick,但只在累積夠足夠 cycle 後才取一個 sample:

pub fn tick(&mut self) {
    // ... tick 各聲道 ...

    self.sample_counter += 1.0;
    if self.sample_counter >= self.sample_period {
        self.sample_counter -= self.sample_period;

        let mixed = self.mixer.mix(p1, p2, tri, noise, dmc);

        // 一階高通濾波,去除 DC offset
        let filtered = self.hpf_alpha * (self.hpf_prev_out + mixed - self.hpf_prev_in);
        self.hpf_prev_in = mixed;
        self.hpf_prev_out = filtered;

        self.sample_buffer.push(filtered);
    }
}

sample_period = 1789773 / 44100 ≈ 40.59 cycles/sample。累計器用 f64 維持相位對齊,避免 sample 漂移。

高通濾波器的截止頻率約 37 Hz——真實 NES 硬體也有類似的 RC 電路濾掉 DC 偏移。參數計算:

RC = 1 / (2π × 37) ≈ 0.0043
α = RC / (RC + dt),其中 dt = 1 / sample_rate

9. DMC 的 DMA 機制

DMC 取樣需要從 CPU memory 讀資料,但 APU 和 CPU 共享匯流排——DMC 每讀一個 byte 要「偷」CPU 4 個 cycle。這個交互在 system.rs 裡實現:

fn step(&mut self) {
    // APU tick
    self.apu.borrow_mut().tick();

    // 檢查 DMC 是否需要 sample
    if let Some(addr) = self.apu.borrow().dmc_sample_request() {
        let val = self.cpu.bus_read(addr); // 從 CPU memory 讀取
        self.apu.borrow_mut().dmc_load_sample(val);
        // 真實硬體這裡會偷 4 個 CPU cycle
    }

    // 檢查 APU 的 IRQ
    if self.apu.borrow().irq_pending() {
        self.cpu.irq();
    }
}

dmc_sample_request() 回傳 Option<u16>——有值時表示 DMC 需要讀取那個位址的 sample byte。外部系統從 CPU 的記憶體映射讀取後,用 dmc_load_sample() 送回去。

10. State 序列化

每個元件都支援 save_state() / load_state() — 存檔和讀檔功能:

所有有狀態的元件(envelope、sweep、length counter、frame counter、各聲道、mixer 的 per-channel waveform buffer)都參與序列化。狀態用 cursor-based 的方式寫入,配合 nes_cpu::state 模組的 helper function,確保每個元件的狀態都能完整保存和恢復。

11. 測試

APU 的測試以 blargg 的 apu_test ROM 為基準,涵蓋:

  • Length counter 行為和 lookup table
  • Frame counter 在 4-step / 5-step 模式下的時序
  • IRQ 的觸發和清除條件
  • DMC 的播放速率表和位址計算
  • Envelope 的衰減邏輯
  • Sweep 的 negation 差異(ones’ vs two’s complement)
  • 混音器的非線性查表

全部模組加起來超過 99 個測試案例。

總結

元件 行數 核心機制
Pulse × 2 ~149 Duty cycle 方波 + sweep 掃頻
Triangle ~164 32-step 三角波 + linear counter
Noise ~136 15-bit LFSR + 長/短模式
DMC ~257 1-bit DPCM + DMA 取樣
Envelope ~124 4-bit 音量衰減/常量
Sweep ~140 頻率自動調整 + mute 條件
Length Counter ~126 32-entry 查表 + halt 控制
Frame Counter ~351 4/5-step 時序 + IRQ
Mixer ~74 非線性查表混音
APU 核心 ~468 Tick、downsampling、HPF

NES APU 最讓我印象深刻的是它的層疊式設計——每個聲道都是 timer → sequencer → output 的基本架構,但透過不同的修飾單元(envelope、sweep、length counter、linear counter)組合出截然不同的音色。5 個聲道、總共不到 2000 行 Rust,就能模擬出那個年代所有經典遊戲的配樂。

CPUPPUJoypadMapper 再到 APU,NES 模擬器的每一塊拼圖都就位了。接下來就是把它們全部接在一起,讓畫面動起來、聲音出來、按鍵有反應——一台完整的 NES 模擬器。

參考資源


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 甚至更大的遊戲。

參考資源