1194 lines
37 KiB
Bash
Executable File
1194 lines
37 KiB
Bash
Executable File
#!/bin/bash
|
|
# hascheevos.sh
|
|
###############
|
|
#
|
|
# A tool to check if your ROMs have cheevos (RetroAchievements.org).
|
|
|
|
# globals ####################################################################
|
|
|
|
readonly USAGE="
|
|
USAGE:
|
|
$(basename "$0") [OPTIONS] romfile1 [romfile2 ...]"
|
|
|
|
readonly GIT_REPO="https://github.com/meleu/hascheevos.git"
|
|
readonly SCRIPT_URL="https://raw.githubusercontent.com/meleu/hascheevos/master/bin/hascheevos.sh"
|
|
readonly SCRIPT_DIR="$(cd "$(dirname $0)" && pwd)"
|
|
readonly DATA_DIR="$SCRIPT_DIR/../data"
|
|
readonly GAMEID_REGEX='^[1-9][0-9]{0,9}$'
|
|
readonly HASH_REGEX='[A-Fa-f0-9]{32}'
|
|
readonly URL="https://retroachievements.org"
|
|
#readonly URL='http://localhost'
|
|
|
|
# flags
|
|
CHECK_FALSE_FLAG=0
|
|
COPY_ROMS_FLAG=0
|
|
CHECK_RA_SERVER_FLAG=0
|
|
TAB_FLAG=0
|
|
ARCADE_FLAG=0
|
|
|
|
RA_USER=
|
|
RA_PASSWORD=
|
|
RA_TOKEN=
|
|
FILES_TO_CHECK=()
|
|
COPY_ROMS_DIR=
|
|
TMP_DIR="/tmp/hascheevos-$$"
|
|
mkdir -p "$TMP_DIR"
|
|
GAME_CONSOLE_NAME="$(mktemp -p "$TMP_DIR")"
|
|
|
|
# these will be increased later, based on extensions supported by the systems
|
|
EXTENSIONS='zip|7z'
|
|
SUPPORTED_SYSTEMS=()
|
|
declare -A CONSOLE_IDS
|
|
|
|
# format: [shortname]='consoleId:extension1|extensionN:Long Name:alias'
|
|
declare -A SYSTEMS_INFO=(
|
|
[megadrive]='1:bin|gen|md|sg|smd:Sega Mega Drive:genesis'
|
|
[n64]='2:z64|n64|v64:Nintendo 64'
|
|
[snes]='3:fig|mgd|sfc|smc|swc:Super Nintendo Entertainment System'
|
|
[gb]='4:gb:GameBoy'
|
|
[gba]='5:gba:GameBoy Advance'
|
|
[gbc]='6:gbc:GameBoy Color'
|
|
[nes]='7:nes|fds:fds:Nintendo Entertainment System:fds'
|
|
[pcengine]='8:pce|ccd|chd|cue|:PC Engine:pcenginecd'
|
|
[segacd]='9:bin|chd|cue|iso:Sega CD'
|
|
[sega32x]='10:32x|bin|md|smd:Sega 32X'
|
|
[mastersystem]='11:bin|sms:Sega Master System'
|
|
[psx]='12:cue|ccd|chd|exe|iso|m3u|pbp|toc:PlayStation'
|
|
[atarilynx]='13:lnx:Atari Lynx'
|
|
[ngp]='14:ngp|ngc:NeoGeo Pocket [Color]:ngpc'
|
|
[gamegear]='15:bin|gg|sms:Game Gear'
|
|
[atarijaguar]='17:j64|jag:Atari Jaguar'
|
|
[nds]='18:nds:Nintendo DS'
|
|
[pokemini]='24:min:Pokemon Mini'
|
|
[atari2600]='25:a26|bin|rom:Atari 2600'
|
|
[arcade]='27:fba:Arcade:fbneo|fba'
|
|
[virtualboy]='28:vb:VirtualBoy'
|
|
[sg-1000]='33:bin|sg:SG-1000'
|
|
[coleco]='44:col|rom:ColecoVision'
|
|
[atari7800]='51:a78|bin:Atari 7800'
|
|
[wonderswan]='53:ws|wsc:WonderSwan [Color]:wonderswancolor'
|
|
)
|
|
|
|
# RetroPie specific variables
|
|
readonly RP_ROMS_DIR="$HOME/RetroPie/roms"
|
|
GAMELIST=
|
|
GAMELIST_BAK=
|
|
ROMS_DIR=()
|
|
SCRAPE_FLAG=0
|
|
COLLECTIONS_FLAG=0
|
|
SINGLE_COLLECTION_FLAG=0
|
|
|
|
|
|
# functions ###################################################################
|
|
|
|
function safe_exit() {
|
|
rm -rf "$TMP_DIR"
|
|
if [[ -f "$GAMELIST" && -f "$GAMELIST_BAK" ]]; then
|
|
diff "$GAMELIST" "$GAMELIST_BAK" > /dev/null && rm -f "$GAMELIST_BAK"
|
|
fi
|
|
exit "$1"
|
|
}
|
|
|
|
|
|
|
|
function urlencode() {
|
|
local LC_ALL=C
|
|
local string="$*"
|
|
local length="${#string}"
|
|
local char
|
|
|
|
for (( i = 0; i < length; i++ )); do
|
|
char="${string:i:1}"
|
|
if [[ "$char" == [a-zA-Z0-9.~_-] ]]; then
|
|
printf "$char"
|
|
else
|
|
printf '%%%02X' "'$char"
|
|
fi
|
|
done
|
|
printf '\n' # opcional
|
|
}
|
|
|
|
|
|
|
|
function join_by() {
|
|
local IFS="$1"
|
|
echo "${*:2}"
|
|
}
|
|
|
|
|
|
function fill_data() {
|
|
local shortname
|
|
local entry
|
|
local temp_extensions
|
|
|
|
for shortname in "${!SYSTEMS_INFO[@]}"; do
|
|
entry="${SYSTEMS_INFO[$shortname]}"
|
|
|
|
SUPPORTED_SYSTEMS+=("$shortname")
|
|
CONSOLE_IDS[$shortname]="$(cut -d: -f1 <<< "$entry")"
|
|
temp_extensions="$(
|
|
join_by '|' "$temp_extensions" "$(cut -d: -f2 <<< "$entry" )"
|
|
)"
|
|
done
|
|
EXTENSIONS="$(join_by '|' "$EXTENSIONS" $(tr '|' '\n' <<< "$temp_extensions" | sort -u) )"
|
|
# this subshell must NOT be quoted! ---^
|
|
}
|
|
|
|
|
|
|
|
function get_console_shortname_by_id() {
|
|
local id="$1"
|
|
local shortname
|
|
for shortname in "${!CONSOLE_IDS[@]}"; do
|
|
if [[ "$id" == "${CONSOLE_IDS[$shortname]}" ]]; then
|
|
echo "$shortname"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
|
|
|
|
function help_message() {
|
|
echo "$USAGE"
|
|
echo
|
|
echo "Where [OPTIONS] are:"
|
|
echo
|
|
# getting the help message from the comments in this source code
|
|
sed -n 's/^#H //p' "$0"
|
|
safe_exit 0
|
|
}
|
|
|
|
|
|
function check_dependencies() {
|
|
local cmd
|
|
local answer
|
|
local deps=(jq curl unzip 7z)
|
|
|
|
for cmd in "${deps[@]}"; do
|
|
if ! which "$cmd" >/dev/null 2>&1; then
|
|
if ! which apt-get >/dev/null 2>&1; then
|
|
echo "ERROR: missing dependency: $cmd" >&2
|
|
echo "To use this tool you need to install \"$cmd\" package. Please, install it and try again."
|
|
safe_exit 1
|
|
fi
|
|
echo "To use this tool you need to install \"$cmd\"."
|
|
echo "Do you want to install \"$cmd\" now? (if you're sure, type \"yes\" and press ENTER)"
|
|
read -p 'Answer: ' answer
|
|
|
|
if ! [[ "$answer" =~ ^[Yy][Ee][Ss]$ ]]; then
|
|
echo "Aborting..."
|
|
safe_exit 1
|
|
fi
|
|
|
|
[[ "$cmd" == 7z ]] && cmd=p7zip-full
|
|
sudo apt-get install "$cmd"
|
|
fi
|
|
done
|
|
}
|
|
|
|
|
|
|
|
function is_retropie() {
|
|
if [[ -d "$RP_ROMS_DIR" ]]; then
|
|
mkdir -p "$HOME/.emulationstation/collections"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
|
|
function regex_safe() {
|
|
echo "$@" | sed -e 's/[]\/$*.^|[]/\\&/g'
|
|
}
|
|
|
|
|
|
function get_game_title_hascheevos() {
|
|
echo "$@" | sed 's/^[^:]\+:[^:]\+://'
|
|
}
|
|
|
|
|
|
# XXX: this function needs more intensive tests
|
|
function update() {
|
|
local err_flag=0
|
|
local dir="$SCRIPT_DIR/.."
|
|
|
|
if [[ -d "$dir/.git" ]]; then
|
|
pushd "$dir" > /dev/null
|
|
if ! git pull --rebase 2>/dev/null; then
|
|
git fetch && git reset --hard origin/master || err_flag=1
|
|
fi
|
|
popd > /dev/null
|
|
else
|
|
echo "ERROR: \"$dir/.git\": directory not found!" >&2
|
|
echo "Looks like this tool wasn't installed as instructed in repo's README." >&2
|
|
echo "Aborting..." >&2
|
|
err_flag=1
|
|
fi
|
|
|
|
if [[ "$err_flag" != 0 ]]; then
|
|
echo "UPDATE: Failed to update." >&2
|
|
safe_exit 1
|
|
fi
|
|
|
|
# after updating, silently check hascheevos-local.txt files
|
|
check_hascheevos_file >/dev/null 2>&1
|
|
rm "$DATA_DIR/*.bkp" 2> /dev/null
|
|
|
|
echo
|
|
echo "UPDATE: The files have been successfully updated."
|
|
safe_exit 0
|
|
}
|
|
|
|
|
|
# Getting the RetroAchievements token
|
|
# input: RA_USER, RA_PASSWORD
|
|
# updates: RA_TOKEN
|
|
# exit if fails
|
|
# TODO: cache the token in some file?
|
|
function get_cheevos_token() {
|
|
if [[ -z "$RA_USER" ]]; then
|
|
echo "WARNING: undefined RetroAchievements.org user (see \"--user\" option)." >&2
|
|
return 1
|
|
fi
|
|
|
|
[[ -n "$RA_TOKEN" ]] && return 0
|
|
|
|
if [[ -z "$RA_PASSWORD" ]]; then
|
|
echo "WARNING: undefined RetroAchievements.org password (see \"--password\" option)." >&2
|
|
return 1
|
|
fi
|
|
|
|
RA_TOKEN="$(curl -s "$URL/dorequest.php?r=login&u=${RA_USER}&p=${RA_PASSWORD}" | jq -e -r .Token)"
|
|
if [[ "$?" -ne 0 || "$RA_TOKEN" == null || -z "$RA_TOKEN" ]]; then
|
|
echo "ERROR: cheevos authentication failed. Aborting..."
|
|
safe_exit 1
|
|
fi
|
|
}
|
|
|
|
|
|
function is_supported_system() {
|
|
local sys
|
|
local match="$1"
|
|
for sys in "${SUPPORTED_SYSTEMS[@]}"; do
|
|
[[ "$sys" == "$match" ]] && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
|
|
# download hashlibrary for a specific console
|
|
# $1 is the system shortname
|
|
function download_hashlibrary() {
|
|
local system="$1"
|
|
local json_file="$DATA_DIR/${system}_hashlibrary.json"
|
|
|
|
echo "--- getting the console hash library for \"$system\"..." >&2
|
|
curl -s "$URL/dorequest.php?r=hashlibrary&c=${CONSOLE_IDS[$system]}" \
|
|
| jq '.' > "$json_file" 2> /dev/null \
|
|
|| echo "ERROR: failed to download hash library for \"$system\"!" >&2
|
|
|
|
[[ -s "$json_file" ]] || rm -f "$json_file"
|
|
}
|
|
|
|
|
|
# if a valid system is given in $1, the function tries to update only the
|
|
# hashlib for that system.
|
|
# Otherwise update hashlibraries older than 1 day.
|
|
function update_hashlib() {
|
|
local line
|
|
local given_system="$1"
|
|
local file
|
|
local sys
|
|
|
|
echo "Checking JSON hash libraries..." >&2
|
|
for sys in "${SUPPORTED_SYSTEMS[@]}"; do
|
|
[[ -f "$DATA_DIR/${sys}_hashlibrary.json" ]] || download_hashlibrary "$sys"
|
|
done
|
|
echo "Done!" >&2
|
|
|
|
if [[ -n "$given_system" ]]; then
|
|
file="$DATA_DIR/${given_system}_hashlibrary.json"
|
|
# check if the file exists and is older than 1 minute
|
|
if [[ -n "$(find "$file" -mmin +1 2>/dev/null)" ]]; then
|
|
echo "Updating \"$given_system\" hashlib..." >&2
|
|
download_hashlibrary "$given_system" && echo "Done!" >&2
|
|
return "$?"
|
|
else
|
|
if [[ -f "$file" ]]; then
|
|
echo "The \"$given_system\" hashlib is already up-to-date." >&2
|
|
return 0
|
|
else
|
|
echo "ERROR: invalid system: \"$given_system\""
|
|
return 1
|
|
fi
|
|
fi
|
|
else
|
|
# update hashlibs older than one day
|
|
while read -r line; do
|
|
system="$(basename "${line%_hashlibrary.json*}")"
|
|
download_hashlibrary "$system"
|
|
done < <(find "$DATA_DIR" -type f -name '*_hashlibrary.json' -mtime +1)
|
|
fi
|
|
}
|
|
|
|
|
|
# Print (echo) the game ID of a given rom file
|
|
# This function try to get the game id from local *_hashlibrary.json files, if
|
|
# these files don't exist the script will try to get them from RA server.
|
|
# input:
|
|
# $1 is a rom file (should be previously validated with validate_rom_file())
|
|
# also needs RA_TOKEN
|
|
function get_game_id() {
|
|
local rom="$1"
|
|
local line
|
|
local hash
|
|
local hash_i
|
|
local gameid
|
|
local console_id=0
|
|
local console_shortname
|
|
|
|
echo -n > "$GAME_CONSOLE_NAME"
|
|
|
|
hash="$(get_rom_hash "$rom")" || return 1
|
|
|
|
while read -r line; do
|
|
echo "--- $line" >&2
|
|
hash_i="$(echo "$line" | sed 's/^\(SNES\|NES\|Genesis\|Lynx\|plain MD5\): //')"
|
|
line="$(grep -i "\"$hash_i\"" "$DATA_DIR"/*_hashlibrary.json 2> /dev/null)"
|
|
echo -n "$(basename "${line%_hashlibrary.json*}")" > "$GAME_CONSOLE_NAME"
|
|
gameid="$(echo ${line##*: } | tr -d ' ,')"
|
|
[[ $gameid =~ $GAMEID_REGEX ]] && break
|
|
done <<< "$hash"
|
|
|
|
if [[ "$CHECK_RA_SERVER_FLAG" -eq 1 && ! $gameid =~ $GAMEID_REGEX ]]; then
|
|
echo "--- checking at RetroAchievements.org server..." >&2
|
|
for hash_i in $(echo "$hash" | sed 's/^\(SNES\|NES\|Genesis\|Lynx\|plain MD5\): //'); do
|
|
echo "--- hash: $hash_i" >&2
|
|
gameid="$(curl -s "$URL/dorequest.php?r=gameid&m=$hash_i" | jq .GameID)"
|
|
if [[ $gameid =~ $GAMEID_REGEX ]]; then
|
|
# if the logic reaches this point, mark this game's console to download the hashlibrary
|
|
console_id="$(
|
|
curl -s "$URL/dorequest.php?r=patch&u=${RA_USER}&g=${gameid}&f=3&l=1&t=${RA_TOKEN}" \
|
|
| jq '.PatchData.ConsoleID'
|
|
)"
|
|
console_shortname="$(get_console_shortname_by_id "$console_id")"
|
|
echo "$console_shortname" > "$GAME_CONSOLE_NAME"
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
|
|
if [[ "$gameid" == 0 ]]; then
|
|
echo "--- WARNING: this ROM file doesn't feature achievements." >&2
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! $gameid =~ $GAMEID_REGEX ]]; then
|
|
echo "--- unable to get game ID." >&2
|
|
return 1
|
|
fi
|
|
|
|
# if the logic reaches this point, we have a valid game ID
|
|
|
|
[[ -n "$console_shortname" ]] && download_hashlibrary "$console_id"
|
|
|
|
echo "$gameid"
|
|
}
|
|
|
|
|
|
# Check if a game has cheevos.
|
|
# returns 0 if yes; 1 if not; 2 if an error occurred
|
|
function game_has_cheevos() {
|
|
local gameid="$1"
|
|
local hascheevos_file
|
|
local boolean
|
|
local game_title
|
|
local patch_json
|
|
local console_id
|
|
local console_shortname
|
|
local number_of_cheevos
|
|
|
|
if [[ ! $gameid =~ $GAMEID_REGEX ]]; then
|
|
echo "ERROR: \"$gameid\" invalid game ID." >&2
|
|
return 1
|
|
fi
|
|
|
|
echo "--- game ID: $gameid" >&2
|
|
|
|
# check if $DATA_DIR exist.
|
|
if [[ ! -d "$DATA_DIR" ]]; then
|
|
echo "ERROR: \"$DATA_DIR\": directory not found!" >&2
|
|
echo "Looks like this tool wasn't installed as instructed in repo's README." >&2
|
|
echo "Aborting..." >&2
|
|
safe_exit 1
|
|
fi
|
|
|
|
if [[ "$CHECK_RA_SERVER_FLAG" -ne 1 ]]; then
|
|
hascheevos_file="$(grep -l "^$gameid:" "$DATA_DIR"/*_hascheevos-local.txt 2> /dev/null)"
|
|
[[ -f "$hascheevos_file" ]] || hascheevos_file="$(grep -l "^$gameid:" "$DATA_DIR"/*_hascheevos.txt 2> /dev/null)"
|
|
|
|
if [[ -f "$hascheevos_file" ]]; then
|
|
boolean="$( grep "^$gameid:" "$hascheevos_file" | cut -d: -f2)"
|
|
game_title="$(get_game_title_hascheevos "$(grep "^$gameid:" "$hascheevos_file")" )"
|
|
[[ -n "$game_title" ]] && echo "--- Game Title: $game_title" >&2
|
|
[[ "$boolean" == true ]] && return 0
|
|
[[ "$boolean" == false && "$CHECK_FALSE_FLAG" -eq 0 ]] && return 1
|
|
fi
|
|
fi
|
|
|
|
if [[ -z "$RA_TOKEN" ]]; then
|
|
get_cheevos_token || return $?
|
|
fi
|
|
|
|
echo "--- checking at RetroAchievements.org server..." >&2
|
|
patch_json="$(curl -s "$URL/dorequest.php?r=patch&u=${RA_USER}&g=${gameid}&f=3&l=1&t=${RA_TOKEN}")"
|
|
|
|
console_id="$(echo "$patch_json" | jq -e '.PatchData.ConsoleID')"
|
|
if [[ "$?" -ne 0 || "$console_id" -lt 1 || -z "$console_id" ]]; then
|
|
echo "--- WARNING: unable to find the Console ID for Game #$gameid!" >&2
|
|
return 1
|
|
fi
|
|
|
|
console_shortname="$(get_console_shortname_by_id "$console_id")"
|
|
|
|
hascheevos_file="$DATA_DIR/${console_shortname}_hascheevos-local.txt"
|
|
|
|
game_title="$(echo "$patch_json" | jq -e '.PatchData.Title')" || game_title=
|
|
[[ -n "$game_title" ]] && echo "--- Game Title: $game_title" >&2
|
|
|
|
number_of_cheevos="$(echo "$patch_json" | jq '.PatchData.Achievements | length')"
|
|
|
|
# if the game has no cheevos...
|
|
if [[ -z "$number_of_cheevos" || "$number_of_cheevos" -lt 1 ]]; then
|
|
sed -i "s/^${gameid}:true/${gameid}:false/" "$hascheevos_file" 2> /dev/null
|
|
if ! grep -q "^${gameid}:false" "$hascheevos_file" 2> /dev/null; then
|
|
echo "${gameid}:false:${game_title}" >> "$hascheevos_file"
|
|
sort -un "$hascheevos_file" -o "$hascheevos_file"
|
|
fi
|
|
return 1
|
|
fi
|
|
|
|
# if the logic reaches this point, the game has cheevos.
|
|
sed -i "s/^${gameid}:false/${gameid}:true/" "$hascheevos_file" 2> /dev/null
|
|
if ! grep -q "^${gameid}:true" "$hascheevos_file" 2> /dev/null; then
|
|
echo "${gameid}:true:${game_title}" >> "$hascheevos_file"
|
|
sort -un "$hascheevos_file" -o "$hascheevos_file"
|
|
fi
|
|
|
|
sleep 1 # XXX: a small delay to not stress the server
|
|
return 0
|
|
}
|
|
|
|
|
|
# print the hash of a given rom file
|
|
function get_rom_hash() {
|
|
local rom="$1"
|
|
local hash
|
|
local uncompressed_rom
|
|
local ret=0
|
|
|
|
if [[ "$ARCADE_FLAG" == 1 ]]; then
|
|
rom="$(basename "$rom")"
|
|
hash="$(echo -n "${rom%.*}" | md5sum | grep -Eo "^$HASH_REGEX")"
|
|
[[ "$hash" =~ ^$HASH_REGEX$ ]] || return 1
|
|
echo "$hash"
|
|
return 0
|
|
fi
|
|
|
|
case "$rom" in
|
|
*.zip|*.ZIP)
|
|
uncompressed_rom="$TMP_DIR/$(unzip -Z1 "$rom" | head -1)"
|
|
unzip -o -d "$TMP_DIR" "$rom" >/dev/null
|
|
validate_rom_file "$uncompressed_rom" || ret=1
|
|
;;
|
|
*.7z|*.7Z)
|
|
uncompressed_rom="$TMP_DIR/$(7z l -slt "$rom" | sed -n 's/^Path = //p' | sed '2q;d')"
|
|
7z e -y -bd -o"$TMP_DIR" "$rom" >/dev/null
|
|
validate_rom_file "$uncompressed_rom" || ret=1
|
|
;;
|
|
esac
|
|
if [[ $ret -ne 0 ]]; then
|
|
rm -f "$uncompressed_rom"
|
|
return $ret
|
|
fi
|
|
|
|
if [[ -n "$uncompressed_rom" ]]; then
|
|
hash="$($SCRIPT_DIR/cheevoshash "$uncompressed_rom")"
|
|
rm -f "$uncompressed_rom"
|
|
else
|
|
hash="$($SCRIPT_DIR/cheevoshash "$rom")"
|
|
fi
|
|
|
|
[[ "$hash" =~ :\ [^\ ]{32} ]] || return 1
|
|
echo "$hash"
|
|
}
|
|
|
|
|
|
# check if the file exists and has a valid extension
|
|
function validate_rom_file() {
|
|
local rom="$1"
|
|
|
|
if [[ -z "$rom" ]]; then
|
|
echo "ERROR: missing ROM file name." >&2
|
|
echo "$USAGE" >&2
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! -f "$rom" ]]; then
|
|
echo "ERROR: \"$rom\": file not found!" >&2
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! "${rom##*.}" =~ ^($EXTENSIONS)$ ]]; then
|
|
echo "ERROR: \"$rom\": invalid file extension." >&2
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
|
|
# Check if a game has cheevos.
|
|
# returns 0 if yes; 1 if not; 2 if an error occurred
|
|
function rom_has_cheevos() {
|
|
local rom="$1"
|
|
validate_rom_file "$rom" || return 1
|
|
|
|
echo "Checking \"$rom\"..." >&2
|
|
[[ "$TAB_FLAG" == 1 ]] && echo -en "${rom/ (*/}"
|
|
|
|
local gameid
|
|
gameid="$(get_game_id "$rom")"
|
|
if [[ -z "$gameid" ]]; then
|
|
echo -e "\t"
|
|
return 1
|
|
fi
|
|
|
|
[[ "$TAB_FLAG" == 1 ]] && echo -en "\t$gameid"
|
|
|
|
game_has_cheevos "$gameid"
|
|
}
|
|
|
|
|
|
# check if the local hascheevos repository is synchronized with the remote one.
|
|
# returns
|
|
# 0 if yes
|
|
# 1 if no
|
|
# 2 if unable to check
|
|
function is_updated() {
|
|
local version_local
|
|
local version_remote
|
|
|
|
version_local=$(git -C "$SCRIPT_DIR" log -1 --format="%H") || return 2
|
|
version_remote=$(git ls-remote "$GIT_REPO" | head -1 | cut -f1) || return 2
|
|
|
|
[[ "$version_local" == "$version_remote" ]]
|
|
}
|
|
|
|
|
|
|
|
function check_hascheevos_files() {
|
|
local file_local
|
|
local file_orig
|
|
local file_pr # file for Pull Request
|
|
local line_local
|
|
local line_orig
|
|
local gameid
|
|
local bool_local
|
|
local bool_orig
|
|
local title_local
|
|
local title_orig
|
|
local ret
|
|
local updated
|
|
local pr_files
|
|
local ret=0
|
|
local tmp_ret
|
|
|
|
is_updated
|
|
ret="$?"
|
|
case "$ret" in
|
|
0) updated=true
|
|
;;
|
|
1) update=false
|
|
echo "ERROR: your hascheevos files are outdated. Perform an '--update' and try again." >&2
|
|
return "$ret"
|
|
;;
|
|
2) updated=false
|
|
echo "WARNING: unable to compare your local files with remote ones from hascheevos repository." >&2
|
|
return "$ret"
|
|
;;
|
|
esac
|
|
|
|
while read -r file_local; do
|
|
tmp_ret=0
|
|
file_orig="${file_local/-local/}"
|
|
file_pr="${file_orig/.txt/-PR.txt}"
|
|
[[ "$updated" == true ]] && cat "$file_orig" > "$file_pr"
|
|
|
|
echo
|
|
echo "Checking \"$(basename "$file_orig")\"..."
|
|
|
|
while read -r line_local; do
|
|
gameid=$(echo "$line_local" | cut -d: -f1)
|
|
line_orig=$(grep "^$gameid:" "$file_orig")
|
|
bool_local=$(echo "$line_local" | cut -d: -f2)
|
|
bool_orig=$( echo "$line_orig" | cut -d: -f2)
|
|
title_local="$(get_game_title_hascheevos "$line_local")"
|
|
title_orig="$( get_game_title_hascheevos "$line_orig")"
|
|
|
|
if [[ -z "$line_orig" ]]; then
|
|
echo "* there's no Game ID #$gameid ($title_local) on your \"$(basename "$file_orig")\"."
|
|
ret=3
|
|
tmp_ret=1
|
|
elif [[ "$bool_local" == "$bool_orig" ]]; then
|
|
if [[ "$title_local" == "$title_orig" ]]; then
|
|
sed -i "/$(regex_safe "$line_local")/d" "$file_local"
|
|
[[ -s "$file_local" ]] || rm "$file_local"
|
|
else
|
|
echo "* Game ID #$gameid is named $title_local locally but it's $title_orig in the original file."
|
|
ret=3
|
|
tmp_ret=1
|
|
fi
|
|
else
|
|
echo "* Game ID #$gameid ($title_local) is marked as \"$bool_local\" locally but it's \"$bool_orig\" in the original file."
|
|
ret=3
|
|
tmp_ret=1
|
|
fi
|
|
|
|
if [[ "$updated" == true && "$ret" != 0 ]]; then
|
|
sed -i "/^$gameid/d" "$file_pr"
|
|
echo "$line_local" >> "$file_pr"
|
|
sort -o "$file_pr" -un "$file_pr"
|
|
fi
|
|
|
|
done < "$file_local"
|
|
if [[ "$updated" == true ]]; then
|
|
diff -q "$file_pr" "$file_orig" >/dev/null && rm "$file_pr"
|
|
fi
|
|
done < <(find "$DATA_DIR" -type f -name '*_hascheevos-local.txt')
|
|
|
|
while read -r file_pr; do
|
|
file_orig="${file_pr/-PR.txt/.txt}"
|
|
if diff -q "$file_pr" "$file_orig" >/dev/null; then
|
|
rm "$file_pr"
|
|
else
|
|
pr_files+=("$file_pr")
|
|
fi
|
|
done < <(find "$DATA_DIR" -maxdepth 1 -name '*-PR.txt')
|
|
|
|
if [[ -n "$pr_files" ]]; then
|
|
# XXX: yeah! I shouldn't hardcode this thing, but it helps to keep the repo updated! :)
|
|
if [[ "$updated" == true && "$RA_USER" == meleu ]]; then
|
|
update_repository || echo "WARNING: hascheevos repository was NOT updated!" >&2
|
|
else
|
|
echo -e "\n-----"
|
|
echo "Consider helping to keep the hascheevos files synchronized with RetroAchievements.org data."
|
|
echo "Please, copy the output's content above and paste it in a new issue at https://github.com/meleu/hascheevos/issues"
|
|
echo "Attaching the file(s) below to your issue would be really useful:"
|
|
echo "${pr_files[@]}"
|
|
fi
|
|
fi
|
|
|
|
return "$ret"
|
|
}
|
|
|
|
|
|
# this function exists only for me, sorry :)
|
|
# needs to be called from check_hascheevos_files() to access its variables
|
|
function update_repository() {
|
|
[[ "$RA_USER" != meleu ]] && return 1
|
|
|
|
local file_bkp
|
|
local commit_msg=()
|
|
|
|
commit_msg=(-m "updated *_hascheevos.txt files ($(date +'%d-%b-%Y %H:%M'))")
|
|
|
|
pushd "$dir" > /dev/null
|
|
for file_pr in "${pr_files[@]}"; do
|
|
file_orig="${file_pr/-PR.txt/.txt}"
|
|
file_bkp="${file_orig/.txt/.bkp}"
|
|
|
|
cat "$file_orig" > "$file_bkp"
|
|
cat "$file_pr" > "$file_orig"
|
|
|
|
git add "$file_orig"
|
|
commit_msg+=(-m "$(basename "$file_orig")" )
|
|
done
|
|
echo
|
|
git commit "${commit_msg[@]}"
|
|
git push origin master
|
|
|
|
# revert things if failed to push
|
|
if [[ "$?" != 0 ]]; then
|
|
for file_pr in "${pr_files[@]}"; do
|
|
file_orig="${file_pr/-PR.txt/.txt}"
|
|
file_bkp="${file_orig/.txt/.bkp}"
|
|
cat "$file_bkp" > "$file_orig"
|
|
done
|
|
git reset --soft HEAD^
|
|
fi
|
|
|
|
popd > /dev/null
|
|
}
|
|
|
|
|
|
|
|
# a trick for getting the system based on the folder where the rom is stored
|
|
function get_rom_system() {
|
|
echo "$1" | sed 's|\(.*/RetroPie/roms/[^/]*\).*|\1|' | xargs basename
|
|
}
|
|
|
|
|
|
# add the game to the system specific custom collection
|
|
# XXX: RetroPie specific
|
|
function set_cheevos_custom_collection() {
|
|
[[ -f "$1" ]] || return 1
|
|
|
|
local set="$2"
|
|
local system
|
|
local rom_full_path="$1"
|
|
local collection_cfg
|
|
|
|
if [[ "$SINGLE_COLLECTION_FLAG" == 1 ]]; then
|
|
collection_cfg="$HOME/.emulationstation/collections/custom-achievements.cfg"
|
|
else
|
|
system=$(get_rom_system "$rom_full_path")
|
|
collection_cfg="$HOME/.emulationstation/collections/custom-achievements ${system}.cfg"
|
|
fi
|
|
|
|
if [[ -z "$set" || "$set" == true ]]; then
|
|
echo "$rom_full_path" >> "$collection_cfg"
|
|
elif [[ "$set" == false ]]; then
|
|
sed -i "/$(regex_safe "$rom_full_path")/d" "$collection_cfg"
|
|
else
|
|
return 1
|
|
fi
|
|
|
|
sort -o "$collection_cfg" -u "$collection_cfg" \
|
|
&& echo "--- This game has been added to \"$collection_cfg\"." >&2
|
|
}
|
|
|
|
|
|
# update gamelist.xml info
|
|
# TODO: will it be useful? this feature will be useful only if the related PR gets merged on ES.
|
|
# XXX: RetroPie specific
|
|
function set_cheevos_gamelist_xml() {
|
|
local set="$2"
|
|
local system
|
|
local rom_full_path="$1"
|
|
local rom
|
|
local game_name
|
|
local new_entry_flag
|
|
local has_cheevos_xml_element
|
|
|
|
system=$(get_rom_system "$rom_full_path")
|
|
|
|
[[ -f "$rom_full_path" ]] || return 1
|
|
rom="$(basename "$rom_full_path")"
|
|
|
|
# From https://github.com/RetroPie/EmulationStation/blob/master/GAMELISTS.md
|
|
# ES will check three places for a gamelist.xml in the following order, using
|
|
# the first one it finds:
|
|
# - [SYSTEM_PATH]/gamelist.xml
|
|
# - ~/.emulationstation/gamelists/[SYSTEM_NAME]/gamelist.xml
|
|
# - /etc/emulationstation/gamelists/[SYSTEM_NAME]/gamelist.xml
|
|
for GAMELIST in \
|
|
"$RP_ROMS_DIR/$system/gamelist.xml" \
|
|
"$HOME/.emulationstation/gamelists/$system/gamelist.xml" \
|
|
"/etc/emulationstation/gamelists/$system/gamelist.xml"
|
|
do
|
|
[[ -f "$GAMELIST" ]] && break
|
|
GAMELIST=
|
|
done
|
|
[[ -f "$GAMELIST" ]] || return 1
|
|
|
|
GAMELIST_BAK="${GAMELIST}-$(date +'%Y%m%d').bak"
|
|
[[ -f "$GAMELIST_BAK" ]] || cp "$GAMELIST" "$GAMELIST_BAK"
|
|
|
|
# if set != true, just delete <achievements> element (it's considered false).
|
|
if [[ "$set" != true ]]; then
|
|
xmlstarlet ed -L -d "/gameList/game[contains(path,\"$rom\")]/achievements" "$GAMELIST"
|
|
return "$?"
|
|
fi
|
|
|
|
# 0 means new entry
|
|
new_entry_flag="$(xmlstarlet sel -t -v "count(/gameList/game[contains(path,\"$rom\")])" "$GAMELIST")"
|
|
|
|
# 0 means no <achievements> xml element
|
|
has_cheevos_xml_element="$(xmlstarlet sel -t -v "count(/gameList/game[contains(path,\"$rom\")]/achievements)" "$GAMELIST")"
|
|
|
|
# if it's a new entry in gamelist.xml...
|
|
if [[ "$new_entry_flag" -eq 0 ]]; then
|
|
game_name="${rom%.*}"
|
|
xmlstarlet ed -L -s "/gameList" -t elem -n "game" -v "" \
|
|
-s "/gameList/game[last()]" -t elem -n "name" -v "$game_name" \
|
|
-s "/gameList/game[last()]" -t elem -n "path" -v "$rom_full_path" \
|
|
-s "/gameList/game[last()]" -t elem -n "achievements" -v "true" \
|
|
"$GAMELIST" || return 1
|
|
elif [[ "$has_cheevos_xml_element" -gt 0 ]]; then
|
|
xmlstarlet ed -L \
|
|
-u "/gameList/game[contains(path,\"$rom\")]/achievements" -v "true" \
|
|
"$GAMELIST" || return 1
|
|
else
|
|
xmlstarlet ed -L \
|
|
-s "/gameList/game[contains(path,\"$rom\")]" -t elem -n achievements -v "true" \
|
|
"$GAMELIST" || return 1
|
|
fi
|
|
echo "--- This game has been defined as having cheevos in \"$GAMELIST\"." >&2
|
|
}
|
|
|
|
|
|
function process_files() {
|
|
local f
|
|
readonly local max=10
|
|
|
|
# avoiding to stress the server
|
|
if [[ "$CHECK_RA_SERVER_FLAG" == 1 && "$#" -gt "$max" ]]; then
|
|
echo >&2
|
|
echo "ABORTING!" >&2
|
|
echo "Using the --check-ra-server option to check more than $max files isn't allowed!" >&2
|
|
return 1
|
|
fi
|
|
|
|
for f in "$@"; do
|
|
if rom_has_cheevos "$f"; then
|
|
if [[ "$TAB_FLAG" == 1 ]]; then
|
|
echo -e "\tx"
|
|
else
|
|
[[ "$COLLECTIONS_FLAG" -eq 1 ]] && set_cheevos_custom_collection "$f" true
|
|
[[ "$SCRAPE_FLAG" -eq 1 ]] && set_cheevos_gamelist_xml "$f" true
|
|
echo -n "--- \"" >&2
|
|
echo -n "$f"
|
|
echo "\" HAS CHEEVOS!" >&2
|
|
echo
|
|
fi
|
|
|
|
if [[ "$COPY_ROMS_FLAG" -eq 1 ]]; then
|
|
console_name="$(cat "$GAME_CONSOLE_NAME")"
|
|
mkdir -p "$COPY_ROMS_DIR/$console_name"
|
|
cp -v "$f" "$COPY_ROMS_DIR/$console_name"
|
|
fi
|
|
else
|
|
if [[ "$TAB_FLAG" == 1 ]]; then
|
|
# echo -e "\tno"
|
|
echo
|
|
else
|
|
echo -e "\"$f\" has no cheevos. :(\n" >&2
|
|
fi
|
|
fi
|
|
done
|
|
}
|
|
|
|
|
|
# helping to deal with command line arguments
|
|
function check_argument() {
|
|
# limitation: the argument 2 can NOT start with '-'
|
|
if [[ -z "$2" || "$2" =~ ^- ]]; then
|
|
echo "$1: missing argument" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
|
|
function parse_args() {
|
|
local i
|
|
local ret
|
|
local oldIFS
|
|
|
|
while [[ -n "$1" ]]; do
|
|
case "$1" in
|
|
|
|
#H -h|--help Print the help message and exit.
|
|
#H
|
|
-h|--help)
|
|
help_message
|
|
;;
|
|
|
|
#H --update Update hascheevos files and exit.
|
|
#H
|
|
--update)
|
|
update
|
|
;;
|
|
|
|
#H -u|--user USER USER is your RetroAchievements.org username.
|
|
#H
|
|
-u|--user)
|
|
check_argument "$1" "$2" || safe_exit 1
|
|
shift
|
|
RA_USER="$1"
|
|
;;
|
|
|
|
#H -p|--password PASSWORD PASSWORD is your RetroAchievements.org password.
|
|
#H
|
|
-p|--password)
|
|
check_argument "$1" "$2" || safe_exit 1
|
|
shift
|
|
RA_PASSWORD="$(urlencode "$1")"
|
|
;;
|
|
|
|
# TODO: is it really necessary?
|
|
##H --token TOKEN TOKEN is your RetroAchievements.org token.
|
|
##H
|
|
--token)
|
|
check_argument "$1" "$2" || safe_exit 1
|
|
shift
|
|
RA_TOKEN="$1"
|
|
get_cheevos_token
|
|
;;
|
|
|
|
#H -g|--game-id GAME_ID Check if there are cheevos for a given GAME_ID and
|
|
#H exit. Accept game IDs separated by commas, ex: 1,2,3
|
|
#H Note: this option should be the last argument.
|
|
#H
|
|
-g|--game-id)
|
|
check_argument "$1" "$2" || safe_exit 1
|
|
ret=0
|
|
IFS=, # XXX: not sure if it will impact other parts
|
|
for i in $2; do
|
|
if game_has_cheevos "$i"; then
|
|
echo "--- Game ID $i HAS CHEEVOS!" >&2
|
|
else
|
|
echo "--- Game ID $i has no cheevos. :(" >&2
|
|
ret=1
|
|
fi
|
|
done
|
|
safe_exit "$ret"
|
|
;;
|
|
|
|
#H --hash CHECKSUM Check if there are cheevos for a given CHECKSUM and exit.
|
|
#H Note: this option should be the last argument.
|
|
#H
|
|
--hash)
|
|
local line
|
|
local gameid
|
|
|
|
check_argument "$1" "$2" || safe_exit 1
|
|
ret=0
|
|
|
|
if [[ ! $2 =~ ^$HASH_REGEX$ ]]; then
|
|
echo "--- invalid checksum: $2" >&2
|
|
safe_exit 1
|
|
fi
|
|
|
|
line="$(grep -i "\"$2\"" "$DATA_DIR"/*_hashlibrary.json 2> /dev/null)"
|
|
echo -n "$(basename "${line%_hashlibrary.json*}")" > "$GAME_CONSOLE_NAME"
|
|
gameid="$(echo ${line##*: } | tr -d ' ,')"
|
|
if [[ ! $gameid =~ $GAMEID_REGEX ]]; then
|
|
echo "--- unable to get game ID." >&2
|
|
safe_exit 1
|
|
fi
|
|
|
|
if game_has_cheevos "$gameid"; then
|
|
echo "--- Game ID $gameid HAS CHEEVOS!" >&2
|
|
else
|
|
echo "--- Game ID $gameid has no cheevos. :(" >&2
|
|
ret=1
|
|
fi
|
|
safe_exit "$ret"
|
|
;;
|
|
|
|
#H --get-hashlib SYSTEM Download JSON hash library for a given SYSTEM (console)
|
|
#H and exit.
|
|
#H
|
|
--get-hashlib)
|
|
check_argument "$1" "$2" || safe_exit 1
|
|
shift
|
|
update_hashlib "$1"
|
|
safe_exit "$?"
|
|
;;
|
|
|
|
#H -f|--check-false Check at RetroAchievements.org server even if the
|
|
#H game ID is marked as "has no cheevos" (false) in
|
|
#H the local *_hascheevos.txt files.
|
|
#H
|
|
-f|--check-false)
|
|
CHECK_FALSE_FLAG=1
|
|
;;
|
|
|
|
#H -a|--arcade Arcade hashes are calculated agains the ROM filename
|
|
#H and then needs a different treatment. Use -a to check
|
|
#H arcade ROM files.
|
|
#H
|
|
-a|--arcade)
|
|
ARCADE_FLAG=1
|
|
;;
|
|
|
|
#H -t|--tab-output Instead of the normal output, the -t option makes
|
|
#H it be as in this example:
|
|
#H Game With Cheevos yes
|
|
#H Game With No Cheevos no
|
|
#H
|
|
-t|--tab-output)
|
|
TAB_FLAG=1
|
|
;;
|
|
|
|
# XXX: is it a good idea to let users use the script this way? can stress the server
|
|
# answer: it's useful to check if a game doesn't have cheevos anymore.
|
|
#H -r|--check-ra-server Force checking info at RetroAchievements.org server
|
|
#H ignoring some info you may have locally.
|
|
#H Note: do NOT use this option to check many files at once.
|
|
#H
|
|
-r|--check-ra-server)
|
|
CHECK_RA_SERVER_FLAG=1
|
|
;;
|
|
|
|
#H -d|--copy-roms-to DIR Create a copy of the ROMs that has cheevos and put
|
|
#H them at "DIR/CONSOLE_NAME/". There's no need to
|
|
#H specify the console name, the script detects it.
|
|
#H
|
|
-d|--copy-roms-to)
|
|
check_argument "$1" "$2" || safe_exit 1
|
|
shift
|
|
COPY_ROMS_FLAG=1
|
|
COPY_ROMS_DIR="$1"
|
|
;;
|
|
|
|
#H -c|--check-hascheevos Check if your local data is synchronized with the
|
|
#H repository, print a report and exit.
|
|
#H
|
|
-c|--check-hascheevos)
|
|
if check_hascheevos_files; then
|
|
echo "Your hascheevos files are up-to-date."
|
|
safe_exit "0"
|
|
fi
|
|
safe_exit "1"
|
|
;;
|
|
|
|
# TODO: is it really necessary?
|
|
##H --print-token Print the user's RetroAchievements.org token and exit.
|
|
##H
|
|
--print-token)
|
|
get_cheevos_token
|
|
echo "$RA_TOKEN"
|
|
safe_exit 0
|
|
;;
|
|
|
|
# TODO: will it be useful? this feature will be useful only if the related PR will be merged on ES.
|
|
##H --scrape [RETROPIE ONLY] Updates the gamelist.xml file with
|
|
##H <achievements>true</achievements> if the ROM has
|
|
##H cheevos.
|
|
##H
|
|
--scrape)
|
|
if ! is_retropie; then
|
|
echo "ERROR: not a RetroPie system." >&2
|
|
echo "The \"$1\" option is available only for RetroPie systems." >&2
|
|
safe_exit 1
|
|
fi
|
|
SCRAPE_FLAG=1
|
|
;;
|
|
|
|
#H --collection [RETROPIE ONLY] Creates a custom collection file
|
|
#H to use on RetroPie's EmulationStation. The resulting
|
|
#H files will be named as
|
|
#H "~/.emuationstation/collections/custom-SYSTEM achievements.cfg"
|
|
#H and filled with full paths for ROMs that have cheevos.
|
|
#H
|
|
--collection)
|
|
if ! is_retropie; then
|
|
echo "ERROR: not a RetroPie system." >&2
|
|
echo "The \"$1\" option is available only for RetroPie systems." >&2
|
|
safe_exit 1
|
|
fi
|
|
COLLECTIONS_FLAG=1
|
|
;;
|
|
|
|
#H --single-collection [RETROPIE ONLY] Creates one big custom collection file
|
|
#H to use on RetroPie's EmulationStation. The resulting
|
|
#H file will be named
|
|
#H "~/.emuationstation/collections/custom-achievements.cfg"
|
|
#H and filled with full paths to ALL ROMs that have cheevos.
|
|
#H
|
|
--single-collection)
|
|
if ! is_retropie; then
|
|
echo "ERROR: not a RetroPie system." >&2
|
|
echo "The \"$1\" option is available only for RetroPie systems." >&2
|
|
safe_exit 1
|
|
fi
|
|
COLLECTIONS_FLAG=1
|
|
SINGLE_COLLECTION_FLAG=1
|
|
;;
|
|
|
|
#H -s|--system SYSTEM [RETROPIE ONLY] Check if each ROM in the respective
|
|
#H "~/RetroPie/roms/SYSTEM" directory has cheevos. You
|
|
#H can specifie multiple systems separeted by commas or
|
|
#H use "all" to check all supported systems' directory.
|
|
#H
|
|
-s|--system)
|
|
local directories=()
|
|
|
|
if ! is_retropie; then
|
|
echo "ERROR: not a RetroPie system." >&2
|
|
echo "The \"$1\" option is available only for RetroPie systems." >&2
|
|
safe_exit 1
|
|
fi
|
|
|
|
check_argument "$1" "$2" || safe_exit 1
|
|
shift
|
|
|
|
if [[ "$1" == all ]]; then
|
|
directories=("${SUPPORTED_SYSTEMS[@]}")
|
|
else
|
|
oldIFS="$IFS"
|
|
IFS=, # XXX: not sure if it will impact other parts
|
|
for i in $1; do
|
|
directories+=("$i")
|
|
done
|
|
IFS="$oldIFS"
|
|
fi
|
|
|
|
for i in "${directories[@]}"; do
|
|
if [[ -d "$RP_ROMS_DIR/$i" ]]; then
|
|
ROMS_DIR+=("$RP_ROMS_DIR/$i")
|
|
continue
|
|
fi
|
|
echo "WARNING: ignoring \"$(basename "$i")\": not found." >&2
|
|
done
|
|
;;
|
|
|
|
*) break
|
|
;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
FILES_TO_CHECK=("$@")
|
|
}
|
|
|
|
|
|
|
|
# START HERE ##################################################################
|
|
|
|
function main() {
|
|
trap safe_exit SIGHUP SIGINT SIGQUIT SIGKILL SIGTERM
|
|
|
|
if [[ "$(id -u)" == 0 ]]; then
|
|
echo "ERROR: You can't use this script as super user." >&2
|
|
echo " Please, try again as a regular user." >&2
|
|
safe_exit 1
|
|
fi
|
|
|
|
check_dependencies
|
|
|
|
[[ -z "$1" ]] && help_message
|
|
|
|
fill_data
|
|
|
|
update_hashlib
|
|
|
|
parse_args "$@"
|
|
|
|
if is_retropie && [[ -n "$ROMS_DIR" ]]; then
|
|
local line
|
|
while read -r line; do
|
|
FILES_TO_CHECK+=("$line")
|
|
done < <(find "${ROMS_DIR[@]}" -type f -regextype egrep -iregex ".*\.($EXTENSIONS)$")
|
|
fi
|
|
|
|
process_files "${FILES_TO_CHECK[@]}"
|
|
safe_exit "$?"
|
|
}
|
|
|
|
main "$@"
|