diff --git a/pyboy/__main__.py b/pyboy/__main__.py index 1cd68f571..ae3a7007d 100644 --- a/pyboy/__main__.py +++ b/pyboy/__main__.py @@ -107,6 +107,14 @@ def valid_sample_rate(freq): help="Add GameShark cheats on start-up. Add multiple by comma separation (i.e. '010138CD, 01033CD1')", ) +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)" @@ -160,6 +168,9 @@ def valid_sample_rate(freq): 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/cartridge/cartridge.py b/pyboy/core/cartridge/cartridge.py index 39e823dfc..9e6cd36e7 100644 --- a/pyboy/core/cartridge/cartridge.py +++ b/pyboy/core/cartridge/cartridge.py @@ -53,7 +53,7 @@ def load_romfile(filename): logger.debug("Loading ROM file: %d bytes", len(romdata)) if len(romdata) == 0: - logger.error("ROM file is empty!") + logger.critical("ROM file is empty!") raise PyBoyException("Empty ROM file") banksize = 16 * 1024 diff --git a/pyboy/core/cartridge/mbc1.py b/pyboy/core/cartridge/mbc1.py index 30e7591eb..42cff0c9b 100644 --- a/pyboy/core/cartridge/mbc1.py +++ b/pyboy/core/cartridge/mbc1.py @@ -47,7 +47,7 @@ def setitem(self, address, value): def getitem(self, address): if 0xA000 <= address < 0xC000: if not self.rambank_initialized: - logger.error("RAM banks not initialized: %0.4x", address) + logger.warning("RAM banks not initialized: %0.4x", address) if not self.rambank_enabled: return 0xFF diff --git a/pyboy/core/cartridge/mbc2.py b/pyboy/core/cartridge/mbc2.py index 9acd11ca2..988f5dd53 100644 --- a/pyboy/core/cartridge/mbc2.py +++ b/pyboy/core/cartridge/mbc2.py @@ -34,7 +34,7 @@ def getitem(self, address): return self.rombanks[self.rombank_selected, address - 0x4000] elif 0xA000 <= address < 0xC000: if not self.rambank_initialized: - logger.error("RAM banks not initialized: %0.4x", address) + logger.warning("RAM banks not initialized: %0.4x", address) if not self.rambank_enabled: return 0xFF diff --git a/pyboy/core/cartridge/rtc.py b/pyboy/core/cartridge/rtc.py index eb613128a..036415091 100644 --- a/pyboy/core/cartridge/rtc.py +++ b/pyboy/core/cartridge/rtc.py @@ -22,7 +22,7 @@ def __init__(self, filename): self.halt = 0 if not os.path.exists(self.filename): - logger.info("No RTC file found. Skipping.") + logger.debug("No RTC file found. Skipping.") else: with open(self.filename, "rb") as f: self.load_state(IntIOWrapper(f), STATE_VERSION) diff --git a/pyboy/core/mb.py b/pyboy/core/mb.py index 32f81953c..872fb3819 100644 --- a/pyboy/core/mb.py +++ b/pyboy/core/mb.py @@ -9,8 +9,8 @@ PyBoyException, PyBoyOutOfBoundsException, INTR_TIMER, - INTR_SERIAL, INTR_HIGHTOLOW, + INTR_SERIAL, OPCODE_BRK, MAX_CYCLES, ) @@ -32,9 +32,12 @@ def __init__( sound_sample_rate, 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") + logger.debug("Boot-ROM file provided") self.cartridge = cartridge.load_cartridge(gamerom) logger.debug("Cartridge started:\n%s", str(self.cartridge)) @@ -49,7 +52,7 @@ def __init__( logger.debug("Cartridge type auto-detected to %s", ("CGB" if self.cartridge.cgb else "DMG")) self.timer = timer.Timer() - self.serial = serial.Serial() + self.serial = serial.Serial(serial_address, serial_bind, serial_interrupt_based) self.interaction = interaction.Interaction() self.ram = ram.RAM(cgb, randomize=randomize) self.cpu = cpu.CPU(self) @@ -228,6 +231,7 @@ def buttonevent(self, key): def stop(self, save): self.sound.stop() + self.serial.stop() if save: self.cartridge.stop() @@ -394,6 +398,9 @@ def getitem(self, i): if self.serial.tick(self.cpu.cycles): self.cpu.set_interruptflag(INTR_SERIAL) if i == 0xFF01: + # logger.debug(("Master " if self.serial.is_master else "Slave ") + "Read SB: %02x", self.serial.SB) + assert self.serial.sending_state == 2 # PASSIVE + assert self.serial.SC & 0x80 == 0 # No transfer active return self.serial.SB elif i == 0xFF02: return self.serial.SC diff --git a/pyboy/core/serial.pxd b/pyboy/core/serial.pxd index 0caa01615..f32c7eb5e 100644 --- a/pyboy/core/serial.pxd +++ b/pyboy/core/serial.pxd @@ -3,27 +3,37 @@ # GitHub: https://github.com/Baekalfen/PyBoy # +cimport cython from libc.stdint cimport int64_t, uint8_t, uint16_t, uint32_t, uint64_t +from pyboy.logging.logging cimport Logger from pyboy.utils cimport IntIOInterface -import cython - -from pyboy.logging.logging cimport Logger cdef uint64_t MAX_CYCLES, CYCLES_8192HZ cdef Logger logger +cdef uint64_t SENDING, RECEIVING, PASSIVE + cdef class Serial: cdef uint64_t SB, SC cdef int64_t _cycles_to_interrupt cdef uint64_t last_cycles, clock, clock_target cdef bint transfer_enabled, double_speed, internal_clock + cdef bint serial_connected, is_master + cdef uint64_t sending_state cdef bint tick(self, uint64_t) noexcept nogil + cdef void stop(self) noexcept cdef void set_SB(self, uint8_t) noexcept nogil cdef void set_SC(self, uint8_t) noexcept nogil cdef int save_state(self, IntIOInterface) except -1 cdef int load_state(self, IntIOInterface, int) except -1 + + cdef object connection, binding_connection + cdef uint8_t trans_bits + cdef bint serial_interrupt_based + + cdef void disconnect(self) noexcept nogil \ No newline at end of file diff --git a/pyboy/core/serial.py b/pyboy/core/serial.py index 02b157ee4..cb81edaa4 100644 --- a/pyboy/core/serial.py +++ b/pyboy/core/serial.py @@ -3,39 +3,147 @@ # GitHub: https://github.com/Baekalfen/PyBoy # +import time +import socket +import pyboy from pyboy.utils import MAX_CYCLES +import queue + +logger = pyboy.logging.get_logger(__name__) + +try: + import cython +except ImportError: + + class _mock: + def __enter__(self): + pass + + def __exit__(self, *args): + pass + + exec( + """ +class cython: + gil = _mock() + nogil = _mock() +""", + globals(), + locals(), + ) + CYCLES_8192HZ = 128 +async_recv = queue.Queue() + + +def async_comms(socket): + while True: + item = socket.recv(1) + async_recv.put(item) + + +SENDING, RECEIVING, PASSIVE = 0, 1, 2 + class Serial: - def __init__(self): + def __init__(self, serial_address, serial_bind, serial_interrupt_based): self.SB = 0xFF # Always 0xFF for a disconnected link cable self.SC = 0 self.transfer_enabled = 0 + self.is_master = False self.internal_clock = 0 self._cycles_to_interrupt = 0 self.last_cycles = 0 self.clock = 0 self.clock_target = MAX_CYCLES + self.serial_connected = False + self.connection = None + self.sending_state = PASSIVE + + self.trans_bits = 0 + self.serial_interrupt_based = serial_interrupt_based + + self.all_data = {"send": [], "recv": []} + + logger.debug("Serial starts: %s, %d, %d", serial_address, serial_bind, serial_interrupt_based) + + if not serial_address: + logger.debug("No serial address supplied. Link Cable emulated as disconnected.") + return + + if not serial_address.count(".") == 3 and serial_address.count(":") == 1: + logger.error("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.binding_connection = None + 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.debug(f"Binding to {serial_address}") + self.binding_connection.bind(address_tuple) + self.binding_connection.listen(1) + self.connection, _ = self.binding_connection.accept() + logger.debug("Client has connected!") + else: + self.connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + for _ in range(10): + logger.debug(f"Connecting to {serial_address}") + try: + self.connection.connect(address_tuple) + break + except ConnectionRefusedError: + time.sleep(1) + except OSError: + time.sleep(1) + + logger.debug("Connection successful!") + + self.serial_connected = self.connection is not None + # if self.serial_interrupt_based: + # logger.debug("Interrupt-based serial emulation active!") + # self.recv_thread = threading.Thread(target=async_comms, args=(self.connection,)) + # self.recv_thread.start() + def set_SB(self, value): - # Always 0xFF when cable is disconnected. Connecting is not implemented yet. - self.SB = 0xFF + # if not value == self.SB: + # logger.debug(("Master " if self.is_master else "Slave ") + "Write SB: %02x", value) + # logger.debug("SB set %02x", value) + if not self.serial_connected: + # Always 0xFF when cable is disconnected. Connecting is not implemented yet. + self.SB = 0xFF + else: + self.SB = value def set_SC(self, value): # cgb, double_speed + # logger.debug(("Master " if self.is_master else "Slave ") + "Write SC: %02x", value) self.SC = value self.transfer_enabled = self.SC & 0x80 + self.is_master = self.SC & 1 + if self.is_master: + self.sending_state = SENDING + else: + self.sending_state = RECEIVING + # logger.debug("SC set %02x, %s", value, self.transfer_enabled) # TODO: # if cgb and (self.SC & 0b10): # High speed transfer # self.double_speed = ... self.internal_clock = self.SC & 1 # 0: external, 1: internal - if self.internal_clock: - self.clock_target = self.clock + 8 * CYCLES_8192HZ + if not self.serial_connected: + if self.internal_clock: + self.clock_target = self.clock + 8 * CYCLES_8192HZ + else: + # Will never complete, as there is no connection + self.transfer_enabled = 0 # Technically it is enabled, but no reason to track it. + self.clock_target = MAX_CYCLES else: - # Will never complete, as there is no connection - self.transfer_enabled = 0 # Technically it is enabled, but no reason to track it. - self.clock_target = MAX_CYCLES + # If connected then immediate send data + self.clock_target = self.clock self._cycles_to_interrupt = self.clock_target - self.clock def tick(self, _cycles): @@ -47,20 +155,109 @@ def tick(self, _cycles): self.clock += cycles interrupt = False - if self.transfer_enabled and self.clock >= self.clock_target: - self.SC &= 0x80 - self.transfer_enabled = 0 - # self._cycles_to_interrupt = MAX_CYCLES - self.clock_target = MAX_CYCLES - interrupt = True + if self.transfer_enabled: + if not self.serial_connected: + if self.clock >= self.clock_target: + # Disconnected emulation + self.SC &= 0x80 + self.transfer_enabled = 0 + self.clock_target = MAX_CYCLES + interrupt = True + elif self.serial_interrupt_based: # Connected emulation + self.clock_target = MAX_CYCLES # interrupt-based serial has no timing, just asap + # TODO: SB-read-write based transfers? Schedule interrupt, but don't transfer before SB is read. Transfer whatever is needed and resync both. + with cython.gil: + try: + if self.is_master: + if self.SC & 0x80: + if self.sending_state == SENDING: + # logger.debug("Master sending!") + self.connection.send(bytes([self.SB])) + self.all_data["send"].append(self.SB) + self.sending_state = RECEIVING + logger.debug("Master byte sent: %02x", self.SB) + self.clock_target = 0 + elif self.sending_state == RECEIVING: + try: + data = self.connection.recv( + 1, socket.MSG_DONTWAIT + ) # TODO: Timeout if the other side disconnects + if len(data) > 0: + self.SB = data[0] + self.all_data["recv"].append(self.SB) + logger.debug("Master byte received: %02x", self.SB) + self.SC &= 0b0111_1111 + interrupt = True + self.sending_state = PASSIVE + self.clock_target = MAX_CYCLES + else: + logger.error("Master disconnect!") + self.disconnect() + except BlockingIOError: + pass + # else: + # self.clock_target = self.clock + 8 * CYCLES_8192HZ + else: + if self.sending_state == SENDING: + self.connection.send(bytes([self.SB])) + self.all_data["send"].append(self.SB) + logger.debug("Slave byte sent: %02x", self.SB) + # data = self.connection.recv(1) + self.SB = self.SB_latched + self.SC &= 0b0111_1111 + interrupt = True + self.sending_state = PASSIVE + self.clock_target = MAX_CYCLES + elif self.sending_state == RECEIVING: + try: + data = self.connection.recv(1, socket.MSG_DONTWAIT) + if len(data) > 0: + self.SB_latched = data[0] + logger.debug("Slave recv! %02x", self.SB_latched) + self.all_data["recv"].append(self.SB_latched) + self.sending_state = SENDING + self.clock_target = 0 + else: + logger.error("Slave disconnect!") + self.disconnect() + except BlockingIOError: + # logger.debug("Slave no data!") + # self.clock_target = self.clock + 8 * CYCLES_8192HZ + pass + except (ConnectionResetError, BrokenPipeError) as ex: + logger.error(("Master " if self.is_master else "Slave ") + "Exception in serial tick: %s", ex) + self.disconnect() + else: + raise Exception(("Master " if self.is_master else "Slave ") + "Invalid mode") self._cycles_to_interrupt = self.clock_target - self.clock return interrupt + # Clock based serial: + # else: + # exit(2) + # # if self.SC & 1: # Master + # send_bit = bytes([(self.SB >> 7) & 1]) + # self.connection.send(send_bit) + + # data = self.connection.recv(1) + # self.SB = ((self.SB << 1) & 0xFF) | data[0] & 1 + + # logger.debug(f"recv sb: {self.SB:08b}") + # self.trans_bits += 1 + + # if self.trans_bits == 8: + # self.trans_bits = 0 + # self.SC &= 0b0111_1111 + # interrupt = True + + # self.clock_target = self.clock + CYCLES_8192HZ + def save_state(self, f): f.write(self.SB) f.write(self.SC) f.write(self.transfer_enabled) + f.write(self.is_master) f.write(self.internal_clock) f.write_64bit(self.last_cycles) f.write_64bit(self._cycles_to_interrupt) @@ -71,8 +268,41 @@ def load_state(self, f, state_version): self.SB = f.read() self.SC = f.read() self.transfer_enabled = f.read() + self.is_master = f.read() self.internal_clock = f.read() self.last_cycles = f.read_64bit() self._cycles_to_interrupt = f.read_64bit() self.clock = f.read_64bit() self.clock_target = f.read_64bit() + + def disconnect(self): + logger.debug("DISCONNECTING") + with cython.gil: + if self.serial_connected: + if self.connection: + self.connection.close() + if self.binding_connection: + self.binding_connection.close() + # if self.serial_interrupt_based and self.recv_thread: + # self.recv_thread.kill() + + self.sending_state = PASSIVE + self.serial_connected = False + self.SB = 0xFF + self.clock_target = MAX_CYCLES + + def stop(self): + from pprint import pprint as pp + + pp(self.all_data) + if hasattr(self, "binding_connection") and self.binding_connection is not None: + with open("master_recv.bin", "wb") as f: + f.write(bytes(self.all_data["recv"])) + with open("master_send.bin", "wb") as f: + f.write(bytes(self.all_data["send"])) + else: + with open("slave_recv.bin", "wb") as f: + f.write(bytes(self.all_data["recv"])) + with open("slave_send.bin", "wb") as f: + f.write(bytes(self.all_data["send"])) + self.disconnect() diff --git a/pyboy/core/sound.py b/pyboy/core/sound.py index 9ddd6fba1..b0ebb5a90 100644 --- a/pyboy/core/sound.py +++ b/pyboy/core/sound.py @@ -299,7 +299,7 @@ def sample(self): right_sample = 0 if self.audiobuffer_head >= self.audiobuffer_length: - logger.critical("Buffer overrun! %d of %d", self.audiobuffer_head, self.audiobuffer_length) + logger.warning("Buffer overrun! %d of %d", self.audiobuffer_head, self.audiobuffer_length) return self.audiobuffer[self.audiobuffer_head] = left_sample self.audiobuffer[self.audiobuffer_head + 1] = right_sample diff --git a/pyboy/logging/__init__.py b/pyboy/logging/__init__.py index 38637e819..eec805942 100644 --- a/pyboy/logging/__init__.py +++ b/pyboy/logging/__init__.py @@ -1,12 +1,12 @@ ( - DEBUG, - INFO, - WARNING, - ERROR, - CRITICAL, + DEBUG, # Only for developers + WARNING, # Unexpected failure that can be worked around + ERROR, # Unexpected failure that impacts usability + CRITICAL, # Unexpected failure where we cannot continue + INFO, # Normal operation, that the user *needs* or *wants* to be aware of ) = range(5) -_log_level = WARNING +_log_level = INFO def get_log_level(): diff --git a/pyboy/plugins/window_sdl2.py b/pyboy/plugins/window_sdl2.py index 9d7f1b788..5a8d794c8 100644 --- a/pyboy/plugins/window_sdl2.py +++ b/pyboy/plugins/window_sdl2.py @@ -282,10 +282,11 @@ def post_tick(self): def paused(self, pause): self.sound_paused = pause - if self.sound_paused: - sdl2.SDL_PauseAudioDevice(self.sound_device, 1) - else: - sdl2.SDL_PauseAudioDevice(self.sound_device, 0) + if self.sound_support: + if self.sound_paused: + sdl2.SDL_PauseAudioDevice(self.sound_device, 1) + else: + sdl2.SDL_PauseAudioDevice(self.sound_device, 0) def enabled(self): if self.pyboy_argv.get("window") in ("SDL2", None): diff --git a/pyboy/pyboy.py b/pyboy/pyboy.py index 2451dd965..50ef940dd 100644 --- a/pyboy/pyboy.py +++ b/pyboy/pyboy.py @@ -92,6 +92,9 @@ def __init__( color_palette=defaults["color_palette"], cgb_color_palette=defaults["cgb_color_palette"], title_status=False, + serial_address=None, + serial_bind=None, + serial_interrupt_based=False, **kwargs, ): """ @@ -209,6 +212,9 @@ def __init__( sound_sample_rate, cgb, randomize=randomize, + serial_address=serial_address, + serial_bind=serial_bind, + serial_interrupt_based=serial_interrupt_based, ) # Validate all kwargs @@ -220,7 +226,7 @@ def __init__( for k, v in kwargs.items(): if k not in defaults and k not in plugin_manager_keywords: - logger.error("Unknown keyword argument: %s", k) + logger.critical("Unknown keyword argument: %s", k) raise KeyError(f"Unknown keyword argument: {k}") # Performance measures @@ -610,7 +616,7 @@ def _pause(self): self.paused = True self.save_target_emulationspeed = self.target_emulationspeed self.target_emulationspeed = 1 - logger.info("Emulation paused!") + logger.debug("Emulation paused!") self._update_window_title() self._plugin_manager.paused(True) @@ -619,7 +625,7 @@ def _unpause(self): return self.paused = False self.target_emulationspeed = self.save_target_emulationspeed - logger.info("Emulation unpaused!") + logger.debug("Emulation unpaused!") self._update_window_title() self._plugin_manager.paused(False) @@ -677,9 +683,9 @@ def stop(self, save=True): provided game-ROM. """ if self.initialized and not self.stopped: - logger.info("###########################") - logger.info("# Emulator is turning off #") - logger.info("###########################") + logger.debug("###########################") + logger.debug("# Emulator is turning off #") + logger.debug("###########################") self._plugin_manager.stop() self.mb.stop(save) self.stopped = True @@ -1119,7 +1125,7 @@ def _load_symbols(self): gamerom_file_no_ext, rom_ext = os.path.splitext(self.gamerom) for sym_path in [self.symbols_file, gamerom_file_no_ext + ".sym", gamerom_file_no_ext + rom_ext + ".sym"]: if sym_path and os.path.isfile(sym_path): - logger.info("Loading symbol file: %s", sym_path) + logger.debug("Loading symbol file: %s", sym_path) with open(sym_path) as f: for _line in f.readlines(): line = _line.strip()