Hi!
I had to make an account just to jump into this thread, because it was helpful for me.
Nintendo Switch RetroArch currently has a bug that does not allow it to properly hash CHD files for RetroAchievements, so I had to convert a ton of my CHDs… Anyways, here’s a more robust script to convert between CHD/CUE/ISO/ZIP:
#!/bin/bash
# chdsh — Convert between CHD and other disc image formats
# Requirements: chdman (from mame-tools AUR package), unzip (for zip), 7z (for 7z), unrar (for rar).
# Usage: chdsh [options] <input> <output_format>
#
# input A format extension (chd, cue, gdi, iso, zip, 7z, rar)
# or a specific file path (e.g. game.iso)
# output_format Target format: chd, cue, gdi, iso
#
# Options:
# -c Delete original file(s) after successful conversion
# -o <dir> Output directory (default: ./chdsh_output)
# -r Recurse into subdirectories when scanning for files
# -h Show this help and exit
#
# Supported conversions:
# chd → cue, gdi, iso (chdman extractcd)
# cue → chd (chdman createcd)
# gdi → chd (chdman createcd)
# iso → chd (chdman createcd)
# zip/7z/rar → chd (extract CUE/BIN, then chdman createcd)
#
# Examples:
# chdsh chd cue Convert all .chd files in current dir to .cue
# chdsh game.iso chd Convert a single file to .chd
# chdsh -c zip chd Convert .zip archives to .chd, delete originals
# chdsh -r chd cue Recurse into subdirectories
# chdsh -o /tmp/out iso chd Custom output directory
SCRIPT_NAME=$(basename "$0")
OUTPUT_DIR="./chdsh_output"
CLEAN=false
RECURSIVE=false
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
print_help() {
cat >&2 <<'EOF'
chdsh — Convert between CHD and other disc image formats
Usage: chdsh [options] <input> <output_format>
input A format extension (chd, cue, gdi, iso, zip, 7z, rar)
or a specific file path (e.g. game.iso)
output_format Target format: chd, cue, gdi, iso
Options:
-c Delete original file(s) after successful conversion
-o <dir> Output directory (default: ./chdsh_output)
-r Recurse into subdirectories when scanning for files
-h Show this help and exit
Supported conversions:
chd → cue, gdi, iso (chdman extractcd)
cue → chd (chdman createcd)
gdi → chd (chdman createcd)
iso → chd (chdman createcd)
zip/7z/rar → chd (extract CUE/BIN, then chdman createcd)
Examples:
chdsh chd cue Convert all .chd files in current dir to .cue
chdsh game.iso chd Convert a single file to .chd
chdsh -c zip chd Convert .zip archives to .chd, delete originals
chdsh -r chd cue Recurse into subdirectories
chdsh -o /tmp/out iso chd Custom output directory
EOF
}
die() {
echo "$SCRIPT_NAME: error: $*" >&2
exit 1
}
in_array() {
local val="$1"; shift
for item in "$@"; do [[ "$item" == "$val" ]] && return 0; done
return 1
}
# ---------------------------------------------------------------------------
# Option parsing
# ---------------------------------------------------------------------------
while getopts ":co:rh" opt; do
case $opt in
c) CLEAN=true ;;
o) OUTPUT_DIR="$OPTARG" ;;
r) RECURSIVE=true ;;
h) print_help; exit 0 ;;
\?) die "Invalid option: -$OPTARG" ;;
:) die "Option -$OPTARG requires an argument." ;;
esac
done
shift $((OPTIND-1))
if [[ $# -lt 2 ]]; then
print_help
exit 1
fi
INPUT_ARG="$1"
OUTPUT_FORMAT="${2,,}"
# ---------------------------------------------------------------------------
# Determine input files and format
# ---------------------------------------------------------------------------
VALID_INPUTS=(chd cue gdi iso zip 7z rar)
VALID_OUTPUTS=(chd cue gdi iso)
if [[ -f "$INPUT_ARG" ]]; then
# Single-file mode
INPUT_FORMAT="${INPUT_ARG##*.}"
INPUT_FORMAT="${INPUT_FORMAT,,}"
mapfile -t FILES < <(printf '%s\n' "$INPUT_ARG")
else
# Format mode — scan current directory (optionally recursive)
INPUT_FORMAT="${INPUT_ARG,,}"
DEPTH_FLAG=()
$RECURSIVE || DEPTH_FLAG=(-maxdepth 1)
mapfile -t FILES < <(find "$(pwd)" "${DEPTH_FLAG[@]}" -type f -iname "*.${INPUT_FORMAT}" | sort)
fi
in_array "$INPUT_FORMAT" "${VALID_INPUTS[@]}" \
|| die "Unsupported input format '$INPUT_FORMAT'. Supported: ${VALID_INPUTS[*]}"
in_array "$OUTPUT_FORMAT" "${VALID_OUTPUTS[@]}" \
|| die "Unsupported output format '$OUTPUT_FORMAT'. Supported: ${VALID_OUTPUTS[*]}"
[[ "$INPUT_FORMAT" == "$OUTPUT_FORMAT" ]] && die "Input and output formats are the same ('$INPUT_FORMAT')."
# Archives can only target chd
if in_array "$INPUT_FORMAT" zip 7z rar && [[ "$OUTPUT_FORMAT" != "chd" ]]; then
die "Archive inputs (zip, 7z, rar) can only be converted to chd."
fi
# ---------------------------------------------------------------------------
# Resolve chdman subcommand
# ---------------------------------------------------------------------------
case "$INPUT_FORMAT:$OUTPUT_FORMAT" in
chd:cue|chd:gdi|chd:iso) CHDMAN_CMD="extractcd" ;;
cue:chd|gdi:chd|iso:chd) CHDMAN_CMD="createcd" ;;
zip:chd|7z:chd|rar:chd) CHDMAN_CMD="createcd" ;; # applied after extraction
*) die "No conversion path from '$INPUT_FORMAT' to '$OUTPUT_FORMAT'." ;;
esac
# ---------------------------------------------------------------------------
# Dependency check (only what is actually needed)
# ---------------------------------------------------------------------------
DEPS=(chdman)
case "$INPUT_FORMAT" in
zip) DEPS+=(unzip) ;;
7z) DEPS+=(7z) ;;
rar) DEPS+=(unrar) ;;
esac
MISSING=()
for CMD in "${DEPS[@]}"; do
command -v "$CMD" >/dev/null 2>&1 || MISSING+=("$CMD")
done
if [[ ${#MISSING[@]} -gt 0 ]]; then
echo "$SCRIPT_NAME: missing dependencies: ${MISSING[*]}" >&2
if command -v apt >/dev/null 2>&1; then
echo " Install: sudo apt update && sudo apt install ${MISSING[*]}" >&2
elif command -v dnf >/dev/null 2>&1; then
echo " Install: sudo dnf install ${MISSING[*]}" >&2
fi
exit 1
fi
# ---------------------------------------------------------------------------
# Pre-flight summary
# ---------------------------------------------------------------------------
if [[ ${#FILES[@]} -eq 0 ]]; then
echo "$SCRIPT_NAME: no .$INPUT_FORMAT files found." >&2
exit 1
fi
echo "Conversion : $INPUT_FORMAT → $OUTPUT_FORMAT"
echo "Output dir : $OUTPUT_DIR"
$RECURSIVE && echo "Mode : recursive"
echo ""
echo "Files:"
TOTAL_SIZE=0
for FILE in "${FILES[@]}"; do
SIZE_HR=$(du -h "$FILE" | cut -f1)
printf " %-8s %s\n" "$SIZE_HR" "$FILE"
FILE_SIZE=$(stat --format="%s" "$FILE")
TOTAL_SIZE=$(( TOTAL_SIZE + FILE_SIZE ))
done
TOTAL_HR=$(numfmt --to=iec --suffix=B "$TOTAL_SIZE")
FREE=$(df -h "$(pwd)" | awk 'NR==2 {print $4}')
echo ""
printf "%-11s %s\n" "Files:" "${#FILES[@]}"
printf "%-11s %s\n" "Total:" "$TOTAL_HR"
printf "%-11s %s\n" "Free:" "$FREE"
$CLEAN && echo "Originals : will be DELETED after successful conversion"
echo ""
if $CLEAN; then
read -rp "Proceed? Originals will be deleted on success (y/n): " CONFIRM
else
read -rp "Proceed? (y/n): " CONFIRM
fi
[[ "$CONFIRM" != "y" ]] && echo "Aborted." && exit 0
# ---------------------------------------------------------------------------
# Conversion
# ---------------------------------------------------------------------------
mkdir -p "$OUTPUT_DIR"
SUCCESS=0
FAIL=0
convert_archive() {
local IN_FILE="$1" OUT_FILE="$2"
local TEMP_DIR
TEMP_DIR=$(mktemp -d)
# Extract based on extension
case "${IN_FILE,,}" in
*.zip) unzip -q "$IN_FILE" -d "$TEMP_DIR" ;;
*.7z) 7z x -y "$IN_FILE" -o"$TEMP_DIR" > /dev/null ;;
*.rar) unrar x -o+ "$IN_FILE" "$TEMP_DIR" > /dev/null ;;
esac
# Prefer .cue (track layout) over bare .bin
local CUE_FILE
CUE_FILE=$(find "$TEMP_DIR" -type f -iname "*.cue" | head -n 1)
[[ -z "$CUE_FILE" ]] && CUE_FILE=$(find "$TEMP_DIR" -type f -iname "*.bin" | head -n 1)
local RET=0
if [[ -z "$CUE_FILE" ]]; then
echo " [SKIP] no CUE/BIN found in $(basename "$IN_FILE")"
RET=1
elif chdman createcd -i "$CUE_FILE" -o "$OUT_FILE" --force 2>&1 | sed 's/^/ /'; then
echo " [OK] $(basename "$IN_FILE") → $(basename "$OUT_FILE")"
else
echo " [FAIL] $(basename "$IN_FILE")"
RET=1
fi
rm -rf "$TEMP_DIR"
return $RET
}
echo ""
for FILE in "${FILES[@]}"; do
BASENAME=$(basename "$FILE")
BASENAME="${BASENAME%.*}"
OUT_FILE="$OUTPUT_DIR/$BASENAME.$OUTPUT_FORMAT"
if in_array "$INPUT_FORMAT" zip 7z rar; then
if convert_archive "$FILE" "$OUT_FILE"; then
(( SUCCESS++ )) || true
if $CLEAN; then
rm -f -- "$FILE" && echo " [DEL] $FILE"
fi
else
(( FAIL++ )) || true
fi
else
if chdman "$CHDMAN_CMD" -i "$FILE" -o "$OUT_FILE" --force 2>&1 | sed 's/^/ /'; then
echo " [OK] $(basename "$FILE") → $(basename "$OUT_FILE")"
(( SUCCESS++ )) || true
if $CLEAN; then
rm -f -- "$FILE" && echo " [DEL] $FILE"
fi
else
echo " [FAIL] $(basename "$FILE")"
(( FAIL++ )) || true
fi
fi
done
echo ""
echo "Done — success: $SUCCESS | failed: $FAIL"
[[ $FAIL -gt 0 ]] && exit 1 || exit 0
Copy to /usr/local/bin, make it executable and reload the shell (source ~/.bashrc)
Then run it from any folder. Just include the source format and destination format. For example:
chdsh chd cue
This converts all CHD in current directory to CUE
By default, source file are left in place. Use the -c flag to cleanup after conversion.