#!/usr/bin/env python3

import argparse
import os
import queue
import serial
import socket
import sys
import time
from binascii import crc32
from datetime import datetime
from enum import Enum, IntEnum
from io import BufferedReader
from serial.tools import list_ports
from threading import Thread
from typing import Callable, Optional
from PIL import Image



class BadBlockError(Exception):
    pass


class DD64Image:
    __DISK_HEADS = 2
    __DISK_TRACKS = 1175
    __DISK_BLOCKS_PER_TRACK = 2
    __DISK_SECTORS_PER_BLOCK = 85
    __DISK_BAD_TRACKS_PER_ZONE = 12
    __DISK_SYSTEM_SECTOR_SIZE = 232
    __DISK_ZONES = [
        (0, 232, 158, 0),
        (0, 216, 158, 158),
        (0, 208, 149, 316),
        (0, 192, 149, 465),
        (0, 176, 149, 614),
        (0, 160, 149, 763),
        (0, 144, 149, 912),
        (0, 128, 114, 1061),
        (1, 216, 158, 157),
        (1, 208, 158, 315),
        (1, 192, 149, 464),
        (1, 176, 149, 613),
        (1, 160, 149, 762),
        (1, 144, 149, 911),
        (1, 128, 149, 1060),
        (1, 112, 114, 1174),
    ]
    __DISK_VZONE_TO_PZONE = [
        [0, 1, 2, 9, 8, 3, 4, 5, 6, 7, 15, 14, 13, 12, 11, 10],
        [0, 1, 2, 3, 10, 9, 8, 4, 5, 6, 7, 15, 14, 13, 12, 11],
        [0, 1, 2, 3, 4, 11, 10, 9, 8, 5, 6, 7, 15, 14, 13, 12],
        [0, 1, 2, 3, 4, 5, 12, 11, 10, 9, 8, 6, 7, 15, 14, 13],
        [0, 1, 2, 3, 4, 5, 6, 13, 12, 11, 10, 9, 8, 7, 15, 14],
        [0, 1, 2, 3, 4, 5, 6, 7, 14, 13, 12, 11, 10, 9, 8, 15],
        [0, 1, 2, 3, 4, 5, 6, 7, 15, 14, 13, 12, 11, 10, 9, 8],
    ]
    __DISK_DRIVE_TYPES = [(
        'development',
        192,
        [11, 10, 3, 2],
        [0, 1, 8, 9, 16, 17, 18, 19, 20, 21, 22, 23],
    ), (
        'retail',
        232,
        [9, 8, 1, 0],
        [2, 3, 10, 11, 12, 16, 17, 18, 19, 20, 21, 22, 23],
    )]

    __file: Optional[BufferedReader]
    __drive_type: Optional[str]
    __block_info_table: list[tuple[int, int]]
    loaded: bool = False

    def __init__(self) -> None:
        self.__file = None
        self.__drive_type = None
        block_info_table_length = self.__DISK_HEADS * self.__DISK_TRACKS * self.__DISK_BLOCKS_PER_TRACK
        self.__block_info_table = [None] * block_info_table_length

    def __del__(self) -> None:
        self.unload()

    def __check_system_block(self, lba: int, sector_size: int, check_disk_type: bool) -> tuple[bool, bytes]:
        self.__file.seek(lba * self.__DISK_SYSTEM_SECTOR_SIZE * self.__DISK_SECTORS_PER_BLOCK)
        system_block_data = self.__file.read(sector_size * self.__DISK_SECTORS_PER_BLOCK)
        system_data = system_block_data[:sector_size]
        for sector in range(1, self.__DISK_SECTORS_PER_BLOCK):
            sector_data = system_block_data[(sector * sector_size):][:sector_size]
            if (system_data != sector_data):
                return (False, None)
        if (check_disk_type):
            if (system_data[4] != 0x10):
                return (False, None)
            if ((system_data[5] & 0xF0) != 0x10):
                return (False, None)
        return (True, system_data)

    def __parse_disk(self) -> None:
        disk_system_data = None
        disk_id_data = None
        disk_bad_lbas = []

        drive_index = 0
        while (disk_system_data == None) and (drive_index < len(self.__DISK_DRIVE_TYPES)):
            (drive_type, system_sector_size, system_data_lbas, bad_lbas) = self.__DISK_DRIVE_TYPES[drive_index]
            disk_bad_lbas.clear()
            disk_bad_lbas.extend(bad_lbas)
            for system_lba in system_data_lbas:
                (valid, system_data) = self.__check_system_block(system_lba, system_sector_size, check_disk_type=True)
                if (valid):
                    self.__drive_type = drive_type
                    disk_system_data = system_data
                else:
                    disk_bad_lbas.append(system_lba)
            drive_index += 1

        for id_lba in [15, 14]:
            (valid, id_data) = self.__check_system_block(id_lba, self.__DISK_SYSTEM_SECTOR_SIZE, check_disk_type=False)
            if (valid):
                disk_id_data = id_data
            else:
                disk_bad_lbas.append(id_lba)

        if not (disk_system_data and disk_id_data):
            raise ValueError('Provided 64DD disk file is not valid')

        disk_zone_bad_tracks = []

        for zone in range(len(self.__DISK_ZONES)):
            zone_bad_tracks = []
            start = 0 if zone == 0 else system_data[0x07 + zone]
            stop = system_data[0x07 + zone + 1]
            for offset in range(start, stop):
                zone_bad_tracks.append(system_data[0x20 + offset])
            for ignored_track in range(self.__DISK_BAD_TRACKS_PER_ZONE - len(zone_bad_tracks)):
                zone_bad_tracks.append(self.__DISK_ZONES[zone][2] - ignored_track - 1)
            disk_zone_bad_tracks.append(zone_bad_tracks)

        disk_type = disk_system_data[5] & 0x0F

        current_lba = 0
        starting_block = 0
        disk_file_offset = 0

        for zone in self.__DISK_VZONE_TO_PZONE[disk_type]:
            (head, sector_size, tracks, track) = self.__DISK_ZONES[zone]

            for zone_track in range(tracks):
                current_zone_track = (
                    (tracks - 1) - zone_track) if head else zone_track

                if (current_zone_track in disk_zone_bad_tracks[zone]):
                    track += (-1) if head else 1
                    continue

                for block in range(self.__DISK_BLOCKS_PER_TRACK):
                    index = (track << 2) | (head << 1) | (starting_block ^ block)
                    if (current_lba not in disk_bad_lbas):
                        self.__block_info_table[index] = (disk_file_offset, sector_size * self.__DISK_SECTORS_PER_BLOCK)
                    else:
                        self.__block_info_table[index] = None
                    disk_file_offset += sector_size * self.__DISK_SECTORS_PER_BLOCK
                    current_lba += 1

                track += (-1) if head else 1
                starting_block ^= 1

    def __check_track_head_block(self, track: int, head: int, block: int) -> None:
        if (track < 0 or track >= self.__DISK_TRACKS):
            raise ValueError('Track outside of possible range')
        if (head < 0 or head >= self.__DISK_HEADS):
            raise ValueError('Head outside of possible range')
        if (block < 0 or block >= self.__DISK_BLOCKS_PER_TRACK):
            raise ValueError('Block outside of possible range')

    def __get_table_index(self, track: int, head: int, block: int) -> int:
        return (track << 2) | (head << 1) | (block)

    def __get_block_info(self, track: int, head: int, block: int) -> Optional[tuple[int, int]]:
        if (self.__file.closed):
            return None
        self.__check_track_head_block(track, head, block)
        index = self.__get_table_index(track, head, block)
        return self.__block_info_table[index]

    def load(self, path: str) -> None:
        self.unload()
        self.__file = open(path, 'rb+')
        self.__parse_disk()
        self.loaded = True

    def unload(self) -> None:
        self.loaded = False
        if (self.__file != None and not self.__file.closed):
            self.__file.close()
        self.__drive_type = None

    def get_block_info_table(self) -> list[tuple[int, int]]:
        return self.__block_info_table

    def get_drive_type(self) -> str:
        return self.__drive_type

    def read_block(self, track: int, head: int, block: int) -> bytes:
        info = self.__get_block_info(track, head, block)
        if (info == None):
            raise BadBlockError
        (offset, block_size) = info
        self.__file.seek(offset)
        return self.__file.read(block_size)

    def write_block(self, track: int, head: int, block: int, data: bytes) -> None:
        info = self.__get_block_info(track, head, block)
        if (info == None):
            raise BadBlockError
        (offset, block_size) = info
        if (len(data) != block_size):
            raise ValueError(f'Provided data block size is different than expected ({len(data)} != {block_size})')
        self.__file.seek(offset)
        self.__file.write(data)


class ConnectionException(Exception):
    pass


class SC64Serial:
    __disconnect = False
    __serial: Optional[serial.Serial] = None
    __thread_read = None
    __thread_write = None
    __queue_output = queue.Queue()
    __queue_input = queue.Queue()
    __queue_packet = queue.Queue()

    __VID = 0x0403
    __PID = 0x6014

    __CHUNK_SIZE = (64 * 1024)

    def __init__(self) -> None:
        ports = list_ports.comports()
        device_found = False

        if (self.__serial != None and self.__serial.is_open):
            raise ConnectionException('Serial port is already open')

        for p in ports:
            if (p.vid == self.__VID and p.pid == self.__PID and p.serial_number.startswith('SC64')):
                try:
                    self.__serial = serial.Serial(p.device, timeout=1.0, write_timeout=1.0)
                    self.__reset_link()
                except (serial.SerialException, ConnectionException):
                    if (self.__serial):
                        self.__serial.close()
                    continue
                device_found = True
                break

        if (not device_found):
            raise ConnectionException('No SC64 device was found')

        self.__thread_read = Thread(target=self.__serial_process_input, daemon=True)
        self.__thread_write = Thread(target=self.__serial_process_output, daemon=True)

        self.__thread_read.start()
        self.__thread_write.start()

    def __del__(self) -> None:
        self.__disconnect = True
        if (self.__thread_read != None and self.__thread_read.is_alive()):
            self.__thread_read.join(1)
        if (self.__thread_write != None and self.__thread_write.is_alive()):
            self.__thread_write.join(1)
        if (self.__serial != None and self.__serial.is_open):
            self.__serial.close()

    def __reset_link(self) -> None:
        self.__serial.reset_output_buffer()

        retry_counter = 0
        self.__serial.dtr = 1
        while (self.__serial.dsr == 0):
            time.sleep(0.1)
            retry_counter += 1
            if (retry_counter >= 10):
                raise ConnectionException('Could not reset SC64 device')

        self.__serial.reset_input_buffer()

        retry_counter = 0
        self.__serial.dtr = 0
        while (self.__serial.dsr == 1):
            time.sleep(0.1)
            retry_counter += 1
            if (retry_counter >= 10):
                raise ConnectionException('Could not reset SC64 device')

    def __write(self, data: bytes) -> None:
        try:
            if (self.__disconnect):
                raise ConnectionException
            for offset in range(0, len(data), self.__CHUNK_SIZE):
                self.__serial.write(data[offset:offset + self.__CHUNK_SIZE])
            self.__serial.flush()
        except (serial.SerialException, serial.SerialTimeoutException):
            raise ConnectionException

    def __read(self, length: int) -> bytes:
        try:
            data = b''
            while (len(data) < length and not self.__disconnect):
                data += self.__serial.read(length - len(data))
            if (self.__disconnect):
                raise ConnectionException
            return data
        except serial.SerialException:
            raise ConnectionException

    def __read_int(self) -> int:
        return int.from_bytes(self.__read(4), byteorder='big')

    def __serial_process_output(self) -> None:
        while (not self.__disconnect and self.__serial != None and self.__serial.is_open):
            try:
                packet: bytes = self.__queue_output.get(timeout=0.1)
                self.__write(packet)
                self.__queue_output.task_done()
            except queue.Empty:
                continue
            except ConnectionException:
                break

    def __serial_process_input(self) -> None:
        while (not self.__disconnect and self.__serial != None and self.__serial.is_open):
            try:
                token = self.__read(4)
                if (len(token) == 4):
                    identifier = token[0:3]
                    command = token[3:4]
                    if (identifier == b'PKT'):
                        data = self.__read(self.__read_int())
                        self.__queue_packet.put((command, data))
                    elif (identifier == b'CMP' or identifier == b'ERR'):
                        data = self.__read(self.__read_int())
                        success = identifier == b'CMP'
                        self.__queue_input.put((command, data, success))
                    else:
                        raise ConnectionException
            except ConnectionException:
                break

    def __check_threads(self) -> None:
        if (not (self.__thread_write.is_alive() and self.__thread_read.is_alive())):
            raise ConnectionException('Serial link is closed')

    def __queue_cmd(self, cmd: bytes, args: list[int]=[0, 0], data: bytes=b'') -> None:
        if (len(cmd) != 1):
            raise ValueError('Length of command is different than 1 byte')
        if (len(args) != 2):
            raise ValueError('Number of arguments is different than 2')
        packet: bytes = b'CMD'
        packet += cmd[0:1]
        for arg in args:
            packet += arg.to_bytes(4, byteorder='big')
        packet += data
        self.__queue_output.put(packet)

    def __pop_response(self, cmd: bytes, timeout: float, raise_on_err: bool) -> bytes:
        try:
            (response_cmd, data, success) = self.__queue_input.get(timeout=timeout)
            self.__queue_input.task_done()
            if (cmd != response_cmd):
                raise ConnectionException('CMD wrong command response')
            if (raise_on_err and success == False):
                raise ConnectionException('CMD response error')
            return data
        except queue.Empty:
            raise ConnectionException('CMD response timeout')

    def execute_cmd(self, cmd: bytes, args: list[int]=[0, 0], data: bytes=b'', response: bool=True, timeout: float=5.0, raise_on_err: bool=True) -> Optional[bytes]:
        self.__check_threads()
        self.__queue_cmd(cmd, args, data)
        if (response):
            return self.__pop_response(cmd, timeout, raise_on_err)
        return None

    def get_packet(self, timeout: float=0.1) -> Optional[tuple[bytes, bytes]]:
        self.__check_threads()
        try:
            packet = self.__queue_packet.get(timeout=timeout)
            self.__queue_packet.task_done()
            return packet
        except queue.Empty:
            return None


class SC64:
    class __Address(IntEnum):
        MEMORY = 0x0000_0000
        SDRAM = 0x0000_0000
        FLASH = 0x0400_0000
        BUFFER = 0x0500_0000
        EEPROM = 0x0500_2000
        END = 0x0500_297F
        FIRMWARE = 0x0200_0000
        DDIPL = 0x03BC_0000
        SAVE = 0x03FE_0000
        EXTENDED = 0x0400_0000
        BOOTLOADER = 0x04E0_0000
        SHADOW = 0x04FE_0000

    class __Length(IntEnum):
        MEMORY = 0x0500_2980
        SDRAM = (64 * 1024 * 1024)
        FLASH = (16 * 1024 * 1024)
        BUFFER = (8 * 1024)
        EEPROM = (2 * 1024)
        DDIPL = (4 * 1024 * 1024)
        SAVE = (128 * 1024)
        EXTENDED = (14 * 1024 * 1024)
        BOOTLOADER = (1920 * 1024)
        SHADOW = (128 * 1024)

    class __SaveLength(IntEnum):
        NONE = 0
        EEPROM_4K = 512
        EEPROM_16K = (2 * 1024)
        SRAM = (32 * 1024)
        FLASHRAM = (128 * 1024)
        SRAM_BANKED = (3 * 32 * 1024)

    class __CfgId(IntEnum):
        BOOTLOADER_SWITCH = 0
        ROM_WRITE_ENABLE = 1
        ROM_SHADOW_ENABLE = 2
        DD_MODE = 3
        ISV_ADDRESS = 4
        BOOT_MODE = 5
        SAVE_TYPE = 6
        CIC_SEED = 7
        TV_TYPE = 8
        DD_SD_ENABLE = 9
        DD_DRIVE_TYPE = 10
        DD_DISK_STATE = 11
        BUTTON_STATE = 12
        BUTTON_MODE = 13
        ROM_EXTENDED_ENABLE = 14

    class __SettingId(IntEnum):
        LED_ENABLE = 0

    class __UpdateError(IntEnum):
        OK = 0
        TOKEN = 1
        CHECKSUM = 2
        SIZE = 3
        UNKNOWN_CHUNK = 4
        READ = 5

    class __UpdateStatus(IntEnum):
        MCU = 1
        FPGA = 2
        BOOTLOADER = 3
        DONE = 0x80
        ERROR = 0xFF

    class __DDMode(IntEnum):
        NONE = 0
        REGS = 1
        DDIPL = 2
        FULL = 3

    class __DDDriveType(IntEnum):
        RETAIL = 0
        DEVELOPMENT = 1

    class __DDDiskState(IntEnum):
        EJECTED = 0
        INSERTED = 1
        CHANGED = 2

    class __ButtonMode(IntEnum):
        NONE = 0
        N64_IRQ = 1
        USB_PACKET = 2
        DD_DISK_SWAP = 3

    class BootMode(IntEnum):
        MENU = 0
        ROM = 1
        DDIPL = 2
        DIRECT_ROM = 3
        DIRECT_DDIPL = 4

    class SaveType(IntEnum):
        NONE = 0
        EEPROM_4K = 1
        EEPROM_16K = 2
        SRAM = 3
        FLASHRAM = 4
        SRAM_BANKED = 5

    class CICSeed(IntEnum):
        DEFAULT = 0x3F
        X103 = 0x78
        X105 = 0x91
        X106 = 0x85
        ALECK = 0xAC
        DD_JP = 0xDD
        DD_US = 0xDE
        AUTO = 0xFFFF

    class TVType(IntEnum):
        PAL = 0
        NTSC = 1
        MPAL = 2
        AUTO = 3

    class __DebugDatatype(IntEnum):
        TEXT = 1
        RAWBINARY = 2
        HEADER = 3
        SCREENSHOT = 4
        GDB = 0xDB

    __SUPPORTED_MAJOR_VERSION = 2
    __SUPPORTED_MINOR_VERSION = 12

    __isv_line_buffer: bytes = b''
    __debug_header: Optional[bytes] = None
    __gdb_client: Optional[socket.socket] = None

    def __init__(self) -> None:
        self.__link = SC64Serial()
        identifier = self.__link.execute_cmd(cmd=b'v')
        if (identifier != b'SCv2'):
            raise ConnectionException('Unknown SC64 v2 identifier')

    def __get_int(self, data: bytes) -> int:
        return int.from_bytes(data[:4], byteorder='big')

    def check_firmware_version(self) -> tuple[str, bool]:
        try:
            version = self.__link.execute_cmd(cmd=b'V')
            major = self.__get_int(version[0:2])
            minor = self.__get_int(version[2:4])
            if (major != self.__SUPPORTED_MAJOR_VERSION):
                raise ConnectionException()
            if (minor < self.__SUPPORTED_MINOR_VERSION):
                raise ConnectionException()
            return (f'{major}.{minor}', minor > self.__SUPPORTED_MINOR_VERSION)
        except ConnectionException:
            raise ConnectionException(f'Unsupported SC64 version [{major}.{minor}], please update firmware')

    def __set_config(self, config: __CfgId, value: int) -> None:
        try:
            self.__link.execute_cmd(cmd=b'C', args=[config, value])
        except ConnectionException:
            raise ValueError(f'Could not set config {config.name} to {value:08X}')

    def __get_config(self, config: __CfgId) -> int:
        try:
            data = self.__link.execute_cmd(cmd=b'c', args=[config, 0])
        except ConnectionException:
            raise ValueError(f'Could not get config {config.name}')
        return self.__get_int(data)

    def __set_setting(self, setting: __SettingId, value: int) -> None:
        try:
            self.__link.execute_cmd(cmd=b'A', args=[setting, value])
        except ConnectionException:
            raise ValueError(f'Could not set setting {setting.name} to {value:08X}')

    def __get_setting(self, setting: __SettingId) -> int:
        try:
            data = self.__link.execute_cmd(cmd=b'a', args=[setting, 0])
        except ConnectionException:
            raise ValueError(f'Could not get setting {setting.name}')
        return self.__get_int(data)

    def __write_memory(self, address: int, data: bytes) -> None:
        if (len(data) > 0):
            self.__link.execute_cmd(cmd=b'M', args=[address, len(data)], data=data, timeout=20.0)

    def __read_memory(self, address: int, length: int) -> bytes:
        if (length > 0):
            return self.__link.execute_cmd(cmd=b'm', args=[address, length], timeout=20.0)
        return bytes([])

    def __dd_set_block_ready(self, error: int) -> None:
        self.__link.execute_cmd(cmd=b'D', args=[error, 0])

    def __flash_wait_busy(self) -> None:
        self.__link.execute_cmd(cmd=b'p', args=[True, 0])

    def __flash_get_erase_block_size(self) -> int:
        data = self.__link.execute_cmd(cmd=b'p', args=[False, 0])
        return self.__get_int(data[0:4])

    def __flash_erase_block(self, address: int) -> None:
        self.__link.execute_cmd(cmd=b'P', args=[address, 0])

    def __erase_flash_region(self, address: int, length: int) -> None:
        if (address < self.__Address.FLASH):
            raise ValueError('Flash erase address or length outside of possible range')
        if ((address + length) > (self.__Address.FLASH + self.__Length.FLASH)):
            raise ValueError('Flash erase address or length outside of possible range')
        erase_block_size = self.__flash_get_erase_block_size()
        if (address % erase_block_size != 0):
            raise ValueError('Flash erase address not aligned to block size')
        for offset in range(address, address + length, erase_block_size):
            self.__flash_erase_block(offset)

    def __program_flash(self, address: int, data: bytes):
        program_chunk_size = (128 * 1024)
        if (self.__read_memory(address, len(data)) != data):
            self.__erase_flash_region(address, len(data))
            for offset in range(0, len(data), program_chunk_size):
                self.__write_memory(address + offset, data[offset:offset + program_chunk_size])
            self.__flash_wait_busy()
            if (self.__read_memory(address, len(data)) != data):
                raise ConnectionException('Flash memory program failure')

    def autodetect_save_type(self, data: bytes) -> Optional[SaveType]:
        if (len(data) < 0x40):
            return None
        if (data[0x3C:0x3E] != b'ED'):
            return None
        save = (data[0x3F] >> 4) & 0x0F
        if (save < 0 or save > 6):
            return None
        save_type_mapping = [
            self.SaveType.NONE,
            self.SaveType.EEPROM_4K,
            self.SaveType.EEPROM_16K,
            self.SaveType.SRAM,
            self.SaveType.SRAM_BANKED,
            self.SaveType.FLASHRAM,
            self.SaveType.SRAM,
        ]
        return save_type_mapping[save]

    def reset_state(self) -> None:
        self.__link.execute_cmd(cmd=b'R')

    def get_state(self):
        return {
            'bootloader_switch': bool(self.__get_config(self.__CfgId.BOOTLOADER_SWITCH)),
            'rom_write_enable': bool(self.__get_config(self.__CfgId.ROM_WRITE_ENABLE)),
            'rom_shadow_enable': bool(self.__get_config(self.__CfgId.ROM_SHADOW_ENABLE)),
            'dd_mode': self.__DDMode(self.__get_config(self.__CfgId.DD_MODE)),
            'isv_address': self.__get_config(self.__CfgId.ISV_ADDRESS),
            'boot_mode': self.BootMode(self.__get_config(self.__CfgId.BOOT_MODE)),
            'save_type': self.SaveType(self.__get_config(self.__CfgId.SAVE_TYPE)),
            'cic_seed': self.CICSeed(self.__get_config(self.__CfgId.CIC_SEED)),
            'tv_type': self.TVType(self.__get_config(self.__CfgId.TV_TYPE)),
            'dd_sd_enable': bool(self.__get_config(self.__CfgId.DD_SD_ENABLE)),
            'dd_drive_type': self.__DDDriveType(self.__get_config(self.__CfgId.DD_DRIVE_TYPE)),
            'dd_disk_state': self.__DDDiskState(self.__get_config(self.__CfgId.DD_DISK_STATE)),
            'button_state': bool(self.__get_config(self.__CfgId.BUTTON_STATE)),
            'button_mode': self.__ButtonMode(self.__get_config(self.__CfgId.BUTTON_MODE)),
            'rom_extended_enable': bool(self.__get_config(self.__CfgId.ROM_EXTENDED_ENABLE)),
            'led_enable': bool(self.__get_setting(self.__SettingId.LED_ENABLE)),
        }

    def debug_send(self, datatype: __DebugDatatype, data: bytes) -> None:
        if (len(data) > (8 * 1024 * 1024)):
            raise ValueError('Debug data size too big')
        self.__link.execute_cmd(cmd=b'U', args=[datatype, len(data)], data=data, response=False)

    def download_memory(self, address: int, length: int) -> bytes:
        if ((address < 0) or (length < 0) or ((address + length) > self.__Address.END)):
            raise ValueError('Invalid address or length')
        return self.__read_memory(address, length)

    def upload_rom(self, data: bytes, use_shadow: bool=True) -> None:
        rom_length = len(data)
        if (rom_length > (self.__Length.SDRAM + self.__Length.EXTENDED)):
            raise ValueError('ROM size too big')
        sdram_length = self.__Length.SDRAM
        shadow_enabled = use_shadow and rom_length > (self.__Length.SDRAM - self.__Length.SHADOW)
        extended_enabled = rom_length > self.__Length.SDRAM
        if (shadow_enabled):
            sdram_length = (self.__Length.SDRAM - self.__Length.SHADOW)
            shadow_data = data[sdram_length:sdram_length + self.__Length.SHADOW]
            self.__program_flash(self.__Address.SHADOW, shadow_data)
        self.__set_config(self.__CfgId.ROM_SHADOW_ENABLE, shadow_enabled)
        if (extended_enabled):
            extended_data = data[self.__Length.SDRAM:]
            self.__program_flash(self.__Address.EXTENDED, extended_data)
        self.__set_config(self.__CfgId.ROM_EXTENDED_ENABLE, extended_enabled)
        self.__write_memory(self.__Address.SDRAM, data[:sdram_length])

    def upload_ddipl(self, data: bytes) -> None:
        if (len(data) > self.__Length.DDIPL):
            raise ValueError('DDIPL size too big')
        self.__write_memory(self.__Address.DDIPL, data)

    def upload_save(self, data: bytes) -> None:
        save_type = self.SaveType(self.__get_config(self.__CfgId.SAVE_TYPE))
        if (save_type == self.SaveType.NONE):
            raise ValueError('No save type set inside SC64 device')
        if (len(data) != self.__SaveLength[save_type.name]):
            raise ValueError('Wrong save data length')
        address = self.__Address.SAVE
        if (save_type == self.SaveType.EEPROM_4K or save_type == self.SaveType.EEPROM_16K):
            address = self.__Address.EEPROM
        self.__write_memory(address, data)

    def download_save(self) -> bytes:
        save_type = self.SaveType(self.__get_config(self.__CfgId.SAVE_TYPE))
        if (save_type == self.SaveType.NONE):
            raise ValueError('No save type set inside SC64 device')
        address = self.__Address.SAVE
        length = self.__SaveLength[save_type.name]
        if (save_type == self.SaveType.EEPROM_4K or save_type == self.SaveType.EEPROM_16K):
            address = self.__Address.EEPROM
        return self.__read_memory(address, length)

    def upload_bootloader(self, data: bytes) -> None:
        if (len(data) > self.__Length.BOOTLOADER):
            raise ValueError('Bootloader size too big')
        padded_data = data + (b'\xFF' * (self.__Length.BOOTLOADER - len(data)))
        if (self.__read_memory(self.__Address.BOOTLOADER, self.__Length.BOOTLOADER) != padded_data):
            self.__erase_flash_region(self.__Address.BOOTLOADER, self.__Length.BOOTLOADER)
            self.__write_memory(self.__Address.BOOTLOADER, data)
            self.__flash_wait_busy()
            if (self.__read_memory(self.__Address.BOOTLOADER, self.__Length.BOOTLOADER) != padded_data):
                raise ConnectionException('Bootloader program failure')

    def set_rtc(self, t: datetime) -> None:
        to_bcd = lambda v: ((int((v / 10) % 10) << 4) | int(int(v) % 10))
        data = bytes([
            to_bcd(t.weekday() + 1),
            to_bcd(t.hour),
            to_bcd(t.minute),
            to_bcd(t.second),
            0,
            to_bcd(t.year),
            to_bcd(t.month),
            to_bcd(t.day),
        ])
        self.__link.execute_cmd(cmd=b'T', args=[self.__get_int(data[0:4]), self.__get_int(data[4:8])])

    def set_boot_mode(self, mode: BootMode) -> None:
        self.__set_config(self.__CfgId.BOOT_MODE, mode)

    def set_cic_seed(self, seed: int) -> None:
        if (seed != self.CICSeed.AUTO):
            if (seed < 0 or seed > 0xFF):
                raise ValueError('CIC seed outside of allowed values')
        self.__set_config(self.__CfgId.CIC_SEED, seed)

    def set_tv_type(self, type: TVType) -> None:
        self.__set_config(self.__CfgId.TV_TYPE, type)

    def set_save_type(self, type: SaveType) -> None:
        self.__set_config(self.__CfgId.SAVE_TYPE, type)

    def set_led_enable(self, enabled: bool) -> None:
        self.__set_setting(self.__SettingId.LED_ENABLE, enabled)

    def update_firmware(self, data: bytes, status_callback: Optional[Callable[[str], None]]=None) -> None:
        address = self.__Address.FIRMWARE
        self.__write_memory(address, data)
        response = self.__link.execute_cmd(cmd=b'F', args=[address, len(data)], raise_on_err=False)
        error = self.__UpdateError(self.__get_int(response[0:4]))
        if (error != self.__UpdateError.OK):
            raise ConnectionException(f'Bad update image [{error.name}]')
        status = None
        while status != self.__UpdateStatus.DONE:
            packet = self.__link.get_packet(timeout=60.0)
            if (packet == None):
                raise ConnectionException('Update timeout')
            (cmd, data) = packet
            if (cmd != b'F'):
                raise ConnectionException('Wrong update status packet')
            status = self.__UpdateStatus(self.__get_int(data))
            if (status_callback):
                status_callback(status.name)
            if (status == self.__UpdateStatus.ERROR):
                raise ConnectionException('Update error, device is most likely bricked')
        time.sleep(2)

    def backup_firmware(self) -> bytes:
        address = self.__Address.FIRMWARE
        info = self.__link.execute_cmd(cmd=b'f', args=[address, 0], timeout=60.0, raise_on_err=False)
        error = self.__UpdateError(self.__get_int(info[0:4]))
        length = self.__get_int(info[4:8])
        if (error != self.__UpdateError.OK):
            raise ConnectionException('Error while getting firmware backup')
        return self.__read_memory(address, length)

    def update_cic_parameters(self, seed: Optional[int]=None, disabled=False) -> tuple[int, int, bool]:
        if ((seed != None) and (seed < 0 or seed > 0xFF)):
            raise ValueError('CIC seed outside of allowed values')
        boot_mode = self.__get_config(self.__CfgId.BOOT_MODE)
        address = self.__Address.BOOTLOADER
        if (boot_mode == self.BootMode.DIRECT_ROM):
            address = self.__Address.SDRAM
        elif (boot_mode == self.BootMode.DIRECT_DDIPL):
            address = self.__Address.DDIPL
        ipl3 = self.__read_memory(address, 4096)[0x40:0x1000]
        seed = seed if (seed != None) else self.__guess_ipl3_seed(ipl3)
        checksum = self.__calculate_ipl3_checksum(ipl3, seed)
        data = [(1 << 0) if disabled else 0, seed, *checksum.to_bytes(6, byteorder='big')]
        self.__link.execute_cmd(cmd=b'B', args=[self.__get_int(data[0:4]), self.__get_int(data[4:8])])
        return (seed, checksum, boot_mode == self.BootMode.DIRECT_DDIPL)

    def __guess_ipl3_seed(self, ipl3: bytes) -> int:
        checksum = crc32(ipl3)
        seed_mapping = {
            0x587BD543: 0xAC,   # 5101
            0x6170A4A1: 0x3F,   # 6101
            0x009E9EA3: 0x3F,   # 7102
            0x90BB6CB5: 0x3F,   # 6102/7101
            0x0B050EE0: 0x78,   # x103
            0x98BC2C86: 0x91,   # x105
            0xACC8580A: 0x85,   # x106
            0x0E018159: 0xDD,   # 5167
            0x10C68B18: 0xDD,   # NDXJ0
            0xBC605D0A: 0xDD,   # NDDJ0
            0x502C4466: 0xDD,   # NDDJ1
            0x0C965795: 0xDD,   # NDDJ2
            0x8FEBA21E: 0xDE,   # NDDE0
        }
        return seed_mapping[checksum] if checksum in seed_mapping else 0x3F

    def __calculate_ipl3_checksum(self, ipl3: bytes, seed: int) -> int:
        _CHECKSUM_MAGIC = 0x6C078965

        _add = lambda a1, a2: ((a1 + a2) & 0xFFFFFFFF)
        _sub = lambda a1, a2: ((a1 - a2) & 0xFFFFFFFF)
        _mul = lambda a1, a2: ((a1 * a2) & 0xFFFFFFFF)
        _xor = lambda a1, a2: ((a1 ^ a2) & 0xFFFFFFFF)
        _lsh = lambda a, s: (((a & 0xFFFFFFFF) << s) & 0xFFFFFFFF)
        _rsh = lambda a, s: (((a & 0xFFFFFFFF) >> s) & 0xFFFFFFFF)

        def _get(offset: int) -> int:
            offset *= 4
            return int.from_bytes(ipl3[offset:(offset + 4)], byteorder='big')

        def _checksum(a0: int, a1: int, a2: int) -> int:
            prod = (a0 * (a2 if (a1 == 0) else a1))
            hi = ((prod >> 32) & 0xFFFFFFFF)
            lo = (prod & 0xFFFFFFFF)
            diff = ((hi - lo) & 0xFFFFFFFF)
            return ((a0 if (diff == 0) else diff) & 0xFFFFFFFF)

        if (seed < 0x00 or seed > 0xFF):
            raise ValueError('Invalid seed')

        buffer = [_xor(_add(_mul(_CHECKSUM_MAGIC, seed), 1), _get(0))] * 16

        for i in range(1, 1009):
            data_prev = data_curr if (i > 1) else _get(0)
            data_curr = _get(i - 1)
            data_next = _get(i)

            buffer[0] = _add(buffer[0], _checksum(_sub(1007, i), data_curr, i))
            buffer[1] = _checksum(buffer[1], data_curr, i)
            buffer[2] = _xor(buffer[2], data_curr)
            buffer[3] = _add(buffer[3], _checksum(_add(data_curr, 5), _CHECKSUM_MAGIC, i))

            shift = (data_prev & 0x1F)
            data_left = _lsh(data_curr, (32 - shift))
            data_right = _rsh(data_curr, shift)
            b4_shifted = (data_left | data_right)
            buffer[4] = _add(buffer[4], b4_shifted)

            shift = _rsh(data_prev, 27)
            data_left = _lsh(data_curr, shift)
            data_right = _rsh(data_curr, (32 - shift))
            b5_shifted = (data_left | data_right)
            buffer[5] = _add(buffer[5], b5_shifted)

            if (data_curr < buffer[6]):
                buffer[6] = _xor(_add(buffer[3], buffer[6]), _add(data_curr, i))
            else:
                buffer[6] = _xor(_add(buffer[4], data_curr), buffer[6])

            shift = (data_prev & 0x1F)
            data_left = _lsh(data_curr, shift)
            data_right = _rsh(data_curr, (32 - shift))
            buffer[7] = _checksum(buffer[7], (data_left | data_right), i)

            shift = _rsh(data_prev, 27)
            data_left = _lsh(data_curr, (32 - shift))
            data_right = _rsh(data_curr, shift)
            buffer[8] = _checksum(buffer[8], (data_left | data_right), i)

            if (data_prev < data_curr):
                buffer[9] = _checksum(buffer[9], data_curr, i)
            else:
                buffer[9] = _add(buffer[9], data_curr)

            if (i == 1008):
                break

            buffer[10] = _checksum(_add(buffer[10], data_curr), data_next, i)
            buffer[11] = _checksum(_xor(buffer[11], data_curr), data_next, i)
            buffer[12] = _add(buffer[12], _xor(buffer[8], data_curr))

            shift = (data_curr & 0x1F)
            data_left = _lsh(data_curr, (32 - shift))
            data_right = _rsh(data_curr, shift)
            tmp = (data_left | data_right)
            shift = (data_next & 0x1F)
            data_left = _lsh(data_next, (32 - shift))
            data_right = _rsh(data_next, shift)
            buffer[13] = _add(buffer[13], _add(tmp, (data_left | data_right)))

            shift = (data_curr & 0x1F)
            data_left = _lsh(data_next, (32 - shift))
            data_right = _rsh(data_next, shift)
            sum = _checksum(buffer[14], b4_shifted, i)
            buffer[14] = _checksum(sum, (data_left | data_right), i)

            shift = _rsh(data_curr, 27)
            data_left = _lsh(data_next, shift)
            data_right = _rsh(data_next, (32 - shift))
            sum = _checksum(buffer[15], b5_shifted, i)
            buffer[15] = _checksum(sum, (data_left | data_right), i)

        final_buffer = [buffer[0]] * 4

        for i in range(16):
            data = buffer[i]

            shift = (data & 0x1F)
            data_left = _lsh(data, (32 - shift))
            data_right = _rsh(data, shift)
            b0_shifted = _add(final_buffer[0], (data_left | data_right))
            final_buffer[0] = b0_shifted

            if (data < b0_shifted):
                final_buffer[1] = _add(final_buffer[1], data)
            else:
                final_buffer[1] = _checksum(final_buffer[1], data, i)

            if (_rsh((data & 0x02), 1) == (data & 0x01)):
                final_buffer[2] = _add(final_buffer[2], data)
            else:
                final_buffer[2] = _checksum(final_buffer[2], data, i)

            if (data & 0x01):
                final_buffer[3] = _xor(final_buffer[3], data)
            else:
                final_buffer[3] = _checksum(final_buffer[3], data, i)

        sum = _checksum(final_buffer[0], final_buffer[1], 16)
        xor = _xor(final_buffer[3], final_buffer[2])

        return ((sum << 32) | xor) & 0xFFFF_FFFFFFFF

    def __generate_filename(self, prefix: str, extension: str) -> str:
        return f'{prefix}-{datetime.now().strftime("%y%m%d%H%M%S.%f")}.{extension}'

    def __handle_dd_packet(self, dd: Optional[DD64Image], data: bytes) -> None:
        CMD_READ_BLOCK = 1
        CMD_WRITE_BLOCK = 2
        cmd = self.__get_int(data[0:])
        address = self.__get_int(data[4:])
        track_head_block = self.__get_int(data[8:])
        track = (track_head_block >> 2) & 0xFFF
        head = (track_head_block >> 1) & 0x1
        block = track_head_block & 0x1
        try:
            if (not dd or not dd.loaded):
                raise BadBlockError
            if (cmd == CMD_READ_BLOCK):
                block_data = dd.read_block(track, head, block)
                self.__write_memory(address, block_data)
                self.__dd_set_block_ready(0)
            elif (cmd == CMD_WRITE_BLOCK):
                block_data = data[12:]
                dd.write_block(track, head, block, block_data)
                self.__dd_set_block_ready(0)
            else:
                self.__dd_set_block_ready(1)
        except BadBlockError:
            self.__dd_set_block_ready(1)

    def __handle_isv_packet(self, data: bytes) -> None:
        self.__isv_line_buffer += data
        while (b'\n' in self.__isv_line_buffer):
            (line, self.__isv_line_buffer) = self.__isv_line_buffer.split(b'\n', 1)
            print(line.decode('EUC-JP', errors='backslashreplace'))

    def __handle_usb_packet(self, data: bytes) -> None:
        header = self.__get_int(data[0:4])
        datatype = self.__DebugDatatype((header >> 24) & 0xFF)
        length = (header & 0xFFFFFF)
        packet = data[4:]
        if (len(packet) != length):
            print(f'Debug packet length and data length did not match')
        elif (datatype == self.__DebugDatatype.TEXT):
            print(packet.decode('UTF-8', errors='backslashreplace'), end='')
        elif (datatype == self.__DebugDatatype.RAWBINARY):
            filename = self.__generate_filename('binaryout', 'bin')
            with open(filename, 'wb') as f:
                f.write(packet)
                print(f'Wrote {len(packet)} bytes to {filename}')
        elif (datatype == self.__DebugDatatype.HEADER):
            if (len(packet) == 16):
                self.__debug_header = packet
            else:
                print(f'Size of header packet is invalid: {len(packet)}')
        elif (datatype == self.__DebugDatatype.SCREENSHOT):
            filename = self.__generate_filename('screenshot', 'png')
            if (self.__debug_header != None):
                header_datatype = self.__get_int(self.__debug_header[0:4])
                pixel_format = self.__get_int(self.__debug_header[4:8])
                image_w = self.__get_int(self.__debug_header[8:12])
                image_h = self.__get_int(self.__debug_header[12:16])
                if (header_datatype == 0x04 and pixel_format != 0 and image_w != 0 and image_h != 0):
                    mode = 'RGBA' if pixel_format == 4 else 'I;16'
                    screenshot = Image.frombytes(mode, (image_w, image_h), packet)
                    screenshot.save(filename)
                    print(f'Wrote {image_w}x{image_h} pixels to {filename}')
                else:
                    print('Screenshot header data is invalid')
            else:
                print('Got screenshot packet without header data')
        elif (datatype == self.__DebugDatatype.GDB):
            self.__handle_gdb_datatype(packet)

    def __handle_gdb_socket(self, gdb_port: int) -> None:
        MAX_PACKET_SIZE = 65536
        gdb_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        gdb_socket.bind(('localhost', gdb_port))
        gdb_socket.listen()
        while (True):
            (self.__gdb_client, address) = gdb_socket.accept()
            client_address = f'{address[0]}:{address[1]}'
            print(f'[GDB]: New connection ({client_address})')
            try:
                connected = True
                while (connected):
                    data = self.__gdb_client.recv(MAX_PACKET_SIZE)
                    if (data):
                        self.debug_send(self.__DebugDatatype.GDB, data)
                    else:
                        connected = False
            except:
                pass
            finally:
                self.__gdb_client.close()
                print(f'[GDB]: Connection closed ({client_address})')

    def __handle_gdb_datatype(self, data: bytes) -> None:
        if (self.__gdb_client):
            try:
                self.__gdb_client.sendall(data)
            except:
                pass

    def __handle_debug_input(self) -> None:
        running = True
        while (running):
            try:
                command = input()
                if (len(command) > 0):
                    data = b''
                    datatype = self.__DebugDatatype.TEXT
                    if (command.count('@') != 2):
                        data += f'{command}\0'.encode()
                    else:
                        start = command.index('@')
                        end = command.rindex('@')
                        filename = command[(start + 1):end]
                        if (len(filename) == 0):
                            raise FileNotFoundError
                        with open(filename, 'rb') as f:
                            file_data = f.read()
                        if (command.startswith('@') and command.endswith('@')):
                            datatype = self.__DebugDatatype.RAWBINARY
                            data += file_data
                        else:
                            data += f'{command[:start]}@{len(file_data)}@'.encode()
                            data += file_data
                            data += b'\0'
                    self.debug_send(datatype, data)
            except ValueError as e:
                print(f'Error: {e}')
            except FileNotFoundError as e:
                print(f'Error: Cannot open file {e.filename}')
            except EOFError:
                running = False

    def debug_loop(self, isv: int=0, disks: Optional[list[str]]=None, gdb_port: Optional[int]=None) -> None:
        dd = None
        current_image = 0
        next_image = 0

        self.__set_config(self.__CfgId.ROM_WRITE_ENABLE, 1 if (isv != 0) else 0)
        self.__set_config(self.__CfgId.ISV_ADDRESS, isv)
        if (isv != 0):
            print(f'IS-Viewer64 support set to [ENABLED] at ROM offset [0x{(isv):08X}]')
            if (self.__get_config(self.__CfgId.ROM_SHADOW_ENABLE)):
                print('ROM shadow enabled - IS-Viewer64 will NOT work (use --no-shadow option while uploading ROM to disable it)')

        if (disks):
            dd = DD64Image()
            drive_type = None
            for path in disks:
                try:
                    dd.load(path)
                    if (drive_type == None):
                        drive_type = dd.get_drive_type()
                    elif (drive_type != dd.get_drive_type()):
                        raise ValueError('Disk drive type mismatch')
                    dd.unload()
                except ValueError as e:
                    dd = None
                    print(f'64DD disabled, incorrect disk images provided: {e}')
                    break
            if (dd):
                self.__set_config(self.__CfgId.DD_MODE, self.__DDMode.FULL)
                self.__set_config(self.__CfgId.DD_SD_ENABLE, False)
                self.__set_config(self.__CfgId.DD_DRIVE_TYPE, {
                    'retail': self.__DDDriveType.RETAIL,
                    'development': self.__DDDriveType.DEVELOPMENT
                }[drive_type])
                self.__set_config(self.__CfgId.DD_DISK_STATE, self.__DDDiskState.EJECTED)
                self.__set_config(self.__CfgId.BUTTON_MODE, self.__ButtonMode.USB_PACKET)
                print('64DD enabled, loaded disks:')
                for disk in disks:
                    print(f' - {os.path.basename(disk)}')
                print('Press button on SC64 device to cycle through provided disks')

        if (gdb_port):
            gdb_thread = Thread(target=lambda: self.__handle_gdb_socket(gdb_port), daemon=True)
            gdb_thread.start()
            print(f'GDB server started and listening on port [{gdb_port}]')

        print('\nDebug loop started, press Ctrl-C to exit\n')

        try:
            thread_input = Thread(target=self.__handle_debug_input, daemon=True)
            thread_input.start()
            while (thread_input.is_alive()):
                packet = self.__link.get_packet()
                if (packet != None):
                    (cmd, data) = packet
                    if (cmd == b'D'):
                        self.__handle_dd_packet(dd, data)
                    if (cmd == b'I'):
                        self.__handle_isv_packet(data)
                    if (cmd == b'U'):
                        self.__handle_usb_packet(data)
                    if (cmd == b'B'):
                        if (not dd.loaded):
                            dd.load(disks[next_image])
                            self.__set_config(self.__CfgId.DD_DISK_STATE, self.__DDDiskState.INSERTED)
                            current_image = next_image
                            next_image += 1
                            if (next_image >= len(disks)):
                                next_image = 0
                            print(f'64DD disk inserted - {os.path.basename(disks[current_image])}')
                        else:
                            self.__set_config(self.__CfgId.DD_DISK_STATE, self.__DDDiskState.EJECTED)
                            dd.unload()
                            print(f'64DD disk ejected - {os.path.basename(disks[current_image])}')
        except KeyboardInterrupt:
            pass
        finally:
            print('\nDebug loop stopped\n')

        if (dd and dd.loaded):
            self.__set_config(self.__CfgId.DD_DISK_STATE, self.__DDDiskState.EJECTED)
        if (isv != 0):
            self.__set_config(self.__CfgId.ISV_ADDRESS, 0)


class EnumAction(argparse.Action):
    def __init__(self, **kwargs):
        type = kwargs.pop('type', None)
        if type is None:
            raise ValueError('No type was provided')
        if not issubclass(type, Enum):
            raise TypeError('Provided type is not an Enum subclass')
        items = (choice.lower().replace('_', '-') for (choice, _) in type.__members__.items())
        kwargs.setdefault('choices', tuple(items))
        super(EnumAction, self).__init__(**kwargs)
        self.__enum = type

    def __call__(self, parser, namespace, values, option_string):
        key = str(values).upper().replace('-', '_')
        value = self.__enum[key]
        setattr(namespace, self.dest, value)



if __name__ == '__main__':
    def cic_params_type(argument: str):
        params = argument.split(',')
        if (len(params) > 2):
            raise argparse.ArgumentError()
        seed = int(params[0], 0) if len(params) >= 1 else None
        disabled = bool(int(params[1])) if len(params) >= 2 else None
        return (seed, disabled)

    def download_memory_type(argument: str):
        params = argument.split(',')
        if (len(params) < 2 or len(params) > 3):
            raise argparse.ArgumentError()
        address = int(params[0], 0)
        length = int(params[1], 0)
        file = params[2] if len(params) >= 3 else 'sc64dump.bin'
        return (address, length, file)

    parser = argparse.ArgumentParser(description='SC64 control software')
    parser.add_argument('--backup-firmware', metavar='file', help='backup SC64 firmware and write it to specified file')
    parser.add_argument('--update-firmware', metavar='file', help='update SC64 firmware from specified file')
    parser.add_argument('--reset-state', action='store_true', help='reset SC64 internal state')
    parser.add_argument('--print-state', action='store_true', help='print SC64 internal state')
    parser.add_argument('--led-blink', metavar='{yes,no}', help='disable or enable LED I/O activity blinking')
    parser.add_argument('--boot', type=SC64.BootMode, action=EnumAction, help='set boot mode')
    parser.add_argument('--tv', type=SC64.TVType, action=EnumAction, help='force TV type to set value, not used when direct boot mode is enabled')
    parser.add_argument('--cic', type=SC64.CICSeed, action=EnumAction, help='force CIC seed to set value, not used when direct boot mode is enabled')
    parser.add_argument('--cic-params', metavar='seed,[disabled]', type=cic_params_type, help='set CIC emulation parameters')
    parser.add_argument('--rtc', action='store_true', help='update clock in SC64 to system time')
    parser.add_argument('--no-shadow', action='store_false', help='do not put last 128 kB of ROM inside flash memory (can corrupt non EEPROM saves)')
    parser.add_argument('--rom', metavar='file', help='upload ROM from specified file')
    parser.add_argument('--save-type', type=SC64.SaveType, action=EnumAction, help='set save type')
    parser.add_argument('--save', metavar='file', help='upload save from specified file')
    parser.add_argument('--backup-save', metavar='file', help='download save and write it to specified file')
    parser.add_argument('--ddipl', metavar='file', help='upload 64DD IPL from specified file')
    parser.add_argument('--disk', metavar='file', action='append', help='path to 64DD disk (.ndd format), can be specified multiple times')
    parser.add_argument('--isv', type=lambda x: int(x, 0), default=0, help='enable IS-Viewer64 support at provided ROM offset')
    parser.add_argument('--gdb', metavar='port', type=int, help='expose socket port for GDB debugging')
    parser.add_argument('--debug', action='store_true', help='run debug loop')
    parser.add_argument('--download-memory', metavar='address,length,[file]', type=download_memory_type, help='download specified memory region and write it to file')

    if (len(sys.argv) <= 1):
        parser.print_help()
        parser.exit()

    args = parser.parse_args()

    try:
        sc64 = SC64()
        autodetected_save_type = None

        if (args.backup_firmware):
            with open(args.backup_firmware, 'wb') as f:
                print('Generating backup, this might take a while... ', end='', flush=True)
                f.write(sc64.backup_firmware())
                print('done')

        if (args.update_firmware):
            with open(args.update_firmware, 'rb') as f:
                print('Updating firmware, this might take a while... ', end='', flush=True)
                status_callback = lambda status: print(f'{status} ', end='', flush=True)
                sc64.update_firmware(f.read(), status_callback)
                print('done')

        (version, script_outdated) = sc64.check_firmware_version()

        print(f'SC64 firmware version: [{version}]')
        if (script_outdated):
            print('\x1b[33m')
            print('[      SC64 firmware is newer than last known version.      ]')
            print('[          Consider downloading latest script from          ]')
            print('[ https://github.com/Polprzewodnikowy/SummerCart64/releases ]')
            print('\x1b[0m')

        if (args.reset_state):
            sc64.reset_state()
            print('SC64 internal state reset')

        if (args.print_state):
            state = sc64.get_state()
            print('Current SC64 internal state:')
            for key, value in state.items():
                if (hasattr(value, 'name')):
                    value = getattr(value, 'name')
                print(f'  {key}: {value}')

        if (args.led_blink):
            blink = (args.led_blink == 'yes')
            sc64.set_led_enable(blink)
            print(f'LED blinking set to [{"ENABLED" if blink else "DISABLED"}]')

        if (args.tv != None):
            sc64.set_tv_type(args.tv)
            print(f'TV type set to [{args.tv.name}]')

        if (args.cic != None):
            sc64.set_cic_seed(args.cic)
            print(f'CIC seed set to [0x{args.cic:X}]')

        if (args.rtc):
            value = datetime.now()
            sc64.set_rtc(value)
            print(f'RTC set to [{value.strftime("%Y-%m-%d %H:%M:%S")}]')

        if (args.rom):
            with open(args.rom, 'rb') as f:
                print('Uploading ROM... ', end='', flush=True)
                rom_data = f.read()
                sc64.upload_rom(rom_data, use_shadow=args.no_shadow)
                autodetected_save_type = sc64.autodetect_save_type(rom_data)
                print('done')

        if (args.ddipl):
            with open(args.ddipl, 'rb') as f:
                print('Uploading 64DD IPL... ', end='', flush=True)
                sc64.upload_ddipl(f.read())
                print('done')

        if (args.save_type != None or autodetected_save_type != None):
            save_type = args.save_type if args.save_type != None else autodetected_save_type
            sc64.set_save_type(save_type)
            print(f'Save type set to [{save_type.name}]{" (autodetected)" if autodetected_save_type != None else ""}')

        if (args.save):
            with open(args.save, 'rb') as f:
                print('Uploading save... ', end='', flush=True)
                sc64.upload_save(f.read())
                print('done')

        if (args.boot != None):
            sc64.set_boot_mode(args.boot)
            print(f'Boot mode set to [{args.boot.name}]')

        if (args.cic_params != None):
            (seed, disabled) = args.cic_params
            (seed, checksum, dd_mode) = sc64.update_cic_parameters(seed, disabled)
            print('CIC parameters set to [', end='')
            print(f'{"DISABLED" if disabled else "ENABLED"}, ', end='')
            print(f'{"DDIPL" if dd_mode else "ROM"}, ', end='')
            print(f'seed: 0x{seed:02X}, checksum: 0x{checksum:012X}', end='')
            print(']')
        else:
            sc64.update_cic_parameters()

        if (args.debug or args.isv or args.disk or args.gdb):
            sc64.debug_loop(isv=args.isv, disks=args.disk, gdb_port=args.gdb)

        if (args.backup_save):
            with open(args.backup_save, 'wb') as f:
                print('Downloading save... ', end='', flush=True)
                f.write(sc64.download_save())
                print('done')

        if (args.download_memory != None):
            (address, length, file) = args.download_memory
            with open(file, 'wb') as f:
                print('Downloading memory... ', end='', flush=True)
                f.write(sc64.download_memory(address, length))
                print('done')
    except ValueError as e:
        print(f'\n\x1b[31mValue error: {e}\x1b[0m\n')
    except ConnectionException as e:
        print(f'\n\x1b[31mSC64 error: {e}\x1b[0m\n')
