# -*- coding: utf-8 -*-
# FlashGBX
# Author: Lesserkuma (github.com/lesserkuma)

import math, time, datetime, copy, configparser, threading, statistics, os, platform, traceback, io, struct, re
from enum import Enum

# Common constants
APPNAME = "FlashGBX"
VERSION_PEP440 = "3.36"
VERSION = "v{:s}".format(VERSION_PEP440)
VERSION_TIMESTAMP = 1705328830
DEBUG = False
DEBUG_LOG = []
APP_PATH = ""
CONFIG_PATH = ""

AGB_Header_ROM_Sizes = [ "64 KiB", "128 KiB", "256 KiB", "512 KiB", "1 MiB", "2 MiB", "4 MiB", "8 MiB", "16 MiB", "32 MiB", "64 MiB", "128 MiB", "256 MiB" ]
AGB_Header_ROM_Sizes_Map = [ 0x10000, 0x20000, 0x40000, 0x80000, 0x100000, 0x200000, 0x400000, 0x800000, 0x1000000, 0x2000000, 0x4000000, 0x8000000, 0x10000000 ]
AGB_Header_Save_Types = [ "None", "4K EEPROM (512 Bytes)", "64K EEPROM (8 KiB)", "256K SRAM/FRAM (32 KiB)", "512K FLASH (64 KiB)", "1M FLASH (128 KiB)", "8M DACS (1 MiB)", "Unlicensed 512K SRAM (64 KiB)", "Unlicensed 1M SRAM (128 KiB)", "Unlicensed Batteryless SRAM" ]
AGB_Header_Save_Sizes = [ 0, 512, 8192, 32768, 65536, 131072, 1048576, 65536, 131072, 0 ]
AGB_Flash_Save_Chips = { 0xBFD4:"SST 39VF512", 0x1F3D:"Atmel AT29LV512", 0xC21C:"Macronix MX29L512", 0x321B:"Panasonic MN63F805MNP", 0xC209:"Macronix MX29L010", 0x6213:"SANYO LE26FV10N1TS", 0xBF5B:"Unlicensed SST SST49LF080A", 0xFFFF:"Unlicensed 0xFFFF" }
AGB_Flash_Save_Chips_Sizes = [ 0x10000, 0x10000, 0x10000, 0x10000, 0x20000, 0x20000, 0x20000, 0x20000 ]

DMG_Header_Mapper = { 0x00:'None', 0x01:'MBC1', 0x02:'MBC1+SRAM', 0x03:'MBC1+SRAM+BATTERY', 0x06:'MBC2+SRAM+BATTERY', 0x0F:'MBC3+RTC+BATTERY', 0x10:'MBC3+RTC+SRAM+BATTERY', 0x110:'MBC30+RTC+SRAM+BATTERY', 0x12:'MBC3+SRAM', 0x13:'MBC3+SRAM+BATTERY', 0x19:'MBC5', 0x1A:'MBC5+SRAM', 0x1B:'MBC5+SRAM+BATTERY', 0x1C:'MBC5+RUMBLE', 0x1E:'MBC5+RUMBLE+SRAM+BATTERY', 0x20:'MBC6+SRAM+FLASH+BATTERY', 0x22:'MBC7+ACCELEROMETER+EEPROM', 0x101:'MBC1M', 0x103:'MBC1M+SRAM+BATTERY', 0x0B:'MMM01',  0x0D:'MMM01+SRAM+BATTERY', 0xFC:'MAC-GBD+SRAM+BATTERY', 0x105:'G-MMC1+SRAM+BATTERY', 0x104:'M161', 0xFF:'HuC-1+IR+SRAM+BATTERY', 0xFE:'HuC-3+RTC+SRAM+BATTERY', 0xFD:'TAMA5+RTC+EEPROM', 0x201:'Unlicensed 256M Mapper', 0x202:'Unlicensed Wisdom Tree Mapper', 0x203:'Unlicensed Xploder GB Mapper', 0x204:'Unlicensed Sachen Mapper', 0x205:'Unlicensed Datel Orbit V2 Mapper' }
DMG_Mapper_Types = { "None":[ 0x00, 0x08, 0x09 ], "MBC1":[ 0x01, 0x02, 0x03 ], "MBC2":[ 0x05, 0x06 ], "MBC3":[ 0x0F, 0x10, 0x11, 0x12, 0x13 ], "MBC30":[ 0x110 ], "MBC5":[ 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E ], "MBC6":[ 0x20 ], "MBC7":[ 0x22 ], "MBC1M":[ 0x101, 0x103 ], "MMM01":[ 0x0B, 0x0D ], "MAC-GBD":[ 0xFC ], "G-MMC1":[ 0x105 ], "M161":[ 0x104 ], "HuC-1":[ 0xFF ], "HuC-3":[ 0xFE ], "TAMA5":[ 0xFD ], "Unlicensed 256M Multi Cart Mapper":[ 0x201 ], "Unlicensed Wisdom Tree Mapper":[ 0x202 ], "Unlicensed Xploder GB Mapper":[ 0x203 ], "Unlicensed Sachen Mapper":[ 0x204 ], "Unlicensed Datel Orbit V2 Mapper":[ 0x205 ] }
DMG_Header_ROM_Sizes = [ "32 KiB", "64 KiB", "128 KiB", "256 KiB", "512 KiB", "1 MiB", "2 MiB", "4 MiB", "8 MiB", "16 MiB", "32 MiB" ]
DMG_Header_ROM_Sizes_Map = [ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A ]
DMG_Header_ROM_Sizes_Flasher_Map = [ 0x8000, 0x10000, 0x20000, 0x40000, 0x80000, 0x100000, 0x200000, 0x400000, 0x800000, 0x1000000, 0x2000000 ]
DMG_Header_RAM_Sizes = [ "None", "4K SRAM (512 Bytes)", "16K SRAM (2 KiB)", "64K SRAM (8 KiB)", "256K SRAM (32 KiB)", "512K SRAM (64 KiB)", "1M SRAM (128 KiB)", "MBC6 SRAM+FLASH (1.03 MiB)", "MBC7 2K EEPROM (256 Bytes)", "MBC7 4K EEPROM (512 Bytes)", "TAMA5 EEPROM (32 Bytes)", "Unlicensed 4M SRAM (512 KiB)", "Unlicensed 1M EEPROM (128 KiB)" ]
DMG_Header_RAM_Sizes_Map = [ 0x00, 0x100, 0x01, 0x02, 0x03, 0x05, 0x04, 0x104, 0x101, 0x102, 0x103, 0x201, 0x203, 0x204 ]
DMG_Header_RAM_Sizes_Flasher_Map = [ 0, 0x200, 0x800, 0x2000, 0x8000, 0x10000, 0x20000, 0x108000, 0x100, 0x200, 0x20, 0x80000, 0x20000, 0x80000 ] # RAM size in bytes
DMG_Header_SGB = { 0x00:'No support', 0x03:'Supported' }
DMG_Header_CGB = { 0x00:'No support', 0x80:'Supported', 0xC0:'Required' }

class ANSI:
	BOLD = '\033[1m'
	RED = '\033[91m'
	GREEN = '\033[92m'
	YELLOW = '\033[33m'
	DARK_GRAY = '\033[90m'
	RESET = '\033[0m'
	CLEAR_LINE = '\033[2K'

class IniSettings():
	FILENAME = ""
	SETTINGS = None
	MAIN_SECTION = "General"
	def __init__(self, path="", ini="", main_section="General"):
		if path != "":
			try:
				if not os.path.isdir(os.path.dirname(path)):
					os.makedirs(os.path.dirname(path))
				if os.path.exists(path):
					with open(path, "a+", encoding="UTF-8") as f: f.close()
				else:
					with open(path, "w+", encoding="UTF-8") as f: f.close()
			except:
				print("Can’t access the configuration directory or settings file.")
				return
			self.FILENAME = path
			self.SETTINGS = configparser.RawConfigParser()
			self.SETTINGS.optionxform = str
			try:
				self.Reload()
			except configparser.MissingSectionHeaderError:
				print("Resetting invalid configuration file...")
				with open(path, "w+", encoding="UTF-8") as f: f.close()
				path = ""
		
		if path == "":
			self.FILENAME = False
			self.SETTINGS = configparser.RawConfigParser()
			self.SETTINGS.read_string(ini)
			self.SETTINGS.optionxform = str
		
		self.MAIN_SECTION = main_section
	
	def Reload(self):
		if self.SETTINGS is None: return
		if self.FILENAME is not False:
			with open(self.FILENAME, "r", encoding="UTF-8") as f:
				self.SETTINGS.read_file(f)
		if len(self.SETTINGS.sections()) == 0:
			self.SETTINGS.add_section(self.MAIN_SECTION)
	
	def value(self, key, default=None): return self.GetValue(key, default)
	def GetValue(self, key, default=None):
		if self.SETTINGS is None: return None
		self.Reload()
		if key not in self.SETTINGS[self.MAIN_SECTION]:
			if default is not None: self.SetValue(key, default)
			return default
		return (self.SETTINGS[self.MAIN_SECTION][key])
	
	def setValue(self, key, value): self.SetValue(key, value)
	def SetValue(self, key, value):
		if self.SETTINGS is None: return None
		self.Reload()
		self.SETTINGS[self.MAIN_SECTION][key] = value
		dprint("Updating settings:", key, "=", value)
		if self.FILENAME is not False:
			with open(self.FILENAME, "w", encoding="UTF-8") as f:
				self.SETTINGS.write(f)
	
	def clear(self): self.Clear()
	def Clear(self):
		if self.SETTINGS is None: return None
		self.SETTINGS.clear()
		if self.FILENAME is not False:
			with open(self.FILENAME, "w", encoding="UTF-8") as f:
				self.SETTINGS.write(f)

class Progress():
	MUTEX = threading.Lock()
	PROGRESS = {}
	UPDATER = None
	
	def __init__(self, updater):
		self.UPDATER = updater
	
	def SetProgress(self, args):
		self.MUTEX.acquire(1)
		try:
			if not "method" in self.PROGRESS: self.PROGRESS = {}
			now = time.time()
			if args["action"] == "INITIALIZE":
				self.PROGRESS["action"] = args["action"]
				self.PROGRESS["method"] = args["method"]
				if "flash_offset" in args:
					self.PROGRESS["flash_offset"] = args["flash_offset"]
				else:
					self.PROGRESS["flash_offset"] = 0
				if "size" in args:
					self.PROGRESS["size"] = args["size"] - self.PROGRESS["flash_offset"]
				else:
					self.PROGRESS["size"] = 0
				if "pos" in args:
					self.PROGRESS["pos"] = args["pos"] - self.PROGRESS["flash_offset"]
				else:
					self.PROGRESS["pos"] = 0
				if "time_start" in args:
					self.PROGRESS["time_start"] = args["time_start"]
				else:
					self.PROGRESS["time_start"] = now
				self.PROGRESS["time_last_emit"] = now
				self.PROGRESS["time_last_update_speed"] = now
				self.PROGRESS["time_left"] = 0
				self.PROGRESS["speed"] = 0
				self.PROGRESS["speeds"] = []
				self.PROGRESS["bytes_last_update_speed"] = 0
				self.UPDATER(self.PROGRESS)
			
			if args["action"] == "ABORT":
				self.UPDATER(args)
				self.PROGRESS = {}
			
			elif args["action"] in ("ERASE", "SECTOR_ERASE", "UNLOCK", "UPDATE_RTC", "ERROR"):
				if "time_start" in self.PROGRESS:
					args["time_elapsed"] = now - self.PROGRESS["time_start"]
				elif "time_start" in args:
					args["time_elapsed"] = now - args["time_start"]
				args["pos"] = 1
				args["size"] = 0
				self.UPDATER(args)
			
			elif self.PROGRESS == {}:
				return
			
			elif args["action"] == "UPDATE_POS":
				self.PROGRESS["pos"] = args["pos"] - self.PROGRESS["flash_offset"]
				self.PROGRESS["action"] = "PROGRESS"
				if "time_start" in self.PROGRESS:
					self.PROGRESS["time_elapsed"] = now - self.PROGRESS["time_start"]
				
				try:
					total_speed = statistics.mean(self.PROGRESS["speeds"])
					self.PROGRESS["time_left"] = (self.PROGRESS["size"] - self.PROGRESS["pos"]) / 1024 / total_speed
				except:
					pass
				if "abortable" in args: self.PROGRESS["abortable"] = args["abortable"]
				self.UPDATER(self.PROGRESS)
			
			elif args["action"] in ("READ", "WRITE"):
				if "method" not in self.PROGRESS: return
				elif args["action"] in ("READ") and self.PROGRESS["method"] in ("SAVE_WRITE", "ROM_WRITE"): return
				elif args["action"] in ("WRITE") and self.PROGRESS["method"] in ("SAVE_READ", "ROM_READ", "ROM_WRITE_VERIFY"): return
				if self.PROGRESS["pos"] >= self.PROGRESS["size"]: return
				
				self.PROGRESS["action"] = "PROGRESS"
				self.PROGRESS["pos"] += args["bytes_added"]
				if (now - self.PROGRESS["time_last_emit"]) > 0.05:
					self.PROGRESS["time_elapsed"] = now - self.PROGRESS["time_start"]
					if (now - self.PROGRESS["time_last_update_speed"]) > 0.25:
						time_delta = now - self.PROGRESS["time_last_update_speed"]
						pos_delta = self.PROGRESS["pos"] - self.PROGRESS["bytes_last_update_speed"]
						if time_delta > 0:
							speed = (pos_delta / time_delta) / 1024
							self.PROGRESS["speeds"].append(speed)
							if len(self.PROGRESS["speeds"]) > 256: self.PROGRESS["speeds"].pop(0)
							self.PROGRESS["speed"] = statistics.median(self.PROGRESS["speeds"])
						self.PROGRESS["time_last_update_speed"] = now
						self.PROGRESS["bytes_last_update_speed"] = self.PROGRESS["pos"]
					
					if "skipping" in args and args["skipping"] is True:
						self.PROGRESS["speed"] = 0
						self.PROGRESS["skipping"] = True
					else:
						self.PROGRESS["skipping"] = False
					
					if self.PROGRESS["speed"] > 0:
						total_speed = statistics.mean(self.PROGRESS["speeds"])
						self.PROGRESS["time_left"] = (self.PROGRESS["size"] - self.PROGRESS["pos"]) / 1024 / total_speed
					
					self.UPDATER(self.PROGRESS)
					self.PROGRESS["time_last_emit"] = now
			
			elif args["action"] == "FINISHED":
				self.PROGRESS["pos"] = self.PROGRESS["size"]
				self.UPDATER(self.PROGRESS)
				self.PROGRESS["action"] = args["action"]
				self.PROGRESS["bytes_last_update_speed"] = self.PROGRESS["size"]
				self.PROGRESS["time_elapsed"] = now - self.PROGRESS["time_start"]
				self.PROGRESS["time_last_emit"] = now
				self.PROGRESS["time_last_update_speed"] = now
				self.PROGRESS["time_left"] = 0
				if self.PROGRESS["time_elapsed"] == 0: self.PROGRESS["time_elapsed"] = 0.001
				self.PROGRESS["speed"] = (self.PROGRESS["size"] / self.PROGRESS["time_elapsed"]) / 1024
				self.PROGRESS["bytes_last_emit"] = self.PROGRESS["size"]
				if "verified" in args:
					self.PROGRESS["verified"] = (args["verified"] == True)
				
				if self.PROGRESS["speed"] > self.PROGRESS["size"] / 1024:
					self.PROGRESS["speed"] = self.PROGRESS["size"] / 1024
				
				self.UPDATER(self.PROGRESS)
				del(self.PROGRESS["method"])
		
		finally:
			self.MUTEX.release()
	
class TAMA5_CMD(Enum):
	RAM_WRITE = 0x0
	RAM_READ = 0x1
	RTC = 0x4

class TAMA5_REG(Enum):
	ROM_BANK_L = 0x0
	ROM_BANK_H = 0x1
	MEM_WRITE_L = 0x4
	MEM_WRITE_H = 0x5
	ADDR_H_SET_MODE = 0x6
	ADDR_L = 0x7
	ENABLE = 0xA
	MEM_READ_L = 0xC
	MEM_READ_H = 0xD

def isx2bin(buffer):
	data_input = io.BytesIO(buffer)
	data_output = bytearray(8 * 1024 * 1024)
	rom_size = 0
	temp = 32 * 1024
	while 1:
		try:
			type = struct.unpack('B', data_input.read(1))[0]
			if type == 4:
				break
			elif type != 1:
				print("WARNING: Unhandled ISX record type 0x{:02X} found. Converted ROM may not be working correctly.".format(type))
				continue
			bank = struct.unpack('B', data_input.read(1))[0]
			offset = struct.unpack('<H', data_input.read(2))[0] % 0x4000
			realoffset = bank * 16 * 1024 + offset
			size = struct.unpack('<H', data_input.read(2))[0]
			data_output[realoffset:realoffset+size] = data_input.read(size)
			rom_size = max(rom_size, realoffset+size)
			temp = 32 * 1024
			while temp < rom_size: temp *= 2
		except:
			print("ERROR: Couldn’t convert ISX file correctly.")
			break
	return data_output[:temp]

def round2(num, decimals=2):
	x = (pow(10, decimals))
	return int(num * x) / x

def formatFileSize(size, asInt=False, nobr=True):
	space = " " if nobr else " "
	if size == 1:
		return "{:d}{:s}Byte".format(size, space)
	elif size < 1024:
		return "{:d}{:s}Bytes".format(size, space)
	elif size < 1024 * 1024:
		val = size / 1024
		val = round2(val)
		if asInt:
			return "{:d}{:s}KiB".format(int(val), space)
		else:
			return "{:.1f}{:s}KiB".format(val, space)
	else:
		val = size / 1024 / 1024
		val = round2(val)
		if asInt:
			return "{:d}{:s}MiB".format(int(val), space)
		else:
			return "{:.2f}{:s}MiB".format(val, space)

def formatProgressTimeShort(sec):
	sec = sec % (24 * 3600)
	hr = sec // 3600
	sec %= 3600
	min = sec // 60
	sec %= 60
	return "{:02d}:{:02d}:{:02d}".format(int(hr), int(min), int(sec))

def formatProgressTime(sec, asFloat=False):
	if int(sec) == 1:
		return "{:d} second".format(int(sec))
	elif sec < 60:
		if sec < 1 and asFloat:
			return "{:.2f} seconds".format(sec)
		else:
			return "{:d} seconds".format(int(sec))
	elif int(sec) == 60:
		return "1 minute"
	else:
		min = int(sec / 60)
		sec = int(sec % 60)
		s = str(min) + " "
		if min == 1:
			s = s + "minute"
		else:
			s = s + "minutes"
		s = s + ", " + str(sec) + " "
		if sec == 1:
			s = s + "second"
		else:
			s = s + "seconds"
		return s

def formatPathOS(path, end_sep=False):
	if platform.system() == "Windows":
		path = path.replace("/", "\\")
		if end_sep:
			path += "\\"
	else:
		if end_sep:
			path += "/"
	return path

def bitswap(n, s):
	p, q = s
	if (((n & (1 << p)) >> p) ^ ((n & (1 << q)) >> q)) == 1:
		n ^= (1 << p)
		n ^= (1 << q)
	return n

def DecodeBCD(value):
	return (((value) & 0x0F) + (((value) >> 4) * 10))
def EncodeBCD(value):
	return math.floor(value / 10) << 4 | value % 10

def ParseCFI(buffer):
	buffer = copy.copy(buffer)
	info = {}
	magic = "{:s}{:s}{:s}".format(chr(buffer[0x20]), chr(buffer[0x22]), chr(buffer[0x24]))
	if magic != "QRY": # nothing swapped
		return False
	
	try:
		info["flash_id"] = buffer[0:8]
		info["magic"] = "{:s}{:s}{:s}".format(chr(buffer[0x20]), chr(buffer[0x22]), chr(buffer[0x24]))
		
		if buffer[0x36] == 0xFF and buffer[0x48] == 0xFF:
			print("FAIL: No information about the voltage range found in CFI data.")
			try:
				with open("./cfi_debug.bin", "wb") as f: f.write(buffer)
			except:
				pass
			return False
		
		pri_address = (buffer[0x2A] | (buffer[0x2C] << 8)) * 2
		if (pri_address + 0x3C) >= 0x400: pri_address = 0x80
		
		info["vdd_min"] = (buffer[0x36] >> 4) + ((buffer[0x36] & 0x0F) / 10)
		info["vdd_max"] = (buffer[0x38] >> 4) + ((buffer[0x38] & 0x0F) / 10)
		
		if buffer[0x3E] > 0 and buffer[0x3E] < 0xFF:
			info["single_write"] = True
			info["single_write_time_avg"] = int(math.pow(2, buffer[0x3E]))
			info["single_write_time_max"] = int(math.pow(2, buffer[0x46]) * info["single_write_time_avg"])
		else:
			info["single_write"] = False

		if buffer[0x40] > 0 and buffer[0x40] < 0xFF:
			info["buffer_write"] = True
			info["buffer_write_time_avg"] = int(math.pow(2, buffer[0x40]))
			info["buffer_write_time_max"] = int(math.pow(2, buffer[0x48]) * info["buffer_write_time_avg"])
		else:
			info["buffer_write"] = False

		if buffer[0x42] > 0 and buffer[0x42] < 0xFF:
			info["sector_erase"] = True
			info["sector_erase_time_avg"] = int(math.pow(2, buffer[0x42]))
			info["sector_erase_time_max"] = int(math.pow(2, buffer[0x4A]) * info["sector_erase_time_avg"])
		else:
			info["sector_erase"] = False

		if buffer[0x44] > 0 and buffer[0x44] < 0xFF:
			info["chip_erase"] = True
			info["chip_erase_time_avg"] = int(math.pow(2, buffer[0x44]))
			info["chip_erase_time_max"] = int(math.pow(2, buffer[0x4C]) * info["chip_erase_time_avg"])
		else:
			info["chip_erase"] = False

		info["tb_boot_sector"] = False
		info["tb_boot_sector_raw"] = 0
		if "{:s}{:s}{:s}".format(chr(buffer[pri_address]), chr(buffer[pri_address+2]), chr(buffer[pri_address+4])) == "PRI":
			if buffer[pri_address + 0x1E] not in (0, 0xFF):
				temp = { 0x02: 'As shown', 0x03: 'Reversed' }
				info["tb_boot_sector_raw"] = buffer[pri_address + 0x1E]
				try:
					info["tb_boot_sector"] = "{:s} (0x{:02X})".format(temp[buffer[pri_address + 0x1E]], buffer[pri_address + 0x1E])
				except:
					info["tb_boot_sector"] = "0x{:02X}".format(buffer[pri_address + 0x1E])
		
		info["device_size"] = int(math.pow(2, buffer[0x4E]))
		info["buffer_size"] = buffer[0x56] << 8 | buffer[0x54]
		if info["buffer_size"] > 1:
			info["buffer_write"] = True
			info["buffer_size"] = int(math.pow(2, info["buffer_size"]))
		else:
			del(info["buffer_size"])
			info["buffer_write"] = False
		info["erase_sector_regions"] = buffer[0x58]
		info["erase_sector_blocks"] = []
		total_blocks = 0
		pos = 0
		for i in range(0, min(4, info["erase_sector_regions"])):
			b = (buffer[0x5C+(i*8)] << 8 | buffer[0x5A+(i*8)]) + 1
			t = (buffer[0x60+(i*8)] << 8 | buffer[0x5E+(i*8)]) * 256
			total_blocks += b
			size = b * t
			pos += size
			info["erase_sector_blocks"].append([ t, b, size ])
	
	except:
		print("ERROR: Trying to parse CFI data resulted in an error.")
		try:
			with open("./cfi_debug.bin", "wb") as f: f.write(buffer)
		except:
			pass
		return False
	
	return info

def ConvertMapperToMapperType(mapper_raw):
	i = 0
	for (k, v) in DMG_Mapper_Types.items():
		if i == 0:
			retval = (k, v, i)
		if mapper_raw in v:
			retval = (k, v, i)
			break
		i += 1
	# (string, list of ids, index in DMG_Mapper_Types)
	return retval

def ConvertMapperTypeToMapper(mapper_type):
	i = 0
	for (_, v) in DMG_Mapper_Types.items():
		if mapper_type == i:
			return v[0]
		i += 1
	return 0

def GetDumpReport(di, device):
	header = di["header"]["unchanged"]
	if "db" in di["header"]: header["db"] = di["header"]["db"]
	if di["system"] == "DMG":
		mode = "DMG"
		di["system"] = "Game Boy"
		if di["rom_size"] in DMG_Header_ROM_Sizes_Flasher_Map:
			di["rom_size"] = "{:s}".format(DMG_Header_ROM_Sizes[DMG_Header_ROM_Sizes_Flasher_Map.index(di["rom_size"])])
		else:
			di["rom_size"] = "{:,} bytes".format(di["rom_size"])
		
		if di["mapper_type"] in DMG_Header_Mapper:
			di["mapper_type"] = "{:s}".format(ConvertMapperToMapperType(di["mapper_type"])[0])
		else:
			di["mapper_type"] = "0x{:02X}".format(di["mapper_type"])
	
	elif di["system"] == "AGB":
		mode = "AGB"
		di["system"] = "Game Boy Advance"
		if di["rom_size"] in AGB_Header_ROM_Sizes_Map:
			di["rom_size"] = "{:s}".format(AGB_Header_ROM_Sizes[AGB_Header_ROM_Sizes_Map.index(di["rom_size"])])
		else:
			di["rom_size"] = "{:,} bytes".format(di["rom_size"])
	
	di["cart_type"] = list(device.SUPPORTED_CARTS[mode].keys())[di["cart_type"]]
	if di["file_name"] is None:
		di["file_name"] = ""
	else:
		di["file_name"] = os.path.split(di["file_name"])[1]
	di["file_size"] = "{:s} ({:d} bytes)".format(formatFileSize(size=di["file_size"]), di["file_size"])
	
	s = "" \
		"= FlashGBX Dump Report =\n" \
	
	s += "" \
		"\n== File Information ==\n" \
		"* File Name:       {file_name:s}\n" \
		"* File Size:       {file_size:s}\n" \
		"* CRC32:           {hash_crc32:08x}\n" \
		"* MD5:             {hash_md5:s}\n" \
		"* SHA-1:           {hash_sha1:s}\n" \
		"* SHA-256:         {hash_sha256:s}\n" \
	.format(file_name=di["file_name"], file_size=di["file_size"], hash_crc32=di["hash_crc32"], hash_md5=di["hash_md5"], hash_sha1=di["hash_sha1"], hash_sha256=di["hash_sha256"])

	s += "" \
		"\n== General Information ==\n" \
		"* Hardware:        {hardware:s} – Firmware {firmware:s}\n" \
		"* Software:        {software:s}\n" \
		"* OS Platform:     {platform:s}\n" \
		"* Baud Rate:       {baud_rate:d}\n" \
		"* Dump Time:       {timestamp:s}\n" \
		"* Time Elapsed:    %TIME_ELAPSED% (%TRANSFER_RATE%)\n" \
		"* Transfer Buffer: {buffer_size:d} bytes\n" \
		"* Retries:         {retries:d}\n" \
	.format(hardware=device.GetFullName(), firmware=device.GetFirmwareVersion(), software="{:s} {:s}".format(APPNAME, VERSION), platform=platform.platform(), buffer_size=di["transfer_size"], timestamp=di["timestamp"], baud_rate=device.GetBaudRate(), retries=device.GetReadErrors())
	
	if mode == "DMG":
		s += "" \
			"\n== Dumping Settings ==\n" \
			"* Mode:            {system:s}\n" \
			"* ROM Size:        {rom_size:s}\n" \
			"* Mapper Type:     {mapper_type:s}\n" \
			"* Cartridge Type:  {cart_type:s}\n" \
		.format(system=di["system"], rom_size=di["rom_size"], mapper_type=di["mapper_type"], cart_type=di["cart_type"])
	elif mode == "AGB":
		s += "" \
			"\n== Dumping Settings ==\n" \
			"* Mode:            {system:s}\n" \
			"* ROM Size:        {rom_size:s}\n" \
			"* Cartridge Type:  {cart_type:s}\n" \
		.format(system=di["system"], rom_size=di["rom_size"], cart_type=di["cart_type"])

	di["hdr_logo"] = "OK" if header["logo_correct"] else "Invalid"
	header["game_title_raw"] = header["game_title_raw"].replace("\0", "␀")
	if mode == "DMG":
		game_title = header["game_title"]
		game_code = ""
		if header["cgb"] in (0xC0, 0x80):
			if "game_code" in header and len(header["game_code"]) > 0:
				game_code = "* Game Code:       {:s}\n".format(header["game_code"])
			di["hdr_target_platform"] = "Game Boy Color"
		elif header["old_lic"] == 0x33 and header["sgb"] == 0x03:
			di["hdr_target_platform"] = "Super Game Boy"
		else:
			di["hdr_target_platform"] = "Game Boy"

		if header["old_lic"] == 0x33 and header["sgb"] == 0x03:
			di["hdr_sgb"] = "Supported"
		else:
			di["hdr_sgb"] = "No support" # (146h=0x{:02X}, 14Bh=0x{:02X})".format(header["sgb"], header["old_lic"])
		if header["cgb"] in DMG_Header_CGB:
			di["hdr_cgb"] = "{:s}".format(DMG_Header_CGB[header["cgb"]])
		else:
			di["hdr_cgb"] = "Unknown (0x{:02X})".format(header["cgb"])
		
		di["hdr_header_checksum"] = "OK (0x{:02X})".format(header["header_checksum"]) if header["header_checksum_correct"] else "Invalid (0x{:02X}≠0x{:02X})".format(header["header_checksum_calc"], header["header_checksum"])
		header["rom_checksum_calc"] = device.INFO["rom_checksum_calc"]
		header["rom_checksum_correct"] = header["rom_checksum_calc"] == header["rom_checksum"]
		di["hdr_rom_checksum"] = "OK (0x{:04X})".format(header["rom_checksum"]) if header["rom_checksum_correct"] else "Invalid (0x{:04X}≠0x{:04X})".format(header["rom_checksum_calc"], header["rom_checksum"])
		
		di["hdr_rom_size"] = header["rom_size_raw"]
		if di["hdr_rom_size"] in DMG_Header_ROM_Sizes_Map:
			di["hdr_rom_size"] = "{:s} (0x{:02X})".format(DMG_Header_ROM_Sizes[DMG_Header_ROM_Sizes_Map.index(di["hdr_rom_size"])], di["hdr_rom_size"])
		else:
			di["hdr_rom_size"] = "Unknown (0x{:02X})".format(di["hdr_rom_size"])
		
		di["hdr_save_type"] = header["ram_size_raw"]
		if di["hdr_save_type"] == 0x00:
			di["hdr_save_type"] = "No SRAM (0x{:02X})".format(di["hdr_save_type"])
		elif di["hdr_save_type"] in DMG_Header_RAM_Sizes_Map:
			di["hdr_save_type"] = "{:s} (0x{:02X})".format(DMG_Header_RAM_Sizes[DMG_Header_RAM_Sizes_Map.index(di["hdr_save_type"])], di["hdr_save_type"])
		else:
			di["hdr_save_type"] = "Unknown (0x{:02X})".format(di["hdr_save_type"])
		
		di["hdr_mapper_type"] = header["mapper_raw"]
		if di["hdr_mapper_type"] in DMG_Header_Mapper:
			di["hdr_mapper_type"] = "{:s} (0x{:02X})".format(DMG_Header_Mapper[di["hdr_mapper_type"]], di["hdr_mapper_type"])
		else:
			di["hdr_mapper_type"] = "Unknown (0x{:02X})".format(di["hdr_mapper_type"])
		
		s += "" \
			"\n== Parsed Data ==\n" \
			"* Game Title:      {hdr_game_title:s}\n" \
			"{hdr_game_code:s}" \
			"* Revision:        {hdr_revision:s}\n" \
			"* Super Game Boy:  {hdr_sgb:s}\n" \
			"* Game Boy Color:  {hdr_cgb:s}\n" \
			"* Nintendo Logo:   {hdr_logo:s}\n" \
			"* Header Checksum: {hdr_header_checksum:s}\n" \
			"* ROM Checksum:    {hdr_rom_checksum:s}\n" \
			"* ROM Size:        {hdr_rom_size:s}\n" \
			"* SRAM Size:       {hdr_save_type:s}\n" \
			"* Mapper Type:     {hdr_mapper_type:s}\n" \
			"* Target Platform: {hdr_target_platform:s}\n" \
		.format(hdr_game_title=game_title, hdr_game_code=game_code, hdr_revision=str(header["version"]), hdr_sgb=di["hdr_sgb"], hdr_cgb=di["hdr_cgb"], hdr_logo=di["hdr_logo"], hdr_header_checksum=di["hdr_header_checksum"], hdr_rom_checksum=di["hdr_rom_checksum"], hdr_rom_size=di["hdr_rom_size"], hdr_save_type=di["hdr_save_type"], hdr_mapper_type=di["hdr_mapper_type"], hdr_target_platform=di["hdr_target_platform"])
		if "gbmem" in di and di["gbmem"] is not None:
			raw_data = ""
			for i in range(0, 4):
				raw_data += ''.join(format(x, '02X') for x in di["gbmem"][i*0x20:i*0x20+0x20]) + "\n                   "
			raw_data = raw_data[:-20]
			
			if "gbmem_parsed" in di and di["gbmem_parsed"] is not None and len(di["gbmem_parsed"]) > 0:
				if (isinstance(di["gbmem_parsed"], list)):
					s += "" \
						"\n== GB-Memory Data (Multi Menu) ==\n" \
						"* Write Timestamp: {timestamp:s}\n" \
						"* Write Kiosk ID:  {kiosk_id:s}\n" \
						"* Number of Games: {num_games:d}\n" \
						"* Write Counter:   {write_count:d}\n" \
						"* Cartridge ID:    {cart_id:s}\n" \
						"* Raw Map Data:    {raw_data:s}\n" \
					.format(
						timestamp=di["gbmem_parsed"][0]["timestamp"],
						kiosk_id=di["gbmem_parsed"][0]["kiosk_id"],
						cart_id=di["gbmem_parsed"][0]["cart_id"],
						write_count=di["gbmem_parsed"][0]["write_count"],
						num_games=di["gbmem_parsed"][0]["num_games"],
						raw_data=raw_data
					)
					for i in range(1, len(di["gbmem_parsed"])):
						if di["gbmem_parsed"][i]["menu_index"] == 0xFF: continue
						if di["gbmem_parsed"][i]["header"]["logo_correct"] is False: continue
						if i == 1:
							s += "\n=== Menu ROM ===\n"
						else:
							s += "\n=== Game {:d} ===\n".format(i-1)
						s += "" \
							"* Game Code:       {game_code:s}\n" \
							"* Game Title:      {title:s}\n" \
							"* Write Timestamp: {timestamp:s}\n" \
							"* Write Kiosk ID:  {kiosk_id:s}\n" \
							"* Location:        {location:s}\n" \
							"* ROM Size:        {size:s}\n" \
						.format(
							game_code=di["gbmem_parsed"][i]["game_code"],
							title=di["gbmem_parsed"][i]["title"],
							timestamp=di["gbmem_parsed"][i]["timestamp"],
							kiosk_id=di["gbmem_parsed"][i]["kiosk_id"],
							location="0x{:06X}–0x{:06X}".format(di["gbmem_parsed"][i]["rom_offset"], di["gbmem_parsed"][i]["rom_offset"]+di["gbmem_parsed"][i]["rom_size"]-1),
							size="{:s} ({:d} bytes)".format(formatFileSize(size=di["gbmem_parsed"][i]["rom_size"]), di["gbmem_parsed"][i]["rom_size"]),
						)
						if "crc32" in di["gbmem_parsed"][i]: s += "* CRC32:           {:08x}\n".format(di["gbmem_parsed"][i]["crc32"])
						if "md5" in di["gbmem_parsed"][i]: s += "* MD5:             {:s}\n".format(di["gbmem_parsed"][i]["md5"])
						if "sha1" in di["gbmem_parsed"][i]: s += "* SHA-1:           {:s}\n".format(di["gbmem_parsed"][i]["sha1"])
						if "sha256" in di["gbmem_parsed"][i]: s += "* SHA-256:         {:s}\n".format(di["gbmem_parsed"][i]["sha256"])
						
						if "db_entry" in di["gbmem_parsed"][i] and "crc32" in di["gbmem_parsed"][i] and di["gbmem_parsed"][i]["db_entry"]["rc"] == di["gbmem_parsed"][i]["crc32"]:
							s += "* Database Match:  {:s} {:s}\n".format(di["gbmem_parsed"][i]["db_entry"]["gn"], di["gbmem_parsed"][i]["db_entry"]["ne"])
				elif isinstance(di["gbmem_parsed"]["game_code"], str):
					s += "" \
						"\n== GB-Memory Data (Single Game) ==\n" \
						"* Game Code:       {game_code:s}\n" \
						"* Game Title:      {title:s}\n" \
						"* Write Timestamp: {timestamp:s}\n" \
						"* Write Kiosk ID:  {kiosk_id:s}\n" \
						"* Write Counter:   {write_count:d}\n" \
						"* Cartridge ID:    {cart_id:s}\n" \
						"* Raw Map Data:    {raw_data:s}\n" \
					.format(
						game_code=di["gbmem_parsed"]["game_code"],
						title=di["gbmem_parsed"]["title"],
						timestamp=di["gbmem_parsed"]["timestamp"],
						kiosk_id=di["gbmem_parsed"]["kiosk_id"],
						cart_id=di["gbmem_parsed"]["cart_id"],
						write_count=di["gbmem_parsed"]["write_count"],
						raw_data=raw_data
					)
			else:
				s += "" \
					"* GB-Memory Data:  {:s}\n" \
				.format(raw_data)
		
		if header["db"] is not None and header["db"]["rc"] == di["hash_crc32"]:
			db = header["db"]
			s += "\n== Database Match ==\n"
			if "gn" in db and "ne" in db: s += "* Game Name:       {:s} {:s}\n".format(db["gn"], db["ne"])
			elif "gn" in db: s += "* Game Name:       {:s}\n".format(db["gn"])
			if "rg" in db: s += "* Region:          {:s}\n".format(db["rg"])
			if "lg" in db: s += "* Language(s):     {:s}\n".format(db["lg"])
			if "rv" in db: s += "* Revision:        {:s}\n".format(db["rv"])
			if "gc" in db: s += "* Game Code:       {:s}\n".format(db["gc"])
			if "rc" in db: s += "* ROM CRC32:       {:08x}\n".format(db["rc"])
			if "rs" in db: s += "* ROM Size:        {:s}\n".format(formatFileSize(size=db["rs"], asInt=True))

	if mode == "AGB":
		header["game_code_raw"] = header["game_code_raw"].replace("\0", "␀")
		di["hdr_header_checksum"] = "OK (0x{:02X})".format(header["header_checksum"]) if header["header_checksum_correct"] else "Invalid (0x{:02X}≠0x{:02X})".format(header["header_checksum_calc"], header["header_checksum"])
		if "agb_savelib" not in di:
			di["agb_savelib"] = "None"
		elif "SRAM_F_" in di["agb_savelib"]:
			di["agb_savelib"] = "256K SRAM/FRAM ({:s})".format(di["agb_savelib"])
		elif "SRAM_" in di["agb_savelib"]:
			di["agb_savelib"] = "256K SRAM ({:s})".format(di["agb_savelib"])
		elif "EEPROM_V" in di["agb_savelib"]:
			di["agb_savelib"] = "4K or 64K EEPROM ({:s})".format(di["agb_savelib"])
		elif "FLASH_V" in di["agb_savelib"] or "FLASH512_V" in di["agb_savelib"]:
			di["agb_savelib"] = "512K FLASH ({:s})".format(di["agb_savelib"])
		elif "FLASH1M_V" in di["agb_savelib"] or "FLASH512_V" in di["agb_savelib"]:
			di["agb_savelib"] = "1M FLASH ({:s})".format(di["agb_savelib"])
		elif "AGB_8MDACS_DL_V" in di["agb_savelib"]:
			di["agb_savelib"] = "8M DACS ({:s})".format(di["agb_savelib"])
		elif di["agb_savelib"] == "N/A":
			di["agb_savelib"] = "None"
		else:
			di["agb_savelib"] = "Unknown ({:s})".format(di["agb_savelib"])
		s += "" \
			"\n== Parsed Data ==\n" \
			"* Game Title:      {hdr_game_title:s}\n" \
			"* Game Code:       {hdr_game_code:s}\n" \
			"* Revision:        {hdr_revision:s}\n" \
			"* Nintendo Logo:   {hdr_logo:s}\n" \
			"* Header Checksum: {hdr_header_checksum:s}\n" \
			"* Save Type:       {agb_savetype}\n" \
		.format(hdr_game_title=header["game_title_raw"], hdr_game_code=header["game_code_raw"], hdr_revision=str(header["version"]), hdr_logo=di["hdr_logo"], hdr_header_checksum=di["hdr_header_checksum"], agb_savetype=di["agb_savelib"])
		if "agb_save_flash_id" in di and di["agb_save_flash_id"] is not None:
			s += "" \
				"* Save Flash Chip: {agb_save_flash_chip_name:s} (0x{agb_save_flash_chip_id:04X})\n" \
			.format(agb_save_flash_chip_name=di["agb_save_flash_id"][1], agb_save_flash_chip_id=di["agb_save_flash_id"][0])

		if "eeprom_data" in di:
			s += "* EEPROM area:     {:s}…\n".format(''.join(format(x, '02X') for x in di["eeprom_data"]))

		if header["db"] is not None and header["db"]["rc"] == di["hash_crc32"]:
			db = header["db"]
			s += "\n== Database Match ==\n"
			if "gn" in db and "ne" in db: s += "* Game Name:       {:s} {:s}\n".format(db["gn"], db["ne"])
			elif "gn" in db: s += "* Game Name:       {:s}\n".format(db["gn"])
			if "rg" in db: s += "* Region:          {:s}\n".format(db["rg"])
			if "lg" in db: s += "* Language(s):     {:s}\n".format(db["lg"])
			if "rv" in db: s += "* Revision:        {:s}\n".format(db["rv"])
			if "gc" in db: s += "* Game Code:       {:s}\n".format(db["gc"])
			if "rc" in db: s += "* ROM CRC32:       {:08x}\n".format(db["rc"])
			if "rs" in db: s += "* ROM Size:        {:s}\n".format(formatFileSize(size=db["rs"], asInt=True))
			if "st" in db: s += "* Save Type:       {:s}\n".format(AGB_Header_Save_Types[db["st"]])
			#if "ss" in db: s += "* Save Size:       {:s}\n".format(formatFileSize(size=db["ss"], asInt=True))

	return s

def GenerateFileName(mode, header, settings=None):
	fe_ni = True
	if settings is not None:
		fe_ni = settings.value(key="UseNoIntroFilenames", default="enabled").lower() == "enabled"
	
	path = "ROM"
	if mode == "DMG":
		path_title = header["game_title"]
		path_code = ""
		path_revision = str(header["version"])
		path_extension = "bin"
		path = "%TITLE%-%REVISION%"
		fe_sgb = "enabled"
		if settings is not None:
			path = settings.value(key="FileNameFormatDMG", default=path)
			fe_sgb = settings.value(key="AutoFileExtensionSGB", default="enabled")
		
		if len(header["game_code"]) > 0:
			path_code = header["game_code"]
			path = "%TITLE%_%CODE%-%REVISION%"
			if settings is not None:
				path = settings.value(key="FileNameFormatCGB", default=path)

		if header["mapper_raw"] >= 0x200:
			path = "%TITLE%"
		if header["cgb"] in (0xC0, 0x80):
			path_extension = "gbc"
		elif header["old_lic"] == 0x33 and header["sgb"] == 0x03 and fe_sgb.lower() == "enabled":
			path_extension = "sgb"
		else:
			path_extension = "gb"
		if path_title == "":
			path = "ROM.{:s}".format(path_extension)
		else:
			path = path.replace("%TITLE%", path_title.strip())
			path = path.replace("%CODE%", path_code.strip())
			path = path.replace("%REVISION%", path_revision)
			path = re.sub(r"[<>:\"/\\|\?\*]", "_", path)
			if get_mbc_name(header["mapper_raw"]) == "G-MMC1":
				if "gbmem_parsed" in header:
					if (isinstance(header["gbmem_parsed"], list)):
						path += "_{:s}".format(header["gbmem_parsed"][0]["cart_id"])
					else:
						path += "_{:s}".format(header["gbmem_parsed"]["cart_id"])
			path += ".{:s}".format(path_extension)
	elif mode == "AGB":
		path = "%TITLE%_%CODE%-%REVISION%"
		if settings is not None:
			path = settings.value(key="FileNameFormatAGB", default=path)
		path_title = header["game_title"]
		path_code = header["game_code"]
		path_revision = str(header["version"])
		path_extension = "gba"
		if (path_title == "" and path_code == ""):
			path = "ROM"
		else:
			path = path.replace("%TITLE%", path_title.strip())
			path = path.replace("%CODE%", path_code.strip())
			path = path.replace("%REVISION%", path_revision)
			path = re.sub(r"[<>:\"/\\|\?\*]", "_", path)
		path += "." + path_extension
	
	if fe_ni and header["db"] is not None:
		if mode == "DMG" and get_mbc_name(header["mapper_raw"]) == "G-MMC1" and "gbmem_parsed" in header:
			if (isinstance(header["gbmem_parsed"], list)):
				path = "NP GB-Memory Cartridge ({:s}).{:s}".format(header["gbmem_parsed"][0]["cart_id"], path_extension)
			else:
				path = "NP GB-Memory Cartridge ({:s}).{:s}".format(header["gbmem_parsed"]["cart_id"], path_extension)
		else:
			path = "{:s} {:s}.{:s}".format(header["db"]["gn"], header["db"]["ne"], path_extension)
	
	return path

def compare_mbc(a, b):
	for v in DMG_Mapper_Types.values():
		if a in v and b in v: return True
	return False

def get_mbc_name(id):
	for (k, v) in DMG_Mapper_Types.items():
		if id in v: return k
	return "Unknown mapper type 0x{:02X}".format(id)

def save_size_includes_rtc(mode, mbc, save_size, save_type):
	rtc_size = 0x10
	if mode == "DMG":
		save_type = DMG_Header_RAM_Sizes_Map.index(save_type)
		if get_mbc_name(mbc) in ("MBC3", "MBC30"): rtc_size = 0x30
		elif get_mbc_name(mbc) == "HuC-3": rtc_size = 0x0C
		elif get_mbc_name(mbc) == "TAMA5": rtc_size = 0x28
		return (((DMG_Header_RAM_Sizes_Flasher_Map[save_type] + rtc_size) % save_size) == 0)
	elif mode == "AGB":
		rtc_size = 0x10
		return (((AGB_Header_Save_Sizes[save_type] + rtc_size) % save_size) == 0)
	return False

def validate_datetime_format(string, format):
	try:
		if string != datetime.datetime.strptime(string, format).strftime(format):
			raise ValueError
		return True
	except ValueError:
		return False

def find_size(data, max_size, min_size=0x20):
	offset = max_size
	while offset >= min_size:
		offset = int(offset / 2)
		if data[0:offset] != data[offset:offset*2]:
			offset = offset * 2
			break
	return offset

def dprint(*args, **kwargs):
	global DEBUG_LOG
	stack = traceback.extract_stack()
	stack = stack[len(stack)-2]
	msg = "[{:s}] [{:s}:{:d}] {:s}(): {:s}".format(datetime.datetime.now().astimezone().replace(microsecond=0).isoformat(), os.path.split(stack.filename)[1], stack.lineno, stack.name, " ".join(map(str, args)), **kwargs)
	DEBUG_LOG.append(msg)
	DEBUG_LOG = DEBUG_LOG[-32768:]
	if DEBUG:
		msg = "{:s}{:s}".format(ANSI.CLEAR_LINE, msg)
		print(msg)
