cge_nes 0.1.1

Cycle-accurate NES (Nintendo Entertainment System) emulator library: CPU, PPU, cartridge, input, and iNES ROM loading.
Documentation

cge_nes

Crates.io Documentation License: MIT License: Apache-2.0

A cycle-accurate NES emulator core written in Rust (2021 edition). Front-end agnostic: the library handles CPU, PPU, cartridge, and input emulation; you bring your own rendering, windowing, and host input.

Features:

  • Cycle-accurate 6502 CPU with multi-cycle instruction execution
  • Bit-accurate PPU with precise sprite rendering, palette handling, and all four mirroring modes
  • iNES ROM loading and cartridge mapper support (mappers 0, 1, 2, 3, 4)
  • Composable memory devices (CPU bus and PPU bus) built from devices6502
  • Decoupled input: you implement HostInput, the emulator polls each frame
  • no_std-friendly core
  • Single dependency (cge_nes) gets you the whole stack

Roadmap: APU (audio) emulation is not yet implemented in this release but is planned for a future one.

Quick facts:

  • 256×240 pixel frame buffer, one already-resolved NES palette color per pixel
  • 262 scanlines per frame (~29,780 CPU cycles, ~60 Hz NTSC)
  • CPU and PPU synchronized every cycle; PPU runs at 3× the CPU clock

Contents

Getting started

Prerequisites:

  • Rust 1.75+ (Rust 2021 edition)
  • Windows / Linux / macOS

Add to your project:

[dependencies]

cge_nes = "0.1"

Build the library, examples, and binaries:

cargo build

cargo build --examples

cargo build --bins

Architecture

High-Level Design

┌────────────────────────────────────────────────────┐
│                  Your Front-End                    │
│        (Rendering, Input, Frame Loop, UI)          │
└────────────────────────────────────────────────────┘
                          ↓
┌────────────────────────────────────────────────────┐
│                    cge_nes                         │
│  ┌──────────────────────────────────────────────┐  │
│  │  nes::NesConsole                             │  │
│  │  ├─ cpu (cpu6502) + cpu_bus (devices6502)    │  │
│  │  ├─ ppu::Ppu + ppu_bus                       │  │
│  │  ├─ cartridge::Cartridge trait               │  │
│  │  ├─ input::HostInput trait                   │  │
│  │  └─ nes::dma (OAM DMA)                       │  │
│  └──────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────┘

Key Concepts

  • CPU & Memory: The Cpu from cpu6502 executes against a memory map built from devices6502 traits. CPU RAM, PPU registers, and cartridge ROM/RAM are all addressable in one 16-bit address space.
  • PPU & Video Output: The Ppu renders pixels scanline-by-scanline, reading CHR ROM, name tables, attribute tables, and OAM. Frame buffer is exposed as &[Color] (one NES palette color per pixel).
  • Cartridge Loading: rom_loader parses iNES ROM files and instantiates the matching mapper. Mappers handle bank switching, mirroring, and PRG RAM.
  • Input: input::HostInput is the trait your front-end implements. The emulator polls it once per frame per controller port via run_frame.

Execution Model

The NES runs in a frame loop:

  1. Frame setup: Reset scanline counter, clear frame buffer.
  2. Scanline loop (262 scanlines per frame):
    • CPU cycles: Execute multiple 6502 instructions per scanline (~114 CPU cycles).
    • PPU rendering: PPU advances each cycle in lockstep with CPU; pixels are generated from pattern / name / attribute / OAM lookups.
    • Sprites & priority: OAM sprites evaluated and rendered with correct priority each visible scanline.
  3. Frame complete: Frame buffer is ready for your front-end to render.
  4. Input polling (optional, your front-end decides): Sample host input, update controller state.
  5. Repeat.

Module layout

cge_nes/
├── README.md               # This file — single source of docs
├── LICENSE                  # MIT (also LICENSE-MIT, LICENSE-APACHE)
├── docs/                   # Hardware reference (mappers, etc.)
├── examples/
│   └── run_frame.rs        # Canonical front-end bootstrap example
├── src/
│   ├── lib.rs              # Public surface + module declarations + re-exports
│   ├── bin/
│   │   └── ines_header_inspector.rs   # Debug tool: dumps iNES headers from test_assets/
│   ├── cartridge/          # Cartridge trait (shared abstraction)
│   ├── input/              # HostInput trait + gamepad state
│   ├── ppu/                # PPU emulation
│   ├── nes/                # System integration (CPU + PPU + cartridge + input)
│   └── rom_loader/         # iNES parser + mapper dispatch
└── test_assets/            # Real .nes ROMs (gitignored, for manual testing)

External dependencies (from crates.io):

  • cpu6502 — 6502 CPU emulator
  • devices6502 — composable memory devices
  • bitflags, arrayvec, ringbuffer — small utility crates

Public API

The crate root re-exports every commonly-needed type. Most front-ends only need a handful of these:

use cge_nes::{
    // System integration
    NesConsole,
    // ROM loading
    load_rom, LoadRomResult, RomError,
    // Cartridge contract (implement for custom mappers)
    Cartridge, ChrRomContentStatus,
    // Input
    ConnectedSocket, GamepadButtonsPressedFlags, GamepadState,
    HostInput, InputDeviceState,
    // Video
    Color,
};

Lower-level access (PPU internals, mapper implementations, etc.) is available through the module hierarchy:

use cge_nes::ppu::{Ppu, PpuCartMemorySpace, FrameEvent, Register};
use cge_nes::rom_loader::ines::{HeaderData, Mirroring};
use cge_nes::nes::system::NesConsole;   // same as the re-exported NesConsole

Features

Per-feature flags:

Feature Purpose
instr_log CPU instruction logging (writes every instruction to nes_cpu_log.txt). Enables cpu6502/logging under the hood.
oam_array_raw Alternative OAM memory layout (profiling / optimization experiments).
show_name_table_change Debug name / attribute table access during rendering.
mapper_debug_log Mapper bank-switching traces.
cargo build --features instr_log

cargo build --features oam_array_raw,show_name_table_change

cargo build --features mapper_debug_log

cargo build --all-features    # enables everything; some pre-existing PP   U debug paths require a manual fix-up

Build

# Default build (library + examples + binaries)

cargo build


# With specific features

cargo build --features instr_log

cargo build --features oam_array_raw,show_name_table_change

cargo build --features mapper_debug_log


# Release

cargo build --release

Testing

# Run all library + integration + binary tests

cargo test


# Per-target

cargo test --lib

cargo test --tests

cargo test --bins


# Show backtrace for a failing test

RUST_BACKTRACE=1 cargo test render_scanline

Quick start

The canonical bootstrap lives as a runnable example at examples/run_frame.rs:

cargo run --example run_frame -- path/to/game.nes

It loads the ROM, runs one frame, and prints the buffer dimensions plus the count of non-background pixels. The source is the recommended starting point for a front-end. The skeleton, condensed:

use cge_nes::{
    ConnectedSocket, HostInput, InputDeviceState, NesConsole, load_rom,
};

struct NullHostInput;
impl HostInput for NullHostInput {
    fn input_device_state(&mut self, _player: ConnectedSocket) -> InputDeviceState {
        InputDeviceState::Disconnected
    }
}

let rom_data = std::fs::read("game.nes")?;
let cartridge = load_rom(&mut rom_data.as_slice())?;

let mut console = NesConsole::new();
console.insert_cartridge(cartridge);
console.switch_on();

let mut host = NullHostInput;
console.run_frame(&mut host);

let frame: &[cge_nes::Color] = console.screen_colors();
// render `frame` to your window of choice.

Replace NullHostInput with an adapter that polls your keyboard / gamepad each frame.

Front-end integration

This library is front-end agnostic. You supply:

  1. Rendering: convert &[Color] (256×240 already-resolved NES palette colors) to your target pixel format and display.
  2. Input handling: implement HostInput to expose host keyboard / gamepad state as GamepadState.
  3. Frame timing: drive run_frame at your own cadence (~60 Hz for authentic speed, or unlimited for testing).
  4. Audio (optional): APU emulation is not yet implemented in this crate (planned for a future release); if you need audio now, expose the APU register reads/writes from your front-end and render the samples yourself.

Suggested front-end stacks:

  • SDL2 — simple cross-platform graphics and input
  • wgpu — modern GPU-accelerated rendering
  • Bevy — full-featured game engine with ECS
  • druid / iced — GUI framework for emulator settings / UI
  • rodio — audio playback (once APU support lands in this crate, or if you implement it yourself)

Module reference

cartridge — Cartridge trait

The contract every NES mapper must satisfy. The system bus dispatches reads and writes here; front-end code does not call these directly.

pub trait Cartridge {
    fn read_cpu_mapped(&self, addr: u16) -> u8;
    fn write_cpu_mapped(&mut self, data: u8, addr: u16) -> ChrRomContentStatus;
    fn read_ppu_mapped(&mut self, addr: u16) -> u8;
    fn write_ppu_mapped(&mut self, data: u8, addr: u16) -> ChrRomContentStatus;
    fn irq_pin(&self) -> bool { false }                                   // override for MMC3
    fn requires_cycle_accurate_sprites(&self) -> bool { false }           // override for MMC3
    fn notify_vram_addr_change(&mut self, _old_addr: u16, _new_addr: u16) {} // A12-clocked counters
}

ChrRomContentStatus (returned by writes) lets the PPU decide whether its caches need invalidating.

input — Host input

pub trait HostInput {
    fn input_device_state(&mut self, player: ConnectedSocket) -> InputDeviceState;
}

pub enum InputDeviceState {
    Gamepad(GamepadState),
    Disconnected,  // default
}

pub struct GamepadState {
    pub buttons_pressed: GamepadButtonsPressedFlags, // bitflags: A, B, SELECT, START, UP, DOWN, LEFT, RIGHT
}

pub enum ConnectedSocket { Player1 = 0, Player2 = 1 }

InputRegisters (also re-exported) drives the serial shift-register protocol at $4016 / $4017. Most front-ends don't need to touch it directly — NesConsole::run_frame handles strobe and shift transparently.

ppu — Picture Processing Unit

Rendering pipeline (per visible scanline, 0–239):

  1. Name table lookup: For each tile (32 × 8 = 256 pixels wide), read tile ID from the name table.
  2. Attribute table lookup: Read palette index (0–3) from the attribute table.
  3. Pattern table lookup: Fetch the 8×8 tile bitmap from CHR ROM using the tile ID.
  4. Sprite evaluation: For each scanline, evaluate OAM to find up to 8 sprites per scanline.
  5. Pixel composition: For each pixel, composite background and sprite layers with priority.

Memory organization:

  • PPU registers ($2000–$2007):
    • $2000 PPUCTRL — NMI enable, sprite size, name-table base
    • $2001 PPUMASK — rendering enable, emphasis bits
    • $2002 PPUSTATUS — VBLANK, sprite overflow, sprite-0 hit
    • $2003 OAMADDR / $2004 OAMDATA — OAM access
    • $2005 PPUSCROLL — background scroll X/Y
    • $2006 PPUADDR / $2007 PPUDATA — VRAM access with read buffer
  • VRAM ($0000–$3FFF):
    • $0000–$1FFF — CHR ROM (graphics from cartridge)
    • $2000–$2FFF — name tables with mirroring
    • $3000–$3EFF — mirror of name tables
    • $3F00–$3FFF — palette RAM (16 entries × 4 backgrounds + 4 sprites)
  • OAM — 256 bytes, 64 sprites × 4 bytes (Y, tile ID, attributes, X)

Mirroring modes:

  • Horizontal — name tables 0 & 1 are horizontally mirrored; 2 & 3 too
  • Vertical — name tables 0 & 2 are vertically mirrored; 1 & 3 too
  • One-screen — single name table repeated (single-screen boards)
  • Four-screen — cartridge provides all four name tables (e.g. MMC3 with no mirroring bit set)

API:

impl Ppu {
    pub fn new() -> Self;
    pub fn reset(&mut self);
    pub fn screen_colors(&self) -> &[Color];    // 256×240 already-resolved colors

    /// Advance the PPU by one cycle.
    pub fn run_cycle(&mut self, cart: &mut impl PpuCartMemorySpace) -> FrameEvent;
    pub fn nmi_signal(&self) -> bool;

    pub fn write_ppu_register(&mut self, value: u8, register: Register, cart: &mut impl PpuCartMemorySpace);
    pub fn read_ppu_register(&mut self, register: Register, cart: &mut impl PpuCartMemorySpace) -> u8;
    pub fn write_ppu_register_by_addr(&mut self, addr: u16, value: u8, cart: &mut impl PpuCartMemorySpace);
    pub fn read_ppu_register_by_addr(&mut self, addr: u16, cart: &mut impl PpuCartMemorySpace) -> u8;
}

pub enum FrameEvent { None, EndOfScanline, ReadyToPresent, EndOfFrame }
pub enum Register { PpuControl, PpuMask, PpuStatus, OamAddr, OamData, PpuScroll, PpuAddr, Data }

nes — System integration

NesConsole wires together CPU, PPU, cartridge, and input.

System components:

  • CPU bus — 16-bit address space:
    • $0000–$07FF — CPU internal RAM (2 KB, mirrored 4×)
    • $2000–$2007 — PPU registers
    • $4000–$401F — APU and I/O registers (including controller input)
    • $8000–$FFFF — cartridge ROM/RAM
  • PPU bus — video memory (see ppu module).
  • OAM (Object Attribute Memory) — 256 bytes of sprite data
  • Controllers — two ports (Player 1 / Player 2) polled through input::HostInput

Execution model: NesConsole::run_frame(&mut host_input) executes one complete frame:

  • Scanlines 0–239: visible rendering area
  • Scanline 240: post-render (VBLANK starts)
  • Scanlines 241–260: vertical blank period (no PPU rendering)
  • Scanline 261: pre-render (sprite evaluation, flag clearing)

Internally the CPU executes ~114 cycles per scanline; the PPU runs at 3× CPU clock so each CPU cycle produces three PPU cycles.

API:

impl NesConsole {
    pub fn new() -> Self;
    pub fn insert_cartridge(&mut self, cart: Box<dyn Cartridge>);   // panics if already inserted
    pub fn switch_on(&mut self);                                  // power on (resets CPU + PPU)
    pub fn switch_off(&mut self);
    pub fn reset(&mut self);                                      // CPU + PPU + cycle counter reset
    pub fn remove_cartridge(&mut self);                           // panics if currently on
    pub fn cartridge_connected(&self) -> bool;
    pub fn on(&self) -> bool;
    pub fn screen_colors(&self) -> &[Color];

    /// Low-level: one CPU cycle + three PPU cycles.
    pub fn run_console_cycle(&mut self, cart: &mut dyn Cartridge, host: &mut impl HostInput) -> FrameEvent;

    /// Run until the PPU indicates the next frame is ready to present.
    pub fn run_until_present(&mut self, host: &mut impl HostInput);

    /// Run one complete frame (262 scanlines).
    pub fn run_frame(&mut self, host: &mut impl HostInput);
}

Internal module layout:

  • nes::systemNesConsole type and frame execution
  • nes::ppu_cart_memory — PPU address space (CHR ROM, VRAM, palette)
  • nes::dma — OAM DMA controller for sprite data transfers

rom_loader — iNES parser and mapper dispatch

Given a 16-byte iNES header plus the trailing PRG / CHR bytes, returns a Box<dyn Cartridge> ready for NesConsole::insert_cartridge.

iNES file format:

Offset   Size    Description
------   ----    -----------
0x00     4       Magic: "NES\x1A"
0x04     1       PRG ROM size in 16 KB units
0x05     1       CHR ROM size in 8 KB units (0 means CHR RAM)
0x06     1       Flags 6: mapper low, mirroring, PRG RAM, trainer, four-screen
0x07     1       Flags 7: mapper high, iNES 2.0 marker
0x08+    var     Optional 512-byte trainer (if flag 6 bit 2 is set)
N+       var     PRG ROM data
N+M      var     CHR ROM data

API:

pub type LoadRomResult = Result<Box<dyn Cartridge>, RomError>;

pub fn load_rom(rom_reader: &mut impl Read) -> LoadRomResult;

pub enum RomError {
    Io(std::io::Error),
    RomFormat(String),
    UnsupportedMapper(u8),  // known mapper id, not compiled in
}

pub struct HeaderData {
    pub prg_rom_size: u64,
    pub chr_rom_size: u64,
    pub mirroring: Mirroring,
    pub has_persistent_ram: bool,
    pub has_trainer: bool,
    pub mapper: u8,
    pub vs_unisystem: bool,
    pub playchoice: bool,
    pub ines2: bool,
    pub prg_ram_size: u16,
}

pub enum Mirroring { Horizontal, Vertical, FourScreen }

Supported mappers:

ID Name Features Status
0 NROM No bank switching; 16 KB or 32 KB PRG; 8 KB CHR ROM or CHR RAM ✅ Implemented
1 MMC1 PRG/CHR bank switching, mirroring, PRG RAM protect ✅ Implemented
2 UxROM 16 KB switchable PRG + fixed last bank; CHR fixed ✅ Implemented
3 CNROM 32 KB fixed PRG; 8 KB switchable CHR ROM ✅ Implemented
4 MMC3 PRG/CHR bank switching, scanline IRQ counter ✅ Implemented
Others ❌ Not implemented (returns RomError::UnsupportedMapper(mapper_id))

NROM, UxROM, and CNROM share rom_loader::ines::mappers::basic_mapper. Bus conflicts that real UxROM / CNROM boards exhibit on writes to $8000–$FFFF are intentionally not emulated.

Adding new mappers: implement Cartridge, create src/rom_loader/ines/mappers/mapperNNN.rs with pub fn load(&HeaderData, &mut impl Read) -> LoadRomResult, add a match arm in rom_loader::load_rom, and add unit tests.

Known issues:

  • Trainer block not skipped. When header[6] bit 2 is set, the 512-byte trainer area is read as if it were PRG ROM.
  • NES 2.0 largely ignored. The ines2 flag is not decoded; extended fields in header[8..15] are dropped. NES 2.0 ROMs parse as iNES 1.0.
  • No board-quirk handling on mappers 1 and 4 beyond the basics.

ines_header_inspector binary

A debug tool that scans test_assets/ for .nes files and writes header_info.txt summarising each ROM's header:

cargo run --bin ines_header_inspector

Run from the repo root so it can find the test_assets/ directory.

Development notes

  • Architecture philosophy:

    • Each top-level module (cartridge, input, ppu, nes, rom_loader) has a single responsibility and a narrow public surface
    • Memory built from trait-based device composition via devices6502, with no allocations in the hot path
    • Testable: per-module tests verify behavior in isolation
    • Performance-conscious: cycle accuracy without overhead; bit-accurate pixel rendering
  • When modifying the PPU:

    • PPU rendering is bit-accurate; small changes to pixel math can break test ROM behavior
    • Add focused unit tests for sprite priority, palette selection, and flipping
    • Test against ROM test suites (e.g. nestest) if available
    • VRAM read buffering is a hardware quirk: the first read after an address write returns the buffered value, not the new value
  • When modifying cartridge / mapper logic:

    • Mappers handle bank switching and address translation; errors affect every CPU / PPU memory access
    • Add mapper-specific tests for bank switching sequences and ROM paging
    • Use the mapper_debug_log feature to trace bank-switching
  • Performance tuning (from Cargo.toml):

    • [profile.dev.package."*"] opt-level = 3 — fast dependency builds
    • [profile.dev] opt-level = 1 — reasonable debug iteration
    • [profile.release] lto = "fat" — full link-time optimization
    • Profile CPU-heavy paths with perf or flamegraph

License

Licensed under either of:

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.