From 096084b875c2a6502dd8f717e450f0ea87f596fc Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:54:04 +0000 Subject: [PATCH 1/3] feat: Complete emulator loop and add keyboard input support - Removed 1,000,000 cycle limitation in main loop so simulation runs continuously. - Replaced debug ROM with Pac-Man (USA) (Namco).nes sample game. - Added get_keys() in pynes/engine.py to process pygame events and capture input. - Mapped pygame keyboard keys to NES controller bit sequence (A, B, Select, Start, Up, Down, Left, Right). - Ensured inputs are polled once per frame when the PPU signals frame_complete. Co-authored-by: zqigolden <27563199+zqigolden@users.noreply.github.com> --- main.py | 29 +++++++++++++++++++++++------ pynes/engine.py | 8 ++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index 9a336fb..674136a 100755 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ from pynes.cartridge import Cartridge from loguru import logger import time +import pygame logger.remove() logger.add(sys.stderr, level="INFO") @@ -18,23 +19,39 @@ def main(debug:int=typer.Option(-1, '-d', '--debug')): 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: diff --git a/pynes/engine.py b/pynes/engine.py index 3e750e7..b466fe7 100644 --- a/pynes/engine.py +++ b/pynes/engine.py @@ -23,6 +23,14 @@ def update(self): self.finished = True pygame.quit() + def get_keys(self): + # 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): From 8634a77964764c7d8307e6f624bc873d9b81abcb Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:54:09 +0000 Subject: [PATCH 2/3] fix(cpu): correct 6502 flags and instructions - Fixed SBC overflow logic to use inverted operand (val) - Fixed PHP to correctly handle B flag - Fixed PLP to correctly set U and clear B flag - Fixed RTI to correctly set U flag Co-authored-by: zqigolden <27563199+zqigolden@users.noreply.github.com> --- pynes/cpu.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pynes/cpu.py b/pynes/cpu.py index 2fee117..b4aee28 100644 --- a/pynes/cpu.py +++ b/pynes/cpu.py @@ -212,7 +212,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) @@ -490,7 +490,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 @@ -505,6 +504,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: @@ -535,7 +535,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) From 93598317f60fee92d25128428d7cc5aeb8d6475c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 23:01:59 +0000 Subject: [PATCH 3/3] docs: add READMEs and missing docstrings - Updated `requirements.txt` to include `pygame`, `easydict`, `typer`, and `loguru`. - Added detailed English `README.md` and Chinese `README-CN.md`. - Added high-level docstrings to `Bus`, `CPU`, `PPU`, `Engine` classes, and `main()`. Co-authored-by: zqigolden <27563199+zqigolden@users.noreply.github.com> --- README-CN.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 57 ++++++++++++++++++++++++++++++++++++++++++++---- main.py | 5 +++++ pynes/bus.py | 10 +++++++++ pynes/cpu.py | 12 ++++++++++ pynes/engine.py | 16 ++++++++++++-- pynes/ppu.py | 11 ++++++++++ requirements.txt | 6 ++++- 8 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 README-CN.md diff --git a/README-CN.md b/README-CN.md new file mode 100644 index 0000000..9d83b4f --- /dev/null +++ b/README-CN.md @@ -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 封装的前端界面。负责将像素绘制到显示窗口并持续轮询硬件键盘事件。 diff --git a/README.md b/README.md index 4839036..954b101 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file + +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. diff --git a/main.py b/main.py index 674136a..650b2f7 100755 --- a/main.py +++ b/main.py @@ -14,6 +14,11 @@ @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()) diff --git a/pynes/bus.py b/pynes/bus.py index a27f892..52c410d 100644 --- a/pynes/bus.py +++ b/pynes/bus.py @@ -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 @@ -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: diff --git a/pynes/cpu.py b/pynes/cpu.py index b4aee28..cc3da68 100644 --- a/pynes/cpu.py +++ b/pynes/cpu.py @@ -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) @@ -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) diff --git a/pynes/engine.py b/pynes/engine.py index b466fe7..fb9ce39 100644 --- a/pynes/engine.py +++ b/pynes/engine.py @@ -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]) @@ -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: @@ -24,8 +33,11 @@ def update(self): pygame.quit() def get_keys(self): - # We need to process events so that key.get_pressed() updates correctly - # if there's any pending events not caught by update() yet. + """ + 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 {} diff --git a/pynes/ppu.py b/pynes/ppu.py index 10d83bf..45574a1 100644 --- a/pynes/ppu.py +++ b/pynes/ppu.py @@ -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) @@ -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 diff --git a/requirements.txt b/requirements.txt index 403ae55..0b25c45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,5 @@ -bitarray \ No newline at end of file +bitarray +typer +loguru +pygame +easydict