#!/usr/bin/env bash

# -----------------------------------------------------------------------------
#
#	App Title:		verify-game.sh
#	App Version:	5.0
#	Author:			Jared Breland <jbreland@legroom.net>
#	Homepage:		https://www.legroom.net/software
#	License:		GNU General Public License, v3.0
#						http://www.gnu.org/licenses/gpl.html
#
#	Script Function:
#		Verify game ROM or CD/DVD image against No-Intro and Redump DAT files
#
#	Instructions:
#		Run 'verify-game.sh -h' for usage instructions
#		Optionally review/modify $DATDIR and $TEMPDIR
#
#	Requirements:
#		No-Intro DAT-o-MATIC files (https://datomatic.no-intro.org/)
#		Redump DAT files (https://redump.info/)
#		Miscellaneous utilities for decompressing or extracting files;
#			search code for 'bincheck' to see utilities
#		For Switch NSPs: ~/.switch/prod.keys must exist for hactool
#		For CHD images, chdmame from MAME 0.267 or newer
#
#	TODO:
#		Fix PS4, Vita verification - new split files and naming scheme from no-intro
#
#	Release History:
#		5.0 (07/11/2026)
#			Add support for new ROM formats:
#				Atari 5200, 7800, and 8-bit; Amiga IPF and and CD32; Switch NSP;
#				Vita PSVgameSD;
#			Add support for new compression formats:
#				CHD; ZST; RVZ (Wii/GC); NSZ (Switch); NBZ (Commodore 64)
#			Add support for Aftermarket and Private DAT files
#			Add support for report mode to identify misnamed ROMs; additionally
#				add support for optional 'personalized' reporting with -R
#			Add support for 'group mode' to validate a bundle of related files
#				(such as a Wii U DLC or updates); -e can be used to specify
#				unique files like certs that should be excluded
#			Add support for auto-excluding known unique files with group mode
#			Add support for choosing hashing algorithm (SHA1 or MD5);
#			Add support for verbose flag to print additional details in output
#			Change name to verify-game.sh
#			Change default hashing algorithm to SHA1; this is faster than MD5
#				on modern CPUs with sha_ni instruction
#			Change handling for some DAT files and platforms:
#				Merge psp, psp-psn, and psp-psn-dec; ps4-psn and ps4-upd; 
#					vita-psn and vita-psn-dec
#				Rename 360-dlc to 360-cdn; wiiu-dlc to wiiu-cdn; vita* tp psv*
#			Remove 3ds-digital (3DS (Digital)) DAT support
#			Update platform detection logic to improve auto-detection
#			Update disc handling to verify and report as a single object
#			Update output to show DAT game title instead of DAT file name;
#				use -f option to revert to file name output
#			Update the following to support newer DAT file names and formats
#				3DO; Atari platforms, NGPC, 3DS Updates and DLC, SNES, Wii WAD,
#				PS3 PSN
#			Update WUX decompression to use JWUDTool; requires java, and must
#				define path to JWUDTool.jar in $JWUDTOOL
#			Update error handling logic to fix some errors and improve output
#			Update multi-file matches to filter on original filename; should,
#				e.g., prevent common audio tracks from spewing multiple matches;
#				added '-a' option to revert this behavior and show all matches
#			Update CUE handling to assume all Dreamcast-games are CUE-based;
#				this will break GDI-based images, which need to be re-compressed
#			Update cert strip process to eliminate extra copy operation
#			Update verbose output to show both original and extracted file names
#			Update platform detection to walk directory tree for matches
#			Update -V option to trigger debug output instead of showing version;
#				includes performance metrics; does not delete temporary files
#			Update script to use common directory for all temp files
#
#		4.1 (08/21/2021)
#			Add support for Intellivision
#			Add support for NKit-compressed images (Wii/GC)
#				Drop support for deprecated nNASOS (Wii/GC)
#			Add support for 3DS CDN files (Updates and DLC)
#				Rename 3ds-dlc to 3ds-digital
#			Add support for PS4 updates
#			Include MD5 checksum in output for unmatched ROMs
#			N64 only supports big indian format; z64 and n64 treated the same
#			Strip certificate from 3DS and XCI ROMs to match no-intro DAT
#			Check for header before stripping
#			Update FDS dat filename to match no-intro change
#
#		4.0.2 (01/26/2020):
#			Strip headers from LNX ROMs to match no-intro DAT
#			Indicate in verification output if ROM has been stripped
#
#		4.0.1 (06/04/2019):
#			Add support for IBM PC Compatible (pc) optical media
#
#		4.0 (02/03/2019):
#			Add bundled No-Intro and Redump.org DAT files
#				Modify DATDIR and instructions to support bundling
#			Add support for Xbox 360 DLC, PS4 DLC, and Wii WAD files
#			Add support for Wii U Updates and DLC
#			Add support for nNASOS-compressed images (.dec)
#			Add support for compressed WUD images (.wux)
#				Very inefficient; can't specify output dir with wudcompress
#			Add -d option to specify temp directory
#			Set vita-psn as default platform for Vita
#			Fixed bug relating to TEMPDIR
#
#		3.2 (08/04/2018):
#			Add -t option to allow specifying number of concurrent threads
#			Add support fpr pc-fx, vita-psn[-dec]
#			Include support for both encrypted and decrypted PSN files
#			Update filenames for various DAT files
#
#		3.1 (07/11/2018):
#			Add support for lnx, ps3 (iso) ps3-psn, ps4 (iso), xci and xcz
#			Fixed error checking output
#
#		3.01 (12/20/2017):
#			Add support for wiiu
#
#		3.0 (05/20/2017):
#			Add support for transparently decompressing games before verifying
#			Add support for verifying multiple games
#			Add support for parallelizing multi-game verification
#			Add support for 32x, 360, 3do, 3ds-cdn, a26, bs, c64,
#				c64-crt, c64-tap, dreamcast, fds, gba, gen, gg, j64, n64, ngc,
#				pce-cd, saturn, scd, sgx, sms, wsc, xbox, z64
#			Add -V and -n options
#			Unified grep through Redump and DAT-o-MATIC files
#
#		2.01 (02/19/2017):
#			Add support for nes
#			Add support for stripping headers from games (currently only nes)
#
#		2.0 (01/14/2017):
#			Add support for Redump.org DAT files
#			Add support for gamecube, ps1, ps2, wii
#
#		1.1 (01/13/2017):
#			Add support for gb, gbc, pce, psp, psp-psn, sfc
#
#		1.0 (12/08/2014):
#			Initial release
#
# -----------------------------------------------------------------------------

# Static variables
readonly PROG="${0##*/}"
readonly PROGDIR="${0%/*}"
readonly TITLE=${PROG}

# Setup/Initialize environment
DATDIR="${PROGDIR}"/checksums
JWUDTOOL="${PROGDIR}"/JWUDTool.jar
THREADS=$(nproc)
FILES=()
PLATFORM=
DATFILE=
CKSUMCOM="sha1sum"
CKSUMNAME="sha1"
ALLFILES=0
SHOWFILE=0
COLORIZE=1
VERBOSE=0
DEBUGMODE=0
GROUPMODE=0
REPORTMODE=0
GROUPEXCLUDE=()
TEMPROOT="${TMPDIR:-/tmp}"

# Declare a global timer for debugging
declare -g DEBUGSTART=$(date +%s%3N)
declare -g DEBUGELAPSE=$(date +%s%3N)

# Define header length for ROMs that must be stripped prior to verification
declare -A HEADERLEN
HEADERLEN[nes]=16
HEADERLEN[lnx]=64
HEADERLEN[a78]=128
#HEADERLEN[a8b]=16 # for no-intro, but not A8SP

# Define pattern to check whether header exists before stripping
declare -A HEADERCHECK
HEADERCHECK[nes]='NES'
HEADERCHECK[lnx]='LYNX'
HEADERCHECK[a78]='.ATARI7800'
#HEADERCHECK[a8b]='CART' # for no-intro, but not A8SP

# Define certificate position and length for ROMs that must be stripped
declare -A CERTPARM
CERTPARM[3ds]='4608|72'
CERTPARM[xci]='28672|512'

# Report mode configuration.
#
# Default report report mode (-r) compares local names directly against verified
# DAT names and reports mismatches.
#
# Filtered report mode (-R) optionally loads personal report filters from the
# following location:
#	${XDG_CONFIG_HOME}/Legroom.net/verify-game.conf.sh
#
# The following empty defaults are used unless overridden by the filters file.

REPORTFILTERFILE="${XDG_CONFIG_HOME:-${HOME}/.config}/Legroom.net/${PROG%.*}.conf.sh"
REPORTFILTERLOADED=0

REPORT_DEFAULT_REGION=
REPORT_LANGUAGE_TAG_PATTERN=

declare -A REPORT_REGION_MAP
REPORT_REGION_NAMES=()
REPORT_KNOWN_REGIONS_RE=

REPORT_COMMON_TAG_PATTERNS=()
REPORT_LOCAL_STRIPS=()
REPORT_VERIFIED_STRIPS=()
REPORT_VERIFIED_STRIP_PATTERNS=()
REPORT_COMMON_NAME_FIXES=()
REPORT_LOCAL_NAME_FIXES=()
REPORT_VERIFIED_NAME_FIXES=()

# Function to display correct usage information
function warning() {
	echo -e "Usage: ${PROG} [-c cksum] [-d dir] [-f] [-g [-e regex]] [-n]"
	echo -e "       $(printf "%${#PROG}s") [-p platform] [-r|-R] [-t N] [-v [-a]] [-V]"
	echo -e "       $(printf "%${#PROG}s") <Game Name.ext> [<Game 2.ext> ...]"
	echo -e "Verify dumped ROM/disc file(s) against DAT file\n"
	echo -e "${PROG} will try to guess platform automatically, or add -p to specify\n"
	echo -e "Options:"
	echo -e "  -a        display all checksum matches instaed of expected result"
	echo -e "  -c cksum  checksum algorithm (sha1 or md5); defaults to sha1"
	echo -e "  -d dir    specify temporary working directory; defaults to /tmp"
	echo -e "  -e regex  exclusion patterns to exclude specific files in group mode"
	echo -e "  -f        display matched ROM file name instead of game title"
	echo -e "  -g        enable group mode for groups of related files, like patch files;"
	echo -e "              pass the directory containing the patch instead of files"
	echo -e "  -h        display this help message"
	echo -e "  -n        no color output"
	echo -e "  -p pform  set game platform: must be one of"
	echo -e "              32x, 360[-cdn], 3do, 3ds, 3ds-cdn, a26, a52, a78, a8b, bs, c64,"
	echo -e "              c64-crt, c64-tap, cd32, dreamcast, fds, gamecube, gb, gba, gbc,"
	echo -e "              gen, gg, ipf, itv, j64, lnx, n64, nds, nes, ngc, nsp, pc, pc-fx,"
	echo -e "              pce[-cd], ps1, ps2, ps3[-psn], ps4[-psn], psp, psv[-psn], saturn,"
	echo -e "              scd, sfc, sgx, sms, vb, wii. wii-wad, wiiu, wiiu-cdn, xci, wsc,"
	echo -e "              xbox"
	echo -e "  -r        enable report mode; compare local names to DAT names"
	echo -e "  -R        enable filtered report mode using personalized name mapping;"
	echo -e "              this is meant for the author's personal collection"
	echo -e "  -t N      specify number of concurrent threads; defaults to max available"
	echo -e "  -v        verbose output"
	echo -e "  -V        extra verbose debugging output"
	exit 1
}

# Function to display colorized information output
#	yellow (info) when called with cinfo
#	red (warning) when called with cwarn
#	green (good) when called with cgood
#	$1 = message to be echoed
# Note: $1 may optionally be an argument to echo (such as -n); in that case,
#	the message will be $2
function cinfo() {
	ARG=
	[ "${1:0:1}" == "-" ] && ARG=$1 && shift
	case "${FUNCNAME[1]}" in
		cgood) COLOR='\033[01;32m' ;;	# bold green
		cwarn) COLOR='\033[01;31m' ;;	# bold red
		*) COLOR='\033[01;33m' ;;		# bold yellow
	esac
	RESET='\033[00;00m'					# normal white
	MESSAGE=${@:-"${RESET}Error: No message passed"}
	echo $ARG -e "${COLOR}${MESSAGE}${RESET}"
}
function cwarn() {
	cinfo "${@}"
}
function cgood() {
	cinfo "${@}"
}

# General debugging and performance testing function
debug() {
	if (( ${DEBUGMODE} )); then
		DATE=$(date '+%b %d %T')
		EPOCH=$(date +%s%3N)

		# Calculate duration since last debug statement
		ELAPSED=$(( EPOCH - ${DEBUGELAPSE} ))
		MIN=$(( ELAPSED / 60000 ))
		SEC=$(( (ELAPSED % 60000) / 1000 ))
		MSEC=$(( ELAPSED % 1000 ))

		# Calculate duration since start
		TELAPSED=$(( EPOCH - ${DEBUGSTART} ))
		TMIN=$(( TELAPSED / 60000 ))
		TSEC=$(( (TELAPSED % 60000) / 1000 ))
		TMSEC=$(( TELAPSED % 1000 ))

		# Colorize output unless disabled
		if (( $COLORIZE )); then
			COLOR='\033[01;34m'
			RESET='\033[00;00m'
		else
			COLOR=''
			RESET=''
		fi

		echo -e "\n${1}\n${COLOR}${DATE} / ${MIN}m ${SEC}s ${MSEC}ms elapsed / ${TMIN}m ${TSEC}s ${TMSEC}ms total${RESET}"

		DEBUGELAPSE=${EPOCH}
	fi
}	

# Generically validate binaries used by other functions in this file
function bincheck() {
	while [ -n "$1" ]; do
		[ ! -f "$(which $1 2>/dev/null)" ] && cwarn "Error: required binary $1 cannot be found" >&2 && return 1
		shift
	done
}

# Universal unpack function (lite version) to extract files from any format
#	$1 = supported archive
function unplite() {
	EXT=$(echo "${1##*.}" | sed 's/\(.*\)/\L\1/')
	EXT2=$(echo "${1%.*}" | sed -e 's/^.*\.\(.*\)/\1/' -e 's/\(.*\)/\L\1/')
	[ "$EXT2" == "tar" ] && EXT="tar.$EXT"
	[ "$EXT2" == "nkit" ] && EXT="nkit.$EXT"
	BASE="${PWD}/$(basename "${1//.${EXT}}")"
	case "$EXT" in
		7z)
			bincheck 7z || return 1
			7z x "$1" ;;
		bz2)
			bzip2 -d -c "$1" >"$BASE" ;;
		chd)
			bincheck chdman || return 1
			# Determine if disc needs to be extracted as CD or DVD
			CHDTYPE=$(hexdump -e '4 "%_p"' -s 0x7c -n 4 "$1")
			if [ "$CHDTYPE" == "DVD " ]; then
				ARGS=(extractdvd)
				OUTEXT=iso
			else
				ARGS=(extractcd -sb)
				OUTEXT=cue
			fi
			chdman "${ARGS[@]}" -i "$1" -o "${BASE##*/}.${OUTEXT}" 2>/dev/null ;;
		cso)
			bincheck maxcso || return 1
			maxcso --decompress --quiet --threads=${THREADS} "$1" -o "$(basename "${1%.*}.iso")" ;;
		gz)
			gzip -d -c "$1" >"$BASE" ;;
		lzma)
			bincheck lzma || return 1
			lzma -d -k "$1" ;;
		nbz)
			bincheck nibconv || return 1
			nibconv "$1" "${BASE}.nib" ;;
		nkit.iso|nkit.gcz)
			bincheck ConvertToISO || return 1
			ConvertToISO "$1"
			mv "${1/${EXT}/iso}" "${BASE}.iso" ;;
		nsz)
			bincheck nsz
			nsz -D -o . "$1" ;;
		rar)
			bincheck unrar || return 1
			unrar x "$1" ;;
		rvz)
			bincheck dolphin-tool || return 1
			dolphin-tool convert -f iso -i "$1" -o "${BASE}.iso" 2>/dev/null ;;
		tar|tgz|tbz2|tar.gz|tar.bz2)
			tar xf "$1" ;;
		txz|tlz|tlzma|tzst|tar.xz|tar.lzma|tar.zst)
			tar xf "$1" ;;
		wux)
			bincheck java || return 1
			if [ ! -f "${JWUDTOOL}" ]; then
				echo "ERROR: ${JWUDTOOL} does not exist." >&2
				echo "       Copy JWUDtool.jar to this location for WUX support." >&2
				return 1
			fi
			java -jar "${JWUDTOOL}" -decompress -noVerify -in "$1" -out "${EXTRACTDIR}" 2>/dev/null
			mv "${EXTRACTDIR}/game.wud" "${EXTRACTDIR}/${BASE##*/}.wud" ;;
		xz)
			bincheck xz || return 1
			xz -d -T0 -c "$1" >"$BASE" ;;
		zst)
			bincheck zstd || return 1
			zstd -q -d "$1" -o "$BASE" ;;
		zip|jar|war|xpi|cbz|vpk)
			bincheck unzip || return 1
			unzip "$1" ;;
		*)
			echo "Error: unrecognized archive format"
			return 2 ;;
	esac
}

# Function to determine if variable is an integer
function is_int() {
	return $(test "$1" -eq "$1" > /dev/null 2>&1);
}

# Function to log game names for later reporting if enabled
#	$1 = original name
#	$2 = extracted name / directory
#	$3 = verified name
#	$4 = verified status
#	$5 = rom type
function report_add() {
	(( ${REPORTMODE} )) && printf "%s|%s|%s|%s|%s\n" "$1" "$2" "$3" "$4" "$5" >>"${REPORTFILE}"
}

# Function to match directory names against regex to identify platform
#	$1 = platform regex
match_parents() {
	REGEX="${1}"

	for i in "${PARENTS[@]}"; do
		[[ "$i" =~ $REGEX ]] && return 0
	done
	return 1
}

# Attempt to identify game platform based on pattern matching
#	$1 = original passed file
#	$2 = extracted/working game file
function id-platform() {
	ORIGINAL=${1}
	EXTRACTED=${2}
	PLATFORM=
	EXT=${GAME##*.}
	EXT=${EXT,,}

	#echo "ORIGINAL = $ORIGINAL" >&2
	#echo "EXTRACTED = $EXTRACTED" >&2
	#echo "EXT = $EXT" >&2

	# Walk directory tree up to get all names for paltform comparison
	declare -a PARENTS=()
	DIRPATH=$(dirname "$ORIGINAL")
	while [[ -n "$DIRPATH" && "$DIRPATH" != "/" ]]; do
		PARENTS+=("${DIRPATH##*/}")
		DIRPATH=$(dirname "$DIRPATH")
	done

	# Platform autom-matching patterns
	if match_parents '^(3DO )?Interactive( )Multiplayer$' || \
			match_parents '^(Panasonic )?3DO$'; then
		PLATFORM=3do
	elif match_parents '^(Atari )?2600$' || \
			match_parents '^(Atari )?VCS$' || \
			[[ "${EXT}" == "a26" ]]; then
		PLATFORM=a26
	elif match_parents '^(Atari )?5200$' || \
			[[ "${EXT}" == "a52" ]]; then
		PLATFORM=a52
	elif match_parents '^(Atari )?7800$' || \
			[[ "${EXT}" == "a78" ]]; then
		PLATFORM=a78
	elif match_parents '^(Atari )?8-bit$' || \
			match_parents '^(Atari )?8-bit( )Family$' || \
			[[ "${EXT}" == "atr" || \
				"${EXT}" == "atx" || \
				"${EXT}" == "car" ]]; then
		PLATFORM=a8b
	elif match_parents '^(Atari )?Jaguar$' || \
			[[ "${EXT}" == "j64" ]]; then
		PLATFORM=j64
	elif match_parents '^(Atari )?Lynx$' || \
			[[ "${EXT}" == "lnx" ]]; then
		PLATFORM=lnx
	elif match_parents '^(Bandai )?Wonder( )?[Ss]wan( )Color$' || \
			match_parents '^(Bandai )?WSC$' || \
			[[ "${EXT}" == "wsc" ]]; then
		PLATFORM=wsc
	elif match_parents '^((Commodore )?Amiga)?( )?CD32$'; then
		PLATFORM=cd32
	elif match_parents '^(Commodore )?Amiga$' || \
			[[ "${EXT}" == "ipf" ]]; then
		PLATFORM=ipf
	elif ( match_parents '^(Commodore )?Commodore( )?64$' || \
			match_parents '^(Commodore )?C64$' ) && \
			[[ "${EXT}" == "crt" ]]; then
		PLATFORM=c64-crt
	elif ( match_parents '^(Commodore )?Commodore( )?64$' || \
			match_parents '^(Commodore )?C64$' ) && \
			[[ "${EXT}" == "tap" ]]; then
		PLATFORM=c64-tap
	elif match_parents '^(Commodore )?Commodore( )?64$' || \
			match_parents '^(Commodore )?C64$' || \
			[[ "${EXT}" == "nib" || \
				"${EXT}" == "nbz" ]]; then
		PLATFORM=c64
	elif match_parents '^(IBM )?PC$' || \
			match_parents '^(Microsoft )?Windows$' || \
			match_parents '^(Microsoft )?DOS$' || \
			match_parents '^Linux$'; then
		PLATFORM=pc
	elif match_parents '^(Mattel )?Intellivision$' || \
			[[ "${EXT}" == "int" || \
				"${EXT}" == "itv" ]]; then
		PLATFORM=itv
	elif match_parents '^(Microsoft )?Xbox$'; then
		PLATFORM=xbox
	elif match_parents '^(Microsoft )?(Xbox )?360$' && \
			[[ "${EXT}" == "iso" ]]; then
		PLATFORM=360
	elif match_parents '^(Microsoft )?(Xbox )?360$'; then
		PLATFORM=360-cdn
	elif match_parents '^(NEC )?PC(-)?FX$' || \
			match_parents '^(NEC )?PC( )?Engine( )Duo$'; then
		PLATFORM=pc-fx
	elif match_parents '^(NEC )?SuperGrafx$' || \
			match_parents '^(NEC )?PC( )?Engine( )SuperGrafx$' || \
			[[ "${EXT}" == "sgx" ]]; then
		PLATFORM=sgx
	elif match_parents '^(NEC )?TurboGrafx-CD$' || \
			match_parents '^(NEC )?Turbo( )?Duo$' || \
			match_parents '^(NEC )?PC( )?Engine( )Duo$'; then
		PLATFORM=pce-cd
	elif match_parents '^(NEC )?TurboGrafx-16$' || \
			match_parents '^(NEC )?PC( )?Engine$' || \
			match_parents '^(NEC )?PCE$' || \
			[[ "${EXT}" == "pce" ]]; then
		PLATFORM=pce
	elif match_parents '^(Nintendo )?3DS$' && \
			[[ "${EXT}" == "3ds" || \
				"${EXT}" == "3dz" ]]; then
		PLATFORM=3ds
	elif match_parents '^(Nintendo )?3DS$'; then
		PLATFORM=3ds-cdn
	elif match_parents '^(Nintendo )?Game( )?Boy$' || \
			match_parents '^(Nintendo )?GB$' || \
			[[ "${EXT}" == "gb" ]]; then
		PLATFORM=gb
	elif match_parents '^(Nintendo )?Game( )?Boy( )?Advance$' || \
			match_parents '^(Nintendo )?GBA$' || \
			[[ "${EXT}" == "gba" ]]; then
		PLATFORM=gba
	elif match_parents '^(Nintendo )?Game( )?Boy( )?Color$' || \
			match_parents '^(Nintendo )?GBC$' || \
			[[ "${EXT}" == "gbc" ]]; then
		PLATFORM=gbc
	elif match_parents '^(Nintendo )?Game( )?[Cc]ube$' || \
			match_parents '^(Nintendo )?GC$' || \
			[[ "${EXT}" == "gcm" ]]; then
		PLATFORM=gamecube
	elif match_parents '^(Nintendo )?DS$' || \
			[[ "${EXT}" == "nds" ]]; then
		PLATFORM=nds
	elif match_parents '^(Nintendo )?Family( )Computer( )Disk( )System$' || \
			match_parents '^(Nintendo )?Famicom( )Disk( )System$' || \
			match_parents '^(Nintendo )?FDS$' || \
			[[ "${EXT}" == "fds" ]]; then
		PLATFORM=fds
	elif match_parents '^(Nintendo )?Nintendo( )64$' || \
			match_parents '^(Nintendo )?64$' || \
			[[ "${EXT}" == "n64" || \
				"${EXT}" == "z64" ]]; then
		PLATFORM=n64
	elif match_parents '^(Nintendo )?Nintendo( )Entertainment( )System$' || \
			match_parents '^(Nintendo )?NES$' || \
			[[ "${EXT}" == "nes" ]]; then
		PLATFORM=nes
	elif [[ "${EXT}" == "nsp" || \
			"${EXT}" == "nsz" ]]; then
		PLATFORM=nsp
	elif match_parents '^(Nintendo )?Switch$' || \
			[[ "${EXT}" == "xci" || \
				"${EXT}" == "xcz" ]]; then
		PLATFORM=xci
	elif match_parents '^(Nintendo )?Super( )Nintendo( )Entertainment( )System( )Satellaview$' || \
			match_parents '^(Nintendo )?Super( )NE( )Satellaview$' || \
			match_parents '^(Nintendo )?SNES( )Satellaview$' || \
			match_parents '^(Nintendo )?Satellaview$' || \
			[[ "${EXT}" == "bs" ]]; then
		PLATFORM=bs
	elif match_parents '^(Nintendo )?Super( )Nintendo( )Entertainment( )System$' || \
			match_parents '^(Nintendo )?Super( )NES$' || \
			match_parents '^(Nintendo )?SNES$' || \
			[[ "${EXT}" == "sfc" ]]; then
		PLATFORM=sfc
	elif match_parents '^(Nintendo )?Virtual( )?Boy$' || \
			match_parents '^(Nintendo )?VB$' || \
			[[ "${EXT}" == "vb" ]]; then
		PLATFORM=vb
	elif match_parents '^(Nintendo )?W[Ii][Ii]$' && \
			[[ "${EXT}" == "wad" ]]; then
		PLATFORM=wii-wad
	elif match_parents '^(Nintendo )?W[Ii][Ii]$'; then
		PLATFORM=wii
	elif match_parents '^(Nintendo )?W[Ii][Ii]( )?U$' && \
			[[ "${EXT}" == "wud" || \
				"${EXT}" == "wux" ]] ; then
		PLATFORM=wiiu
	elif match_parents '^(Nintendo )?W[Ii][Ii]( )?U$'; then
		PLATFORM=wiiu-cdn
	elif match_parents '^(Sega )?Dreamcast$'; then
		PLATFORM=dreamcast
	elif match_parents '^(Sega )?Sega( )CD$' || \
			match_parents '^(Sega )?Mega([ -])CD$'; then
		PLATFORM=scd
	elif match_parents '^(Sega )?Genesis( )?32[Xx]$' || \
			match_parents '^(Sega )?Mega( )?Drive( )?32[Xx]$' || \
			match_parents '^(Sega )?32[Xx]$' || \
			[[ "${EXT}" == "32x" ]]; then
		PLATFORM=32x
	elif match_parents '^(Sega )?Game( )?Gear$' || \
			match_parents '^(Sega )?GG$' || \
			[[ "${EXT}" == "gg" ]]; then
		PLATFORM=gg
	elif match_parents '^(Sega )?Genesis$' || \
			match_parents '^(Sega )?Mega( )?Drive$' || \
			[[ "${EXT}" == "gen" || \
				"${EXT}" == "md" ]]; then
		PLATFORM=gen
	elif match_parents '^(Sega )?Master( )?System$' || \
			match_parents '^(Sega )?Mark( )?III$' || \
			[[ "${EXT}" == "sms" ]]; then
		PLATFORM=sms
	elif match_parents '^(Sega )?Saturn$'; then
		PLATFORM=saturn
	elif match_parents '^(SNK )?Neo([ -])?Geo( )Pocket( )Color$' || \
			match_parents '^(SNK )?NGPC$' || \
			[[ "${EXT}" == "ngc" ]]; then
		PLATFORM=ngc
	elif match_parents '^(Sony )?Play[Ss]tation$' || \
		  match_parents '^(Sony )?PS( )?[1X]$'; then
		PLATFORM=ps1
	elif match_parents '^(Sony )?Play[Ss]tation\ 2$' || \
			match_parents '^(Sony )?PS( )?2$'; then
		PLATFORM=ps2
	elif ( match_parents '^(Sony )?Play[Ss]tation\ 3$' || \
			match_parents '^(Sony )?PS( )?3$' ) && \
			[[ "${EXT}" == "iso" ]]; then
		PLATFORM=ps3
	elif ( match_parents '^(Sony )?Play[Ss]tation\ 3$' || \
			match_parents '^(Sony )?PS( )?3$' ) && \
			[[ "${EXT}" == "pkg" ]]; then
		PLATFORM=ps3-psn
	elif ( match_parents '^(Sony )?Play[Ss]tation\ 4$' || \
			match_parents '^(Sony )?PS( )?4$' ) && \
			[[ "${EXT}" == "iso" ]]; then
		PLATFORM=ps4
	elif ( match_parents '^(Sony )?Play[Ss]tation\ 4$' || \
			match_parents '^(Sony )?PS( )?4$' ) && \
			[[ "${EXT}" == "pkg" ]]; then
		PLATFORM=ps4-psn
	elif ( match_parents '^(Sony )?Play[Ss]tation\ Portable$' || \
			match_parents '^(Sony )?PSP$' ) && \
			[[ "${EXT}" == "pkg" ]]; then
		PLATFORM=psp
	elif match_parents '^(Sony )?Play[Ss]tation\ Portable$' || \
		  match_parents '^(Sony )?PSP$'; then
		PLATFORM=psp
	elif ( match_parents '^(Sony )?Play[Ss]tation\ Vita$' || \
		match_parents '^(Sony )?Vita$' ) && \
			[[ "${EXT}" == "pkg" ]]; then
		PLATFORM=psv-psn
	elif match_parents '^(Sony )?Play[Ss]tation\ Vita$' || \
		  match_parents '^(Sony )?Vita$'; then
		PLATFORM=psv
	fi
	echo "${PLATFORM}"
	#echo "${PLATFORM}" >&2
}

# Determine checksum file and other format requirements
#	$1 = platform
function id-dat() {
	STRIPHEAD=0
	STRIPCERT=0
	declare -a EXCLUDE
	case "${1}" in
		32x)
			DAT=("${DATDIR}/Sega - 32X ("*.dat) ;;
		360)
			DAT=("${DATDIR}/Microsoft - Xbox 360 - Datfile ("*.dat) ;;
		360-cdn)
			DAT=("${DATDIR}/Microsoft - Xbox 360 (Digital) ("*.dat "${DATDIR}/Unofficial - Microsoft - Xbox 360 (Title Updates) ("*.dat) ;;
		3do)
			DAT=("${DATDIR}/3DO Interactive Multiplayer - Datfile ("*.dat) ;;
		3ds)
			DAT=("${DATDIR}/Nintendo - Nintendo 3DS (Encrypted) ("*.dat)
			STRIPCERT=1
			;;
		3ds-cdn)
			DAT=("${DATDIR}/Nintendo - Nintendo 3DS (Digital) (CDN) ("*.dat "${DATDIR}/Unofficial - Nintendo - Nintendo 3DS (Digital) (Updates and DLC) (Encrypted) ("*.dat) ;;
		a26)
			DAT=("${DATDIR}/Atari - Atari 2600 ("*.dat) ;;
		a52)
			DAT=("${DATDIR}/Atari - Atari 5200 ("*.dat) ;;
		a78)
			DAT=("${DATDIR}/Atari - Atari 7800 ("*.dat)
			STRIPHEAD=1
			;;
		a8b)
			#DAT=("${DATDIR}/Atari - 8-bit Family ("*.dat)
			#STRIPHEAD=1
			DAT=("${DATDIR}/Atari 8bit Preserved Software "*.dat)
			;;
		bs)
			DAT=("${DATDIR}/Nintendo - Satellaview ("*.dat) ;;
		c64)
			DAT=("${DATDIR}/Commodore - Commodore 64 (PP) ("*.dat) ;;
		c64-crt)
			DAT=("${DATDIR}/Commodore - Commodore 64 ("*.dat) ;;
		c64-tap)
			DAT=("${DATDIR}/Commodore - Commodore 64 (Tapes) ("*.dat) ;;
		cd32)
			DAT=("${DATDIR}/Commodore - Amiga CD32 - Datfile ("*.dat) ;;
		dreamcast)
			DAT=("${DATDIR}/Sega - Dreamcast - Datfile ("*.dat) ;;
		fds)
			DAT=("${DATDIR}/Nintendo - Family Computer Disk System (FDS) ("*.dat) ;;
		gb)
			DAT=("${DATDIR}/Nintendo - Game Boy ("*.dat) ;;
		gba)
			DAT=("${DATDIR}/Nintendo - Game Boy Advance ("*.dat) ;;
		gbc)
			DAT=("${DATDIR}/Nintendo - Game Boy Color ("*.dat) ;;
		gamecube)
			DAT=("${DATDIR}/Nintendo - GameCube - Datfile ("*.dat) ;;
		gen)
			DAT=("${DATDIR}/Sega - Mega Drive - Genesis ("*.dat) ;;
		gg)
			DAT=("${DATDIR}/Sega - Game Gear ("*.dat) ;;
		ipf)
			DAT=("${DATDIR}/Commodore - Amiga (2"*.dat) ;;
		itv)
			DAT=("${DATDIR}/Mattel - Intellivision ("*.dat) ;;
		j64)
			DAT=("${DATDIR}/Atari - Atari Jaguar (J64) ("*.dat) ;;
		lnx)
			DAT=("${DATDIR}/Atari - Atari Lynx ("*.dat)
			STRIPHEAD=1
			;;
		n64)
			DAT=("${DATDIR}/Nintendo - Nintendo 64 (BigEndian) ("*.dat) ;;
		nds)
			DAT=("${DATDIR}/Nintendo - Nintendo DS (Decrypted) ("*.dat) ;;
		nes)
			DAT=("${DATDIR}/Nintendo - Nintendo Entertainment System ("*.dat)
			STRIPHEAD=1
			;;
		ngc)
			DAT=("${DATDIR}/SNK - NeoGeo Pocket Color ("*.dat) ;;
		nsp)
			bincheck hactool || return 1
			DAT=("${DATDIR}/Nintendo - Nintendo Switch (Digital) (CDN) ("*.dat) ;;
		ps1)
			DAT=("${DATDIR}/Sony - PlayStation - Datfile ("*.dat) ;;
		ps2)
			DAT=("${DATDIR}/Sony - PlayStation 2 - Datfile ("*.dat) ;;
		ps3)
			DAT=("${DATDIR}/Sony - PlayStation 3 - Datfile ("*.dat) ;;
		ps3-psn)
			DAT=("${DATDIR}/Sony - PlayStation 3 (PSN) ("*.dat) ;;
		ps4)
			DAT=("${DATDIR}/Sony - PlayStation 4 - Datfile ("*.dat) ;;
		ps4-psn)
			DAT=("${DATDIR}/Sony - PlayStation 4 (PSN) ("*.dat) ;;
		pc)
			DAT=("${DATDIR}/IBM - PC compatible - Datfile ("*.dat) ;;
		pc-fx)
			DAT=("${DATDIR}/NEC - PC-FX & PC-FXGA - Datfile ("*.dat) ;;
		pce)
			DAT=("${DATDIR}/NEC - PC Engine - TurboGrafx-16 ("*.dat) ;;
		pce-cd)
			DAT=("${DATDIR}/NEC - PC Engine CD & TurboGrafx CD - Datfile ("*.dat) ;;
		psp)
			DAT=("${DATDIR}/Sony - PlayStation Portable - Datfile ("*.dat "${DATDIR}/Sony - PlayStation Portable (PSN) ("*.dat) ;;
		saturn)
			DAT=("${DATDIR}/Sega - Saturn - Datfile ("*.dat) ;;
		scd)
			DAT=("${DATDIR}/Sega - Mega CD & Sega CD - Datfile ("*.dat) ;;
		sfc)
			DAT=("${DATDIR}/Nintendo - Super Nintendo Entertainment System ("*.dat) ;;
		sgx)
			DAT=("${DATDIR}/NEC - PC Engine SuperGrafx ("*.dat) ;;
		sms)
			DAT=("${DATDIR}/Sega - Master System - Mark III ("*.dat) ;;
		vb)
			DAT=("${DATDIR}/Nintendo - Virtual Boy ("*.dat) ;;
		psv)
			DAT=("${DATDIR}/Unofficial - Sony - PlayStation Vita (PSVgameSD) ("*.dat) ;;
		psv-psn)
			DAT=("${DATDIR}/Sony - PlayStation Vita ("*.dat) ;;
		wii)
			DAT=("${DATDIR}/Nintendo - Wii - Datfile ("*.dat) ;;
		wii-wad)
			DAT=("${DATDIR}/Unofficial - Nintendo - Wii (Digital) (Deprecated) (WAD) ("*.dat) ;;
		wiiu)
			DAT=("${DATDIR}/Nintendo - Wii U - Datfile ("*.dat) ;;
		wiiu-cdn)
			DAT=("${DATDIR}/Nintendo - Wii U (Digital) (CDN) ("*.dat)
			EXCLUDE=("*.cert" "*.tik") ;;
		wsc)
			DAT=("${DATDIR}/Bandai - WonderSwan Color ("*.dat) ;;
		xbox)
			DAT=("${DATDIR}/Microsoft - Xbox - Datfile ("*.dat) ;;
		xci)
			DAT=("${DATDIR}/Nintendo - Nintendo Switch ("[0-9]*.dat)
			STRIPCERT=1
			;;
		*)
			echo "Error: Invalid platform specified and could not be guessed." >&2
			echo "Run '${PROG} -h' for list of supported platforms" >&2
			exit 1
			;;
	esac
	echo "$(IFS=: ; echo "${DAT[*]}")|${STRIPHEAD}|${STRIPCERT}|$(IFS=: ; echo "${EXCLUDE[*]}")"
}

# Collect group member files from a directory, applying exclusions
#	$1 = directory to scan
function collect-group-files() {
	local FINDARGS=()
	for PATTERN in "${GROUPEXCLUDE[@]}"; do
		FINDARGS+=(-not -name "${PATTERN}")
	done
	find "${1}" -type f "${FINDARGS[@]}" -print0 | sort -z | tr '\0' '\n'
}

# Verify a single group member and write results to a file
#	$1 = member file path
#	$2 = member index
#	$3 = result output file
function verify-member() {
	local FILE=$1
	local NUM=$2
	local RESULT=$3

	# Get member checksum
	debug "Checksumming member file '${FILE##*/}'"
	CHECKSUM=$(${CKSUMCOM} -b "${FILE}" | cut -f1 -d' ')

	# Do not search DATs with an empty checksum.  Empty grep/sed patterns can
	# match every DAT entry and produce bogus verified names.
	if [ -z "${CHECKSUM}" ]; then
		cwarn "Warning: Could not calculate ${CKSUMNAME^^} for group member: ${FILE}" >&2

		echo "${NUM}" >"${RESULT}"
		echo "${FILE}" >>"${RESULT}"
		echo "" >>"${RESULT}"
		echo "UNVERIFIED" >>"${RESULT}"

		debug "Empty checksum for group member '${FILE##*/}'"
		return 1
	fi

	# Find matching name(s) in DAT; write to temp files for later
	# aggregation and analysis
	if [ "${NAMEMODE}" == "cd" ]; then

		debug "Checking DAT for track '${FILE##*/}'"
		NAME=$(grep -i "${CHECKSUM}" "${DATFILE[@]}" | sed -e 's/^.* name[= ]"\([^"]*\)".*$/\1/' | sed -e 's/&amp;/\&/')
		echo "${NUM}" >"${RESULT}"
		echo "${FILE}" >>"${RESULT}"
		echo "${CHECKSUM}" >>"${RESULT}"
		if [ -n "${NAME}" ]; then
			echo "VERIFIED" >>"${RESULT}"
			echo "${NAME}" >>"${RESULT}"
		else
			echo "UNVERIFIED" >>"${RESULT}"
		fi
		debug "Completed DAT lookup for '${FILE##*/}'"

	else
		debug "Checking DAT for file '${FILE##*/}'"

# Search DAT files in priority order and use the first DAT that returns
		# matching checksum entries.  Some platforms have overlapping DATs with
		# different names for the same hashed content, so DATFILE order is used
		# as the authority order.
		#
		# awk receives shell variables with -v, then processes one DAT file one
		# line at a time.  Pattern blocks match relevant XML lines, while awk
		# variables persist between lines.
		#
		# attrval() extracts quoted XML attributes with sub().  decode()
		# repeatedly unescapes XML ampersands with gsub().
		#
		# The current game name is saved when awk sees a <game ...> line or a
		# <description>...</description> line.  For matching <rom ...> lines,
		# awk compares the selected checksum attribute and prints the ROM name
		# plus the saved game name as:
		#
		#    rom-name<TAB>game-name
		#
		# If the current DAT produces any matches, stop searching later DATs.
		MATCHES=

		for DAT in "${DATFILE[@]}"; do
			MATCHES=$(awk -v cksumname="${CKSUMNAME}" -v checksum="${CHECKSUM}" '
				function decode(s) {
					while (gsub(/&amp;/, "\\&", s)) {}
					return s
				}
				function attrval(line, attr, s) {
					s = line
					if (s !~ attr "[[:space:]]*=[[:space:]]*\"")
						return ""
					sub("^.*" attr "[[:space:]]*=[[:space:]]*\"", "", s)
					sub("\".*$", "", s)
					return decode(s)
				}
				BEGIN {
					cksumname = tolower(cksumname)
					checksum = tolower(checksum)
				}
				/<game[[:space:]]/ {
					game = attrval($0, "name")
				}
				/<description>/ {
					desc = $0
					sub("^.*<description>", "", desc)
					sub("</description>.*$", "", desc)
					game = decode(desc)
				}
				/<rom[[:space:]]/ {
					sum = tolower(attrval($0, cksumname))
					if (sum == checksum)
						print attrval($0, "name") "\t" game
				}
			' "${DAT}")

			[ -n "${MATCHES}" ] && break
		done

		echo "${NUM}" >"${RESULT}"
		echo "${FILE}" >>"${RESULT}"
		echo "${CHECKSUM}" >>"${RESULT}"
		if [ -n "${MATCHES}" ]; then
			echo "VERIFIED" >>"${RESULT}"
			while IFS=$'\t' read -r ROMNAME GAMENAME; do
				[ -n "${ROMNAME}" ] || continue
				echo "${ROMNAME}" >>"${RESULT}"
				echo "${GAMENAME}" >>"${RESULT}"
			done <<< "${MATCHES}"
		else
			echo "UNVERIFIED" >>"${RESULT}"
		fi
		debug "Completed DAT lookup for '${FILE##*/}'"
	fi

}

# Verify a group of related files by verifying each member in parallel
#	$1 = mode: cd or dir
#	$2 = original file name or directory
#	$3 = optional: extracted directory
function verify-group() {
	local GROUPMODE="${1}"
	local ORIGINAL="${2}"
	local EXTRACTDIR="${3}"
	local GROUPDIR
	[ -n "${EXTRACTDIR}" ] && GROUPDIR="${3}" || GROUPDIR="${2}"

	GROUPRESULTDIR=$(mktemp -d -p "${TEMPDIR}" "group-results.XXXXX")
	NAMELISTFILE=$(mktemp -u -p "${TEMPDIR}" "group-names.XXXXX")
	BADMEMBERSFILE=$(mktemp -u -p "${TEMPDIR}" "group-bad.XXXXX")

	if [ -z "${PLATFORM}" ]; then
		debug "Identifying platform for '${ORIGINAL##*/}'"
		# Passing original file twice; function assumes original
		# + extracted, but latter makes no sense CDs or group dirs
		if ! PLATFORM=$(id-platform "${ORIGINAL}" "${ORIGINAL}"); then
			(( ! ${DEBUGMODE} )) && [[ -d "${EXTRACTDIR}" ]] && rm -rf "${EXTRACTDIR}"
			return 1
		fi
	fi

	if [ -z "${DATFILE}" ]; then
		debug "Identifying DAT for platform '${PALTFORM}' for '${ORIGINAL##*/}'"
		#if ! RESULTS=$(id-dat "${PLATFORM}" "${GROUPDIR}"); then
		if ! RESULTS=$(id-dat "${PLATFORM}"); then
			(( ! ${DEBUGMODE} )) && [[ -d "${EXTRACTDIR}" ]] && rm -rf "${EXTRACTDIR}"
			return 1
		fi
		IFS=: read -r -a DATFILE <<< $(echo "${RESULTS}" | cut -d'|' -f1)
		IFS=: read -r -a NEWEXCLUDE <<< $(echo "${RESULTS}" | cut -d'|' -f4)
		GROUPEXCLUDE+=( "${NEWEXCLUDE[@]}" )
	fi

	if [ "${GROUPMODE}" == "cd" ]; then
		NAMEMODE="cd"
		GROUPLABEL="CD"
		MEMBERLABEL="track"
		GROUPORIGLABEL="Disc File"

		# Only bin files are validated for CDs
		debug "Identifying files for ${GROUPLABEL} '${ORIGINAL##*/}'"
		GROUPMEMBERS=$(find "${GROUPDIR}" -type f -name '*.bin' -print0 | sort -z | tr '\0' '\n')

	else
		NAMEMODE="dir"
		GROUPLABEL="dir"
		MEMBERLABEL="file"
		GROUPORIGLABEL="Directory"

		# Ensure files matching excluded pattern are omitted
		debug "Identifying files for ${GROUPLABEL} '${ORIGINAL##*/}'"
		GROUPMEMBERS=$(collect-group-files "${GROUPDIR}")
		if [ -z "${GROUPMEMBERS}" ]; then
			cwarn "Error: No files found in '${GROUPDIR}'"
			return 1
		fi
	fi

	# Spawn parallel member verifications
	MEMBERNUM=1
	while IFS= read -r MEMBER; do
		# Cap member verify to nproc instead of THREADS; THREADS will apply
		# to overall files/groups being scanned
		while [ $(jobs | wc -l) -ge $(nproc) ]; do
			sleep 0.1
			jobs >/dev/null
		done
		verify-member "${MEMBER}" "${MEMBERNUM}" "${GROUPRESULTDIR}/member$(printf "%02d" ${MEMBERNUM}).txt" &
		MEMBERNUM=$((MEMBERNUM + 1))
	done <<< "${GROUPMEMBERS}"

	# Wait for all member verifications to complete
	wait

	# Aggregate results
	debug "Aggregating results for ${GROUPLABEL} '${ORIGINAL##*/}'"
	TOTALMEMBERS=0
	GOODMEMBERS=0
	BADMEMBERS=0

	for RESULTFILE in "${GROUPRESULTDIR}"/member*.txt; do
		declare -a MEMRESULTS
		TOTALMEMBERS=$((TOTALMEMBERS + 1))

		IFS=$'\n' mapfile -t MEMRESULTS < <(head -n 4 "${RESULTFILE}")
		MEMRNUM="${MEMRESULTS[0]}"
		MEMRFILE="${MEMRESULTS[1]}"
		MEMRCHECKSUM="${MEMRESULTS[2]}"
		MEMRSTATUS="${MEMRESULTS[3]}"

		if [ "${MEMRSTATUS}" == "VERIFIED" ]; then
			GOODMEMBERS=$((GOODMEMBERS + 1))
			if [ "${GROUPMODE}" == "cd" ]; then
				# For CDs, output the list of ROM names
				sed -n '5,$p' "${RESULTFILE}" | sed -e 's/ (Track [0-9]\+).bin$//' -e 's/\.bin$//' | sort -u | while IFS= read -r NAME; do
					[ -n "${NAME}" ] && echo "${NAME}" >>"${NAMELISTFILE}"
				done
			else
				# For group files, output unique game names associated with this file
				sed -n '6~2p' "${RESULTFILE}" | sort -u | while IFS= read -r NAME; do
					[ -n "${NAME}" ] && echo "${NAME}" >>"${NAMELISTFILE}"
				done
			fi				

		else
			BADMEMBERS=$((BADMEMBERS + 1))
			echo "${MEMRNUM}|${MEMRFILE}|${MEMRFILE##*/}|${MEMRCHECKSUM}" >>"${BADMEMBERSFILE}"			
		fi
	done

	# Determine overall status
	if [ ${BADMEMBERS} -eq 0 -a ${GOODMEMBERS} -eq ${TOTALMEMBERS} ]; then
		GROUPVERIFIED=1
	else
		GROUPVERIFIED=0
	fi

	# Find common names across all verified members
	debug "Identifying verified name for ${GROUPLABEL} '${ORIGINAL##*/}'"
	COMMONNAMES=""
	COMMONCOUNT=0

	# The purpose of this is to identify the *right* game name when individual
	# files match multiple different games.  The logic differs between CD and
	# group files.
	#echo "NAMELISTFILE = $NAMELISTFILE"
	#exit
	if [ ${GOODMEMBERS} -gt 0 ]; then
		if [ "${GROUPMODE}" == "cd" ]; then
			# For CDs, read each track name and strip number to get base name
			# The game name will batch this base name
			TITLECOUNTS=$(sort "${NAMELISTFILE}" | uniq -c | sort -rn)
		else
			# For group files, read the game name associated with each file
			TITLECOUNTS=$(sort "${NAMELISTFILE}" | uniq -c | sort -rn)
		fi

		# Game title logic
		#
		# For CDs, one and only one basename must match all titles; report this
		# as the game title.  Some CDs contain duplicate tracks, so one title
		# may have more than track count matches, so overall requirement is
		# single highest base name count >= track count
		#
		# For group files, individual file names are listed as ROM names, so
		# the associated game name with each matching ROM must be counted.
		# Game title is single highest game name count >= file count

		MAXCOUNT=$(echo "${TITLECOUNTS}" | awk 'NF {print $1; exit}')
		MAXTITLES=$(echo "${TITLECOUNTS}" | sed -ne "s/^\s*${MAXCOUNT}\s\(.*\)$/\1/p")
		NUMMAXTITLES=$(echo "${MAXTITLES}" | grep -c .)

		if [ -n "${MAXCOUNT}" -a "${MAXCOUNT}" -ge "${TOTALMEMBERS}" -a "${NUMMAXTITLES}" -eq 1 ]; then
			COMMONNAMES="${MAXTITLES}"
			COMMONCOUNT=1
		else
			COMMONNAMES=$(echo "${TITLECOUNTS}" | awk 'NF {sub(/^[[:space:]]*[0-9]+[[:space:]]+/, ""); print}')
			COMMONCOUNT=${NUMMAXTITLES}
		fi
	fi

	# Build status message
	debug "Building status message for ${GROUPLABEL} '${ORIGINAL##*/}'"

	# If all members verify successfully, report good status
	if [ ${BADMEMBERS} -eq 0 -a ${GOODMEMBERS} -eq ${TOTALMEMBERS} -a ${COMMONCOUNT} -eq 1 ]; then
		GROUPNAME=$(echo "${COMMONNAMES}" | head -n 1)
		MESSAGE="Verified ${PLATFORM^^} ${GROUPLABEL}: ${GROUPNAME}"
		if (( $VERBOSE )); then
			MESSAGE+="\n    ${GROUPORIGLABEL}: ${ORIGINAL##*/}"
			if [ "${GROUPDIR}" != "${ORIGINAL}" ]; then
				MESSAGE+="\n    Extracted: ${GROUPDIR}"
			fi
			MESSAGE+="\n    DAT file:  "
			LINE=1
			for i in "${DATFILE[@]}"; do
				if [ ${LINE} -eq 1 ]; then
					MESSAGE+="${i##*/}"
				else
					MESSAGE+="               ${i##*/}"
				fi
				((LINE++))
			done
		fi
		(( $COLORIZE )) && cgood "${MESSAGE}\n" || echo -e "${MESSAGE}\n"
		report_add "${ORIGINAL##*/}" "${GROUPDIR##*/}" "${GROUPNAME}" "OK" "${NAMEMODE}"

	else
		# This shouldn't happen, but warn if all verify but no clear title found
		if [ ${BADMEMBERS} -eq 0 -a ${GOODMEMBERS} -eq ${TOTALMEMBERS} ]; then
			MESSAGE="Warning: Verified ${PLATFORM^^} ${MEMBERLABEL}s, but no single dominant ${GROUPLABEL} name found:"
			MESSAGE+="\n    ${GROUPORIGLABEL}: ${ORIGINAL##*/}"
			if [ "${GROUPDIR}" != "${ORIGINAL}" ]; then
				MESSAGE+="\n    Extracted: ${GROUPDIR}"
			fi
			MESSAGE+="\n    DAT file:  "
			LINE=1
			for i in "${DATFILE[@]}"; do
				if [ ${LINE} -eq 1 ]; then
					MESSAGE+="${i##*/}\n"
				else
					MESSAGE+="               ${i##*/}"
				fi
					((LINE++))
			done
			MESSAGE+="\n    Candidate Names:"
			MESSAGE+="\n$(echo "${TITLECOUNTS}" | head -n 5)"
			(( $COLORIZE )) && cinfo "${MESSAGE}\n" >&2 || echo -e "${MESSAGE}\n" >&2
			report_add "${ORIGINAL##*/}" "${GROUPDIR##*/}" "" "UNVERIFIED" "${NAMEMODE}"

		# Otherwise, if at least one member onverified - report parent as such
		else
			MESSAGE="Warning: No match found for ${PLATFORM^^} ${GROUPLABEL}:"
			MESSAGE+="\n    ${GROUPORIGLABEL}: ${ORIGINAL##*/}"
			if [ "${GROUPDIR}" != "${ORIGINAL}" ]; then
				MESSAGE+="\n    Extracted: ${GROUPDIR}"
			fi
			MESSAGE+="\n    DAT file:  "
			LINE=1
			for i in "${DATFILE[@]}"; do
				if [ ${LINE} -eq 1 ]; then
					MESSAGE+="${i##*/}\n"
				else
					MESSAGE+="               ${i##*/}"
				fi
					((LINE++))
			done
			#MESSAGE+="\n    Unverified ${MEMBERLABEL^}s:"
			while IFS= read -r BADMEMBER; do
				BMNUM=$(echo "${BADMEMBER}" | cut -d'|' -f1)
				PADNUM=$(printf "%2s" "${BMNUM}")
				BMNAME=$(echo "${BADMEMBER}" | cut -d'|' -f3)
				BMCHECKSUM=$(echo "${BADMEMBER}" | cut -d'|' -f4)
				MESSAGE+="\n    Unverified ${MEMBERLABEL^} ${PADNUM}:"
				MESSAGE+="\n        Name: ${BMNAME}:"
				MESSAGE+="\n        ${CKSUMNAME^^}: ${BMCHECKSUM}"
			done < "${BADMEMBERSFILE}"
			(( $COLORIZE )) && cwarn "${MESSAGE}\n" >&2 || echo -e "${MESSAGE}\n" >&2
			report_add "${ORIGINAL##*/}" "${GROUPDIR##*/}" "" "UNVERIFIED" "${NAMEMODE}"
		fi
		echo "${ORIGINAL##*/}" >>"${ERRORSFILE}"
	fi

	# Report individual member details if verbose
	if (( $VERBOSE )); then
		debug "Reporting verbose details for ${GROUPLABEL} '${ORIGINAL##*/}'"
		MESSAGE=
		for RESULTFILE in "${GROUPRESULTDIR}"/member*.txt; do
			MEMRNUM=$(sed -n '1p' "${RESULTFILE}")
			MEMRCHECKSUM=$(sed -n '3p' "${RESULTFILE}")
			MEMRSTATUS=$(sed -n '4p' "${RESULTFILE}")

			if [ "${MEMRSTATUS}" == "VERIFIED" ]; then
				PADNUM=$(printf "%02d" "${MEMRNUM}")
				MESSAGE+="\n    Verified ${MEMBERLABEL} ${PADNUM}:"

				# Include all matching file names, not just the "correct" name
				if [ ${ALLFILES} -eq 1 -o ${BADMEMBERS} -gt 0 -o ${COMMONCOUNT} -ne 1 ]; then
					if [ "${GROUPMODE}" == "cd" ]; then
						while IFS= read -r NAME; do
							[ -n "${NAME}" ] && MESSAGE+="\n        Name: ${NAME}"
						done < <(sed -n '5,$p' "${RESULTFILE}")
					else
						while IFS= read -r NAME; do
							[ -n "${NAME}" ] && MESSAGE+="\n        Name: ${NAME}"
						done < <(sed -n '5~2p' "${RESULTFILE}")
					fi

				# If not ALLFILES, only print file names from matching game
				else
					TITLENAMES=

					# For CDs, print track whose base name matches game name
					if [ "${GROUPMODE}" == "cd" ]; then
						while IFS= read -r NAME; do
							[ -n "${NAME}" ] || continue
							BASENAME=$(echo "${NAME}" | sed -e 's/ (Track [0-9]\+).bin$//')
							if [ "${BASENAME}" == "${COMMONNAMES}" ]; then
								TITLENAMES+="${NAME}"$'\n'
							fi
						done < <(sed -n '5,$p' "${RESULTFILE}")

					# Froup group files, print track whose associated game name
					# matches detected game name
					else
						ROMNAME=
						GAMENAME=
						while IFS= read -r LINE; do
							[ -n "${LINE}" ] || continue
							if [ -z "${ROMNAME}" ]; then
								ROMNAME="${LINE}"
							else
								GAMENAME="${LINE}"
								if [ "${GAMENAME}" == "${COMMONNAMES}" ]; then
									TITLENAMES+="${ROMNAME}"$'\n'
								fi
								ROMNAME=
								GAMENAME=
							fi
						done < <(sed -n '5,$p' "${RESULTFILE}")
					fi

					# Some CDs have duplicate tracks (same checksum matching
					# multiple tracks); in this case, filter on the track name
					# matching the track number for cleaner reporting
					BESTNAME=
					while IFS= read -r NAME; do
						[ -n "${NAME}" ] || continue
						if [ "${GROUPMODE}" == "cd" ]; then
							if echo "${NAME}" | grep -qE " \(Track $(printf "%02d" ${MEMRNUM})\)\.bin$"; then
								BESTNAME="${NAME}"
								break
							fi
						fi
					done <<< "${TITLENAMES}"

					if [ -n "${BESTNAME}" ]; then
						MESSAGE+="\n        Name: ${BESTNAME}"
					else
						while IFS= read -r NAME; do
							[ -n "${NAME}" ] && MESSAGE+="\n        Name: ${NAME}"
						done <<< "${TITLENAMES}"
					fi
				fi
				MESSAGE+="\n        ${CKSUMNAME^^}: ${MEMRCHECKSUM}"
			fi
		done
		if [ -n "${MESSAGE}" ]; then
			[ $COLORIZE -eq 1 -a $GROUPVERIFIED -eq 1 ] && cgood "${MESSAGE#\\n}\n" || echo -e "${MESSAGE#\\n}\n"
		fi
	fi

	# Cleanup
	debug "Cleaning temporary files for ${GROUPLABEL} '${ORIGINAL##*/}'"
	(( ! ${DEBUGMODE} )) && rm -rf "${NAMELISTFILE}" "${BADMEMBERSFILE}" "${GROUPRESULTDIR}" [[ -d "${EXTRACTDIR}" ]] && rm -rf "${EXTRACTDIR}"
	exit 0
}

# Function to verify each game
function verify() {
	EXTRACTED_FILE=0

	# Decompress file, if necessary
	debug "Attempting to decompress '${FILE##*/}'"
	EXTRACTDIR=$(mktemp -d -p "${TEMPDIR}" "extracted.XXXXX")
	cd "${EXTRACTDIR}"

	# Preservice original file name through recursive calls
	if [[ -z "${ORIGPATH}" && "${ORIGFILE}" != "${FILE}" ]]; then
		ORIGPATH="${FILE}"
		ORIGNAME="${ORIGPATH##*/}"
		ORIGBASE="${ORIGNAME%.*}"
		ORIGSTRIP="${ORIGBASE// (*)/}"
	fi
	ORIGFILE="${FILE}"

	unplite "${FILE}" >/dev/null
	UNPRESULT=$?

	# If unpacking was reported successful, check list of unpacked files
	if [ $UNPRESULT -eq 0 ]; then
		GAME=$(find "${EXTRACTDIR}" -type f -print0 | sort -z | tr '\0' '\n')
		MATCHES=$(echo "${GAME}" | wc -l)
		EXTRACTED_FILE=1
		debug "Extract completed for '${FILE##*/}'\nFiles extracted to '${EXTRACTDIR}':\n${GAME}"

		# If group mode is enabled, treat unpacked files as a single unit
		if [ ${GROUPMODE} -eq 1 ]; then
			debug "Group mode enabled for ${FILE##*/}"
			verify-group "dir" "${FILE}" "${EXTRACTDIR}"
			(( ! ${DEBUGMODE} )) && rm -rf "${EXTRACTDIR}"
			return 0

		# Otherwise check and process either individual files or CD tracks
		else
			# If no files are matched, assume failure
			if [ $MATCHES -eq 0 ]; then
				debug "No extracted files detected in '${EXTRACTDIR}'"
				echo "Error: Could not identify decompressed game file in ${EXTRACTDIR}"
				echo "${GAME}" >>"${ERRORSFILE}"
				(( ! ${DEBUGMODE} )) && rm -rf "${EXTRACTDIR}"
				return 1

			# If more than one files are matched
			elif [ $MATCHES -gt 1 ]; then

				# Attempt to detect extracted CD image and verify BIN files
				# Logic: extracted files include 1 CUE or GDI, 1 or more BIN
				# files, and nothing else
				if [ \( $(echo "${GAME}" | sed -ne 's/^.*\.\(.*\)$/\L\1/p' | grep '^cue$' | wc -l) -eq 1 -o \
					$(echo "${GAME}" | sed -ne 's/^.*\.\(.*\)$/\L\1/p' | grep '^gdi$' | wc -l) -eq 1 \) -a \
					$(echo "${GAME}" | sed -ne 's/^.*\.\(.*\)$/\L\1/p' | grep '^bin$' | wc -l) -ge 1 -a \
					$(echo "${GAME}" | sed -ne 's/^.*\.\(.*\)$/\L\1/p' | grep -v '^cue$\|^gdi$\|^bin$' | wc -l) -eq 0 ]; then
			
					debug "Beginning verify for CD '${FILE##*/}'"
					verify-group "cd" "${FILE}" "${EXTRACTDIR}"
					(( ! ${DEBUGMODE} )) && rm -rf "${EXTRACTDIR}"
					return 0

				# Otherwise, abort as this function is designed to verify a
				# single ROM
				else
					debug "Multiple unknown files extracted from '${FILE##*/}'"
					cwarn "Error: Multiple game files in single archive not supported"
					echo "${GAME}" >>"${ERRORSFILE}"
					(( ! ${DEBUGMODE} )) && rm -rf "${EXTRACTDIR}"
					return 1
				fi
			fi
		fi

	# If unpacking was reported unsuccessful, abort
	elif [ $UNPRESULT -eq 1 -o $UNPRESULT -eq 127 ]; then
		debug "Extract failed for '${FILE##*/}'"
		echo "Error: ${FILE} could not be decompressed or extracted ($UNPRESULT)"
		(( ! ${DEBUGMODE} )) && rm -rf "${EXTRACTDIR}"
		return 1

	else
		debug "Continue processing uncompressed file '${FILE##*/}'"
		GAME="${FILE}"
	fi

	# If platform not specified, try to guess based on parent directory or ext
	[ -n "${ORIGPATH}" ] && PLATPATH=${ORIGPATH} || PLATPATH=${FILE}
	if [ -z "${PLATFORM}" ]; then
		debug "Identifying platform for '${GAME##*/}'"
		if ! PLATFORM=$(id-platform "${PLATPATH}" "${GAME}"); then
			(( ! ${DEBUGMODE} )) && rm -rf "${EXTRACTDIR}"
			return 1
		fi
	fi

	# Determine DAT filename and format requirements
	if [ -z "${DATFILE}" ]; then
		debug "Identifying DAT for platform '${PALTFORM}' for '${GAME##*/}'"
		if ! RESULTS=$(id-dat "${PLATFORM}"); then
			(( ! ${DEBUGMODE} )) && rm -rf "${EXTRACTDIR}"
			return 1
		fi
		IFS=: read -r -a DATFILE <<< $(echo "${RESULTS}" | cut -d'|' -f1)
		#echo "DAT = ${DATFILE[@]}"
		STRIPHEAD=$(echo "${RESULTS}" | cut -d'|' -f2)
		STRIPCERT=$(echo "${RESULTS}" | cut -d'|' -f3)
		IFS=: read -r -a NEWEXCLUDE <<< $(echo "${RESULTS}" | cut -d'|' -f4)
		GROUPEXCLUDE+=( "${NEWEXCLUDE[@]}" )
	fi

	# Strip ROM header or certificate if necessary
	if [ ${STRIPHEAD} -eq 1 -o ${STRIPCERT} -eq 1 ]; then
		STRIPPED="${EXTRACTDIR}/stripped.${GAME##*/}"

		if [ ${STRIPHEAD} -eq 1 ]; then
			debug "Stripping header from file '${GAME##*/}'"
			# Check whether header exists
			if [ "$(hexdump -n${#HEADERCHECK[${PLATFORM}]} -e "${#HEADERCHECK[${PLATFORM}]} \"%_p\" \"\\n\"" "${GAME}")" == "${HEADERCHECK[${PLATFORM}]}" ]; then

				# If so, strip ROM header
				tail -c +$((${HEADERLEN[${PLATFORM}]}+1)) "${GAME}" >"${STRIPPED}"
				GAME=${STRIPPED}
			else
				STRIPHEAD=0
			fi

		else
			debug "Stripping cert from file '${GAME##*/}'"
			CERTSTART=$(echo "${CERTPARM[${PLATFORM}]}" | cut -d'|' -f1)
			CERTLEN=$(echo "${CERTPARM[${PLATFORM}]}" | cut -d'|' -f2)

			# Due to size of ROMs, first check if cert needs to be stripped
			dd bs=1 skip=${CERTSTART} count=${CERTLEN} status=none if="${GAME}" >"${TEMPDIR}/${GAME##*/}.cert"
			printf '\xff%.0s' $(seq 1 ${CERTLEN}) >"${TEMPDIR}/${GAME##*/}.empty"
			if [ $(cmp -l "${TEMPDIR}/${GAME##*/}.cert" "${TEMPDIR}/${GAME##*/}.empty" | wc -l) -gt 0 ]; then

				# If ROM was extracted, modify directly;
				# if using original ROM, copy first
				if [ $EXTRACTED_FILE -eq 1 ]; then
					STRIPPED="${GAME}"
				else
					cp "${GAME}" "${STRIPPED}"
				fi
				printf '\xff%.0s' $(seq 1 ${CERTLEN}) | dd bs=1 seek=${CERTSTART} count=${CERTLEN} conv=notrunc status=none of="${STRIPPED}"
				GAME=${STRIPPED}
			else
				STRIPCERT=0
			fi
			rm -f "${TEMPDIR}/${GAME##*/}".{cert,empty}
		fi
	fi

	# Some platforms require special handling

	# Switch NSPs must be unpacked and NCAs checksummed
	if [ "${PLATFORM}" == "nsp" ]; then
		debug "Beginning NSP verify for '${GAME##*/}'"
		# Initialize NSP variables and unpack NSP file
		NAME=
		GOOD=0
		BAD=0
		XMLBAD=0
		hactool --disablekeywarns -x -t pfs0 --pfs0dir="$EXTRACTDIR" "$GAME" >/dev/null

		# Verify each NCA extracted from NSP
		shopt -s nullglob
		for i in "${EXTRACTDIR}"/*.{nca,cnmt.xml}; do
			CHECKSUM=$(${CKSUMCOM} -b "$i" | cut -f1 -d' ')

			# Skip DAT lookup if checksum calculation failed
			[ -z "${CHECKSUM}" ] && let $((BAD++)) && continue

			MATCH=$(sed -n "/<game /h;/${CKSUMNAME}=\"${CHECKSUM}\"/I{g;p;}" "${DATFILE[@]}")
			#MATCHTITLE=$(echo "${MATCH}" | sed -e 's/^.* name="\([^"]*\)".*$/\1/')
			MATCHTITLE=$(echo "${MATCH}" | sed -n 's/^.* name="\([^"]*\)".*$/\1/p' | sed '$!N; /^\(.*\)\n\1$/!P; D')

			if [ -z "${MATCH}" ]; then
				if [ "$(echo "${i##*.}" | sed 's/\(.*\)/\L\1/')" == "xml" ]; then
					let $((XMLBAD++))
				else
					let $((BAD++))
				fi
			else
				if [ -z "${NAME}" ]; then
					NAME=${MATCHTITLE}
					let $((GOOD++))
				elif [ "${NAME}" == "${MATCHTITLE}" ]; then
					let $((GOOD++))
				else
					let $((BAD++))
				fi
			fi
		done
		if [ ${GOOD} -gt 0 -a ${BAD} -eq 0 -a ${XMLBAD} -eq 0  ]; then
			MESSAGE="Verified NSP game: ${NAME}"
			(( $COLORIZE )) && cgood "${MESSAGE}\n" || echo -e "${MESSAGE}\n"
			report_add "${ORIGNAME}" "${GAME##*/}" "${NAME}" "OK" "rom"
		elif [ ${GOOD} -gt 0 -a ${BAD} -eq 0 -a ${XMLBAD} -gt 0 ]; then
			MESSAGE="Warning: Verified NCAs for game ${GAME}, but XML validation failed (may not be DATted)"
			(( $COLORIZE )) && cinfo "${MESSAGE}\n" >&2 || echo -e "${MESSAGE}\n" >&2
			report_add "${ORIGNAME}" "${GAME##*/}" "" "UNVERIFIED" "rom"
		elif [ ${GOOD} -gt 0 -a ${BAD} -gt 0 ]; then
			MESSAGE="Warning: Only partial match found for NCAs for game: ${GAME}"
			(( $COLORIZE )) && cwarn "${MESSAGE}\n" >&2 || echo -e "${MESSAGE}\n" >&2
			report_add "${ORIGNAME}" "${GAME##*/}" "" "UNVERIFIED" "rom"
		elif [ ${GOOD} -eq 0 -a ${BAD} -gt 0 ]; then
			MESSAGE="Warning: No match found for NSP game: ${GAME}"
			(( $COLORIZE )) && cwarn "${MESSAGE}\n" >&2 || echo -e "${MESSAGE}\n" >&2
			report_add "${ORIGNAME}" "${GAME##*/}" "" "UNVERIFIED" "rom"
		else
			MESSAGE="Warning: Unknown status or could not process ${STRIPMSG}${PLATFORM^^} NCAs for game: ${GAME}"
			(( $COLORIZE )) && cwarn "${MESSAGE}\n" >&2 || echo -e "${MESSAGE}\n" >&2
			report_add "${ORIGNAME}" "${GAME##*/}" "" "UNVERIFIED" "rom"
		fi

	# For everything else...
	else
		debug "Checksumming file '${GAME##*/}'"
		# Get game file checksum
		CHECKSUM=$(${CKSUMCOM} -b "$GAME" | cut -f1 -d' ')

		# Do not search DATs with an empty checksum.  Empty sed patterns can
		# match every DAT entry and produce bogus verified names.
		if [ -z "${CHECKSUM}" ]; then
			if [ -n "${ORIGPATH}" ]; then
				OLDFILE="${GAME} (${ORIGPATH##*/})"
			else
				OLDFILE="${FILE##*/}"
			fi

			MESSAGE="Warning: Could not calculate ${CKSUMNAME^^} for ${STRIPMSG}${PLATFORM^^} game:"
			MESSAGE+="\n    Orig File: ${OLDFILE}"
			MESSAGE+="\n    Hashed File: ${GAME}"

			(( $COLORIZE )) && cwarn "${MESSAGE}\n" >&2 || echo -e "${MESSAGE}\n" >&2
			echo "${OLDFILE}" >>"${ERRORSFILE}"
			report_add "${ORIGNAME}" "${GAME##*/}" "" "UNVERIFIED" "rom"

			(( ! ${DEBUGMODE} )) && rm -rf "${EXTRACTDIR}"
			return 1
		fi

		# Find checksum in DAT
		#echo "Checking '${DATFILE[@]}'"
		# Regex supports No-Intro, Redump, and Atari 8bit Preservation formats
		debug "Checking DAT for '${GAME##*/}'"
		if [ ${SHOWFILE} -eq 1 ]; then
			NAME=$(grep -i "${CHECKSUM}" "${DATFILE[@]}" | sed -e 's/^.* name[= ]"\([^"]*\)".*$/\1/' | sed -e 's/&amp;/\&/')
		else
			MATCH=$(sed -n "/\(game\|description\) /h;/${CKSUMNAME}[= ]\(\"\)\?${CHECKSUM}\(\"\)\?/I{g;p;}" "${DATFILE[@]}")
			NAME=$(echo "${MATCH}" | sed -e 's/^.*\s\(name\|description\)[= ]"\([^"]*\)".*$/\2/' | sed -e 's/&amp;/\&/' | sed '$!N; /^\(.*\)\n\1$/!P; D')
		fi

		# Report results
		debug "Reporting results for '${GAME##*/}'"
		[ ${STRIPHEAD} -eq 1 -o ${STRIPCERT} -eq 1 ] && STRIPMSG="stripped " || STRIPMSG=""
		if [ -n "${ORIGPATH}" ]; then
			OLDFILE="${GAME} (${ORIGPATH##*/})"
		else
			OLDFILE="${FILE##*/}"
		fi
		if [ -n "${NAME}" ]; then
			MESSAGE="Verified ${STRIPMSG}${PLATFORM^^} game: ${NAME}"
			if (( $VERBOSE )); then
				MESSAGE+="\n    File: ${OLDFILE}"
				MESSAGE+="\n    ${CKSUMNAME^^}: ${CHECKSUM}"
				MESSAGE+="\n    DAT:  "
				LINE=1
				for i in "${DATFILE[@]}"; do
					if [ ${LINE} -eq 1 ]; then
						MESSAGE+="$(basename "$i")\n"
					else
						MESSAGE+="          $(basename "$i")\n"
					fi
					((LINE++))
				done
			fi
			(( $COLORIZE )) && cgood "${MESSAGE%\\n}\n" || echo -e "${MESSAGE%\\n}\n"
			report_add "${ORIGNAME}" "${GAME##*/}" "${NAME}" "OK" "rom"
		else
			MESSAGE="Warning: No match found for ${STRIPMSG}${PLATFORM^^} game:"
			MESSAGE+="\n    Orig File: ${OLDFILE}"
			MESSAGE+="\n    Hashed File: ${GAME}"
			MESSAGE+="\n    ${CKSUMNAME^^}: ${CHECKSUM}"
			MESSAGE+="\n    DAT: "
			LINE=1
			for i in "${DATFILE[@]}"; do
				if [ ${LINE} -eq 1 ]; then
					MESSAGE+="$(basename "$i")\n"
				else
					MESSAGE+="         $(basename "$i")\n"
				fi
				((LINE++))
			done
			(( $COLORIZE )) && cwarn "${MESSAGE%\\n}\n" >&2 || echo -e "${MESSAGE%\\n}\n" >&2
			echo "${OLDFILE}" >>"${ERRORSFILE}"
			report_add "${ORIGNAME}" "${GAME##*/}" "" "UNVERIFIED" "rom"
		fi

	fi

	# Cleanup temp files
	debug "Cleaning temporary files for '${GAME##*/}'"
	cd - >/dev/null
	(( ! ${DEBUGMODE} )) && rm -rf "${EXTRACTDIR}"
	exit 1
}

# Load optional personalized report filters.
function report_load_filters() {
	local CODE

	if [ ! -f "${REPORTFILTERFILE}" ]; then
		cinfo "Warning: filtered report mode (-R) requested, but no filter file was found"
		echo -e "Expected '${REPORTFILTERFILE}'"
		echo -e "Falling back to default report mode (-r)\n"
		REPORTMODE=1
		REPORTFILTERLOADED=0
		return 0
	fi

	if ! source "${REPORTFILTERFILE}"; then
		cwarn "\nError: failed to load filtered report mode fileter file"
		echo -e "Filter file: '${REPORTFILTERFILE}'"
		echo -e "Skipping report mode."
		REPORTMODE=1
		REPORTFILTERLOADED=0
		return 1
	fi

	REPORT_REGION_NAMES=()
	REPORT_KNOWN_REGIONS_RE=

	for CODE in "${!REPORT_REGION_MAP[@]}"; do
		REPORT_REGION_NAMES+=("${REPORT_REGION_MAP[${CODE}]}")
	done

	if [ ${#REPORT_REGION_NAMES[@]} -gt 0 ]; then
		readarray -t REPORT_REGION_NAMES < <(
			printf '%s\n' "${REPORT_REGION_NAMES[@]}" |
				sort -u |
				awk '{ print length, $0 }' |
				sort -nr |
				cut -d' ' -f2-
		)

		REPORT_KNOWN_REGIONS_RE=$(IFS='|'; echo "${REPORT_REGION_NAMES[*]}")
	fi

	REPORTFILTERLOADED=1
	debug "Loaded report filters from '${REPORTFILTERFILE}'"
	return 0
}

# Apply FROM|TO name fixes to a report name variable in place.
#	$1 = variable name containing the name to modify
#	$@ = replacement rules after shifting variable name away
function report_apply_name_fixes() {
	local -n NAME_REF="$1"
	local NAME_FIX FROM TO

	shift

	for NAME_FIX in "${@}"; do
		FROM="${NAME_FIX%%|*}"
		TO="${NAME_FIX#*|}"
		NAME_REF="${NAME_REF//${FROM}/${TO}}"
	done
}

# Normalize a local or verified name for filtered report comparison.
# This is only used by filtered report mode (-R).
#	$1 = name to normalize
#	$2 = name type/context: local or verified
function report_normalize() {
	local NAME="$1"
	local TYPE="${2:-local}"
	local TAG PHRASE
	local ARTICLE PREFIX SUFFIX REST
	local MATCH TAGTEXT TOKEN EXPANDED SCAN_NAME
	local HAS_REGION
	local REGIONS REGION
	local -a TAGS TOKENS REGIONS_ARR NORMAL_REGIONS
	local -A SEEN_REGIONS

	# Remove tag-style text that should not affect filtered report matching.
	# Fixed tags and wildcard tags are both regexes here because they are
	# bounded tag tokens rather than arbitrary title fragments.
	for TAG in "${REPORT_COMMON_TAG_PATTERNS[@]}"; do
		while [[ "${NAME}" =~ ${TAG} ]]; do
			NAME="${NAME/${BASH_REMATCH[0]}/}"
		done
	done

	# Remove local-only phrases that are part of the preferred local filename
	# convention but not part of the canonical DAT verified name.
	if [ "${TYPE}" == "local" ]; then
		for PHRASE in "${REPORT_LOCAL_STRIPS[@]}"; do
			NAME="${NAME//${PHRASE}/}"
		done
	fi

	# Remove verified-only phrases that are part of DAT naming conventions but
	# should not affect comparison against the preferred local naming scheme.
	if [ "${TYPE}" == "verified" ]; then
		for PHRASE in "${REPORT_VERIFIED_STRIPS[@]}"; do
			NAME="${NAME//${PHRASE}/}"
		done

		for PHRASE in "${REPORT_VERIFIED_STRIP_PATTERNS[@]}"; do
			while [[ "${NAME}" =~ ${PHRASE} ]]; do
				NAME="${NAME/${BASH_REMATCH[0]}/ }"
			done
		done
	fi

	# Apply common literal name fixes.
	report_apply_name_fixes NAME "${REPORT_COMMON_NAME_FIXES[@]}"

	# Convert leading article style to DAT-style suffix.
	#
	# Example:
	#    The Game -> Game, The
	if [[ "${NAME}" == The\ * ]]; then
		ARTICLE="The"
		NAME="${NAME#The }"
	elif [[ "${NAME}" == A\ * ]]; then
		ARTICLE="A"
		NAME="${NAME#A }"
	elif [[ "${NAME}" == An\ * ]]; then
		ARTICLE="An"
		NAME="${NAME#An }"
	fi

	if [ -n "${ARTICLE}" ]; then
		if [[ "${NAME}" == *\ -\ * ]]; then
			PREFIX="${NAME%% - *}"
			REST="${NAME#* - }"
			SUFFIX=" - ${REST}"
		elif [[ "${NAME}" == *\(* ]]; then
			PREFIX="${NAME%% (*}"
			REST="${NAME#*(}"
			SUFFIX=" (${REST}"
		else
			PREFIX="${NAME}"
			SUFFIX=""
		fi

		PREFIX="${PREFIX% }"
		NAME="${PREFIX}, ${ARTICLE}${SUFFIX}"
	fi

	# Apply side-specific literal name fixes.
	if [ "${TYPE}" == "local" ]; then
		report_apply_name_fixes NAME "${REPORT_LOCAL_NAME_FIXES[@]}"
	elif [ "${TYPE}" == "verified" ]; then
		report_apply_name_fixes NAME "${REPORT_VERIFIED_NAME_FIXES[@]}"
	fi

	REGIONS_ARR=()

	# Extract parenthesized tags.  Only tags that are entirely made of known
	# region tokens are removed from the title and stored as regions.
	#
	# This avoids stripping non-region tags like "(Rev 1)", "(Alt)",
	# "(Disc 1)", "(Beta)", etc.
	TAGS=()
	SCAN_NAME="${NAME}"

	while [[ "${SCAN_NAME}" =~ \([^()]+\) ]]; do
		MATCH="${BASH_REMATCH[0]}"
		TAGS+=("${MATCH}")
		SCAN_NAME="${SCAN_NAME#*"${MATCH}"}"
	done

	for MATCH in "${TAGS[@]}"; do
		TAGTEXT="${MATCH#(}"
		TAGTEXT="${TAGTEXT%)}"
		IFS=',' read -r -a TOKENS <<< "${TAGTEXT}"

		HAS_REGION=0

		for TOKEN in "${TOKENS[@]}"; do
			# Trim whitespace around each region token.
			TOKEN="${TOKEN#"${TOKEN%%[![:space:]]*}"}"
			TOKEN="${TOKEN%"${TOKEN##*[![:space:]]}"}"
			EXPANDED=""

			# Expand local region abbreviations.
			if [ -n "${REPORT_REGION_MAP[${TOKEN}]+_}" ]; then
				EXPANDED="${REPORT_REGION_MAP[${TOKEN}]}"

			# Keep already-expanded DAT region names we explicitly know.
			elif [ -n "${REPORT_KNOWN_REGIONS_RE}" ] && [[ "${TOKEN}" =~ ^(${REPORT_KNOWN_REGIONS_RE})$ ]]; then
				EXPANDED="${TOKEN}"

			# Ignore untracked DAT regions inside a detected region tag.
			else
				continue
			fi

			HAS_REGION=1
			REGIONS_ARR+=("${EXPANDED}")
		done

		# If at least one known region was found, treat the whole tag as a
		# region tag and remove it from the title.
		if [ ${HAS_REGION} -eq 1 ]; then
			NAME="${NAME//${MATCH}/}"
		fi
	done

	# Handle default region if set
	if [ ${#REGIONS_ARR[@]} -eq 0 ]; then
		REGIONS="${REPORT_DEFAULT_REGION}"
	else
		NORMAL_REGIONS=()

		for REGION in "${REGIONS_ARR[@]}"; do
			if [ -z "${SEEN_REGIONS[${REGION}]+_}" ]; then
				SEEN_REGIONS["${REGION}"]=1
				NORMAL_REGIONS+=("${REGION}")
			fi
		done

		REGIONS="${NORMAL_REGIONS[*]}"
	fi

	# Decode common XML/HTML entities, strip language-only tags, and normalize
	# whitespace/punctuation spacing.  Language tags may include subtags, such
	# as Zh-Hant or Zh-Hans.
	if [ -n "${REPORT_LANGUAGE_TAG_PATTERN}" ]; then
		NAME=$(sed -E \
			-e 's/&amp;amp;/\&/g' \
			-e 's/&amp;/\&/g' \
			-e "s/${REPORT_LANGUAGE_TAG_PATTERN}//g" \
			-e 's/,([A-Za-z])/, \1/g' \
			-e 's/ +/ /g' \
			-e 's/^ +//' \
			-e 's/ +$//' \
			<<< "${NAME}")
	else
		NAME=$(sed -E \
			-e 's/&amp;amp;/\&/g' \
			-e 's/&amp;/\&/g' \
			-e 's/,([A-Za-z])/, \1/g' \
			-e 's/ +/ /g' \
			-e 's/^ +//' \
			-e 's/ +$//' \
			<<< "${NAME}")
	fi

	printf "%s\t%s\n" "${NAME}" "${REGIONS}"
}

# Process sorted report rows and emit report output.
function report_process() {
	local ORIGINAL EXTRACTED VERIFIED STATUS TYPE EXTRA
	local LOCAL_NAME LOWER
	local NORM_LOCAL_RESULT NORM_VERIFIED_RESULT
	local NORM_LOCAL NORM_VERIFIED
	local REG_LOCAL REG_VERIFIED
	local REGION REGION_MATCH
	local TITLE_MATCH VERIFIED_PART VERIFIED_PART_BASE LOCAL_BASE
	local DISPLAY_VERIFIED DISPLAY_REGIONS
	local -A LOCAL_SET VERIFIED_SET
	local -a VERIFIED_PARTS

	while IFS='|' read -r ORIGINAL EXTRACTED VERIFIED STATUS TYPE EXTRA; do
		[ -n "${ORIGINAL}" ] || continue

		# Select the local name used for comparison.
		#
		# For ROMs, prefer the extracted/hashed file name when available to
		# eliminate the compression extension.  This
		#
		# For CDs, use the original input name.  The extracted value is usually
		# a temporary directory, not a meaningful game name.
		#
		# For group directories, use the original directory/archive name as-is.
		case "${TYPE}" in
		rom)
			if [ -n "${EXTRACTED}" ] && [ "${EXTRACTED}" != "${ORIGINAL}" ]; then
				LOCAL_NAME="${EXTRACTED}"
			else
				LOCAL_NAME="${ORIGINAL}"
			fi
			
			# Strip the remaining extension to get base name
			LOCAL_NAME="${LOCAL_NAME%.*}"
			;;

		cd)
			LOCAL_NAME="${ORIGINAL}"

			# Strip the remaining extension to get base name
			LOCAL_NAME="${LOCAL_NAME%.*}"
			;;

		dir)
			# Group directories should usually compare against the directory
			# name without stripping dotted title components.
			#
			# If ORIGINAL and EXTRACTED differ, this was a group archive that
			# unpacked to a temporary directory.  In that case, compare against
			# the archive basename with one archive suffix removed:
			if [ -n "${EXTRACTED}" ] && [ "${EXTRACTED}" != "${ORIGINAL}" ]; then
				LOCAL_NAME="${ORIGINAL%.*}"
			else
				LOCAL_NAME="${ORIGINAL}"
			fi
			;;

		*)
			# Fallback for unexpected report types.
			LOCAL_NAME="${ORIGINAL}"
			LOCAL_NAME="${LOCAL_NAME%.*}"
			;;
		esac

		# Remove the script-generated stripped marker before both display and
		# normalization so report output reflects the original game name.
		LOCAL_NAME="${LOCAL_NAME//stripped./}"

		# Only successful verifications can produce rename mismatches.
		# Verbose mode also lists unverified files/directories.
		if [ "${STATUS}" != "OK" ] || [ -z "${VERIFIED}" ]; then
			if (( ${VERBOSE} )); then
				printf "%s\n" "${ORIGINAL}"
				printf "    Status: Unverified\n\n"
			fi

			continue
		fi

		if [ ${REPORTMODE} -eq 1 ]; then
			# Compare local base name directly to the raw verified  DAT name.
			# No filtering or other personalized conversions
			NORM_LOCAL="${LOCAL_NAME}"
			NORM_VERIFIED="${VERIFIED}"
			DISPLAY_VERIFIED="${VERIFIED}"

			if [ "${NORM_LOCAL}" == "${NORM_VERIFIED}" ]; then
				TITLE_MATCH=1
			else
				TITLE_MATCH=0
			fi

			REGION_MATCH=1

		else
			# Enable personalized filtering
			NORM_LOCAL_RESULT=$(report_normalize "${LOCAL_NAME}" "local")
			NORM_VERIFIED_RESULT=$(report_normalize "${VERIFIED}" "verified")

			REG_LOCAL="${NORM_LOCAL_RESULT##*$'\t'}"
			NORM_LOCAL="${NORM_LOCAL_RESULT%%$'\t'*}"

			REG_VERIFIED="${NORM_VERIFIED_RESULT##*$'\t'}"
			NORM_VERIFIED="${NORM_VERIFIED_RESULT%%$'\t'*}"

			# Build verified display name.  Omit default region if personalized.
			# Show mixed/non-default USA region tags so report output reflects
			# the DAT name being matched.
			DISPLAY_VERIFIED="${NORM_VERIFIED}"

			if [ -n "${REG_VERIFIED}" ] && [ "${REG_VERIFIED}" != "${REPORT_DEFAULT_REGION}" ]; then
				DISPLAY_REGIONS="${REG_VERIFIED// /, }"
				DISPLAY_VERIFIED="${NORM_VERIFIED} (${DISPLAY_REGIONS})"
			fi

			# Check title match separately from region match.
			#
			# No-Intro sometimes joins alternate names with " ~ ".  Treat
			# either side as a valid title match, while keeping the full
			# string match as the default behavior.
			TITLE_MATCH=0

			if [ "${NORM_LOCAL}" == "${NORM_VERIFIED}" ]; then
				TITLE_MATCH=1

			elif [[ "${NORM_VERIFIED}" == *" ~ "* ]]; then
				IFS='~' read -r -a VERIFIED_PARTS <<< "${NORM_VERIFIED}"

				for VERIFIED_PART in "${VERIFIED_PARTS[@]}"; do
					# Trim whitespace around each alternate title.
					VERIFIED_PART="${VERIFIED_PART#"${VERIFIED_PART%%[![:space:]]*}"}"
					VERIFIED_PART="${VERIFIED_PART%"${VERIFIED_PART##*[![:space:]]}"}"

					if [ "${NORM_LOCAL}" == "${VERIFIED_PART}" ]; then
						TITLE_MATCH=1
						break
					fi

					if [[ "${NORM_LOCAL}" != *"("* ]]; then
						VERIFIED_PART_BASE="${VERIFIED_PART}"

						while [[ "${VERIFIED_PART_BASE}" =~ [[:space:]]*\([^()]*\) ]]; do
							VERIFIED_PART_BASE="${VERIFIED_PART_BASE/${BASH_REMATCH[0]}/}"
						done

						while [[ "${VERIFIED_PART_BASE}" == *"  "* ]]; do
							VERIFIED_PART_BASE="${VERIFIED_PART_BASE//  / }"
						done

						VERIFIED_PART_BASE="${VERIFIED_PART_BASE#"${VERIFIED_PART_BASE%%[![:space:]]*}"}"
						VERIFIED_PART_BASE="${VERIFIED_PART_BASE%"${VERIFIED_PART_BASE##*[![:space:]]}"}"

						if [ "${NORM_LOCAL}" == "${VERIFIED_PART_BASE}" ]; then
							TITLE_MATCH=1
							break
						fi
					fi
				done
			fi

			# Compare region sets.  World implicitly includes default region,
			# so a verified World game matches a local file with no region tag.
			LOCAL_SET=()
			VERIFIED_SET=()

			for REGION in ${REG_LOCAL}; do
				LOCAL_SET["${REGION}"]=1

				if [ "${REGION}" == "World" ]; then
					LOCAL_SET["${REPORT_DEFAULT_REGION}"]=1
				fi
			done

			for REGION in ${REG_VERIFIED}; do
				VERIFIED_SET["${REGION}"]=1

				if [ "${REGION}" == "World" ]; then
					VERIFIED_SET["${REPORT_DEFAULT_REGION}"]=1
				fi
			done

			REGION_MATCH=0

			for REGION in "${!LOCAL_SET[@]}"; do
				if [ -n "${VERIFIED_SET[${REGION}]+_}" ]; then
					REGION_MATCH=1
					break
				fi
			done
		fi

		# Report a mismatch if title differs or region sets do not overlap.
		if [ ${TITLE_MATCH} -eq 0 ] || [ ${REGION_MATCH} -eq 0 ]; then
			printf "%s\n" "${ORIGINAL}"

			if (( ${DEBUGMODE} )); then
				printf "    Local:         %s\n" "${LOCAL_NAME}"
				printf "    Normal Local:  %s\n" "${NORM_LOCAL}"
				printf "    Verified:      %s\n" "${DISPLAY_VERIFIED}"
				printf "    Orig Verified: %s\n\n" "${VERIFIED}"
			else
				printf "    Local:    %s\n" "${LOCAL_NAME}"
				printf "    Verified: %s\n\n" "${DISPLAY_VERIFIED}"
			fi

		# In verbose mode, also show verified files that already match.
		elif (( ${VERBOSE} )); then
			printf "%s\n" "${ORIGINAL}"
			printf "    Status: Verified\n\n"
		fi
	done
}

# Process arguments
while [ $# -ne 0 ]; do
	# Display help if requested
	if [ "$1" == "-h" -o "$1" == "--help" -o "$1" == "-?" ]; then
		warning

	# Show all matching filenames instead of filtering on expected match
	elif [ "$1" == "-a" ]; then
		ALLFILES=1

	# Set checksum algorithm
	elif [ "$1" == "-c" ]; then
		shift
		if [ "$1" == "md5" ]; then
			CKSUMCOM="md5sum"
			CKSUMNAME="md5"
		elif [ "$1" == "sha1" ]; then
			CKSUMCOM="sha1sum"
			CKSUMNAME="sha1"
		else
			cwarn "Error: '$1' is not a supported hashing algorithm"
			warning
		fi

	# Specify non-default temp directory
	elif [ "$1" == "-d" ]; then
		shift
		if [ -d "$1" ]; then
			TEMPROOT="$1"
		else
			cwarn "Error: '$1' does not appear to be a valid directory\n"
			warning
		fi

	# Specify non-default temp directory
	elif [ "$1" == "-e" ]; then
		shift
		GROUPEXCLUDE+=("$1")

	# Show filename instead of game title in output
	elif [ "$1" == "-f" ]; then
		SHOWFILE=1

	# Enable group mode
	elif [ "$1" == "-g" ]; then
		GROUPMODE=1

	# Disable colorized output if requested
	elif [ "$1" == "-n" -o "$1" == "--nocolor" ]; then
		COLORIZE=0

	# Set platform if specified
	elif [ "$1" == "-p" ]; then
		shift
		PLATFORM=$1

	# Enable strict report mode
	elif [ "$1" == "-r" -o "$1" == "--report" ]; then
		REPORTMODE=1

	# Enable filtered/personalized report mode
	elif [ "$1" == "-R" -o "$1" == "--report-filtered" ]; then
		REPORTMODE=2

	elif [ "$1" == "-t" ]; then
		shift
		THREADS=${1}
		is_int "$THREADS"
		if [ $? -ne 0 ] || [ $THREADS -lt 1 ]; then
			echo "Error: You must specify the number of threads (-t N)"
			exit 1
		elif [ ${THREADS} -gt $(nproc) ]; then
			echo "You specified $THREADS threads, but you only have $(nproc) total threads."
			echo "Please specify no more than $(nproc) threads."
			exit 1
		fi

	# Enable verbose output
	elif [ "${1}" == "-v" -a ${VERBOSE} -eq 0 ]; then
		VERBOSE=1

	# Enable debug output
	elif [ "${1}" == "-V" ]; then
		DEBUGMODE=1

	# Consider everything else a game file
	else
		FILES[${#FILES[*]}]=$1
	fi
	shift
done

# Initialize working directory
TEMPDIR=$(mktemp -d -p "${TEMPROOT}" "${TITLE%.*}.XXXXX")
ERRORSFILE="${TEMPDIR}/errors.txt"
REPORTFILE="${TEMPDIR}/report.txt"

# Begin processing game files
debug "Beginning main file loop"

for INFILE in "${FILES[@]}"; do
	# Verify input exists
	if ! FILE=$(realpath -e "${INFILE}" 2>/dev/null); then
		# If not, warn but continue with other files
		cwarn "Error: '${INFILE}' does not exist or is not a file or directory"
		continue
	fi

	# Initiate parellel support
	while [ $(jobs | wc -l) -ge ${THREADS} ]; do
		sleep 0.1
		jobs >/dev/null
	done

	# If input is directory, confirm group mode and initialize file list
	if [ -d "${FILE}" ]; then
		if [ ${GROUPMODE} -eq 1 ]; then
			debug "Initiate group verify for '${INFILE}'"
			verify-group "dir" "${FILE}" &
		else
			cwarn "Error: group mode must be enabled to process directory of related files"
			exit 1
		fi
	# No special handling required for files
	else
		debug "Initiate verify for '${INFILE}'"
		verify &
	fi
done


# Wait for any remaining processes to finish before exiting
wait
debug "Completed all processing"

# Report on mismatched names if requested
LOADRESULT=0
if [ ${REPORTMODE} -eq 2 ]; then
	debug "Loading custom report filters"
	report_load_filters
	LOADRESULT=$?
fi

if [ ${REPORTMODE} -ne 0 -a -f "${REPORTFILE}" -a "${LOADRESULT}" -eq 0 ]; then
	debug "Preparing mismatched names report"

	MISMATCHES="${TEMPDIR}/mismatches.txt"

	# Sort by original basename so mismatch and verbose-unverified output is
	# alphabetized while avoiding command substitution of the full report.
	LC_ALL=C sort -f -t'|' -k1,1 "${REPORTFILE}" | report_process >"${MISMATCHES}"

	if [ -s "${MISMATCHES}" ]; then
		cinfo "Mismatched names report:"
		cat "${MISMATCHES}"
	fi

	(( ! ${DEBUGMODE} )) && rm -f "${MISMATCHES}" "${REPORTFILE}"
	debug "Mismatch names report complete"
fi

# Check for errors from child processes
if [ -f "${ERRORSFILE}" ]; then
	cwarn "Warning: Errors were detected when verifying the following games:"
	cat "${ERRORSFILE}"
	if (( ${DEBUGMODE} )); then
		echo -e "\nPreserving '${TEMPDIR}' for debugging"
	else
		rm -rf "${TEMPDIR}"
	fi
	debug "Cleanup complete"
	exit 1
fi

if (( ${DEBUGMODE} )); then
	echo -e "\nPreserving '${TEMPDIR}' for debugging"
else
	rm -rf "${TEMPDIR}"
fi

debug "Cleanup complete"
exit 0
