diff --git a/pyboy/__main__.py b/pyboy/__main__.py index dbcc1dfd0..299cb786d 100644 --- a/pyboy/__main__.py +++ b/pyboy/__main__.py @@ -88,6 +88,14 @@ def valid_file_path(path): parser.add_argument("--sound", action="store_true", help="Enable sound (beta)") parser.add_argument("--no-renderer", action="store_true", help="Disable rendering (internal use)") +parser.add_argument("--serial-bind", action="store_true", help="Bind to this TCP addres for using Link Cable") +parser.add_argument( + "--serial-address", default=None, type=str, help="Connect (or bind) to this TCP address for using Link Cable" +) +parser.add_argument( + "--serial-interrupt-based", action="store_true", help="Use only interrupt-based transfers for using Link Cable" +) + gameboy_type_parser = parser.add_mutually_exclusive_group() gameboy_type_parser.add_argument( "--dmg", action="store_const", const=False, dest="cgb", help="Force emulator to run as original Game Boy (DMG)" @@ -106,6 +114,9 @@ def valid_file_path(path): def main(): argv = parser.parse_args() + if (argv.serial_bind or argv.serial_interrupt_based) and not argv.serial_address: + parser.error("--serial-bind and --serial-interrupt-based requires --serial-address") + print( """ The Game Boy controls are as follows: diff --git a/pyboy/core/mb.pxd b/pyboy/core/mb.pxd index c43f7fb18..bed253c25 100644 --- a/pyboy/core/mb.pxd +++ b/pyboy/core/mb.pxd @@ -13,8 +13,12 @@ cimport pyboy.core.cpu cimport pyboy.core.interaction cimport pyboy.core.lcd cimport pyboy.core.ram +cimport pyboy.core.serial cimport pyboy.core.sound cimport pyboy.core.timer + +from threading import Thread + from pyboy.logging.logging cimport Logger from pyboy.utils cimport IntIOInterface, WindowEvent @@ -36,6 +40,8 @@ cdef class Motherboard: cdef pyboy.core.timer.Timer timer cdef pyboy.core.sound.Sound sound cdef pyboy.core.cartridge.base_mbc.BaseMBC cartridge + cdef pyboy.core.serial.Serial serial + cdef object serial_thread cdef bint bootrom_enabled cdef char[1024] serialbuffer cdef uint16_t serialbuffer_count @@ -60,7 +66,7 @@ cdef class Motherboard: cdef void buttonevent(self, WindowEvent) noexcept cdef void stop(self, bint) noexcept @cython.locals(cycles=int64_t, mode0_cycles=int64_t, breakpoint_index=int64_t) - cdef bint tick(self) noexcept nogil + cdef bint tick(self) noexcept with gil cdef void switch_speed(self) noexcept nogil diff --git a/pyboy/core/mb.py b/pyboy/core/mb.py index 78264a527..9a4ae06c7 100644 --- a/pyboy/core/mb.py +++ b/pyboy/core/mb.py @@ -3,10 +3,9 @@ # GitHub: https://github.com/Baekalfen/PyBoy # -from pyboy import utils from pyboy.utils import STATE_VERSION -from . import bootrom, cartridge, cpu, interaction, lcd, ram, sound, timer +from . import bootrom, cartridge, cpu, interaction, lcd, ram, serial, sound, timer INTR_VBLANK, INTR_LCDC, INTR_TIMER, INTR_SERIAL, INTR_HIGHTOLOW = [1 << x for x in range(5)] OPCODE_BRK = 0xDB @@ -27,6 +26,9 @@ def __init__( sound_emulated, cgb, randomize=False, + serial_address=None, + serial_bind=None, + serial_interrupt_based=False, ): if bootrom_file is not None: logger.info("Boot-ROM file provided") @@ -42,6 +44,7 @@ def __init__( self.bootrom = bootrom.BootROM(bootrom_file, cgb) self.ram = ram.RAM(cgb, randomize=randomize) self.cpu = cpu.CPU(self) + self.serial = serial.Serial(self, serial_address or None, serial_bind or None, serial_interrupt_based) if cgb: self.lcd = lcd.CGBLCD( @@ -204,6 +207,7 @@ def buttonevent(self, key): def stop(self, save): self.sound.stop() + # self.serial.stop() if save: self.cartridge.stop() @@ -296,10 +300,8 @@ def tick(self): cycles = max( 0, min( - self.lcd.cycles_to_interrupt(), - self.timer.cycles_to_interrupt(), - # self.serial.cycles_to_interrupt(), - mode0_cycles + self.lcd.cycles_to_interrupt(), self.timer.cycles_to_interrupt(), + self.serial.cycles_to_transmit(), mode0_cycles ) ) @@ -316,6 +318,9 @@ def tick(self): if self.timer.tick(cycles): self.cpu.set_interruptflag(INTR_TIMER) + if self.serial.tick(cycles): + self.cpu.set_interruptflag(INTR_SERIAL) + lcd_interrupt = self.lcd.tick(cycles) if lcd_interrupt: self.cpu.set_interruptflag(lcd_interrupt) @@ -323,8 +328,8 @@ def tick(self): if self.breakpoint_singlestep: break - # TODO: Move SDL2 sync to plugin - self.sound.sync() + # TODO: Move SDL2 sync to plugin + self.sound.sync() return self.breakpoint_singlestep @@ -364,7 +369,15 @@ def getitem(self, i): 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 i == 0xFF04: + if i == 0xFF01: + # logger.info(f"get SB {self.serial.SB}") + # if not self.serial: + # return 0xFF + return self.serial.SB + elif i == 0xFF02: + # logger.info(f"get SC {self.serial.SC}") + return self.serial.SC + elif i == 0xFF04: return self.timer.DIV elif i == 0xFF05: return self.timer.TIMA @@ -478,10 +491,15 @@ def setitem(self, i, value): if i == 0xFF00: self.ram.io_ports[i - 0xFF00] = self.interaction.pull(value) elif i == 0xFF01: + self.serial.SB = value + self.serialbuffer[self.serialbuffer_count] = value self.serialbuffer_count += 1 self.serialbuffer_count &= 0x3FF self.ram.io_ports[i - 0xFF00] = value + elif i == 0xFF02: + self.serial.SC = value + self.ram.io_ports[i - 0xFF00] = value elif i == 0xFF04: self.timer.reset() elif i == 0xFF05: diff --git a/pyboy/core/serial.pxd b/pyboy/core/serial.pxd new file mode 100644 index 000000000..8bb170a7d --- /dev/null +++ b/pyboy/core/serial.pxd @@ -0,0 +1,28 @@ +# serial.pxd + +cdef int INTR_VBLANK, INTR_LCDC, INTR_TIMER, INTR_SERIAL, INTR_HIGHTOLOW +cdef int SERIAL_FREQ, CPU_FREQ +cdef object async_recv + +cdef class Serial: + cdef object mb + cdef int SC + cdef int SB + cdef object connection + cdef object recv + cdef object recv_t + cdef bint quitting + cdef int trans_bits + cdef int cycles_count + cdef int cycles_target + cdef int serial_interrupt_based + cdef bint waiting_for_byte + cdef int byte_retry_count + cdef object binding_connection + cdef int is_master + cdef bint transfer_enabled + + cpdef send_bit(self) + cpdef bint tick(self, int cycles) noexcept with gil + cpdef int cycles_to_transmit(self) noexcept with gil + cpdef stop(self) diff --git a/pyboy/core/serial.py b/pyboy/core/serial.py new file mode 100644 index 000000000..f4e6aadc1 --- /dev/null +++ b/pyboy/core/serial.py @@ -0,0 +1,150 @@ +import logging +import os +import platform +import queue +import select +import socket +import sys +import threading +import time + +from colorama import Fore + +import pyboy + +logger = pyboy.logging.get_logger(__name__) + +INTR_VBLANK, INTR_LCDC, INTR_TIMER, INTR_SERIAL, INTR_HIGHTOLOW = [1 << x for x in range(5)] +SERIAL_FREQ = 8192 # Hz +CPU_FREQ = 4194304 # Hz # Corrected CPU Frequency + + +class Serial: + """Gameboy Link Cable Emulation""" + def __init__(self, mb, serial_address, serial_bind, serial_interrupt_based): + self.mb = mb + self.SC = 0b00000000 # Serial transfer control + self.SB = 0b00000000 # Serial transfer data + self.connection = None + + self.trans_bits = 0 # Number of bits transferred + self.cycles_count = 0 # Number of cycles since last transfer + self.cycles_target = CPU_FREQ // SERIAL_FREQ + self.serial_interrupt_based = serial_interrupt_based + + self.recv = queue.Queue() + + self.quitting = False + + if not serial_address: + logger.info("No serial address supplied. Link Cable emulation disabled.") + return + + if not serial_address.count(".") == 3 and serial_address.count(":") == 1: + logger.info("Only IP-addresses of the format x.y.z.w:abcd is supported") + return + + address_ip, address_port = serial_address.split(":") + address_tuple = (address_ip, int(address_port)) + + self.is_master = True + self.transfer_enabled = False + self.waiting_for_byte = False + self.byte_retry_count = 0 + + if serial_bind: + self.binding_connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.binding_connection.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + logger.info(f"Binding to {serial_address}") + self.binding_connection.bind(address_tuple) + self.binding_connection.listen(1) + self.connection, _ = self.binding_connection.accept() + logger.info(f"Client has connected!") + else: + self.connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + logger.info(f"Connecting to {serial_address}") + self.connection.connect(address_tuple) + logger.info(f"Connection successful!") + self.is_master = False + self.connection.setblocking(False) + self.recv_t = threading.Thread(target=lambda: self.recv_thread()) + self.recv_t.daemon = True + self.recv_t.start() + + def recv_thread(self): + while not self.quitting: + try: + data = self.connection.recv(1) + self.recv.put(data) + except BlockingIOError as e: + pass + except ConnectionResetError as e: + print(f"Connection reset by peer: {e}") + break + + def send_bit(self): + send_bit = bytes([(self.SB >> 7) & 1]) + try: + self.connection.send(send_bit) + except ConnectionResetError: + self.SB = 0xFF + + def tick(self, cycles): + if self.connection is None: + # No connection, no serial + self.SB = 0xFF + return False + + if self.SC & 0x80 == 0: # Check if transfer is enabled + return False + + self.cycles_count += cycles # Accumulate cycles + + if self.cycles_to_transmit() == 0: + if not self.waiting_for_byte: + self.send_bit() + time.sleep(1 / SERIAL_FREQ) + + try: + rb = self.recv.get_nowait() + self.waiting_for_byte = False + self.byte_retry_count = 0 + self.trans_bits += 1 + except queue.Empty as e: + # This part prevents indefinite lockup + # while waiting for bytes + self.waiting_for_byte = True + self.byte_retry_count += 1 + if self.byte_retry_count >= 8: + self.byte_retry_count = 0 + self.cycles_count = 0 # Reset cycles + return False + self.SB = ((self.SB << 1) & 0xFF) | rb[0] + + self.cycles_count = 0 # Reset cycle count after transmission + + if self.trans_bits == 8: + self.trans_bits = 0 + # Clear transfer start flag + self.SC &= 0x7F + return True + return False + + def cycles_to_transmit(self): + if self.connection: + if self.SC & 0x80: + return max(self.cycles_target - self.cycles_count, 0) + else: + return 1 << 16 + else: + return 1 << 16 + + def stop(self): + self.quitting = True + if self.connection: + self.connection.close() + if hasattr(self, "binding_connection"): + self.binding_connection.close() + self.connection = None + self.binding_connection = None + self.recv_t.join() diff --git a/pyboy/pyboy.py b/pyboy/pyboy.py index f2c969134..49df332d0 100644 --- a/pyboy/pyboy.py +++ b/pyboy/pyboy.py @@ -26,7 +26,7 @@ logger = get_logger(__name__) -SPF = 1 / 60. # inverse FPS (frame-per-second) +SPF = 1 / 60. # inverse FPS (frame-per-second) defaults = { "color_palette": (0xFFFFFF, 0x999999, 0x555555, 0x000000), @@ -51,6 +51,9 @@ def __init__( sound_emulated=False, cgb=None, log_level=defaults["log_level"], + serial_address=None, + serial_bind=None, + serial_interrupt_based=False, **kwargs ): """ @@ -111,7 +114,7 @@ def __init__( kwargs["window"] = window kwargs["scale"] = scale - randomize = kwargs.pop("randomize", False) # Undocumented feature + randomize = kwargs.pop("randomize", False) # Undocumented feature for k, v in defaults.items(): if k not in kwargs: @@ -142,6 +145,9 @@ def __init__( sound, sound_emulated, cgb, + serial_address=serial_address, + serial_bind=serial_bind, + serial_interrupt_based=serial_interrupt_based, randomize=randomize, ) @@ -394,13 +400,13 @@ def _tick(self, render): t_post = time.perf_counter_ns() nsecs = t_pre - t_start - self.avg_pre = 0.9 * self.avg_pre + (0.1*nsecs/1_000_000_000) + self.avg_pre = 0.9 * self.avg_pre + (0.1 * nsecs / 1_000_000_000) nsecs = t_tick - t_pre - self.avg_tick = 0.9 * self.avg_tick + (0.1*nsecs/1_000_000_000) + self.avg_tick = 0.9 * self.avg_tick + (0.1 * nsecs / 1_000_000_000) nsecs = t_post - t_tick - self.avg_post = 0.9 * self.avg_post + (0.1*nsecs/1_000_000_000) + self.avg_post = 0.9 * self.avg_post + (0.1 * nsecs / 1_000_000_000) return not self.quitting @@ -451,7 +457,7 @@ def tick(self, count=1, render=True): running = False while count != 0: - _render = render and count == 1 # Only render on last tick to improve performance + _render = render and count == 1 # Only render on last tick to improve performance running = self._tick(_render) count -= 1 return running @@ -477,7 +483,7 @@ def _handle_events(self, events): with open(state_path, "rb") as f: self.mb.load_state(IntIOWrapper(f)) elif event == WindowEvent.PASS: - pass # Used in place of None in Cython, when key isn't mapped to anything + pass # Used in place of None in Cython, when key isn't mapped to anything elif event == WindowEvent.PAUSE_TOGGLE: if self.paused: self._unpause() @@ -1495,6 +1501,7 @@ class PyBoyMemoryView: ``` """ + def __init__(self, mb): self.mb = mb @@ -1530,7 +1537,7 @@ def __getitem__(self, addr): return self.__getitem(addr, 0, 1, bank, is_single, is_bank) def __getitem(self, start, stop, step, bank, is_single, is_bank): - slice_length = (stop-start) // step + slice_length = (stop - start) // step if is_bank: # Reading a specific bank if start < 0x8000: @@ -1545,7 +1552,7 @@ def __getitem(self, start, stop, step, bank, is_single, is_bank): if not is_single: mem_slice = [0] * slice_length for x in range(start, stop, step): - mem_slice[(x-start) // step] = self.mb.bootrom.bootrom[x] + mem_slice[(x - start) // step] = self.mb.bootrom.bootrom[x] return mem_slice else: return self.mb.bootrom.bootrom[start] @@ -1554,7 +1561,7 @@ def __getitem(self, start, stop, step, bank, is_single, is_bank): if not is_single: mem_slice = [0] * slice_length for x in range(start, stop, step): - mem_slice[(x-start) // step] = self.mb.cartridge.rombanks[bank, x] + mem_slice[(x - start) // step] = self.mb.cartridge.rombanks[bank, x] return mem_slice else: return self.mb.cartridge.rombanks[bank, start] @@ -1570,7 +1577,7 @@ def __getitem(self, start, stop, step, bank, is_single, is_bank): if not is_single: mem_slice = [0] * slice_length for x in range(start, stop, step): - mem_slice[(x-start) // step] = self.mb.lcd.VRAM0[x] + mem_slice[(x - start) // step] = self.mb.lcd.VRAM0[x] return mem_slice else: return self.mb.lcd.VRAM0[start] @@ -1578,7 +1585,7 @@ def __getitem(self, start, stop, step, bank, is_single, is_bank): if not is_single: mem_slice = [0] * slice_length for x in range(start, stop, step): - mem_slice[(x-start) // step] = self.mb.lcd.VRAM1[x] + mem_slice[(x - start) // step] = self.mb.lcd.VRAM1[x] return mem_slice else: return self.mb.lcd.VRAM1[start] @@ -1591,7 +1598,7 @@ def __getitem(self, start, stop, step, bank, is_single, is_bank): if not is_single: mem_slice = [0] * slice_length for x in range(start, stop, step): - mem_slice[(x-start) // step] = self.mb.cartridge.rambanks[bank, x] + mem_slice[(x - start) // step] = self.mb.cartridge.rambanks[bank, x] return mem_slice else: return self.mb.cartridge.rambanks[bank, start] @@ -1608,17 +1615,17 @@ def __getitem(self, start, stop, step, bank, is_single, is_bank): if not is_single: mem_slice = [0] * slice_length for x in range(start, stop, step): - mem_slice[(x-start) // step] = self.mb.ram.internal_ram0[x + bank*0x1000] + mem_slice[(x - start) // step] = self.mb.ram.internal_ram0[x + bank * 0x1000] return mem_slice else: - return self.mb.ram.internal_ram0[start + bank*0x1000] + return self.mb.ram.internal_ram0[start + bank * 0x1000] else: assert None, "Invalid memory address for bank" elif not is_single: # Reading slice of memory space mem_slice = [0] * slice_length for x in range(start, stop, step): - mem_slice[(x-start) // step] = self.mb.getitem(x) + mem_slice[(x - start) // step] = self.mb.getitem(x) return mem_slice else: # Reading specific address of memory space @@ -1671,7 +1678,7 @@ def __setitem(self, start, stop, step, v, bank, is_single, is_bank): if not is_single: # Writing slice of memory space if hasattr(v, "__iter__"): - assert (stop-start) // step == len(v), "slice does not match length of data" + assert (stop - start) // step == len(v), "slice does not match length of data" _v = iter(v) for x in range(start, stop, step): self.mb.bootrom.bootrom[x] = next(_v) @@ -1684,7 +1691,7 @@ def __setitem(self, start, stop, step, v, bank, is_single, is_bank): if not is_single: # Writing slice of memory space if hasattr(v, "__iter__"): - assert (stop-start) // step == len(v), "slice does not match length of data" + assert (stop - start) // step == len(v), "slice does not match length of data" _v = iter(v) for x in range(start, stop, step): self.mb.cartridge.overrideitem(bank, x, next(_v)) @@ -1706,7 +1713,7 @@ def __setitem(self, start, stop, step, v, bank, is_single, is_bank): if not is_single: # Writing slice of memory space if hasattr(v, "__iter__"): - assert (stop-start) // step == len(v), "slice does not match length of data" + assert (stop - start) // step == len(v), "slice does not match length of data" _v = iter(v) for x in range(start, stop, step): self.mb.lcd.VRAM0[x] = next(_v) @@ -1719,7 +1726,7 @@ def __setitem(self, start, stop, step, v, bank, is_single, is_bank): if not is_single: # Writing slice of memory space if hasattr(v, "__iter__"): - assert (stop-start) // step == len(v), "slice does not match length of data" + assert (stop - start) // step == len(v), "slice does not match length of data" _v = iter(v) for x in range(start, stop, step): self.mb.lcd.VRAM1[x] = next(_v) @@ -1737,7 +1744,7 @@ def __setitem(self, start, stop, step, v, bank, is_single, is_bank): if not is_single: # Writing slice of memory space if hasattr(v, "__iter__"): - assert (stop-start) // step == len(v), "slice does not match length of data" + assert (stop - start) // step == len(v), "slice does not match length of data" _v = iter(v) for x in range(start, stop, step): self.mb.cartridge.rambanks[bank, x] = next(_v) @@ -1759,21 +1766,21 @@ def __setitem(self, start, stop, step, v, bank, is_single, is_bank): if not is_single: # Writing slice of memory space if hasattr(v, "__iter__"): - assert (stop-start) // step == len(v), "slice does not match length of data" + assert (stop - start) // step == len(v), "slice does not match length of data" _v = iter(v) for x in range(start, stop, step): - self.mb.ram.internal_ram0[x + bank*0x1000] = next(_v) + self.mb.ram.internal_ram0[x + bank * 0x1000] = next(_v) else: for x in range(start, stop, step): - self.mb.ram.internal_ram0[x + bank*0x1000] = v + self.mb.ram.internal_ram0[x + bank * 0x1000] = v else: - self.mb.ram.internal_ram0[start + bank*0x1000] = v + self.mb.ram.internal_ram0[start + bank * 0x1000] = v else: assert None, "Invalid memory address for bank" elif not is_single: # Writing slice of memory space if hasattr(v, "__iter__"): - assert (stop-start) // step == len(v), "slice does not match length of data" + assert (stop - start) // step == len(v), "slice does not match length of data" _v = iter(v) for x in range(start, stop, step): self.mb.setitem(x, next(_v))