Skip to content

Instantly share code, notes, and snippets.

@doesdev
Last active June 30, 2025 22:15
Show Gist options
  • Select an option

  • Save doesdev/0bc2e71e47a8c47d45214a020898dea6 to your computer and use it in GitHub Desktop.

Select an option

Save doesdev/0bc2e71e47a8c47d45214a020898dea6 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
#
# Binding of Isaac Mod Merger v5
# – Portable, non‐destructive, interactive or headless
# – Dry‐run, conflict resolution, dependency scan
# – File‐list editing (-f), metadata/log/README, Lua syntax check + dump on failure
# – Tracks conflicted mods and shows merge progress bar
#
set -euo pipefail
IFS=$'\n\t'
trap 'echo "❌ Script failed at line $LINENO"' ERR
# Colors
RED='\033[0;31m'; YEL='\033[0;33m'; GRN='\033[0;32m'; BLU='\033[0;34m'; NC='\033[0m'
# Bash ≥4
if (( BASH_VERSINFO[0] < 4 )); then
echo -e "${RED}Bash 4+ required (found ${BASH_VERSINFO[0]}).${NC}"
exit 1
fi
VERSION="v5"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DEF_NAME="merged_mod_${TIMESTAMP}"
DEF_AUTHOR="SteamDeckUser"
DEF_DESC="Merged mod created on Steam Deck"
DEF_VER="1.0"
POSSIBLE=(
"$HOME/.local/share/Steam/steamapps/common/The Binding of Isaac Rebirth/mods"
"$HOME/.steam/steam/steamapps/common/The Binding of Isaac Rebirth/mods"
"$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/The Binding of Isaac Rebirth/mods"
)
ASSUME_YES=false
SKIP_DRY_RUN=false
QUIET=false
USE_LIST_FILE=false
CR_MODE=""
# track conflicted mods
declare -A mod_conflict_seen
conflict_mods=()
show_help(){
cat <<EOF
Usage: $(basename "$0") [options]
Options:
-h, --help Show help
-y, --yes Accept defaults
-n, --no-dry-run Skip dry-run
-q, --quiet Quiet output
-f, --file Edit source-mod list in $(pwd)/mods_to_merge.txt
EOF
}
# progress bar function
progress_bar(){
local cur=$1 total=$2 width=40 filled empty i
(( filled = cur * width / total ))
(( empty = width - filled ))
printf "\rProgress: ["
for ((i=0; i<filled; i++)); do printf '#'; done
for ((i=0; i<empty; i++)); do printf '-'; done
printf "] %d/%d\n" "$cur" "$total"
}
# parse flags
while (( "$#" )); do
case "$1" in
-h|--help) show_help; exit 0;;
-y|--yes) ASSUME_YES=true; shift;;
-n|--no-dry-run) SKIP_DRY_RUN=true; shift;;
-q|--quiet) QUIET=true; shift;;
-f|--file) USE_LIST_FILE=true; shift;;
*) echo "Unknown option: $1"; show_help; exit 1;;
esac
done
log(){ $QUIET || echo -e "$@"; }
info(){ log "${BLU}➔${NC} $@"; }
warn(){ log "${YEL}⚠️${NC} $@"; }
error(){ log "${RED}❌${NC} $@"; }
info "Isaac Mod Merger ${VERSION}"
# autodetect mods dir
MOD_ROOT=""
for p in "${POSSIBLE[@]}"; do
[[ -d "$p" ]] && { MOD_ROOT="$p"; break; }
done
[[ -z "$MOD_ROOT" ]] && MOD_ROOT="${POSSIBLE[0]}"
# interactive prompts
if ! $ASSUME_YES; then
read -rp "Merged mod name [${DEF_NAME}]: " MOD_NAME; MOD_NAME="${MOD_NAME:-$DEF_NAME}"
read -rp "Author [${DEF_AUTHOR}]: " AUTHOR; AUTHOR="${AUTHOR:-$DEF_AUTHOR}"
read -rp "Description [${DEF_DESC}]: " DESC; DESC="${DESC:-$DEF_DESC}"
read -rp "Version [${DEF_VER}]: " VER; VER="${VER:-$DEF_VER}"
read -rp "Mods directory [${MOD_ROOT}]: " input; MOD_ROOT="${input:-$MOD_ROOT}"
else
MOD_NAME="$DEF_NAME"; AUTHOR="$DEF_AUTHOR"; DESC="$DEF_DESC"; VER="$DEF_VER"
fi
# sanitize & set dest
MOD_NAME="${MOD_NAME//[^A-Za-z0-9_-]/_}"
DEST="$MOD_ROOT/$MOD_NAME"
info "Destination: $DEST"
[[ -d "$MOD_ROOT" ]] || { error "Mods dir not found: $MOD_ROOT"; exit 1; }
# dry-run decision
if ! $SKIP_DRY_RUN && ! $ASSUME_YES; then
read -rp "Dry run first? (Y/n): " dr
SKIP_DRY_RUN=$([[ "${dr:-Y}" =~ ^[Nn]$ ]] && echo true || echo false)
fi
# collect source-mods
if $USE_LIST_FILE; then
LIST_FILE="$(pwd)/mods_to_merge.txt"
[[ ! -f "$LIST_FILE" ]] && cat >"$LIST_FILE" <<EOF
# One absolute path per line. Blank/# lines ignored.
# Save & exit to continue.
EOF
${EDITOR:-vi} "$LIST_FILE"
MODS=(); while read -r m; do [[ "$m" =~ ^# ]] && continue; [[ -z "$m" ]] && continue; MODS+=("$m"); done <"$LIST_FILE"
else
echo; info "Enter mod paths (one per line), blank to finish:"
MODS=(); while read -r m && [[ -n "$m" ]]; do MODS+=("$m"); done
fi
(( ${#MODS[@]} )) || { error "No mods specified"; exit 1; }
# validate & analyze
declare -A MODULES; VALID=(); TOTAL_MB=0
echo; info "🔍 Analyzing mods..."
for mp in "${MODS[@]}"; do
[[ -d "$mp" ]] || { warn "Not a dir: $mp"; continue; }
base=$(basename "$mp"); info "• $base"
[[ ! -f "$mp/main.lua" ]] && warn " Missing main.lua"
MODULES["$base"]=1
if command -v du &>/dev/null; then mb=$(du -sm "$mp" | cut -f1); TOTAL_MB=$((TOTAL_MB+mb)); (( mb>100 )) && warn " Large (${mb}MB)"; fi
VALID+=("$mp")
done
(( ${#VALID[@]} )) || { error "No valid mods"; exit 1; }
info "Mods to merge: ${#VALID[@]}, total ≈ ${TOTAL_MB}MB"
# prune backups >7d
pruned=$(find "$MOD_ROOT" -maxdepth 1 -type d -name "*_backup_*" -mtime +7 | wc -l)
(( pruned>0 )) && info "Pruned $pruned old backups"
# dry-run preview
if ! $SKIP_DRY_RUN; then
echo; info "🔍 Dry run:"
EXIST=()
[[ -d "$DEST" ]] && while read -r f; do EXIST+=("${f#"$DEST/"}"); done < <(find "$DEST" -type f)
for mp in "${VALID[@]}"; do
base=$(basename "$mp"); info "• $base"
find "$mp" -type f ! -name main.lua ! -name metadata.xml | while read -r f; do
prefix="$mp/"; rel="${f#$prefix}"
for e in "${EXIST[@]}"; do [[ "$rel" == "$e" ]] && warn " Conflict: $rel"; done
done
done
if ! $ASSUME_YES; then read -rp "Proceed? (y/N): " ok; [[ "${ok^^}" == "Y" ]] || { info "Cancelled."; exit 0; }; fi
fi
# conflict mode
if ! $ASSUME_YES; then
until [[ $CR_MODE =~ ^[1-4]$ ]]; do echo; info "Conflict modes: 1)ask 2)rename 3)skip 4)overwrite"; read -rp "Choose [2]: " CR_MODE; CR_MODE="${CR_MODE:-2}"; done
else
CR_MODE=2
fi
# backup existing\[[ -d "$DEST" ]] && { mv "$DEST" "${DEST}_backup_${TIMESTAMP}"; info "Backed up existing mod"; }
mkdir -p "$DEST"
# start merged_main.lua
MERGED="$DEST/merged_main.lua"
{
echo "-- $MOD_NAME merged"
echo "-- by $AUTHOR on $(date)"
echo
} >"$MERGED"
# merge
info "🔄 Merging mods..."
total=${#VALID[@]}; idx=0
for mp in "${VALID[@]}"; do
idx=$((idx+1)); progress_bar $idx $total
base=$(basename "$mp"); ns="${base//[^A-Za-z0-9_]/_}"
info "• $base → $ns"
# assets
find "$mp" -type f ! -name main.lua ! -name metadata.xml | while read -r f; do
prefix="$mp/"; rel="${f#$prefix}"; dest="$DEST/$rel"
if [[ -f "$dest" ]]; then
warn " Conflict: $rel"
# record mod conflict
if [[ -z ${mod_conflict_seen[$base]:-} ]]; then mod_conflict_seen[$base]=1; conflict_mods+=("$base"); fi
case $CR_MODE in
2) : ;; 1) echo " [1] rename [2] skip [3] overwrite"; read -rp " choice: " ch;; *) ch=$CR_MODE;;
esac
case $ch in
2) nm="${rel%.*}_$ns.${rel##*.}"; dest="$DEST/$nm"; info " renamed → $nm";;
3) info " overwriting";;
*) continue;;
esac
fi
mkdir -p "$(dirname "$dest")"; cp "$f" "$dest"
done
# main.lua
if [[ -f "$mp/main.lua" ]]; then
info " merging main.lua"
{
echo; echo "-- from $base/main.lua"
echo "do local mod = RegisterMod(\"$base\", 1)"
sed -E '/^[[:space:]]*local[[:space:]]+mod[[:space:]]*=[[:space:]]*RegisterMod/ s/^/-- /; \$a\\' "$mp/main.lua"
echo "end"
echo
} >>"$MERGED"
fi
done
# final newline
echo >>"$MERGED"
# finalize & syntax-check
cp "$MERGED" "$DEST/main.lua"
if command -v luac &>/dev/null; then
if ! luac -p "$DEST/main.lua"; then
error "Lua syntax error; dumping merged main.lua to MERGE_LOG.txt"
{
echo; echo "=== merged main.lua begin ==="
cat "$DEST/main.lua"
echo "=== merged main.lua end ==="
} >>"$DEST/MERGE_LOG.txt"
exit 1
fi
fi
# write metadata.xml
cat >"$DEST/metadata.xml" <<EOF
<metadata>
<name>$MOD_NAME</name>
<description>$DESC</description>
<author>$AUTHOR</author>
<version>$VER</version>
<visibility>Public</visibility>
</metadata>
EOF
# write logs & README
cat >"$DEST/MERGE_LOG.txt" <<EOF
Merged on $(date)
Sources: ${VALID[*]##*/}
EOF
cat >"$DEST/README.txt" <<EOF
$MOD_NAME
Merged mods: ${VALID[*]##*/}
EOF
# final summary
echo -e "${GRN}🎉 Merge complete!${NC}"
echo "Total files: $(find "$DEST" -type f | wc -l)"
echo "Mods with conflicts: ${conflict_mods[*]:-None}"
echo "Location: $DEST"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment