Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions README-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# PyNes

[English Documentation](README.md)

## 简介 (Introduction)

PyNes 是一个旨在利用 Python 和 Pygame 构建一个功能完善的 NES(Nintendo Entertainment System)模拟器项目。它是一个非常有用的教育工具,有助于理解复古游戏机的底层硬件交互和基于周期的精准模拟技术。

## 依赖要求 (Requirements)

请确保你的系统上安装了 Python 3。您可以使用 `pip` 命令安装所有必需的依赖:

```bash
pip install -r requirements.txt
```

项目的核心依赖库:
- `pygame` (用于屏幕渲染和输入捕获)
- `loguru` (用于日志记录和调试)
- `typer` (支持命令行参数)
- `easydict` 和 `bitarray` (数据结构和位操作)

## 使用方法 (Usage)

在项目的根目录下执行以下脚本即可启动模拟器:

```bash
python main.py
```

默认情况下,模拟器会加载 `roms/` 文件夹下的《吃豆人》(Pac-Man) 示例游戏。

### 操作说明 (Controls)

标准的 PC 键盘与 NES 手柄的映射关系如下:

| NES 手柄按键 | PC 键盘按键 |
|--------------|-------------|
| A | Z |
| B | X |
| Select | A |
| Start | S |
| 方向键 上 | 上箭头 |
| 方向键 下 | 下箭头 |
| 方向键 左 | 左箭头 |
| 方向键 右 | 右箭头 |

## 项目架构 (Structure)

模拟器分为独立的模块化硬件组件:

- **总线 Bus (`bus.py`)**:核心的通信中枢。负责连接 CPU、PPU、内存 RAM 和卡带,并将读写操作路由到映射到正确内存地址的设备。
- **中央处理器 CPU (`cpu.py`)**:模拟 Ricoh 2A03 (基于 MOS 6502) 处理器。主要负责取指、解码、执行指令,以及管理内部寄存器。
- **图形处理器 PPU (`ppu.py`)**:图像处理单元 (Ricoh 2C02)。主要负责将图形、背景(NameTables)和精灵(OAM)渲染到屏幕上。
- **卡带与内存映射 Cartridge & Mapper (`cartridge.py`, `mapper.py`)**:负责加载 `.nes` ROM 文件,并处理内存 Bank 的切换(Mapper),将 PRG 和 CHR ROM 数据映射到 CPU 和 PPU 的寻址空间中。
- **引擎 Engine (`engine.py`)**:使用 Pygame 封装的前端界面。负责将像素绘制到显示窗口并持续轮询硬件键盘事件。
57 changes: 53 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,56 @@
# PyNes

[中文说明](README-CN.md)

## Introduction
It is a project to build a nes simulator with Python

PyNes is a project focused on building a fully functional NES (Nintendo Entertainment System) simulator using Python and Pygame. It serves as an educational tool to understand the low-level hardware interactions and cycle-accurate emulation of classic retro gaming consoles.

## Requirements

Ensure you have Python 3 installed. You can install all necessary dependencies using `pip`:

```bash
pip install -r requirements.txt
```

The core dependencies are:
- `pygame` (Rendering and input processing)
- `loguru` (Logging and debugging)
- `typer` (CLI support)
- `easydict` & `bitarray` (Data structures and bit manipulation)

## Usage

To start the emulator, simply run the main script from the root directory:

```bash
python main.py
```

By default, the emulator loads the Pac-Man sample ROM located in the `roms/` directory.

### Controls

The standard PC keyboard is mapped to the NES controller as follows:

| NES Button | PC Key |
|------------|--------|
| A | Z |
| B | X |
| Select | A |
| Start | S |
| D-Pad Up | Up Arrow |
| D-Pad Down | Down Arrow |
| D-Pad Left | Left Arrow |
| D-Pad Right| Right Arrow |

## Structure
1. bus
2. cpu
3. ram

The emulator is separated into distinct modular hardware components:

- **Bus (`bus.py`)**: The central communication backbone. It connects the CPU, PPU, RAM, and cartridges, routing read/write operations to the correct device mapped in memory.
- **CPU (`cpu.py`)**: An emulation of the Ricoh 2A03 (MOS 6502 based) processor. It handles fetching, decoding, and executing instructions and managing internal registers.
- **PPU (`ppu.py`)**: The Picture Processing Unit (Ricoh 2C02). It is responsible for rendering graphics, backgrounds (NameTables), and sprites (OAM) to the screen.
- **Cartridge & Mapper (`cartridge.py`, `mapper.py`)**: Handles loading `.nes` ROM files and manages memory bank switching (Mappers) to map PRG and CHR ROM data into the CPU and PPU address spaces.
- **Engine (`engine.py`)**: The Pygame frontend responsible for painting pixels to the display window and polling hardware keyboard events.
34 changes: 28 additions & 6 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,56 @@
from pynes.cartridge import Cartridge
from loguru import logger
import time
import pygame

logger.remove()
logger.add(sys.stderr, level="INFO")

@logger.catch
def main(debug:int=typer.Option(-1, '-d', '--debug')):
"""
Main entrypoint of the NES Simulator. It initializes the core system components (Bus, CPU, PPU),
connects the game cartridge, and starts the infinite loop to run system clock cycles until
the user quits the Pygame window.
"""

bus = Bus()
bus.connect(CPU())
bus.connect(PPU())
# bus.connect_cartridge(Cartridge('roms/Tetris (USA) (Tengen) (Unl).nes'))
# bus.connect_cartridge(Cartridge('roms/Pac-Man (USA) (Namco).nes'))
bus.connect_cartridge(Cartridge('roms/full_palette.nes'))
bus.connect_cartridge(Cartridge('roms/Pac-Man (USA) (Namco).nes'))
# bus.connect_cartridge(Cartridge('roms/full_palette.nes'))
# bus.connect_cartridge(Cartridge('roms/helloworld.nes'))
# bus.connect_cartridge(Cartridge('roms/starter.nes'))
bus.reset()
DEBUG = False

ts = time.time()
while True:

while not bus.ppu.engine.finished:
c = bus.nSystemClockCounter
cpu_cycle = bus.cpu.clock_count
debug_ready = not bus.dma_transfer and bus.cpu.cycles == 0

# Poll inputs once per frame (PPU updates on scanline 261)
if bus.ppu.frame_complete:
bus.ppu.frame_complete = False
keys = bus.ppu.engine.get_keys()
if keys:
# NES controller layout: A, B, Select, Start, Up, Down, Left, Right
controller = 0
controller |= (1 << 7) if keys[pygame.K_z] else 0 # A
controller |= (1 << 6) if keys[pygame.K_x] else 0 # B
controller |= (1 << 5) if keys[pygame.K_a] else 0 # Select
controller |= (1 << 4) if keys[pygame.K_s] else 0 # Start
controller |= (1 << 3) if keys[pygame.K_UP] else 0 # Up
controller |= (1 << 2) if keys[pygame.K_DOWN] else 0 # Down
controller |= (1 << 1) if keys[pygame.K_LEFT] else 0 # Left
controller |= (1 << 0) if keys[pygame.K_RIGHT] else 0 # Right
bus.controller[0] = controller

if c % 100000 == 0:
logger.info(f'cycles: {c}, cpu_cycle: {cpu_cycle}')
if c == 1000000:
logger.info(f'time spend: {time.time() - ts}')
break
bus.clock()
#[86694, 655061] target 655061
if cpu_cycle == debug and not DEBUG:
Expand Down
10 changes: 10 additions & 0 deletions pynes/bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@


class Bus(Device):
"""
The Bus represents the central communication hub of the NES.
It connects the CPU, PPU, APU, Controllers, and Cartridge together
by mapping their registers and internal RAM into a single 16-bit address space.
"""
def __init__(self, address_count: int = 16, data_count: int = 8, name: str = '') -> None:
super().__init__(name=name)
self.address_count = address_count
Expand Down Expand Up @@ -98,6 +103,11 @@ def reset(self) -> None:


def clock(self) -> None:
"""
Steps the system simulation forward by one clock cycle.
The PPU runs 3 times as fast as the CPU. The Bus ensures that the CPU
only ticks once every 3 system clock cycles. It also handles DMA transfers.
"""
self.ppu.clock()
if self.nSystemClockCounter % 3 == 0:
if self.dma_transfer:
Expand Down
18 changes: 15 additions & 3 deletions pynes/cpu.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@


class CPU(Device):
"""
Emulates the Ricoh 2A03 processor (a variant of the MOS Technology 6502).
It manages fetching, decoding, and executing instructions from memory, and interacts
with internal registers: Accumulator (A), X Index (X), Y Index (Y), Program Counter (PC),
Stack Pointer (SP), and Status Flags.
"""
def __init__(self, debug: bool = False, name: str = '') -> None:
super().__init__(name=name)

Expand Down Expand Up @@ -212,7 +218,7 @@ def SBC(self) -> int:
self.temp = u16(self.a) + val + self.getFlag('C')
self.setFlag('C', self.temp > 255)
self.setFlag('Z', self.temp & 0x00FF == 0)
self.setFlag('V', (~(self.a ^ self.fetched) &
self.setFlag('V', (~(self.a ^ val) &
(self.a ^ self.temp)) & 0x0080)
self.setFlag('N', self.temp & 0x80)
self.a = u8(self.temp)
Expand Down Expand Up @@ -490,7 +496,6 @@ def PHA(self) -> int:
def PHP(self) -> int:
self.write(0x0100 + self.stkp, self.status | FLAGS6502.B | FLAGS6502.U)
self.setFlag("B", 0)
self.setFlag("U", 0)
self.stkp = u8(self.stkp - 1)
return 0

Expand All @@ -505,6 +510,7 @@ def PLP(self) -> int:
self.stkp = u8(self.stkp + 1)
self.status = self.read(0x0100 + self.stkp)
self.setFlag("U", 1)
self.setFlag("B", 0)
return 0

def ROL(self) -> int:
Expand Down Expand Up @@ -535,7 +541,7 @@ def RTI(self) -> int:
self.stkp = u8(self.stkp + 1)
self.status = self.read(0x0100 + self.stkp)
self.status &= ~FLAGS6502.B
self.status &= ~FLAGS6502.U
self.status |= FLAGS6502.U
self.stkp = u8(self.stkp + 1)
self.pc = self.read(0x0100 + self.stkp)
self.stkp = u8(self.stkp + 1)
Expand Down Expand Up @@ -648,6 +654,12 @@ def nmi(self) -> None:
self.cycles = 8

def clock(self) -> None:
"""
Executes a single cycle of the CPU. If the CPU is not currently waiting out
cycles for a previously fetched instruction, it fetches the next opcode at the
Program Counter, decodes the addressing mode, executes the operation, and calculates
the additional cycles consumed.
"""
if self.cycles == 0:
self.optcode = self.read(self.pc)
self.setFlag('U', True)
Expand Down
20 changes: 20 additions & 0 deletions pynes/engine.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import pygame

class Engine:
"""
Engine class encapsulates Pygame initialization and loop management.
It provides an interface for drawing pixels to the display window and retrieving
pressed keyboard events to be mapped to the NES controller keys.
"""
def __init__(self):
pygame.init()
self.screen = pygame.display.set_mode([256, 240])
Expand All @@ -12,6 +17,10 @@ def set_pixel(self, x, y, color):
self.screen.set_at((x, y), color)

def update(self):
"""
Updates the display surface and checks for OS events such as quit commands.
Called once per frame generated by the PPU.
"""
if not self.finished:
self.running = True
if self.running:
Expand All @@ -23,6 +32,17 @@ def update(self):
self.finished = True
pygame.quit()

def get_keys(self):
"""
Reads the current state of the keyboard.
We need to process events so that key.get_pressed() updates correctly
if there's any pending events not caught by update() yet.
"""
pygame.event.pump()
if not self.running:
return {}
return pygame.key.get_pressed()

if __name__ == '__main__':
e = Engine()
for i in range(10000):
Expand Down
11 changes: 11 additions & 0 deletions pynes/ppu.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ def set_flag(reg, reg2, flag):
return reg

class PPU(Device):
"""
Emulates the Ricoh 2C02 Picture Processing Unit (PPU).
It maintains its own memory space (VRAM, OAM, Palettes) separate from the CPU
and handles generating the 256x240 pixel screen output, refreshing once per frame.
"""
def __init__(self, name: str=None) -> None:
super().__init__(name=name)

Expand Down Expand Up @@ -359,6 +364,12 @@ def UpdateShifters(self):
self.sprite_shifter_pattern_hi[i] = u8(self.sprite_shifter_pattern_hi[i] << 1)

def clock(self) -> None:
"""
Executes a single clock tick for the PPU. Because the PPU is tied intimately
to the NTSC television signal standard, the screen generation is driven by
calculating positions of scanlines and dots (cycles). When reaching the end
of a frame, it signals VBLANK and triggers NMI to the CPU if enabled.
"""
if self.scanline >= -1 and self.scanline < 240:
if self.scanline == 0 and self.cycle == 0 and self.odd_frame and (self.mask & (MASK_FLAG.render_background | MASK_FLAG.render_sprites)):
self.cycle = 1
Expand Down
6 changes: 5 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
bitarray
bitarray
typer
loguru
pygame
easydict