nes_ebook

repository·master·Indexed 19 days ago

https://github.com/bugzmanov/nes_ebook

A technical resource and ebook focused on NES (Nintendo Entertainment System) development. The project includes instructional content and practical code examples, featuring a Rust-based CPU emulator (nes_book_emu v0.1.0) with implementations of the 6502 status register, memory bus, and opcode mapping. It provides a functional example of a Snake game emulator using SDL2 for input handling and screen rendering.

Tokens
53.7K
Snippets
182
Records
241
Agent score
63%

What's inside nes_ebook

  1. Access the NES Ebook and Source Code

    master

    The nes_ebook project consists of a technical book about NES development. You can access the published book, the source code for the examples used within the book, and the source code for the book itself (built with mdbook) via the following links:

  2. Understand the NES CPU Memory Map

    master

    The NES CPU uses 16-bit addressing, allowing access to 65,536 memory cells. The memory map is divided into several functional regions:

    • [0x0000 … 0x2000]: Internal 2 KiB RAM.
    • [0x2000 … 0x4020]: Redirected to hardware modules like the PPU (Picture Processing Unit), APU (Audio Processing Unit), and GamePads.
    • [0x4020 … 0x6000]: Cartridge-specific space (mapped to RAM, ROM, or nothing via mappers).
    • [0x6000 … 0x8000]: Reserved for cartridge RAM (used for game state, e.g., Zelda).
    • [0x8000 … 0xFFFF]: Program ROM (PRG ROM) space on the cartridge.
  3. Implement VRAM Mirroring for the PPU

    master

    The NES uses 2 KiB of VRAM to represent two screen states, but the PPU memory map reserves 4 KiB for Nametables ([0x2000...0x3000]). To handle this, you must implement mirroring logic that maps the extra address space back to the existing 2 KiB of VRAM. The specific mapping depends on whether the game uses Mirroring::Horizontal or Mirroring::Vertical (information typically found in the iNES header).

    Horizontal Mirroring Mapping:

    • [0x2000 .. 0x2400] and [0x2400 .. 0x2800] map to the first 1 KiB of VRAM.
    • [0x2800 .. 0x2C00] and [0x2C00 .. 0x3000] map to the second 1 KiB of VRAM.
    • The region [0x3000 .. 0x3EFF] is a mirror of [0x2000 .. 0x2EFF].
    impl NesPPU {
       // Horizontal:
       //   [ A ] [ a ]
       //   [ B ] [ b ]
     
       // Vertical:
       //   [ A ] [ B ]
       //   [ a ] [ b ]
       pub fn mirror_vram_addr(&self, addr: u16) -> u16 {
           let mirrored_vram = addr & 0b10111111111111; // mirror down 0x3000-0x3eff to 0x2000 - 0x2eff
           let vram_index = mirrored_vram - 0x2000; // to vram vector
           let name_table = vram_index / 0x400; // to the name table index
           match (&self.mirroring, name_table) {
               (Mirroring::Vertical, 2) | (Mirroring::Vertical, 3) => vram_index - 0x800,
               (Mirroring::Horizontal, 2) => vram_index - 0x400,
               (Mirroring::Horizontal, 1) => vram_index - 0x400,
               (Mirroring::Horizontal, 3) => vram_index - 0x800,
               _ => vram_index,
           }
       }
    }
  4. Understand NES Background Rendering Components

    master

    The NES background is composed of three main memory sections:

    1. Pattern Table: One of two banks of tiles from CHR ROM.
    2. Nametable: The state of a screen stored in VRAM. A single frame consists of 960 tiles (each 8x8 pixels), where each tile is represented by one byte in the Nametable. Additionally, a Nametable holds 64 bytes for color palette information (the attribute table).
    3. Palette Table: Information about the actual coloring of pixels, stored in internal PPU memory.

    To render a background, you must:

    1. Determine the active Nametable (via bits 0 and 1 of the Control register).
    2. Determine the CHR ROM bank used for background tiles (via bit 4 of the Control register).
    3. Read the 960 bytes from the Nametable and map them to tiles in the Pattern Table.
  5. How the NES architecture works

    master

    The NES operates without an Operating System. Unlike modern computers where applications interact with hardware through an OS, NES applications (games) communicate directly with the hardware using machine language. This means the machine language serves as the primary interface between the emulator and the NES games.

    In the context of building an emulator, the goal is to simulate the NES Computer Architecture, the Arithmetic Logic Unit (ALU), and Memory using high-level language constructs (like Rust) instead of simulating individual logic gates and boolean arithmetic.

  6. Map PRG ROM to the Bus address space

    master

    The cartridge's PRG ROM must be mapped to the CPU's address space in the range [0x8000 … 0xFFFF].

    Handling 16 KiB vs 32 KiB ROM

    Because the mapped region is 32 KiB, if a game only has 16 KiB of PRG ROM, the upper 16 KiB of the address space must be mirrored to the lower 16 KiB. This is handled by applying a modulo operation on the address during the read process.

    Implementation Details

    • Read: Use read_prg_rom to access the data. If the ROM size is 16 KiB (0x4000), mirror the address using addr % 0x4000 if the address exceeds the ROM size.
    • Write: Writing to the cartridge ROM space should trigger a panic!, as ROM is read-only.
    impl Mem for Bus {
       fn mem_read(&self, addr: u16) -> u8 {
           match addr {
               //…
               0x8000..=0xFFFF => self.read_prg_rom(addr),
           }
       }
    
       fn mem_write(&mut self, addr: u16, data: u8) {
           match addr {
               //…
               0x8000..=0xFFFF => {
                   panic!("Attempt to write to Cartridge ROM space")
               }
           }
       }
    }
    
    impl Bus {
      // …
    
       fn read_prg_rom(&self, mut addr: u16) -> u8 {
           addr -= 0x8000;
           if self.rom.prg_rom.len() == 0x4000 && addr >= 0x4000 {
               //mirror if needed
               addr = addr % 0x4000;
           }
           self.rom.prg_rom[addr as usize]
       }
    }
  7. How addressing modes work in the NES CPU

    master

    An addressing mode defines how the CPU interprets the subsequent 1 or 2 bytes in the instruction stream to find an operand. Instruction sizes vary based on the mode:

    • Zero Page: 2 bytes total (1 opcode + 1 parameter). References the first 256 bytes of memory.
    • Absolute: 3 bytes total (1 opcode + 2 parameter bytes). Can reference the full 64 KiB address space.

    To avoid duplicating logic for every instruction (like LDA or STA), implement a central get_operand_address method that takes an AddressingMode enum and returns the calculated u16 target address.

    #[derive(Debug)]
    #[allow(non_camel_case_types)]
    pub enum AddressingMode {
       Immediate,
       ZeroPage,
       ZeroPage_X,
       ZeroPage_Y,
       Absolute,
       Absolute_X,
       Absolute_Y,
       Indirect_X,
       Indirect_Y,
       NoneAddressing,
    }
  8. Calculate Background Palette using Attribute Tables

    master

    The NES uses an Attribute Table (the last 64 bytes of a Nametable) to assign palettes to background tiles.

    • One byte in the attribute table controls a 2x2 block of tiles (a 4x4 tile area, or 32x32 pixels).
    • Each byte is split into four 2-bit blocks. Each block selects one of the four palettes available for that area.
    • For background tiles, a value of 0b00 refers to the Universal background color stored at 0x3F00.

    To find the palette for a specific tile, calculate the attribute table index based on the tile's column and row, then extract the correct 2-bit block.

    fn bg_pallette(ppu: &NesPPU, tile_column: usize, tile_row : usize) -> [u8;4] {
       let attr_table_idx = tile_row / 4 * 8 +  tile_column / 4;
       let attr_byte = ppu.vram[0x3c0 + attr_table_idx];  // note: still using hardcoded first nametable
    
       let pallet_idx = match (tile_column %4 / 2, tile_row % 4 / 2) {
           (0,0) => attr_byte & 0b11,
           (1,0) => (attr_byte >> 2) & 0b11,
           (0,1) => (attr_byte >> 4) & 0b11,
           (1,1) => (attr_byte >> 6) & 0b11,
           (_,_) => panic!("should not happen"),
       };
    
       let pallete_start: usize = 1 + (pallet_idx as usize)*4;
       [ppu.palette_table[0], ppu.palette_table[pallete_start], ppu.palette_table[pallete_start+1], ppu.palette_table[pallete_start+2]]
    }
  9. Understand NES Joypad Emulation Logic

    master

    NES joypads are mapped to CPU addresses 0x4016 (Joypad 1) and 0x4017 (Joypad 2). The registers are used for both reading and writing.

    Reading Behavior

    Reading from the register reports the state of a single button at a time (1 for pressed, 0 for released). To retrieve the state of all buttons, the CPU must perform 8 consecutive reads. The buttons are reported in this specific order:

    A -> B -> Select -> Start -> Up -> Down -> Left -> Right

    After the RIGHT button is reported, subsequent reads will continually return 1 until a strobe mode change occurs.

    Writing Behavior (Strobe Mode)

    Writing a byte to the register controls the 'strobe' mode via the first bit:

    • Strobe bit ON (1): The controller resets its internal pointer and reports only the status of button A on every read.
    • Strobe bit OFF (0): The controller cycles through all buttons sequentially on each read.

    Standard CPU Read Cycle

    To read a full joypad state, the CPU typically follows these steps:

    1. Write 0x1 to 0x4016 (enables strobe to reset pointer to button A).
    2. Write 0x00 to 0x4016 (disables strobe to allow cycling).
    3. Read from 0x4016 eight times.
    4. Repeat.
  10. How the Bus abstraction works in NES emulation

    master

    In this project, the Bus is not implemented as a physical hardware simulation of address, control, and data lines. Instead, it serves as a coordination and routing layer between platform components.

    Key responsibilities of the Bus module include:

    • Intra-device communication: Facilitating data reads and writes between components.
    • Interrupt routing: Routing hardware interrupts to the CPU.
    • Memory mapping: Handling how different address ranges map to specific hardware (like RAM or PPU registers).
    • Clock coordination: Coordinating clock cycles between the PPU and CPU.

    By using a Bus, the CPU code remains clean and oblivious to the specific memory-mapped regions, interacting only with the bus interface.

  11. Use Sprite Zero Hit for mid-frame progress

    master

    While the NMI interrupt signals the end of a frame, many games use the Sprite Zero Hit flag to detect mid-frame progress. This is useful for smooth scrolling.

    To use this mechanism:

    1. Place a sprite with index 0 at a specific screen location (X, Y).
    2. Poll the PPU status register 0x2002.
    3. When the sprite_zero_hit flag changes from 0 to 1, the CPU knows the PPU has finished rendering scanlines [0 .. Y] and has finished rendering up to X pixels on the Y scanline.

    Note: In a simulation, the flag should be erased upon entering the VBLANK state.

    // Example logic for checking sprite zero hit in a PPU tick
    if self.is_sprite_0_hit(self.cycles) {
        self.status.set_sprite_zero_hit(true);
    }
  12. Implement RAM and PPU mirroring in the Bus

    master

    The NES uses address mirroring to map smaller physical memory spaces into larger CPU address ranges. The Bus must handle this by masking the address bits to route requests to the correct physical location.

    RAM Mirroring

    The CPU RAM is 2 KiB ([0x0000 .. 0x1FFF]). Because the physical RAM only requires 11 bits for addressing, the highest 2 bits of a 13-bit CPU request are ignored. This results in the RAM being mirrored three times:

    • [0x0000 .. 0x0800]
    • [0x0800 .. 0x1000]
    • [0x1000 .. 0x1800]
    • [0x1800 .. 0x2000]

    To implement this, the Bus should mask the address using addr & 0b00000111_11111111 (or addr & 0x07FF) for reads, and addr & 0b11111111111 (or addr & 0x07FF) for writes to target the 2048-byte cpu_vram array.

    PPU Register Mirroring

    The PPU register space [0x2000 .. 0x2007] is mirrored in the range [0x2008 .. 0x3FFF].

    const RAM: u16 = 0x0000;
    const RAM_MIRRORS_END: u16 = 0x1FFF;
    const PPU_REGISTERS: u16 = 0x2000;
    const PPU_REGISTERS_MIRRORS_END: u16 = 0x3FFF;
    
    impl Mem for Bus {
       fn mem_read(&self, addr: u16) -> u8 {
           match addr {
               RAM ..= RAM_MIRRORS_END => {
                   let mirror_down_addr = addr & 0b00000111_11111111;
                   self.cpu_vram[mirror_down_addr as usize]
               }
               PPU_REGISTERS ..= PPU_REGISTERS_MIRRORS_END => {
                   let _mirror_down_addr = addr & 0b00100000_00000111;
                   todo!("PPU is not supported yet")
               }
               _ => {
                   println!("Ignoring mem access at {}", addr);
                   0
               }
           }
       }
    
       fn mem_write(&mut self, addr: u16, data: u8) {
           match addr {
               RAM ..= RAM_MIRRORS_END => {
                   let mirror_down_addr = addr & 0b11111111111;
                   self.cpu_vram[mirror_down_addr as usize] = data;
               }
               PPU_REGISTERS ..= PPU_REGISTERS_MIRRORS_END => {
                   let _mirror_down_addr = addr & 0b00100000_00000111;
                   todo!("PPU is not supported yet");
               }
               _ => {
                   println!("Ignoring mem write-access at {}", addr);
               }
           }
       }
    }