#!/usr/bin/env bash VERSION="0.3.8" # --- Globals --- RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' NC='\033[0m' MODS_DIR="" DB_FILE="" MODPACKS_FOLDER="" MODPACKS_DB_FILE="" H2PATH="${HOME}/.config/h2mm/h2path" LAST_CHECKED_UPDATE_FILE="${HOME}/.config/h2mm/last_update" VERSION_URL="https://raw.githubusercontent.com/v4n00/h2mm-cli/refs/heads/master/version" # --- Utility Functions --- function get_version_major() { echo "$1" | awk -F. '{print $2}' } function get_filename_without_path() { echo "$1" | awk -F/ '{print $NF}' } function get_basename() { get_filename_without_path "$1" | sed -E 's/\.+.*//' } function get_extension() { get_filename_without_path "$1" | sed -E 's/.*patch_[0-9]+//' } function get_files_by_entry_from_db() { echo "$1" | cut -d',' -f4- | tr ',' ' ' | head -1 } function disable_all_modpacks() { sed -i 's/ENABLED/DISABLED/' "$MODPACKS_DB_FILE" } function remove_disabled_prefix() { local _file="$1" while [[ "$_file" == disabled_* ]]; do _file=$(echo "$_file" | sed 's/^disabled_//') done echo "$_file" } function get_mod_name_and_index() { if [[ -n "$mod_index" ]]; then # if mod index exists entry=$(grep "^${mod_index}," "$DB_FILE") mod_name=$(echo "$entry" | awk -F, '{print $3}') elif [[ -n "$mod_name" ]]; then # if mod name exists entry=$(grep ",$mod_name," "$DB_FILE") mod_index=$(echo "$entry" | awk -F, '{print $1}' | head -1) fi if [[ -z "$entry" || -z "$mod_index" || -z "$mod_name" ]]; then if [[ "$1" == "--do-not-exit" ]]; then mod_index=-1 else echo -e "${RED}Error${NC}: Mod not found." >&2 exit 1 fi fi status=$(echo "$entry" | awk -F, '{print $2}') } function get_modpack_name_and_index() { if [[ -n "$modpack_index" ]]; then # if modpack index exists entry=$(grep "^${modpack_index}," "$MODPACKS_DB_FILE") modpack_name=$(echo "$entry" | awk -F, '{print $3}') elif [[ -n "$modpack_name" ]]; then # if modpack name exists entry=$(grep ",$modpack_name$" "$MODPACKS_DB_FILE") modpack_index=$(echo "$entry" | awk -F, '{print $1}' | head -1) fi if [[ -z "$entry" || -z "$modpack_index" || -z "$modpack_name" ]]; then echo -e "${RED}Error${NC}: Modpack not found." >&2 exit 1 fi } function find_game_directory() { local search_dir="${HOME}" local target_dir="Steam/steamapps/common/Helldivers\ 2/data" # check if path is saved if [[ -f "$H2PATH" ]]; then saved_dir=$(cat "$H2PATH") if [[ -d "$saved_dir" ]]; then echo "$saved_dir" return else echo -e "${RED}Error${NC}: Saved game directory is invalid. Proceeding to get a new directory." >&2 fi fi # first time setup, or directory is not valid anymore echo "Searching for the Helldivers 2 data directory... (20 seconds timeout)" >&2 game_dir=$(timeout 20 find "$search_dir" -type d -path "*/$target_dir" 2>/dev/null | head -n 1) if [[ -z "$game_dir" ]]; then echo "Could not find the Helldivers 2 data directory automatically." >&2 echo -ne "Please enter the path to the Helldivers 2 data directory:" >&2 IFS= read -e game_dir game_dir="$(realpath "${game_dir/#\~/$HOME}")" [[ ! -d "$game_dir" ]] && { echo -e "${RED}Error${NC}: Provided path is not a valid directory." >&2; exit 1; } fi # save path mkdir -p "$(dirname "$H2PATH")" echo "$game_dir" > "$H2PATH" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not save game directory." >&2; exit 1; } echo -e "Game directory ${GREEN}saved${NC}: $game_dir" >&2 # return the directory echo "$game_dir" } function initialize_directories() { MODS_DIR=$(find_game_directory) DB_FILE="$MODS_DIR/mods.csv" if [[ ! -f "$DB_FILE" ]]; then touch "$DB_FILE" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not create database file." >&2; exit 1; } echo "$VERSION" | awk -F. '{print $2}' > "$DB_FILE" echo -e "Database file ${GREEN}created${NC}: $DB_FILE" &>2 fi } function initialize_modpack_directories() { MODPACKS_FOLDER="$MODS_DIR/modpacks" MODPACKS_DB_FILE="$MODPACKS_FOLDER/modpacks.csv" if [[ ! -d "$MODPACKS_FOLDER" || ! -f "$MODPACKS_DB_FILE" ]]; then mkdir -p "$MODPACKS_FOLDER" && touch "$MODPACKS_DB_FILE" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not create modpacks folder/file." >&2; exit 1; } echo "$VERSION" | awk -F. '{print $2}' > "$MODPACKS_DB_FILE" echo -e "Modpacks folder and file ${GREEN}created${NC}: $MODPACKS_FOLDER" &>2 fi } # --- Help Functions --- function display_help() { echo "Helldivers 2 Mod Manager v${VERSION}" echo "Usage: h2mm [command] [options]" echo "Commands:" echo " i, install Install a mod by the file provided (directory, zip, patch)." echo " u, uninstall Uninstall a mod by name (or index)." echo " l, list List all installed mods." echo " e, enable Enable a mod by name (or index)." echo " d, disable Disable a mod by name (or index)." echo " ex, export Export installed mods to a zip file." echo " im, import Import mods from a zip file." echo " mc, modpack-create Create a modpack from the currently installed mods." echo " ms, modpack-switch Switch to a modpack by name (or index)." echo " ml, modpack-list List all installed modpacks." echo " mc, modpack-delete Delete a modpack by name (or index)." echo " mo, modpack-overwrite Overwrite a modpack by name (or index)." echo " mr, modpack-reset Reset all installed modpacks." echo " up, update Update h2mm to the latest version." echo " r, reset Reset all installed mods." echo " help Display this help message." echo "For more information on usage, use h2mm [command] --help." echo "Basic Usage:" echo " h2mm install /path/to/mod.zip" echo " h2mm install /path/to/mod/files" echo " h2mm uninstall \"Example mod\"" } function display_install_help() { echo "Usage: h2mm install [options] " echo "Short form: h2mm i" echo "Options:" echo " -n \"\" Name the mod yourself, inside double quotes." echo " Multiple mod files, accepts wildcards." echo " Directory/directories containing mod files." echo " Zip file(s) containing mod files." echo "Usage:" echo " h2mm install /path/to/mod.zip" echo " h2mm install /path/to/mod/files" echo " h2mm install /path/to/mod.zip /path/to/mod2.zip /path/to/mod/files" echo " h2mm install -n \"Example mod\" mod.patch_0 mod.patch_0.stream # -n is mandatory when using files" echo " h2mm install -n \"Example mod\" mod* # using a wildcard to include all files" echo "If the mod has more than 1 variant, you need to install the one you want by unarchiving it separately." } function display_uninstall_help() { echo "Usage: h2mm uninstall [options] \"\"" echo "Short form: h2mm u" echo "Options:" echo " -i Index of the mod to uninstall." echo "Usage:" echo " h2mm uninstall \"Example mod\"" echo " h2mm uninstall -i 1 # uninstall mod with index 1" } function display_enable_help() { echo "Usage: h2mm enable [options] \"\"" echo "Short form: h2mm e" echo "Options:" echo " -i Index of the mod to enable." echo "Usage:" echo " h2mm enable \"Example mod\"" echo " h2mm enable -i 1 # enable mod with index 1" } function display_disable_help() { echo "Usage: h2mm disable [options] \"\"" echo "Short form: h2mm d" echo "Options:" echo " -i Index of the mod to disable." echo "Usage:" echo " h2mm disable \"Example mod\"" echo " h2mm disable -i 1 # disable mod with index 1" } function display_list_help() { echo "Usage: h2mm list" echo "Short form: h2mm l" echo "List all installed mods." echo "Database of mods is stored in Steam/steamapps/common/Helldivers\ 2/data/mods.csv" echo "You can rename, delete, or edit this file to manage mods manually." } function display_reset_help() { echo "Usage: h2mm reset" echo "Short form: h2mm r" echo "Reset all installed mods." echo "Deletes all installed mods and the database file." echo "Database of mods is stored in Steam/steamapps/common/Helldivers\ 2/data/mods.csv, along with the mods." } function display_export_help() { echo "Usage: h2mm export" echo "Short form: h2mm ex" echo "Export installed mods and database to a zip file (in h2mm format, archive with csv)." } function display_import_help() { echo "Usage: h2mm import" echo "Short form: h2mm im" echo "Import mods and database from an archive file (coming from h2mm)." } function display_modpack_list_help() { echo "Usage: h2mm modpack-list" echo "Short form: h2mm ml" echo "List all installed modpacks." echo "Database of modpacks is stored in Steam/steamapps/common/Helldivers\ 2/data/modpacks/modpacks.csv" echo "You can rename, delete, or edit this file to manage modpacks manually." } function display_modpack_create_help() { echo "Usage: h2mm modpack-create \"\"" echo "Short form: h2mm mc" echo "Create a modpack from the currently installed mods." } function display_modpack_switch_help() { echo "Usage: h2mm modpack-switch [options] \"\"" echo "Short form: h2mm ms" echo "Options:" echo " -i Index of the modpack to switch to." echo "Switch to a modpack by name or index." } function display_modpack_reset_help() { echo "Usage: h2mm modpack-reset" echo "Short form: h2mm mr" echo "Reset all installed modpacks." echo "Deletes all installed modpacks and the database file." echo "Database of modpacks is stored in Steam/steamapps/common/Helldivers\ 2/data/modpacks/modpacks.csv, along with the modpacks." } function display_modpack_delete_help() { echo "Usage: h2mm modpack-delete [options] \"\"" echo "Short form: h2mm md" echo "Options:" echo " -i Index of the modpack to delete." echo "Delete a modpack by name or index." } function display_modpack_overwrite_help() { echo "Usage: h2mm modpack-overwrite [options] \"\"" echo "Short form: h2mm mo" echo "Options:" echo " -i Index of the modpack to overwrite." echo "Overwrite a modpack (the mods that it uses) by name or index." } # --- Main Functions --- # Check for updates function check_for_updates() { if [[ -f "$LAST_CHECKED_UPDATE_FILE" ]]; then last_update=$(cat "$LAST_CHECKED_UPDATE_FILE") if [[ "$(date +%s)" -lt "$(date +%s -d "$last_update + 1 hour")" ]]; then return fi else echo "$(date +%Y-%m-%dT%H:%M:%S)" > "$LAST_CHECKED_UPDATE_FILE" exit 0 fi latest_version=$(curl -sS "$VERSION_URL") if [[ $? -ne 0 ]]; then echo "${RED}Error:${NC} Could not check for updates." >&2 return fi if [[ "$latest_version" != "$VERSION" ]]; then echo -e "${ORANGE}Info:${NC} A new version of h2mm is available: ${ORANGE}$VERSION${NC} -> ${GREEN}$latest_version${NC}" >&2 echo -e "${ORANGE}Info:${NC} Run \"h2mm update\" to update." >&2 fi echo "$(date +%Y-%m-%dT%H:%M:%S)" > "$LAST_CHECKED_UPDATE_FILE" } # Upgrade/downgrade logic function downgrade_mods() { local files="$1" declare -A downgrades_versions declare -A downgrades_to_apply for file in $files; do # save the basename for the files that were deleted into a hash table, so we can downgrade mods with greater version number # also depending on how many patches the mod has, we need to downgrade with more versions current_version=$(echo "$file" | grep -oP '(?<=patch_)\d+') base_name=$(get_basename "$file") downgrades_versions["$base_name"]=$current_version # count how many unique patches the mod has [[ -z "${downgrades_to_apply["$base_name"]+unset}" ]] && downgrades_to_apply["$base_name"]=$(echo $files | tr ' ' '\n' | grep "$base_name" | sed -E "s/(.*_[0-9]+).*/\1/" | sort -u | wc -l) done # downgrade any necessary mods - it takes ~20 minutes to re-understand this code so I'm writing a comment here # for each base name, find all files that have the same base name, and are greater than the current version, and downgrade them # the downgrades_versions hash table stores the current version, so we can compare it with the version of the files # the downgrades_to_apply hash table stores the number of patches the mod has, so we can downgrade with more versions # for example, if we have: # mod 1: AAA.patch_0 AAA.patch_0.stream AAA.patch_1 AAA.patch_1.stream # mod 2: AAA.patch_2 AAA.patch_2.stream AAA.patch_3 AAA.patch_3.stream # mod 3: AAA.patch_4 AAA.patch_4.stream AAA.patch_5 AAA.patch_5.stream # if we remove mod with index 2, we need to downgrade mod with index 3 (which has patch version 4 and 5 > 3 (biggest last patch removed)) by 2 versions, # the number 2 we get by counting the number of unique base names (without extensions like .stream, but with the .patch_[0-9]) in the files for base_name in "${!downgrades_to_apply[@]}"; do # find all files that have the same base name, and are greater than the current version, and downgrade them IFS=$'\n' mods_to_downgrade=($(ls "$MODS_DIR/$base_name"* 2>/dev/null | sort -V)); unset IFS for mod in "${mods_to_downgrade[@]}"; do mod=$(get_filename_without_path "$mod") patch_version=$(echo $mod | grep -oP '(?<=patch_)\d+') if [[ $patch_version -gt ${downgrades_versions[$base_name]} ]]; then new_version=$((patch_version - downgrades_to_apply["$base_name"])) extension=$(get_extension "$mod") new_patch="${base_name}.patch_${new_version}${extension}" mv "$MODS_DIR/$mod" "$MODS_DIR/$new_patch" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not downgrade mod file $mod." >&2; exit 1; } echo -e "Downgraded ${ORANGE}$mod${NC} to ${GREEN}\$MODS_DIR/$new_patch${NC}." >&2 # save changes in database as well sed -i "s/\(\b$mod\b\)/$new_patch/" "$DB_FILE" fi done done } function upgrade_mods() { local files="$1" declare -A upgrade_versions declare -A upgrades_to_apply # opposite of downgrade_mods for file in $files; do # remove disabled_ prefix if it exists file=$(remove_disabled_prefix "$file") current_version=$(echo "$file" | grep -oP '(?<=patch_)\d+') base_name=$(get_basename "$file") # basically save the lowest number, by limiting the setting of the key to the first time we see it [[ -z "${upgrade_versions["$base_name"]+unset}" ]] && upgrade_versions["$base_name"]=$current_version [[ -z "${upgrades_to_apply["$base_name"]+unset}" ]] && upgrades_to_apply["$base_name"]=$(echo $files | tr ' ' '\n' | grep "$base_name" | sed -E "s/(.*_[0-9]+).*/\1/" | sort -u | wc -l) done for base_name in "${!upgrades_to_apply[@]}"; do IFS=$'\n' mods_to_upgrade=($(ls "$MODS_DIR/$base_name"* 2>/dev/null | sort -V)); unset IFS for mod in "${mods_to_upgrade[@]}"; do mod=$(get_filename_without_path "$mod") patch_version=$(echo $mod | grep -oP '(?<=patch_)\d+') if [[ $patch_version -ge ${upgrade_versions[$base_name]} ]]; then new_version=$((patch_version + upgrades_to_apply["$base_name"])) extension=$(get_extension "$mod") new_patch="${base_name}.patch_${new_version}${extension}" mv "$MODS_DIR/$mod" "$MODS_DIR/$new_patch" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not upgrade mod file $mod." >&2; exit 1; } echo -e "Upgraded ${ORANGE}$mod${NC} to ${GREEN}\$MODS_DIR/$new_patch${NC}." >&2 sed -i "s/\(\b$mod\b\)/$new_patch/" "$DB_FILE" fi done done } # Mod management function mod_disable() { local mod_name="" local mod_index="" [[ $# -eq 0 ]] && { display_disable_help; exit 0; } # parse arguments while [[ $# -gt 0 ]]; do case "$1" in -i) mod_index="$2"; shift 2 ;; --help|-h) display_disable_help; exit 0 ;; *) mod_name="$1"; shift 1 ;; esac done if [[ -z "$mod_name" && -z "$mod_index" ]]; then echo -e "${RED}Error${NC}: Mod name or index is required to disable." >&2 exit 1 fi # find mod files get_mod_name_and_index if [[ "$status" == "DISABLED" ]]; then echo -e "${RED}Error${NC}: Mod $mod_name is already disabled." >&2 exit 1 fi # disable each mod file by adding disabled_ to the start of the filename files=$(get_files_by_entry_from_db "$entry") for file in $files; do [[ ! -f "$MODS_DIR/$file" ]] && { echo -e "${RED}Error${NC}: Mod file $file does not exist." >&2; exit 1; } disabled_file="disabled_$file" while [[ -f "$MODS_DIR/$disabled_file" ]]; do disabled_file="disabled_$disabled_file" done mv "$MODS_DIR/$file" "$MODS_DIR/$disabled_file" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not disable mod file $file." >&2; exit 1; } echo -e "Disabled ${ORANGE}$file${NC} (changed to ${GREEN}\$MODS_DIR/$disabled_file${NC})." >&2 # save change to db sed -i "/^$mod_index,/ s/\(\b$file\b\)/$disabled_file/" "$DB_FILE" done # downgrade mods with greater version number downgrade_mods "$files" # update the database sed -i "/^$mod_index,/s/ENABLED/DISABLED/" "$DB_FILE" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not disable mod." >&2; exit 1; } echo -e "Mod $mod_name ${ORANGE}disabled${NC} successfully." >&2 } function mod_enable() { local mod_name="" local mod_index="" [[ $# -eq 0 ]] && { display_enable_help; exit 0; } # parse arguments while [[ $# -gt 0 ]]; do case "$1" in -i) mod_index="$2"; shift 2 ;; --help|-h) display_enable_help; exit 0 ;; *) mod_name="$1"; shift 1 ;; esac done [[ -z "$mod_name" && -z "$mod_index" ]] && { echo -e "${RED}Error${NC}: Mod name or index is required to enable." >&2; exit 1; } # find mod files get_mod_name_and_index [[ "$status" == "ENABLED" ]] && { echo -e "${RED}Error${NC}: Mod $mod_name is already enabled." >&2; exit 1; } files=$(get_files_by_entry_from_db "$entry") # upgrade mods with lower version number upgrade_mods "$files" # enable each mod file by removing disabled_ from the start of the filename for file in $files; do enabled_file=$(remove_disabled_prefix "$file") # check if the files exists [[ -f "$MODS_DIR/$file" ]] || { echo -e "${RED}Error${NC}: Mod file $file does not exist." >&2; exit 1; } mv "$MODS_DIR/$file" "$MODS_DIR/$enabled_file" # check if the file was moved successfully [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not enable mod file $file." >&2; exit 1; } echo -e "Enabled ${ORANGE}$file${NC} (changed to ${GREEN}\$MODS_DIR/$enabled_file${NC})." >&2 # save change to db sed -i "/^$mod_index,/ s/\(\b$file\b\)/$enabled_file/" "$DB_FILE" done # update the database sed -i "/^$mod_index,/s/DISABLED/ENABLED/" "$DB_FILE" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not enable mod." >&2; exit 1; } echo -e "Mod $mod_name ${GREEN}enabled${NC} successfully." >&2 } function modpack_reset() { : } function mod_reset() { if [[ "$1" == "--help" || "$1" == "-h" ]]; then display_reset_help exit 0 fi local without_modpacks=false [[ "$1" == "--without-modpacks" ]] && without_modpacks=true echo -ne "Are you sure you want to ${RED}reset${NC} all installed mods? (Y/n): " >&2 read -r confirm if [[ "$confirm" == "y" || "$confirm" == "Y" || "$confirm" = "" ]]; then rm -f "$MODS_DIR"/*.patch_* rm -f "$DB_FILE" rm -f "$H2PATH" echo "Mods and related database file deleted." [[ $without_modpacks == false ]] && modpack_reset --force else echo "Reset cancelled." >&2 exit 1 fi } function mod_install() { local mod_name="" local mod_dir=() local mod_files=() local mod_zip=() [[ $# -eq 0 ]] && { display_install_help; exit 0; } # parse arguments while [[ $# -gt 0 ]]; do case "$1" in -n) mod_name="$2"; shift 2 ;; --help|-h) display_install_help; exit 0 ;; *) if [[ -f "$1" && "$1" == *.zip ]]; then mod_zip+=("$1") elif [[ -d "$1" ]]; then mod_dir+=("$1") else mod_files+=("$1") fi shift ;; esac done # edge case when there is a combination of mod zips and directories, call function for all zips and dirs if [[ ${mod_zip} && ${mod_dir} ]]; then mod_install "${mod_zip[@]}" mod_install "${mod_dir[@]}" # reset arrays mod_zip=() mod_dir=() # if there are no more arguments, exit [[ ${#mod_files[@]} -eq 0 ]] && exit 0 fi # if there's more than 1 zip, call recursively while [[ ${#mod_zip[@]} -gt 1 ]]; do mod_install "${mod_zip[0]}" mod_zip=("${mod_zip[@]:1}") done # extract the zip file and pass it to mod dirs if [[ -n "$mod_zip" ]]; then command -v unzip &> /dev/null || { echo -e "${RED}Error${NC}: unzip package is not installed." >&2; exit 1; } [[ ! -f "$mod_zip" ]] && { echo -e "${RED}Error${NC}: Zip file $mod_zip does not exist." >&2; exit 1; } # if the name is not specified, use the name of the directory, last sed for making nexusmods names not have numbers if [[ -z "$mod_name" ]]; then mod_name=$(basename "$mod_zip" | sed -E 's/\.zip//' | awk -F/ '{print $NF}' | sed -E 's/-[0-9]+-.*//') fi # mod_dir as a temporary directory mod_dir+=$(mktemp -d) unzip -qq "$mod_zip" -d "$mod_dir" fi # if there's more than 1 directory, call recursively while [[ ${#mod_dir[@]} -gt 1 ]]; do mod_install "${mod_dir[0]}" mod_dir=("${mod_dir[@]:1}") done # directory containing mod files if [[ -n "$mod_dir" ]]; then # verify directory exists [[ ! -d "$mod_dir" ]] && { echo -e "${RED}Error${NC}: Directory $mod_dir does not exist." >&2; exit 1; } # read every file from the directory readarray -d '' mod_files < <(find "$mod_dir" -type f -name "*.patch_*" -print0) # if the name is not specified, use the name of the directory, last sed for making nexusmods names not have numbers if [[ -z "$mod_name" ]]; then mod_name=$(echo "$mod_dir" | sed 's:/*$::' | awk -F/ '{print $NF}' | sed -E 's/-[0-9]+-.*//') fi fi # verify minimum information required [[ -z "$mod_name" || ${#mod_files[@]} -eq 0 ]] && { echo -e "${RED}Error${NC}: Mod name and files are required." >&2; exit 1; } # verify duplicate mod names get_mod_name_and_index --do-not-exit [[ $mod_index -ne -1 ]] && { echo -e "${RED}Error${NC}: The mod '$mod_name' is already installed."; exit 1; } # verify mod files exist for file in "${mod_files[@]}"; do [[ ! -f "$file" ]] && { echo -e "${RED}Error${NC}: Mod file $file does not exist." >&2; exit 1; } done # check for mod variants and handle # if the mod directory contains more than 1 directory, it means there are multiple variants for the mod # prompt the user to choose which variant to install, or install multiple readarray -d '' all_dirs < <(find "$mod_dir" -mindepth 1 -type d -print0) # filter so that we only have dirs that have *.patch_* files inside them filtered_dirs=() for dir in "${all_dirs[@]}"; do if find "$dir" -maxdepth 1 -type f -name "*.patch_*" -print -quit | grep -q .; then filtered_dirs+=("$dir") fi done if [[ ${#filtered_dirs[@]} -gt 1 ]]; then echo -e "Multiple mod variants found for mod ${mod_name}." >&2 for i in "${!filtered_dirs[@]}"; do echo "$((i + 1)). $(basename "${filtered_dirs[$i]}")" >&2 done # prompt user to choose echo -ne "Enter the number of the variant(s) to install (separated by space) or press Enter to install all: " >&2 read -a variant_indices if [[ -n "${variant_indices[0]}" ]]; then # clear mod_files mod_files=() # get the files from the chosen variant for index in "${variant_indices[@]}"; do [[ ! "$index" =~ ^[0-9]+$ ]] && { echo -e "${RED}Error${NC}: Invalid variant index." >&2; exit 1; } [[ $index -lt 1 || $index -gt ${#filtered_dirs[@]} ]] && { echo -e "${RED}Error${NC}: Variant index out of range." >&2; exit 1; } readarray -d '' variant_files < <(find "${filtered_dirs[$((index - 1))]}" -type f -name "*.patch_*" -print0) # update mod_name to contain the variant name mod_name="${mod_name} [$(basename "${filtered_dirs[$((index - 1))]}")]" # add the files to the mod_files array mod_files+=("${variant_files[@]}") done fi fi # hash table - in case multiple named files are needed for 1 mod install, store the patch count declare -A patch_count # store the target files so we can put them in the database later target_files=() # sort the mod files because with the below logic, the .stream and .gpu_resources files need to come after their respective patch files IFS=$'\n' mod_files=($(printf "%s\n" "${mod_files[@]}" | sort -t. -k1,1 -k2,2n)); unset IFS for file in "${mod_files[@]}"; do base_name=$(get_basename "$file") patch_prefix="$MODS_DIR/${base_name}.patch_" # count already installed patches count=$(ls "${patch_prefix}"* 2>/dev/null | grep -E '([0-9]+$)' 2>/dev/null | wc -l) # set patch count for file name if it doesn't exist yet if [[ -z "${patch_count[$file]+unset}" ]]; then patch_count["$file"]=$count fi # if the file has an extension (e.g. .stream, .gpu_resources), set the last patch number for the next step patch_count["$base_name"]=$count # if the file has an extension, look for the last patch number and use that, otherwise, the count will be wrong extension=$(get_extension "$file") if [[ -n "$extension" ]]; then target_file="${base_name}.patch_$((patch_count[$base_name] - 1))${extension}" else target_file="${base_name}.patch_${patch_count[$file]}" fi target_files+=($target_file) cp "$file" "$MODS_DIR/$target_file" # verify installation worked [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not install mod file $file." >&2; exit 1; } echo -e "Mod file ${ORANGE}$file${NC} installed at ${GREEN}\$MODS_DIR/$target_file${NC}." >&2 done # add entry to database next_id=$(awk -F, 'NR > 1 {last_id = $1} END {print last_id + 1}' "$DB_FILE") echo "$next_id,ENABLED,$mod_name,${target_files[*]}" >> "$DB_FILE" echo -e "Mod $mod_name ($base_name) ${GREEN}installed${NC} successfully." >&2 # disable any modpack disable_all_modpacks } function mod_uninstall() { local mod_name="" local mod_index="" [[ $# -eq 0 ]] && { display_uninstall_help; exit 0; } # parse arguments while [[ $# -gt 0 ]]; do case "$1" in -i) mod_index="$2"; shift 2 ;; --help|-h) display_uninstall_help; exit 0 ;; *) mod_name="$1"; shift 1 ;; esac done [[ -z "$mod_name" && -z "$mod_index" ]] && { echo -e "${RED}Error${NC}: Mod name or index is required to uninstall." >&2; exit 1; } # find mod files get_mod_name_and_index # delete mod files files=$(get_files_by_entry_from_db "$entry") for file in $files; do [[ ! -f "$MODS_DIR/$file" ]] && { echo -e "${RED}Error${NC}: Mod file $file does not exist." >&2; exit 1; } echo -e "Removing ${ORANGE}\$MODS_DIR/$file${NC}." >&2 rm "$MODS_DIR/$file" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not remove mod file $file." >&2; exit 1; } done # downgrade mods with greater version number, only if the mod is enabled [[ "$status" == "ENABLED" ]] && downgrade_mods "$files" # remove entry from database sed -i "/^$mod_index,/d" "$DB_FILE" echo -e "Mod $mod_name ${ORANGE}uninstalled${NC} successfully." >&2 # disable any modpack disable_all_modpacks } function mod_list() { [[ "$1" == "--help" || "$1" == "-h" ]] && { display_list_help; exit 0; } [[ $(wc -l < "$DB_FILE") -le 1 ]] && { echo "No mods installed."; return; } echo "Installed mods:" >&2 awk -v GREEN="$GREEN" -v RED="$RED" -v NC="$NC" -F, 'NR > 1 { color = ($2 == "DISABLED") ? RED : GREEN; if (length($4) > 150) $4 = substr($4, 1, 147) "..."; printf "%2s. [%s%s%s] %s (%s)\n", $1, color, $2, NC, $3, $4}' "$DB_FILE" } function mod_export() { [[ "$1" == "--help" || "$1" == "-h" ]] && { display_export_help; exit 0; } local save_dir=$(pwd) local archive_name="Helldivers_2_Mods_$(date +%Y-%m-%d_%H-%M-%S)" local modpack_export=false local force=false if [[ "$1" == "--modpack" ]]; then modpack_export=true save_dir="$2" archive_name="$3" fi [[ $(wc -l < "$DB_FILE") -le 1 ]] && { echo "No modpacks saved."; exit 1; } if [[ $modpack_export == false ]]; then echo -ne "Archive file will be saved to ${save_dir}/${archive_name}. Make? (Y/n): " >&2 read -r confirm fi if [[ silent == true || "$confirm" == "y" || "$confirm" == "Y" || "$confirm" = "" ]]; then # create a temporary directory to store the mods OUT_DIR=$(mktemp -d) MODS_EXPORT_DIR="$OUT_DIR/Helldivers 2 Mods" mkdir -p "$MODS_EXPORT_DIR" cp "$DB_FILE" "$MODS_EXPORT_DIR" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not copy mods to target directory." >&2; exit 1; } # copy all mod files to the export directory for file in $(ls "$MODS_DIR/" 2>/dev/null | grep -E 'patch_.*'); do cp "$MODS_DIR/$file" "$MODS_EXPORT_DIR" done [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not export mods. Possibly because no mods are present." >&2; exit 1; } # zip up the mods with the current date and time in the name [[ -f "$save_dir/${archive_name}.tar.gz" ]] && { echo -e "${RED}Error${NC}: File $save_dir/${archive_name}.tar.gz already exists." >&2; exit 1; } tar -czf "$save_dir/${archive_name}.tar.gz" -C "$OUT_DIR" "Helldivers 2 Mods" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Failed to export mods." >&2; exit 1; } [[ "$modpack_export" == false ]] && echo -e "Mods ${GREEN}exported${NC} to $save_dir/${archive_name}.tar.gz." >&2 fi } function mod_import() { [[ "$1" == "--help" || "$1" == "-h" ]] && { display_import_help; exit 0; } local modpack_export=false [[ "$1" == "--modpack" ]] && { modpack_export=true; shift 1; } [[ ! -f "$1" ]] && { echo -e "${RED}Error${NC}: File $1 does not exist." >&2; exit 1; } # reset mods before importing [[ modpack_export == false ]] && echo -e "Importing mods will ${RED}reset${NC} your mods." >&2 mod_reset --without-modpacks # extract in temp directory OUT_DIR=$(mktemp -d) tar -xzf "$1" -C "$OUT_DIR" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not import mods. Possibly because the archive is invalid." >&2; exit 1; } MODS_EXPORT_DIR="$OUT_DIR/Helldivers 2 Mods" [[ ! -d "$MODS_EXPORT_DIR" ]] && { echo -e "${RED}Error${NC}: Could not import mods. Possibly because the archive is invalid." >&2; exit 1; } # copy mods cp "$MODS_EXPORT_DIR"/* "$MODS_DIR" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Failed to import mods." >&2; exit 1; } echo -e "Mods imported ${GREEN}successfully${NC}." >&2 } # --- Modpacks management --- function modpack_list() { [[ "$1" == "--help" || "$1" == "-h" ]] && { display_modpack_list_help; exit 0; } [[ $(wc -l < "$MODPACKS_DB_FILE") -le 1 ]] && { echo "No modpacks saved."; return; } echo "Saved modpacks:" >&2 awk -v GREEN="$GREEN" -v RED="$RED" -v NC="$NC" -F, 'NR > 1{ color = ($2 == "DISABLED") ? RED : GREEN; printf "%2s. [%s%s%s] %s\n", $1, color, $2, NC, $3}' "$MODPACKS_DB_FILE" } function modpack_create() { local modpack_name="" [[ $# -eq 0 ]] && { display_modpack_create_help; exit 0; } # if no mods are installed, exit [[ $(wc -l < "$DB_FILE") -le 1 ]] && { echo -e "${RED}Error${NC}: No mods installed." >&2; exit 1; } # use built-in export function modpack_name="$1" mod_export --modpack "$MODPACKS_FOLDER" "$modpack_name" echo -e "Modpack ${GREEN}created${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz" >&2 # add entry to database next_id=$(awk -F, 'NR > 1 {last_id = $1} END {print last_id + 1}' "$MODPACKS_DB_FILE") sed -i "s/ENABLED/DISABLED/" "$MODPACKS_DB_FILE" echo "$next_id,ENABLED,$modpack_name" >> "$MODPACKS_DB_FILE" } function modpack_switch() { local modpack_name="" local modpack_index="" [[ $# -eq 0 ]] && { display_modpack_switch_help; exit 0; } # parse arguments while [[ $# -gt 0 ]]; do case "$1" in -i) modpack_index="$2"; shift 2 ;; --help|-h) display_modpack_switch_help; exit 0 ;; *) modpack_name="$1"; shift 1 ;; esac done [[ -z "$modpack_name" && -z "$modpack_index" ]] && { echo -e "${RED}Error${NC}: Modpack name or index is required to switch." >&2; exit 1; } # find modpack files get_modpack_name_and_index "$modpack_name" "$modpack_index" echo -e "Switching modpacks mods will ${RED}reset${NC} your mods." >&2 mod_import --modpack "$MODPACKS_FOLDER/$modpack_name.tar.gz" echo -e "Modpack ${GREEN}switched${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz" >&2 # save status to db sed -i "s/ENABLED/DISABLED/" "$MODPACKS_DB_FILE" sed -i "/^$modpack_index,/s/DISABLED/ENABLED/" "$MODPACKS_DB_FILE" } function modpack_reset() { if [[ "$1" == "--help" || "$1" == "-h" ]]; then display_modpack_reset_help exit 0 fi local force=false [[ "$1" == "--force" ]] && force=true if [[ force == false ]]; then echo -ne "Are you sure you want to ${RED}reset${NC} all installed modpacks? (Y/n): " >&2 read confirm fi if [[ force == true || "$confirm" == "y" || "$confirm" == "Y" || "$confirm" = "" ]]; then rm -f "$MODPACKS_FOLDER"/*.tar.gz rm -f "$MODPACKS_DB_FILE" rmdir "$MODPACKS_FOLDER" echo "Modpacks and related database file deleted." else echo "Reset cancelled." >&2 exit 1 fi } function modpack_delete() { local modpack_name="" local modpack_index="" [[ $# -eq 0 ]] && { display_modpack_delete_help; exit 0; } # parse arguments while [[ $# -gt 0 ]]; do case "$1" in -i) modpack_index="$2"; shift 2 ;; --help|-h) display_modpack_delete_help; exit 0 ;; *) modpack_name="$1"; shift 1 ;; esac done [[ -z "$modpack_name" && -z "$modpack_index" ]] && { echo -e "${RED}Error${NC}: Modpack name or index is required to delete." >&2; exit 1; } get_modpack_name_and_index "$modpack_name" "$modpack_index" rm -f "$MODPACKS_FOLDER/$modpack_name.tar.gz" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not delete modpack." >&2; exit 1; } echo -e "Modpack ${GREEN}deleted${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz" >&2 # remove entry from database sed -i "/^$modpack_index,/d" "$MODPACKS_DB_FILE" } function modpack_overwrite() { [[ $# -eq 0 ]] && { display_modpack_overwrite_help; exit 0; } local modpack_name="" local modpack_index="" # parse arguments while [[ $# -gt 0 ]]; do case "$1" in -i) modpack_index="$2"; shift 2 ;; --help|-h) display_modpack_save_help; exit 0 ;; *) modpack_name="$1"; shift 1 ;; esac done [[ -z "$modpack_name" && -z "$modpack_index" ]] && { echo -e "${RED}Error${NC}: Modpack name or index is required to save." >&2; exit 1; } get_modpack_name_and_index "$modpack_name" "$modpack_index" # if the modpack doesn't exist, exit [[ ! -f "$MODPACKS_FOLDER/$modpack_name.tar.gz" ]] && { echo -e "${RED}Error${NC}: Modpack $modpack_name does not exist." >&2; exit 1; } rm -f "$MODPACKS_FOLDER/$modpack_name.tar.gz" [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not delete modpack." >&2; exit 1; } # use built-in export function mod_export --modpack "$MODPACKS_FOLDER" "$modpack_name" echo -e "Modpack ${GREEN}saved${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz" >&2 sed -i "/^$modpack_index,/s/DISABLED/ENABLED/" "$MODPACKS_DB_FILE" } function self_update() { latest_version=$(curl -sS "$VERSION_URL") if [[ "$latest_version" == "$VERSION" ]]; then echo -e "h2mm is already up-to-date." >&2 exit 0 fi echo -e "Starting update script..." >&2 # Run the installer for the latest version bash -c "$(curl -fsSL https://raw.githubusercontent.com/v4n00/h2mm-cli/refs/heads/master/install.sh)" exit 0 } # --- Main --- function main() { [[ $# -lt 1 ]] && { display_help; exit 1; } command="$1" shift initialize_directories initialize_modpack_directories check_for_updates case "$command" in "install"|"i") mod_install "$@" ;; "list"|"l") mod_list "$@" ;; "uninstall"|"u") mod_uninstall "$@" ;; "enable"|"e") mod_enable "$@" ;; "disable"|"d") mod_disable "$@" ;; "export"|"ex") mod_export "$@" ;; "import"|"im") mod_import "$@" ;; "modpack-list"|"ml") modpack_list "$@" ;; "modpack-create"|"mc") modpack_create "$@" ;; "modpack-delete"|"md") modpack_delete "$@" ;; "modpack-overwrite"|"mo") modpack_overwrite "$@" ;; "modpack-switch"|"ms") modpack_switch "$@" ;; "modpack-reset"|"mr") modpack_reset "$@" ;; "reset"|"r") mod_reset "$@" ;; "version"|"v"|"-v"|"--version") echo "${VERSION}" ;; "update"|"up") self_update ;; "help"|"--help"|"-h"|"h") display_help ;; *) display_help ;; esac } main "$@"