前言
最近在研究游戏模拟器的工作原理,最后找到一个叫
PyBoy的Python开源项目。PyBoy 是一个完全使用 Python 编写的任天堂Game Boy模拟器,其核心原理是在现代计算机上,通过软件来模拟 Game Boy 这台物理掌机的所有关键硬件组件,并让它们协同工作以运行原始的游戏ROM(只读存储器)文件。它的设计目标不仅仅是能够玩游戏,更重要的是为AI研究、自动化测试和学习计算机体系结构提供一个高度可访问的Python接口。
先放PyBoy的研究测试效果,如下图:

Pyboy是个开源的项目我们可以从国内的GitCode网站上找到这个项目,https://gitcode.com/gh_mirrors/py/PyBoy

查看完pyboy的源码后,我们大致理解PyBoy内部这些核心模块是如何协同工作的,下图展示了其核心的数据流与执行循环。
否 是 加载游戏ROM文件 模拟器初始化
CPU,Memory,GPU,Timer, Cartridge 进入主循环 模拟CPU执行一条指令
模拟取指, 解码, 执行 GPU处理图形管线
读取Tile,渲染扫描线 处理输入事件 Timer计数器更新 单帧指令执行完毕? 更新图形界面
SDL2/Headless
下面,我们详细拆解这张图里的各个核心模块和工作原理。
使用python开发任天堂gameboy模拟器|pyboy开发实践
-
- 一、核心组件模拟
-
- (一)中央处理器 (CPU)
- (二)内存管理单元 (Memory Management Unit, MMU)
- [(二)图形处理单元 (GPU):](#(二)图形处理单元 (GPU):)
- (三)中断检测与定时器:
- 二、在pycharm中运行pyboy
- 三、结语
一、核心组件模拟
PyBoy的成功运行,依赖于它精确地模拟了Game Boy的以下几个核心硬件单元。
(一)中央处理器 (CPU)
PyBoy模拟的是Game Boy上使用的Sharp LR35902处理器。这是一个基于Intel 8080和Zilog Z80的8位处理器。模拟器的核心是一个巨大的循环,在这个循环中,PyBoy的CPU模拟模块会从内存中读取指令,然后解码并执行它,同时更新程序计数器和各种寄存器。这是模拟器最复杂、最基础的部分。这里主要涉及源码里面core中的cpu.py文件与opcodes.py文件,其中opcodes.py文件实现z80 CPU指令与本地系统CPU(这里是windows)的映射,相当长和复杂,这里就不一一列举代码,只放cpu.py代码。

核心代码如下:
python
#
# License: See LICENSE.md file
# GitHub: https://github.com/Baekalfen/PyBoy
#
import pyboy
from . import opcodes
from pyboy.utils import INTR_VBLANK, INTR_LCDC, INTR_TIMER, INTR_SERIAL, INTR_HIGHTOLOW
FLAGC, FLAGH, FLAGN, FLAGZ = range(4, 8)
logger = pyboy.logging.get_logger(__name__)
class CPU:
def __init__(self, mb):
self.A = 0
self.F = 0
self.B = 0
self.C = 0
self.D = 0
self.E = 0
self.HL = 0
self.SP = 0
self.PC = 0
self.interrupts_flag_register = 0
self.interrupts_enabled_register = 0
self.interrupt_master_enable = False
self.interrupt_queued = False
self.mb = mb
self.halted = False
self.stopped = False
self.is_stuck = False
self.cycles = 0
def save_state(self, f):
for n in [self.A, self.F, self.B, self.C, self.D, self.E]:
f.write(n & 0xFF)
for n in [self.HL, self.SP, self.PC]:
f.write_16bit(n)
f.write(self.interrupt_master_enable)
f.write(self.halted)
f.write(self.stopped)
f.write(self.interrupts_enabled_register)
f.write(self.interrupt_queued)
f.write(self.interrupts_flag_register)
f.write_64bit(self.cycles)
def load_state(self, f, state_version):
self.A, self.F, self.B, self.C, self.D, self.E = [f.read() for _ in range(6)]
self.HL = f.read_16bit()
self.SP = f.read_16bit()
self.PC = f.read_16bit()
self.interrupt_master_enable = f.read()
self.halted = f.read()
self.stopped = f.read()
if state_version >= 5:
# Interrupt register moved from RAM to CPU
self.interrupts_enabled_register = f.read()
if state_version >= 8:
self.interrupt_queued = f.read()
self.interrupts_flag_register = f.read()
if state_version >= 12:
self.cycles = f.read_64bit()
logger.debug("State loaded: %s", self.dump_state(""))
def dump_state(self, sym_label):
opcode_data = [
self.mb.getitem(self.mb.cpu.PC + n) for n in range(3)
] # Max 3 length, then we don't need to backtrack
opcode = opcode_data[0]
opcode_length = opcodes.OPCODE_LENGTHS[opcode]
opcode_str = f"Opcode: [{opcodes.CPU_COMMANDS[opcode]}]"
if opcode == 0xCB:
opcode_str += f" {opcodes.CPU_COMMANDS[opcode_data[1]+0x100]}"
else:
opcode_str += " " + " ".join(f"{d:02X}" for d in opcode_data[1:opcode_length])
return (
"\n"
f"A: {self.mb.cpu.A:02X}, F: {self.mb.cpu.F:02X}, B: {self.mb.cpu.B:02X}, "
f"C: {self.mb.cpu.C:02X}, D: {self.mb.cpu.D:02X}, E: {self.mb.cpu.E:02X}, "
f"HL: {self.mb.cpu.HL:04X}, SP: {self.mb.cpu.SP:04X}, PC: {self.mb.cpu.PC:04X} ({sym_label})\n"
f"{opcode_str} "
f"Interrupts - IME: {self.mb.cpu.interrupt_master_enable}, "
f"IE: {self.mb.cpu.interrupts_enabled_register:08b}, "
f"IF: {self.mb.cpu.interrupts_flag_register:08b}\n"
f"LCD Intr.: {self.mb.lcd._cycles_to_interrupt}, LY:{self.mb.lcd.LY}, LYC:{self.mb.lcd.LYC}\n"
f"Timer Intr.: {self.mb.timer._cycles_to_interrupt}\n"
f"Sound: PCM12:{self.mb.sound.pcm12():02X}, PCM34:{self.mb.sound.pcm34():02X}\n"
f"Sound CH1: \n"
f"sound_period: {self.mb.sound.sweepchannel.sound_period}\n"
f"length_enable: {self.mb.sound.sweepchannel.length_enable}\n"
f"enable: {self.mb.sound.sweepchannel.enable}\n"
f"lengthtimer: {self.mb.sound.sweepchannel.lengthtimer}\n"
f"envelopetimer: {self.mb.sound.sweepchannel.envelopetimer}\n"
f"periodtimer: {self.mb.sound.sweepchannel.periodtimer}\n"
f"period: {self.mb.sound.sweepchannel.period}\n"
f"waveframe: {self.mb.sound.sweepchannel.waveframe}\n"
f"volume: {self.mb.sound.sweepchannel.volume}\n"
f"halted:{self.halted}, "
f"interrupt_queued:{self.interrupt_queued}, "
f"stopped:{self.stopped}\n"
f"cycles:{self.cycles}\n"
)
def set_interruptflag(self, flag):
self.interrupts_flag_register |= flag
def tick(self, cycles_target):
_cycles0 = self.cycles
_target = _cycles0 + cycles_target
if self.check_interrupts():
self.halted = False
# TODO: Cycles it took to handle the interrupt
if self.halted and self.interrupt_queued:
# GBCPUman.pdf page 20
# WARNING: The instruction immediately following the HALT instruction is "skipped" when interrupts are
# disabled (DI) on the GB,GBP, and SGB.
self.halted = False
self.PC += 1
self.PC &= 0xFFFF
elif self.halted:
self.cycles += cycles_target # TODO: Number of cycles for a HALT in effect?
self.interrupt_queued = False
self.bail = False
while self.cycles < _target:
# TODO: cpu-stuck check for blargg tests?
self.fetch_and_execute()
if self.bail: # Possible cycles-target changes
break
def check_interrupts(self):
if self.interrupt_queued:
# Interrupt already queued. This happens only when using a debugger.
return False
raised_and_enabled = (self.interrupts_flag_register & 0b11111) & (self.interrupts_enabled_register & 0b11111)
if raised_and_enabled:
# Clear interrupt flag
if self.halted:
self.PC += 1 # Escape HALT on return
self.PC &= 0xFFFF
if self.interrupt_master_enable:
if raised_and_enabled & INTR_VBLANK:
self.handle_interrupt(INTR_VBLANK, 0x0040)
elif raised_and_enabled & INTR_LCDC:
self.handle_interrupt(INTR_LCDC, 0x0048)
elif raised_and_enabled & INTR_TIMER:
self.handle_interrupt(INTR_TIMER, 0x0050)
elif raised_and_enabled & INTR_SERIAL:
self.handle_interrupt(INTR_SERIAL, 0x0058)
elif raised_and_enabled & INTR_HIGHTOLOW:
self.handle_interrupt(INTR_HIGHTOLOW, 0x0060)
self.interrupt_queued = True
return True
else:
self.interrupt_queued = False
return False
def handle_interrupt(self, flag, addr):
self.interrupts_flag_register ^= flag # Remove flag
self.mb.setitem((self.SP - 1) & 0xFFFF, self.PC >> 8) # High
self.mb.setitem((self.SP - 2) & 0xFFFF, self.PC & 0xFF) # Low
self.SP -= 2
self.SP &= 0xFFFF
self.PC = addr
self.interrupt_master_enable = False
def fetch_and_execute(self):
opcode = self.mb.getitem(self.PC)
if opcode == 0xCB: # Extension code
opcode = self.mb.getitem(self.PC + 1)
opcode += 0x100 # Internally shifting look-up table
return opcodes.execute_opcode(self, opcode)
(二)内存管理单元 (Memory Management Unit, MMU)
Game Boy 有一个16位地址总线,可寻址64KB的内存空间。这片空间被划分为不同的区域:引导ROM、卡带ROM、工作RAM、显存等。MMU的作用就像是整个系统的"交通枢纽",负责管理CPU对其他所有组件的访问。当CPU需要读写内存时,MMU会根据地址将请求正确地路由到卡带、RAM或GPU等相应的硬件组件上。这里主要涉及源码里面core中的mb.py文件。

核心代码如下:
python
# MemoryManager
#
def getitem(self, i):
if 0x0000 <= i < 0x4000: # 16kB ROM bank #0
if self.bootrom_enabled and (i <= 0xFF or (self.bootrom.cgb and 0x200 <= i < 0x900)):
return self.bootrom.getitem(i)
else:
return self.cartridge.rombanks[self.cartridge.rombank_selected_low, i]
elif 0x4000 <= i < 0x8000: # 16kB switchable ROM bank
return self.cartridge.rombanks[self.cartridge.rombank_selected, i - 0x4000]
elif 0x8000 <= i < 0xA000: # 8kB Video RAM
if not self.cgb or self.lcd.vbk.active_bank == 0:
return self.lcd.VRAM0[i - 0x8000]
else:
return self.lcd.VRAM1[i - 0x8000]
elif 0xA000 <= i < 0xC000: # 8kB switchable RAM bank
return self.cartridge.getitem(i)
elif 0xC000 <= i < 0xE000: # 8kB Internal RAM
bank_offset = 0
if self.cgb and 0xD000 <= i:
# Find which bank to read from at FF70
bank = self.ram.non_io_internal_ram1[0xFF70 - 0xFF4C] & 0b111
if bank == 0x0:
bank = 0x01
bank_offset = (bank - 1) * 0x1000
return self.ram.internal_ram0[i - 0xC000 + bank_offset]
elif 0xE000 <= i < 0xFE00: # Echo of 8kB Internal RAM
# Redirect to internal RAM
return self.getitem(i - 0x2000)
elif 0xFE00 <= i < 0xFEA0: # Sprite Attribute Memory (OAM)
return self.lcd.OAM[i - 0xFE00]
elif 0xFEA0 <= i < 0xFF00: # Empty but unusable for I/O
return self.ram.non_io_internal_ram0[i - 0xFEA0]
elif 0xFF00 <= i < 0xFF4C: # I/O ports
if 0xFF01 <= i <= 0xFF02:
if self.serial.tick(self.cpu.cycles):
self.cpu.set_interruptflag(INTR_SERIAL)
if i == 0xFF01:
return self.serial.SB
elif i == 0xFF02:
return self.serial.SC
elif 0xFF04 <= i <= 0xFF07:
if self.timer.tick(self.cpu.cycles):
self.cpu.set_interruptflag(INTR_TIMER)
if i == 0xFF04:
return self.timer.DIV
elif i == 0xFF05:
return self.timer.TIMA
elif i == 0xFF06:
return self.timer.TMA
elif i == 0xFF07:
return self.timer.TAC
elif i == 0xFF0F:
return self.cpu.interrupts_flag_register
elif 0xFF10 <= i < 0xFF40:
self.sound.tick(self.cpu.cycles)
return self.sound.get(i - 0xFF10)
elif 0xFF40 <= i <= 0xFF4B:
if lcd_interrupt := self.lcd.tick(self.cpu.cycles):
self.cpu.set_interruptflag(lcd_interrupt)
if i == 0xFF40:
return self.lcd._LCDC.value
elif i == 0xFF41:
return self.lcd._STAT.value
elif i == 0xFF42:
return self.lcd.SCY
elif i == 0xFF43:
return self.lcd.SCX
elif i == 0xFF44:
return self.lcd.LY
elif i == 0xFF45:
return self.lcd.LYC
elif i == 0xFF46:
return 0x00 # DMA
elif i == 0xFF47:
return self.lcd.BGP.get()
elif i == 0xFF48:
return self.lcd.OBP0.get()
elif i == 0xFF49:
return self.lcd.OBP1.get()
elif i == 0xFF4A:
return self.lcd.WY
elif i == 0xFF4B:
return self.lcd.WX
else:
return self.ram.io_ports[i - 0xFF00]
elif 0xFF4C <= i < 0xFF80: # Empty but unusable for I/O
# CGB registers
if self.cgb and i == 0xFF4D:
return self.key1
elif self.cgb and i == 0xFF4F:
return self.lcd.vbk.get()
elif self.cgb and i == 0xFF68:
return self.lcd.bcps.get() | 0x40
elif self.cgb and i == 0xFF69:
return self.lcd.bcpd.get()
elif self.cgb and i == 0xFF6A:
return self.lcd.ocps.get() | 0x40
elif self.cgb and i == 0xFF6B:
return self.lcd.ocpd.get()
elif self.cgb and i == 0xFF51:
# logger.debug("HDMA1 is not readable")
return 0x00 # Not readable
elif self.cgb and i == 0xFF52:
# logger.debug("HDMA2 is not readable")
return 0x00 # Not readable
elif self.cgb and i == 0xFF53:
# logger.debug("HDMA3 is not readable")
return 0x00 # Not readable
elif self.cgb and i == 0xFF54:
# logger.debug("HDMA4 is not readable")
return 0x00 # Not readable
elif self.cgb and i == 0xFF55:
return self.hdma.hdma5 & 0xFF
elif self.cgb and i == 0xFF76:
self.sound.tick(self.cpu.cycles)
return self.sound.pcm12()
elif self.cgb and i == 0xFF77:
self.sound.tick(self.cpu.cycles)
return self.sound.pcm34()
return self.ram.non_io_internal_ram1[i - 0xFF4C]
elif 0xFF80 <= i < 0xFFFF: # Internal RAM
return self.ram.internal_ram1[i - 0xFF80]
elif i == 0xFFFF: # Interrupt Enable Register
return self.cpu.interrupts_enabled_register
# else:
# logger.critical("Memory access violation. Tried to read: %0.4x", i)
def setitem(self, i, value):
if 0x0000 <= i < 0x4000: # 16kB ROM bank #0
# Doesn't change the data. This is for MBC commands
self.cartridge.setitem(i, value)
self.cpu.bail = True
elif 0x4000 <= i < 0x8000: # 16kB switchable ROM bank
# Doesn't change the data. This is for MBC commands
self.cartridge.setitem(i, value)
self.cpu.bail = True
elif 0x8000 <= i < 0xA000: # 8kB Video RAM
if not self.cgb or self.lcd.vbk.active_bank == 0:
self.lcd.VRAM0[i - 0x8000] = value
if i < 0x9800: # Is within tile data -- not tile maps
# Mask out the byte of the tile
self.lcd.renderer.invalidate_tile(((i & 0xFFF0) - 0x8000) // 16, 0)
else:
self.lcd.VRAM1[i - 0x8000] = value
if i < 0x9800: # Is within tile data -- not tile maps
# Mask out the byte of the tile
self.lcd.renderer.invalidate_tile(((i & 0xFFF0) - 0x8000) // 16, 1)
elif 0xA000 <= i < 0xC000: # 8kB switchable RAM bank
self.cartridge.setitem(i, value)
elif 0xC000 <= i < 0xE000: # 8kB Internal RAM
bank_offset = 0
if self.cgb and 0xD000 <= i:
# Find which bank to read from at FF70
bank = self.getitem(0xFF70)
bank &= 0b111
if bank == 0x0:
bank = 0x01
bank_offset = (bank - 1) * 0x1000
self.ram.internal_ram0[i - 0xC000 + bank_offset] = value
elif 0xE000 <= i < 0xFE00: # Echo of 8kB Internal RAM
self.setitem(i - 0x2000, value) # Redirect to internal RAM
elif 0xFE00 <= i < 0xFEA0: # Sprite Attribute Memory (OAM)
self.lcd.OAM[i - 0xFE00] = value
elif 0xFEA0 <= i < 0xFF00: # Empty but unusable for I/O
self.ram.non_io_internal_ram0[i - 0xFEA0] = value
elif 0xFF00 <= i < 0xFF4C: # I/O ports
if i == 0xFF00:
self.ram.io_ports[i - 0xFF00] = self.interaction.pull(value)
elif 0xFF01 <= i <= 0xFF02:
if self.serial.tick(self.cpu.cycles):
self.cpu.set_interruptflag(INTR_SERIAL)
if i == 0xFF01:
self.serialbuffer[self.serialbuffer_count] = value
self.serialbuffer_count += 1
self.serialbuffer_count &= 0x3FF
self.serial.set_SB(value)
elif i == 0xFF02:
self.serial.set_SC(value)
elif 0xFF04 <= i <= 0xFF07:
if self.timer.tick(self.cpu.cycles):
self.cpu.set_interruptflag(INTR_TIMER)
if i == 0xFF04:
# Pan docs:
# "DIV-APU" ... is increased every time DIV's bit 4 (5 in double-speed mode) goes from 1 to 0 ...
# the counter can be made to increase faster by writing to DIV while its relevant bit is set (which
# clears DIV, and triggers the falling edge).
if self.timer.DIV & (0b1_0000 << self.sound.speed_shift):
self.sound.tick(self.cpu.cycles) # Process outstanding cycles
# TODO: Force a falling edge tick
self.sound.reset_apu_div()
self.timer.reset()
elif i == 0xFF05:
self.timer.TIMA = value
elif i == 0xFF06:
self.timer.TMA = value
elif i == 0xFF07:
self.timer.TAC = value & 0b111 # TODO: Move logic to Timer class
elif i == 0xFF0F:
self.cpu.interrupts_flag_register = value
elif 0xFF10 <= i < 0xFF40:
self.sound.tick(self.cpu.cycles)
self.sound.set(i - 0xFF10, value)
elif 0xFF40 <= i <= 0xFF4B:
if lcd_interrupt := self.lcd.tick(self.cpu.cycles):
self.cpu.set_interruptflag(lcd_interrupt)
if i == 0xFF40:
self.lcd.set_lcdc(value)
elif i == 0xFF41:
self.lcd._STAT.set(value)
elif i == 0xFF42:
self.lcd.SCY = value
elif i == 0xFF43:
self.lcd.SCX = value
elif i == 0xFF44:
# LCDC Read-only
return
elif i == 0xFF45:
self.lcd.LYC = value
elif i == 0xFF46:
self.transfer_DMA(value)
elif i == 0xFF47:
if self.lcd.BGP.set(value):
# TODO: Move out of MB
self.lcd.renderer.clear_tilecache0()
elif i == 0xFF48:
if self.lcd.OBP0.set(value):
# TODO: Move out of MB
self.lcd.renderer.clear_spritecache0()
elif i == 0xFF49:
if self.lcd.OBP1.set(value):
# TODO: Move out of MB
self.lcd.renderer.clear_spritecache1()
elif i == 0xFF4A:
self.lcd.WY = value
elif i == 0xFF4B:
self.lcd.WX = value
else:
self.ram.io_ports[i - 0xFF00] = value
self.cpu.bail = True
elif 0xFF4C <= i < 0xFF80: # Empty but unusable for I/O
if self.bootrom_enabled and i == 0xFF50 and (value == 0x1 or value == 0x11):
logger.debug("Bootrom disabled!")
self.bootrom_enabled = False
self.cpu.bail = True
# CGB registers
elif self.cgb and i == 0xFF4D:
self.key1 = value
self.cpu.bail = True
elif self.cgb and i == 0xFF4F:
self.lcd.vbk.set(value)
elif self.cgb and i == 0xFF51:
self.hdma.hdma1 = value
elif self.cgb and i == 0xFF52:
self.hdma.hdma2 = value # & 0xF0
elif self.cgb and i == 0xFF53:
self.hdma.hdma3 = value # & 0x1F
elif self.cgb and i == 0xFF54:
self.hdma.hdma4 = value # & 0xF0
elif self.cgb and i == 0xFF55:
self.hdma.set_hdma5(value, self)
self.cpu.bail = True
elif self.cgb and i == 0xFF68:
self.lcd.bcps.set(value)
elif self.cgb and i == 0xFF69:
self.lcd.bcpd.set(value)
self.lcd.renderer.clear_tilecache0()
self.lcd.renderer.clear_tilecache1()
elif self.cgb and i == 0xFF6A:
self.lcd.ocps.set(value)
elif self.cgb and i == 0xFF6B:
self.lcd.ocpd.set(value)
self.lcd.renderer.clear_spritecache0()
self.lcd.renderer.clear_spritecache1()
else:
self.ram.non_io_internal_ram1[i - 0xFF4C] = value
elif 0xFF80 <= i < 0xFFFF: # Internal RAM
self.ram.internal_ram1[i - 0xFF80] = value
elif i == 0xFFFF: # Interrupt Enable Register
self.cpu.interrupts_enabled_register = value
self.cpu.bail = True
# else:
# logger.critical("Memory access violation. Tried to write: 0x%0.2x to 0x%0.4x", value, i)
def transfer_DMA(self, src):
# http://problemkaputt.de/pandocs.htm#lcdoamdmatransfers
# TODO: Add timing delay of 160µs and disallow access to RAM!
dst = 0xFE00
offset = src * 0x100
for n in range(0xA0):
self.setitem(dst + n, self.getitem(n + offset))
(二)图形处理单元 (GPU):
GPU负责渲染图形。Game Boy的屏幕分辨率为160x144 像素。GPU模拟模块会按照固定的时序,从显存中读取背景图、精灵(角色图块)等数据,然后将它们合成为最终的图像帧。PyBoy通过SDL2和PIL等图形库将最终图像输出到屏幕上,或者是在无界面模式下直接供程序处理。这里主要涉及源码里面core中的lcd.py文件。

(三)中断检测与定时器:
模拟器还包含了系统定时器的模拟,许多游戏依赖它来计时。同时,PyBoy会捕获键盘或手柄的输入事件,并将其映射为Game Boy的十字键和A、B、选择、开始按钮事件。这里主要涉及源码里面core中的interaction.py文件。

主要代码如下:
python
from pyboy.utils import WindowEvent
P10, P11, P12, P13 = range(4)
def reset_bit(x, bit):
return x & ~(1 << bit)
def set_bit(x, bit):
return x | (1 << bit)
class Interaction:
def __init__(self):
self.directional = 0xF
self.standard = 0xF
def key_event(self, key):
_directional = self.directional
_standard = self.standard
if key == WindowEvent.PRESS_ARROW_RIGHT:
self.directional = reset_bit(self.directional, P10)
elif key == WindowEvent.PRESS_ARROW_LEFT:
self.directional = reset_bit(self.directional, P11)
elif key == WindowEvent.PRESS_ARROW_UP:
self.directional = reset_bit(self.directional, P12)
elif key == WindowEvent.PRESS_ARROW_DOWN:
self.directional = reset_bit(self.directional, P13)
elif key == WindowEvent.PRESS_BUTTON_A:
self.standard = reset_bit(self.standard, P10)
elif key == WindowEvent.PRESS_BUTTON_B:
self.standard = reset_bit(self.standard, P11)
elif key == WindowEvent.PRESS_BUTTON_SELECT:
self.standard = reset_bit(self.standard, P12)
elif key == WindowEvent.PRESS_BUTTON_START:
self.standard = reset_bit(self.standard, P13)
elif key == WindowEvent.RELEASE_ARROW_RIGHT:
self.directional = set_bit(self.directional, P10)
elif key == WindowEvent.RELEASE_ARROW_LEFT:
self.directional = set_bit(self.directional, P11)
elif key == WindowEvent.RELEASE_ARROW_UP:
self.directional = set_bit(self.directional, P12)
elif key == WindowEvent.RELEASE_ARROW_DOWN:
self.directional = set_bit(self.directional, P13)
elif key == WindowEvent.RELEASE_BUTTON_A:
self.standard = set_bit(self.standard, P10)
elif key == WindowEvent.RELEASE_BUTTON_B:
self.standard = set_bit(self.standard, P11)
elif key == WindowEvent.RELEASE_BUTTON_SELECT:
self.standard = set_bit(self.standard, P12)
elif key == WindowEvent.RELEASE_BUTTON_START:
self.standard = set_bit(self.standard, P13)
# XOR to find the changed bits, AND it to see if it was high before.
# Test for both directional and standard buttons.
return ((_directional ^ self.directional) & _directional) or ((_standard ^ self.standard) & _standard)
def pull(self, joystickbyte):
P14 = (joystickbyte >> 4) & 1
P15 = (joystickbyte >> 5) & 1
# Bit 7 - Not used (No$GMB)
# Bit 6 - Not used (No$GMB)
# Bit 5 - P15 out port
# Bit 4 - P14 out port
# Bit 3 - P13 in port
# Bit 2 - P12 in port
# Bit 1 - P11 in port
# Bit 0 - P10 in port
# Guess to make first 4 and last 2 bits true, while keeping selected bits
joystickByte = 0xFF & (joystickbyte | 0b11001111)
if P14 and P15:
joystickByte = 0xF
elif not P14 and not P15:
pass # FIXME: What happens when both are requested?
elif not P14:
joystickByte &= self.directional
elif not P15:
joystickByte &= self.standard
return joystickByte | 0b11000000
def save_state(self, f):
f.write(self.directional)
f.write(self.standard)
def load_state(self, f, state_version):
if state_version >= 7:
self.directional = f.read()
self.standard = f.read()
else:
self.directional = 0xF
self.standard = 0xF
二、在pycharm中运行pyboy
对PyBoy的源码有了基础的了解后,我们就可以用它来跑一跑实际程序了,第一步是先下载和导入PyBoy的Python包,然后新建一个pyboy对象,并调用pyboy.tick()函数即可。
(一)下载pyboy包

(二)导入pyboy包并运行
新建main.py文件,并写入以下代码,特别的是还要准备一个游戏rom(使用游戏rom存在法律风险,请注意)并指定rom地址。
python
from pyboy import PyBoy
pyboy = PyBoy('roms/bkmy.gb')
try:
while pyboy.tick():
pass
pyboy.stop()
except Exception as e:
print(e)

(三)展示运行效果
如果程序运行正常将得到以下运行结果,特别的是游戏rom来源网络仅做学习研究。

三、结语
学习PyBoy源码与GBC游戏模拟是一次充满挑战与惊喜的探索。深入源码,仿佛揭开游戏模拟的神秘面纱,理解其复杂运行逻辑。用Python操控模拟过程,让我将理论知识用于实践。这不仅提升了编程能力,更让我感受到技术还原经典游戏的魅力,收获颇丰。