#!/usr/bin/env bash

# -----------------------------------------------------------------------------
#
#	App Title:		read-game-id.sh
#	Author:			Jared Breland <jbreland@legroom.net>
#	Homepage:		http://www.legroom.net/software
#
#	Script Function:
#		Read game ID + metadata for PlayStation 1/2/3/4 and Xbox 360 games
#		Game ID (only) also supported for PlayStation 3, 4 PSN packages
#
#	Instructions:
#		Run 'read-game-id.sh -h' for help
#
#	Requirements:
#		The following programs must be installed and available:
#
#	Optional Requirements:
#		The following programs may be installed to read additional metadata:
#		abgx360 (https://abgx360.xecuter.com/)
#			used to parse Xbox 360 ISOs for additional metadata
#		CDEmu (https://cdemu.sourceforge.io/)
#			used to virtually read CHD images, if chdman not used
#		chdman (MAME) (https://docs.mamedev.org/tools/chdman.html)
#			used to decompress CHD images, if CDEmu not used
#		ctrtool (https://github.com/3DSGuy/Project_CTR)
#			used to parse 3DS ROMs for additional metadata
#		dolphin-tool (Dolphin) )https://cdemu.sourceforge.io/)
#			used to parse GameCube and Wii ISOs for additional metadata
#		gawk (GNU awk) (https://www.gnu.org/software/gawk/)
#			used for some complex data transformations
#		JWUDTool (https://github.com/Maschell/JWUDTool)
#			used to parse Wii U images for additional metadata
#		maxcso (https://github.com/unknownbrackets/maxcso)
#			used to decompress non-standard PS2/PSP CSO images with -C
#			most CSO images	should be natively supported
#		nstool (https://github.com/jakcron/nstool)
#			used parse Switch XCI and NSP files for additional metadata
#
#	Insutructions
#		Run read-game-id.sh -h for usage info
#
#	Release History:
#		2.0 (09/01/2026):
#			COMPLETE SCRIPT REWRITE - structure, logic, data handling all new;
#				the following notes just detail new features
#			Add support for the following new platforms:
#				3DO, Dreamcast, Neo Geo CD, PC-FX, PS5, TG/PCE-CD, Wii, Wii U,
#				and Xbox ISOs
#				2600, 5200, 7800, Lynx, Jaguar, WonderSwan, Intellivision,
#				TG16/PCE, SGX, NES+FDS, SNES, N64, GB, GBC, GBA, DS, 3DS,
#				Switch (XCI/NSP), SMS,	GEN, GG, NGP+NGPC, VB, and
#				Vita (PSV) ROMs
#				3DS/Wii U TMD, Xbox XBE, X360 STFS, and Wii WAD files
#			Add native read of CSO, NSZ, RVZ, and WUX images - no need for full
#				decompression; Note: non-standard CSO images may not be
#				compatible; use -C to disable this feature
#			Add support for transparent decompression for many common archive
#				and compression formats; will decompress and read from temp copy
#			Add generic Unkown / PC fallback for ISOs - print useful PVD info
#			Add direct CHD reading via cdemu (-c) - no need to first decompress;
#				Note: Dreamcast and TG/PCE CHD images cannot be supported by -c
#			Add support for reading from physical devices (e.g., /dev/cdrom)
#
#		1.3.1 (09/13/2021):
#			Add -q option to only output ID
#			Change (again) sfo reader to utilize actively maintained utility
#			Update PS4 logic to read ID from PVD, which is MUCH quicker
#				-m still requires slower method of extracting app.pkg
#				Note: this will show the Disc ID, which may be different from
#				the Game ID for multi-title discs like Life Is Strange: Before
#				the Storm; use -m if in doubt to see the Game ID
#
#		1.3 (08/21/2021):
#			Add support for Saturn and Sega CD images
#			Add support for PS4 discs with multiple or differently named
#				app.pkg files, such as Final Fantasy VII Remake
#			Add converted ID for PS1/2 games (e.g., SLUS_200.35 -> SLUS-20035)
#			Fixed a couple bugs in PS2 ISO handling
#			Fixed bug in package ver display for Vita games
#			Change from read-sfo to sfo for better internationalization support
#
#		1.2.1 (12/15/2018):
#			Add support for Xbox 360 ISOs and XEX files
#
#		1.2 (08/15/2018):
#			Add -d option to specify temp directory
#			Add support for PS4 discs with multiple game packages, such as
#				Life Is Strange: Before the Storm
#
#		1.1 (08/12/2018):
#			Add support for PS3, Vita, and PS4 PSN packages
#			Add support for PSP ISOs and CSOs
#			Add support for Vita NoNpDrm zip packages
#			Fix bug with PS4 attribute 2 metadata
#
#		1.0 (08/11/2018):
#			Initial release
#
# -----------------------------------------------------------------------------

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

# Setup environment - declare global variables
THREADS=$(nproc)
TEMPROOT="${TMPDIR:-/tmp}"
VERBOSE=0
CHDMODE=0
DEBUGMODE=0
COLORIZE=1
QUIET=0

# Platform-specific globals
CDEMUDONE=0
CDEMUSECS=5
CDEMUTICKS=$((CDEMUSECS * 10))
CSO_MODE=1
CSO_BLOCK_SIZE=0
CSO_SHIFT=0
CSO_CACHED_BLK=-1
CSO_CACHED_HEX=""
declare -a FILES METADATA_ORDER CDEMU_POOL PS4_PKG_OFFSETS
declare -A PVD CDEMU_VIRT_DEVS ROOT_ENTRIES

# Requred to extract extended metadata for Wii U and can't be auto-located
# Wii U key directory (also set with -k)
WIIU_KEYDIR="${HOME}/data/games/Nintendo/Wii U/Backup"
# Wii U JWUDTool JAR path (also set with -j)
JWUDTOOL="${HOME}/data/bin/JWUDTool.jar"
# Required to extract extended metadata for Switch; default loc for nstool
SWITCHKEYS="${HOME}/.switch/prod.keys"

# Enforce C locale for byte-exact binary parsing - avoid miscounts in UTF-8 
export LC_ALL=C


#################################
# General script helper functions
#################################

# Function to display correct usage information
warning() {
	printf '%s' "Usage: ${PROG} [-C] [-d dir] [-h] [-n] [-q] [-t N] [-v] [-V]
       ${PROG//?/ } game.iso <game2.pkg ...>
Output game ID (and optionally other info) from game ROMs and ISOs

Options:
  -c       enable direct CHD reading via cdemu
  -C       disable native CSO read - decompress with maxcso instead
  -d dir   specify temporary working directory; defaults to /tmp
  -h       display this help message
  -k dir   specify directory for Wii U game keys (defaults to game dir)
  -j path  specify JWUDTool JAR path for extended Wii U attributes
  -n       no color output
  -q       quiet mode - output ID only
  -t N     specify number of concurrent threads; defaults to ${THREADS}
  -V       extra verbose debugging output
"
	exit 1
}

# Print a colorized message
#	$1 = color escape sequence
#	$2 = output stream (1 = stdout, 2 = stderr)
#	$3..$N = message
cprint() {
	local COLOR="$1" FD="$2"
	local RESET=$'\033[00;00m' MESSAGE
	shift 2

	(( ! COLORIZE )) && COLOR="" && RESET=""
	MESSAGE="$*"
	printf '%b\n' "${COLOR}${MESSAGE}${RESET}" >&"$FD"
}
cinfo() {
	cprint $'\033[01;33m' 1 "$@"	# bold yellow
}
cwarn() {
	cprint $'\033[01;31m' 2 "$@"	# bold red
}
cdiag() {
	cprint $'\033[01;34m' 2 "$@"	# bold blue
}

# Debug output and performance metrics
#	$1..$N = message
debug() {
	if (( DEBUGMODE )); then
		local CALLER="${FUNCNAME[1]:-main}" DATE EPOCH ELAPSED MIN SEC MSEC
		local TELAPSED TMIN TSEC TMSEC MESSAGE
		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 ))

		MESSAGE="[${CALLER}] ${*}\n${DATE} / ${MIN}m ${SEC}s ${MSEC}ms elapsed / ${TMIN}m ${TSEC}s ${TMSEC}ms total\n"
		cdiag "${MESSAGE}"

		DEBUGELAPSE=${EPOCH}
	fi
}	

# Verify required binaries
#	$@ = list of binaries
bincheck() {
	local BIN
	for BIN in "${@}"; do
		command -v "${BIN}" >/dev/null 2>&1 || { cwarn "Error: required binary ${BIN} cannot be found"; return 1; }
	done
}

# Verify optional binaries
#	$@ = list of binaries
optbincheck() {
	local BIN
	for BIN in "${@}"; do
		command -v "${BIN}" >/dev/null 2>&1 || return 1
	done
}

# Verify required support files
#	$@ = list of support files
filecheck() {
	local SFILE
	for SFILE in "${@}"; do
		[[ -f "${SFILE}" ]] || { cwarn "Error: required support file ${SFILE} cannot be found"; return 1; }
	done
}

# Verify optional support files
#	$@ = list of support files
optfilecheck() {
	local SFILE
	for SFILE in "${@}"; do
		[[ -f "${SFILE}" ]] || return 1
	done
}

# Universal unpack function (lite version) to extract files from any format
#	$1 = supported archive
unplite() {
	local SOURCE="$1"
	local NAME U_EXT EXT_LAST EXT_BODY BASE RC
	# Determine file type and base filename
	NAME="${SOURCE##*/}"
	EXT_LAST="${NAME##*.}"
	EXT_LAST="${EXT_LAST,,}"
	EXT_BODY="${NAME%.*}"

	# Create extract directory
	EXTRACTDIR=$(mktemp -d -p "${TEMPDIR}" "extracted.XXXXX")

	case "$NAME" in
		*.tar.*)
			U_EXT="tar.${EXT_LAST}"
			BASE="${EXTRACTDIR}/${NAME%.tar.*}"
			;;
		*.nkit.*)
			U_EXT="nkit.${EXT_LAST}"
			BASE="${EXTRACTDIR}/${NAME%.nkit.*}"
			;;
		*)
			U_EXT="$EXT_LAST"
			BASE=${EXTRACTDIR}/"$EXT_BODY"
			;;
	esac

	SOURCE="$(realpath "$SOURCE")"

	#debug "SOURCE = ${SOURCE}\nEXTRACTDIR = ${EXTRACTDIR}\nBASE = ${BASE}"

	# Run from extract directory to simplify output file handling
	if ! cd "$EXTRACTDIR" 2>/dev/null; then
		debug "Error creating and changing to EXTRACTDIR '${EXTRACTDIR}'"
		exit 2
	fi

	case "$U_EXT" in
		7z)
			bincheck 7z || exit 1
			7z x "$SOURCE"
			RC="$?" ;;
		bz2)
			bincheck bzip2 || exit 1
			bzip2 -d -c "$SOURCE" >"$BASE" ;;
		chd)
			bincheck chdman || exit 1
			# Determine if disc needs to be extracted as CD or DVD
			CHDTYPE=$(hexdump -e '4 "%_p"' -s 0x7c -n 4 "$SOURCE")
			if [[ "$CHDTYPE" == "DVD " ]]; then
				ARGS=(extractdvd)
				OUTEXT=iso
			else
				ARGS=(extractcd -sb)
				OUTEXT=cue
			fi
			chdman "${ARGS[@]}" -i "$SOURCE" -o "${BASE##*/}.${OUTEXT}" 2>/dev/null
			RC="$?" ;;
		cso)
			if (( CSO_MODE )); then
				debug "CSO native mode - skipping maxcso decompress"
				cd - >/dev/null
				rmdir "${EXTRACTDIR}" 2>/dev/null
				return 0
			else
				bincheck maxcso || exit 1
				maxcso --decompress --quiet --threads="${THREADS}" "$SOURCE" -o "$(basename "${1%.*}.iso")"
				RC="$?"
			fi ;;
		gz)
			bincheck gzip || exit 1
			gzip -d -c "$SOURCE" >"$BASE"
			RC="$?" ;;
		lzma)
			bincheck lzma || exit 1
			lzma -d -k "$SOURCE"
			RC="$?" ;;
		nbz)
			bincheck nibconv || exit 1
			nibconv "$SOURCE" "${BASE}.nib"
			RC="$?" ;;
		nkit.iso|nkit.gcz)
			bincheck ConvertToISO || exit 1
			ConvertToISO "$SOURCE"
			RC="$?"
			mv "${1/${U_EXT}/iso}" "${BASE}.iso" ;;
		rar)
			bincheck unrar || exit 1
			unrar x "$SOURCE"
			RC="$?" ;;
		tar|tgz|tbz2|tar.gz|tar.bz2)
			tar xf "$SOURCE"
			RC="$?" ;;
		txz|tlz|tlzma|tzst|tar.xz|tar.lzma|tar.zst)
			tar xf "$SOURCE"
			RC="$?" ;;
		xz)
			bincheck xz || exit 1
			xz -d -T0 -c "$SOURCE" >"$BASE"
			RC="$?" ;;
		zst)
			bincheck zstd || exit 1
			zstd -q -d "$SOURCE" -o "$BASE"
			RC="$?" ;;
		zip|jar|war|xpi|cbz|vpk)
			bincheck unzip || exit 1
			unzip "$SOURCE"
			RC="$?" ;;
		*)
			debug "'${U_EXT}' not a supported archive extension - continue normal procesing"
			cd - >/dev/null
			rmdir "${EXTRACTDIR}"
			return 0 ;;
	esac

	# Handle results based on tool RC code
	case "$RC" in
		# If successful unpack, check unpacked files
		0)
			debug "Successful extraction reported"
			local -a EX_FILES
			local F F_EXT CUE_FILE CUE_COUNT=0 BIN_COUNT=0 OTHER_COUNT=0

			# Set gobal EXTRACTED flag for cleanup
			EXTRACTED=1

			shopt -s nullglob
			EX_FILES=("$EXTRACTDIR"/*)
			shopt -u nullglob

			# If no files found despite successful report, assume error
			if (( ${#EX_FILES[@]} == 0 )); then
				debug "Unpack reported successful, but no files found."
				cwarn "Error: File unpack reported successful, but no files found."
				cleanup_env "$EXTRACTDIR"
				cd - >/dev/null
				return 2

			# If one file found, proceed with reading that file
			elif (( ${#EX_FILES[@]} == 1 )); then
				# If processing one file, update FILE and EXT for read_media
				FILE="${EX_FILES[0]}"
				EXT="${FILE##*.}"; EXT="${EXT,,}"
				debug "Found single extracted file: '$FILE'"
				return
			
			# If multiple files found, we only support a single object
			# Check if BIN/CUE image, otherwise abort
			else
				for F in "${EX_FILES[@]}"; do
					F_EXT="${F##*.}"; F_EXT="${F_EXT,,}"
					case "$F_EXT" in
						cue)
							CUE_FILE="$F"
							(( CUE_COUNT++ ))
							;;
						bin)
							(( BIN_COUNT++ ))
							;;
						*)
							(( OTHER_COUNT++ ))
							;;
					esac
				done

				# If 1 CUE file and 1 or more BINs, treat as BIN/CUE image
				if (( CUE_COUNT == 1 && BIN_COUNT > 0 && OTHER_COUNT == 0 )); then
					# If processing BIN/CUE, update FILE and EXT for read_media
					FILE="$CUE_FILE"
					EXT="${FILE##*.}"; EXT="${EXT,,}"
					debug "Found extracted BIN/CUE image: using CUE '$FILE'"
					return

				else
					debug "Found multiple extracted files, aborting."
					cleanup_env "$EXTRACTDIR"
					cwarn "Error: Multiple files extracted; only multi-file BIN/CUE images are supported."
					cd - >/dev/null
					return 2
				fi
			fi
			;;

		# Assume anything else is a failure
		*)
			debug "Non-zero return code ${RC} detected for unpack - assuming failure"
			cwarn "Error: ${FILE} could not be decompressed or extracted (${RC})"
			cd - >/dev/null
			cleanup_env "${EXTRACTDIR}"
			return 2
			;;
	esac
}

# Verify variable is an integer
#	$1 = arg to test
is_int() {
	[[ "$1" =~ ^-?[0-9]+$ ]]
}

# Check whether directory contains files or subdirectories
#	$1 = directory path
# Returns 0 if empty, 1 if non-empty
is_empty() {
	local DIR="$1"

	# Verify the target is actually a directory
	[[ -d "$DIR" ]] || return 1

	# Collect all non-hidden entries in the directory
	shopt -s nullglob
	local -a ENTRIES=("$DIR"/*)
	shopt -u nullglob

	# If the array has zero entries, the directory is empty
	[[ ${#ENTRIES[@]} -eq 0 ]]
}


# Delete temporary files dir if not in debug mode
#	$1 = temp directory
cleanup_dir() {
	local DIR="$1"
	if (( DEBUGMODE )) && ! is_empty "$DIR"; then
		debug "Temporary files retained in ${DIR}"
	else
		rm -rf "$DIR"
	fi
}

# Delete extracted files upon reading metadata
cleanup_extracted() {
	if (( ! DEBUGMODE )); then
		# Confirm extract dir is within TEMPDIR as a safety catch
		if [[ "${EXTRACTDIR}" == "${TEMPIR}"* ]]; then
			rm -rf "$EXTRACTDIR" 
		fi
	fi
}

# Used by exit trap - prevent executing when exiting subshell
# Kills any remaining workers, removes cdemu pool, and cleans temp dir
cleanup_env() {
	local PID

	# Guard against subshell exits to prevent premature cleanup
	# Guard against multiple invocations running concurrently
	(( BASH_SUBSHELL > 0 )) && return
	(( CDEMUDONE )) && return
	CDEMUDONE=1

	# Kill any remaining workers and wait for them to be reaped
	while IFS= read -r PID; do
		[[ -n "$PID" ]] && kill "$PID" 2>/dev/null
	done < <(jobs -p 2>/dev/null)
	wait

	# Remove cdemu device pool
	cdemu_cleanup_pool

	# Remove temporary directory
	cleanup_dir "$TEMPDIR"
}


########################
# Data reading functions
########################

# Trim leading/trailing whitespace from a variable in-place
#	$1 = variable name (nameref)
# Returns trimmed value via nameref
trim_var() {
	local -n NREF="$1"

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

# Read and return raw bytes from file for given offset and length
#	$1 = file
#	$2 = offset
#	$3 = length
# Returns raw bytes
read_bytes() {
	local SOURCE="$1" OFF="$2" LEN="$3"

	# If reading CSO, decompress blocks on the fly
	if (( CSO_BLOCK_SIZE )); then
		cso_read_bytes "$SOURCE" "$OFF" "$LEN"
		return
	fi

	# Byte-accurate skip/count with large block transfers
	dd if="$SOURCE" bs=64K iflag=skip_bytes,count_bytes skip="$OFF" count="$LEN" status=none 2>/dev/null
}

# Return bytes as hex
#	$1 = file
#	$2 = offset
#	$3 = length
# Returns bytes as hex
read_hex() {
	local SOURCE="$1" OFF="$2" LEN="$3"

	# If reading CSO, decompress blocks on the fly
	if (( CSO_BLOCK_SIZE )); then
		cso_read_hex "$SOURCE" "$OFF" "$LEN"
		return
	fi

	# Convert raw bytes to two-digit hex values and collapse whitespace
	xxd -p -s "$OFF" -l "$LEN" "$SOURCE" 2>/dev/null | tr -d '\n'
}

# Extract a little-endian 64-bit integer from a hex string at byte offset
#	$1 = hex string
#	$2 = byte offset
#	$3 = variable name (nameref)
# Returns reversed hex string via nameref (leading zeros preserved)
hex_le64() {
	local HEX="$1" OFF="$2"
	local -n NREF="$3"

	local H="${HEX:$((OFF * 2)):16}"
	while (( ${#H} < 16 )); do
		H="0$H"
	done

	NREF="${H:14:2}${H:12:2}${H:10:2}${H:8:2}${H:6:2}${H:4:2}${H:2:2}${H:0:2}"
}

# Extract a little-endian 32-bit integer from a hex string at byte offset
#	$1 = hex string
#	$2 = byte offset
#	$3 = variable name (nameref)
# Returns int via nmameref
hex_le32() {
	local HEX="$1" OFF="$2"
	local -n NREF="$3"

	local H="${HEX:$((OFF * 2)):8}"
	while (( ${#H} < 8 )); do
		H="0$H"
	done

	NREF=$((16#${H:6:2}${H:4:2}${H:2:2}${H:0:2}))
}

# Extract a little-endian 16-bit integer from a hex string at byte offset
#	$1 = hex string
#	$2 = byte offset
#	$3 = output variable name (nameref)
# Returns int via nameref
hex_le16() {
	local HEX="$1" OFF="$2"
	local -n NREF="$3"

	local H="${HEX:$((OFF * 2)):4}"
	while (( ${#H} < 4 )); do
		H="0$H"
	done

	NREF=$((16#${H:2:2}${H:0:2}))
}

# Extract a big-endian 32-bit integer from a hex string at byte offset
#	$1 = hex string
#	$2 = byte offset
#	$3 = output variable name (nameref)
# Returns int via nameref
hex_be32() {
	local HEX="$1" OFF="$2"
	local -n NREF="$3"

	local H="${HEX:$((OFF * 2)):8}"
	while (( ${#H} < 8 )); do
		H="0$H"
	done

	NREF=$((16#$H))
}

# Extract a substring from a hex string by byte offset and byte length
#	$1 = hex string
#	$2 = byte offset
#	$3 = byte length
#	$4 = output variable name (nameref)
# Returns hex substr via nameref
hex_substr() {
	local HEX="$1" OFF="$2" LEN="$3"
	local -n NREF="$4"

	NREF="${HEX:$((OFF * 2)):$((LEN * 2))}"
}

# Decode hex string to ASCII, stripping NULs
#	$1 = hex string
#	$2 = output variable name (nameref)
# Returns string via nameref
hex_to_ascii() {
	local HEX="$1"
	local -n NREF="$2"
	local I BYTE C

	NREF=""
	for (( I=0; I<${#HEX}; I+=2 )); do
		BYTE="${HEX:I:2}"
		# NUL bytes (00) are skipped - bash variables cannot hold NUL
		[[ "$BYTE" == "00" ]] && continue
		printf -v C '%b' "\\x$BYTE"
		NREF+="$C"
	done
}

# Decode UTF-16BE hex string to UTF-8
#	$1 = hex string
#	$2 = output variable name (nameref)
# Returns string via nameref
hex_to_ucs2be() {
	local HEX="$1"
	local -n NREF="$2"
	local I CODE

	NREF=""
	for (( I=0; I<${#HEX}; I+=4 )); do
		CODE="${HEX:I:4}"
		# NUL code units (0000) are skipped
		[[ "$CODE" == "0000" ]] && continue
		printf -v NREF '%s\\u%s' "$NREF" "$CODE"
	done

	printf -v NREF '%b' "$NREF"
}

# Decode UTF-16LE hex string to UTF-8
#	$1 = hex string
#	$2 = output variable name (nameref)
# Returns string via nameref
hex_to_ucs2le() {
	local HEX="$1"
	local -n NREF="$2"
	local I CODE

	NREF=""
	for (( I=0; I<${#HEX}; I+=4 )); do
		# Swap LE byte order to BE for the \uXXXX escape
		CODE="${HEX:I+2:2}${HEX:I:2}"
		# NUL code units (0000) are skipped
		[[ "$CODE" == "0000" ]] && continue
		printf -v NREF '%s\\u%s' "$NREF" "$CODE"
	done

	printf -v NREF '%b' "$NREF"
}

# Unescape XML-encoded entities back to raw characters
#	$1 = string to unescape
# Returns unescaped string
xml_unescape() {
	local VALUE="$1" PREV

	while [[ "$VALUE" != "$PREV" ]]; do
		PREV="$VALUE"
		VALUE="$(printf '%s' "$VALUE" | sed \
			-e 's/&apos;/'"'"'/g' \
			-e 's/&quot;/"/g' \
			-e 's/&lt;/</g' \
			-e 's/&gt;/>/g' \
			-e 's/&amp;/\&/g')"
	done
	printf '%s' "$VALUE"
}

# Extract the text content of an XML tag from a blob
#	$1 = XML blob (string)
#	$2 = tag name
# Returns the tag's text content, or empty if tag not found
xml_value() {
	local XML="$1" TAG="$2"
	local VALUE

	VALUE=$(printf '%s' "$XML" | sed -n 's/^.*<'"$TAG"' .*>\([^<]*\)<.*$/\1/p')

	printf '%s' "$VALUE"
}


###############################
# Optical disc helper functions
###############################

# Identify corresponding BIN file from CUE sheet
#	$1 = file
# Returns path to bin file
resolve_cue_to_bin() {
	local SOURCE="$1"
	local BINFILE SOURCEPATH SOURCEDIR
	SOURCEPATH="$(realpath "$SOURCE")"
	SOURCEDIR="${SOURCEPATH%/*}"

	# Get first non-audio file name from CUE
	BINFILE=$(awk '
		/^[[:space:]]*FILE[[:space:]]+/ {
			match($0, /"([^"]+)"/, arr)
			file = arr[1]
		}
		/^[[:space:]]*TRACK[[:space:]]+/ && $3 !~ /^(AUDIO|CDG)$/ {
			print file
			exit
		}
	' "$SOURCE")

	# Prepend path to BINFILE to handle testing in EXTRACTDIR
	[[ ! "$BINFILE" =~ / ]] && BINFILE="${SOURCEDIR}/${BINFILE}"

	if [[ -f "$BINFILE" ]]; then
		printf "%s" "$BINFILE"
	else
		cwarn "Error: data track for CUE file '$SOURCE' not found"
		return 1
	fi
}

# Parse a CUE sheet and output the first MODE1 track's LBA and BIN path
#	$1 = CUE file
# Returns "<index01_lba> <bin_path>"
resolve_cue_to_data_track() {
	local SOURCE="$1"
	local BINFILE CUR_FILE CUR_TRACK_MODE INDEX_LBA M S F LINE
	local SOURCEPATH SOURCEDIR
	SOURCEPATH="$(realpath "$SOURCE")"
	SOURCEDIR="${SOURCEPATH%/*}"

	# Walk FILE/TRACK/INDEX - capture the first MODE1 track with INDEX 01
	while IFS= read -r LINE; do
		# Strip trailing CR for CRLF cue files
		LINE="${LINE%$'\r'}"

		# FILE "name" BINARY - remember the current binary (strip quotes)
		if [[ "$LINE" =~ ^[[:space:]]*FILE[[:space:]]+\"([^\"]+)\" ]]; then
			CUR_FILE="${BASH_REMATCH[1]}"
			continue
		fi

		# TRACK NN <MODE> - track the type of the current track
		if [[ "$LINE" =~ ^[[:space:]]*TRACK[[:space:]]+[0-9]+[[:space:]]+([A-Za-z0-9/]+) ]]; then
			CUR_TRACK_MODE="${BASH_REMATCH[1]}"
			continue
		fi

		# INDEX 01 MM:SS:FF - on the first MODE1 track we care about
		if [[ -n "$CUR_TRACK_MODE" && "$CUR_TRACK_MODE" == MODE1/* ]] && \
		   [[ "$LINE" =~ ^[[:space:]]*INDEX[[:space:]]+01[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2}) ]]; then
			M="${BASH_REMATCH[1]}" S="${BASH_REMATCH[2]}" F="${BASH_REMATCH[3]}"
			INDEX_LBA=$(( 10#$M * 60 * 75 + 10#$S * 75 + 10#$F ))
			BINFILE="$CUR_FILE"
			break
		fi
	done <"$SOURCE"

	[[ -z "$BINFILE" || -z "$INDEX_LBA" ]] && return 1

	# Prepend path to BINFILE to handle testing in EXTRACTDIR
	[[ "$BINFILE" != /* ]] && BINFILE="${SOURCEDIR}/${BINFILE}"
	[[ -f "$BINFILE" ]] || return 1

	printf "%s" "${INDEX_LBA} ${BINFILE}"
}

# Convert an ISO 9660 LBA to an absolute byte offset, accounting for the physical sector size and any pre-data overhead
#	$1 = LBA
lba_to_offset() {
	local LBA="$1"
	printf "%s" $((LBA * SECTORSIZE + DATAOFF))
}

# Detect raw BIN sector layout and data offset for BIN vs. CUE images
#	$1 = file
# Returns sector size and data offset
detect_sector_size() {
	local SOURCE="$1"
	local SYNC MODE
	SYNC=$(read_hex "$SOURCE" 0 12)

	if [[ "${SYNC,,}" != "00ffffffffffffffffffff00" ]]; then
		SECTORSIZE=2048
		DATAOFF=0
		return 0
	fi

	SECTORSIZE=2352
	MODE=$(read_hex "$SOURCE" 15 1)

	case "$MODE" in
		01) DATAOFF=16 ;;
		02) DATAOFF=24 ;;
		*)  DATAOFF=16 ;;
	esac
}

# Initialize CSO globals for native direct-read access
#	$1 = cso source file
# Returns 0 on success, 1 on invalid header
cso_init() {
	local SOURCE="$1"
	local MAGIC HEX_HDR UNCOMP_LO UNCOMP_HI BLOCK_SIZE_RAW IDX_END RAW0
	local RAW0_HEX S SAVED_CSO_BLOCK_SIZE NUM_BLOCKS UNCOMP_SIZE

	# Save CSO_BLOCK_SIZE to prevent read_hex dispatch during init
	SAVED_CSO_BLOCK_SIZE=$CSO_BLOCK_SIZE
	CSO_BLOCK_SIZE=0

	# Validate CISO magic (4 bytes at offset 0)
	MAGIC=$(read_hex "$SOURCE" 0 4)
	if [[ "$MAGIC" != "4349534f" ]]; then
		CSO_BLOCK_SIZE=$SAVED_CSO_BLOCK_SIZE
		debug "CSO: invalid magic '${MAGIC}'"
		return 1
	fi

	# Read header fields
	HEX_HDR=$(read_hex "$SOURCE" 0x08 14)
	hex_le32 "$HEX_HDR" 0 UNCOMP_LO
	hex_le32 "$HEX_HDR" 4 UNCOMP_HI
	hex_le32 "$HEX_HDR" 8 BLOCK_SIZE_RAW

	# Read first index entry at 0x18 (raw data, not CSO-compressed)
	RAW0_HEX=$(read_hex "$SOURCE" 0x18 4)
	hex_le32 "$RAW0_HEX" 0 RAW0

	# Now set globals - all raw reads are complete
	CSO_BLOCK_SIZE=$BLOCK_SIZE_RAW
	UNCOMP_SIZE=$(( UNCOMP_HI * 4294967296 + UNCOMP_LO ))
	NUM_BLOCKS=$(( UNCOMP_SIZE / CSO_BLOCK_SIZE ))

	# Auto-detect shift: smallest 1..4 where (raw0>>1)<<shift >= index_end
	IDX_END=$((0x18 + (NUM_BLOCKS + 1) * 4))

	CSO_SHIFT=0
	for (( S=1; S<=4; S++ )); do
		if (( (RAW0 >> 1) << S >= IDX_END )); then
			CSO_SHIFT=$S
			break
		fi
	done
	if (( CSO_SHIFT == 0 )); then
		CSO_BLOCK_SIZE=$SAVED_CSO_BLOCK_SIZE
		debug "CSO: shift auto-detection failed (raw0=${RAW0}, idx_end=${IDX_END})"
		return 1
	fi

	# Reset cache
	CSO_CACHED_BLK=-1
	CSO_CACHED_HEX=""

	debug "CSO: block_size=${CSO_BLOCK_SIZE} shift=${CSO_SHIFT} num_blocks=${NUM_BLOCKS}"
	return 0
}

# Decompress a single CSO block and return its contents as hex
# Reads index entries and compressed data directly via xxd
#	$1 = source .cso file
#	$2 = block number
# Returns block contents as hex string
cso_read_block() {
	local SOURCE="$1" BLK="$2"
	local ENTRY_RAW ENTRY NEXT OFFSET NEXT_OFFSET FLAG SKIP DELTA IDX_OFF
	local COMPRESSED_HEX HEX_BLOCK

	# Uses a single-block cache (CSO_CACHED_BLK/CSO_CACHED_HEX) to avoid
	# re-decompressing the same block when consecutive reads hit it

	# Cache hit
	if (( BLK == CSO_CACHED_BLK )) && [[ -n "$CSO_CACHED_HEX" ]]; then
		printf '%s' "$CSO_CACHED_HEX"
		return
	fi

	# Read this block's index entry and the next entry directly via xxd
	IDX_OFF=$((0x18 + BLK * 4))
	ENTRY_RAW=$(xxd -p -s "$IDX_OFF" -l 8 "$SOURCE" 2>/dev/null | tr -d '\n')
	hex_le32 "$ENTRY_RAW" 0 ENTRY
	hex_le32 "$ENTRY_RAW" 4 NEXT

	# Decode offset, flag, skip using the unified formula:
	OFFSET=$(( (ENTRY >> 1) << CSO_SHIFT ))
	FLAG=$(( ENTRY & 1 ))
	SKIP=$(( FLAG * CSO_SHIFT ))
	NEXT_OFFSET=$(( (NEXT >> 1) << CSO_SHIFT ))

	DELTA=$(( NEXT_OFFSET - OFFSET - SKIP ))

	if (( DELTA >= CSO_BLOCK_SIZE )); then
		# Stored (uncompressed) block - read directly via xxd
		CSO_CACHED_HEX=$(xxd -p -s $((OFFSET + SKIP)) -l "$CSO_BLOCK_SIZE" "$SOURCE" 2>/dev/null | tr -d '\n')
	else
		# Compressed block - prepend gzip header and decompress
		COMPRESSED_HEX=$(xxd -p -s $((OFFSET + SKIP)) -l "$DELTA" "$SOURCE" 2>/dev/null | tr -d '\n')
		# Convert hex to binary, prepend gzip header (required for gzip -d),
		# decompress, re-encode to hex. NUL bytes in the header can't be
		# stored in a bash variable so printf interprets the escapes directly.
		HEX_BLOCK=$(printf '%s' "$COMPRESSED_HEX" | xxd -r -p \
			| { printf '\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff'; cat; } \
			| gzip -dc 2>/dev/null | xxd -p | tr -d '\n')

		# Zero-pad to block_size (deflate streams may omit trailing fill bytes)
		local PAD=$(( CSO_BLOCK_SIZE * 2 - ${#HEX_BLOCK} ))
		if (( PAD > 0 )); then
			HEX_BLOCK="${HEX_BLOCK}$(printf '%*s' "$((PAD / 2))" '' | tr ' ' '0')"
		fi
		CSO_CACHED_HEX="$HEX_BLOCK"
	fi

	CSO_CACHED_BLK=$BLK

	# Trim/pad to exactly block_size bytes
	printf '%s' "$CSO_CACHED_HEX"
}

# CSO-aware replacement for read_hex: translates virtual offset + length to
# the containing block(s), decompresses, and returns hex
#	$1 = source .cso file
#	$2 = virtual offset (within uncompressed image)
#	$3 = length in bytes
# Returns hex string
cso_read_hex() {
	local SOURCE="$1" VOFF="$2" LEN="$3"
	local BLK BLOCK_OFF IN_BLOCK REMAINING RESULT HEX_BLOCK

	BLK=$(( VOFF / CSO_BLOCK_SIZE ))
	BLOCK_OFF=$(( BLK * CSO_BLOCK_SIZE ))
	IN_BLOCK=$(( VOFF - BLOCK_OFF ))
	REMAINING=$LEN
	RESULT=""

	while (( REMAINING > 0 )); do
		HEX_BLOCK=$(cso_read_block "$SOURCE" "$BLK")
		local AVAIL=$(( CSO_BLOCK_SIZE - IN_BLOCK ))
		local TAKE=$(( REMAINING < AVAIL ? REMAINING : AVAIL ))
		RESULT+="${HEX_BLOCK:$((IN_BLOCK * 2)):$((TAKE * 2))}"

		REMAINING=$(( REMAINING - TAKE ))
		(( BLK++ ))
		IN_BLOCK=0
	done

	printf '%s' "$RESULT"
}

# CSO-aware replacement for read_bytes
#	$1 = source .cso file
#	$2 = virtual offset (within uncompressed image)
#	$3 = length in bytes
# Returns raw bytes via stdout
cso_read_bytes() {
	cso_read_hex "$1" "$2" "$3" | xxd -r -p
}

# Initialize WUX globals for direct-read access
#	$1 = wux source file
# Returns offset of start of data area
wux_init() {
	local SOURCE="$1"
	local MAGIC1 MAGIC2 SECT_HEX SECTSIZE UNCOMP_HEX UNCOMP_REV WUX_SECTORS
	local LUT_SIZE LUT_SECTORS WUX_DATA_START

	MAGIC1=$(read_hex "$SOURCE" 0 4)
	[[ "$MAGIC1" == "57555830" ]] || return 1   # "WUX0" LE
	MAGIC2=$(read_hex "$SOURCE" 4 4)
	[[ "$MAGIC2" == "2ed09910" ]] || return 1   # 0x1099d02e LE32

	SECT_HEX=$(read_hex "$SOURCE" 8 4)
	hex_le32 "$SECT_HEX" 0 SECTSIZE

	# Uncompressed size is LE64 at offset 0x10; hex_le64 reverses to BE hex
	UNCOMP_HEX=$(read_hex "$SOURCE" 0x10 8)
	hex_le64 "$UNCOMP_HEX" 0 UNCOMP_REV
	WUX_SECTORS=$(( 16#$UNCOMP_REV / 0x8000 ))

	# LUT at 0x20: WUX_SECTORS entries of 4 bytes each
	# Data area starts sector-aligned after the LUT
	LUT_SIZE=$(( 0x20 + WUX_SECTORS * 4 ))
	LUT_SECTORS=$(( (LUT_SIZE + 0x7FFF) / 0x8000 ))
	WUX_DATA_START=$(( LUT_SECTORS * 0x8000 ))

	debug "WUX init: sectors=${WUX_SECTORS} data_start=${WUX_DATA_START}"
	printf '%s' "$WUX_DATA_START"
}

# Resolve a virtual byte offset to a physical offset in a WUX file
#	$1 = source file
#	$2 = virtual byte offset
#	$3 = WUX data-area start offset
# Returns physical byte offset
wux_resolve_offset() {
	local SOURCE="$1" VOFF="$2" WUX_DATA_START="$3"
	local SECTOR GP_HEX GP

	# Sector index in the virtual image
	SECTOR=$(( VOFF / 0x8000 ))
	# Read the LUT entry for this sector
	GP_HEX=$(read_hex "$SOURCE" $(( 0x20 + SECTOR * 4 )) 4)
	hex_le32 "$GP_HEX" 0 GP
	printf "%s" $(( WUX_DATA_START + GP * 0x8000 ))
}


#####################################
# ISO 9660-specific helper functions
#####################################

# Notes:
# The functions below have a heavy inter-dependence and provide different
# methods of achieving similar functionality to address requirements for all
# supported platforms in the most performant way possible.
# 
# iso_walk_dir - the core function interacting with the ISO filesystem and
#     returning data about file names and locations
#
# iso_scan_root - reads and caches all files in root directory (ROOT_ENTRIES);
#     ROOT_ENTRIES can then be instantly queried for file info for multiple
#     platforms from that single initial read
#     Example: SYSTEM.CNF
#
# iso_dir_find - finds and returns location of one file in a specified directory
#     location; both dir location and file name are provided, so only the one
#     directory tree must be searched
#     Example: PARAM.SFO, under ROOT_ENTRIES[PSP_GAME]
#
# iso_find_path - finds and returns location of file in any directory;
#     path may be provided if known to narrow (speed up) search;
#     necessary for finding arbitrary files, such as COPYRIGHT file in PVD;
#     Example: COPYRIT.SCR or ORIGIN/COPYRIT.SCR

# Walk an ISO 9660 directory extent record by record
# Parses each record header and passes the parsed fields to a callback
# function supplied by the caller.  Uses callbacks to facilitate reuse.
#	$1 = file
#	$2 = directory LBA
#	$3 = directory size in bytes
#	$4 = callback function name (called once per record)
#	$5 .. $9 = caller variable names to receive the parsed fields:
#		LBA SIZE FLAGS FI_HEX FI_NAME
#	$10 = optional state variable name passed through to the callback
# Returns 0 if the callback requested stop, 1 if the extent ran out
# No output returned directlry; all output handled by callback function
iso_walk_dir() {
	local SOURCE="$1" DIR_LBA="$2" DIR_SIZE="$3" CB="$4"
	local STATE="${10:-}" DIR_OFF HEX HEX_BYTES POS REC_LEN FI_LEN FI_HEX H

	DIR_OFF=$(lba_to_offset "$DIR_LBA")

	# Read the directory extent into HEX.  For ISOs, this can be directly read.
	# For BIN files, the 2352 sector size results in garbage data between
	# logical 2048-byte sector sizes.  This skips the raw inter-sector overhead
	# to ensure a clean extent read even when spanning sectors.
	if (( SECTORSIZE > 2048 )); then
		local SECTORS=$(( (DIR_SIZE + 2047) / 2048 )) S BASE_OFF HEX_PART

		for (( S = 0; S < SECTORS; S++ )); do
			# Each logical sector is at DIR_OFF + S * SECTORSIZE, data
			# starts at +DATAOFF within the physical sector
			BASE_OFF=$(( DIR_OFF + S * SECTORSIZE ))
			HEX_PART=$(read_hex "$SOURCE" "$BASE_OFF" 2048)
			HEX+="$HEX_PART"
		done

	else
		HEX=$(read_hex "$SOURCE" "$DIR_OFF" "$DIR_SIZE")
	fi

	HEX_BYTES=$((${#HEX} / 2))
	POS=0

	# Parse data in directory extent
	while (( POS < HEX_BYTES )); do
		H="${HEX:$((POS * 2)):2}"
		REC_LEN=$((16#${H:-00}))
		if (( REC_LEN == 0 )); then
			# When REC_LEN = 0, rest of sector is padding.
			# Advance to next 2048-byte logical sector boundary
			POS=$(((POS / 2048 + 1) * 2048))
			continue
		fi

		# Parsed fields are written directly into caller-named variables
		# so the callback can read them via dynamic scope.
		# Processing performed locally vs. hex_substr/hex_le32 to significantly
		# improve performance on discs with large number entries
		H="${HEX:$((POS * 2 + 4)):8}"
		while (( ${#H} < 8 )); do H="0$H"; done
		printf -v "$5" '%d' "$((16#${H:6:2}${H:4:2}${H:2:2}${H:0:2}))"
		H="${HEX:$((POS * 2 + 20)):8}"
		while (( ${#H} < 8 )); do H="0$H"; done
		printf -v "$6" '%d' "$((16#${H:6:2}${H:4:2}${H:2:2}${H:0:2}))"
		H="${HEX:$((POS * 2 + 50)):2}"
		printf -v "$7" '%d' "$((16#${H:-00}))"
		H="${HEX:$((POS * 2 + 64)):2}"
		FI_LEN=$((16#${H:-00}))
		FI_HEX="${HEX:$((POS * 2 + 66)):$((FI_LEN * 2))}"
		printf -v "$8" '%s' "$FI_HEX"
		hex_to_ascii "$FI_HEX" "$9"

		"$CB" "$STATE"; local RC=$?
		# Callback controlls walker:
		# 0 = continue; 1 = stop w/ walker success; 2 = stop w/ walker failure
		if (( RC == 1 )); then return; fi
		if (( RC == 2 )); then return 1; fi
		POS=$((POS + REC_LEN))
	done

	return 1
}

# Scan the ISO 9660 root directory and cache all entries
# Populates global ROOT_ENTRIES associative array:
# ROOT_ENTRIES[name]="<lba> <size> <flags>"
# Version suffixes (;1) are stripped from names for simpler lookup
#	$1 = file
iso_scan_root() {
	local SOURCE="$1"
	local LBA SIZE FLAGS FI_HEX FI_NAME

	ROOT_ENTRIES=()
	iso_walk_dir "$SOURCE" "${PVD[ROOT_LBA]}" "${PVD[ROOT_SIZE]}" scan_root_cb LBA SIZE FLAGS FI_HEX FI_NAME
	return 0
}

# Write ISO file record into ROOT_ENTRIES (callback for iso_scan_root)
# Reads per-record fields from the namerefs set by iso_walk_dir
scan_root_cb() {
	# Skips self/parent entries
	[[ "$FI_HEX" == "00" || "$FI_HEX" == "01" ]] && return
	# Strip the ";*" version suffix
	ROOT_ENTRIES[${FI_NAME%;*}]="$LBA $SIZE $FLAGS"
	return 0
}

# Find a record by name and output its location (callback for iso_dir_find);
# Matches the target name, then outputs location details and stops the walker;
# Reads per-record fields from the namerefs set by iso_walk_dir
# Returns "<lba> <size> <flags>"
find_named_cb() {
	[[ "$FI_NAME" == "$ISOPATH" || "$FI_NAME" == "${ISOPATH};1" ]] || return 0
	printf "%s" "$LBA $SIZE $FLAGS"
	return 1
}

# Search an ISO 9660 directory extent for an entry by name
#	$1 = file
#	$2 = directory LBA
#	$3 = directory size in bytes
#	$4 = target name
# Returns "<lba> <size> <flags>", or nothing if not found
iso_dir_find() {
	local SOURCE="$1" DIR_LBA="$2" DIR_SIZE="$3" ISOPATH="$4"
	local LBA SIZE FLAGS FI_HEX FI_NAME

	iso_walk_dir "$SOURCE" "$DIR_LBA" "$DIR_SIZE" find_named_cb LBA SIZE FLAGS FI_HEX FI_NAME
	return
}

# Callback for BFS tree search during iso_find_path.  Upon file name, output
# "LBA SIZE" and stop the walker.  Upon new subdirectory, add to NEXT_DIRS
# queue.  The cller drains NEXT_DIRS to drive the breadth-first search.
bfs_find_cb() {
	if (( (FLAGS & 2) == 0 )); then
		if [[ "$FI_NAME" == "$ISOPATH" || "$FI_NAME" == "${ISOPATH};1" ]]; then
			debug "ISO file found '$FI_NAME' at LBA=${LBA} size=${SIZE}"
			printf "%s" "$LBA $SIZE"
			return 1
		fi
	else
		if [[ "$FI_HEX" != "00" && "$FI_HEX" != "01" && -z "${VISITED[$LBA]}" ]]; then
			VISITED[$LBA]=1
			NEXT_DIRS+=("$LBA $SIZE")
		fi
	fi
	return 0
}

# Walk an ISO 9660 path and print the file's LBA and size
# If the path has no slash, it is treated as a basename and the entire
# directory tree is searched if the file is not in the root directory.
#	$1 = file
#	$2 = path (e.g., "PS3_GAME/PARAM.SFO" or "COPYRIT.SCR")
#	output = "<lba> <size>"
# Returns 1 if not found
iso_find_path() {
	local SOURCE="$1" ISOPATH="$2"
	local DIR_LBA DIR_SIZE LBA SIZE FLAGS PART IS_LAST RESULT FI_HEX FI_NAME
	local -a PARTS DIRS NEXT_DIRS
	local -A VISITED

	# ISO 9660 filenames always contain a dot separator; reject inputs
	# from free-form PVD text fields that cannot possibly be a file name
	[[ "$ISOPATH" != *.* ]] && return 1

	# All searches start at the root directory from the PVD
	DIR_LBA=${PVD[ROOT_LBA]}
	DIR_SIZE=${PVD[ROOT_SIZE]}
	debug "Attempt to find '${ISOPATH}' starting at LBA=${DIR_LBA} size=${DIR_SIZE}"

	# Paths with a slash are explicit; walk each directory component in order
	if [[ "$ISOPATH" == */* ]]; then
		IFS='/' read -ra PARTS <<< "${ISOPATH}"

		# Walk each component. Intermediate parts must be directories;
		# the final part is the target file.
		for (( i=0; i<${#PARTS[@]}; i++ )); do
			PART="${PARTS[$i]}"
			IS_LAST=$(( i + 1 == ${#PARTS[@]} ))

			RESULT=$(iso_dir_find "$SOURCE" "$DIR_LBA" "$DIR_SIZE" "$PART")
			if [[ -z "$RESULT" ]]; then
				return 1
			fi

			read -r LBA SIZE FLAGS <<< "$RESULT"

			if (( IS_LAST )); then
				debug "ISO file found '$PART' at LBA=${LBA} size=${SIZE}"
				printf "%s" "${LBA} ${SIZE}"
				return 0
			fi

			if (( (FLAGS & 2) == 0 )); then
				return 1
			fi

			DIR_LBA=$LBA
			DIR_SIZE=$SIZE
		done

		return 1
	fi

	# Basename only: check root entries cache first if populated
	if [[ -n "${ROOT_ENTRIES[$ISOPATH]+x}" ]]; then
		local ENTRY="${ROOT_ENTRIES[$ISOPATH]}"
		debug "ISO file found '$ISOPATH' in root cache"
		printf "%s" "${ENTRY% *}"
		return 0
	fi

	# Basename only: most reference files live in root, so try there first
	RESULT=$(iso_dir_find "$SOURCE" "$DIR_LBA" "$DIR_SIZE" "$ISOPATH")
	if [[ -n "$RESULT" ]]; then
		read -r LBA SIZE FLAGS <<< "$RESULT"
		debug "ISO file found '$ISOPATH' at LBA=${LBA} size=${SIZE}"
		printf "%s" "${LBA} ${SIZE}"
		return 0
	fi

	# Not in root. Search directory extents breadth-first, queuing
	# subdirectories level by level until the file is found.
	debug "'${ISOPATH}' not in root; searching subdirectories"
	# Mark root visited so self/parent references do not re-queue it
	VISITED[${DIR_LBA}]=1
	DIRS=("${DIR_LBA} ${DIR_SIZE}")

	while (( ${#DIRS[@]} > 0 )); do
		NEXT_DIRS=()

		# Process all directories at the current depth before moving deeper.
		# The callback matches the target file or queues subdirectories into
		# NEXT_DIRS; VISITED guards against directory cycles.
		for DIR_ENTRY in "${DIRS[@]}"; do
			read -r DIR_LBA DIR_SIZE <<< "$DIR_ENTRY"
			iso_walk_dir "$SOURCE" "$DIR_LBA" "$DIR_SIZE" bfs_find_cb \
				LBA SIZE FLAGS FI_HEX FI_NAME && return
		done

		DIRS=("${NEXT_DIRS[@]}")
	done

	# File not found in root or any subdirectory
	return 1
}

# Read the contents of a file in ISO 9660 volume at a given LBA and size
#	$1 = file
#	$2 = LBA
#	$3 = size in bytes
# Returns raw bytes
iso_read_file() {
	local SOURCE="$1" LBA="$2" SIZE="$3"
	local OFF

	OFF=$(lba_to_offset "$LBA")
	debug "Reading LBA=$LBA size=$SIZE at offset=$OFF"

	read_bytes "$SOURCE" "$OFF" "$SIZE"
}

# Read a file from an ISO image and return its contents as hex
#	$1 = source image file
#	$2 = LBA of file
#	$3 = size in bytes
# Returns file in hex
iso_read_file_hex() {
	local SOURCE="$1" LBA="$2" SIZE="$3"
	iso_read_file "$SOURCE" "$LBA" "$SIZE" | xxd -p | tr -d '\n'
}

# Format a 17-byte ISO 9660 PVD date field into a human-readable string.
#	$1 = PVD date/time
pvd_format_date() {
	local D="$1"
	local YEAR TZBYTE TZ_SIGN TZ_MIN TZ_STR

	# Empty or all-zero date means no valid date
	if [[ -z "$D" || "$D" =~ ^0+$ ]]; then
		printf "%s" ""
		return
	fi

	# Require at least YYYYMMDDHHMMSS (14 digits)
	if [[ "$D" =~ ^[0-9]{14} ]]; then
		YEAR="${D:0:4}"

		if is_int "$YEAR"; then
			# Fix common PS1/PS2 PVD year quirks:
			# - Some 2000-era discs store year as 0000
			# - Some 2001-2009 discs store year as 1901-1909
			case "$YEAR" in
				0000) YEAR="2000" ;;
				190[1-9]) YEAR="20${YEAR:2:2}" ;;
			esac

			# Extract the timezone offset byte (17th character).
			if [[ ${#D} -ge 17 ]]; then
				TZBYTE=$(printf '%d' "'${D:16:1}")
				(( TZBYTE > 127 )) && TZBYTE=$((TZBYTE - 256))
			else
				# Offset byte was likely a trailing NUL and got stripped
				# by command substitution; assume UTC.
				TZBYTE=0
			fi

			# Convert offset to minutes and format as +/-HH:MM
			TZ_MIN=$((TZBYTE * 15))
			if (( TZ_MIN < 0 )); then
				TZ_SIGN="-"
				TZ_MIN=$((-TZ_MIN))
			else
				TZ_SIGN="+"
			fi

			# Output date + timezone
			printf -v TZ_STR '%s%02d:%02d' "$TZ_SIGN" $((TZ_MIN / 60)) $((TZ_MIN % 60))

			printf "%s" "${YEAR}-${D:4:2}-${D:6:2} ${D:8:2}:${D:10:2}:${D:12:2} ${TZ_STR}"
			return 0
		fi
	fi

	# Fallback: return the input unchanged if it doesn't look like a date
	printf "%s" "$D"
}

# Load the Primary Volume Descriptor (PVD) and cache fields
#	$1 = file
#	$2 = absolute byte offset of the PVD in the image
# No direct return; populates PVD global array
cache_pvd() {
	local SOURCE="$1" OFFSET="$2"
	local HEX RAWTEXT VAL DATEHEX

	# Read entire PVD as hex in a single read (preserves null bytes as "00")
	HEX=$(read_hex "$SOURCE" "$OFFSET" 2048)

	# Convert entire PVD to raw text once for all non-date text fields
	# NUL bytes become spaces; unused fields trim to empty 
	RAWTEXT=$(printf '%s' "$HEX" | xxd -r -p | tr '\0' ' ')

	VAL="${RAWTEXT:1:5}";     trim_var VAL; PVD[MAGIC]="$VAL"
	VAL="${RAWTEXT:8:32}";    trim_var VAL; PVD[SYSTEM]="$VAL"
	VAL="${RAWTEXT:40:32}";   trim_var VAL; PVD[VOLUME]="$VAL"
	VAL="${RAWTEXT:190:128}"; trim_var VAL; PVD[VOLUME_SET]="$VAL"
	VAL="${RAWTEXT:318:128}"; trim_var VAL; PVD[PUBLISHER]="$VAL"
	VAL="${RAWTEXT:446:128}"; trim_var VAL; PVD[PREPARER]="$VAL"
	VAL="${RAWTEXT:574:128}"; trim_var VAL; PVD[APPLICATION]="$VAL"
	VAL="${RAWTEXT:702:37}";  trim_var VAL; PVD[COPYRIGHT]="$VAL"
	VAL="${RAWTEXT:739:37}";  trim_var VAL; PVD[ABSTRACT]="$VAL"
	VAL="${RAWTEXT:776:37}";  trim_var VAL; PVD[BIBLIOGRAPHIC]="$VAL"

	# Date fields: extract from hex to preserve NUL-stripping behavior for the
	# binary timezone byte at position 16
	hex_substr "$HEX" 813 17 DATEHEX; hex_to_ascii "$DATEHEX" VAL; trim_var VAL; PVD[CREATED]="$VAL"
	hex_substr "$HEX" 830 17 DATEHEX; hex_to_ascii "$DATEHEX" VAL; trim_var VAL; PVD[MODIFIED]="$VAL"
	hex_substr "$HEX" 847 17 DATEHEX; hex_to_ascii "$DATEHEX" VAL; trim_var VAL; PVD[EXPIRES]="$VAL"
	hex_substr "$HEX" 864 17 DATEHEX; hex_to_ascii "$DATEHEX" VAL; trim_var VAL; PVD[EFFECTIVE]="$VAL"

	# Binary fields: extract from hex via hex_le32
	hex_le32 "$HEX" 158 VAL; PVD[ROOT_LBA]="$VAL"
	hex_le32 "$HEX" 166 VAL; PVD[ROOT_SIZE]="$VAL"

	#printf 'Raw PVD date hex: %s\n' "$(read_hex "$SOURCE" "$OFFSET" 2048 | cut -c1627-1660)" >&2	
	#for K in "${!PVD[@]}"; do printf '  [%s] = %s\n' "$K" "${PVD[$K]}"; done | sort >&2
}



########################
# cdemu helper functions
########################

# Poll a cdemu subcommand until a checker function returns success or timeout
#	$1 = cdemu subcommand (e.g., "status")
#	$2 = checker function name
#	$3 = error message for timeout
#	$4 = error return code on timeout
# Returns 0 on success, 1 if time exceeded
cdemu_poll() {
	local CMD="$1" CHECKER="$2" ERRMSG="$3" ERRRC="$4"
	local TICKS=0 OUTPUT

	while true; do
		OUTPUT=$(cdemu "$CMD" 2>/dev/null)
		if "$CHECKER" <<< "$OUTPUT"; then
			return 0
		fi

		if (( TICKS >= CDEMUTICKS )); then
			cwarn "Error: ${ERRMSG}"
			return "$ERRRC"
		fi

		sleep 0.1
		TICKS=$((TICKS + 1))
	done
}

# Checkers for cdemu_poll - confirm device reports loaded or empty filename
#	Uses global CDEMU_DEV
# Returns 0 on seccess, 1 on failure
cdemu_check_loaded() {
	local FILENAME
	FILENAME=$(awk -v dev="$CDEMU_DEV" '$1 == dev { for (i=3; i<=NF; i++) printf "%s%s", (i>3 ? " " : ""), $i; exit }')
	[[ -n "$FILENAME" ]]
}

cdemu_check_unloaded() {
	local FILENAME
	FILENAME=$(awk -v dev="$CDEMU_DEV" '$1 == dev { for (i=3; i<=NF; i++) printf "%s%s", (i>3 ? " " : ""), $i; exit }')
	[[ -z "$FILENAME" ]]
}


# Initialize a pool of cdemu devices for direct CHD processing
#	$1 = number of devices to add
# On success, populates CDEMU_POOL and CDEMU_VIRT_DEVS
# Devices are added rapidly and validated as a group at the end
cdemu_add_devices() {
	local COUNT="$1"
	local TICKS=0 VALID=0 I J BEFORE AFTER DEV VPATH
	local -A MAP

	debug "Adding $COUNT cdemu devices"

	# Determine current max device number
	BEFORE=$(cdemu device-mapping 2>/dev/null | awk 'BEGIN{max=-1} NF && $1 ~ /^[0-9]+$/ { if ($1 > max) max=$1 } END { print max }')
	AFTER=$((BEFORE + COUNT))
	debug "Expecting cdemu device numbers $((BEFORE + 1)) .. ${AFTER}"

	# Rapidly add new devices
	for (( I=0; I<COUNT; I++ )); do
		# Check return codes for error
		if ! cdemu add-device >/dev/null 2>&1; then
			cwarn "Error: failed to add cdemu device $((i + 1)) of ${COUNT}"
			debug "Rolling back ${i} cdemu devices after add failure"
			# If failed, rollback devices added so far
			for (( J=i-1; J>=0; J-- )); do
				cdemu remove-device >/dev/null 2>&1
			done
			return 1
		fi
	done

	debug "Issued ${COUNT} cdemu add-device commands; waiting for virtual devices"

	# Poll until all new devices are mapped with valid virtual paths
	while (( TICKS < CDEMUTICKS )); do
		MAP=()
		while IFS=$' \t' read -r DEV VPATH REST; do
			is_int "$DEV" && MAP["$DEV"]="$VPATH"
		done <<< "$(cdemu device-mapping 2>/dev/null)"

		VALID=0
		for (( DEV=BEFORE+1; DEV<=AFTER; DEV++ )); do
			VPATH="${MAP[$DEV]:-}"
			[[ -n "$VPATH" && -e "$VPATH" ]] && (( VALID++ ))
		done

		(( VALID == COUNT )) && break

		sleep 0.1
		TICKS=$((TICKS + 1))
	done

	if (( VALID < COUNT )); then
		cwarn "Error: only ${VALID} of ${COUNT} cdemu devices mapped within limit"
		debug "Rolling back ${COUNT} cdemu devices after validation timeout"

		# Rollback all added devices
		for (( I=0; I<COUNT; I++ )); do
			cdemu remove-device >/dev/null 2>&1
		done
		return 1
	fi

	# Populate pool and virtual device map
	for (( DEV=BEFORE+1; DEV<=AFTER; DEV++ )); do
		VPATH="${MAP[$DEV]}"
		CDEMU_POOL+=("$DEV")
		CDEMU_VIRT_DEVS[$DEV]="$VPATH"
		#debug "cdemu device $DEV mapped to '$VPATH'"
	done

	debug "Added cdemu device pool: ${CDEMU_POOL[*]}"
	return 0
}

# Load a CHD image into the existing cdemu device
#	$1 = CHD file path
# On success, sets FILE to the mapped kernel device path
cdemu_load_image() {
	local CHD="$1"

	# Verify device has been previously created
	if [[ -z "$CDEMU_DEV" ]]; then
		cwarn "Error: cdemu device not initialized"
		return 1
	fi

	# Verify virtual device path is known
	if [[ -z "$VIRTUAL_DEV" ]]; then
		cwarn "Error: cdemu virtual device path not set"
		return 1
	fi

	# Load CHD into virtual device
	debug "Loading CHD '${CHD}' into cdemu device ${CDEMU_DEV}"

	if ! cdemu load "$CDEMU_DEV" "$CHD" >/dev/null; then
		cwarn "Error: failed to load CHD '$CHD' into cdemu device $CDEMU_DEV"
		return 1
	fi

	# Poll until cdemu reports the loaded filename for this device
	if ! cdemu_poll status cdemu_check_loaded  \
			"cdemu device ${CDEMU_DEV} did not report loaded filename within limit" 2; then
		return $?
	fi

	FILE="$VIRTUAL_DEV"
	debug "CHD '${CHD}' loaded in cdemu dev ${CDEMU_DEV} at '${FILE}'"
	return 0
}

# Unload the current image from the cdemu device
cdemu_unload_image() {

	# Add CHDMODE guard here to simplify unmount calls
	(( CHDMODE )) || return

	[[ -z "$CDEMU_DEV" ]] && return

	# Unload device
	debug "Unloading cdemu device ${CDEMU_DEV}"
	if ! cdemu unload "$CDEMU_DEV" >/dev/null 2>&1; then
		cwarn "Error: failed to unload CHD from device $CDEMU_DEV"
		return 1
	fi

	# Poll until the filename is no longer reported for this device
	if ! cdemu_poll status cdemu_check_unloaded \
			"cdemu device ${CDEMU_DEV} still reports loaded image after unload" 2; then
		return $?
	fi

	debug "cdemu device ${CDEMU_DEV} no longer loaded with '${ORIGFILE}'"
}

# Remove all cdemu devices in the pool
# Unloads devices, then rapidly removes them and validates final count
cdemu_remove_devices() {
	local I BEFORE AFTER COUNT

	# Add CHDMODE guard here to simplify unmount calls
	(( CHDMODE )) || return

	COUNT=${#CDEMU_POOL[@]}
	[[ ${COUNT} -eq 0 ]] && return

	debug "Removing $COUNT cdemu devices"

	# Unload all devices before removal
	for (( I=COUNT-1; I>=0; I-- )); do
		cdemu unload "${CDEMU_POOL[$I]}" >/dev/null 2>&1
	done

	# Capture device count before removal
	BEFORE=$(cdemu device-mapping 2>/dev/null | awk 'NF && $1 ~ /^[0-9]+$/ { count++ } END { print count+0 }')
	debug "cdemu device count before removal: ${BEFORE}"

	# Rapidly remove all devices
	for (( I=0; I<COUNT; I++ )); do
		cdemu remove-device >/dev/null 2>&1
	done

	# Validate removal
	AFTER=$(cdemu device-mapping 2>/dev/null | awk 'NF && $1 ~ /^[0-9]+$/ { count++ } END { print count+0 }')

	if (( AFTER != BEFORE - COUNT )); then
		cinfo "Warning: expected $((BEFORE - COUNT)) devices after cleanup, found ${AFTER}"
		debug "cdemu device count mismatch: expected $((BEFORE - COUNT)), found ${AFTER}"
	else
		debug "cdemu device count after removal: $AFTER"
	fi

	return 0
}

# Claim a cdemu device from the pool
#	Polls until a device is available, with a 2-minute safety timeout
#	Returns device number via stdout
cdemu_claim_device() {
	local TICKS=0 DEV

	(( CHDMODE )) || return 1
	[[ -z "$CDEMU_LOCKDIR" ]] && return 1

	# If all devices remain claimed for 2 minutes, assume failure
	while (( TICKS < 1200 )); do
		for DEV in "${CDEMU_POOL[@]}"; do
			if mkdir "${CDEMU_LOCKDIR}/inuse_${DEV}" 2>/dev/null; then
				debug "Claimed cdemu device ${DEV} for worker (waited ${TICKS} ticks)"
				printf "%s" "$DEV"
				return 0
			fi
		done
		sleep 0.1
		TICKS=$((TICKS + 1))
	done

	cwarn "Error: timed out waiting for available cdemu device"
	return 1
}

# Release a cdemu device back to the pool
#	$1 = device number
cdemu_release_device() {
	local DEV="$1"

	(( CHDMODE )) || return
	[[ -z "$DEV" ]] && return
	[[ -z "$CDEMU_LOCKDIR" ]] && return

	if rmdir "$CDEMU_LOCKDIR/inuse_$DEV" 2>/dev/null; then
		: #debug "Released cdemu device $DEV"
	fi
}

# Initialize a pool of cdemu devices for parallel CHD processing
#	$1 = number of devices to create
# On success, populates CDEMU_POOL and CDEMU_VIRT_DEVS
cdemu_init_pool() {
	local COUNT="$1"

	(( CHDMODE )) || return
	bincheck cdemu || return 1

	CDEMU_LOCKDIR=$(mktemp -d -p "${TEMPDIR}" "cdemu_lock.XXXXXX")

	if ! cdemu_add_devices "$COUNT"; then
		debug "cdemu device pool initialization failed, cleaning up lock dir"
		rmdir "$CDEMU_LOCKDIR" 2>/dev/null
		CDEMU_LOCKDIR=""
		return 1
	fi

	debug "cdemu device pool initialized with $COUNT devices"
	return 0
}

# Remove all cdemu devices in the pool
cdemu_cleanup_pool() {
	(( CHDMODE )) || return
	[[ ${#CDEMU_POOL[@]} -eq 0 ]] && return

	debug "Cleaning up cdemu device pool"

	cdemu_remove_devices

	rm -rf "$CDEMU_LOCKDIR" 2>/dev/null
	unset CDEMU_LOCKDIR CDEMU_POOL CDEMU_VIRT_DEVS
}


####################################################
# Platform-specific reading and extraction functions
####################################################

# Compute CRC-32 of a file from a byte offset and return it as uppercase hex
#	$1 = file
#	$2 = start offset in bytes (optional, default 0)
# Returns 8-digit uppercase hex
generate_crc32() {
	local SOURCE="$1" OFF="${2:-0}"
	local CRC

	CRC=$(dd if="$SOURCE" bs=64K iflag=skip_bytes skip="$OFF" status=none 2>/dev/null | cksum -a crc32b | cut -d' ' -f1)
	printf '%08X' "$CRC"
}

# Identify APP*.PKG entries during ISO walk and add to global PS4_PKG_OFFSETS
#	$1 = name of caller's SEEN state variable
# No direct output; updates global PS4_PKG_OFFSETS
ps4_pkg_collect_cb() {
	local -n SEEN_LIST="$1"
	local PKG_OFF

	# Skip directories and self/parent markers
	(( (FLAGS & 2) != 0 )) && return
	[[ "$FI_HEX" == "00" || "$FI_HEX" == "01" ]] && return

	# Strip ISO 9660 version suffix, match APP*.PKG only
	local PKG_BASE_NAME="${FI_NAME%;1}"
	[[ "$PKG_BASE_NAME" == APP*.PKG ]] || return 0

	# Dedup by base name within this game directory
	[[ " $SEEN_LIST " == *" $PKG_BASE_NAME "* ]] && return
	SEEN_LIST+=" $PKG_BASE_NAME"

	PKG_OFF=$(lba_to_offset "$LBA")
	PS4_PKG_OFFSETS+=("$PKG_OFF")
	debug "PS4 ISO walk: PKG '$PKG_BASE_NAME' LBA=$LBA offset=$PKG_OFF size=$SIZE"
	return 0
}

# Walk one game directory under /APP to find APP*.PKG offsets
#	$1 = source file
#	$2 = game dir LBA
#	$3 = game dir size (already capped)
ps4_walk_game_dir() {
	local SOURCE="$1" GAME_LBA="$2" GAME_SIZE="$3"
	local LBA SIZE FLAGS FI_HEX FI_NAME
	# shellcheck disable=SC2034  # read indirectly via ps4_pkg_collect_cb
	local SEEN=""

	iso_walk_dir "$SOURCE" "$GAME_LBA" "$GAME_SIZE" ps4_pkg_collect_cb LBA SIZE FLAGS FI_HEX FI_NAME SEEN
	return 0
}

# Descend into a subdirectory under /APP and find APP*.PKG
# Reads per-record fields from the namerefs set by iso_walk_dir
ps4_app_dir_cb() {
	(( (FLAGS & 2) != 0 )) || return 0
	[[ "$FI_HEX" == "00" || "$FI_HEX" == "01" ]] && return

	debug "PS4 ISO walk: game directory '$FI_NAME' LBA=$LBA size=$SIZE"
	local SCAN_SIZE=$SIZE
	(( SCAN_SIZE > 131072 )) && SCAN_SIZE=131072
	ps4_walk_game_dir "$SOURCE" "$LBA" "$SCAN_SIZE"
	return 0
}

# Decode a SFO string value from hex.  SFO strings may be NUL-terminated or
# fixed-length, depending on field format.
#	$1 = SFO hex string
#	$2 = output variable name (nameref)
# Returns value via nameref
sfo_string_from_hex() {
	local HEX="$1"
	local -n NREF="$2"
	local I BYTE C

	NREF=""
	for (( I=0; I<${#HEX}; I+=2 )); do
		BYTE="${HEX:I:2}"
		# NUL-terminated string: stop at first NUL
		[[ "$BYTE" == "00" ]] && break
		printf -v C '%b' "\\x$BYTE"
		NREF+="$C"
	done

	# Inline trim
	NREF="${NREF#"${NREF%%[![:space:]]*}"}"
	NREF="${NREF%"${NREF##*[![:space:]]}"}"
}

# Parse a multiple format SFO hex dumps and return tab-delimited results:
#	KEY<TAB>VALUE<TAB>FORMAT
#	$1 = full file contents as a hex string
# Returns parsed hex list
sfo_parse_hex() {
	local FULL_HEX="$1"
	local FILE_SIZEMAGIC KEY_TABLE_OFF DATA_TABLE_OFF COUNT ENTRY_OFF ENTRY_HEX
	local KEY_OFF FMT USED MAX_LEN DATA_OFF KEY_HEX KEY VALUE_HEX VALUE I

	# File size in bytes is half the hex string length
	FILE_SIZE=$((${#FULL_HEX} / 2))

	# Confirm SFO magic ("\0PSF")
	hex_substr "$FULL_HEX" 0 4 MAGIC
	[[ "$MAGIC" == "00505346" ]] || { debug "sfo_parse_hex: bad magic $MAGIC"; return 1; }

	# Get header data
	hex_le32 "$FULL_HEX" 8 KEY_TABLE_OFF
	hex_le32 "$FULL_HEX" 12 DATA_TABLE_OFF
	hex_le32 "$FULL_HEX" 16 COUNT
	#debug "key_table=$KEY_TABLE_OFF data_table=$DATA_TABLE_OFF count=$COUNT"

	(( KEY_TABLE_OFF > 0 && KEY_TABLE_OFF < FILE_SIZE )) || return 1
	(( DATA_TABLE_OFF > 0 && DATA_TABLE_OFF <= FILE_SIZE )) || return 1
	(( COUNT > 0 && COUNT <= 512 )) || return 1

	for (( I = 0; I < COUNT; I++ )); do
		ENTRY_OFF=$((0x14 + I * 16))
		(( ENTRY_OFF + 16 <= FILE_SIZE )) || continue

		hex_substr "$FULL_HEX" "$ENTRY_OFF" 16 ENTRY_HEX
		hex_le16 "$ENTRY_HEX" 0 KEY_OFF
		hex_le16 "$ENTRY_HEX" 2 FMT
		hex_le32 "$ENTRY_HEX" 4 USED
		hex_le32 "$ENTRY_HEX" 8 MAX_LEN
		hex_le32 "$ENTRY_HEX" 12 DATA_OFF

		(( MAX_LEN >= USED )) || continue
		(( KEY_TABLE_OFF + KEY_OFF < FILE_SIZE )) || continue
		(( DATA_TABLE_OFF + DATA_OFF + USED <= FILE_SIZE )) || continue

		hex_substr "$FULL_HEX" $((KEY_TABLE_OFF + KEY_OFF)) 128 KEY_HEX
		sfo_string_from_hex "$KEY_HEX" KEY
		[[ -n "$KEY" ]] || continue

		hex_substr "$FULL_HEX" $((DATA_TABLE_OFF + DATA_OFF)) "$USED" VALUE_HEX

		case "$FMT" in
			4|516)
				sfo_string_from_hex "$VALUE_HEX" VALUE
				;;
			1028)
				hex_le32 "$VALUE_HEX" 0 VALUE
				;;
			*)
				VALUE="$VALUE_HEX"
				;;
		esac

		printf '%s\t%s\t%s\n' "$KEY" "$VALUE" "$FMT"
		#debug "KEY=${KEY} VELUE=${VALUE} FMOT=${FMT}"
	done
}

# Detect PKG platform by reading the 4-byte magic at offset 0
#	$1 = source file
# Returns pkg platform type, or 1 if unknown
pkg_detect() {
	local SOURCE="$1"
	local HEX MAGIC PKG_TYPE EXT_HEX EXT_MAGIC KEY_ID TYPE_HEX

	# Read 8 bytes: magic (4) + revision (2) + type (2)
	HEX=$(read_hex "$SOURCE" 0 8)
	[[ -n "$HEX" ]] || return 1

	hex_substr "$HEX" 0 4 MAGIC

	case "${MAGIC^^}" in
		# PS4 = \x7fCNT
		7F434E54)
			printf "%s" "ps4"
			;;

		# PSP/PS3/Vita = \x7fPKG
		7F504B47)
			# pkg_type at 0x06; 1 = PS3, 2 = PSP/Vita
			hex_substr "$HEX" 6 2 TYPE_HEX
			PKG_TYPE=$((16#$TYPE_HEX))
			if (( PKG_TYPE == 1 )); then
				printf "%s" "ps3"

			elif (( PKG_TYPE == 2 )); then
				# Distinguish by key_id at ext header offset 0x24
				# 0x1 = PSP, 0xC0000002 = Vita
				EXT_HEX=$(read_hex "$SOURCE" 192 64)
				[[ -n "$EXT_HEX" ]] || return 1
				hex_substr "$EXT_HEX" 0 4 EXT_MAGIC
				if [[ "${EXT_MAGIC^^}" == "7F657874" ]]; then
					hex_be32 "$EXT_HEX" 36 KEY_ID
					if (( KEY_ID == 1 )); then
						printf "%s" "psp"
					elif (( KEY_ID == 0xC0000002 )); then
						printf "%s" "vita"
					else
						return 1
					fi
				else
					return 1
				fi
			else
				return 1
			fi
			;;

		*)
			return 1 ;;
	esac
}

# Decrypt a byte range from PS3/PSP PKG encrypted data and return hex;
# AES-128-CTR with constant key; IV adjusted for random access
#	$1 = source file
#	$2 = offset within decrypted data stream
#	$3 = size to decrypt
#	$4 = pkg_data_riv (32-char hex IV from header offset 0x70)
#	$5 = pkg data_offset (absolute file offset of encrypted data)
#	$6 = AES key (32-char hex)
# Returns hex string
pkg_decrypt_range() {
	local SOURCE="$1" DATA_OFF="$2" SIZE="$3" RIV="$4" DATA_START="$5" AES_KEY="$6"
	local A B C D BLOCKS ADJ_IV ALIGN_OFF ALIGN_DELTA READ_SIZE FILE_OFF

	# Split 128-bit IV into four 32-bit parts for bash arithmetic
	A=$((16#${RIV:0:8}))
	B=$((16#${RIV:8:8}))
	C=$((16#${RIV:16:8}))
	D=$((16#${RIV:24:8}))

	# Advance IV by the number of 16-byte blocks to skip (random access)
	# Align to block boundary to keep keystream synchronized with ciphertext
	ALIGN_DELTA=$((DATA_OFF % 16))
	ALIGN_OFF=$((DATA_OFF - ALIGN_DELTA))
	BLOCKS=$((ALIGN_OFF / 16))
	D=$((D + BLOCKS))

	# Propagate carry through 32-bit parts
	if (( D > 0xFFFFFFFF )); then
		C=$((C + 1))
		D=$((D & 0xFFFFFFFF))
	fi
	if (( C > 0xFFFFFFFF )); then
		B=$((B + 1))
		C=$((C & 0xFFFFFFFF))
	fi
	if (( B > 0xFFFFFFFF )); then
		A=$((A + 1))
		B=$((B & 0xFFFFFFFF))
	fi

	printf -v ADJ_IV '%08x%08x%08x%08x' "$A" "$B" "$C" "$D"

	# Read from block-aligned offset; skip prefix bytes in decrypted output
	READ_SIZE=$((SIZE + ALIGN_DELTA))
	FILE_OFF=$((DATA_START + ALIGN_OFF))

	debug "PKG decrypt: file_off=$FILE_OFF read_size=$READ_SIZE skip=$ALIGN_DELTA adj_iv=$ADJ_IV"

	dd if="$SOURCE" bs=64K iflag=skip_bytes,count_bytes skip="$FILE_OFF" count="$READ_SIZE" status=none 2>/dev/null \
		| openssl enc -aes-128-ctr -K "$AES_KEY" -iv "$ADJ_IV" -nopad 2>/dev/null \
		| tail -c +"$((ALIGN_DELTA + 1))" \
		| xxd -p | tr -d '\n'
}

# Find a file by name in a PS3/PSP PKG file table and return its offset and size
#	$1 = source file
#	$2 = IV hex
#	$3 = number of file table entries
#	$4 = absolute file offset of encrypted data
#	$5 = AES key (32-char hex)
#	$6 = target filename
# Returns "<data_offset> <data_size>" within decrypted stream
pkg_ps3_find_file() {
	local SOURCE="$1" RIV="$2" ITEM_COUNT="$3" DATA_START="$4" AES_KEY="$5" TARGET="$6"
	local TABLE_SIZE READ_SIZE TABLE_HEX FN_OFF FN_SIZE D_OFF D_OFF_HI D_SIZE
	local D_SIZE_HI I ENTRY_OFF FN_HEX FN_NAME

	# File table: item_count entries of 32 bytes, plus buffer for filenames
	TABLE_SIZE=$((ITEM_COUNT * 32))
	READ_SIZE=$((TABLE_SIZE + 4096))
	(( READ_SIZE > 131072 )) && READ_SIZE=131072

	# Decrypt file table + filename area in a single call
	TABLE_HEX=$(pkg_decrypt_range "$SOURCE" 0 "$READ_SIZE" "$RIV" "$DATA_START" "$AES_KEY")
	[[ -n "$TABLE_HEX" ]] || return 1

	# Iterate entries to find target file
	# PKG_ITEM_RECORD (32 bytes, all big-endian):
	#   filename_offset(u32) filename_size(u32) data_offset(u64)
	#   data_size(u64) flags(u32) padding(u32)
	for (( I=0; I<ITEM_COUNT; I++ )); do
		ENTRY_OFF=$((I * 32))
		(( ENTRY_OFF + 32 > ${#TABLE_HEX} / 2 )) && break

		hex_be32 "$TABLE_HEX" "$ENTRY_OFF" FN_OFF
		hex_be32 "$TABLE_HEX" $((ENTRY_OFF + 4)) FN_SIZE

		# data_offset and data_size are u64; high 32 bits must be zero
		hex_be32 "$TABLE_HEX" $((ENTRY_OFF + 8)) D_OFF_HI
		hex_be32 "$TABLE_HEX" $((ENTRY_OFF + 12)) D_OFF
		hex_be32 "$TABLE_HEX" $((ENTRY_OFF + 16)) D_SIZE_HI
		hex_be32 "$TABLE_HEX" $((ENTRY_OFF + 20)) D_SIZE
		(( D_OFF_HI == 0 && D_SIZE_HI == 0 )) || continue

		# Verify filename is within decrypted range
		(( FN_OFF + FN_SIZE <= READ_SIZE )) || continue

		hex_substr "$TABLE_HEX" "$FN_OFF" "$FN_SIZE" FN_HEX
		hex_to_ascii "$FN_HEX" FN_NAME

		# Match by suffix to support both root-level files (PS3: "PARAM.SFO")
		# and nested paths (PSP: "USRDIR/CONTENT/PARAM.PBP")
		if [[ "$FN_NAME" == *"$TARGET" ]]; then
			debug "PKG: '$TARGET' found at entry $I offset=$D_OFF size=$D_SIZE"
			printf '%d %d\n' "$D_OFF" "$D_SIZE"
			return 0
		fi
	done

	return 1
}

# Extract PARAM.SFO hex from a decrypted PBP (PSP)
# PBP header is 40 bytes, SFO offset at byte 8
#	$1 = PBP hex string
# Returns SFO hex string
pkg_psp_sfo_from_pbp() {
	local PBP_HEX="$1"
	local MAGIC SFO_OFF ICON0_OFF SFO_SIZE SFO_HEX

	# Verify PBP magic: 0x00504250 ("\0PBP")
	hex_substr "$PBP_HEX" 0 4 MAGIC
	[[ "$MAGIC" == "00504250" ]] || return 1

	# SFO offset at PBP byte 8 (u32 LE), icon0 offset at byte 12 (u32 LE)
	hex_le32 "$PBP_HEX" 8 SFO_OFF
	hex_le32 "$PBP_HEX" 12 ICON0_OFF

	# SFO size = icon0_offset - sfo_offset
	(( SFO_OFF > 0 && ICON0_OFF > SFO_OFF )) || return 1
	SFO_SIZE=$((ICON0_OFF - SFO_OFF))

	debug "PSP PBP: SFO at offset=$SFO_OFF size=$SFO_SIZE"

	# Extract SFO portion from PBP hex
	hex_substr "$PBP_HEX" "$SFO_OFF" "$SFO_SIZE" SFO_HEX
	printf "%s" "$SFO_HEX"
}

# Find PARAM.SFO in a Vita PKG metadata table and return its offset and size
# Vita stores SFO info in unencrypted metadata entry 0x0E
#	$1 = source file
#	$2 = metadata_offset (from header at 0x08)
#	$3 = metadata_count (from header at 0x0C)
# Returns "<sfo_file_offset> <sfo_size>"
pkg_vita_find_sfo() {
	local SOURCE="$1" META_OFF="$2" META_COUNT="$3"
	local META_HEX ENTRY_TYPE ENTRY_SIZE ENTRY_OFF POS SFO_OFF SFO_SIZE I
	local READ_SIZE

	# Read entire metadata region in a single call; cap to avoid huge reads
	(( READ_SIZE = META_COUNT * 512 ))
	(( READ_SIZE > 65536 )) && READ_SIZE=65536

	META_HEX=$(read_hex "$SOURCE" "$META_OFF" "$READ_SIZE")
	[[ -n "$META_HEX" ]] || return 1

	# Each metadata entry: type(u32 BE) + size(u32 BE) + data(size bytes)
	POS=0
	for (( I = 0; I < META_COUNT; I++ )); do
		(( POS + 8 > ${#META_HEX} / 2 )) && break

		hex_be32 "$META_HEX" "$POS" ENTRY_TYPE
		hex_be32 "$META_HEX" $((POS + 4)) ENTRY_SIZE

		ENTRY_OFF=$((POS + 8))
		(( ENTRY_OFF + ENTRY_SIZE > ${#META_HEX} / 2 )) && break

		if (( ENTRY_TYPE == 0x0E )); then
			# PARAM.SFO Info: offset(u32 BE) at 0, size(u32 BE) at 4
			hex_be32 "$META_HEX" "$ENTRY_OFF" SFO_OFF
			hex_be32 "$META_HEX" $((ENTRY_OFF + 4)) SFO_SIZE
			(( SFO_OFF > 0 && SFO_SIZE > 0 )) || return 1
			debug "Vita PKG: PARAM.SFO at file offset=$SFO_OFF size=$SFO_SIZE"
			printf '%d %d\n' "$SFO_OFF" "$SFO_SIZE"
			return 0
		fi

		POS=$((ENTRY_OFF + ENTRY_SIZE))
	done

	return 1
}

# Find the embedded PARAM.SFO offset within PS4 PKG
#	$1 = source file
#	$2 = absolute PKG offset
# Returns "<sfo_abs_offset> <sfo_size>"
ps4_pkg_sfo_info_at() {
	local SOURCE="$1" PKG_OFF="$2"
	local HEADER_HEX TABLE_HEX MAGIC FILE_COUNT TABLE_OFF TABLE_ABS TABLE_SIZE
	local ENTRY_OFF ID_HEX REL_OFF SFO_SIZE I

	# Read PKG header (32 bytes covers magic, file_count, and table_off)
	HEADER_HEX=$(read_hex "$SOURCE" "$PKG_OFF" 32)
	[[ -n "$HEADER_HEX" ]] || return 1

	# PKG magic is 0x7f434e54 ("\x7fCNT")
	hex_substr "$HEADER_HEX" 0 4 MAGIC
	[[ "${MAGIC^^}" == "7F434E54" ]] || return 1

	# Header fields: file_count at 0x0c, table_off at 0x18
	hex_be32 "$HEADER_HEX" 12 FILE_COUNT
	hex_be32 "$HEADER_HEX" 24 TABLE_OFF

	# Sanity checks
	(( FILE_COUNT > 0 && FILE_COUNT <= 65536 )) || return 1
	(( TABLE_OFF > 0 && TABLE_OFF <= 16777216 )) || return 1

	# Read file table region in a single call; cap size to avoid huge reads.
	# PARAM.SFO entry (0x00001000) is normally early in the table.
	TABLE_ABS=$((PKG_OFF + TABLE_OFF))
	TABLE_SIZE=$((FILE_COUNT * 32))
	(( TABLE_SIZE > 131072 )) && TABLE_SIZE=131072

	TABLE_HEX=$(read_hex "$SOURCE" "$TABLE_ABS" "$TABLE_SIZE")
	[[ -n "$TABLE_HEX" ]] || return 1

	# Each file table entry is 32 bytes. PARAM.SFO is entry ID 0x00001000.
	for (( I = 0; I < FILE_COUNT; I++ )); do
		ENTRY_OFF=$((I * 32))
		(( ENTRY_OFF + 32 <= ${#TABLE_HEX} / 2 )) || break

		hex_substr "$TABLE_HEX" "$ENTRY_OFF" 4 ID_HEX
		[[ "$ID_HEX" == "00001000" ]] || continue

		# SFO relative offset at entry+0x10, size at entry+0x14 (both BE32)
		hex_be32 "$TABLE_HEX" $((ENTRY_OFF + 16)) REL_OFF
		hex_be32 "$TABLE_HEX" $((ENTRY_OFF + 20)) SFO_SIZE

		(( REL_OFF > 0 && SFO_SIZE > 0 && SFO_SIZE <= 1048576 )) || return 1

		printf '%d %d\n' "$((PKG_OFF + REL_OFF))" "$SFO_SIZE"
		return 0
	done

	return 1
}

# Locate PS4 app PKG(s) via ISO 9660 directory walker;
# Populates global PS4_PKG_OFFSETS array with absolute byte offsets
#	$1 = source file
ps4_find_pkg_via_iso() {
	local SOURCE="$1"
	local APP_LBA APP_SIZE APP_FLAGS RESULT SCAN_SIZE LBA SIZE FLAGS FI_HEX
	local FI_NAME

	PS4_PKG_OFFSETS=()

	# Root directory from cached PVD; cap the read to avoid inflated PS4 extents
	SCAN_SIZE=${PVD[ROOT_SIZE]}
	(( SCAN_SIZE > 131072 )) && SCAN_SIZE=131072

	RESULT=$(iso_dir_find "$SOURCE" "${PVD[ROOT_LBA]}" "$SCAN_SIZE" "APP") || return 1
	read -r APP_LBA APP_SIZE APP_FLAGS <<< "$RESULT"
	(( (APP_FLAGS & 2) != 0 )) || return 1

	# Walk /APP subdirectories; each game dir collects its APP*.PKG entries;
	# ps4_app_dir_cb and ps4_walk_game_dir read SOURCE via dynamic scope
	SCAN_SIZE=$APP_SIZE
	(( SCAN_SIZE > 131072 )) && SCAN_SIZE=131072
	iso_walk_dir "$SOURCE" "$APP_LBA" "$SCAN_SIZE" ps4_app_dir_cb LBA SIZE FLAGS FI_HEX FI_NAME

	(( ${#PS4_PKG_OFFSETS[@]} > 0 ))
}

# Locate the CNT package offset in a PS5 disc image. The CNT marker is embedded
# inside the UDF layer and not at a predictable offset, so a bounded scan is
# performed with increasingly large windows. Multi-PKG discs not supported.
#	$1 = source file
# Returns absolute byte offset of the first CNT marker, or null on failure
ps5_find_cnt_offset() {
	local SOURCE="$1"
	local CNT=$'\x7fCNT'
	local SIZE WINDOW START COUNT POS LINE FROM_EOF PREV

	# get file size via stat
	SIZE=$(stat -c %s "$SOURCE" 2>/dev/null) || return 1

	# Tail windows (bytes from end): 192MiB, 512MiB, 8GiB, whole file.
	# Intent is to minimize file reading time, especially on large ISOs.
	# Each pass scans only the unscanned delta (sliding window)
	PREV=0
	for WINDOW in 201326592 536870912 8589934592 "$SIZE"; do
		(( WINDOW > SIZE )) && WINDOW=$SIZE
		(( WINDOW <= PREV )) && continue
		START=$(( SIZE - WINDOW ))
		COUNT=$(( WINDOW - PREV ))
		debug "PS5: scanning band [-${WINDOW} .. -${PREV}] (start=$START, count=$COUNT) for CNT marker"

		# dd outputs "offset:MATCH" lines per match; the matched
		# marker bytes follow the colon and must be discarded.
		while read -r LINE; do
			POS=$(printf '%s' "$LINE" | cut -d: -f1)
			is_int "$POS" || continue
			POS=$(( START + POS ))
			FROM_EOF=$(( SIZE - POS ))
			debug "PS5: CNT candidate at $POS (band rel $((POS-START)), ${FROM_EOF} bytes from EOF)"
			printf "%s" "$POS"
			return 0
		done < <(dd if="$SOURCE" iflag=skip_bytes,count_bytes \
					skip="$START" count="$COUNT" 2>/dev/null \
					| grep -abo -F "$CNT")

		debug "PS5: no CNT in this band"
		PREV=$WINDOW
	done

	return 1
}

# Extract the param.json blob from a CNT package. Reads a 1MB block at the CNT offset as a hex string, locates the marker, then retrieves the json content.
#	$1 = source file
#	$2 = CNT package absolute byte offset
# Returns the JSON object, or null on failure
ps5_extract_param_json() {
	local SOURCE="$1" CNT_OFF="$2"
	local BLOCK=1048576 HEX NEEDLE PJ_HEX PJ_BYTE BR_HEX BR_BYTE JSON_ABS JSON

	# 1MB read at CNT offset (param.json lives early in the CNT block)
	HEX=$(read_hex "$SOURCE" "$CNT_OFF" "$BLOCK")
	[[ -z "$HEX" ]] && { debug "PS5: read_hex returned empty at $CNT_OFF"; return 1; }

	# Get the json offset by locating its marker
	NEEDLE=$(printf 'param.json' | xxd -p | tr -d '\n')
	PJ_HEX=$(printf '%s' "$HEX" | grep -abo -F "$NEEDLE" | head -1 | cut -d: -f1)
	[[ -z "$PJ_HEX" ]] && { debug "PS5: 'param.json' marker not found in 1MB block at $CNT_OFF"; return 1; }
	PJ_BYTE=$(( PJ_HEX / 2 ))
	debug "PS5: 'param.json' at block offset $PJ_BYTE"

	# First '{' after the param.json marker begins the JSON object
	BR_HEX=$(printf '%s' "${HEX:$(( PJ_HEX + 20 ))}" | grep -abo -F '7b' | head -1 | cut -d: -f1)
	[[ -z "$BR_HEX" ]] && { debug "PS5: no '{' after param.json"; return 1; }
	BR_BYTE=$(( PJ_BYTE + 10 + BR_HEX / 2 ))

	# Absolute offset of the JSON object's opening brace in the source file
	JSON_ABS=$(( CNT_OFF + BR_BYTE ))
	debug "PS5: JSON open brace at $JSON_ABS"

	# Use awk to extract all text between until the final closing }
	JSON=$(dd if="$SOURCE" iflag=skip_bytes bs=1 \
			skip="$JSON_ABS" count=131072 2>/dev/null \
			| awk '
		BEGIN { depth=0; started=0; instr=0; esc=0 }
		{
			for (i=1; i<=length($0); i++) {
				c = substr($0, i, 1)
				if (!started) {
					if (c == "{") { started=1; buf = buf c; continue }
					continue
				}
				if (esc) { esc=0; buf = buf c; continue }
				if (instr) {
					if (c == "\\") { esc=1; continue }
					if (c == "\"") { instr=0; buf = buf c; continue }
					buf = buf c
					continue
				}
				if (c == "\"") { instr=1; buf = buf c; continue }
				if (c == "{") { depth++; buf = buf c; continue }
				if (c == "}") {
					buf = buf c
					if (depth == 0) { printf "%s\n", buf; exit }
					depth--
					continue
				}
				buf = buf c
			}
		}')

	[[ -z "$JSON" ]] && { debug "PS5: awk produced no JSON"; return 1; }
	printf '%s\n' "$JSON"
}

# Extract a JSON field value by key. Most keys in param.json are unique; see
# ps5_json_title and ps5_json_agelevel for exceptions
#	$1 = JSON blob (single object)
#	$2 = key name
# Returns the value, or null if no key
ps5_json_value() {
	local JSON="$1" KEY="$2"
	local VALUE

	# Try quoted-string match first, then bare-integer fallback
	VALUE=$(printf '%s' "$JSON" \
		| sed -n 's/.*"'"$KEY"'"[[:space:]]*:[[:space:]]*"\(\(\(\\"\)*[^"]*\)*\)".*/\1/p')
	[[ -z "$VALUE" ]] && VALUE=$(printf '%s' "$JSON" \
		| sed -n 's/.*"'"$KEY"'"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p')

	printf '%s' "$VALUE"
}

# Extract the default parental age level from PS5 param.json
#	$1 = JSON blob
# Returns the value
ps5_json_agelevel() {
	local JSON="$1"
	local VALUE

	# Match "default":[whitespace]: <digits> within the ageLevel object.
	VALUE=$(printf '%s' "$JSON" \
		| sed -n 's/.*"ageLevel"[[:space:]]*:[[:space:]]*{[^}]*"default"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p')

	printf '%s' "$VALUE"
}

# Extract a localized titleName from a PS5 param.json, preferring English
#	$1 = JSON blob
# Returnes the localized title
ps5_json_title() {
	local JSON="$1"
	local VALUE
	local PAT_US='s/.*"en-US"[[:space:]]*:[[:space:]]*{[[:space:]]*"titleName"[[:space:]]*:[[:space:]]*"\(\(\(\\"\)*[^"]*\)*\)".*/\1/p'
	local PAT_EN='s/.*"en-[A-Z][A-Z]"[[:space:]]*:[[:space:]]*{[[:space:]]*"titleName"[[:space:]]*:[[:space:]]*"\(\(\(\\"\)*[^"]*\)*\)".*/\1/p'
	local PAT_JA='s/.*"ja-JP"[[:space:]]*:[[:space:]]*{[[:space:]]*"titleName"[[:space:]]*:[[:space:]]*"\(\(\(\\"\)*[^"]*\)*\)".*/\1/p'
	local PAT_FIRST='s/.*"titleName"[[:space:]]*:[[:space:]]*"\(\(\(\\"\)*[^"]*\)*\)".*/\1/p'

	# Prefer en-US
	VALUE=$(printf '%s' "$JSON" | sed -n "$PAT_US")
	# Fall back to any en-XX
	[[ -z "$VALUE" ]] && VALUE=$(printf '%s' "$JSON" | sed -n "$PAT_EN")
	# Fall back to ja-JP
	[[ -z "$VALUE" ]] && VALUE=$(printf '%s' "$JSON" | sed -n "$PAT_JA")
	# Last resort: first titleName in the blob
	[[ -z "$VALUE" ]] && VALUE=$(printf '%s' "$JSON" | sed -n "$PAT_FIRST" | head -1)

	printf '%s' "$VALUE"
}

# Find a file by name in an Xbox XDVDFS directory table (binary tree layout)
#	$1 = source file
#	$2 = dir byte offset
#	$3 = dir size
#	$4 = wanted name (case-insensitive)
#	$5 = entry dword offset (optional, default 0)
# Returns "sector size attr" on success
xbox_dir_find_file() {
	local SOURCE="$1" DIR_OFF="$2" DIR_SIZE="$3" WANTED="${4,,}"
	local ENTRY_REL="${5:-0}"
	local ENTRY_OFF LEFT RIGHT FILE_SECTOR FILE_SIZE ATTR NAME_LEN NAME_HEX NAME

	ENTRY_OFF=$((DIR_OFF + ENTRY_REL * 4))

	# A leading 0xFF byte marks an empty/padding entry
	[[ "$(read_hex "$SOURCE" "$ENTRY_OFF" 1)" == "ff" ]] && return 1

	hex_le16 "$(read_hex "$SOURCE" "$ENTRY_OFF" 2)" 0 LEFT
	hex_le16 "$(read_hex "$SOURCE" "$((ENTRY_OFF + 2))" 2)" 0 RIGHT
	hex_le32 "$(read_hex "$SOURCE" "$((ENTRY_OFF + 4))" 4)" 0 FILE_SECTOR
	hex_le32 "$(read_hex "$SOURCE" "$((ENTRY_OFF + 8))" 4)" 0 FILE_SIZE
	ATTR=$((16#$(read_hex "$SOURCE" "$((ENTRY_OFF + 12))" 1)))
	NAME_LEN=$((16#$(read_hex "$SOURCE" "$((ENTRY_OFF + 13))" 1)))

	(( NAME_LEN > 0 && NAME_LEN <= 255 )) || return 1

	NAME_HEX=$(read_hex "$SOURCE" "$((ENTRY_OFF + 14))" "$NAME_LEN")
	hex_to_ascii "$NAME_HEX" NAME

	if [[ "${NAME,,}" == "$WANTED" ]]; then
		printf '%s %s %s\n' "$FILE_SECTOR" "$FILE_SIZE" "$ATTR"
		return 0
	fi

	(( LEFT > 0 )) && xbox_dir_find_file "$SOURCE" "$DIR_OFF" "$DIR_SIZE" "$WANTED" "$LEFT" && return
	(( RIGHT > 0 )) && xbox_dir_find_file "$SOURCE" "$DIR_OFF" "$DIR_SIZE" "$WANTED" "$RIGHT" && return

	return 1
}

# Parse the certificate embedded in an Xbox default.xbe
#	$1 = XBE file path
#	$2 = XBE byte offset within the file
#	$3 = XBE byte size
#	$4 = Title ID (nameref)
#	$5 = Title name (nameref)
#	$6 = Region flags (nameref)
#	$7 = Disc number (nameref)
#	$8 = Version (nameref)
# Returns 1 if the XBE magic is not XBEH or the certificate is out of bounds
xbe_parse_cert() {
	local SOURCE="$1" XBE_OFF="$2" XBE_SIZE="$3"
	local -n TITLE_ID_REF="$4" TITLE_NAME_REF="$5" REGION_REF="$6"
	local -n DISC_NUM_REF="$7" VERSION_REF="$8"
	local TID_VAL BASE_ADDR CERT_ADDR CERT_FILE_OFF TITLE_HEX

	# Certificate is at (cert_addr - base_addr) within the XBE
	hex_le32 "$(read_hex "$SOURCE" "$((XBE_OFF + 0x104))" 4)" 0 BASE_ADDR
	hex_le32 "$(read_hex "$SOURCE" "$((XBE_OFF + 0x118))" 4)" 0 CERT_ADDR
	CERT_FILE_OFF=$((CERT_ADDR - BASE_ADDR))
	(( CERT_FILE_OFF > 0 && CERT_FILE_OFF < XBE_SIZE )) || return 1

	# Title ID at cert + 0x08
	hex_le32 "$(read_hex "$SOURCE" "$((XBE_OFF + CERT_FILE_OFF + 0x08))" 4)" 0 TID_VAL
	TITLE_ID_REF=$(printf '%08X' "$TID_VAL")

	# Title name cert + 0x0c
	TITLE_HEX=$(read_hex "$SOURCE" "$((XBE_OFF + CERT_FILE_OFF + 0x0c))" 80)
	hex_to_ucs2le "$TITLE_HEX" TITLE_NAME_REF
	trim_var TITLE_NAME_REF

	# Read remaining attributes
	hex_le32 "$(read_hex "$SOURCE" "$((XBE_OFF + CERT_FILE_OFF + 0xa0))" 4)" 0 REGION_REF
	hex_le32 "$(read_hex "$SOURCE" "$((XBE_OFF + CERT_FILE_OFF + 0xa8))" 4)" 0 DISC_NUM_REF
	hex_le32 "$(read_hex "$SOURCE" "$((XBE_OFF + CERT_FILE_OFF + 0xac))" 4)" 0 VERSION_REF

	return 0
}

# Derive the Xbox Serial Num and XMID from an XBE title id / region / version
#	$1 = Title ID (8-hex)
#	$2 = Region flags
#	$3 = Version
#	$4 = Serial Num (nameref)
#	$5 = XMID (nameref)
xbox_serial_xmid() {
	local TITLE_ID="$1" REGION_FLAGS="$2" VERSION="$3"
	local -n SERIAL_REF="$4" XMID_REF="$5"
	local PUBLISHER_CODE GAME_NUMBER XMID_REGION

	# Derive and return serial and XMID
	hex_to_ascii "${TITLE_ID:0:4}" PUBLISHER_CODE
	GAME_NUMBER=$((16#${TITLE_ID:4:4}))
	SERIAL_REF=$(printf '%s-%03d' "$PUBLISHER_CODE" "$GAME_NUMBER")
	XMID_REGION=$(region_map_xmid_xbox "$REGION_FLAGS")
	XMID_REF=$(printf '%s%03d%02d%s' "$PUBLISHER_CODE" "$GAME_NUMBER" "$VERSION" "$XMID_REGION")
}

# Extract the highestmin-kernel version from an XEX2 and format it
#	$1 = source file
#	$2 = XEX2 file byte offset
#	$3 = import-libs table byte offset (relative to file, absolute)
# Returns formatted kernel version
x360_parse_min_kernel() {
	local SOURCE="$1" XEX_OFF="$2" IL_OFF="$3"
	local MAX_HEX="00000000" MAX_VAL=0 LIBS_COUNT NAMES_LEN VER_ADDR MIN_VAL
	local LIB_LEN I

	hex_be32 "$(read_hex "$SOURCE" "$((IL_OFF + 4))" 4)" 0 NAMES_LEN
	hex_be32 "$(read_hex "$SOURCE" "$((IL_OFF + 8))" 4)" 0 LIBS_COUNT
	(( LIBS_COUNT > 256 )) && LIBS_COUNT=256

	VER_ADDR=$((IL_OFF + 12 + NAMES_LEN + 28))

	for (( I=0; I<LIBS_COUNT; I++ )); do
		# per-lib length prefix lives 28 bytes before the version block
		hex_be32 "$(read_hex "$SOURCE" "$((VER_ADDR - 28))" 4)" 0 LIB_LEN
		hex_be32 "$(read_hex "$SOURCE" "$((VER_ADDR + 4))" 4)" 0 MIN_VAL
		(( MIN_VAL > MAX_VAL )) && { MAX_VAL=$MIN_VAL; MAX_HEX=$(printf '%08X' "$MIN_VAL"); }
		VER_ADDR=$((VER_ADDR + LIB_LEN))
	done

	printf '%s' "$MAX_HEX"
}

# Decrypt a WUD/WUX sector via AES-128-CBC and return hex; callers must pass
# the correct IV for the start of each encrypted segment
# WUX mode: translates virtual offset -> physical via LUT lookup.
#	$1 = source file
#	$2 = virtual byte offset (sector-aligned in the uncompressed image)
#	$3 = size to decrypt (multiple of 16)
#	$4 = AES key (32-char hex)
#	$5 = IV (32-char hex, zeros for partition table / FST)
#	$6 = WUX mode flag (1 = WUX, 0 = plain WUD)
#	$7 = WUX data-area start offset (from wux_init, unused when $6=0)
# Returns hex string
wud_decrypt_cbc() {
	local SOURCE="$1" VOFF="$2" SIZE="$3" AES_KEY="$4" IV="$5"
	local WUX_MODE="${6:-0}" WUX_DATA_START="${7:-0}"
	local PHYS_OFF DEC_OFF DEC_SIZE

	# Resolve virtual -> physical offset (no-op for WUD where WUX_MODE=0)
	if (( WUX_MODE )); then
		PHYS_OFF=$(wux_resolve_offset "$SOURCE" "$VOFF" "$WUX_DATA_START")
	else
		PHYS_OFF="$VOFF"
	fi

	# dd skip_bytes/count_bytes require decimal, not hex literals
	DEC_OFF=$(( PHYS_OFF ))
	DEC_SIZE=$(( SIZE ))

	debug "WUD decrypt cbc: voff=$VOFF phys=$PHYS_OFF size=$SIZE"

	dd if="$SOURCE" bs=64K iflag=skip_bytes,count_bytes skip="$DEC_OFF" count="$DEC_SIZE" status=none 2>/dev/null \
		| openssl enc -aes-128-cbc -d -K "$AES_KEY" -iv "$IV" -nopad 2>/dev/null \
		| xxd -p | tr -d '\n'
}

# PFS0/HFS0 container parser for Switch NSP (PFS0) and XCI (HFS0)
# Validates magic, reads the string table into a byte-offset / name map, then
# walks the entry table populating an entries array
#	$1 = source file
#	$2 = base offset
#	$3 = nameref for entries array
#	$4 = nameref for string table array
#	$5 = entry stride (optional, default 0x18; pass 0x40 for HFS0)
# Returns 0 on success, 1 on magic mismatch or error
pfs0_hfs0_parse() {
	local SOURCE="$1" BASE_OFF="$2" STRIDE="${5:-0x18}"
	local -n PF_ENTRIES="$3" PF_STRINGS="$4"
	local HEADER MAGIC ENTRY_HEX ENTRY_COUNT STR_SIZE ENTRY_TBL_OFF STR_OFF
	local DATA_OFF CENTRY_IDX CENTRY_OFF FILE_NAME ENTRY_OFF_HEX ENTRY_SIZE_HEX
	local ENTRY_DATA_OFF_STR ENTRY_DATA_SIZE_STR ENTRY_STR_OFF_HEX STR_HEX
	local ENTRY_STR_OFF_NUM BYTE_OFF SEG_HEX SEG_NAME

	# Read header and validate magic: PFS0 = 50465330, HFS0 = 48465330
	HEADER=$(read_hex "$SOURCE" "$BASE_OFF" 0x10)
	MAGIC="${HEADER:0:8}"
	[[ "$MAGIC" == "50465330" || "$MAGIC" == "48465330" ]] || { debug "pfs0/hfs0 header validation failed"; return 1; }

	# Get entry count, string table size, and offset
	ENTRY_COUNT=$(printf '%d' "0x${HEADER:14:2}${HEADER:12:2}${HEADER:10:2}${HEADER:8:2}")
	STR_SIZE=$(printf '%d' "0x${HEADER:22:2}${HEADER:20:2}${HEADER:18:2}${HEADER:16:2}")
	ENTRY_TBL_OFF=$((BASE_OFF + 0x10))
	STR_OFF=$((ENTRY_TBL_OFF + ENTRY_COUNT * STRIDE))
	DATA_OFF=$((STR_OFF + STR_SIZE))

	# Read the string table as hex and split on NUL to build a byte-offset /
	# name map; each entry references its string by byte offset, not index
	STR_HEX=$(read_hex "$SOURCE" "$STR_OFF" "$STR_SIZE")

	# Split on NUL yields one hex segment per name
	BYTE_OFF=0
	PF_STRINGS[0]=""
	for SEG_HEX in ${STR_HEX//00/ }; do
		hex_to_ascii "$SEG_HEX" SEG_NAME
		PF_STRINGS[$BYTE_OFF]="$SEG_NAME"
		BYTE_OFF=$(( BYTE_OFF + ${#SEG_NAME} + 1 ))
	done

	# Parse entries; only the first 0x18 bytes of each entry contain useful data
	for (( CENTRY_IDX=0; CENTRY_IDX<ENTRY_COUNT; CENTRY_IDX++ )); do
		CENTRY_OFF=$((ENTRY_TBL_OFF + CENTRY_IDX * STRIDE))
		ENTRY_HEX=$(read_hex "$SOURCE" "$CENTRY_OFF" 0x18)

		# Offset (8B LE at +0x00) — relative to DATA_OFF
		ENTRY_OFF_HEX="${ENTRY_HEX:0:16}"
		ENTRY_DATA_OFF_STR=$(printf '%d' "0x${ENTRY_OFF_HEX:14:2}${ENTRY_OFF_HEX:12:2}${ENTRY_OFF_HEX:10:2}${ENTRY_OFF_HEX:8:2}${ENTRY_OFF_HEX:6:2}${ENTRY_OFF_HEX:4:2}${ENTRY_OFF_HEX:2:2}${ENTRY_OFF_HEX:0:2}")

		# Size (8B LE at +0x08)
		ENTRY_SIZE_HEX="${ENTRY_HEX:16:16}"
		ENTRY_DATA_SIZE_STR=$(printf '%d' "0x${ENTRY_SIZE_HEX:14:2}${ENTRY_SIZE_HEX:12:2}${ENTRY_SIZE_HEX:10:2}${ENTRY_SIZE_HEX:8:2}${ENTRY_SIZE_HEX:6:2}${ENTRY_SIZE_HEX:4:2}${ENTRY_SIZE_HEX:2:2}${ENTRY_SIZE_HEX:0:2}")

		# String table offset (4B LE at +0x10)
		ENTRY_STR_OFF_HEX="${ENTRY_HEX:32:8}"
		ENTRY_STR_OFF_NUM=$(printf '%d' "0x${ENTRY_STR_OFF_HEX:6:2}${ENTRY_STR_OFF_HEX:4:2}${ENTRY_STR_OFF_HEX:2:2}${ENTRY_STR_OFF_HEX:0:2}")
		FILE_NAME="${PF_STRINGS[$ENTRY_STR_OFF_NUM]}"

		# Populate array with each valid name and offset
		[[ -z "$FILE_NAME" ]] && continue
		PF_ENTRIES["$FILE_NAME"]="$((DATA_OFF + ENTRY_DATA_OFF_STR)) $ENTRY_DATA_SIZE_STR"
	done

	return 0
}

# Navigate Switch XCI root HFS0 to find the secure partition's absolute offset
#	$1 = source file
# Returns the absolute file offset of the secure HFS0 partition, or 1 on failure
hfs0_secure_offset() {
	local SOURCE="$1"
	local -A ROOT_ENTRIES
	local -a ROOT_STRINGS
	local SECURE_OFF SECURE_SIZE

	pfs0_hfs0_parse "$SOURCE" 0xF000 ROOT_ENTRIES ROOT_STRINGS 0x40 || return 1
	read -r SECURE_OFF SECURE_SIZE <<< "${ROOT_ENTRIES[secure]:-}"
	[[ -n "$SECURE_OFF" ]] || return 1

	# The HFS0 entry offset is relative to DATA_START, so SECURE_OFF is already
	# the absolute offset
	printf '%s' "$SECURE_OFF"
	return 0
}

# Extract NCA from Switch container by raw byte copy
#	$1 = source container
#	$2 = entry filename to extract
#	$3 = output path
#	$4 = nameref to the container-entries array ("<absolute_offset> <size>")
switch_nca_extract() {
	local SOURCE="$1" NAME="$2" OUT="$3"
	local -n ENTRIES="$4"
	local ENTRY_OFF ENTRY_SIZE

	# Pull the NCA straight out of the container at its parsed absolute offset
	read -r ENTRY_OFF ENTRY_SIZE <<< "${ENTRIES[$NAME]:-}"
	[[ -n "$ENTRY_OFF" ]] || { debug "Switch: entry '$NAME' not found in container"; return 1; }
	read_bytes "$SOURCE" "$ENTRY_OFF" "$ENTRY_SIZE" >"$OUT"
}


##########################################
# Platform-specific data mapping functions
##########################################

# PS1 and PS2 regions
#	$1 = CNF game id
region_map_ps1-2() {
	local ID="${1^^}"
	local S
	case "$ID" in
		             S[CL]US*) S="USA"   ;;  S[CL]E[DS]*|PBPX*) S="Europe"  ;;
		S[CL]P[MS]*|P[AC]PX*) S="Japan"  ;;              SCAJ*) S="Asia"    ;;
		             S[CL]KA*) S="Korea" ;;                  *) S="Unknown" ;;
	esac
	printf "%s" "$S"
}

# PSP regions
#	$1 = SFO game id
region_map_psp() {
	local ID="${1^^}"
	local S
	case "$ID" in
		U[CL]US*|NPU*) S="USA"        ;;  U[CL]ES*|NPE*) S="Europe"    ;;
		U[CL]JS*|NPJ*) S="Japan"      ;;  U[CL]KS*|NPK*) S="Korea"     ;;
		U[CL]AS*|NPH*) S="Asia"       ;;       U[CL]AU*) S="Australia" ;;
		           P*) S="Demo/Promo" ;;              *) S="Unknown"   ;;
	esac
	printf "%s" "$S"
}

# PSP categories
#	$1 = SFO category
category_map_psp() {
	local CAT="${1^^}"
	local S
	case "$CAT" in
		UG) S="UMD Game"          ;;  MG) S="Memory Stick Game"       ;;
		MS) S="Memory Stick Save" ;;  WG) S="WLAN Game (Gamesharing)" ;;
		UV) S="UMD Video"         ;;  UA) S="UMD Audio"               ;;
		UC) S="UMD Cleaning Disc" ;;   *) S="Unknown"                 ;;
	esac
	printf "%s" "$S"
}

# PSP and PS3 parental levl
#	$1 = SFO parental level
parental_map_ps() {
	local LEVEL="$1"
	local S
	case "$LEVEL" in
		       0) S="Unrestricted"     ;;  [1-4]) S="General Audience" ;;
		   [5-6]) S="12+ years"        ;;  [7-8]) S="15+ years"        ;;
		9|1[0-1]) S="18+ years"        ;;      *) S="Unknown"          ;;
	esac
	printf "%s" "$S"
}

# PS3 region map
#	$1 = SFO game id
region_map_ps3() {
	local ID="${1^^}"
	local S
	case "$ID" in
		B[CL]U[DS]*|NPU[BCZ]*) S="USA"   ;;  B[CL]E[DS]*|NPE[BZ]*) S="Europe" ;;
		B[CL]J[MS]*|NPJ[GHJ]*) S="Japan" ;;  B[CL]AS*|NPH[BZ]*)    S="Asia"   ;;
		B[CL]K[ADS]*|NPK[BZ]*) S="Korea/Hong Kong" ;;
		*)                     S="Unknown" ;;
	esac
	printf "%s" "$S"
}

# PSV region map
#	$1 = SFO game id
region_map_vita() {
	local ID="${1^^}"
	local S
	case "$ID" in
		            PCS[AE]*) S="USA"    ;;        PCS[BF]*) S="Europe"       ;;
		PCS[CG]*|V[CL]J[MS]*) S="Japan"  ;;  PCSH*|V[CL]AS*) S="Asia / Korea" ;;
		                NPXS) S="System" ;;               *) S="Unknown"      ;;
	esac
	printf "%s" "$S"
}


# PS3/V/4 title map
#	$1 = SFO array
#	$2 = attribute name (TITLE, STITLE)
title_map_ps3-v-4() {
	local -n LSFO="$1"
	local LNAME="$2"
	local LTITLE

	# Prefer USA/UK English, fall back to default
	LTITLE="${LSFO[${LNAME}_01]}"
	[[ -z "$LTITLE" ]] && LTITLE="${LSFO[${LNAME}_28]}"
	[[ -z "$LTITLE" ]] && LTITLE="${LSFO[$LNAME]}"

	printf "%s" "$LTITLE"
}

# PS3 categories
#	$1 = SFO category
category_map_ps3() {
	local CAT="${1^^}"
	local S
	case "$CAT" in
		1P) S="PS1 Classics"      ;;  2D) S="PS2 Data"              ;;
		2G) S="PS2 Game"          ;;  2P) S="PS2 Classics"          ;;
		AM) S="Application Music" ;;  AP) S="Application Photo"     ;;
		AR) S="Autoinstall Root"  ;;  AS) S="Application Streaming" ;;
		AT) S="Application TV"    ;;  AV) S="Application Video"     ;;
		BV) S="Broadcast Video"   ;;  CB) S="Channel Broadcast"     ;;
		DG) S="Disc Game"         ;;  DM) S="Disc Movie"            ;;
		DP) S="Disc Packages"     ;;  GD) S="Game Data"             ;;
		HG) S="HDD Game"          ;;  HM) S="Home"                  ;;
		IP) S="Install Package"   ;;  MN) S="Minis"                 ;;
		MS) S="Memory Stick Save" ;;  PE) S="PSP Emulation"         ;;
		PP) S="PSP Package"       ;;  SF) S="Store Frontend"        ;;
		SD) S="Save Data"         ;;  TI) S="Theme Item"            ;;
		TR) S="Theme Root"        ;;  VI) S="Video Item"            ;;
		VR) S="Video Root"        ;;  WT) S="Web TV"                ;;
		XR) S="Extra Root"        ;;   *) S="Unknown"               ;;
	esac
	printf "%s" "$S"
}

# PS3 resolutions
#	$1 = SFO resolution
resolution_map_ps3() {
	local VAL="$1"
	local OUT43 OUT169 OUT

	# 4:3 group
	[[ $((VAL & 1))  -ne 0 ]] && OUT43="480"
	[[ $((VAL & 2))  -ne 0 ]] && OUT43="${OUT43:+$OUT43/}576"

	# 16:9 group
	[[ $((VAL & 16)) -ne 0 ]] && OUT169="480"
	[[ $((VAL & 32)) -ne 0 ]] && OUT169="${OUT169:+$OUT169/}576"
	[[ $((VAL & 4))  -ne 0 ]] && OUT169="${OUT169:+$OUT169/}720"
	[[ $((VAL & 8))  -ne 0 ]] && OUT169="${OUT169:+$OUT169/}1080"

	# Join groups
	[[ -n "$OUT43" ]]  && OUT="$OUT43 (4:3)"
	[[ -n "$OUT169" ]] && OUT="${OUT:+$OUT + }$OUT169 (16:9)"

	printf "%s" "$OUT"
}

# PS3 sound formats
#	$1 = SFO sound format
sound_format_map_ps3() {
	local VAL="$1"
	local LPCM DD DTS OUT

	# LPCM group: 2.0 (bits 0/1), 5.1 (bit 2), 7.1 (bit 4)
	[[ $((VAL & 3))  -ne 0 ]] && LPCM="2.0"
	[[ $((VAL & 4))  -ne 0 ]] && LPCM="${LPCM:+$LPCM/}5.1"
	[[ $((VAL & 16)) -ne 0 ]] && LPCM="${LPCM:+$LPCM/}7.1"
	[[ -n "$LPCM" ]] && LPCM="LPCM $LPCM"

	# Dolby Digital (bit 8)
	[[ $((VAL & 256)) -ne 0 ]] && DD="DD 5.1"

	# DTS (bit 9)
	[[ $((VAL & 512)) -ne 0 ]] && DTS="DTS 5.1"

	# Join groups with " + "
	OUT="$LPCM"
	[[ -n "$DD" ]]  && OUT="${OUT:+$OUT + }$DD"
	[[ -n "$DTS" ]] && OUT="${OUT:+$OUT + }$DTS"

	printf "%s" "$OUT"
}

# PS4 system version format
#	$1 = SFO SYSTEM_VER value (decimal)
firmware_map_ps4() {
	local VALUE="$1"
	local HEX

	# Decimal input
	if is_int "$VALUE"; then
		printf -v HEX '%08X' "$VALUE"
		printf '%d.%s' "$((16#${HEX:0:2}))" "${HEX:2:2}"
	fi
}

# PS4 category map
#	$1 SFO CATEGORY value
category_map_psv-4() {
	local CAT="${1^^}"
	local S
	case "$CAT" in
		AC)  S="Additional Content" ;;      BD)  S="Blu-ray Disc(?)" ;;
		GC)  S="Game Content(?)" ;;         GD)  S="Game Digital App" ;;
		GDA) S="System Application" ;;      GDC) S="Non-Game Big App" ;;
		GDD) S="BG Application" ;;
		GDE) S="Non-Game Mini App / Video Svc Native App" ;;
		GDG) S="CommonDialog" ;;            GDK) S="Video Svc Web App" ;;
		GDL) S="PS Cloud Beta App" ;;       GDO) S="PS2 Classic" ;;
		GP)  S="Game App Patch" ;;          GPD) S="Big App Patch" ;;
		GPC) S="Non-Game BG App Patch" ;;   GPK) S="Video Svc Web App Patch" ;;
		GPE) S="Non-Game Mini App Patch / Video Svc Native App Patch" ;;
		GPL) S="PS Cloud Beta App Patch" ;; SD)  S="Save Data" ;;
		LA)  S="License Area" ;;            *)   S="Unknown" ;;
	esac
	printf "%s" "$S"
}

# PS4 app type map
#	$1 SFO APP_TYPE value
app_type_map_ps4() {
	local VAL="$1"
	local S
	case "$VAL" in
		0) S="Not Specified"  ;;  1) S="Paid Standalone Full App" ;;
		2) S="Upgradable App" ;;  3) S="Demo App"                 ;;
		4) S="Freemium App"   ;;  *) S="Unknown"                  ;;
	esac
	printf "%s" "$S"
}

# PS5 region map
#	$1 = param.json titleId
# Note: Digital titles use PPSA regardless of region, so region not guaranteed
region_map_ps5() {
	local ID="${1^^}"
	local S
	case "$ID" in
		PPSA*)     S="USA/Europe" ;;  E[CL]AS*)  S="Asia" ;;
		ELJM*)     S="Japan"      ;;     NPXS*)  S="System" ;;
		    *)     S="Unknown"    ;;
	esac
	printf "%s" "$S"
}

# PS5 category map
#	$1 = SFO CATEGORY value (decimal or hex)
category_map_ps5() {
	local CAT="${1^^}"
	local S
	case "$CAT" in
		       0) S="Native Game"         ;;     65536) S="Native Media App" ;;
		   65792) S="RNPS Media App"      ;;     66048) S="Web Media App"    ;;
		  131328) S="System Built-in App" ;;    131584) S="Big Daemon"       ;;
		16777216) S="ShellUI"             ;;  33554432) S="Daemon"           ;;
		50331648) S="CommonDialog"        ;;  67108864) S="ShellApp"         ;;
		       *) S="Unknown"             ;;
	esac
	printf "%s" "$S"
}

# PS5 system version format
#	$1 = param.json requiredSystemSoftwareVersion value
firmware_map_ps5() {
	local VALUE="$1"
	local HEX

	HEX="${VALUE#0[xX]}"
	HEX="${HEX^^}"

	# Pad to 16 hex digits (64-bit), then take the first two bytes
	printf -v HEX '%016s' "$HEX"
	HEX="${HEX// /0}"

	printf '%d.%s' "$((16#${HEX:0:2}))" "${HEX:2:2}"
}

# Xbox region map from XBE certificate region flags
#	$1 = region flags
region_map_xbox() {
	local FLAGS="$1"
	local OUT

	(( FLAGS & 0x00000001 )) && OUT+="USA / Canada"
	(( FLAGS & 0x00000002 )) && OUT+="${OUT:+ + }Japan"
	(( FLAGS & 0x00000004 )) && OUT+="${OUT:+ + }Europe"
	(( FLAGS & 0x80000000 )) && OUT+="${OUT:+ + }Manufacturing"

	printf "%s" "${OUT:-Unknown}"
}

# Xbox XMID region code from XBE certificate region flags
# https://consolemods.org/wiki/Xbox_Region_Compatibility
#	$1 = region flags (32-bit integer)
region_map_xmid_xbox() {
	local FLAGS="$1"
	local S
	case "$FLAGS" in
		1) S="A" ;;	 2) S="J" ;;  3) S="K" ;;  4) S="E" ;;	5) S="L" ;;
		7) S="W" ;;  *) S="X" ;;
		
	esac
	printf "%s" "$S"
}

# Xbox 360 region map from XEX2 certificate region code
#	$1 = region code
region_map_x360() {
	local FLAGS="$1"
	local OUT

	if (( FLAGS == 4294967295 )); then
		printf "%s" "Region Free"
		return 0
	fi

	(( FLAGS & 0x000000FF )) && OUT+="USA / Canada"
	(( FLAGS & 0x0000FF00 )) && OUT+="${OUT:+ / }Japan"
	(( FLAGS & 0x00FF0000 )) && OUT+="${OUT:+ / }Europe"

	printf "%s" "${OUT:-Unknown}"
}

# Xbox 360 XeMID region letter from XEX2 certificate region code
# https://consolemods.org/wiki/Xbox_Region_Compatibility
#	$1 = region code (32-bit integer)
region_map_xemid_x360() {
	local FLAGS="$1"
	local S

	if (( FLAGS == 4294967295 )); then
		printf "W"
		return 0
	fi

	local S=""
	(( FLAGS & 0x000000FF )) && S+="A"
	(( FLAGS & 0x0000FF00 )) && S+="J"
	(( FLAGS & 0x00FF0000 )) && S+="E"

	case "$S" in
		  A) S="A" ;;   J) S="J" ;;   E) S="E" ;;
		 AJ) S="K" ;;  AE) S="L" ;;  JE) S="H" ;;
		AJE) S="W" ;;   *) S="?" ;;
	esac
	printf "%s" "$S"
}

# Xbox 360 STFS content type mapper
#	$1 = STFS content type value
# Returns human-readable type name
content_type_map_stfs_x360() {
	local T="$1"
	local H S
	printf -v H '%07X' "$T"
	case "$H" in
		0000001) S="Saved Game"         ;;  0000002) S="Marketplace Content" ;;
		0000003) S="Publisher"          ;;  0001000) S="Xbox 360 Title"      ;;
		0002000) S="IPTV Pause Buffer"  ;;  0004000) S="Installed Game"      ;;
		0005000) S="Xbox Original Game" ;;  0007000) S="Game on Demand"      ;;
		0009000) S="Avatar Item"        ;;  0010000) S="Profile"             ;;
		0020000) S="Gamer Picture"      ;;  0030000) S="Theme"               ;;
		0040000) S="Cache File"         ;;  0050000) S="Storage Download"    ;;
		0060000) S="Xbox Saved Game"    ;;  0070000) S="Xbox Download"       ;;
		0080000) S="Game Demo"          ;;  0090000) S="Video"               ;;
		00A0000) S="Game Title"         ;;  00B0000) S="Installer"           ;;
		00C0000) S="Game Trailer"       ;;  00D0000) S="Arcade Title"        ;;
		00E0000) S="XNA"                ;;  00F0000) S="License Store"       ;;
		0100000) S="Movie"              ;;  0200000) S="TV"                  ;;
		0300000) S="Music Video"        ;;  0400000) S="Game Video"          ;;
		0500000) S="Podcast Video"      ;;  0600000) S="Viral Video"         ;;
		2000000) S="Community Game"     ;;        *) S="Type 0x$H"           ;;
	esac
	printf "%s" "$S"
}

# Xbox 360 title + publisher parser
#	$1 = file path
#	$2 = nameref for game title
#	$3 = nameref for publisher
title_publisher_parse_x360() {
	local ISO="$1"
	local -n TITLE_REF="$2" PUB_REF="$3"
	local RAW

	TITLE_REF=""; PUB_REF=""
	optbincheck abgx360 || return 0

	# Read game data with abgx360
	# -vwo = verbose + nowrite + stayoffline - prevent writing sidecar file
	RAW=$(abgx360 -vwo "$ISO" 2>&1 | sed -r 's/\x1B\[[0-9;]*[mK]//g')

	# Both fields have leading whitespace in verbose output, hence
	# the optional \s* before the field name.  Exactly one match per
	# disc; trailing whitespace trimmed.
	TITLE_REF=$(printf '%s' "$RAW" \
		| sed -n 's/^[[:space:]]*Game Name:[[:space:]]*//p; s/[[:space:]]*$//')
	PUB_REF=$(printf '%s' "$RAW" \
		| sed -n 's/^[[:space:]]*Publisher:[[:space:]]*//p; s/[[:space:]]*$//')
}

# Format an Xbox 360 aexec version
#	$1 = version hex
format_x360_version() {
	local H="${1^^}"
	printf 'v%d.%d.%d.%d' $((16#${H:0:2})) $((16#${H:2:2})) $((16#${H:4:2})) $((16#${H:6:2}))
}

# Format an Xbox 360 min-kernel version
#	$1 = version hex
format_x360_kernel_version() {
	local V="$((16#${1^^}))"
	printf 'v%d.%d.%d.%d' $(( (V >> 28) & 0xF )) $(( (V >> 24) & 0xF )) $(( (V >>  8) & 0xFFFF )) $(( V & 0xFF ))
}

# Sega Master System / Game Gear region map
#	$1 = region nibble from TMR SEGA header
# Returns region/system name
region_map_sms-gg() {
	local V="$1"
	local S
	case "$V" in
		3) S="SMS Japan"               ;;  4) S="SMS Export"       ;;
		5) S="Game Gear Japan"         ;;  6) S="Game Gear Export" ;;
		7) S="Game Gear International" ;;  *) S="Unknown"          ;;
	esac
	printf "%s" "$S"
}

# Sega Genesis / Mega Drive region map
# Handles both old-style (J/U/E letters) and new-style (hex digit bitfield)
#	$1 = 3-byte region string
# Returns comma-separated region names
region_map_genesis() {
	local R="$1"
	local S CH

	# New style: single ASCII hex digit (bitfield)
	# 1=Japan, 4=Americas, 8=Europe, F=all
	if [[ "$R" =~ ^[0-9A-Fa-f] ]] && [[ "${R:0:1}" != "J" && "${R:0:1}" != "U" && "${R:0:1}" != "E" ]]; then
		local V=$((16#${R:0:1}))
		(( V & 1 )) && S="Japan"
		(( V & 4 )) && S+="${S:+, }Americas"
		(( V & 8 )) && S+="${S:+, }Europe"
		[[ -n "$S" ]] || S="Unknown"
		printf "%s" "$S"
		return
	fi

	# Old style: scan all 3 bytes for J/U/E letters
	for (( CH=0; CH<3; CH++ )); do
		case "${R:$CH:1}" in
			J) [[ "$S" != *"Japan"*    ]] && S+="${S:+, }Japan"    ;;
			U) [[ "$S" != *"Americas"* ]] && S+="${S:+, }Americas" ;;
			E) [[ "$S" != *"Europe"*   ]] && S+="${S:+, }Europe"   ;;
		esac
	done
	[[ -n "$S" ]] || S="Unknown"
	printf "%s" "$S"
}

# Sega CD / Saturn region map
#	$1 = region letter codes
region_map_segacd-saturn() {
	local CODES="$1"
	local NAMES C N I
	for (( I=0; I<${#CODES}; I++ )); do
		C="${CODES:$I:1}"
		case "$C" in
			J) N="Japan" ;;  U) N="USA" ;;  E) N="Europe" ;;  T) N="Asia" ;;
			*) N="$C"    ;;
		esac
		[[ -n "$NAMES" ]] && NAMES+="/"
		NAMES+="$N"
	done
	printf "%s" "${NAMES}"
}

# Sega CD disc type/category map
#	$1 = type code
category_map_gen-segacd() {
	local CAT="${1^^}"
	local S
	case "$CAT" in
		GM) S="Game" ;;  AL) S="Educational" ;;  *) S="Unknown" ;;
	esac
	printf "%s" "$S"
}

# Sega CD / Genesis peripheral map
#	$1 = peripheral letters string (e.g., "J", "J A", "J M", "J6")
# Covers Sega CD + Genesis/Mega Drive device letters
peripherals_map_gen-segacd() {
	local P="$1"
	local OUT C

	for (( I=0; I<${#P}; I++ )); do
		C="${P:$I:1}"
		case "$C" in
			[[:space:]]) continue ;;
			O) OUT+="${OUT:+, }Master System Joystick" ;;
			J) OUT+="${OUT:+, }Genesis Joystick" ;;
			6) OUT+="${OUT:+, }6-button Controller" ;;
			0) OUT+="${OUT:+, }SMS Controller" ;;
			4) OUT+="${OUT:+, }Multitap" ;;
			G) OUT+="${OUT:+, }Lightgun" ;;
			L) OUT+="${OUT:+, }Activator" ;;
			K) OUT+="${OUT:+, }Keyboard" ;;
			R) OUT+="${OUT:+, }Serial I/O (RS232C)" ;;
			P) OUT+="${OUT:+, }Printer" ;;
			T) OUT+="${OUT:+, }Tablet" ;;
			B) OUT+="${OUT:+, }Trackball" ;;
			V) OUT+="${OUT:+, }Paddle Controller" ;;
			A) OUT+="${OUT:+, }Analog Joystick" ;;
			M) OUT+="${OUT:+, }Mouse" ;;
			C) OUT+="${OUT:+, }CD-ROM" ;;
			F) OUT+="${OUT:+, }Floppy Drive" ;;
		esac
	done

	printf "%s" "${OUT:-Unknown}"
}

# Format a Sega CD date string
#	$1 = raw date string
# Returns formatted string, or original if not in the expected format
format_gen-segacd_date() {
	local D="$1"
	local YEAR MON
	# Accept YYYY[.,]MON
	if [[ "$D" =~ ^([0-9]{4})[.,]?([A-Za-z]{3})$ ]]; then
		YEAR="${BASH_REMATCH[1]}"
		MON="${BASH_REMATCH[2]^^}"
		printf "%s" "${MON} ${YEAR}"
	else
		echo "$D"
	fi
}

# Saturn peripheral map
#	$1 = peripheral letters string (e.g., "J", "J A", "J M")
peripherals_map_saturn() {
	local P="$1"
	local OUT C

	for (( I=0; I<${#P}; I++ )); do
		C="${P:$I:1}"
		case "$C" in
			[[:space:]]) continue ;;
			J) OUT+="${OUT:+, }Control Pad" ;;
			A) OUT+="${OUT:+, }Analog Controller" ;;
			M) OUT+="${OUT:+, }Mouse" ;;
			K) OUT+="${OUT:+, }Keyboard" ;;
			S) OUT+="${OUT:+, }Steering Controller" ;;
			T) OUT+="${OUT:+, }Multitap" ;;
			G) OUT+="${OUT:+, }Virtua Gun / Stunner" ;;
			C) OUT+="${OUT:+, }Serial Link Cable" ;;
			P) OUT+="${OUT:+, }MPEG Card" ;;
			F) OUT+="${OUT:+, }Disk Drive" ;;
			D) OUT+="${OUT:+, }Modem" ;;
			X) OUT+="${OUT:+, }XBand" ;;
			R) OUT+="${OUT:+, }Data Cartridge (ROM Cart)" ;;
			W) OUT+="${OUT:+, }RAM Expansion Cart" ;;
			E) OUT+="${OUT:+, }Save Cart" ;;
		esac
	done

	printf "%s" "${OUT:-Unknown}"
}

# Format a Saturn date string
#	$1 = raw date string
# Returns formatted string, or original if not in the expected format
format_saturn-dreamcast_date() {
	local D="$1"
	if [[ "$D" =~ ^[0-9]{8}$ ]]; then
		printf "%s" "${D:0:4}-${D:4:2}-${D:6:2}"
	else
		printf "%s" "$D"
	fi
}

# Dreamcast peripheral map
#	$1 = 7-digit hex string from IP.BIN peripherals field (28-bit bitfield)
peripherals_map_dreamcast() {
	local HEX="${1^^}"
	local V=$((16#$HEX))
	local OUT

	# Optional peripherals / controller requirements (bits 27-6)
	(( V & 0x08000000 )) && OUT+="${OUT:+, }Mouse"
	(( V & 0x04000000 )) && OUT+="${OUT:+, }Keyboard"
	(( V & 0x02000000 )) && OUT+="${OUT:+, }Gun"

	# If any analog, expanded, or extra button bits are set, add once
	(( V & 0x01FFF000 )) && OUT+="${OUT:+, }Analog Controller"

	(( V & 0x00000800 )) && OUT+="${OUT:+, }Memory Card"
	(( V & 0x00000400 )) && OUT+="${OUT:+, }Mike Device"
	(( V & 0x00000200 )) && OUT+="${OUT:+, }Puru Puru Pack"
	(( V & 0x00000100 )) && OUT+="${OUT:+, }Other Expansions"

	# Expansion units / system flags (lower 6 bits)
	(( V & 0x00000020 )) && OUT+="${OUT:+, }Uses Windows CE"
	(( V & 0x00000010 )) && OUT+="${OUT:+, }VGA Box Support"

	printf "%s" "${OUT:-Standard Controller}"
}

# NES mapper number to name
#	$1 = mapper number
mapper_map_nes() {
	local V="$1"
	local S
	case "$V" in
		  0) S="NROM"           ;;   1)  S="MMC1"              ;;
		  2) S="UxROM"          ;;   3)  S="CNROM"             ;;
		  4) S="MMC3"           ;;   5)  S="MMC5"              ;;
		  6) S="FFE"            ;;   7)  S="AxROM"             ;;
		  9) S="MMC2"           ;;   10) S="MMC4"              ;;
		 11) S="Color Dreams"   ;;   12) S="Rex Soft"          ;;
		 13) S="MMC1 (var)"     ;;   14) S="Rex Soft SL12"     ;;
		 15) S="100-in-1"       ;;   16) S="Bandai FCG"        ;;
		 17) S="FFE F8"         ;;   18) S="Jaleco SS8805"     ;;
		 19) S="Namcot 163"     ;;   21) S="VRC4"              ;;
		 22) S="VRC2a/VRC4"     ;;   23) S="VRC2b"             ;;
		 24) S="VRC6"           ;;   25) S="VRC2c/VRC4"        ;;
		 26) S="VRC6b"          ;;   27) S="VRC2d/VRC4"        ;;
		 28) S="Action 53"      ;;   30) S="UNROM 512"         ;;
		 31) S="infinite NES"   ;;   32) S="Irem G-101"        ;;
		 33) S="Taito TC0190"   ;;   34) S="BNR"               ;;
		 37) S="MMC3+Battery"   ;;   38) S="FFE F4"            ;;
		 40) S="SMB2j"          ;;   41) S="Caltron 6in1"      ;;
		 42) S="FFE F3"         ;;   45) S="Super Mario World" ;;
		 46) S="Rumble Station" ;;   47) S="NES-EVENT"         ;;
		 48) S="TC0690"         ;;   51) S="NES-EVENT+Battery" ;;
		 57) S="Sachen"         ;;   61) S="Sachen"            ;;
		 62) S="Sachen"         ;;   64) S="RAMBO-1"           ;;
		 65) S="Irem H300"      ;;   66) S="GxROM"             ;;
		 67) S="Sunsoft"        ;;   68) S="Sunsoft"           ;;
		 69) S="Sunsoft FME-7"  ;;   70) S="Bandai 74161"      ;;
		 71) S="Camerica"       ;;   72) S="Jaleco JF-17"      ;;
		 73) S="Konami VRC3"    ;;   74) S="MMC3+ROM"          ;;
		 75) S="Konami VRC1"    ;;   76) S="Namcot 118"        ;;
		 77) S="NES-EVENT"      ;;   78) S="Irem LROG"         ;;
		 79) S="AVE Nina"       ;;   80) S="Taito X1-005"      ;;
		 82) S="Taito X1-017"   ;;   84) S="Sachen 3013"       ;;
		 85) S="Konami VRC7"    ;;   86) S="Jaleco JF-13"      ;;
		 87) S="Sachen"         ;;   88) S="Nihon"             ;;
		 89) S="Sunsoft 3"      ;;   90) S="Pirate"            ;;
		 91) S="Pirate"         ;;   92) S="MMC3+ROM"          ;;
		 93) S="Sunsoft 2"      ;;   94) S="MMC3+ROM"          ;;
		 95) S="PCM"            ;;   96) S="Sachen 3014"       ;;
		 97) S="Irem TAM-S1"    ;;   99) S="VS System"         ;;
		105) S="MMC1+Battery"   ;;  115) S="MMC3+ROM"          ;;
		118) S="TxSROM"         ;;  119) S="TQROM"             ;;
		132) S="TxPROM"         ;;  154) S="MMC3+ROM"          ;;
		155) S="MMC1+ROM"       ;;  180) S="UNROM+32K"         ;;
		185) S="CNROM+CIC"      ;;  206) S="ExROM"             ;;
		207) S="Taito X1-005"   ;;  210) S="Sachen 3017"       ;;
		218) S="BMC"            ;;    *) S="Unknown"           ;;
	esac
	printf "%s" "$S"
}

# Super Nintendo region map (country/destination byte)
#	$1 = region byte value
region_map_snes() {
	local V="$1"
	local S
	case "$V" in
		      00) S="Japan"       ;;  01) S="USA/Canada"  ;;  02) S="Europe"  ;;
			  03) S="Scandinavia" ;;  04) S="Finland"     ;;  05) S="Denmark" ;;
		      06) S="France"      ;;  07) S="Netherlands" ;;  08) S="Spain"   ;;
		      09) S="Germany"     ;;  0a) S="Italy"       ;;  0b) S="China/HK";;
		      0c) S="Indonesia"   ;;  0d) S="South Korea" ;;  0e) S="Common"  ;;
		      0f) S="Canada"      ;;  10) S="Brazil"      ;;
			  11) S="Australia"   ;;  12|13|14) S="Other" ;;   *) S="Unknown"      ;;
	esac
	printf "%s" "$S"
}

#	$1 = map mode byte
#	$2 = nameref: receives map mode label
#	$3 = nameref: receives speed string
mapmode_map_snes() {
	local V="$1"
	local -n LABEL_REF="$2" SPEED_REF="$3"
	(( 16#$V & 0x10 )) && SPEED_REF="3.58MHz" || SPEED_REF="2.68MHz"
	case $(( 16#$V & 0x0F )) in
		0)  LABEL_REF="LoROM"           ;;   1) LABEL_REF="HiROM"           ;;
		2)  LABEL_REF="LoROM + S-DD1"   ;;   3) LABEL_REF="LoROM + SA-1"    ;;
		5)  LABEL_REF="ExHiROM"         ;;  10) LABEL_REF="HiROM + SPC7110" ;;
		*)  LABEL_REF="Unknown"         ;;
	esac
}

# Super Nintendo cartridge type
#	$1 = cart type byte
# Returns cart type description
carttype_map_snes() {
	local V="$1"
	local LO HI S
	LO=$(( 16#$V & 0x0F ))
	HI=$(( 16#$V >> 4 ))
	case "$LO" in
		0) S="ROM"                 ;;  1) S="ROM + RAM"      ;;
		2) S="ROM + RAM + Battery" ;;  *) S="ROM (type $LO)" ;;
	esac
	case "$HI" in
		 0) ;;  # None
		 1) S="$S + Super FX" ;;   3) S="$S + SA-1"            ;;
		 4) S="$S + S-DD1"    ;;   5) S="$S + S-RTC"           ;;
		13) S="$S + OBC1"     ;;  14) S="$S + SGB/Satellaview" ;;
		15) S="$S + SPC7110"  ;;
	esac
	printf "%s" "$S"
}

# Nintendo 64 region map
#	$1 = region letter
region_map_n64() {
	local V="$1"
	local S
	case "$V" in
		E) S="North America"     ;;    J) S="Japan"       ;;  P) S="Europe" ;;
		U) S="Australia"         ;;  X|Y) S="Europe"      ;;  F) S="France" ;;
		D) S="Germany"           ;;    I) S="Italy"       ;;  S) S="Spain"  ;;
		H) S="Netherlands"       ;;    W) S="Scandinavia" ;;  B) S="Brazil" ;;
		C) S="China"             ;;    K) S="Korea"       ;;  N) S="Canada" ;;
		G) S="Gateway 64 (NTSC)" ;;    A) S="Asia"        ;;    
		L) S="Gateway 64 (PAL)"  ;;    *) S="Unknown"     ;;
	esac
	printf "%s" "$S"
}

# Nintendo 64 media format map
#	$1 = category char
mediaformat_map_n64() {
	local V="$1"
	local S
	case "$V" in
		N) S="Game Pak"              ;;  D) S="64DD Disk"             ;;
		C) S="Game Pak (expandable)" ;;  E) S="64DD Disk (expansion)" ;;
		Z) S="Aleck64"               ;;  *) S="Unknown"               ;;
	esac
	printf "%s" "$S"
}

# GameCube / Wii region code map
#	$1 = region code
region_map_gc-wii() {
	local V="$1"
	local S
	case "$V" in
		0) S="Japan"       ;;  1) S="USA"   ;;  2) S="Europe"  ;;
		3) S="Region Free" ;;  4) S="Korea" ;;  *) S="Unknown" ;;
	esac
	printf "%s" "$S"
}

# GameCube / Wii country-code fallback region map
# BI2 is compressed within RVZ images; country code provides good approximation
#	$1 = country letter (single ASCII char)
countrycode_map_gc-wii() {
	local V="$1"
	local S
	case "$V" in
		        [JQ]) S="Japan"         ;;  [EBN]) S="USA"           ;;
		[PDFILMRSUV]) S="Europe"        ;;   [KT]) S="Korea"         ;;
		       [XYZ]) S="NTSC-U or PAL" ;;      W) S="NTSC-J or PAL" ;;
		           A) S="Region Free"   ;;      *) S="Unknown"       ;;
	esac
	printf "%s" "$S"
}

# Wii U region code map
#	$1 = 8-char hex region code
region_map_wiiu() {
	local V="${1^^}"
	local S
	case "$V" in
		00000001) S="Japan"        ;;  00000002) S="USA"         ;;
		00000004) S="Europe"       ;;  00000008) S="Australia"   ;;
		00000010) S="China"        ;;  00000020) S="Korea"       ;;
		00000040) S="Taiwan"       ;;  FFFFFFFF) S="Region Free" ;;
		       *) S="Unknown ($V)" ;;
	esac
	printf "%s" "$S"
}

# Wii U title-type map
#	$1 = upper 8 hex chars of Title ID
type_map_wiiu() {
	local V="${1^^}" S
	case "$V" in
		00050002) S="eShop / Kiosk Demo"   ;;  00050000) S="Game / App"  ;;
		00050010) S="System / Overlay App" ;;  0005000C) S="DLC"         ;;
		0005000E) S="Update"               ;;  0005001B) S="Shared Data" ;;
		0005004E) S="Disc Update Package"  ;;  00050030) S="Overlay App" ;;
		       *) S="Unknown"              ;;
	esac
	printf "%s" "$S"
}

# 3DS TMD title-type map (from upper title-ID prefix)
#	$1 = 8-char hex title-ID prefix (upper)
type_map_3ds_tmd() {
	local V="${1^^}"
	local S
	case "$V" in
		00040000) S="Base"    ;;  00040002) S="Demo"    ;;
		0004000E) S="Update"  ;;  0004008C) S="DLC"     ;;
		00048004) S="DSiWare" ;;         *) S="Unknown" ;;
	esac
	printf "%s" "$S"
}

# Wii U OS-version map (from u64 system_version / <os_version>)
#	$1 = 16-char hex OS version
osversion_map_wiiu() {
	local V="${1^^}"
	local S
	case "$V" in
		0005001010004000) S="OSv0 (cafe2wii / vWii)"         ;;
		0005001010004001) S="OSv1 (enhanced vWii for eShop)" ;;
		0005001010004009) S="OSv9 (launch-day OS)"           ;;
		000500101000400A) S="OSv10 (base release OS)"        ;;
		00050010100040FF) S="OSv255 (system updater)"        ;;
		               *) S="$V"                             ;;
	esac
	printf "%s" "$S"
}

# Extract GameCube / Wii long title and publisher from opening.bar
# dolphin-tool is required for this - returns null if unavailable
#	$1 = source file (ISO or RVZ)
#	$2 = title nameref
#	$3 = publisher nameref
#	$4 = titleid nameref
title_publisher_parse_gc-wii() {
	local SOURCE="$1"
	local -n TITLE_REF="$2" PUB_REF="$3" TITLEID_REF="$4"
	local INFO_OFF="0x1860" BNRDIR BNRFILE BNR_MAGIC SLOT OFF RAW S JSON TID_DEC
	local PLATFORM

	TITLE_REF=""; PUB_REF=""; TITLEID_REF=""
	optbincheck dolphin-tool || return 1

	BNRDIR=$(mktemp -d -p "${TEMPDIR}" "gcwii.XXXXX") || return 1
	if ! dolphin-tool extract -i "$SOURCE" -s "/opening.bnr" -o "$BNRDIR" -q >/dev/null 2>&1; then
		debug "dolphin-tool extract failed for opening.bnr"
		rmdir "$BNRDIR" 2>/dev/null
		return 1
	fi

	# GC and Wii have different paths
	if [[ -f "$BNRDIR/files/opening.bnr" ]]; then
		BNRFILE="$BNRDIR/files/opening.bnr"
		PLATFORM="gc"
	elif [[ -f "$BNRDIR/DATA/files/opening.bnr" ]]; then
		BNRFILE="$BNRDIR/DATA/files/opening.bnr"
		PLATFORM="wii"
	else
		debug "opening.bnr not found in extracted output"
		rmdir -p "$BNRDIR" 2>/dev/null
		return 1
	fi

	BNR_MAGIC=$(read_hex "$BNRFILE" 0 4)

	# If GC detected via BNR1 or BNR2 signature
	if [[ "$BNR_MAGIC" == "424e5231" || "$BNR_MAGIC" == "424e5232" ]]; then
		# Read title and publisher from BNR
		hex_to_ascii "$(read_hex "$BNRFILE" "$INFO_OFF" 64)" TITLE_REF
		trim_var TITLE_REF

		hex_to_ascii "$(read_hex "$BNRFILE" $((INFO_OFF + 0x40)) 64)" PUB_REF
		optbincheck iconv && PUB_REF=$(printf '%s' "$PUB_REF" | iconv -f CP1252 -t UTF-8 2>/dev/null)
		trim_var PUB_REF

	# If Wii detected via IMET signature
	elif [[ "$(read_hex "$BNRFILE" 0x40 4)" == "494d4554" ]]; then
		# Read title from IMET; 10 language slots are included - prefer English
		# Requires iconv - return null for Wii title if unavailable
		if optbincheck iconv; then
			for SLOT in 1 0 2 3 4 5 6 7 8 9; do
				OFF=$((0x5C + SLOT * 0x54))
				RAW=$(read_bytes "$BNRFILE" "$OFF" 84 \
					| iconv -f UTF-16BE -t UTF-8 -c 2>/dev/null | tr -d '\0')
				S="${RAW%%$'\n'*}"
				[[ -n "$S" ]] && { TITLE_REF="$S"; break; }
			done
		else
			debug "iconv missing; IMET long title not decoded"
		fi
	fi

	# Cleanup the extracted banner dir
	rm -rf "$BNRDIR"

	# Wii also includes a title ID in the ticket; use dolphin-tool to decrypt
	# GC has no Title ID; the platform guard keeps I/O bounded for GC
	if [[ "$PLATFORM" == "wii" ]]; then
		JSON=$(dolphin-tool header -i "$SOURCE" -j 2>/dev/null)
		if [[ -z "$JSON" ]]; then
			debug "dolphin-tool header returned no JSON for Wii Title ID"
			return 1
		fi
		TID_DEC=$(printf '%s' "$JSON" | sed -n 's/.*"title_id":[[:space:]]*\([0-9][0-9]*\).*/\1/p')
		# Format as hyphenated hex pair
		[[ -n "$TID_DEC" ]] && TITLEID_REF=$(printf '%08x-%08x' $((TID_DEC >> 32)) $((TID_DEC & 0xFFFFFFFF)))
	fi
}

# Extract Wii WAD titel from IMET w/ English priority
# Requires iconv - return null if unavailable
#	$1 = source file
#	$2 = byte offset of the IMET slots start
#	$3 = title nameref (set to first non-empty decoded title)
title_map_wad_wii() {
	local SOURCE="$1" SLOTS_OFF="$2"
	local -n TITLE_REF="$3"
	local SLOT OFF RAW S

	TITLE_REF=""
	optbincheck iconv || return 1
	for SLOT in 1 0 2 3 4 5 6 7 8 9; do
		OFF=$((SLOTS_OFF + SLOT * 0x54))
		RAW=$(read_bytes "$SOURCE" "$OFF" 84 \
			| iconv -f UTF-16BE -t UTF-8 -c 2>/dev/null | tr -d '\0')
		S="${RAW%%$'\n'*}"
		[[ -n "$S" ]] && { TITLE_REF="$S"; break; }
	done
}

# Game Boy / Game Boy Color region map
#	$1 = destination code byte
region_map_gb() {
	local V="$1"
	local S
	case "$V" in
		00) S="Japan/Overseas" ;; 01) S="Japan" ;;  *) S="Unknown" ;;
	esac
	printf "%s" "$S"
}

# Game Boy / Game Boy Color cartridge type map
#	$1 = cart type byte
carttype_map_gb() {
	local V="$1"
	local S
	case "$V" in
		00) S="ROM Only"                ;;  01) S="MBC1"                      ;;
		02) S="MBC1+RAM"                ;;  03) S="MBC1+RAM+Battery"          ;;
		05) S="MBC2+RAM+Battery"        ;;  08) S="ROM+RAM"                   ;;
		09) S="ROM+RAM+Battery"         ;;  0b) S="MMM01"                     ;;
		0c) S="MMM01+RAM"               ;;  0d) S="MMM01+RAM+Battery"         ;;
		0f) S="MBC3+Timer+RAM+Battery"  ;;  10) S="MBC3+Timer+RAM+Battery+RTC";;
		11) S="MBC3"                    ;;  12) S="MBC3+RAM"                  ;;
		13) S="MBC3+RAM+Battery"        ;;  19) S="MBC5"                      ;;
		1a) S="MBC5+RAM"                ;;  1b) S="MBC5+RAM+Battery"          ;;
		1c) S="MBC5+Rumble+RAM"         ;;  1d) S="MBC5+Rumble+RAM+Battery"   ;;
		1e) S="MBC5+Rumble"             ;;  20) S="MBC6"                      ;;
		22) S="MBC7+RAM+Battery+Sensor" ;;  fc) S="Pocket Camera"             ;;
		fd) S="Bandai TAMA5"            ;;  fe) S="Hudson HuC-3"              ;;
		ff) S="Hudson HuC-1"            ;;   *) S="Unknown"                   ;;
	esac
	printf "%s" "$S"
}

# Game Boy / Game Boy Color RAM size map
#	$1 = RAM size code
ramsize_map_gb() {
	local V="$1"
	local S
	case "$V" in
		00) S="None"   ;;  01) S="2 KB"  ;;  02) S="8 KB"    ;;  03) S="32 KB";;
		04) S="128 KB" ;;  05) S="64 KB" ;;   *) S="Unknown" ;;
	esac
	printf "%s" "$S"
}

# Game Boy Advance region map
#	$1 = 4th char of game code
region_map_gba() {
	local V="$1"
	local S
	case "$V" in
		J) S="Japan"   ;;  E) S="North America" ;;  P) S="Europe" ;;
		D) S="Germany" ;;  F) S="France"        ;;  I) S="Italy"  ;;
		S) S="Spain"   ;;  *) S="Unknown"       ;;
	esac
	printf "%s" "$S"
}


# Nintendo DS region map
#	$1 = region letter
# Returns region name
region_map_nds() {
	local V="$1"
	local S
	case "$V" in
		E) S="USA/English" ;;  J) S="Japan"   ;;  P) S="Europe" ;;
		D) S="Germany"     ;;  F) S="France"  ;;  I) S="Italy"  ;;
		S) S="Spain"       ;;  K) S="Korea"   ;;  C) S="China"  ;;
		*) S="Unknown"     ;;
	esac
	printf "%s" "$S"
}

# Nintendo DS region flag map
#	$1 = region flag
region_flag_map_nds() {
	local V="$1"
	local S
	case "$V" in
		00) S="Normal"  ;;  40) S="Korea" ;;  80) S="China" ;;
		 *) S="Unknown" ;;
	esac
	printf "%s" "$S"
}

# Nintendo DS banner title + publisher map
# Priority: English, then first available
# iconv is required for this - returns null if unavailable
#	$1 = source file
#	$2 = banner offset (absolute, in bytes)
#	$3 = nameref for title result
#	$4 = nameref for publisher result
title_publisher_parse_nds() {
	local SOURCE="$1" BANNER_OFF="$2"
	local -n TITLE_REF="$3" PUB_REF="$4"
	local SLOT RAW

	[[ "$BANNER_OFF" -le 0 ]] && return 1
	optbincheck iconv || return 1

	for SLOT in 1 0 2 3 4 5 6 7; do
		RAW=$(read_bytes "$SOURCE" $((BANNER_OFF + 0x340 + SLOT * 0x100)) $((0x100)) \
			| iconv -f UTF-16LE -t UTF-8 -c 2>/dev/null | tr -d '\0')
		debug "DS raw banner title = ${RAW}"
		# The banner slot may contain multiple newlines; take last line as
		# publisher, and concatenate previous lines as title
		while [[ "$RAW" == *$'\n' ]]; do
			RAW="${RAW%$'\n'}"
		done
		if [[ "$RAW" == *$'\n'* ]]; then
			PUB_REF="${RAW##*$'\n'}"
			TITLE_REF="${RAW%$'\n'*}"
			# Normalize embedded newlines in the title to spaces.
			TITLE_REF="${TITLE_REF//$'\n'/ }"
		else
			TITLE_REF="$RAW"
		fi
		[[ -n "$TITLE_REF" ]] || continue
		return 0
	done
}

# Nintendo DS unit code to platform mapper
#	$1 = unitcode hex
#	$2 = platform name nref
#	$3 = product prefix nref
unitcode_map_nds() {
	local U="$1"
	local -n PLAT="$2" PREF="$3"
	local S
	case "$U" in
		00) PLAT="Nintendo DS";       PREF="NTR"     ;;
		02) PLAT="Nintendo DS + DSi"; PREF="NTR/TWL" ;;
		03) PLAT="Nintendo DSi";      PREF="TWL"     ;;
		 *) S="Unknown"                              ;;
	esac
}

# Nintendo 3DS region lockout map
#	$1 = region lockout u32 (decimal int)
# Returns region name; 0x7FFFFFFF = region free
region_map_3ds() {
	local V="$1"
	local S=""
	(( V == 0x7FFFFFFF )) && { printf "Region Free"; return; }

	(( V & 0x01 )) && S+="Japan"
	(( V & 0x02 )) && S+="${S:+, }North America"
	(( V & 0x04 )) && S+="${S:+, }Europe"
	(( V & 0x08 )) && S+="${S:+, }Australia"
	(( V & 0x10 )) && S+="${S:+, }China"
	(( V & 0x20 )) && S+="${S:+, }Korea"
	(( V & 0x40 )) && S+="${S:+, }Taiwan"

	[[ -n "$S" ]] || S="Unknown"
	printf "%s" "$S"
}

# Nintendo 3DS NCCH content type map
#	$1 = NCCH+0x18D flag byte
category_map_3ds() {
	local F="$1"
	local S
	if   (( F & 0x02 ));  then S="Game Card"
	elif (( F & 0x08 ));  then S="Manual"
	elif (( F == 0x0C )); then S="Download Play Child"
	elif (( F & 0x04 ));  then S="System Update"
	elif (( F & 0x10 ));  then S="Trial"
	elif (( F & 0x01 ));  then S="Data"
	else                       S="Unknown"
	fi
	printf "%s" "$S"
}

# Nintendo 3DS card type map
#	$1 = card type (0x18D)
cardtype_map_3ds() {
	local V="$1"
	local S
	case "$V" in
		00) S="Inner Device" ;;  01) S="Card1"           ;;
		02) S="Card2"        ;;  03) S="Extended Device" ;;
		*)  S="Unknown"      ;;
	esac
	printf "%s" "$S"
}

# Nintendo 3DS SMDH title + publisher map
# Priority: English, then first available
#	$1 = path to extracted SMDH icon file (e.g. icon.bin from ctrtool)
#	$2 = nameref for title result
#	$3 = nameref for publisher result
# Returns empty for both if SMDH magic missing/iconv absent/no slot has data
title_publisher_parse_3ds() {
	local ICON="$1"
	local -n TITLE_REF="$2" PUB_REF="$3"
	local SLOT OFF RAW REST SHORT SIG

	# Verify SMDH magic ('SMDH' @ offset 0, 4 bytes)
	hex_to_ascii "$(read_hex "$ICON" 0 4)" SIG
	[[ "$SIG" == "SMDH" ]] || return 1
	optbincheck iconv || return 1

	for SLOT in 1 0 2 3 4 5 6 7 8 9 10 11; do
		OFF=$((0x08 + SLOT * 0x200))
		# Short Description (0x80 bytes UTF-16LE) at slot start
		RAW=$(read_bytes "$ICON" "$OFF" $((0x80)) \
			| iconv -f UTF-16LE -t UTF-8 -c 2>/dev/null | tr -d '\0')
		SHORT="${RAW%%$'\n'*}"
		[[ -n "$SHORT" ]] || continue
		TITLE_REF="$SHORT"
		# Publisher (0x80 UTF-16LE bytes @ slot +0x180)
		RAW=$(read_bytes "$ICON" $((OFF + 0x180)) $((0x80)) \
			| iconv -f UTF-16LE -t UTF-8 -c 2>/dev/null | tr -d '\0')
		PUB_REF="${RAW%%$'\n'*}"
		return 0
	done
}

# Nintendo 3DS NCCH platform mapper (NCCH header byte @ +0x18C).
#	$1 = platform byte
# Returns formatted platform name
platform_map_3ds() {
	local P="$1"
	local S
	case "$P" in
		01) S="Nintendo 3DS"     ;;  02) S="New Nintendo 3DS" ;;
		 *) S="Unknown"          ;;
	esac
	printf "%s" "$S"
}

# Nintendo Switch content type map (CNMT @0x0C)
#	$1 = type byte decimal value
type_map_switch() {
	local T="$1"
	local S
	case "$T" in
		128) S="Application"  ;;  129) S="Patch"     ;;  130) S="AddOnContent";;
		131) S="Delta"        ;;  132) S="DataPatch" ;;    *) S="Unknown" ;;
	esac
	printf "%s" "$S"
}

# Nintendo Switch NACP supported-language bitmask decode
#	$1 = 4-byte LE bitmask value
# Returns comma-separated language code list
languages_map_switch() {
	local V="$1"
	local S I NAME
	local -a LANGS=(
		"en-US" "en-GB" "ja" "fr" "de" "es-419" "es" "it" "nl" "fr-CA" "pt"
		"ru" "ko" "zh-Hant" "zh-Hans" "pt-BR"
	)

	for (( I=0; I<16; I++ )); do
		(( V & (1 << I) )) || continue
		NAME="${LANGS[$I]}"
		[[ -n "$S" ]] && S+=","
		S+="$NAME"
	done
	printf "%s" "$S"
}

# Atari 7800 cart type map
#	$1 = cart type value (16-bit integer)
carttype_map_7800() {
	local T="$1"
	local S
	(( T & 0x0001 )) && S+=${S:+,}"POKEY@4000"
	(( T & 0x0002 )) && S+=${S:+,}"SuperGame"
	(( T & 0x0040 )) && S+=${S:+,}"POKEY@450"
	(( T & 0x0400 )) && S+=${S:+,}"POKEY@440"
	(( T & 0x8000 )) && S+=${S:+,}"POKEY@800"
	(( T & 0x0800 )) && S+=${S:+,}"YM2151"
	(( T & 0x0008 )) && S+=${S:+,}"ROM@4000"
	(( T & 0x0100 )) && S+=${S:+,}"Activision"
	(( T & 0x0200 )) && S+=${S:+,}"Absolute"
	(( T & 0x1000 )) && S+=${S:+,}"Banksets"
	printf "%s" "${S:-Standard}"
}

# Atari 7800 TV type map
#	$1 = TV type byte
tvtype_map_7800() {
	local V="$1"
	local S
	(( V & 0x01 )) && S="PAL" || S="NTSC"
	printf "%s" "$S"
}


###############################
# Metadata-processing functions
###############################

# Set metadata through function to control order; set order = output order
#	$1 = metadata key
#	$2 = metadata value
set_metadata() {
	local KEY="$1" VALUE="$2"
	[[ -z "$VALUE" ]] && return
	METADATA_ORDER+=("$KEY"$'\t'"$VALUE")
}

# Insert a blank line between metadata stanzas;
# Allows separation of multiple titles on single disc
set_metadata_separator() {
	METADATA_ORDER+=("")
}

# Display collected metadata in a formatted list
display_metadata() {
	local WIDTH=0 ENTRY KEY VALUE BASENAME OUTPUT COLOR RESET
	local -a GAME_IDS

	debug "Displaying metadata for '$FILE'"

	# Only output game id (or disc id) if quiet mode
	# Fall back to fist available metadata if neither is set
	if (( QUIET )); then
		local FIRST_VALUE=""

		for ENTRY in "${METADATA_ORDER[@]}"; do
			[[ -z "$ENTRY" ]] && continue
			KEY="${ENTRY%%$'\t'*}"
			VALUE="${ENTRY#*$'\t'}"

			[[ "$KEY" == "Disc ID" ]] && { printf '%s\n' "$VALUE"; return; }
			[[ "$KEY" == "Game ID" ]] && GAME_IDS+=("$VALUE")

			# Capture first non-empty value as fallback
			[[ -z "$FIRST_VALUE" && -n "$VALUE" ]] && FIRST_VALUE="$VALUE"
		done

		if (( ${#GAME_IDS[@]} > 0 )); then
			printf '%s\n' "${GAME_IDS[@]}"
			return 0
		fi

		[[ -n "$FIRST_VALUE" ]] && { printf '%s\n' "$FIRST_VALUE"; return; }

		return 0
	fi

	# Determine longest key for aligned output
	for ENTRY in "${METADATA_ORDER[@]}"; do
		[[ -z "$ENTRY" ]] && continue
		KEY="${ENTRY%%$'\t'*}"
		if (( ${#KEY} > WIDTH )); then
			WIDTH=${#KEY}
		fi
	done

	# Get basename of original file
	FILE="$ORIGFILE"
	BASENAME=${FILE##*/}

	# Build output in a single buffer, and olorize header unless disabled;
	# Apply color here instead of with cinfo because only header is colored
	if (( COLORIZE )); then
		COLOR=$'\033[01;33m'
		RESET=$'\033[00;00m'
	else
		COLOR=''
		RESET=''
	fi
	OUTPUT="${COLOR}${BASENAME}${RESET}"

	# Build output block, ordering fields in the order set_metadata was called
	# Align output so first column is fixed-width
	# Handle multi-line values; shift to beginning of second column
	for ENTRY in "${METADATA_ORDER[@]}"; do
		local FIRST=1 LINE

		# Empty entry = blank line separator between stanzas
		if [[ -z "$ENTRY" ]]; then
			OUTPUT+=$'\n'
			continue
		fi

		KEY="${ENTRY%%$'\t'*}"
		VALUE="${ENTRY#*$'\t'}"
		while IFS= read -r LINE; do
			if (( FIRST )); then
				OUTPUT+=$'\n'"$(printf "%-$((WIDTH + 1))s %s" "$KEY:" "$LINE")"
				FIRST=0
			else
				OUTPUT+=$'\n'"$(printf "%-$((WIDTH + 1))s %s" "" "$LINE")"
			fi
		done <<< "$VALUE"
	done

	# Print atomically
	printf '%b\n\n' "$OUTPUT"
}

# PS1 and PS2 metadata
#	$1 = file
#	$2 = SYSTEM.CNF location
extract_ps1-2() {
	local SOURCE="$1" PFILE_INFO="$2"
	local LBA SIZE BODY KEY VALUE ID

	read -r LBA SIZE <<< "$PFILE_INFO"
	if ! BODY=$(iso_read_file "$SOURCE" "$LBA" "$SIZE" | tr -d '\000' | awk 'BEGIN{RS="\x1a"} NR==1{print}'); then
		cwarn "Error: 'SYSTEM.CNF' file could not be read"
		return 2
	fi
	#printf 'BODY:\n%s\n\n' "$BODY" >&2	

	# Confirm PS1/2 ISO by CNF contents
	if [[ "$BODY" =~ BOOT ]]; then
		debug "SYSTEM.CNF validated"

		# Loop through CNF to set metadata
		while IFS='=' read -r KEY VALUE; do
			trim_var KEY
			trim_var VALUE
			case "$KEY" in
				BOOT*)  ID=${VALUE#*:}; ID=${ID##*\\}; ID=${ID%;1};
						ID=${ID/./}; ID=${ID/_/-}; ID=${ID^^}
						set_metadata "Game ID"  "$ID" ;;
				VER)    set_metadata "Version"  "$VALUE" ;;
				VMODE)  set_metadata "Standard" "$VALUE" ;;
			esac
		done <<< "$BODY"

		# Add region derived from game id
		set_metadata "Region"    "$(region_map_ps1-2 "$ID")"

		# Add additional metdata from PVD if set
		[[ "${PVD[VOLUME]}" != "PLAYSTATION" ]] && set_metadata "Title" "${PVD[VOLUME]}"
		set_metadata "Publisher" "${PVD[PUBLISHER]}"
		set_metadata "Created"   "$(pvd_format_date "${PVD[CREATED]}")"

		# Determine console from BOOT line
		if [[ "$BODY" =~ BOOT2 ]]; then
			set_metadata "Platform" "Sony PlayStation 2"
		else
			set_metadata "Platform" "Sony PlayStation"
		fi

		# Print metadata
		display_metadata
		return 0

	else
		debug "SYSTEM.CNF found but no PlayStation BOOT directive found"
		return 2
	fi
}

# PSP/PS3/Vita metadata
#	$1 = file
#	$2 = PARAM.SFO location ("LBA SIZE" for ISO; unused if $4 is provided)
#	$3 = platform (psp, ps3, or vita)
#	$4 = pre-read SFO hex (optional; skips ISO read when provided)
extract_psp-3-v() {
	local SOURCE="$1" PFILE_INFO="$2" PLAT="$3" SFO_HEX_PRE="${4:-}"
	local LBA SIZE BODY_HEX KEY VALUE ID
	local -A SFO

	# Read PARAM.SFO body from ISO, or use pre-read hex if provided
	if [[ -n "$SFO_HEX_PRE" ]]; then
		BODY_HEX="$SFO_HEX_PRE"
	else
		read -r LBA SIZE <<< "$PFILE_INFO"
		if ! BODY_HEX=$(iso_read_file_hex "$SOURCE" "$LBA" "$SIZE"); then
			cwarn "Error: 'PARAM.SFO' file could not be read"
			return 2
		fi
	fi

	# Parse once into associative array
	debug "Begin parsing PARAM.SFO"
	while IFS=$'\t' read -r KEY VALUE FMT; do
		debug "SFO '$KEY' = '$VALUE'"
		SFO[$KEY]="$VALUE"
	done < <(sfo_parse_hex "$BODY_HEX")

	# Set common properties

	# Set platform-specific properties
	case "$PLAT" in
		psp)
			ID="${SFO[DISC_ID]:0:4}-${SFO[DISC_ID]:4}"
			set_metadata "Game ID"      "$ID"
			set_metadata "Title"        "${SFO[TITLE]}"
			set_metadata "Version"      "${SFO[DISC_VERSION]}"
			set_metadata "Min Firmware" "${SFO[PSP_SYSTEM_VER]}"
			set_metadata "Category"     "${SFO[CATEGORY]} ($(category_map_psp "${SFO[CATEGORY]}"))"
			set_metadata "Parental Lvl" "${SFO[PARENTAL_LEVEL]} ($(parental_map_ps "${SFO[PARENTAL_LEVEL]}"))"
			set_metadata "Region"       "$(region_map_psp "$ID")"
			;;
		ps3)
			ID="${SFO[TITLE_ID]:0:4}-${SFO[TITLE_ID]:4}"
			set_metadata "Game ID"      "$ID"
			set_metadata "Title"        "$(title_map_ps3-v-4 SFO "TITLE")"
			set_metadata "App Version"  "${SFO[APP_VER]}"
			set_metadata "Disc Version" "${SFO[VERSION]}"
			set_metadata "Min Firmware" "${SFO[PS3_SYSTEM_VER]}"
			set_metadata "Resolution"   "${SFO[RESOLUTION]} ($(resolution_map_ps3 "${SFO[RESOLUTION]}"))"
			set_metadata "Sound Format" "${SFO[SOUND_FORMAT]} ($(sound_format_map_ps3 "${SFO[SOUND_FORMAT]}"))"
			set_metadata "Category"     "${SFO[CATEGORY]} ($(category_map_ps3 "${SFO[CATEGORY]}"))"
			set_metadata "Parental Lvl" "${SFO[PARENTAL_LEVEL]} ($(parental_map_ps "${SFO[PARENTAL_LEVEL]}"))"
			set_metadata "Region"       "$(region_map_ps3 "$ID")"
			;;
		vita)
			ID="${SFO[TITLE_ID]:0:4}-${SFO[TITLE_ID]:4}"
			set_metadata "Game ID"      "$ID"
			set_metadata "Title"        "$(title_map_ps3-v-4 SFO "TITLE")"
			set_metadata "Short Title"  "$(title_map_ps3-v-4 SFO "STITLE")"
			set_metadata "App Version"  "${SFO[APP_VER]}"
			set_metadata "Disc Version" "${SFO[VERSION]}"
			set_metadata "Min Firmware" "${SFO[PSP2_DISP_VER]}"
			set_metadata "Content ID"   "${SFO[CONTENT_ID]}"
			set_metadata "NP Comm ID"   "${SFO[NP_COMMUNICATION_ID]}"
			set_metadata "Category"     "${SFO[CATEGORY]} ($(category_map_psv-4 "${SFO[CATEGORY]}"))"
			[[ -n "${SFO[PARENTAL_LEVEL]}" ]] && set_metadata "Parental Lvl" "${SFO[PARENTAL_LEVEL]} ($(parental_map_ps "${SFO[PARENTAL_LEVEL]}"))"
			set_metadata "Region"       "$(region_map_vita "$ID")"
			;;
	esac

	# Add additional metdata from PVD if set
	set_metadata "Publisher" "${PVD[PUBLISHER]}"
	set_metadata "Preparer"  "${PVD[PREPARER]}"
	set_metadata "Created"   "$(pvd_format_date "${PVD[CREATED]}")"

	# Set platform
	case "$PLAT" in
		psp)   set_metadata "Platform" "Sony PlayStation Portable";;
		ps3)   set_metadata "Platform" "Sony PlayStation 3"	;;
		vita)  set_metadata "Platform" "Sony PlayStation Vita";;
	esac

 	# Print metadata
 	display_metadata
 	return 0
}

# PS4 metadata
# Requires global PS4_PKG_OFFSETS, which should be set during detection
#	$1 = source file
#	$2 = mode (optional): "pkg" for standalone PKG; otherwise ISO disc
extract_ps4() {
	local SOURCE="$1" MODE="${2:-iso}"
	local KEY VALUE FMT ID HEX DISC_ID DISC_ID_HEX PKG_OFF SFO_INFO SFO_OFF
	local SFO_SIZE SFO_HEX I
	local -A SFO

	# PS4 discs contain one or more game packages
	# The disc itself contains a game ID that may be different from packages
	# Read disc game ID from the disc header (ISO mode only)
	if [[ "$MODE" != "pkg" ]]; then
		HEX=$(read_hex "$SOURCE" 2560 32)
		hex_substr "$HEX" 16 16 DISC_ID_HEX
		hex_to_ascii "$DISC_ID_HEX" DISC_ID
		trim_var DISC_ID
	fi

	# Loop through each package
	for (( I=0; I<${#PS4_PKG_OFFSETS[@]}; I++ )); do
		PKG_OFF=${PS4_PKG_OFFSETS[$I]}
		debug "PS4 extraction: processing PKG $((i + 1))/${#PS4_PKG_OFFSETS[@]} at offset=$PKG_OFF"

		if ! SFO_INFO=$(ps4_pkg_sfo_info_at "$SOURCE" "$PKG_OFF"); then
			debug "PS4 extraction: PKG at $PKG_OFF has no valid SFO file table"
			continue
		fi
		read -r SFO_OFF SFO_SIZE <<< "$SFO_INFO"

		if ! SFO_HEX=$(read_hex "$SOURCE" "$SFO_OFF" "$SFO_SIZE"); then
			debug "PS4 extraction: failed to read SFO at $SFO_OFF size $SFO_SIZE"
			continue
		fi

		SFO=()
		while IFS=$'\t' read -r KEY VALUE FMT; do
			#debug "SFO '$KEY' = '$VALUE'"
			SFO[$KEY]="$VALUE"
		done < <(sfo_parse_hex "$SFO_HEX")

		# Set platform-specific properties
		[[ -n "$DISC_ID" ]] && set_metadata "Disc ID" "$DISC_ID"
		ID="${SFO[TITLE_ID]:0:4}-${SFO[TITLE_ID]:4}"
		set_metadata "Game ID"      "$ID"
		set_metadata "Title"        "$(title_map_ps3-v-4 SFO "TITLE")"
		set_metadata "App Version"  "${SFO[APP_VER]}"
		set_metadata "Disc Version" "${SFO[VERSION]}"
		set_metadata "Min Firmware" "${SFO[SYSTEM_VER]} ($(firmware_map_ps4 "${SFO[SYSTEM_VER]}"))"
		set_metadata "Content ID"   "${SFO[CONTENT_ID]}"
		set_metadata "Category"     "${SFO[CATEGORY]} ($(category_map_psv-4 "${SFO[CATEGORY]}"))"
		set_metadata "App Type"     "${SFO[APP_TYPE]} ($(app_type_map_ps4 "${SFO[APP_TYPE]}"))"
		set_metadata "Parental Lvl" "${SFO[PARENTAL_LEVEL]} ($(parental_map_ps "${SFO[PARENTAL_LEVEL]}"))"

		# Common PVD metadata and platform
		set_metadata "Created"      "$(pvd_format_date "${PVD[CREATED]}")"
		set_metadata "Platform"     "Sony PlayStation 4"

		# Separator between PKG stanzas
		[[ -n "$ID" ]] && (( i < ${#PS4_PKG_OFFSETS[@]}-1 )) && set_metadata_separator
 	done

	# Print metadata
	display_metadata
}

# PS5 metadata
#	$1 = source file
extract_ps5() {
	local SOURCE="$1"
	local HEX DISC_ID ID CNT_OFF JSON FIRMWARE CATEGORY

	# Read disc ID from PS5 BD-ROM header
	HEX=$(read_hex "$SOURCE" 3088 16)
	hex_to_ascii "$HEX" DISC_ID
	trim_var DISC_ID
	debug "PS5: disc id = '$DISC_ID'"

	# Locate CNT package and extract param.json
	if ! CNT_OFF=$(ps5_find_cnt_offset "$SOURCE"); then
		cwarn "Error: PS5 CNT package not found in '$SOURCE'"
		return 2
	fi
	debug "PS5: CNT at offset $CNT_OFF"

	if ! JSON=$(ps5_extract_param_json "$SOURCE" "$CNT_OFF"); then
		cwarn "Error: PS5 param.json not found in CNT at $CNT_OFF"
		return 2
	fi
	debug "PS5: param.json extracted (${#JSON} bytes)"

	# Set platform-specific properties
	ID=$(ps5_json_value "$JSON" "titleId")
	ID="${ID:0:4}-${ID:4}"
	FIRMWARE="$(ps5_json_value "$JSON" "requiredSystemSoftwareVersion")"
	CATEGORY="$(ps5_json_value "$JSON" "applicationCategoryType")"

	[[ -n "$DISC_ID" ]] && set_metadata "Disc ID" "$DISC_ID"
	set_metadata "Game ID"      "$ID"
	set_metadata "Title"        "$(ps5_json_title "$JSON")"
	set_metadata "Content Ver"  "$(ps5_json_value "$JSON" "contentVersion")"
	set_metadata "Master Ver"   "$(ps5_json_value "$JSON" "masterVersion")"
	set_metadata "Min Firmware" "${FIRMWARE} ($(firmware_map_ps5 "${FIRMWARE}"))"
	set_metadata "Content ID"   "$(ps5_json_value "$JSON" "contentId")"
	set_metadata "Concept ID"   "$(ps5_json_value "$JSON" "conceptId")"
	set_metadata "Category"     "$CATEGORY ($(category_map_ps5 "$CATEGORY"))"
	set_metadata "App DRM"      "$(ps5_json_value "$JSON" "applicationDrmType")"
	set_metadata "Parental Lvl" "$(ps5_json_agelevel "$JSON")"
	set_metadata "Region"       "$(region_map_ps5 "$ID")"

	# Common PVD metadata and platform
	set_metadata "Created"      "$(pvd_format_date "${PVD[CREATED]}")"
	set_metadata "Platform"     "Sony PlayStation 5"

	# Print metadata
	display_metadata
}

# Xbox metadata
# The game lives in an XDVDFS partition at a fixed raw offset (not ISO 9660).
# Parse the XDVDFS root directory for default.xbe
#	$1 = source file
#	$2 = XDVDFS descriptor byte offset (where the dual magic was matched)
extract_xbox() {
	local SOURCE="$1" XDVDFS_OFF="$2"
	local IMAGE_BASE ROOT_OFF ROOT_SECTOR ROOT_SIZE XBE_INFO XBE_SECTOR
	local XBE_SIZE XBE_OFF
	local TITLE_NAME TITLE_ID REGION_FLAGS DISC_NUMBER VERSION
	local SERIAL XMID

	# The game partition starts 32 sectors before the XDVDFS descriptor
	IMAGE_BASE=$((XDVDFS_OFF - 32 * 2048))

	hex_le32 "$(read_hex "$SOURCE" "$((XDVDFS_OFF + 0x14))" 4)" 0 ROOT_SECTOR
	hex_le32 "$(read_hex "$SOURCE" "$((XDVDFS_OFF + 0x18))" 4)" 0 ROOT_SIZE

	debug "Xbox XDVDFS: image_base=$IMAGE_BASE root_sector=$ROOT_SECTOR root_size=$ROOT_SIZE"

	if (( ROOT_SECTOR == 0 || ROOT_SIZE == 0 )); then
		cwarn "Error: Xbox root directory is empty or invalid"
		return 2
	fi

	ROOT_OFF=$((IMAGE_BASE + ROOT_SECTOR * 2048))

	XBE_INFO=$(xbox_dir_find_file "$SOURCE" "$ROOT_OFF" "$ROOT_SIZE" "default.xbe") || {
		cwarn "Error: default.xbe not found in Xbox root directory"
		return 2
	}
	read -r XBE_SECTOR XBE_SIZE _ <<< "$XBE_INFO"
	XBE_OFF=$((IMAGE_BASE + XBE_SECTOR * 2048))

	if ! xbe_parse_cert "$SOURCE" "$XBE_OFF" "$XBE_SIZE" \
			TITLE_ID TITLE_NAME REGION_FLAGS DISC_NUMBER VERSION; then
		cwarn "Error: invalid default.xbe or XBE certificate"
		return 2
	fi

	debug "Xbox default.xbe: offset=$XBE_OFF size=$XBE_SIZE"

	# Serial and XMID (derived from title_id, region flags, and version)
	xbox_serial_xmid "$TITLE_ID" "$REGION_FLAGS" "$VERSION" SERIAL XMID

	debug "Xbox Title ID: $TITLE_ID  Title: $TITLE_NAME  Region: $REGION_FLAGS"

	# Set metadata
	set_metadata "Game ID"    "$TITLE_ID"
	set_metadata "Title"      "$TITLE_NAME"
	set_metadata "Version"    "$VERSION"
	set_metadata "Serial Num" "$SERIAL"
	set_metadata "XMID"       "$XMID"
	[[ "$DISC_NUMBER" -ne 0 ]] && set_metadata "Disc Num" "$DISC_NUMBER"
	set_metadata "Region"     "$REGION_FLAGS ($(region_map_xbox "$REGION_FLAGS"))"

	# Common PVD metadata and platform
	# PVD is identical on XBOX releases, so no use here
	set_metadata "Platform"   "Microsoft Xbox"

	# Print metadata
	display_metadata
}

# Xbox digital content metadata (default.xbe)
#	$1 = XBE file
extract_xbox_xbe() {
	local SOURCE="$1"
	local TITLE_ID TITLE_NAME REGION_FLAGS DISC_NUMBER VERSION XBE_SIZE SERIAL
	local XMID

	# Parse and read all attributes from the certificate
	XBE_SIZE=$(stat -c%s "$SOURCE" 2>/dev/null)
	if ! xbe_parse_cert "$SOURCE" 0 "$XBE_SIZE" \
			TITLE_ID TITLE_NAME REGION_FLAGS DISC_NUMBER VERSION; then
		cwarn "Error: '$SOURCE' is not a valid default.xbe"
		return 2
	fi

	debug "Xbox digital: default.xbe size=$XBE_SIZE"

	# Derive serial and XMID
	xbox_serial_xmid "$TITLE_ID" "$REGION_FLAGS" "$VERSION" SERIAL XMID

	debug "Xbox Title ID: $TITLE_ID  Title: $TITLE_NAME  Region: $REGION_FLAGS"

	set_metadata "Game ID"    "$TITLE_ID"
	set_metadata "Title"      "$TITLE_NAME"
	set_metadata "Version"    "$VERSION"
	set_metadata "Serial Num" "$SERIAL"
	set_metadata "XMID"       "$XMID"
	[[ "$DISC_NUMBER" -ne 0 ]] && set_metadata "Disc Num" "$DISC_NUMBER"
	set_metadata "Region"     "$REGION_FLAGS ($(region_map_xbox "$REGION_FLAGS"))"

	# Set platform and print metadata
	set_metadata "Platform"   "Microsoft Xbox"
	display_metadata
}

# Xbox 360 metadata
# Reads core fields from default.xex plus header entry table, import libraries,
# and certificate
#	$1 = source file
#	$2 = XDVDFS descriptor byte offset
extract_x360() {
	local SOURCE="$1" DESC_OFF="$2"
	local IMAGE_BASE ROOT_OFF ROOT_SECTOR ROOT_SIZE XEX_INFO XEX_SECTOR XEX_SIZE
	local XEX_OFF XEX_MAGIC CERT_OFF COUNT ENTRY_ID ENTRY_VAL EXEC_OFF
	local IMPORTLIBS_OFF TITLE_ID VERSION BASE_VERSION DISC_NUMBER MEDIA_ID_HEX
	local REGION_FLAGS MIN_KERNEL MAX_HEX DMI_ID XEX_ID SERIAL_NUM XEMID
	local DISC_TYPE DISC_COUNT I GAMETITLE PUBLISHER

	# IMAGE_BASE is 32 sectors before the XDVDFS descriptor
	IMAGE_BASE=$((DESC_OFF - 32 * 2048))
	hex_le32 "$(read_hex "$SOURCE" "$((DESC_OFF + 0x14))" 4)" 0 ROOT_SECTOR
	hex_le32 "$(read_hex "$SOURCE" "$((DESC_OFF + 0x18))" 4)" 0 ROOT_SIZE

	if (( ROOT_SECTOR == 0 || ROOT_SIZE == 0 )); then
		cwarn "Error: Xbox 360 root directory is empty or invalid"
		return 2
	fi
	ROOT_OFF=$((IMAGE_BASE + ROOT_SECTOR * 2048))

	XEX_INFO=$(xbox_dir_find_file "$SOURCE" "$ROOT_OFF" "$ROOT_SIZE" "default.xex") || {
		cwarn "Error: default.xex not found in Xbox 360 root directory"
		return 2
	}
	read -r XEX_SECTOR XEX_SIZE _ <<< "$XEX_INFO"
	XEX_OFF=$((IMAGE_BASE + XEX_SECTOR * 2048))

	# Validate XEX2 magic ("XEX2")
	hex_to_ascii "$(read_hex "$SOURCE" "$XEX_OFF" 4)" XEX_MAGIC
	if [[ "$XEX_MAGIC" != "XEX2" ]]; then
		cwarn "Error: default.xex does not start with XEX2"
		return 2
	fi

	debug "Xbox 360 XEX2: xex_off=$XEX_OFF size=$XEX_SIZE"

	hex_be32 "$(read_hex "$SOURCE" "$((XEX_OFF + 16))" 4)" 0 CERT_OFF
	hex_be32 "$(read_hex "$SOURCE" "$((XEX_OFF + 20))" 4)" 0 COUNT
	(( COUNT > 256 )) && COUNT=256

	EXEC_OFF=0
	IMPORTLIBS_OFF=0
	# Walk header entries (stride 8 bytes: BE32 id, BE32 value)
	for (( I=0; I<COUNT; I++ )); do
		hex_be32 "$(read_hex "$SOURCE" "$((XEX_OFF + 24 + I*8))" 4)" 0 ENTRY_ID
		hex_be32 "$(read_hex "$SOURCE" "$((XEX_OFF + 28 + I*8))" 4)" 0 ENTRY_VAL
		case "$ENTRY_ID" in
			262150)  # 0x00040006 = exec info
				EXEC_OFF=$((XEX_OFF + ENTRY_VAL))
				hex_be32 "$(read_hex "$SOURCE" "$((EXEC_OFF + 4))" 4)" 0 VERSION
				hex_be32 "$(read_hex "$SOURCE" "$((EXEC_OFF + 8))" 4)" 0 BASE_VERSION
				hex_be32 "$(read_hex "$SOURCE" "$((EXEC_OFF + 12))" 4)" 0 TITLE_ID
				DISC_NUMBER=$((16#$(read_hex "$SOURCE" "$((EXEC_OFF + 18))" 1)))
				DISC_COUNT=$((16#$(read_hex "$SOURCE" "$((EXEC_OFF + 19))" 1)))
				;;
			66559)  # 0x000103ff = import libraries
				IMPORTLIBS_OFF=$((XEX_OFF + ENTRY_VAL))
				;;
		esac
		(( EXEC_OFF && IMPORTLIBS_OFF )) && break
	done

	# Certificate fields at XEX_OFF + CERT_OFF
	if (( CERT_OFF <= 0 || CERT_OFF + 0x17c > XEX_SIZE )); then
		cwarn "Error: Xbox 360 XEX certificate offset out of bounds"
		return 2
	fi
	hex_be32 "$(read_hex "$SOURCE" "$((XEX_OFF + CERT_OFF + 0x178))" 4)" 0 REGION_FLAGS
	MEDIA_ID_HEX=$(read_hex "$SOURCE" "$((XEX_OFF + CERT_OFF + 0x140))" 16)
	DMI_ID="${MEDIA_ID_HEX:0:24}"
	XEX_ID="${MEDIA_ID_HEX:24}"

	# Min-kernel version
	MIN_KERNEL=""
	if (( IMPORTLIBS_OFF )); then
		MAX_HEX=$(x360_parse_min_kernel "$SOURCE" "$XEX_OFF" "$IMPORTLIBS_OFF")
		[[ -n "$MAX_HEX" ]] && MIN_KERNEL=$(format_x360_kernel_version "$MAX_HEX")
	fi

	# Derive serial number
	SERIAL_NUM="$(printf '%s-%03d' "$(printf '%s' "$(printf '%08X' "$TITLE_ID" | cut -c1-4)" | xxd -r -p)" "$((TITLE_ID & 0xFFFF))")"

	# Derive XeMID (Xenon Master ID); Layout:
	# <Publisher 2><GameID 3+><SKU 2><Region 1><Version 1><DiscType 1><DiscNum 1><DiscCount 1>
	case "$DESC_OFF" in
		265945088)  DISC_TYPE="X" ;;  # XGD2
		34144256)   DISC_TYPE="F" ;;  # XGD3
		*)          DISC_TYPE="X" ;;
	esac
	XEMID="$(printf '%s%03d%02d%s%d%s%d%d' \
		"$(printf '%08X' "$TITLE_ID" | cut -c1-4 | xxd -r -p)" \
		"$((TITLE_ID & 0xFFFF))" "$VERSION" \
		"$(region_map_xemid_x360 "$REGION_FLAGS")" \
		"$((BASE_VERSION >> 24))" "$DISC_TYPE" "$DISC_NUMBER" "$DISC_COUNT")"

	debug "Xbox 360 Title ID: $(printf '%08X' "$TITLE_ID")  Region: $REGION_FLAGS  Version: $VERSION"

	# Title + Publisher  lives in encrypted XDBF/XSTR inside XEX; this requires
	# a level of decryption beyond the scope of this script.  Leverage abgx360
	# to descypt if available, otherwise null data returned.
	title_publisher_parse_x360 "$SOURCE" GAMETITLE PUBLISHER

	# Set metadata
	set_metadata "Game ID"      "$(printf '%08X' "$TITLE_ID")"
	set_metadata "Title"        "$GAMETITLE"
	set_metadata "Version"      "${VERSION} ($(format_x360_version "$(printf '%08X' "$VERSION")"))"
	set_metadata "Base Version" "${BASE_VERSION} ($(format_x360_version "$(printf '%08X' "$BASE_VERSION")"))"
	set_metadata "Min Firmware" "${MAX_HEX} (${MIN_KERNEL})"
	set_metadata "Serial Num"   "$SERIAL_NUM"
	set_metadata "XEX ID"       "${XEX_ID^^}"
	set_metadata "DMI ID"       "${DMI_ID^^}"
	set_metadata "XeMID"        "$XEMID"
	[[ "$DISC_NUMBER" -ne 0 ]] && set_metadata "Disc Num" "$DISC_NUMBER"
	set_metadata "Publisher"    "$PUBLISHER"
	set_metadata "Region"       "$REGION_FLAGS ($(region_map_x360 "$REGION_FLAGS"))"

	# Common PVD metadata and platform
	set_metadata "Created"      "$(pvd_format_date "${PVD[CREATED]}")"
	set_metadata "Platform"     "Microsoft Xbox 360"

	# Add note about missing abgx360 data
	optbincheck abgx360 || { set_metadata_separator; set_metadata "Notice" "abgx360 required for title and publisher"; }

	# Print metadata
	display_metadata
}

# Xbox 360 digital content metadata (STFS package)
#	$1 = STFS package file
extract_x360_stfs() {
	local SOURCE="$1"
	local HEX CONTENT_TYPE MEDIA_ID MAIN_VERSION BASE_VERSION TITLE_ID PUBLISHER
	local TITLE_NAME_HEX PUB_NAME_HEX TITLE_NAME

	# Bulk-read the plaintext XContentHeader metadata block
	HEX=$(read_hex "$SOURCE" 0x340 0x1400)

	# Extract attributes
	hex_be32 "$HEX" 4 CONTENT_TYPE
	hex_be32 "$HEX" 20 MEDIA_ID
	hex_be32 "$HEX" 24 MAIN_VERSION
	hex_be32 "$HEX" 28 BASE_VERSION
	hex_be32 "$HEX" 32 TITLE_ID

	# Read and trim publisher and title follow
	PUB_NAME_HEX="${HEX:$((0x1611 * 2 - 0x340 * 2)):$((0x80 * 2))}"
	hex_to_ucs2be "$PUB_NAME_HEX" PUBLISHER; trim_var PUBLISHER
	TITLE_NAME_HEX="${HEX:$((0x1691 * 2 - 0x340 * 2)):$((0x80 * 2))}"
	hex_to_ucs2be "$TITLE_NAME_HEX" TITLE_NAME; trim_var TITLE_NAME

	debug "Xbox 360 STFS: title_id=$(printf '%08X' "$TITLE_ID") content_type=$CONTENT_TYPE"

	# Set metadata
	set_metadata "Game ID"      "$(printf '%08X' "$TITLE_ID")"
	set_metadata "Title"        "$TITLE_NAME"
	set_metadata "Version"      "$MAIN_VERSION ($(format_x360_version "$(printf '%08X' "$MAIN_VERSION")"))"
	set_metadata "Base Version" "$BASE_VERSION ($(format_x360_version "$(printf '%08X' "$BASE_VERSION")"))"
	set_metadata "Media ID"     "$(printf '%08X' "$MEDIA_ID")"
	set_metadata "Content Type" "$(content_type_map_stfs_x360 "$CONTENT_TYPE")"
	set_metadata "Publisher"    "$PUBLISHER"
	set_metadata "Platform"     "Microsoft Xbox 360"

	# Print metadata
	display_metadata
}

# 3DO metadata
# 3DO uses the proprietary Opera filesystem; it has very little useful
# identifying information directly readable as metadata
#	$1 = source file
#	$2 = label byte offset
extract_3do() {
	local SOURCE="$1" LABEL_OFF="$2"
	local VOLUME_ID ROOT_ID

	# Volume unique identifier (4 hex bytes at label_off + 0x48)
	VOLUME_ID=$(read_hex "$SOURCE" "$((LABEL_OFF + 0x48))" 4)
	# Root unique identifier (4 hex bytes at label_off + 0x54)
	ROOT_ID=$(read_hex "$SOURCE" "$((LABEL_OFF + 0x54))" 4)

	# Set metadata
	set_metadata "Volume ID" "${VOLUME_ID^^}"
	set_metadata "Root ID"   "${ROOT_ID^^}"

	# No PVD; just set pletform and print metadata
	set_metadata "Platform"  "3DO Interactive Multiplayer"
	display_metadata
}

# Sega CD (Mega CD) metadata
# Metadata lives in the Sega IP.BIN header at LBA 0
#	$1 = source file
#	$2 = IP.BIN byte offset (lba_to_offset 0)
extract_segacd() {
	local SOURCE="$1" OFF="$2"
	local HEX NAME
	local -A IP

	# Bulk-read the 512-byte IP.BIN header, then read each field from its offset
	HEX=$(read_hex "$SOURCE" "$OFF" 512)
	hex_to_ascii "${HEX:$((256*2)):32}" IP[HARDWARE];  trim_var IP[HARDWARE]
	hex_to_ascii "${HEX:$((275*2)):10}" IP[COMPANY];   trim_var IP[COMPANY]
	hex_to_ascii "${HEX:$((280*2)):16}" IP[DATE];      trim_var IP[DATE]
	hex_to_ascii "${HEX:$((288*2)):96}" IP[DTITLE];    trim_var IP[DTITLE]
	hex_to_ascii "${HEX:$((336*2)):96}" IP[OTITLE];    trim_var IP[OTITLE]
	hex_to_ascii "${HEX:$((384*2)):4}"  IP[TYPE];      trim_var IP[TYPE]
	hex_to_ascii "${HEX:$((387*2)):16}" IP[PRODUCT];   trim_var IP[PRODUCT]
	hex_to_ascii "${HEX:$((396*2)):4}"  IP[VER];       trim_var IP[VER]
	hex_to_ascii "${HEX:$((400*2)):32}" IP[PERIPH];    trim_var IP[PERIPH]
	hex_to_ascii "${HEX:$((496*2)):32}" IP[REGION];    trim_var IP[REGION]

	#for K in "${!IP[@]}"; do printf '  [%s] = %s\n' "$K" "${IP[$K]}"; done | sort >&2

	if [[ -n "${IP[OTITLE]}" ]]; then
		NAME="${IP[OTITLE]}"
		[[ "${IP[DTITLE]}" != "${IP[OTITLE]}" ]] && NAME="${NAME} (${IP[DTITLE]})";
	else
		NAME="${IP[DTITLE]}"
	fi
	# Pass through iconv for Shift-JIS decode if available
	optbincheck iconv && NAME=$(printf '%s' "$NAME" | iconv -f SHIFT-JIS -t UTF-8//IGNORE 2>/dev/null)

	set_metadata "Game ID"   "${IP[PRODUCT]}"
	set_metadata "Title"     "$NAME"
	set_metadata "Version"   "${IP[VER]}"
	set_metadata "Date"      "$(format_gen-segacd_date "${IP[DATE]}")"
	set_metadata "Company"   "${IP[COMPANY]}"
	set_metadata "Type"      "${IP[TYPE]} ($(category_map_gen-segacd "${IP[TYPE]}"))"
	set_metadata "Periphs"   "${IP[PERIPH]} ($(peripherals_map_gen-segacd "${IP[PERIPH]}"))"
	set_metadata "Region"    "${IP[REGION]} ($(region_map_segacd-saturn "${IP[REGION]}"))"
	set_metadata "Hardware"  "${IP[HARDWARE]}"

	# Common PVD metadata and platform
	set_metadata "Publisher" "${PVD[PUBLISHER]}"
	set_metadata "Preparer"  "${PVD[PREPARER]}"
	set_metadata "Created"   "$(pvd_format_date "${PVD[CREATED]}")"
	set_metadata "Platform"  "Sega Sega CD / Mega-CD"

	# Print metadata
	display_metadata
}

# Saturn metadata
# Metadata lives in the Sega IP.BIN header at LBA 0
#	$1 = source file
#	$2 = IP.BIN byte offset (lba_to_offset 0)
extract_saturn() {
	local SOURCE="$1" OFF="$2"
	local HEX DISC
	local -A IP

	# Bulk-read the 512-byte IP.BIN header, then read each field from its offset
	HEX=$(read_hex "$SOURCE" "$OFF" 256)
	hex_to_ascii "${HEX:$((0*2)):32}"   IP[HARDWARE];  trim_var IP[HARDWARE]
	hex_to_ascii "${HEX:$((16*2)):32}"  IP[MAKER];     trim_var IP[MAKER]
	hex_to_ascii "${HEX:$((32*2)):20}"  IP[PRODUCT];   trim_var IP[PRODUCT]
	hex_to_ascii "${HEX:$((42*2)):12}"  IP[VER];       trim_var IP[VER]
	hex_to_ascii "${HEX:$((48*2)):16}"  IP[DATE];      trim_var IP[DATE]
	hex_to_ascii "${HEX:$((56*2)):16}"  IP[DEVICE];    trim_var IP[DEVICE]
	hex_to_ascii "${HEX:$((64*2)):20}"  IP[REGION];    trim_var IP[REGION]
	hex_to_ascii "${HEX:$((80*2)):32}"  IP[PERIPH];    trim_var IP[PERIPH]
	hex_to_ascii "${HEX:$((96*2)):224}" IP[TITLE];     trim_var IP[TITLE]

	#for K in "${!IP[@]}"; do printf '  [%s] = %s\n' "$K" "${IP[$K]}"; done | sort >&2	

	DISC="${IP[DEVICE]##*-}"
	DISC="${DISC%%/*}"	

	set_metadata "Game ID"   "${IP[PRODUCT]}"
	set_metadata "Title"     "${IP[TITLE]}"
	set_metadata "Version"   "${IP[VER]}"
	set_metadata "Date"      "$(format_saturn-dreamcast_date "${IP[DATE]}")"
	set_metadata "Maker"     "${IP[MAKER]}"
	set_metadata "Disc Num"  "$DISC"
	set_metadata "Periphs"   "${IP[PERIPH]} ($(peripherals_map_saturn "${IP[PERIPH]}"))"
	set_metadata "Region"    "${IP[REGION]} ($(region_map_segacd-saturn "${IP[REGION]}"))"

	# Common PVD metadata and platform
	set_metadata "Publisher" "${PVD[PUBLISHER]}"
	set_metadata "Preparer"  "${PVD[PREPARER]}"
	set_metadata "Created"   "$(pvd_format_date "${PVD[CREATED]}")"
	set_metadata "Platform"  "Sega Saturn"

	# Print metadata
	display_metadata
}

# Dreamcast metadata
# Metadata lives in the Sega IP.BIN header at LBA 0
#	$1 = source file
#	$2 = IP.BIN byte offset (lba_to_offset 0)
extract_dreamcast() {
	local SOURCE="$1" OFF="$2"
	local HEX DISC
	local -A IP

	# Bulk-read the 256-byte IP.BIN header, then read each field from its offset
	HEX=$(read_hex "$SOURCE" "$OFF" 256)
	hex_to_ascii "${HEX:$((0x00*2)):32}"   IP[HARDWARE];  trim_var IP[HARDWARE]
	hex_to_ascii "${HEX:$((0x10*2)):32}"   IP[MAKER];     trim_var IP[MAKER]
	hex_to_ascii "${HEX:$((0x20*2)):32}"   IP[DEVICE];    trim_var IP[DEVICE]
	hex_to_ascii "${HEX:$((0x30*2)):16}"   IP[AREA];      trim_var IP[AREA]
	hex_to_ascii "${HEX:$((0x38*2)):16}"   IP[PERIPH];    trim_var IP[PERIPH]
	hex_to_ascii "${HEX:$((0x40*2)):20}"   IP[PRODUCT];   trim_var IP[PRODUCT]
	hex_to_ascii "${HEX:$((0x4A*2)):12}"   IP[VER];       trim_var IP[VER]
	hex_to_ascii "${HEX:$((0x50*2)):32}"   IP[DATE];      trim_var IP[DATE]
	hex_to_ascii "${HEX:$((0x60*2)):32}"   IP[BOOT];      trim_var IP[BOOT]
	hex_to_ascii "${HEX:$((0x70*2)):32}"   IP[COMPANY];   trim_var IP[COMPANY]
	hex_to_ascii "${HEX:$((0x80*2)):256}"  IP[TITLE];     trim_var IP[TITLE]

	#for K in "${!IP[@]}"; do printf '  [%s] = %s\n' "$K" "${IP[$K]}"; done | sort >&2

	DISC="${IP[DEVICE]##*GD-ROM}"
	DISC="${DISC%%/*}"

	set_metadata "Game ID"   "${IP[PRODUCT]}"
	set_metadata "Title"     "${IP[TITLE]}"
	set_metadata "Version"   "${IP[VER]}"
	set_metadata "Date"      "$(format_saturn-dreamcast_date "${IP[DATE]}")"
	set_metadata "Maker"     "${IP[MAKER]}"
	set_metadata "Disc Num"  "$DISC"
	set_metadata "Periphs"   "${IP[PERIPH]} ($(peripherals_map_dreamcast "${IP[PERIPH]}"))"
	set_metadata "Region"    "${IP[AREA]} ($(region_map_segacd-saturn "${IP[AREA]}"))"
	set_metadata "Company"   "${IP[COMPANY]}"

	# Common PVD metadata and platform
	set_metadata "Publisher" "${PVD[PUBLISHER]}"
	set_metadata "Created"   "$(pvd_format_date "${PVD[CREATED]}")"
	set_metadata "Platform"  "Sega Dreamcast"

	# Print metadata
	display_metadata
}

# GameCube / Wii metadata
# Support extraction from both ISO and RVZ
#	$1 = source file
#	$2 = platform ("gc" or "wii")
#	$3 = disc-header file offset (0 for ISO, 0x58 for RVZ)
extract_gc-wii() {
	local SOURCE="$1" PLATFORM="$2" HDR_OFF="$3"
	local HEX DISC_ID MAKER HEADER_TITLE DISC_NUM REVISION
	local GAMETITLE PUBLISHER TITLEID REGION_HEX REGION_VAL COUNTRY_LETTER REGION_OFF

	debug "Extracting ${PLATFORM^^} metadata from '${SOURCE}' (header @${HDR_OFF})"

	# Bulk-read the 80-byte disc header, then read each field fro its offset
	HEX=$(read_hex "$SOURCE" "$HDR_OFF" 0x80)
	hex_to_ascii "${HEX:$((0x00*2)):12}"  DISC_ID;       trim_var DISC_ID
	hex_to_ascii "${HEX:$((0x04*2)):4}"   MAKER;         trim_var MAKER
	hex_to_ascii "${HEX:$((0x20*2)):128}" HEADER_TITLE;  trim_var HEADER_TITLE
	DISC_NUM=$((16#${HEX:$((0x06*2)):2}))
	REVISION=$((16#${HEX:$((0x07*2)):2}))

	# Region: read BE32 at platform-specific offset when possible;
	# Derive from the disc ID country code on RVZ where BE32 not readable
	if [[ "$PLATFORM" == "gc" ]]; then
		REGION_OFF=0x458
	else
		REGION_OFF=0x4E000
	fi
	if [[ "$HDR_OFF" -eq 0 ]]; then
		# ISO: region data is at a fixed disc offset
		REGION_HEX=$(read_hex "$SOURCE" "$REGION_OFF" 4)
		[[ -n "$REGION_HEX" ]] && hex_be32 "$REGION_HEX" 0 REGION_VAL
	else
		# RVZ: get country letter from disc id
		COUNTRY_LETTER="${DISC_ID:3:1}"
		debug "${PLATFORM^^} RVZ: region derived from country code '${COUNTRY_LETTER}'"
	fi

	# Extract opening.bnr if dolphin-tool available for long title + publisher
	title_publisher_parse_gc-wii "$SOURCE" GAMETITLE PUBLISHER TITLEID

	# Set metadata
	set_metadata "Game ID"      "$DISC_ID"
	set_metadata "Title ID"     "$TITLEID"
	set_metadata "Title"        "$GAMETITLE"
	set_metadata "Header Title" "$HEADER_TITLE"
	set_metadata "Revision"     "$REVISION"
	set_metadata "Maker Code"   "$MAKER"
	set_metadata "Publisher"    "$PUBLISHER"
	[[ "$DISC_NUM" -ne 0 ]] && set_metadata "Disc Num" "$DISC_NUM"
	if [[ -n "$REGION_VAL" ]]; then
		set_metadata "Region" "${REGION_VAL} ($(region_map_gc-wii "$REGION_VAL"))"
	else
		set_metadata "Region" "${COUNTRY_LETTER} ($(countrycode_map_gc-wii "$COUNTRY_LETTER"))"
	fi
	set_metadata "Platform"     "$([[ "$PLATFORM" == "gc" ]] && echo "Nintendo GameCube" || echo "Nintendo Wii")"

	# Add note about missing dolphin-tool data
	optbincheck dolphin-tool || {
		set_metadata_separator
		if [[ "$PLATFORM" == "gc" ]]; then
			set_metadata "Notice" "dolphin-tool required for long title and publisher"
		else
			set_metadata "Notice" "dolphin-tool required for long title, publisher, and title ID"
		fi
	}

	# Print metadata
	display_metadata
}

# Wii WAD metadata extraction
# Reads plaintext TMD fields + footer IMET
#	$1 = source file
extract_wad() {
	local SOURCE="$1"
	local A=64 CERT_OFF=64
	local HDR_HEX WAD_TYPE CERT_SZ TIK_SZ TMD_SZ DATA_SZ FOOTER_SZ TIK_OFF
	local TMD_OFF DATA_OFF FOOTER_OFF TMD_HEX TID_HEX TITLE_TYPE GROUP_ID
	local REGION_VAL TITLE_VER IMET_MAGIC GAME_ID WAD_TITLE

	debug "Extracting Wii WAD metadata from '${SOURCE}'"

	# Validate WAD header
	HDR_HEX=$(read_hex "$SOURCE" 0 0x20)
	if [[ ${HDR_HEX:0:8} != "00000020" ]]; then
		debug "invalid header size '${HDR_SIZE}'"
		return 1
	fi
	hex_to_ascii "${HDR_HEX:$((4*2)):4}" WAD_TYPE
	if [[ "$WAD_TYPE" != "Is" && "$WAD_TYPE" != "ib" ]]; then
		debug "unrecognized wad_type '${WAD_TYPE}'"
		return 1
	fi

	# Read section sizes (all BE32 in header) and compute 0x40-aligned offsets
	CERT_SZ=$((16#${HDR_HEX:$((0x08*2)):8}))
	TIK_SZ=$((16#${HDR_HEX:$((0x10*2)):8}))
	TMD_SZ=$((16#${HDR_HEX:$((0x14*2)):8}))
	DATA_SZ=$((16#${HDR_HEX:$((0x18*2)):8}))
	FOOTER_SZ=$((16#${HDR_HEX:$((0x1C*2)):8}))
	TIK_OFF=$(( CERT_OFF + (CERT_SZ + A - 1) / A * A ))
	TMD_OFF=$(( TIK_OFF + (TIK_SZ + A - 1) / A * A ))
	DATA_OFF=$(( TMD_OFF + (TMD_SZ + A - 1) / A * A ))
	FOOTER_OFF=$(( DATA_OFF + (DATA_SZ + A - 1) / A * A ))

	debug "cert=0x$(printf '%x' $CERT_OFF) tik=0x$(printf '%x' $TIK_OFF)"
	debug "tmd=0x$(printf '%x' $TMD_OFF) data=0x$(printf '%x' $DATA_OFF)"
	debug "footer=0x$(printf '%x' $FOOTER_OFF) footer_sz=$FOOTER_SZ"

	# Bulk-read TMD, then extract attributes
	TMD_HEX=$(read_hex "$SOURCE" "$TMD_OFF" 0x1E0)
	TID_HEX="${TMD_HEX:$((0x18C * 2)):16}"
	TITLE_TYPE=$((16#${TMD_HEX:$((0x194 * 2)):8}))
	hex_to_ascii "${TMD_HEX:$((0x198 * 2)):4}" GROUP_ID
	REGION_VAL=$((16#${TMD_HEX:$((0x19C * 2)):4}))
	TITLE_VER=$((16#${TMD_HEX:$((0x1DC * 2)):4}))

	# Game ID: title ID low 4 bytes as ASCII (e.g. "57523945" -> "WR9E")
	hex_to_ascii "${TID_HEX:8:8}" GAME_ID

	# Title: extract from footer IMET (plaintext banner copy)
	if (( FOOTER_SZ > 0 )); then
		IMET_MAGIC=$(read_hex "$SOURCE" "$((FOOTER_OFF + 0x80))" 4)
		if [[ "$IMET_MAGIC" == "494d4554" ]]; then
			title_map_wad_wii "$SOURCE" "$((FOOTER_OFF + 0x9C))" WAD_TITLE
		else
			debug "footer has no IMET magic (0x${IMET_MAGIC}); no title (DLC?)"
		fi
	else
		debug "no footer copy; no title"
	fi

	# Set metadata
	set_metadata "Game ID"       "$GAME_ID"
	set_metadata "Title ID"      "${TID_HEX:0:8}-${TID_HEX:8:8}"
	set_metadata "Title"         "$WAD_TITLE"
	set_metadata "Version"       "v${TITLE_VER}"
	set_metadata "Maker Code"    "$GROUP_ID"
	set_metadata "Region"        "$REGION_VAL ($(region_map_gc-wii "$REGION_VAL"))"
	set_metadata "Platform"      "Nintendo Wii"

	# Print metadata
	display_metadata
}

# Wii U WUD/WUX metadata extraction
# Supports both WUD and WUX; WUX requires previous wux_init() call + extra args
# JWUDTool and common+title keys required for full output, but gracefully
# degres to produce some output even additional dependencies
#	$1 = source file (.wud or .wux)
#	$2 = WUX mode flag (1 = WUX, 0 = plain WUD)
#	$3 = WUX data-area start offset (from wux_init, 0 for WUD)
extract_wiiu() {
	local SOURCE="$1" WUX_MODE="${2:-0}" WUX_DATA_START="${3:-0}"
	local IV_ZEROS="00000000000000000000000000000000"
	local BASE_NAME DISC_ID PRODCODE REGION GAME_KEY KEYFILE COMMON_KEY PT_MAGIC
	local COMMON_KEYFILE PT_HEX I NUM_PARTS ENTRY_OFF PART_NAME PART_NAME_HEX
	local TITLE_ID TITLE_ID_UP TYPE TDIR META_XML TITLE_NAME PUBLISHER
	local REGION_XML COMPANY_CODE TITLE_VER OS_VER META_XML_DATA

	debug "Extracting Wii U metadata from '${SOURCE}' (WUX_MODE=${WUX_MODE})"

	# For WUX, the disc header lives at virtual offset 0, which maps through
	# the LUT to a physical sector in the data area.  We must translate the
	# virtual offset to physical first.
	local HDR_OFF=0
	if (( WUX_MODE )); then
		HDR_OFF=$(wux_resolve_offset "$SOURCE" 0 "$WUX_DATA_START")
	fi

	# Read header data
	hex_to_ascii "$(read_hex "$SOURCE" $HDR_OFF 25)" DISC_ID
	hex_to_ascii "$(read_hex "$SOURCE" $((HDR_OFF + 6)) 4)" PRODCODE
	hex_to_ascii "$(read_hex "$SOURCE" $((HDR_OFF + 17)) 3)" REGION
	trim_var DISC_ID
	trim_var PRODCODE
	trim_var REGION

	debug "WiiU Tier 0: DISC_ID='${DISC_ID}' / PRODCODE='${PRODCODE}' / REGION='${REGION}'"


	# Decryption keys are required for additional data; find game (title) key
	# by matching base name either in WIIU_KEYDIR or PWD.  Look for common.key
	# in same locations.

	BASE_NAME="${SOURCE##*/}"
	BASE_NAME="${BASE_NAME%.*}"

	# Find game ke
	KEYFILE="${SOURCE%.*}.key"
	if [[ -f "$KEYFILE" ]]; then
		GAME_KEY=$(xxd -p -c 256 "$KEYFILE" | tr -d '\n')
		debug "WiiU: game key loaded from co-located '${KEYFILE}'"
	elif [[ -n "$WIIU_KEYDIR" && -f "${WIIU_KEYDIR}/${BASE_NAME}.key" ]]; then
		GAME_KEY=$(xxd -p -c 256 "${WIIU_KEYDIR}/${BASE_NAME}.key" | tr -d '\n')
		debug "WiiU: game key loaded from keydir '${WIIU_KEYDIR}/${BASE_NAME}.key'"
	fi

	# Find common key
	COMMON_KEYFILE="${SOURCE%/*}/common.key"
	if [[ -f "$COMMON_KEYFILE" ]]; then
		COMMON_KEY=$(xxd -p -c 256 "$COMMON_KEYFILE" | tr -d '\n')
		debug "WiiU: common key loaded from co-located '${COMMON_KEYFILE}'"
	elif [[ -n "$WIIU_KEYDIR" && -f "${WIIU_KEYDIR}/common.key" ]]; then
		COMMON_KEY=$(xxd -p -c 256 "${WIIU_KEYDIR}/common.key" | tr -d '\n')
		debug "WiiU: common key loaded from keydir '${WIIU_KEYDIR}/common.key'"
	fi

	# Validate keys found - skip if not
	if [[ -n "$GAME_KEY" && -n "$COMMON_KEY" ]] && optbincheck openssl; then

		# Decrypt partition table at sector, AES-128-CBC, IV=zeros
		PT_HEX=$(wud_decrypt_cbc "$SOURCE" 0x18000 0x8000 "$GAME_KEY" "$IV_ZEROS" "$WUX_MODE" "$WUX_DATA_START")
		[[ -n "$PT_HEX" ]] && hex_be32 "$PT_HEX" 0 PT_MAGIC

		# Validate partition table magic
		if [[ "$PT_MAGIC" -eq 0xcca6e67b ]]; then
			# NumPartitions at offset 0x1C (28)
			hex_be32 "$PT_HEX" 0x1C NUM_PARTS
			debug "WiiU: partition table valid, ${NUM_PARTS} partitions"

			# Walk partition entries at 0x800 (0x80-byte records)
			for (( I=0; I<NUM_PARTS; I++ )); do
				ENTRY_OFF=$(( 0x800 + I * 0x80 ))
				hex_substr "$PT_HEX" "$ENTRY_OFF" 0x1f PART_NAME_HEX
				hex_to_ascii "$PART_NAME_HEX" PART_NAME; trim_var PART_NAME

				debug "WiiU: partition[${I}] name='${PART_NAME}'"

				# Only the GM partition carries useful game metadata
				if [[ "$PART_NAME" == GM* ]]; then
					TITLE_ID_UP="${PART_NAME:2:16}"
					TITLE_ID="${TITLE_ID_UP:0:8}-${TITLE_ID_UP:8:8}"
					TYPE="${TITLE_ID_UP:0:8}"

					debug "WiiU: title_id=${TITLE_ID} type=${TYPE}"
					break
				fi
			done

			# Next step additionally requires JWUDTool; skip if not found
			if optfilecheck "$JWUDTOOL" && optbincheck java; then

				# JWUDTool decrypts meta.xml from the GM partition
				TDIR=$(mktemp -d -p "${TEMPDIR}" "wiiu.XXXXX")
				java -jar "$JWUDTOOL" -commonkey "$COMMON_KEY" \
					-titleKey "$GAME_KEY" -in "$SOURCE" -out "$TDIR" \
					-decryptFile '/meta/meta\.xml' >/dev/null 2>&1

				# Find meta.xml - support multiple GM partitions
				for META_XML in "$TDIR"/*/meta/meta.xml; do
					debug "WiiU: parsing meta.xml from '${META_XML}'"

					# Slurp meta.xml once to avoid repeated disk reads
					META_XML_DATA="$(<"$META_XML")"

					# Extract fields from meta.xml via xml_value
					TITLE_NAME=$(xml_value "$META_XML_DATA" "longname_en")
					[[ -z "$TITLE_NAME" ]] && TITLE_NAME=$(xml_value "$META_XML_DATA" "shortname_en")
					[[ -z "$TITLE_NAME" ]] && TITLE_NAME=$(xml_value "$META_XML_DATA" "longname_ja")
					[[ -z "$TITLE_NAME" ]] && TITLE_NAME=$(xml_value "$META_XML_DATA" "shortname_ja")
					TITLE_NAME=$(xml_unescape "$TITLE_NAME")

					PUBLISHER=$(xml_value "$META_XML_DATA" "publisher_en")
					PUBLISHER=$(xml_unescape "$PUBLISHER")

					PRODCODE=$(xml_value "$META_XML_DATA" "product_code")
					REGION_XML=$(xml_value "$META_XML_DATA" "region")
					COMPANY_CODE=$(xml_value "$META_XML_DATA" "company_code")
					TITLE_VER=$(xml_value "$META_XML_DATA" "title_version")
					OS_VER=$(xml_value "$META_XML_DATA" "os_version")

					# Skip dummy meta.xml (placeholder product code)
					[[ "${PRODCODE^^}" == "WUP-P-ABCD" ]] && continue

					# Decode XML entities
					[[ -n "$TITLE_NAME" ]] && TITLE_NAME=$(xml_unescape "$TITLE_NAME")
					[[ -n "$PUBLISHER" ]] && PUBLISHER=$(xml_unescape "$PUBLISHER")

					# Only process first valid meta.xml for now
					break
				done

				rm -rf "$TDIR"

			else
				debug "JWUDTool not found, or missing java"
			fi
		else
			debug "Partition table decryption failed"
		fi
	else
		debug "No game key found, or missing openssl"
	fi

	# Set metadata
	set_metadata "Game ID"      "$TITLE_ID"
	set_metadata "Disc Serial"  "$DISC_ID"  # 'serial' to avoid printing with -q
	set_metadata "Title"        "$TITLE_NAME"
	[[ -n "$TITLE_VER" ]] && set_metadata "Title Ver"    "v$((10#${TITLE_VER}))"
	[[ -n "$OS_VER" ]] && set_metadata "OS Version"   "${OS_VER} ($(osversion_map_wiiu "$OS_VER"))"
	set_metadata "Company Code" "$COMPANY_CODE"
	set_metadata "Publisher"    "$PUBLISHER"
	set_metadata "Product Code" "$PRODCODE"
	[[ -n "$TYPE" ]] && set_metadata "Type"         "$TYPE ($(type_map_wiiu "${TITLE_ID_UP:0:8}"))"

	# Display XML region if available; fallback to header region
	if [[ -n "$REGION_XML" ]]; then
		set_metadata "Region"   "$REGION_XML ($(region_map_wiiu "$REGION_XML"))"
	else
		set_metadata "Region"   "$REGION"
	fi

	# Set platform and notices
	set_metadata "Platform"     "Nintendo Wii U"

	# Check veriables that can only be set by gated conditions to simplify
	if [[ -z "$TITLE_ID" || -z "$TITLE_NAME" ]]; then
		set_metadata_separator
		[[ -z "$TITLE_ID" ]] && set_metadata "Notice" "title/common keys and openssl required for title id and type"
		[[ -z "$TITLE_NAME" ]] && set_metadata "Notice" "jwudtool and java required for title name, publisher and company, title and os ver"
	fi

	# Print metadata
	display_metadata
}

# TurboGrafx-CD / PC Engine CD-ROM2  metadata
# TG CDs have no ISO9660 filesystem; the boot header lives at LBA 1 of the
# first MODE1 track
#	$1 = source file
#	$2 = boot-header offset
extract_tgcd() {
	local SOURCE="$1" BYTE_OFF="$2"
	local HDR_TOTAL_LEN=256 COPYRIGHT_LEN=49 TITLE_LEN=22 TGCD_MAGIC_LEN=23
	local HEX COPY_OFF TITLE_LEN TITLE_HEX TGCDTITLE

	[[ -n "$BYTE_OFF" ]] || return 2

	# Skip past the fixed copyright string constant + its NUL terminator to
	# reach the title.  Each field separated by NUL (hence +2).
	HEX=$(read_hex "$SOURCE" "$BYTE_OFF" "$HDR_TOTAL_LEN")
	COPY_OFF=$(( TGCD_MAGIC_LEN + 1 + COPYRIGHT_LEN + 1 ))
	TITLE_HEX="${HEX:$((COPY_OFF * 2)):$((TITLE_LEN * 2))}"

	sfo_string_from_hex "$TITLE_HEX" TGCDTITLE

	# Pass through iconv for Shift-JIS decode if available
	optbincheck iconv && TGCDTITLE=$(printf '%s' "$TGCDTITLE" | iconv -f SHIFT-JIS -t UTF-8//IGNORE 2>/dev/null)

	# Set and print metadata - no PVD
	set_metadata "Title"    "$TGCDTITLE"
	set_metadata "Platform" "NEC TurboGrafx-CD / PC Engine CD-ROM2"
	display_metadata
}

# NEC PC-FX metadata
# PC-FX uses the proprietary Hu_CD-ROM format, no ISO 9660; boot header lives
# in the first data sector
#	$1 = source file
#	$2 = signature byte offset
#	$3 = sector size
extract_pcfx() {
	local SOURCE="$1" SIG_OFF="$2" SECT_SIZE="$3"
	local HDR_LEN=0x80 PUB_OFF=0x34 DATE_OFF=0x78
	local HDR_HEX GAMETITLE GAMEPUB GAMEDATE
	[[ -n "$SIG_OFF" ]] || return 2

	# Game header is in the data sector after the boot-signature sector
	HDR_HEX=$(read_hex "$SOURCE" $((SIG_OFF + SECT_SIZE)) "$HDR_LEN")

	# Read attributes
	sfo_string_from_hex "${HDR_HEX:0:64}" GAMETITLE
	sfo_string_from_hex "${HDR_HEX:$((PUB_OFF * 2)):64}" GAMEPUB
	hex_to_ascii "${HDR_HEX:$((DATE_OFF * 2)):16}" GAMEDATE

	# Pass title/publisher through iconv for Shift-JIS decode if available
	optbincheck iconv && GAMETITLE=$(printf '%s' "$GAMETITLE" | iconv -f SHIFT-JIS -t UTF-8//IGNORE 2>/dev/null)
	optbincheck iconv && GAMEPUB=$(printf '%s' "$GAMEPUB" | iconv -f SHIFT-JIS -t UTF-8//IGNORE 2>/dev/null)

	# Format the mastering date
	GAMEDATE="${GAMEDATE:0:4}-${GAMEDATE:4:2}-${GAMEDATE:6:2}"

	# Set and print metadata - no PVD, proprietary header only
	set_metadata "Title"     "$GAMETITLE"
	set_metadata "Publisher" "$GAMEPUB"
	set_metadata "Date"      "$GAMEDATE"
	set_metadata "Platform"  "NEC PC-FX"
	display_metadata
}

# SNK Neo Geo CD disc image
# IPL.TXT is primary marker, but contains no ridentifying info; use PVD only
#	$1 = source file
extract_neogeocd() {
	local SOURCE="$1"
	debug "Extracting SNK Neo Geo CD metadata from '${SOURCE}'"

	# Set and print metadata
	set_metadata "Title"    "${PVD[VOLUME]}"
	set_metadata "Created"  "$(pvd_format_date "${PVD[CREATED]}")"
	set_metadata "Platform" "SNK Neo Geo CD"
	display_metadata
}

# Sony PlayStation Vita cartridge image (PSVgameSD)
# Supported headered and unheadered; metadata contained in param.sfo,
# but requires a grep scan to find it
#	$1 = source file
extract_vita() {
	local SOURCE="$1"
	local SFO_OFF SFO_HEX KEY VALUE FMT ID OFFSETS
	local -A SFO
	debug "Extracting PS Vita (PSV) metadata from '${SOURCE}'"

	# Locate param.sfo: stream for "\0PSF" magic, keep the first valid candidate
	OFFSETS=$(grep -aobP '\x00PSF' "$SOURCE" | cut -d: -f1)

	for SFO_OFF in $OFFSETS; do
		SFO_HEX=$(read_hex "$SOURCE" "$SFO_OFF" 0x8000)

		# Parse SFO and populate array
		while IFS=$'\t' read -r KEY VALUE FMT; do
			debug "SFO '$KEY' = '$VALUE'"
			SFO[$KEY]="$VALUE"
		done < <(sfo_parse_hex "$SFO_HEX")

		# Consider valid when TITLE_ID set properly
		if [[ -n "${SFO[TITLE_ID]}" ]]; then
			debug "PS Vita: found valid param.sfo at offset $SFO_OFF"
			break
		fi
	done

	# Set SFO metadata
	ID="${SFO[TITLE_ID]:0:4}-${SFO[TITLE_ID]:4}"
	set_metadata "Game ID"      "$ID"
	set_metadata "Title"        "$(title_map_ps3-v-4 SFO "TITLE")"
	set_metadata "Short Title"  "$(title_map_ps3-v-4 SFO "STITLE")"
	set_metadata "App Version"  "${SFO[APP_VER]}"
	set_metadata "Cart Version" "${SFO[VERSION]}"
	set_metadata "Min Firmware" "${SFO[PSP2_DISP_VER]}"
	set_metadata "Content ID"   "${SFO[CONTENT_ID]}"
	set_metadata "NP Comm ID"   "${SFO[NP_COMMUNICATION_ID]}"
	set_metadata "Category"     "${SFO[CATEGORY]} ($(category_map_psv-4 "${SFO[CATEGORY]}"))"
	set_metadata "Parental Lvl" "${SFO[PARENTAL_LEVEL]} ($(parental_map_ps "${SFO[PARENTAL_LEVEL]}"))"
	set_metadata "Region"       "$(region_map_vita "$ID")"

	# Set platform and print metadata
	set_metadata "Platform"     "Sony PlayStation Vita"
	display_metadata
}

# Generic (PC) metadata
#	$1 = file
extract_pc() {
	local SOURCE="$1"
	local KEY VALUE PFILE_INFO LBA SIZE BODY WORD SVD_OFF SVD_HEX SVD_TYPE
	local SVD_ESC JOLIET_VOLUME JOLIET_VOLUME_SET JOLIET_VOLUME_HEX
	local JOLIET_VOLUME_SET_HEX OUTKEY
	debug "Printing PVD info for generic PC image"

	# Try to find a Joliet Supplementary Volume Descriptor (SVD)
	# Volume descriptors start at LBA 16 and run until the terminator (type 0xFF)
	for (( SVD_OFF=16; SVD_OFF<32; SVD_OFF++ )); do
		SVD_HEX=$(read_hex "$SOURCE" "$(lba_to_offset "$SVD_OFF")" 2048)
		hex_substr "$SVD_HEX" 0 1 SVD_TYPE

		[[ "$SVD_TYPE" == "FF" ]] && break
		[[ "$SVD_TYPE" != "02" ]] && continue

		# Joliet escape sequence is 3 bytes at offset 88
		hex_substr "$SVD_HEX" 88 3 SVD_ESC

		# Only Joliet SVDs use escape sequences %/@, %/C, or %/E
		[[ "$SVD_ESC" != "252f40" && "$SVD_ESC" != "252f43" && "$SVD_ESC" != "252f45" ]] && continue

		debug "Joliet SVD found at LBA $SVD_OFF"
		hex_substr "$SVD_HEX" 40 32 JOLIET_VOLUME_HEX
		hex_to_ucs2be "$JOLIET_VOLUME_HEX" JOLIET_VOLUME
		hex_substr "$SVD_HEX" 190 128 JOLIET_VOLUME_SET_HEX
		hex_to_ucs2be "$JOLIET_VOLUME_SET_HEX" JOLIET_VOLUME_SET

		trim_var JOLIET_VOLUME
		trim_var JOLIET_VOLUME_SET
		PVD[JOLIET_VOL]="$JOLIET_VOLUME"
		PVD[JOLIET_VOL_SET]="$JOLIET_VOLUME_SET"
		break
	done

	# Walk through each PVD item in defined order to control order of display
	for KEY in JOLIET_VOL VOLUME JOLIET_VOL_SET VOLUME_SET SYSTEM PUBLISHER PREPARER APPLICATION CREATED MODIFIED EXPIRES EFFECTIVE COPYRIGHT ABSTRACT BIBLIOGRAPHIC; do
		VALUE="${PVD[$KEY]}"
		[[ -z "$VALUE" ]] && continue

		# Format special fields
		if [[ "$KEY" =~ ^(CREATED|MODIFIED|EXPIRES|EFFECTIVE)$ ]]; then
			VALUE="$(pvd_format_date "$VALUE")"
		elif [[ "$KEY" =~ ^(COPYRIGHT|ABSTRACT|BIBLIOGRAPHIC)$ ]]; then
			PFILE_INFO=$(iso_find_path "$SOURCE" "$VALUE")
			if [[ -z "$PFILE_INFO" ]]; then
				debug "'$KEY' file '$VALUE' referenced in PVD, but not found"
			else
				read -r LBA SIZE <<< "$PFILE_INFO"
				BODY="$(iso_read_file "$SOURCE" "$LBA" "$SIZE")"
				#debug "'${KEY}' file contents: $BODY"
				if [[ -n "$BODY" ]]; then
					VALUE="${VALUE}"$'\n'"${BODY}"
				fi
			fi
		fi

		# Normalize key case - underscores to spaces, title-case each word
		OUTKEY=""
		KEY="${KEY//_/ }"
		KEY="${KEY,,}"
		# Read words and capitalize each
		for WORD in $KEY; do
			OUTKEY+="${WORD^} "
		done
		# Trim trailing space
		OUTKEY="${OUTKEY% }"
		
		# Set metadata
		set_metadata "${OUTKEY}" "$VALUE"
	done

	# If disc included autorun.inf, additionally include that in output
	PFILE_INFO=$(iso_find_path "$SOURCE" "AUTORUN.INF")
	if [[ "$PFILE_INFO" ]]; then
		read -r LBA SIZE <<< "$PFILE_INFO"
		BODY="$(iso_read_file "$SOURCE" "$LBA" "$SIZE")"
		#debug "'AUTORUN.INF' file contents: $BODY"
		if [[ -n "$BODY" ]]; then
			set_metadata "autorun.inf" "${BODY}"
		fi
	fi

	# Set generic platform
	set_metadata "Platform" "Unknown / PC"

	# Print metadata
	display_metadata
}

# Game Boy / Game Boy Color metadata extraction
#	$1 = source file
#	$2 = platform ("gb" or "gbc")
extract_gb-gbc() {
	local SOURCE="$1" PLATFORM="$2"
	local HEX GAMETITLE MAKER CGBFLAG SGBFLAG CARTTYPE ROMSIZE RAMSIZE
	local DESTCODE OLDLICENSEE ROMVER DISPPLAT
	debug "Extracting ${PLATFORM} metadata from '${SOURCE}'"

	# Bulk-read header fields
	HEX=$(read_hex "$SOURCE" 0x134 0x1C)

	# CGB flag at determines title length:
	# 0x00 = GB title is 16 bytes
	# 0x80/0xC0 = GBC title is 11 bytes (0x134-0x13E), 0x13F-0x142 = mfr code
	CGBFLAG="${HEX:$((0x0F * 2)):2}"

	# Title depends on CGB flag)
	if [[ "$CGBFLAG" == "80" || "$CGBFLAG" == "c0" ]]; then
		hex_to_ascii "${HEX:0:22}" GAMETITLE
	else
		hex_to_ascii "${HEX:0:32}" GAMETITLE
	fi
	trim_var GAMETITLE

	# Extract remaining fields
	SGBFLAG="${HEX:$((0x12 * 2)):2}"
	CARTTYPE="${HEX:$((0x13 * 2)):2}"
	ROMSIZE="${HEX:$((0x14 * 2)):2}"
	RAMSIZE="${HEX:$((0x15 * 2)):2}"
	DESTCODE="${HEX:$((0x16 * 2)):2}"
	OLDLICENSEE="${HEX:$((0x17 * 2)):2}"
	ROMVER="${HEX:$((0x18 * 2)):2}"

	# Maker Code: if old licensee = 0x33, use new ASCII maker code
	if [[ "$OLDLICENSEE" == "33" ]]; then
		hex_to_ascii "${HEX:$((0x10 * 2)):4}" MAKER
	else
		MAKER="$OLDLICENSEE"
	fi

	DISPPLAT="Nintendo Game Boy"
	[[ "$PLATFORM" == "gbc" ]] && DISPLAY+=" Color"

	# Set metadata
	set_metadata "Title"      "$GAMETITLE"
	set_metadata "Version"    "$((16#$ROMVER))"
	set_metadata "Maker Code" "$MAKER"
	set_metadata "Cart Type"  "$(carttype_map_gb "$CARTTYPE")"
	(( 16#$SGBFLAG )) && set_metadata "SGB" "Enabled"
	(( 16#$RAMSIZE )) && set_metadata "RAM Size" "$(ramsize_map_gb "$RAMSIZE")"

	set_metadata "Region"     "$(region_map_gb "$DESTCODE")"
	set_metadata "Platform"   "$DISPPLAT"

	# Print metadata
	display_metadata
}

# Game Boy Advance cartridge metadata extraction
#	$1 = source file
extract_gba() {
	local SOURCE="$1"
	local HEX GAMETITLE GAMECODE MAKERCODE ROMVER REGIONCHAR
	debug "Extracting GBA metadata from '${SOURCE}'"

	# Bulk-read header data
	HEX=$(read_hex "$SOURCE" 0xA0 0x20)

	# Get title
	hex_to_ascii "${HEX:0:24}" GAMETITLE
	trim_var GAMETITLE

	# Get remaining data
	hex_to_ascii "${HEX:$((0x0C * 2)):8}" GAMECODE
	hex_to_ascii "${HEX:$((0x10 * 2)):4}" MAKERCODE
	ROMVER="${HEX:$((0x1C * 2)):2}"
	REGIONCHAR="${GAMECODE:3:1}"

	set_metadata "Game ID"    "$GAMECODE"
	set_metadata "Title"      "$GAMETITLE"
	set_metadata "Version"    "$((16#$ROMVER))"
	set_metadata "Maker Code" "$MAKERCODE"
	set_metadata "Region"     "${REGIONCHAR} ($(region_map_gba "$REGIONCHAR"))"
	set_metadata "Platform"   "Nintendo Game Boy Advance"
	display_metadata
}

# Virtual Boy cartridge metadata extraction
#	$1 = source file
extract_vb() {
	local SOURCE="$1"
	local FILESIZE HDR_OFF HEX GAMETITLE MAKERCODE GAMECODE ROMVER
	debug "Extracting Virtual Boy metadata from '${SOURCE}'"

	# Header is at the end of the file (header + exception table)
	FILESIZE=$(stat -c %s "$SOURCE")
	HDR_OFF=$(( FILESIZE - 0x220 ))
	HEX=$(read_hex "$SOURCE" "$HDR_OFF" 0x20)

	# Title (20 bytes at header+0x00, may be Shift-JIS)
	hex_to_ascii "${HEX:0:40}" GAMETITLE
	trim_var GAMETITLE
	# Pass through iconv for Shift-JIS decode if available
	optbincheck iconv && GAMETITLE=$(printf '%s' "$GAMETITLE" | iconv -f SHIFT-JIS -t UTF-8//IGNORE 2>/dev/null)

	# Read additional attributes
	hex_to_ascii "${HEX:$((0x19 * 2)):4}" MAKERCODE
	hex_to_ascii "${HEX:$((0x1B * 2)):8}" GAMECODE
	ROMVER=$((16#${HEX:$((0x1F * 2)):2}))

	set_metadata "Game ID"    "$GAMECODE"
	set_metadata "Title"      "$GAMETITLE"
	set_metadata "Version"    "$ROMVER"
	set_metadata "Maker Code" "$MAKERCODE"
	set_metadata "Platform"   "Nintendo Virtual Boy"
	display_metadata
}

# Nintendo DS cartridge metadata
#	$1 = file
extract_nds() {
	local SOURCE="$1"
	local HEX REGIONCODE REGIONFLAG PLATFORM REVISION_HEX
	local REVISION BANNER_OFF_HEX BANNER_OFF BANNERTITLE BANNERPUB GAMECODE
	local MAKERCODE HEADERTITLE REGIONNAME PREFIX
	debug "Extracting Nintendo DS metadata from '${SOURCE}'"

	# Bulk-read the fixed header through the banner-offset field (0x70 bytes)
	HEX=$(read_hex "$SOURCE" 0 0x70)

	# Header title and game/maker codes
	hex_to_ascii "${HEX:0:24}" HEADERTITLE
	hex_to_ascii "${HEX:24:8}" GAMECODE
	hex_to_ascii "${HEX:32:4}" MAKERCODE
	debug "DS HEADER_TITLE = ${HEADERTITLE} / GAMECODE = ${GAMECODE} / MAKERCODE = ${MAKERCODE}"

	# Set region code and floag
	REGIONCODE="${GAMECODE:3:1}"
	REGIONFLAG="${HEX:$((0x1d * 2)):2}"
	debug "DS REGIONCODE = ${REGIONCODE} / REGIONFLAG = ${REGIONFLAG}"

	# Unit code (00=DS, 02=DS+DSi, 03=DSi)
	# Product code depends on unit code: NTR for DS, TWL for DSi
	# Seemingly no definitive result for DS+DSi, so list both
	unitcode_map_nds "${HEX:$((0x12 * 2)):2}" PLATFORM PREFIX

	# Revision
	REVISION_HEX="${HEX:$((0x1e * 2)):2}"
	REVISION=$((16#$REVISION_HEX))

	# Banner offset
	BANNER_OFF_HEX="${HEX:$((0x68 * 2)):8}"
	hex_le32 "$BANNER_OFF_HEX" 0 BANNER_OFF

	# Get banner title + publisher if iconv available
	title_publisher_parse_nds "$SOURCE" "$BANNER_OFF" BANNERTITLE BANNERPUB

	# Set metadata
	set_metadata "Game ID"       "$GAMECODE"
	set_metadata "Title"         "$BANNERTITLE"
	set_metadata "Header Title"  "$HEADERTITLE"
	set_metadata "Revision"      "$REVISION"
	set_metadata "Product Code"  "${PREFIX}-${GAMECODE}"
	set_metadata "Maker Code"    "$MAKERCODE"
	set_metadata "Publisher"     "$BANNERPUB"
	set_metadata "Region"        "${REGIONCODE} ($(region_map_nds "$REGIONCODE"))"
	set_metadata "Region Flag"   "$REGIONFLAG ($(region_flag_map_nds "$REGIONFLAG"))"

	# Platform comes from unitcode to distinguish DS vs. DSi
	set_metadata "Platform"      "Nintendo ${PLATFORM}"

	# Add note about missing iconv data
	optbincheck iconv || { set_metadata_separator; set_metadata "Notice" "iconv required for banner title and publisher"; }

	# Print metadata
	display_metadata
}

# Nintendo 3DS cartridge (CCI/NCSD) metadata
#	$1 = file
extract_3ds() {
	local SOURCE="$1"
	local HEX NCCH_HEX PART_OFF NCCH_OFF TITLEID MAKERCODE
	local PRODUCTCODE CARDTYPE REVISION_HEX REVISION
	local PLATFORM CONTENTTYPE_HEX CONTENTTYPE GAMETITLE PUBLISHER ICONFILE
	local REGION_HEX REGION_VAL
	debug "Extracting Nintendo 3DS metadata from '${SOURCE}'"

	# Bulk-read the card-info region (0..0x320)
	HEX=$(read_hex "$SOURCE" 0 0x320)

	# Partition-0 offset - locate NCCH start.
	hex_le32 "${HEX:$((0x120 * 2)):8}" 0 PART_OFF
	NCCH_OFF=$((PART_OFF * 0x200))

	# Bulk-read the NCCH header region (0x200 bytes from NCCH_OFF)
	NCCH_HEX=$(read_hex "$SOURCE" "$NCCH_OFF" 0x200)

	# Title ID, Maker, and Product Codes
	hex_le64 "$NCCH_HEX" 0x118 TITLEID
	hex_to_ascii "${NCCH_HEX:$((0x110 * 2)):4}" MAKERCODE
	trim_var MAKERCODE

	# Product code must be read directlry fron file
	hex_to_ascii "$(read_hex "$SOURCE" 0x1150 16)" PRODUCTCODE
	trim_var PRODUCTCODE
	debug "3DS TITLEID = ${TITLEID} / PRODUCTCODE = ${PRODUCTCODE} / MAKERCODE = ${MAKERCODE}"

	# Read/convert additional attributes
	# Platform: 01=3DS, 02=New 3DS
	CARDTYPE="${HEX:$((0x18d * 2)):2}"
	REVISION_HEX="${HEX:$((0x312 * 2)):2}"
	REVISION=$((16#$REVISION_HEX))
	PLATFORM="${NCCH_HEX:$((0x18c * 2)):2}"
	CONTENTTYPE_HEX="${NCCH_HEX:$((0x18d * 2)):2}"
	CONTENTTYPE=$((16#$CONTENTTYPE_HEX))
	debug "3DS CARDTYPE = ${CARDTYPE} / REVISION = ${REVISION} / PLATFORM = ${PLATFORM} / CONTENTTYPE = ${CONTENTTYPE}"

	# Title + Publisher lives in SMDH icon inside ExeFS; this requires a
	# level of decryption beyond the scope of this script.  Leverage ctrtool
	# to descypt if available, otherwise skip these attributes.
	if optbincheck ctrtool; then
		if ctrtool --intype=ncsd --ncch=0 --exefsdir="${TEMPDIR}/exefs" --quiet "$SOURCE" >/dev/null 2>&1; then
			ICONFILE="${TEMPDIR}/exefs/icon.bin"
			title_publisher_parse_3ds "$ICONFILE" GAMETITLE PUBLISHER
			# Region Lockout (u32 LE @ SMDH 0x2018)
			REGION_HEX=$(read_hex "$ICONFILE" 0x2018 4)
			[[ -n "$REGION_HEX" ]] && hex_le32 "$REGION_HEX" 0 REGION_VAL
		else
			debug "ctrtool failed"
		fi
	fi

	# Set metadata
	set_metadata "Game ID"      "$TITLEID"
	set_metadata "Title"        "$GAMETITLE"
	set_metadata "Revision"     "$REVISION"
	set_metadata "Product Code" "$PRODUCTCODE"
	set_metadata "Maker Code"   "$MAKERCODE"
	set_metadata "Publisher"    "$PUBLISHER"
	set_metadata "Type"         "$(category_map_3ds "$CONTENTTYPE")"
	set_metadata "Card Type"    "$CARDTYPE ($(cardtype_map_3ds "$CARDTYPE"))"
	set_metadata "Region"       "${REGION_VAL} ($(region_map_3ds "$REGION_VAL"))"

	# Platform comes from  to distinguish 3DS vs. New 3DS
	set_metadata "Platform"     "Nintendo $(platform_map_3ds "$PLATFORM")"

	# Add note about missing iconv data
	optbincheck ctrtool || { set_metadata_separator; set_metadata "Notice" "ctrtool required for title, publisher, and region"; }

	# Print metadata
	display_metadata
}

# Nintendo TMD (Title Metadata) extraction, 3DS CDN + Wii U
#	$1 = source file
extract_tmd() {
	local SOURCE="$1"
	local SIGTYPE_HEX SIGTYPE HDR_OFF HEX TITLE_ID_HEX TITLE_ID TITLE_PREFIX
	local TITLE_VER TMD_VERSION OS_VER_HEX OS_VER PLATFORM TYPE TYPE_MAP

	debug "Extracting TMD metadata from '${SOURCE}'"

	# Use sig type to determine sig block size and header offset
	SIGTYPE_HEX=$(read_hex "$SOURCE" 0 4)
	hex_be32 "$SIGTYPE_HEX" 0 SIGTYPE

	case "$((SIGTYPE))" in
		$((16#00010000))|$((16#00010003))) HDR_OFF=$((0x240)) ;;  # RSA-4096
		$((16#00010001))|$((16#00010004))) HDR_OFF=$((0x140)) ;;  # RSA-2048
		$((16#00010002))|$((16#00010005))) HDR_OFF=$((0x80))  ;;  # RSA-1024/ECDSA
		*)
			debug "TMD: unrecognized signature type, not a TMD file"
			return 1
			;;
	esac

	# Read header window covering all fields for both platforms
	HEX=$(read_hex "$SOURCE" "$HDR_OFF" 0xA0)

	# Get commonly located attributes
	TITLE_ID_HEX="${HEX:$((0x4c * 2)):16}"
	TYPE="${TITLE_ID_HEX:0:8}"
	TITLE_VER="$((16#${HEX:$((0x9c * 2)):4}))"

	# Determine platform from title-ID prefix
	TITLE_PREFIX="${TITLE_ID_HEX:0:4}"

	# Get uniquely located attributes
	if [[ "$TITLE_PREFIX" == "0004" ]]; then
		PLATFORM="Nintendo 3DS"
		TYPE_MAP="$(type_map_3ds_tmd "$TYPE")"
		TITLE_ID="${TITLE_ID_HEX}"
		debug "TMD: detected 3DS TMD (title_id=${TITLE_ID})"

	elif [[ "$TITLE_PREFIX" == "0005" ]]; then
		# Validate Wii U TMD version (1); excludes Wii/vWii (version 0)
		TMD_VERSION="$((16#${HEX:$((0x40 * 2)):2}))"
		if (( TMD_VERSION != 1 )); then
			debug "TMD version ${TMD_VERSION} unsupported"
			return 1
		fi
		PLATFORM="Nintendo Wii U"
		TYPE_MAP="$(type_map_wiiu "$TYPE")"
		TITLE_ID="${TITLE_ID_HEX:0:8}-${TITLE_ID_HEX:8:8}"
		#TITLE_ID="${TITLE_ID^^}"
		OS_VER_HEX="${HEX:$((0x44 * 2)):16}"
		OS_VER="${OS_VER_HEX^^}"
		debug "TMD: detected Wii U TMD (version=${TMD_VERSION})"

	else
		debug "TMD: unrecognized title-ID prefix ${TITLE_PREFIX}"
		return 1
	fi

	# Set metadata
	set_metadata "Title ID"      "$TITLE_ID"
	set_metadata "Title Version" "v$TITLE_VER"
	[[ -n "$OS_VER" ]] && set_metadata "OS Version" "${OS_VER} ($(osversion_map_wiiu "$OS_VER"))"
	set_metadata "Type"          "${TYPE} (${TYPE_MAP})"
	set_metadata "Platform"      "$PLATFORM"

	# Print metadata
	display_metadata
}

# Nintendo Switch metadata (XCI/NSP/NSZ)
# Tier 0: identify switch and retrieve title ID from .tik if possible; .tik is
#         only available for embedded patches on cart, so this is not guaranteed
# Tier 1: optionally use nstool (+ prod.keys) for metadata enrichment from CNMT
#	$1 = source file
#	$2 = format (pfs0 for NSP, hfs0 for XCI)
extract_switch() {
	local SOURCE="$1" FORMAT="$2"
	local FIRST_PACKAGE=1
	local -A CONT_ENTRIES
	local -a CONT_STRINGS
	local STRIDE KEY TITLE_ID_T0 SECURE_OFF
	local -a T0_TIDS

	# Parse container structure and extract Title ID from found .tiks
	# PFS0 (NSP): offset 0, stride 0x18.
	# HFS0 (XCI): root at 0xF000 (stride 0x40), then get tik and nca
	if [[ "$FORMAT" == "hfs0" ]]; then
		# Find secure partition offset
		STRIDE=0x40
		pfs0_hfs0_parse "$SOURCE" 0xF000 CONT_ENTRIES CONT_STRINGS "$STRIDE" || return 1
		SECURE_OFF=$(hfs0_secure_offset "$SOURCE")
		if [[ -z "$SECURE_OFF" ]]; then
			debug "no secure partition found in XCI"
			return 1
		fi

		# Find the .tik/.nca entries
		pfs0_hfs0_parse "$SOURCE" "$SECURE_OFF" CONT_ENTRIES CONT_STRINGS "$STRIDE" || return 1

	elif [[ "$FORMAT" == "pfs0" ]]; then
		# Find the .tik/.nca entries
		STRIDE=0x18
		pfs0_hfs0_parse "$SOURCE" 0 CONT_ENTRIES CONT_STRINGS || return 1

	else
		debug "Switch: unknown format '$FORMAT'"
		return 1
	fi

	# Collect the Title ID from every .tik in the container; for XCI, only patch
	# .tiks can be seen, but a multi-title game can include multiple patches
	for KEY in "${!CONT_ENTRIES[@]}"; do
		[[ "$KEY" == *.tik ]] && T0_TIDS+=("${KEY:0:16}")
	done

	# If nstool + cert unavailable, bail here and show only IDs
	if ! optbincheck nstool || ! optfilecheck "$SWITCHKEYS"; then
		debug "nstool or keys not found, skipping extended metadata"
		for TITLE_ID_T0 in "${T0_TIDS[@]}"; do
			TITLE_ID_T0="${TITLE_ID_T0^^}"
			# Patches have different ID (800) than base game; flag to indicate
			# it's a patch to limit confusion.  Quiet output ommits flag.
			if [[ "$FORMAT" == "hfs0" ]] && (( ! $QUIET )); then
				set_metadata "Game ID" "${TITLE_ID_T0} (Patch)"
			else
				set_metadata "Game ID" "$TITLE_ID_T0"
			fi
		done

		# Set limited (tier 0) metadata
		set_metadata "Platform" "Nintendo Switch"
		set_metadata_separator
		set_metadata "Notice"   "nstool + prod.keys required for additional metadata"

		# Add notice about missing metadata and print metadata
		set_metadata_separator
		set_metadata "Notice" "nstool required for title, publisher, version, and system version"
		display_metadata
		return 0
	fi

	# Tier 1: container extraction + CNMT/NACP parsing are native; nstool is
	# used to decrypt NCA contents (.cnmt / control.nacp) and master key
	local NSDIR CNMT_NCA CNMT_FILE_PATH CNMT_NAME CNMT_LIST HEX CNMT_TYPE
	local CNMT_VERSION CNMT_TITLE_ID RSV_RAW SYS_VERSION EXT_HDR_SIZE
	local CONTENT_COUNT TABLE_OFF POS CTR_NCA_ID CTR_NCA_NAME KG_RAW MASTER_KEY
	local NACP_PATH NACP_HEX NACP_TITLE NACP_PUBLISHER NACP_DISP_VER
	local LANG_BITMASK SLOT_OFF PUB_SLOT_OFF NAME_HEX DISP_HEX PUB_HEX CTYPE_HEX CTYPE_VAL
	local NCAID_HEX C I TYPE_HEX TID_LE CTR_NCA_PATH NACP_DIR LANG_HEX CT_HEX
	local SORTED_ENTRY PRE_CNMT_NCA PRE_CNMT_OUT PRE_CNMT_FILE PRE_CNMT_TYPE
	local LANGUAGES
	local -a SORTED_CNMTS

	# Build list of .cnmt.nca files to process (for multi-package support)
	for KEY in "${!CONT_ENTRIES[@]}"; do
		[[ "$KEY" == *.cnmt.nca ]] && CNMT_LIST+=("$KEY")
	done
	(( ${#CNMT_LIST[@]} == 0 )) && { debug "no .cnmt.nca files found in container"; return 1; }

	# Set up temp directory for nstool extraction
	NSDIR=$(mktemp -d -p "${TEMPDIR}" "extracted.XXXXX")

	# Pre-extract each CNMT NCA inner .cnmt; necessary to read the type byte
	# used for sorting (so base titles come first)
	for CNMT_NAME in "${CNMT_LIST[@]}"; do
		PRE_CNMT_NCA="${NSDIR}/${CNMT_NAME}"
		switch_nca_extract "$SOURCE" "$CNMT_NAME" "$PRE_CNMT_NCA" CONT_ENTRIES

		PRE_CNMT_FILE=""
		if [[ -f "$PRE_CNMT_NCA" ]]; then
			# NCA stores the .cnmt behind its own parser; extract it, then find
			# the .cnmt by a generic name search
			PRE_CNMT_OUT="${NSDIR}/${CNMT_NAME/.nca/.out}"
			nstool -t nca -x /0 "$PRE_CNMT_OUT" "$PRE_CNMT_NCA" >/dev/null 2>&1
			PRE_CNMT_FILE=$(find "$PRE_CNMT_OUT" -name "*.cnmt" 2>/dev/null | head -1)
		fi
		PRE_CNMT_TYPE="ff"
		[[ -f "$PRE_CNMT_FILE" ]] && PRE_CNMT_TYPE=$(read_hex "$PRE_CNMT_FILE" 0x0C 1)
		SORTED_CNMTS+=("${PRE_CNMT_TYPE}|${CNMT_NAME}|${PRE_CNMT_NCA}|${PRE_CNMT_FILE}")
	done

	# Sort by type byte (ascending: 80 Base < 81 Update < 82 DLC)
	IFS=$'\n' SORTED_CNMTS=($(printf '%s\n' "${SORTED_CNMTS[@]}" | sort)) ; unset IFS

	# For main loop, extract metadata per package; this loop parses the .cnmt,
	# pulls the control NCA, and renders the metadata
	for SORTED_ENTRY in "${SORTED_CNMTS[@]}"; do
		IFS='|' read -r PRE_CNMT_TYPE CNMT_NAME CNMT_NCA CNMT_FILE_PATH <<< "$SORTED_ENTRY"

		[[ -f "$CNMT_NCA" ]]       || { debug "Switch: failed to extract CNMT NCA '$CNMT_NAME'"; continue; }
		[[ -f "$CNMT_FILE_PATH" ]] || { debug "Switch: no .cnmt in extracted NCA"; continue; }

		# Parse CNMT header, then extract attributes
		HEX=$(read_hex "$CNMT_FILE_PATH" 0 0x30)

		hex_le64 "$HEX" 0 TID_LE
		CNMT_TITLE_ID="${TID_LE^^}"
		hex_le32 "$HEX" 8 CNMT_VERSION
		TYPE_HEX="${HEX:24:2}"
		CNMT_TYPE=$(printf '%d' "0x${TYPE_HEX}")
		hex_le16 "$HEX" 14 EXT_HDR_SIZE
		hex_le16 "$HEX" 16 CONTENT_COUNT

		# SystemVersion only exists for app and patch packages
		if [[ "$CNMT_TYPE" == "128" || "$CNMT_TYPE" == "129" ]]; then
			hex_le32 "$HEX" 40 RSV_RAW
			if (( RSV_RAW > 0 )); then
				SYS_VERSION="$(( (RSV_RAW >> 26) & 0x3F )).$(( (RSV_RAW >> 20) & 0x3F )).$(( (RSV_RAW >> 16) & 0x0F ))"
			else
				SYS_VERSION="0.0.0"
			fi
		else
			SYS_VERSION=""
		fi

		#Find control NCA (ContentType == 3)
		TABLE_OFF=$((0x20 + EXT_HDR_SIZE))
		# Bulk read whole table, then extract entry's content type and id
		CT_HEX=$(read_hex "$CNMT_FILE_PATH" "$TABLE_OFF" $((CONTENT_COUNT * 0x38)))
		for (( C=0; C<CONTENT_COUNT; C++ )); do
			POS=$((C * 0x38))
			CTYPE_HEX="${CT_HEX:$((POS * 2 + 0x36 * 2)):2}"
			CTYPE_VAL=$(printf '%d' "0x${CTYPE_HEX}")
			if [[ "$CTYPE_VAL" == "3" ]]; then
				NCAID_HEX="${CT_HEX:$((POS * 2 + 0x20 * 2)):32}"
				CTR_NCA_ID="${NCAID_HEX^^}"
				break
			fi
		done

		# Get master key from NCA header; requires nstool
		KG_RAW=$(nstool -t nca "$CNMT_NCA" 2>/dev/null | awk '/Key Generation:/ {print $NF; exit}')
		if is_int "$KG_RAW"; then
			MASTER_KEY=$(( KG_RAW > 0 ? KG_RAW - 1 : 0 ))
		else
			MASTER_KEY=""
		fi

		# Extract control.nacp if control NCA found
		if [[ -n "$CTR_NCA_ID" ]]; then
			# Find the nacp in the container by matching its content ID
			for KEY in "${!CONT_ENTRIES[@]}"; do
				if [[ "${KEY:0:32}" == "${CTR_NCA_ID,,}" ]]; then
					CTR_NCA_NAME="$KEY"
					break
				fi
			done

			if [[ -n "$CTR_NCA_NAME" ]]; then
				CTR_NCA_PATH="${NSDIR}/ctrl.nca"
				switch_nca_extract "$SOURCE" "$CTR_NCA_NAME" "$CTR_NCA_PATH" CONT_ENTRIES

				# Extract nacp from the control NCA
				if [[ -f "$CTR_NCA_PATH" ]]; then
					NACP_DIR="${NSDIR}/nacp_out"
					nstool -t nca -x /0 "$NACP_DIR" "$CTR_NCA_PATH" >/dev/null 2>&1
					NACP_PATH=$(find "$NACP_DIR" -name "control.nacp" 2>/dev/null | head -1)

					if [[ -f "$NACP_PATH" ]]; then
						# Bulk-read the NACP
						NACP_HEX=$(read_hex "$NACP_PATH" 0 0x4000)

						# Title/Publisher: Prefer English names if possible;
						# fall back to first available
						for (( I=0; I<16; I++ )); do
							SLOT_OFF=$((I * 0x300 * 2))
							NAME_HEX="${NACP_HEX:$SLOT_OFF:$((0x200 * 2))}"
							[[ -n "$NAME_HEX" && "$NAME_HEX" != "00"* ]] || continue
							hex_to_ascii "$NAME_HEX" NACP_TITLE
							PUB_SLOT_OFF=$((SLOT_OFF + 0x200 * 2))
							PUB_HEX="${NACP_HEX:$PUB_SLOT_OFF:$((0x100 * 2))}"
							hex_to_ascii "$PUB_HEX" NACP_PUBLISHER
							[[ -n "$NACP_TITLE" ]] && break
						done

						# DisplayVersion
						DISP_HEX="${NACP_HEX:$((0x3060 * 2)):$((0x10 * 2))}"
						hex_to_ascii "$DISP_HEX" NACP_DISP_VER
						NACP_DISP_VER="${NACP_DISP_VER%% }"

						# Convert the laguages bitmask into comma-separated tags
						LANG_HEX="${NACP_HEX:$((0x302C * 2)):8}"
						hex_le32 "$LANG_HEX" 0 LANG_BITMASK
						LANGUAGES=$(languages_map_switch "$LANG_BITMASK")
					fi
				fi
			fi
		fi

		# Set package metadata
		[[ $FIRST_PACKAGE -eq 0 ]] && set_metadata_separator
		FIRST_PACKAGE=0

		set_metadata "Game ID"      "$CNMT_TITLE_ID"
		set_metadata "Title"        "$NACP_TITLE"
		set_metadata "Disp Version" "$NACP_DISP_VER"
		set_metadata "Version"      "$CNMT_VERSION"
		set_metadata "Min Firmware" "$SYS_VERSION"
		set_metadata "Master Key"   "$MASTER_KEY"
		set_metadata "Type"         "${CNMT_TYPE} ($(type_map_switch "$CNMT_TYPE"))"
		set_metadata "Publisher"    "$NACP_PUBLISHER"
		set_metadata "Languages"    "$LANGUAGES"
		set_metadata "Platform"     "Nintendo Switch"
	done

	# Print metadata
	display_metadata
}

# NES cartridge metadata extraction (iNES format)
# No title or game-ID in header; Game ID computed as CRC-32 of ROM data
#	$1 = source file
extract_nes() {
	local SOURCE="$1"
	local HEX FLAGS6 FLAGS7 FLAGS9 MAPPER_LO MAPPER_HI MAPPER MIRROR BATTERY
	local TVSYS NES_DATAOFF NES_GAMEID
	debug "Extracting NES metadata from '${SOURCE}'"

	# Bulk-read iNES header
	HEX=$(read_hex "$SOURCE" 0 0x10)
	FLAGS6="${HEX:12:2}"
	FLAGS7="${HEX:14:2}"
	FLAGS9="${HEX:18:2}"

	# Mapper = lo nibble of flags6 | hi nibble of flags7 << 4
	MAPPER_LO=$(( 16#$FLAGS6 >> 4 ))
	MAPPER_HI=$(( 16#$FLAGS7 >> 4 ))
	MAPPER=$(( (MAPPER_HI << 4) | MAPPER_LO ))

	# Mirroring (bit 0 of flags 6)
	(( 16#$FLAGS6 & 0x01 )) && MIRROR="Vertical" || MIRROR="Horizontal"

	# Battery (bit 1 of flags 6)
	(( 16#$FLAGS6 & 0x02 )) && BATTERY="Yes" || BATTERY="No"

	# TV system (bit 0 of flags 9)
	(( 16#$FLAGS9 & 0x01 )) && TVSYS="PAL" || TVSYS="NTSC"

	# Game ID: CRC-32 of ROM data (skip 16-byte header, skip trainer if present)
	NES_DATAOFF=16
	(( 16#$FLAGS6 & 0x04 )) && NES_DATAOFF=$(( 16 + 512 ))
	NES_GAMEID="$(generate_crc32 "$SOURCE" "$NES_DATAOFF")"
	(( ! QUIET )) && NES_GAMEID="${NES_GAMEID} (CRC32)"

	set_metadata "Game ID"   "$NES_GAMEID"
	set_metadata "Mapper"    "$MAPPER ($(mapper_map_nes "$MAPPER"))"
	set_metadata "Mirroring" "$MIRROR"
	set_metadata "Battery"   "$BATTERY"
	set_metadata "TV System" "$TVSYS"
	set_metadata "Platform"  "Nintendo Nintendo Entertainment System"
	display_metadata
}

# Famicom Disk System metadata extraction
#	$1 = source file
#	$2 = data offset
extract_fds() {
	local SOURCE="$1" DATAOFF="$2"
	local HEX LICENSEE GAMENAME GAMETYPE GAMEVER
	debug "Extracting FDS metadata from '${SOURCE}' (dataoff=$DATAOFF)"

	# Bulk-read 56-byte disk info block (block 1)
	HEX=$(read_hex "$SOURCE" "$DATAOFF" 0x38)

	# Extract attributes
	LICENSEE="${HEX:$((0x0F * 2)):2}"
	hex_to_ascii "${HEX:$((0x10 * 2)):6}" GAMENAME
	GAMETYPE="${HEX:$((0x13 * 2)):2}"
	GAMEVER="${HEX:$((0x14 * 2)):2}"
	#SIDENUM="${HEX:$((0x15 * 2)):2}" - only reading from one disk/side,
	#DISKNUM="${HEX:$((0x16 * 2)):2}" - so not worth always showing 0

	# Country code - ignore because this is always Japan
	#COUNTRY="${HEX:$((0x22 * 2)):2}"

	set_metadata "Game ID"    "$GAMENAME"
	set_metadata "Maker Code" "$LICENSEE"
	set_metadata "Version"    "$((16#$GAMEVER))"
	set_metadata "Platform"   "Nintendo Famicom Disk System"
	display_metadata
}

# Super Nintendo metadata extraction
# Supports .sfc (headerless) and .smc (with 512-byte copier header)
#	$1 = source file
extract_snes() {
	local SOURCE="$1"
	local FOUND=0
	local CKHEX COMP SUM FSIZE HDR_OFF HDR_SHIFT HEX GAMETITLE MAPMODE MAPMODE_S
	local MAPLABEL CARTTYPE REGION DEVID ROMVER CKSUM GAMECODE
	local MAKERCODE EXT_HEX
	debug "Extracting Super Nintendo metadata from '${SOURCE}'"

	# Detect 512-byte copier header
	FSIZE=$(stat -c %s "$SOURCE")
	(( FSIZE & 0x3FF == 0x200 )) && HDR_SHIFT=0x200 || HDR_SHIFT=0

	# Probe header locations until checksum complement validates
	# Necessary to find correct header offset due to hirom vs. lorom, etc.
	for HDR_OFF in $((0x7FC0 + HDR_SHIFT)) $((0xFFC0 + HDR_SHIFT)) $((0x40FFC0 + HDR_SHIFT)); do
		(( HDR_OFF >= 0x400000 + HDR_SHIFT )) && (( FSIZE < 0x400000 )) && continue
		CKHEX=$(read_hex "$SOURCE" $((HDR_OFF + 0x1C)) 4)
		[[ -n "$CKHEX" ]] || continue
		COMP=$((16#${CKHEX:0:4}))
		SUM=$((16#${CKHEX:4:4}))
		(( (COMP ^ SUM) == 0xFFFF )) && { FOUND=1; break; }
	done
	if ! (( FOUND )); then
		debug "SNES header not counf"
		return 1
	fi

	# Read header data to variable
	HEX=$(read_hex "$SOURCE" "$HDR_OFF" 0x20)

	# Extract title; may be Shift-JIS, so pass through iconv if avaialble
	hex_to_ascii "${HEX:0:42}" GAMETITLE
	trim_var GAMETITLE
	optbincheck iconv && GAMETITLE=$(printf '%s' "$GAMETITLE" | iconv -f SHIFT-JIS -t UTF-8//IGNORE 2>/dev/null)

	# Extract other titles
	MAPMODE="${HEX:$((0x15 * 2)):2}"
	CARTTYPE="${HEX:$((0x16 * 2)):2}"
	REGION="${HEX:$((0x19 * 2)):2}"
	DEVID="${HEX:$((0x1A * 2)):2}"
	ROMVER="${HEX:$((0x1B * 2)):2}"
	CKSUM="${HEX:$((0x1E * 2)):4}"

	# Parse map mode
	mapmode_map_snes "$MAPMODE" MAPLABEL MAPMODE_S

	# Read optional extended header if available for more attributes
	if [[ "$DEVID" == "33" ]]; then
		EXT_HEX=$(read_hex "$SOURCE" $((HDR_OFF - 0x10)) 8)
		[[ -n "$EXT_HEX" ]] && {
			hex_to_ascii "${EXT_HEX:0:4}" MAKERCODE
			hex_to_ascii "${EXT_HEX:4:8}" GAMECODE
		}
	fi

	set_metadata "Game ID"      "$GAMECODE"
	set_metadata "Title"        "$GAMETITLE"
	set_metadata "Version"      "v$((16#$ROMVER))"
	set_metadata "Map Mode"     "$MAPLABEL ($MAPMODE_S)"
	set_metadata "Cart Type"    "$(carttype_map_snes "$CARTTYPE")"
	set_metadata "Developer ID" "$DEVID"
	set_metadata "Maker Code"   "$MAKERCODE"
	set_metadata "Region"       "$REGION ($(region_map_snes "$REGION"))"
	set_metadata "Platform"     "Nintendo Super Nintendo Entertainment System"
	display_metadata
}

# Nintendo 64 cartridge metadata extraction
# Supports .z64 (big-endian), .v64 (byteswapped), .n64 (little-endian)
#	$1 = source file
#	$2 = byte-swap mode (0=z64/BE, 1=v64/halfword, 2=n64/word)
extract_n64() {
	local SOURCE="$1" SWAP="$2"
	local HEX GAMETITLE GAMECODE REGIONCHAR ROMVER
	debug "Extracting Nintendo 64 metadata from '${SOURCE}' (swap=$SWAP)"

	HEX=$(read_hex "$SOURCE" 0 0x40)
	[[ -n "$HEX" ]] || return 1

	# Byte-swap normalization to big-endian
	if (( SWAP == 1 )); then
		local I SWAPPED=""
		for (( I=0; I<${#HEX}; I+=4 )); do
			SWAPPED+="${HEX:I+2:2}${HEX:I:2}"
		done; HEX="$SWAPPED"
	elif (( SWAP == 2 )); then
		local I SWAPPED=""
		for (( I=0; I<${#HEX}; I+=8 )); do
			SWAPPED+="${HEX:I+6:2}${HEX:I+4:2}${HEX:I+2:2}${HEX:I:2}"
		done; HEX="$SWAPPED"
	fi

	# Title (20 bytes at 0x20, ASCII space-padded)
	hex_to_ascii "${HEX:$((0x20 * 2)):40}" GAMETITLE
	trim_var GAMETITLE

	# Game code (4 bytes at 0x3B, ASCII)
	hex_to_ascii "${HEX:$((0x3B * 2)):8}" GAMECODE
	REGIONCHAR="${GAMECODE:3:1}"

	# ROM version (1 byte at 0x3F)
	ROMVER=$((16#${HEX:$((0x3F * 2)):2}))

	set_metadata "Game ID"      "$GAMECODE"
	set_metadata "Title"        "$GAMETITLE"
	set_metadata "Version"      "$ROMVER"
	set_metadata "Media Format" "${GAMECODE:0:1} ($(mediaformat_map_n64 "${GAMECODE:0:1}"))"
	set_metadata "Region"       "$REGIONCHAR ($(region_map_n64 "$REGIONCHAR"))"
	set_metadata "Platform"     "Nintendo Nintendo 64"
	display_metadata
}

# Sega Master System / Game Gear cartridge image
# TMR SEGA header at 0x7FF0 (also 0x3FF0 or 0x1FF0 for smaller ROMs)
#	$1 = source file path
#	$2 = platform ("sms" or "gg")
extract_sms-gg() {
	local SOURCE="$1" PLATFORM="$2" HDR_OFF="$3"
	local HEX PROD_LOW PROD_HIGH BYTE6 BYTE7 PROD_HIGH_NIB VERSION REGION_NIB
	local ROMSIZE_NIB PRODUCT PLAT PROD4
	debug "Extracting ${PLATFORM} metadata from '${SOURCE}'"

	# TMR SEGA header offset validated by caller; read 16 bytes
	HEX=$(read_hex "$SOURCE" "$HDR_OFF" 16)
	[[ -n "$HEX" ]] || return 1
	debug "TMR SEGA header at offset 0x$(printf '%x' "$HDR_OFF"), raw: $HEX"

	# Product code BCD
	PROD_LOW="${HEX:$((0x0C * 2)):2}"
	PROD_HIGH="${HEX:$((0x0D * 2)):2}"

	# Offset 0x0E: high nibble = product high digits, low nibble = version
	BYTE6="${HEX:$((0x0E * 2)):2}"
	PROD_HIGH_NIB="${BYTE6:0:1}"
	VERSION="${BYTE6:1:1}"

	# Offset 0x0F: high nibble = region, low nibble = ROM size
	BYTE7="${HEX:$((0x0F * 2)):2}"
	REGION_NIB="${BYTE7:0:1}"
	ROMSIZE_NIB="${BYTE7:1:1}"

	# Decode product code: BCD bytes give last 4 digits (high byte first),
	# high nibble prepends additional leading digits
	PROD4="${PROD_HIGH}${PROD_LOW}"
	if [[ "$PROD_HIGH_NIB" != "0" ]]; then
		# Prepend as decimal string
		PRODUCT="$((16#$PROD_HIGH_NIB))${PROD4}"
	else
		PRODUCT="$PROD4"
	fi

	debug "Product code: $PRODUCT (BCD=$PROD4 high_nib=$PROD_HIGH_NIB), version: $VERSION, region nibble: $REGION_NIB, romsize nibble: $ROMSIZE_NIB"

	# Platform string
	[[ "$PLATFORM" == "sms" ]] && PLAT="Sega Master System" || PLAT="Sega Game Gear"

	# Display and print metadata
	set_metadata "Game ID"    "$PRODUCT"
	set_metadata "Version"    "$VERSION"
	set_metadata "Region"     "$REGION_NIB ($(region_map_sms-gg "$REGION_NIB"))"
	set_metadata "Platform"   "$PLAT"
	display_metadata
}

# Sega Genesis / Mega Drive cartridge image
# SEGA header at 0x100 (256 bytes)
#	$1 = source file path
extract_genesis() {
	local SOURCE="$1"
	local HEX SYS_TYPE COPYRIGHT PUB_STR DOM_TITLE OVS_TITLE SERIAL
	local IO_DEVS REGION_RAW COPY_REST TYPE PRODUCT VER DATE
	debug "Extracting Genesis/Mega Drive metadata from '${SOURCE}'"

	# Bulk read full 256-byte header
	HEX=$(read_hex "$SOURCE" 0x100 0x100)

	# System type
	debug "System type: '$SYS_TYPE'"

	# Copyright & date - (C)XXXX YYYY.ZZZ
	# Date is always the last 8 chars
	hex_to_ascii "${HEX:$((0x10 * 2)):32}" COPYRIGHT
	trim_var COPYRIGHT

	# Extract date from the last 8 chars
	DATE="${COPYRIGHT: -8}"
	# Publisher = text between "(C)" and the date
	if [[ "$COPYRIGHT" == "(C)"* ]]; then
		COPY_REST="${COPYRIGHT:3}"
	else
		COPY_REST="$COPYRIGHT"
	fi
	PUB_STR="${COPY_REST%"$DATE"}"
	trim_var PUB_STR

	# Extract and trim remaining fields
	hex_to_ascii "${HEX:0:32}"             SYS_TYPE;    trim_var SYS_TYPE
	hex_to_ascii "${HEX:$((0x20 * 2)):96}" DOM_TITLE;   trim_var DOM_TITLE
	hex_to_ascii "${HEX:$((0x50 * 2)):96}" OVS_TITLE;   trim_var OVS_TITLE
	hex_to_ascii "${HEX:$((0x80 * 2)):28}" SERIAL;      trim_var SERIAL
	hex_to_ascii "${HEX:$((0x90 * 2)):32}" IO_DEVS;     trim_var IO_DEVS
	hex_to_ascii "${HEX:$((0xF0 * 2)):6}"  REGION_RAW;  trim_var REGION_RAW

	# Titles may be Shift-JIS, so pass through iconv if avaialble
	optbincheck iconv && DOM_TITLE=$(printf '%s' "$DOM_TITLE" | iconv -f SHIFT-JIS -t UTF-8//IGNORE 2>/dev/null)
	optbincheck iconv && OVS_TITLE=$(printf '%s' "$OVS_TITLE" | iconv -f SHIFT-JIS -t UTF-8//IGNORE 2>/dev/null)
	
	debug "System type: '$SYS_TYPE', Serial: '$SERIAL', domestic: '$DOM_TITLE'"
	debug "overseas: '$OVS_TITLE', I/O devices: '$IO_DEVS', region raw: '$REGION_RAW'"

	# Parse serial: "XX PRODUCT[-| VER]"
	TYPE="${SERIAL%% *}"
	PRODUCT="${SERIAL#* }"
	trim_var PRODUCT

	# Handle multiple edge cases for product code and version
	if [[ "$PRODUCT" == *" "* ]]; then
		# Space-separated: version = last token (strip leading - or ,)
		VER="${PRODUCT##* }"
		PRODUCT="${PRODUCT% *}"
		VER="${VER#-}"
		VER="${VER#,}"
	elif [[ "$PRODUCT" == *,* ]]; then
		# Comma-separated
		VER="${PRODUCT##*,}"
		PRODUCT="${PRODUCT%,*}"
	elif [[ "$PRODUCT" == *-* ]]; then
		# Hyphen-only: 2+ hyphens means last is version separator;
		local HYPHEN_COUNT="${PRODUCT//[^-]/}"
		if (( ${#HYPHEN_COUNT} >= 2 )); then
			VER="${PRODUCT##*-}"
			PRODUCT="${PRODUCT%-*}"
		else
			local BEFORE_LAST="${PRODUCT%-*}"
			if [[ "$BEFORE_LAST" =~ ^[0-9]+$ ]]; then
				VER="${PRODUCT##*-}"
				PRODUCT="$BEFORE_LAST"
			else
				VER=""
			fi
		fi
	else
		VER=""
	fi
	trim_var PRODUCT
	trim_var VER

	# Set metadata
	set_metadata "Game ID"   "$PRODUCT"

	if [[ "$DOM_TITLE" == "$OVS_TITLE" ]]; then
		set_metadata "Dom. Title" "$DOM_TITLE"
	else
		set_metadata "Dom. Title" "$DOM_TITLE"
		set_metadata "Ovs. Title" "$OVS_TITLE"
	fi

	set_metadata "Version"   "$VER"
	set_metadata "Date"      "$(format_gen-segacd_date "$DATE")"
	set_metadata "Type"      "$TYPE ($(category_map_gen-segacd "$TYPE"))"
	set_metadata "Periphs"   "${IO_DEVS} ($(peripherals_map_gen-segacd "$IO_DEVS"))"
	set_metadata "Hardware"  "$SYS_TYPE"
	set_metadata "Region"    "$REGION_RAW ($(region_map_genesis "$REGION_RAW"))"
	set_metadata "Platform"  "Sega Genesis / Mega Drive"
	display_metadata
}

# Atari 2600 / 5200 cartridge image
# ROMs have no ID info, so output CRC32 as the game ID
#	$1 = source file
#	$2 = platform string
extract_2600-5200() {
	local SOURCE="$1" PLAT="$2"
	local PLATSTR="Atari "
	local GAME_ID
	debug "Extracting ${PLAT} metadata from '${SOURCE}'"

	# Game ID: CRC-32 of the whole ROM (raw image, no header to skip)
	GAME_ID="$(generate_crc32 "$SOURCE")"
	(( ! QUIET )) && GAME_ID="${GAME_ID} (CRC32)"

	[[ "$PLAT" == "a26" ]] && PLATSTR+="2600 (Video Computer System)" || PLATSTR+="5200"

	# Set and print metadata
	set_metadata "Game ID"  "$GAME_ID"
	set_metadata "Platform" "$PLATSTR"
	display_metadata
}

# Atari 7800 cartridge image
#	$1 = source file
extract_7800() {
	local SOURCE="$1"
	local HEX CARTTYPE TVTYPE CART_TITLE CART_ID
	debug "Extracting Atari 7800 metadata from '${SOURCE}'"

	# Bulk-read the full 128-byte A78 header
	HEX=$(read_hex "$SOURCE" 0 0x80)

	# Title
	hex_to_ascii "${HEX:$((0x11 * 2)):64}" CART_TITLE
	trim_var CART_TITLE

	# Cart type and TV type
	hex_le16 "$HEX" 0x35 CARTTYPE
	TVTYPE=$((16#${HEX:$((0x39 * 2)):2}))

	# Set and print metadata
	set_metadata "Title"     "$CART_TITLE"
	set_metadata "Cart Type" "${CARTTYPE} ($(carttype_map_7800 "$CARTTYPE"))"
	set_metadata "TV Type"   "${TVTYPE} ($(tvtype_map_7800 "$TVTYPE"))"
	set_metadata "Platform"  "Atari 7800"
	display_metadata
}

# Atari Lynx cartridge image
# Supports 64-byte header if found
#	$1 = source file
extract_lynx() {
	local SOURCE="$1"
	local DATAOFF=0
	local HEX CARTNAME MANUFACTURER GAME_ID MAGIC
	debug "Extracting Atari Lynx metadata from '${SOURCE}'"

	# Check for LYNX header
	hex_to_ascii "$(read_hex "$SOURCE" 0 4)" MAGIC
	if [[ "$MAGIC" == "LYNX" ]]; then
		# Bulk-read the 64-byte header
		HEX=$(read_hex "$SOURCE" 0 0x40)
		DATAOFF=64

		# Cart name; strip paths if found
		hex_to_ascii "${HEX:$((0x0A * 2)):64}" CARTNAME
		trim_var CARTNAME
		CARTNAME="${CARTNAME##*\\}"

		# Manufacturer (16 bytes at 0x2A)
		hex_to_ascii "${HEX:$((0x2A * 2)):32}" MANUFACTURER
		trim_var MANUFACTURER
	fi

	# Game ID: headerless CRC-32
	GAME_ID="$(generate_crc32 "$SOURCE" "$DATAOFF")"
	(( ! QUIET )) && GAME_ID="${GAME_ID} (CRC32)"

	# Set and print metadata
	set_metadata "Game ID"   "$GAME_ID"
	set_metadata "Title"     "$CARTNAME"
	set_metadata "Publisher" "$MANUFACTURER"
	set_metadata "Platform"   "Atari Lynx"
	display_metadata
}

# Atari Jaguar cartridge image
# ROMs have no ID info, so output CRC32 as the game ID
#	$1 = source file
extract_jaguar() {
	local SOURCE="$1"
	local GAME_ID
	debug "Extracting Atari Jaguar metadata from '${SOURCE}'"

	# Game ID: full-file CRC-32
	GAME_ID="$(generate_crc32 "$SOURCE")"
	(( ! QUIET )) && GAME_ID="${GAME_ID} (CRC32)"

	set_metadata "Game ID"       "$GAME_ID"
	set_metadata "Platform"      "Atari Jaguar"
	display_metadata
}

# Bandai WS / WSC cartridge image
# Cart metadata is stored in the final 16 bytes of the ROM (standard header)
#	$1 = source file
#	$2 = platform ("ws" or "wsc")
extract_ws-wsc() {
	local SOURCE="$1" PLAT="$2"
	local PLATSTR="Bandai "
	local FSIZE HEX CARTID GAME_ID DEVID RTCFLAG RTC
	debug "Extracting WonderSwan metadata from '${SOURCE}'"

	# Bulk-read the trailing 16-byte cart header
	FSIZE=$(stat -c %s "$SOURCE")
	(( FSIZE >= 16 )) || return 1
	HEX=$(read_hex "$SOURCE" $((FSIZE - 16)) 16)

	# Cart ID serves as the game ID; also read dev ID
	hex_le16 "$HEX" 8 CARTID
	GAME_ID=$(printf '%04X' "$CARTID")
	DEVID=$((16#${HEX:12:2}))

	# RTC present when the RTC flag byte (FSIZE-3) is non-zero
	RTCFLAG=$((16#${HEX:26:2}))
	(( RTCFLAG != 0 )) && RTC="Yes" || RTC="No"

	[[ "$PLAT" == "ws" ]] && PLATSTR+="WonderSwan" || PLATSTR+="WonderSwan Color"

	# Set and print metadata
	set_metadata "Game ID"   "$GAME_ID"
	set_metadata "Developer" "$DEVID"
	set_metadata "RTC"       "$RTC"
	set_metadata "Platform"  "$PLATSTR"
	display_metadata
}

# Mattel Intellivision cartridge image
# ROMs have no ID info, so output CRC32 as the game ID
#	$1 = source file
extract_intv() {
	local SOURCE="$1"
	local GAME_ID
	debug "Extracting Mattel Intellivision metadata from '${SOURCE}'"

	# Game ID: full-file CRC-32
	GAME_ID="$(generate_crc32 "$SOURCE")"
	(( ! QUIET )) && GAME_ID="${GAME_ID} (CRC32)"

	# Set and print metadata
	set_metadata "Game ID"  "$GAME_ID"
	set_metadata "Platform" "Mattel Intellivision"
	display_metadata
}

# NEC TurboGrafx-16 / SuperGrafx cartridge image
# ROMs have no ID info, so output CRC32 as the game ID
#	$1 = source file
#	$2 = platform ("pce" or "sgx")
extract_pce-sgx() {
	local SOURCE="$1" PLAT="$2"
	local PLATSTR="NEC "
	local FSIZE DATAOFF GAME_ID
	debug "Extracting NEC ${PLAT^^} metadata from '${SOURCE}'"

	# Skip optional 512-byte dumper header if present
	DATAOFF=0
	FSIZE=$(stat -c %s "$SOURCE")
	(( FSIZE % 0x4000 == 512 )) && DATAOFF=512

	# Game ID: CRC-32 of the cartridge data
	GAME_ID="$(generate_crc32 "$SOURCE" "$DATAOFF")"
	(( ! QUIET )) && GAME_ID="${GAME_ID} (CRC32)"

	[[ "$PLAT" == "pce" ]] && PLATSTR+="TurboGrafx-16 / PC Engine" || PLATSTR+="SuperGrafx"

	# Set and print metadata
	set_metadata "Game ID"  "$GAME_ID"
	set_metadata "Platform" "$PLATSTR"
	display_metadata
}

# SNK Neo Geo Pocket / Pocket Color cartridge image
#	$1 = source file
extract_ngp-ngpc() {
	local SOURCE="$1"
	local HEX GAME_NUM GAME_ID SUBCODE GAMETITLE PLATSTR
	debug "Extracting SNK Neo Geo Pocket (${EXT}) metadata from '${SOURCE}'"

	# Bulk-read header
	HEX=$(read_hex "$SOURCE" 0x20 0x10)

	# Extract attributes; Subcode = version/revision
	hex_le16 "$HEX" 0 GAME_NUM
	GAME_ID=$(printf '%04X' "$GAME_NUM")
	SUBCODE=$(printf '%02X' "$((16#${HEX:4:2}))")

	# System code at 0x23 selects the platform (0x10 = color)
	if [[ "${HEX:6:2}" == "10" ]]; then
		PLATSTR="SNK Neo Geo Pocket Color"
	else
		PLATSTR="SNK Neo Geo Pocket"
	fi

	# Title is NUL-padded
	hex_to_ascii "${HEX:8:24}" GAMETITLE
	trim_var GAMETITLE

	# Set and print metadata
	set_metadata "Game ID"  "$GAME_ID"
	set_metadata "Title"    "$GAMETITLE"
	set_metadata "Version" "$SUBCODE"
	set_metadata "Platform" "$PLATSTR"
	display_metadata
}


##########################
# Media handling functions
##########################

# Disc-specific detection logic, split to separate function to allow
# on-the-fly decompression of CSO files during normal ISO operations
detect_disc() {
	local ZERO_OFF PVD_OFF PFILE_INFO LBA SIZE FLAGS SIG

	debug "Testing optical media properties for '${FILE}'"

	# Read and validate PVD
	ZERO_OFF=$(lba_to_offset 0)
	PVD_OFF=$(lba_to_offset 16)
	debug "PVD offset = $PVD_OFF"
	cache_pvd "$FILE" "$PVD_OFF"
	debug "Cached PVD - magic = ${PVD[MAGIC]}"

	# Does disc contain ISO 9660 filesystem?
	if [[ "${PVD[MAGIC]}" != "CD001" ]]; then

		# Test for game discs without an ISO 9660 filesystem

		# Detect GC game disc
		SIG=$(read_hex "$FILE" $((ZERO_OFF + 0x1c)) 4)
		if [[ "$SIG" == "c2339f3d" ]]; then
			debug "Detected GameCube disc header at offset ${ZERO_OFF}"
			if ! extract_gc-wii "$FILE" "gc" "$ZERO_OFF"; then
				return 2
			fi
			return 0
		fi

		# Detect Wii optical disc
		SIG=$(read_hex "$FILE" $((ZERO_OFF + 0x18)) 4)
		if [[ "$SIG" == "5d1c9ea3" ]]; then
			debug "Detected Wii disc header at offset ${ZERO_OFF}"
			if ! extract_gc-wii "$FILE" "wii" "$ZERO_OFF"; then
				return 2
			fi
			return 0
		fi

		# Detect 3DO CD
		SIG="$(read_hex "$FILE" "$ZERO_OFF" 6)"
		if [[ "$SIG" == "015a5a5a5a5a" ]]; then
			debug "Detected 3DO disc label at offset ${ZERO_OFF}"
			if ! extract_3do "$FILE" "$ZERO_OFF"; then
				return 2
			fi
			return 0
		fi

		# Detect Turbografx-CD / PC Engine CD-ROM2 or PC-FX
		# Boot header lives at a fixed offset in the first data track
		local TGCD_HDR_OFF=32 TGCD_RAW_USER_OFF=16 SECTSIZE=0
		local TGCD_PROBE_SECTORS=256 HDR_INFO CUE_TRACK TGCD_CUE_BIN
		local CUE_LBA BYTE_OFF SECTOR_INDEX DISC_FORMAT

		# Implement different detection paths based on input file
		if [[ -n "$CUEFILE" ]]; then
			# Parse CUE file to determine offset of first data track
			if CUE_TRACK=$(resolve_cue_to_data_track "$CUEFILE"); then
				read -r CUE_LBA TGCD_CUE_BIN <<< "$CUE_TRACK"
				# TGCD magic in the sector following the data-track INDEX-01
				BYTE_OFF=$(( (CUE_LBA + 1) * 2352 + TGCD_RAW_USER_OFF + TGCD_HDR_OFF ))
				hex_to_ascii "$(read_hex "$TGCD_CUE_BIN" "$BYTE_OFF" 23)" SIG
				if [[ "$SIG" == "PC Engine CD-ROM SYSTEM" ]]; then
					HDR_INFO="$BYTE_OFF"; DISC_FORMAT="tgcd"; SECTSIZE=2352
				else
					# PC-FX: magic in the first data sector's user data
					BYTE_OFF=$(( CUE_LBA * 2352 + TGCD_RAW_USER_OFF ))
					hex_to_ascii "$(read_hex "$TGCD_CUE_BIN" "$BYTE_OFF" 16)" SIG
					[[ "$SIG" == "PC-FX:Hu_CD-ROM " ]] && { HDR_INFO="$BYTE_OFF"; DISC_FORMAT="pcfx"; SECTSIZE=2352; }
				fi
				debug "TG CD / PC-FX (CUE): bin=$TGCD_CUE_BIN lba=$CUE_LBA off=$BYTE_OFF fmt=${DISC_FORMAT:-none}"
			fi

		elif [[ "$EXT" == "iso" ]]; then
			# ISO path: 2048-byte sectors, header at LBA 1
			BYTE_OFF=$(( $(lba_to_offset 1) + TGCD_HDR_OFF ))
			hex_to_ascii "$(read_hex "$FILE" "$BYTE_OFF" 23)" SIG
			if [[ "$SIG" == "PC Engine CD-ROM SYSTEM" ]]; then
				HDR_INFO="$BYTE_OFF"; DISC_FORMAT="tgcd"; SECTSIZE=2048
			else
				# PC-FX: boot magic at data sector 0 (no 16-byte mode header)
				BYTE_OFF=$(lba_to_offset 0)
				hex_to_ascii "$(read_hex "$FILE" "$BYTE_OFF" 16)" SIG
				[[ "$SIG" == "PC-FX:Hu_CD-ROM " ]] && { HDR_INFO="$BYTE_OFF"; DISC_FORMAT="pcfx"; SECTSIZE=2048; }
			fi
			debug "TG CD / PC-FX (ISO): off=$BYTE_OFF fmt=${DISC_FORMAT:-none}"

		elif [[ "$EXT" == "bin" || "$EXT" == "dev" ]]; then
			# Bare BIN path: bounded probe of raw sectors to identify signature
			debug "TG CD / PC-FX (BIN): probing first $TGCD_PROBE_SECTORS raw sectors"
			for (( SECTOR_INDEX=0; SECTOR_INDEX<TGCD_PROBE_SECTORS; SECTOR_INDEX++ )); do
				BYTE_OFF=$(( SECTOR_INDEX * 2352 + TGCD_RAW_USER_OFF + TGCD_HDR_OFF ))
				hex_to_ascii "$(read_hex "$FILE" "$BYTE_OFF" 23)" SIG
				
				# Check xxd's exit to catch read failures on physical/cdemu
				# device, which is not supported due to need to identify
				# track 2 start
				if (( PIPESTATUS[0] != 0 )); then
					debug "TG CD / PC-FX (BIN): read failed at sector $SECTOR_INDEX off=$BYTE_OFF; aborting probe"
					break
				fi
				if [[ "$SIG" == "PC Engine CD-ROM SYSTEM" ]]; then
					debug "TG CD (BIN): matched at raw sector $SECTOR_INDEX off=$BYTE_OFF"
					HDR_INFO="$BYTE_OFF"; DISC_FORMAT="tgcd"; SECTSIZE=2352; break
				fi
				BYTE_OFF=$(( SECTOR_INDEX * 2352 + TGCD_RAW_USER_OFF ))
				hex_to_ascii "$(read_hex "$FILE" "$BYTE_OFF" 16)" SIG
				if [[ "$SIG" == "PC-FX:Hu_CD-ROM " ]]; then
					debug "PC-FX (BIN): matched at raw sector $SECTOR_INDEX off=$BYTE_OFF"
					HDR_INFO="$BYTE_OFF"; DISC_FORMAT="pcfx"; SECTSIZE=2352; break
				fi
			done
		fi

		if [[ -n "$HDR_INFO" ]]; then
			if [[ "$DISC_FORMAT" == "pcfx" ]]; then
				extract_pcfx "$FILE" "$HDR_INFO" "$SECTSIZE"
			else
				extract_tgcd "$FILE" "$HDR_INFO"
			fi
			return 0
		fi

		cwarn "Error: '${FILE}' appears to be a disc image, but no PVD found."
		cdemu_unload_image
		return 2
	fi

	# For confirmed ISO 9660, scan root directory once and build array
	iso_scan_root "$FILE"
	debug "Root directory scanned: ${#ROOT_ENTRIES[@]} entries"

	# Detect PS1 and PS2 CD or DVD
	if [[ "${PVD[SYSTEM]^^}" == "PLAYSTATION" ]]; then
		if [[ -n "${ROOT_ENTRIES[SYSTEM.CNF]}" ]]; then
			PFILE_INFO="${ROOT_ENTRIES[SYSTEM.CNF]}"
			# ROOT_ENTRIES stores "LBA SIZE FLAGS";
			# extract_ps1-2 expects "LBA SIZE"
			PFILE_INFO="${PFILE_INFO% *}"
			debug "Detected PS1/2 image"

			if ! extract_ps1-2 "$FILE" "$PFILE_INFO"; then
				return 2
			fi
			return 0
		fi
	fi

	# Detect PSP UMD
	if [[ -n "${ROOT_ENTRIES[PSP_GAME]}" ]]; then
		read -r LBA SIZE FLAGS <<< "${ROOT_ENTRIES[PSP_GAME]}"
		if PFILE_INFO=$(iso_dir_find "$FILE" "$LBA" "$SIZE" "PARAM.SFO"); then
			# iso_dir_find returns "LBA SIZE FLAGS"; extract_psp-3-v expects "LBA SIZE"
			PFILE_INFO="${PFILE_INFO% *}"
			debug "Detected PSP image"
			if ! extract_psp-3-v "$FILE" "$PFILE_INFO" "psp"; then
				return 2
			fi
			return 0
		fi
	fi

	# Detect PS3 Blu-ray
	if [[ -n "${ROOT_ENTRIES[PS3_GAME]}" ]]; then
		read -r LBA SIZE FLAGS <<< "${ROOT_ENTRIES[PS3_GAME]}"
		if PFILE_INFO=$(iso_dir_find "$FILE" "$LBA" "$SIZE" "PARAM.SFO"); then
			# iso_dir_find returns "LBA SIZE FLAGS"; extract_psp-3-v expects "LBA SIZE"
			PFILE_INFO="${PFILE_INFO% *}"
			debug "Detected PS3 image"
			if ! extract_psp-3-v "$FILE" "$PFILE_INFO" "ps3"; then
				return 2
			fi
			return 0
		fi
	fi

	# Detect PS4 Blu-ray
	if [[ -n "${ROOT_ENTRIES[PS4]}" || "${PVD[VOLUME]^^}" == "PS4VOLUME" ]]; then
		if ps4_find_pkg_via_iso "$FILE"; then
			debug "Detected PS4 image: ${#PS4_PKG_OFFSETS[@]} PKG(s) found"
			if ! extract_ps4 "$FILE"; then
				return 2
			fi
			return 0
		fi
	fi

	# Detect PS5 Blu-ray
	hex_to_ascii "$(read_hex "$FILE" 3072 12)" SIG
	if [[ $SIG == "PlayStation5" ]]; then
		debug "Detected PS5 image"
		if ! extract_ps5 "$FILE"; then
			return 2
		fi
		return 0
	fi

	# Detect Xbox DVD
	# Offsets = 0x18310000 = 405864448 = XGD2
	#           0x10000    =     65536 = XISO
	local XBOX_OFF
	for XBOX_OFF in 405864448 65536; do
		hex_to_ascii "$(read_hex "$FILE" "$XBOX_OFF" 20)" SIG
		if [[ "$SIG" == "MICROSOFT*XBOX*MEDIA" ]]; then
			debug "Detected original Xbox XDVDFS (descriptor at $XBOX_OFF)"
			if ! extract_xbox "$FILE" "$XBOX_OFF"; then
				return 2
			fi
			return 0
		fi
	done

	# Detect Xbox 360 DVD
	# Offsets = 0x0fda0000 = 265945088 = XGD2
	#           0x02090000 =  34144256 = XGD3
	local X360_OFF
	for X360_OFF in 265945088 34144256; do
		hex_to_ascii "$(read_hex "$FILE" "$X360_OFF" 20)" SIG
		if [[ "$SIG" == "MICROSOFT*XBOX*MEDIA" ]]; then
			debug "Detected Xbox 360 XDVDFS (descriptor at $X360_OFF)"
			if ! extract_x360 "$FILE" "$X360_OFF"; then
				return 2
			fi
			return 0
		fi
	done

	# Detect Sega CD / Mega-CD
	hex_to_ascii "$(read_hex "$FILE" "$ZERO_OFF" 14)" SIG
	if [[ $SIG == "SEGADISCSYSTEM" ]]; then
		debug "Detected Sega CD IP.BIN at offset ${ZERO_OFF}"
		if ! extract_segacd "$FILE" "$ZERO_OFF"; then
			return 2
		fi
		return 0
	fi

	# Detect Saturn CD
	hex_to_ascii "$(read_hex "$FILE" "$ZERO_OFF" 15)" SIG
	if [[ $SIG == "SEGA SEGASATURN" ]]; then
		debug "Detected Sega Saturn IP.BIN at offset ${ZERO_OFF}"
		if ! extract_saturn "$FILE" "$ZERO_OFF"; then
			return 2
		fi
		return 0
	fi

	# Detect Dreamcast GD-ROM
	hex_to_ascii "$(read_hex "$FILE" "$ZERO_OFF" 15)" SIG
	if [[ $SIG == "SEGA SEGAKATANA" ]]; then
		debug "Detected Dreamcast IP.BIN at offset ${ZERO_OFF}"
		if ! extract_dreamcast "$FILE" "$ZERO_OFF"; then
			return 2
		fi
		return 0
	fi

	# Detect SNK Neo Geo CD
	if [[ -n "${ROOT_ENTRIES[IPL.TXT]}" ]]; then
		debug "Detected SNK Neo Geo CD (IPL.TXT)"
		if ! extract_neogeocd "$FILE"; then
			return 2
		fi
		return 0
	fi

	# Treat unmatched ISO as generic (PC) platform and print all PVD
	extract_pc "$FILE"
}

# Detect media format and call appropriate extraction function
# Relies on globals set in main and read_media
detect_format() {

	# Initial media detection is based on file extension
	case "$EXT" in

		# All optical media, except for natively supported compressed formats
		# Intentionally kept first in case list
		bin|cue|iso|dev)
			debug "Testing optical media properties for '${FILE}'"

			if [[ "$EXT" == "cue" ]]; then
				CUEFILE="$FILE"
				FILE=$(resolve_cue_to_bin "$FILE") || return $?
				debug "Resolved CUE '${ORIGFILE}' to BIN '${FILE}'"
			fi

			detect_sector_size "$FILE"
			debug "Sector layout: ${SECTORSIZE}-byte sectors, data offset ${DATAOFF}"
			detect_disc
			;;

		# 3DS cartridge image (CCI/NCSD)
		3ds|3dz)
			hex_to_ascii "$(read_hex "$FILE" 256 4)" SIG
			if [[ "$SIG" == "NCSD" ]]; then
				debug "Detected Nintendo 3DS cartridge image"
				if ! extract_3ds "$FILE"; then
					cwarn "Error: '$FILE' recognized as 3DS image, but metadata extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized Nintendo 3DS image"
				return 2
			fi
			;;

		# Atari 2600or 5200 cartridge image (raw ROM, no header)
		a26|a52)
			# No magic - assume extension is accurate
			debug "Detected Atari 2600 cartridge image"
			if ! extract_2600-5200 "$FILE" "$EXT"; then
				cwarn "Error: '$FILE' recognized as Atari 2600 but extraction failed"
				return 2
			fi
			;;

		# Atari 7800 cartridge image (A78 header)
		a78)
			hex_to_ascii "$(read_hex "$FILE" 1 9)" SIG
			if [[ "$SIG" == "ATARI7800" ]]; then
				debug "Detected Atari 7800 cartridge image"
				if ! extract_7800 "$FILE"; then
					cwarn "Error: '$FILE' recognized as Atari 7800 but extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized Atari 7800 image"
				return 2
			fi
			;;

		# Atari Lynx cartridge image
		lnx)
			# Header with LYNX magic *may* exist, but not guaranteed
			debug "Detected Atari Lynx cartridge image"
			if ! extract_lynx "$FILE"; then
				cwarn "Error: '$FILE' recognized as Atari Lynx but extraction failed"
				return 2
			fi
			;;

		# CSO-compressed compressed PSP or PS2 images (CISO)
		cso)
			if ! cso_init "$FILE"; then
				cwarn "Error: '$FILE' is not a recognized CSO image"
				return 2
			fi
			# CSO is always ISO format (never BIN) - hardcode sector layout
			SECTORSIZE=2048
			DATAOFF=0
			debug "CSO: native mode active (block_size=${CSO_BLOCK_SIZE} shift=${CSO_SHIFT})"
			detect_disc
			;;

		# Famicom Disk System image
		fds)
			local FWNES_SIG FDS_DATAOFF FDS_BLK
			# Support optional fwNES header
			FWNES=$(read_hex "$FILE" 0 4)
			[[ "$FWNES" == "4644531a" ]] && FDS_DATAOFF=16 || FDS_DATAOFF=0

			FDS_BLK=$(read_hex "$FILE" "$FDS_DATAOFF" 0x10)

			hex_to_ascii "${FDS_BLK:2:28}" SIG
			if [[ "$SIG" == "*NINTENDO-HVC*" ]]; then
				debug "Detected Famicom Disk System image"
				if ! extract_fds "$FILE" "$FDS_DATAOFF"; then
					cwarn "Error: '$FILE' recognized as FDS but extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized FDS image"
				return 2
			fi
			;;

		# Game Boy / Game Boy Color cartridge image
		gb|gbc)
			# CGB flag at 0x143 distinguishes GB (0x00/0x80) from GBC (0xC0)
			local GB_CGB
			GB_CGB=$(read_hex "$FILE" 0x143 1)
			case "$GB_CGB" in
				00|80)
					debug "Detected Game Boy cartridge image"
					if ! extract_gb-gbc "$FILE" "gb"; then
						cwarn "Error: '$FILE' recognized as GB but extraction failed"
						return 2
					fi
					;;
				c0)
					debug "Detected Game Boy Color cartridge image"
					if ! extract_gb-gbc "$FILE" "gbc"; then
						cwarn "Error: '$FILE' recognized as GBC but extraction failed"
						return 2
					fi
					;;
				*)
					cwarn "Error: '$FILE' is not a recognized Game Boy image"
					return 2
					;;
			esac
			;;

		# Game Boy Advance cartridge image
		gba)
			SIG=$(read_hex "$FILE" 0xB2 1)
			if [[ "$SIG" == "96" ]]; then
				debug "Detected Game Boy Advance cartridge image"
				if ! extract_gba "$FILE"; then
					cwarn "Error: '$FILE' recognized as GBA but extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized GBA image"
				return 2
			fi
			;;

		# Genesis / Mega Drive cartridge image
		gen|md)
			hex_to_ascii "$(read_hex "$FILE" 0x100 4)" GEN_SIG
			if [[ "$GEN_SIG" == "SEGA" ]]; then
				debug "Detected Genesis  cartridge image"
				if ! extract_genesis "$FILE"; then
					cwarn "Error: '$FILE' recognized as Genesis/Mega Drive image but metadata extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized Genesis/Mega Drive image"
				return 2
			fi
			;;

		# Atari Jaguar cartridge image
		j64)
			# No magic - assume extension is accurate
			debug "Detected Atari Jaguar cartridge image"
			if ! extract_jaguar "$FILE"; then
				cwarn "Error: '$FILE' recognized as Atari Jaguar but extraction failed"
				return 2
			fi
			;;

		# Mattel Intellivision cartridge image
		int)
			# No magic - assume extension is accurate
			debug "Detected Mattel Intellivision cartridge image"
			if ! extract_intv "$FILE"; then
				cwarn "Error: '$FILE' recognized as Intellivision but extraction failed"
				return 2
			fi
			;;

		# DS cartridge image
		nds)
			SIG=$(read_hex "$FILE" 0xC0 4)
			if [[ "$SIG" == "24ffae51" ]]; then
				debug "Detected Nintendo DS cartridge image"
				if ! extract_nds "$FILE"; then
					cwarn "Error: '$FILE' recognized as DS image, but metadata extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized Nintendo DS image"
				return 2
			fi
			;;

		# NES cartridge image (iNES format)
		nes)
			SIG=$(read_hex "$FILE" 0 4)
			if [[ "$SIG" == "4e45531a" ]]; then  # "NES\x1A"
				debug "Detected NES cartridge image (iNES format)"
				if ! extract_nes "$FILE"; then
					cwarn "Error: '$FILE' recognized as NES but extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized NES image"
				return 2
			fi
			;;

		# SNK Neo Geo Pocket / Pocket Color cartridge image
		ngp|ngc)
			hex_to_ascii "$(read_hex "$FILE" 0x0A 18)" NGP_SIG
			if [[ "$NGP_SIG" == "BY SNK CORPORATION" ]]; then
				debug "Detected SNK Neo Geo Pocket (${EXT}) cartridge image"
				if ! extract_ngp-ngpc "$FILE"; then
					cwarn "Error: '$FILE' recognized as SNK Neo Geo Pocket but extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized SNK Neo Geo Pocket image"
				return 2
			fi
			;;

		# Nintendo Switch package (NSP/NSZ)
		nsp|nsz)
			SIG=$(read_hex "$FILE" 0 4)
			if [[ "$SIG" == "50465330" ]]; then   # "PFS0"
				debug "Detected Nintendo Switch package (${EXT^^})"
				if ! extract_switch "$FILE" "pfs0"; then
					cwarn "Error: '$FILE' recognized as ${EXT^^} but metadata extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized ${EXT^^} image"
				return 2
			fi
			;;

		# NEC TurboGrafx-16 / SuperGrafx (PC Engine HuCard) cartridge image
		pce|sgx)
			# No magic - assume extension is accurate
			debug "Detected NEC ${EXT^^} cartridge image"
			if ! extract_pce-sgx "$FILE" "$EXT"; then
				cwarn "Error: '$FILE' recognized as NEC ${EXT^^} but extraction failed"
				return 2
			fi
			;;

		# PlayStation PKG format
		pkg)
			local PKG_PLATFORM HDR_HEX REV_HEX PKG_REV ITEM_COUNT DATA_OFF_HI
			local DATA_OFF RIV_HEX FILE_INFO FILE_OFF FILE_SIZE FILE_HEX SFO_HEX
			local TARGET PLAT META_OFF META_COUNT SFO_INFO SFO_OFF SFO_SIZE
			local PSP_PKG_KEY PS3_PKG_KEY AES_KEY
			PSP_PKG_KEY=$(printf "%s" "MDdmMmM2ODI5MGI1MGQyYzMzODE4ZDcwOWI2MGU2MmIK" | base64 -d)
			PS3_PKG_KEY=$(printf "%s" "MmU3YjcxZDdjOWM5YTE0ZWEzMjIxZjE4ODgyOGI4ZjgK" | base64 -d)

			debug "Testing PKG header for '${FILE}'"
			PKG_PLATFORM=$(pkg_detect "$FILE") || {
				cwarn "Error: '$FILE' is not a recognized PKG format"
				return 2
			}

			case "$PKG_PLATFORM" in
				ps3|psp)
					# Per-platform parameters
					case "$PKG_PLATFORM" in
						ps3)  AES_KEY="$PS3_PKG_KEY"; TARGET="PARAM.SFO"; PLAT="ps3"	;;
						psp)  AES_KEY="$PSP_PKG_KEY"; TARGET="PARAM.PBP"; PLAT="psp"	;;
					esac

					debug "Detected ${PKG_PLATFORM^^} PKG"
					bincheck openssl

					# Read full header (0x80 covers all needed fields)
					HDR_HEX=$(read_hex "$FILE" 0 128)
					[[ -n "$HDR_HEX" ]] || { cwarn "Error: failed to read PKG header"; return 2; }

					# Get PKG revision at 0x04 to ensure support
					# 0x8000 = retail, 0x0000 = debug
					hex_substr "$HDR_HEX" 4 2 REV_HEX
					PKG_REV=$((16#$REV_HEX))
					if (( PKG_REV != 0x8000 )); then
						cwarn "Error: ${PKG_PLATFORM^^} debug PKG (revision $(printf '%04x' "$PKG_REV")) is not supported"
						return 2
					fi

					# Get count of items in encrypted file table at 0x14
					hex_be32 "$HDR_HEX" 20 ITEM_COUNT
					(( ITEM_COUNT > 0 && ITEM_COUNT <= 65536 )) || { cwarn "Error: invalid PKG item count ($ITEM_COUNT)"; return 2; }

					# Get encrypted data offset at 0x20; high 32 bits must be zero
					hex_be32 "$HDR_HEX" 32 DATA_OFF_HI
					hex_be32 "$HDR_HEX" 36 DATA_OFF
					(( DATA_OFF_HI == 0 && DATA_OFF > 0 )) || { cwarn "Error: invalid PKG data offset"; return 2; }

					# Get pkg_data_riv (initialization vector) at 0x70
					hex_substr "$HDR_HEX" 112 16 RIV_HEX
					debug "${PKG_PLATFORM^^} PKG: item_count=$ITEM_COUNT data_off=$DATA_OFF riv=$RIV_HEX"

					# Find target file in encrypted file table
					if ! FILE_INFO=$(pkg_ps3_find_file "$FILE" "$RIV_HEX" "$ITEM_COUNT" "$DATA_OFF" "$AES_KEY" "$TARGET"); then
						cwarn "Error: $TARGET not found in ${PKG_PLATFORM^^} PKG"
						return 2
					fi
					read -r FILE_OFF FILE_SIZE <<< "$FILE_INFO"

					# Decrypt target file
					FILE_HEX=$(pkg_decrypt_range "$FILE" "$FILE_OFF" "$FILE_SIZE" "$RIV_HEX" "$DATA_OFF" "$AES_KEY")
					[[ -n "$FILE_HEX" ]] || { cwarn "Error: failed to decrypt $TARGET from PKG"; return 2; }

					# PSP stores SFO inside PARAM.PBP; extract it
					if [[ "$PKG_PLATFORM" == "psp" ]]; then
						if ! SFO_HEX=$(pkg_psp_sfo_from_pbp "$FILE_HEX"); then
							cwarn "Error: failed to extract PARAM.SFO from PARAM.PBP"
							return 2
						fi
					else
						SFO_HEX="$FILE_HEX"
					fi

					if ! extract_psp-3-v "$FILE" "" "$PLAT" "$SFO_HEX"; then
						return 2
					fi
					;;

				vita)
					debug "Detected Vita PKG"

					# Read header fields: offset at 0x08, count at 0x0C
					HDR_HEX=$(read_hex "$FILE" 0 16)

					hex_be32 "$HDR_HEX" 8 META_OFF
					hex_be32 "$HDR_HEX" 12 META_COUNT
					(( META_COUNT > 0 && META_COUNT <= 256 )) || { cwarn "Error: invalid PKG metadata count ($META_COUNT)"; return 2; }

					debug "Vita PKG: meta_off=$META_OFF meta_count=$META_COUNT"

					# Find PARAM.SFO in unencrypted metadata
					if ! SFO_INFO=$(pkg_vita_find_sfo "$FILE" "$META_OFF" "$META_COUNT"); then
						cwarn "Error: PARAM.SFO not found in Vita PKG"
						return 2
					fi
					read -r SFO_OFF SFO_SIZE <<< "$SFO_INFO"

					# Read PARAM.SFO directly (unencrypted)
					SFO_HEX=$(read_hex "$FILE" "$SFO_OFF" "$SFO_SIZE")
					[[ -n "$SFO_HEX" ]] || { cwarn "Error: failed to read PARAM.SFO from PKG"; return 2; }

					if ! extract_psp-3-v "$FILE" "" "vita" "$SFO_HEX"; then
						return 2
					fi
					;;

				ps4)
					debug "Detected PS4 PKG"
					PS4_PKG_OFFSETS=(0)
					if ! extract_ps4 "$FILE" "pkg"; then
						return 2
					fi
					;;
			esac

			return 0
			;;

		# Vita cartridge image (PSVgameSD)
		psv)
			# Header magic = 'PSV\0'; unheadered magic = 'Sony'
			SIG=$(read_hex "$FILE" 0 4)  
			if [[ "$SIG" == "50535600" || "${SIG^^}" == "536F6E79" ]]; then
				debug "Detected Vita cartridge image (PSVgameSD)"
				if ! extract_vita "$FILE"; then
					return 2
				fi
				return 0
			else
				cwarn "Error: '$FILE' is not a recognized Vita (PSVgameSD) image"
				return 2
			fi
			;;

		# GameCube / Wii RVZ-compressed disc image
		# RVZ container embeds the disc header (0x80 bytes) uncompressed at
		# 0x58; disc_type BE32 @ 0x48 selects GC (1) or Wii (2).
		rvz)
			local RVZ_DISCTYPE
			SIG=$(read_hex "$FILE" 0 4)
			if [[ "$SIG" == "52565a01" ]]; then
				RVZ_DISCTYPE=$(read_hex "$FILE" 0x48 4)
				if [[ "$RVZ_DISCTYPE" == "00000001" ]]; then
					debug "Detected GameCube RVZ image"
					if ! extract_gc-wii "$FILE" "gc" 0x58; then
						cwarn "Error: '$FILE' recognized as GC RVZ image, but metadata extraction failed"
						return 2
					fi
				elif [[ "$RVZ_DISCTYPE" == "00000002" ]]; then
					debug "Detected Wii RVZ image"
					if ! extract_gc-wii "$FILE" "wii" 0x58; then
						cwarn "Error: '$FILE' recognized as Wii RVZ image, but metadata extraction failed"
						return 2
					fi
				else
					cwarn "Error: '$FILE' is an unrecognized RVZ disc type"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized RVZ image"
				return 2
			fi
			;;

		# Super Nintendo cartridge image
		sfc|smc)
			debug "Detected Super Nintendo cartridge image"
			if ! extract_snes "$FILE"; then
				cwarn "Error: '$FILE' is not a recognized SNES image"
				return 2
			fi
			;;

		# Sega Master System / Game Gear cartridge image
		sms|gg)
			local HDR_OFF
			for HDR_OFF in 0x7FF0 0x3FF0 0x1FF0; do
				hex_to_ascii "$(read_hex "$FILE" "$HDR_OFF" 8)" SEGA_SIG
				[[ "$SEGA_SIG" == "TMR SEGA" ]] && break
				HDR_OFF=""
			done
			if [[ -n "$HDR_OFF" ]]; then
				debug "Detected ${EXT^^} cartridge image (TMR SEGA at 0x$(printf '%x' "$HDR_OFF"))"
				if ! extract_sms-gg "$FILE" "${EXT,,}" "$HDR_OFF"; then
					cwarn "Error: '$FILE' recognized as ${EXT^^} image but metadata extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized ${EXT^^} image"
				return 2
			fi
			;;

		# Nintendo TMD (Title Metadata) - 3DS CDN or Wii U
		tmd)
			if ! extract_tmd "$FILE"; then
				cwarn "Error: '$FILE' is not a recognized TMD file"
				return 2
			fi
			;;

		# Virtual Boy cartridge image
		# No magic; validated via reserved bytes = 0 at header+0x14
		vb)
			local VB_FILESIZE VB_HDR_OFF VB_RESERVED
			VB_FILESIZE=$(stat -c %s "$FILE")
			VB_HDR_OFF=$(( VB_FILESIZE - 0x220 ))
			VB_RESERVED=$(read_hex "$FILE" $(( VB_HDR_OFF + 0x14 )) 5)

			if [[ "$VB_RESERVED" == "0000000000" ]]; then
				debug "Detected Virtual Boy cartridge image"
				if ! extract_vb "$FILE"; then
					cwarn "Error: '$FILE' recognized as VB but extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized Virtual Boy image"
				return 2
			fi
			;;

		# Nintendo Wii WAD (channel/installable package)
		wad)
			SIG=$(read_hex "$FILE" 0 4)
			hex_to_ascii "$(read_hex "$FILE" 4 2)" WAD_TYPE

			if [[ "$SIG" == "00000020" && ( "$WAD_TYPE" == "Is" || "$WAD_TYPE" == "ib" ) ]]; then
				debug "Detected Wii WAD image (type '${WAD_TYPE}')"
				if ! extract_wad "$FILE"; then
					cwarn "Error: '$FILE' recognized as WAD but metadata extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized WAD image"
				return 2
			fi
			;;

		# Bandai WonderSwan / WonderSwan Color cartridge image
		ws|wsc)
			# Platform follows the extension (ws vs wsc); no magic
			debug "Detected Bandai ${EXT} cartridge image"
			if ! extract_ws-wsc "$FILE" "$EXT"; then
				cwarn "Error: '$FILE' recognized as WonderSwan but extraction failed"
				return 2
			fi
			;;

		# Wii U uncompressed disc image
		wud)
			SIG=$(read_hex "$FILE" 0 3)
			if [[ "$SIG" == "575550" ]]; then   # "WUP"
				debug "Detected Wii U WUD image"
				if ! extract_wiiu "$FILE" 0 0; then
					cwarn "Error: '$FILE' recognized as Wii U WUD image but metadata extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized Wii U disc image"
				return 2
			fi
			;;

		# Wii U deduplicated disc image (direct-read via LUT)
		wux)
			local WUX_DATA_START
			if WUX_DATA_START=$(wux_init "$FILE"); then
				debug "Detected Wii U WUX image (data_start=${WUX_DATA_START})"
				if ! extract_wiiu "$FILE" 1 "$WUX_DATA_START"; then
					cwarn "Error: '$FILE' recognized as Wii U WUX image but metadata extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized WUX image"
				return 2
			fi
			;;

		# Xbox digital content (default.xbe)
		xbe)
			hex_to_ascii "$(read_hex "$FILE" 0 4)" SIG
			if [[ "$SIG" == "XBEH" ]]; then
				debug "Detected default.xbe (Xbox digital content)"
				if ! extract_xbox_xbe "$FILE"; then
					cwarn "Error: '$FILE' recognized as Xbox XBE but metadata extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized Xbox XBE image"
				return 2
			fi
			;;

		# Nintendo Switch gamecard image (XCI)
		xci|xcz)
			SIG=$(read_hex "$FILE" 0xF000 4)
			if [[ "$SIG" == "48465330" ]]; then   # "HFS0"
				debug "Detected Nintendo Switch gamecard image (${EXT^^})"
				if ! extract_switch "$FILE" "hfs0"; then
					cwarn "Error: '$FILE' recognized as ${EXT^^} but metadata extraction failed"
					return 2
				fi
			else
				cwarn "Error: '$FILE' is not a recognized ${EXT^^} image"
				return 2
			fi
			;;

		# Nintendo 64 cartridge image
		z64|v64|n64)
			local N64_SIG N64_SWAP
			N64_SIG=$(read_hex "$FILE" 0 4)
			case "$N64_SIG" in
				80371240) N64_SWAP=0 ;;  # z64 big-endian
				37804012) N64_SWAP=1 ;;  # v64 byteswapped
				40123780) N64_SWAP=2 ;;  # n64 little-endian
				*) cwarn "Error: '$FILE' is not a recognized N64 image"
				   return 2 ;;
			esac
			debug "Detected Nintendo 64 cartridge image (swap=$N64_SWAP)"
			if ! extract_n64 "$FILE" "$N64_SWAP"; then
				cwarn "Error: '$FILE' recognized as N64 but metadata extraction failed"
				return 2
			fi
			;;

		# Anything else with no recognized extension is unsupported
		*)
			# 3DS / Wii U TMD files; may have non-.tmd extensions
			local STEM="${FILE##*/}"; STEM="${STEM%.*}"
			if [[ "$STEM" == "tmd" ]]; then
				if ! extract_tmd "$FILE"; then
					cwarn "Error: '$FILE' is not a recognized TMD file"
					return 2
				fi
				return 0
			fi

			# Xbox 360 STFS digital packages; arbitrary names, no extension
			hex_to_ascii "$(read_hex "$FILE" 0 4)" SIG
			case "$SIG" in
				"CON "|PIRS|LIVE)
					debug "Detected Xbox 360 STFS content package"
					if ! extract_x360_stfs "$FILE"; then
						cwarn "Error: '$FILE' is not a recognized Xbox 360 STFS package"
						return 2
					fi
					return 0
					;;
			esac

			# Anything else is unsupported
			cwarn "Error: '$FILE' is an unsupported game media file"
			return 2
	esac
}

# Prep media by initializing variables, handling any necessary decompression,,
# call detect_format for format detection and metadata extraction, then
# cleanup extracted files
#	$1 = file
read_media() {
	local FILE="$1" ORIGFILE="$1"
	local EXT="${FILE##*.}"; EXT="${EXT,,}"
	local CDEMU_DEV VIRTUAL_DEV CUEFILE

	# Cleanup handler: unload and release any claimed device on any exit path
	trap 'cdemu_unload_image; [[ -n "$CDEMU_DEV" ]] && cdemu_release_device "$CDEMU_DEV"' EXIT

	# If CHD file and CHD mode enabled, claim a device and load
	if [[ "$EXT" == "chd" ]] && (( CHDMODE )); then
		CDEMU_DEV=$(cdemu_claim_device)
		if [[ -z "$CDEMU_DEV" ]]; then
			cwarn "Error: failed to claim cdemu device for '$FILE'"
			return 2
		fi
		debug "CHD '$FILE' assigned to cdemu device $CDEMU_DEV"
		VIRTUAL_DEV="${CDEMU_VIRT_DEVS[$CDEMU_DEV]}"
		if ! cdemu_load_image "$FILE"; then
			return 2
		fi
		# Set dev EXT instead of chd for pickup by optical disc handler below
		EXT="dev"
	fi

	# Is this a raw device?
	[[ "$FILE" == /dev/* ]] && EXT="dev"

	# Always try unpacking file
	# Unrecognized types will fall through and be processed normally
	# When CHDMODE enabled, this should see virtual CDROM for CHDs
	# FILE and EXT will be updated as appropriate
	debug "Attempt to extract '$FILE'"
	unplite "${FILE}" >/dev/null

	# Detect media format and extract metadata
	detect_format

	# Cleanup temprary extracted files
	(( $EXTRACTED )) && cleanup_extracted "$EXTRACTDIR"
}

:<<'COMMENTBLOCK'
	#OLD / UN-INTEGRATED CODE

	# Get list of files from PlayStation ISO
	#	The leading space is intentional to only match files in root directory
	LIST=$(7z l "$FILE" | grep -i ' system.cnf\|ps[3p]_game/param.sfo\|app/.*/sce_sys/param.sfo\|app/.*/app\(.*\)\?.pkg\|VIDEO_TS.IFO')

	# Determine if supported metadata file is included in ISO
	case $(echo "$LIST" | awk '{print $NF}') in

		# Process Vita metadata (PKGs handled separately)
		app/*/sce_sys/param.sfo)
			7z x -o"$TDIR" "$FILE" app/*/sce_sys/param.sfo >/dev/null
			if [ ! -s "$TDIR"/app/*/sce_sys/param.sfo ]; then
				cwarn "Error: '$FILE' is not a valid Vita package"
				exit 1
			fi

			ID=$(sfo -d "${TDIR}"/app/*/sce_sys/param.sfo TITLE_ID)
			if (( VERBOSE )); then
				APPVER=$(sfo -d "${TDIR}"/app/*/sce_sys/param.sfo APP_VER)
				ATTRIB=$(sfo -d "${TDIR}"/app/*/sce_sys/param.sfo ATTRIBUTE)
				ATTRIB2=$(sfo -d "${TDIR}"/app/*/sce_sys/param.sfo ATTRIBUTE2)
				ATTRIBM=$(sfo -d "${TDIR}"/app/*/sce_sys/param.sfo ATTRIBUTE_MINOR)
				CONTID=$(sfo -d "${TDIR}"/app/*/sce_sys/param.sfo CONTENT_ID)
				SYSVER=$(sfo -d "${TDIR}"/app/*/sce_sys/param.sfo PSP2_DISP_VER)
				TITLE=$(sfo -d "${TDIR}"/app/*/sce_sys/param.sfo TITLE)
				PKGVER=$(sfo -d "${TDIR}"/app/*/sce_sys/param.sfo VERSION)

				OUTPUT="Game ID:      ${ID}\nTitle:        ${TITLE}\nVersion:      ${APPVER} (Package Ver. ${PKGVER})\nMin Sys Ver:  ${SYSVER}\nContent ID:   ${CONTID}\nAttributes:   ${ATTRIB}\nAttributes 2: ${ATTRIB2}\nAttr. Minor:  ${ATTRIBM} (Decimal: $(( ${ATTRIB/0x/16\#} )))"
				cinfo "\n${NAME}"
				echo -e "${OUTPUT}"
			else
				(( ! QUIET )) && echo -n "${NAME}: "
				echo "${ID}"
			fi
			;;

	esac
}
COMMENTBLOCK

# Verify necessary binaries can be found
bincheck xxd awk || return 1

# Process arguments
if [[ $# -eq 0 ]]; then
	warning
else
	while [[ $# -ne 0 ]]; do

		# Match known arguments
		if [[ "$1" == "-h" || "$1" == "--help" || "$1" == "-?" ]]; then
			warning

		# Disable native CSO reading, fall back to maxcso decompression
		elif [[ "$1" == "-C" ]]; then
			CSO_MODE=0

		# Enable CHD mode
		elif [[ "$1" == "-c" ]]; then
			bincheck cdemu || exit 1
			CHDMODE=1

		# Change location of work directory
		elif [[ "$1" == "-d" ]]; then
			shift
			if [[ -d "$1" ]]; then
				TEMPROOT="$1"
			else
				cwarn "Error: '${1}' does not appear to be a valid directory"
				warning
			fi

		# Specify Wii U key directory for game key lookup
		elif [[ "$1" == "-k" ]]; then
			shift
			if [[ -d "$1" ]]; then
				WIIU_KEYDIR="$1"
			else
				cwarn "Error: '${1}' does not appear to be a valid directory"
				warning
			fi

		# Specify JWUDTool JAR path for Wii U meta.xml extraction
		elif [[ "$1" == "-j" ]]; then
			shift
			if [[ -f "$1" ]]; then
				JWUDTOOL="$1"
			else
				cwarn "Error: '${1}' does not appear to be a valid file"
				warning
			fi

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

		elif [[ "$1" == "-q" ]]; then
			if (( VERBOSE || DEBUGMODE )); then
				cwarn "Error: -v and -q cannot be used together"
				warning
			else
				QUIET=1
			fi

		# Set number of concurrent processes
		elif [[ "$1" == "-t" ]]; then
			shift
			THREADS=$1
			
			if ! is_int "$THREADS" || [[ "$THREADS" -lt 1 ]]; then
				cwarn "Error: You must specify the number of threads (-tN)"
				exit 1
			elif [[ "$1" -gt "$(nproc)" ]]; then
				cwarn "You specified ${THREADS} threads, but you only have $(nproc) total threads."
				exit 1
			fi

		elif [[ "$1" == "-v" ]]; then
			if (( QUIET )); then
				cwarn "Error: -v and -q cannot be used together"
				warning
			else
				VERBOSE=1
			fi

		elif [[ "$1" == "-V" ]]; then
			if (( QUIET )); then
				cwarn "Error: -V and -q cannot be used together"
				warning
			else
				DEBUGMODE=1
				# Declare global timers for debugging
				DEBUGSTART=$(date +%s%3N)
				DEBUGELAPSE=$(date +%s%3N)

			fi

		# Anything that's not a known argument gets treated as a file
		else
			FILES+=("$1")
		fi
		shift
	done
fi

# Initialize working directory
TEMPDIR=$(mktemp -d -p "${TEMPROOT}" "${TITLE}.XXXXX")

# If CHD mode requested, initialize device pool
if (( CHDMODE )); then
	# Pre-create up to THREADS number of drives drives,
	# but limit to number of files if < THREADS to eliminate wasteful init time
	COUNT="$THREADS"
	(( ${#FILES[@]} < THREADS )) && COUNT=${#FILES[@]}
	if ! cdemu_init_pool "$COUNT"; then
		cwarn "Error: cdemu initialization failed"
		cleanup_dir "$TEMPDIR"
		exit 1
	fi
fi

# Register unified cleanup handler
trap cleanup_env EXIT
trap 'exit 130' SIGINT
trap 'exit 143' SIGTERM

# Process each passed file sequentially
debug "Beginning main file loop"

for INFILE in "${FILES[@]}"; do
	# Verify file exists
	if [[ ! -e "$INFILE" ]]; then
		cwarn "Error: '$INFILE' does not exist"
		exit 1
	fi

	# Wait for a slot if at thread limit
	while (( $(jobs -r | wc -l) >= THREADS )); do
		wait -n
	done

	debug "Initiate read_media for '${INFILE}'"
	read_media "$INFILE" &
done

# Wait for any remaining processes to finish before cleaning up
wait

debug "Completed all processing"
