Skip to content

Instantly share code, notes, and snippets.

@coatezy
Created March 24, 2026 00:09
Show Gist options
  • Select an option

  • Save coatezy/0d57ce7153199655f2d8074bb7d6a59d to your computer and use it in GitHub Desktop.

Select an option

Save coatezy/0d57ce7153199655f2d8074bb7d6a59d to your computer and use it in GitHub Desktop.
Setup
#!/usr/bin/env bash
set -Eeuo pipefail
# ============================================================
# Mac Couch Emulation Bootstrap v3.1
# - Latest upstream builds where practical
# - ES-DE, Dolphin, PCSX2, RPCS3, Cemu
# - Optional RetroArch config patching if already installed
# - ES-DE defaults + custom systems for ps3/wiiu
# ============================================================
EMULATION_ROOT="${HOME}/Emulation"
ROMS_DIR="${EMULATION_ROOT}/roms"
BIOS_DIR="${EMULATION_ROOT}/bios"
SAVES_DIR="${EMULATION_ROOT}/saves"
STATES_DIR="${EMULATION_ROOT}/states"
SCREENSHOTS_DIR="${EMULATION_ROOT}/screenshots"
TOOLS_DIR="${EMULATION_ROOT}/tools"
DOWNLOADS_DIR="${EMULATION_ROOT}/downloads"
TMP_DIR="${EMULATION_ROOT}/tmp"
LOG_FILE="${EMULATION_ROOT}/setup.log"
ESDE_CONFIG_DIR="${HOME}/Library/Application Support/ES-DE"
ESDE_CUSTOM_DIR="${HOME}/.emulationstation/custom_systems"
ESDE_CUSTOM_SYSTEMS_XML="${ESDE_CUSTOM_DIR}/es_systems.xml"
ESDE_SETTINGS_FILE="${ESDE_CONFIG_DIR}/es_settings.xml"
RETROARCH_CFG="${HOME}/Library/Application Support/RetroArch/retroarch.cfg"
DOLPHIN_USER_DIR="${HOME}/Library/Application Support/Dolphin"
PCSX2_INI_DIR="${HOME}/Library/Application Support/PCSX2/inis"
RPCS3_CONFIG_DIR="${HOME}/Library/Application Support/rpcs3"
ADD_ESDE_TO_LOGIN_ITEMS="${ADD_ESDE_TO_LOGIN_ITEMS:-false}"
OPEN_APPS_ON_FINISH="${OPEN_APPS_ON_FINISH:-true}"
PATCH_RETROARCH_IF_PRESENT="${PATCH_RETROARCH_IF_PRESENT:-true}"
ESDE_THEME="${ESDE_THEME:-modern}"
SYSTEM_DIRS=(
"3do" "amiga" "arcade" "atari2600" "atari5200" "atari7800" "atarilynx"
"dreamcast" "fbneo" "gameandwatch" "gamegear" "gamecube" "gb" "gba" "gbc"
"genesis" "mastersystem" "megadrive" "n64" "nds" "nes" "ngp" "ngpc"
"pcengine" "ports" "ps2" "ps3" "psp" "psx" "saturn" "scummvm" "sega32x"
"segacd" "snes" "tg16" "virtualboy" "wii" "wiiu"
)
timestamp() { date +"%Y-%m-%d %H:%M:%S"; }
log() {
mkdir -p "${EMULATION_ROOT}"
echo "[$(timestamp)] $*" | tee -a "${LOG_FILE}"
}
fail() {
log "ERROR: $*"
exit 1
}
command_exists() {
command -v "$1" >/dev/null 2>&1
}
require_macos() {
[[ "$(uname -s)" == "Darwin" ]] || fail "This script is for macOS only."
}
detect_arch() {
uname -m
}
ensure_xcode_cli_tools() {
if xcode-select -p >/dev/null 2>&1; then
log "Xcode Command Line Tools already installed."
return
fi
log "Xcode Command Line Tools not found. Requesting install..."
xcode-select --install || true
log "Complete the Command Line Tools install, then re-run this script."
exit 0
}
ensure_homebrew() {
if command_exists brew; then
log "Homebrew already installed."
return
fi
log "Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
if [[ -x "/opt/homebrew/bin/brew" ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [[ -x "/usr/local/bin/brew" ]]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
command_exists brew || fail "Homebrew installation failed."
}
ensure_brew_shellenv_loaded() {
if command_exists brew; then
return
fi
if [[ -x "/opt/homebrew/bin/brew" ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [[ -x "/usr/local/bin/brew" ]]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
}
ensure_rosetta_if_needed() {
if [[ "$(detect_arch)" != "arm64" ]]; then
log "Intel Mac detected; Rosetta not required."
return
fi
if /usr/bin/pgrep oahd >/dev/null 2>&1; then
log "Rosetta 2 already appears to be installed."
return
fi
log "Installing Rosetta 2..."
softwareupdate --install-rosetta --agree-to-license || log "Rosetta may already be installed."
}
brew_install_formula_if_missing() {
local formula="$1"
if brew list "${formula}" >/dev/null 2>&1; then
log "Formula already installed: ${formula}"
else
log "Installing formula: ${formula}"
brew install "${formula}"
fi
}
install_prereqs() {
log "Updating Homebrew..."
brew update
brew_install_formula_if_missing "jq"
brew_install_formula_if_missing "unar"
brew_install_formula_if_missing "wget"
}
create_directories() {
log "Creating directory structure..."
mkdir -p \
"${ROMS_DIR}" "${BIOS_DIR}" "${SAVES_DIR}" "${STATES_DIR}" \
"${SCREENSHOTS_DIR}" "${TOOLS_DIR}" "${DOWNLOADS_DIR}" "${TMP_DIR}" \
"${ESDE_CONFIG_DIR}" "${ESDE_CUSTOM_DIR}"
for dir in "${SYSTEM_DIRS[@]}"; do
mkdir -p "${ROMS_DIR}/${dir}"
done
mkdir -p "${ROMS_DIR}/ps3/firmware"
mkdir -p "${ROMS_DIR}/wiiu/install"
mkdir -p "${ROMS_DIR}/wiiu/mlc01"
mkdir -p "${ROMS_DIR}/wiiu/keys"
}
download_to() {
local url="$1"
local output="$2"
log "Downloading ${url}"
curl -L --fail --output "${output}" "${url}"
}
github_latest_asset_url() {
local repo="$1"
local regex="$2"
curl -fsSL "https://api.github.com/repos/${repo}/releases/latest" \
| jq -r --arg regex "${regex}" '
.assets[]
| select(.name | test($regex))
| .browser_download_url
' \
| head -n 1
}
install_app_from_dmg() {
local dmg_path="$1"
local app_name="$2"
local mount_point
mount_point="$(hdiutil attach -nobrowse -plist "${dmg_path}" \
| plutil -extract system-entities json -o - - \
| jq -r '.[]."mount-point" // empty' \
| head -n 1)"
[[ -n "${mount_point}" ]] || fail "Could not mount DMG: ${dmg_path}"
local source_app
source_app="$(find "${mount_point}" -maxdepth 2 -type d -name "${app_name}.app" | head -n 1)"
[[ -n "${source_app}" ]] || fail "Could not find ${app_name}.app in mounted DMG"
log "Installing ${app_name}.app to /Applications"
rm -rf "/Applications/${app_name}.app"
cp -R "${source_app}" "/Applications/"
hdiutil detach "${mount_point}" >/dev/null
}
install_app_from_zip() {
local zip_path="$1"
local app_name="$2"
local extract_dir="${TMP_DIR}/$(basename "${zip_path}" .zip)"
rm -rf "${extract_dir}"
mkdir -p "${extract_dir}"
ditto -x -k "${zip_path}" "${extract_dir}"
local source_app
source_app="$(find "${extract_dir}" -maxdepth 4 -type d -name "${app_name}.app" | head -n 1)"
[[ -n "${source_app}" ]] || fail "Could not find ${app_name}.app after unzip"
log "Installing ${app_name}.app to /Applications"
rm -rf "/Applications/${app_name}.app"
cp -R "${source_app}" "/Applications/"
}
install_app_from_7z() {
local archive_path="$1"
local app_name="$2"
local extract_dir="${TMP_DIR}/$(basename "${archive_path}" .7z)"
rm -rf "${extract_dir}"
mkdir -p "${extract_dir}"
unar -quiet -output-directory "${extract_dir}" "${archive_path}"
local source_app
source_app="$(find "${extract_dir}" -maxdepth 4 -type d -name "${app_name}.app" | head -n 1)"
[[ -n "${source_app}" ]] || fail "Could not find ${app_name}.app after extraction"
log "Installing ${app_name}.app to /Applications"
rm -rf "/Applications/${app_name}.app"
cp -R "${source_app}" "/Applications/"
}
remove_quarantine_if_present() {
local app_path="$1"
if [[ -d "${app_path}" ]]; then
xattr -dr com.apple.quarantine "${app_path}" >/dev/null 2>&1 || true
fi
}
install_esde_latest() {
local arch package_name url dmg
arch="$(detect_arch)"
package_name="macOSApple"
[[ "${arch}" == "x86_64" ]] && package_name="macOSIntel"
log "Resolving latest ES-DE package (${package_name})..."
url="$(curl -fsSL "https://raw.githubusercontent.com/RetroDECK/ES-DE/retrodeck-main/latest_release.json" \
| jq -r --arg name "${package_name}" '
.release.packages[]
| select(.name == $name)
| .url
')"
[[ -n "${url}" && "${url}" != "null" ]] || fail "Could not resolve latest ES-DE download URL"
dmg="${DOWNLOADS_DIR}/ES-DE-latest.dmg"
download_to "${url}" "${dmg}"
install_app_from_dmg "${dmg}" "ES-DE"
remove_quarantine_if_present "/Applications/ES-DE.app"
}
install_dolphin_latest() {
local page url dmg
log "Resolving latest Dolphin dev build..."
page="$(curl -fsSL "https://dolphin-emu.org/download/")"
url="$(echo "${page}" \
| grep -Eo 'https://dl\.dolphin-emu\.org/builds/[^"]+\.dmg' \
| head -n 1)"
[[ -n "${url}" ]] || fail "Could not resolve latest Dolphin DMG URL"
dmg="${DOWNLOADS_DIR}/Dolphin-latest.dmg"
download_to "${url}" "${dmg}"
install_app_from_dmg "${dmg}" "Dolphin"
remove_quarantine_if_present "/Applications/Dolphin.app"
}
install_pcsx2_latest() {
local url dmg
log "Resolving latest PCSX2 macOS release..."
url="$(github_latest_asset_url "PCSX2/pcsx2" 'macos.*\.dmg$')"
[[ -n "${url}" && "${url}" != "null" ]] || fail "Could not resolve latest PCSX2 macOS asset"
dmg="${DOWNLOADS_DIR}/PCSX2-latest.dmg"
download_to "${url}" "${dmg}"
install_app_from_dmg "${dmg}" "PCSX2"
remove_quarantine_if_present "/Applications/PCSX2.app"
}
install_rpcs3_latest() {
local arch repo url archive
arch="$(detect_arch)"
if [[ "${arch}" == "arm64" ]]; then
repo="RPCS3/rpcs3-binaries-mac-arm64"
else
repo="RPCS3/rpcs3-binaries-mac"
fi
log "Resolving latest RPCS3 build from ${repo}..."
url="$(github_latest_asset_url "${repo}" '\.7z$')"
[[ -n "${url}" && "${url}" != "null" ]] || fail "Could not resolve latest RPCS3 asset"
archive="${DOWNLOADS_DIR}/RPCS3-latest.7z"
download_to "${url}" "${archive}"
install_app_from_7z "${archive}" "RPCS3"
remove_quarantine_if_present "/Applications/RPCS3.app"
}
install_cemu_latest() {
local url dmg
log "Resolving latest Cemu macOS release..."
url="$(github_latest_asset_url "cemu-project/Cemu" 'macos.*\.dmg$')"
[[ -n "${url}" && "${url}" != "null" ]] || fail "Could not resolve latest Cemu macOS asset"
dmg="${DOWNLOADS_DIR}/Cemu-latest.dmg"
download_to "${url}" "${dmg}"
install_app_from_dmg "${dmg}" "Cemu"
remove_quarantine_if_present "/Applications/Cemu.app"
}
xml_escape() {
python3 - <<'PY' "$1"
import sys
from xml.sax.saxutils import escape
print(escape(sys.argv[1]))
PY
}
write_esde_custom_systems() {
cat > "${ESDE_CUSTOM_SYSTEMS_XML}" <<EOF
<?xml version="1.0"?>
<systemList>
<system>
<name>ps3</name>
<fullname>PlayStation 3</fullname>
<path>${ROMS_DIR}/ps3</path>
<extension>.ps3 .PS3 .iso .ISO .pkg .PKG</extension>
<command>${TOOLS_DIR}/launch-rpcs3.sh "%ROM_RAW%"</command>
<platform>ps3</platform>
<theme>ps3</theme>
</system>
<system>
<name>wiiu</name>
<fullname>Wii U</fullname>
<path>${ROMS_DIR}/wiiu</path>
<extension>.wud .WUD .wux .WUX .rpx .RPX .wua .WUA .elf .ELF</extension>
<command>${TOOLS_DIR}/launch-cemu.sh "%ROM_RAW%"</command>
<platform>wiiu</platform>
<theme>wiiu</theme>
</system>
</systemList>
EOF
log "Wrote ES-DE custom systems."
}
write_helper_scripts() {
cat > "${TOOLS_DIR}/launch-rpcs3.sh" <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
ROM="${1:-}"
if [[ -z "${ROM}" ]]; then
open -a "RPCS3"
else
open -a "RPCS3" --args "${ROM}"
fi
EOF
cat > "${TOOLS_DIR}/launch-cemu.sh" <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
ROM="${1:-}"
if [[ -z "${ROM}" ]]; then
open -a "Cemu"
else
open -a "Cemu" --args -g "${ROM}"
fi
EOF
cat > "${TOOLS_DIR}/open-esde.sh" <<'EOF'
#!/usr/bin/env bash
open -a "ES-DE"
EOF
chmod +x \
"${TOOLS_DIR}/launch-rpcs3.sh" \
"${TOOLS_DIR}/launch-cemu.sh" \
"${TOOLS_DIR}/open-esde.sh"
log "Helper scripts written."
}
write_esde_settings() {
mkdir -p "${ESDE_CONFIG_DIR}"
local roms_escaped
roms_escaped="$(xml_escape "${ROMS_DIR}")"
cat > "${ESDE_SETTINGS_FILE}" <<EOF
<?xml version="1.0"?>
<config>
<string name="ROMDirectory" value="${roms_escaped}" />
<bool name="Fullscreen" value="true" />
<bool name="EnableSplashScreen" value="false" />
<bool name="ShowHiddenGames" value="false" />
<bool name="ShowMediaTypes" value="true" />
<bool name="ScreenSaverControls" value="true" />
<bool name="QuickSystemSelect" value="true" />
<string name="ThemeSet" value="${ESDE_THEME}" />
</config>
EOF
log "Wrote ES-DE settings defaults."
}
upsert_cfg_key() {
local file="$1"
local key="$2"
local value="$3"
mkdir -p "$(dirname "${file}")"
touch "${file}"
if grep -qE "^${key} = " "${file}"; then
perl -0pi -e "s#^${key} = \".*?\"#${key} = \"${value}\"#mg" "${file}"
else
printf '\n%s = "%s"\n' "${key}" "${value}" >> "${file}"
fi
}
patch_retroarch_if_present() {
if [[ "${PATCH_RETROARCH_IF_PRESENT}" != "true" ]]; then
log "Skipping RetroArch patching."
return
fi
if [[ ! -f "${RETROARCH_CFG}" && ! -d "/Applications/RetroArch.app" ]]; then
log "RetroArch not found; skipping RetroArch config patching."
return
fi
log "Patching RetroArch defaults..."
upsert_cfg_key "${RETROARCH_CFG}" "savefile_directory" "${SAVES_DIR}"
upsert_cfg_key "${RETROARCH_CFG}" "savestate_directory" "${STATES_DIR}"
upsert_cfg_key "${RETROARCH_CFG}" "screenshot_directory" "${SCREENSHOTS_DIR}"
upsert_cfg_key "${RETROARCH_CFG}" "system_directory" "${BIOS_DIR}"
upsert_cfg_key "${RETROARCH_CFG}" "rgui_browser_directory" "${ROMS_DIR}"
upsert_cfg_key "${RETROARCH_CFG}" "video_fullscreen" "true"
upsert_cfg_key "${RETROARCH_CFG}" "input_enable_hotkey" "nul"
upsert_cfg_key "${RETROARCH_CFG}" "input_exit_emulator" "escape"
log "RetroArch config patched."
}
patch_dolphin_defaults() {
mkdir -p "${DOLPHIN_USER_DIR}/Config"
local ini="${DOLPHIN_USER_DIR}/Config/Dolphin.ini"
touch "${ini}"
grep -q "^\[Interface\]" "${ini}" || printf '\n[Interface]\n' >> "${ini}"
grep -q "^\[Display\]" "${ini}" || printf '\n[Display]\n' >> "${ini}"
if ! grep -q "^Fullscreen = " "${ini}"; then
printf 'Fullscreen = True\n' >> "${ini}"
fi
log "Dolphin defaults patched."
}
patch_pcsx2_defaults() {
mkdir -p "${PCSX2_INI_DIR}"
local ini="${PCSX2_INI_DIR}/PCSX2.ini"
touch "${ini}"
grep -q "^\[UI\]" "${ini}" || printf '\n[UI]\n' >> "${ini}"
grep -q "^StartFullscreen = " "${ini}" || printf 'StartFullscreen = true\n' >> "${ini}"
log "PCSX2 defaults patched."
}
write_hotkey_notes() {
cat > "${TOOLS_DIR}/HOTKEYS.txt" <<EOF
Controller-friendly notes
=========================
ES-DE
-----
- Fullscreen is enabled by default in the generated settings.
RetroArch
---------
- This script patches fullscreen and directory defaults if RetroArch is already installed.
- ES-DE's macOS guide notes RetroArch should start in fullscreen so focus handoff works correctly.
Dolphin
-------
- Fullscreen default is patched.
- Set your controller inside Dolphin once:
Controllers -> Port 1 -> configure gamepad
- For Wii titles, configure Real Wii Remote or emulated Wii Remote as needed.
PCSX2
-----
- StartFullscreen is enabled in PCSX2.ini if that file exists/gets created.
- Configure your controller once in PCSX2 settings.
RPCS3
-----
- Configure your pad once inside RPCS3:
Pads -> Handler -> SDL
- SDL is usually the least painful option for Xbox / DualSense on macOS.
Cemu
----
- On Apple Silicon, current public macOS Cemu builds are still x86-64 and rely on Rosetta 2.
- Configure input once inside Cemu after first launch.
EOF
log "Wrote hotkey/controller notes."
}
write_readme() {
cat > "${EMULATION_ROOT}/README.txt" <<EOF
Mac Emulation Setup
===================
Installed apps
--------------
/Applications/ES-DE.app
/Applications/Dolphin.app
/Applications/PCSX2.app
/Applications/RPCS3.app
/Applications/Cemu.app
Folders
-------
roms/ Put game files here
bios/ BIOS / firmware where needed
saves/ Save files
states/ Save states
screenshots/ Screenshots
tools/ Launcher/helper scripts
Important ROM paths
-------------------
${ROMS_DIR}/gamecube
${ROMS_DIR}/wii
${ROMS_DIR}/ps2
${ROMS_DIR}/ps3
${ROMS_DIR}/wiiu
Notes
-----
1. Point ES-DE at:
${ROMS_DIR}
2. Add your own firmware / BIOS:
- PS2 BIOS
- PS3 firmware
- Wii U keys / files
- any required RetroArch system BIOS
3. Pair your controller in macOS and map it in each emulator once.
4. Read:
${TOOLS_DIR}/HOTKEYS.txt
EOF
log "README written."
}
open_apps_once() {
if [[ "${OPEN_APPS_ON_FINISH}" != "true" ]]; then
log "Skipping first-launch app initialisation."
return
fi
local apps=("Dolphin" "PCSX2" "RPCS3" "Cemu" "ES-DE")
for app in "${apps[@]}"; do
log "Opening ${app} once..."
open -a "${app}" || log "Could not open ${app}; continuing."
sleep 4
osascript -e "tell application \"${app}\" to quit" >/dev/null 2>&1 || true
sleep 1
done
}
add_esde_to_login_items() {
if [[ "${ADD_ESDE_TO_LOGIN_ITEMS}" != "true" ]]; then
log "Skipping Login Items change."
return
fi
log "Adding ES-DE to Login Items..."
osascript <<'EOF'
tell application "System Events"
if not (exists login item "ES-DE") then
make login item at end with properties {path:"/Applications/ES-DE.app", hidden:false}
end if
end tell
EOF
}
print_next_steps() {
cat <<EOF
============================================
Setup complete
============================================
Installed:
- ES-DE
- Dolphin
- PCSX2
- RPCS3
- Cemu
Created:
- ${EMULATION_ROOT}
- ${ROMS_DIR}
- ${BIOS_DIR}
- ${TOOLS_DIR}
- ${ESDE_CUSTOM_SYSTEMS_XML}
- ${ESDE_SETTINGS_FILE}
Next steps:
1. Put ROMs into:
${ROMS_DIR}/<system>
2. Add required firmware / BIOS yourself:
- PS2 BIOS
- PS3 firmware
- Wii U keys / install data
- any RetroArch system BIOS files
3. Open each emulator once and configure your controller:
- Dolphin
- PCSX2
- RPCS3
- Cemu
4. Open ES-DE and verify:
- controller navigation works
- fullscreen is on
- your theme looks right
- scraping is configured
Optional:
- Re-run with:
ADD_ESDE_TO_LOGIN_ITEMS=true ./$(basename "$0")
to add ES-DE to Login Items
Log file:
- ${LOG_FILE}
EOF
}
main() {
require_macos
create_directories
log "Starting Mac emulation bootstrap v3.1..."
log "Architecture: $(detect_arch)"
ensure_xcode_cli_tools
ensure_homebrew
ensure_brew_shellenv_loaded
ensure_rosetta_if_needed
install_prereqs
install_esde_latest
install_dolphin_latest
install_pcsx2_latest
install_rpcs3_latest
install_cemu_latest
write_esde_custom_systems
write_helper_scripts
write_esde_settings
patch_retroarch_if_present
patch_dolphin_defaults
patch_pcsx2_defaults
write_hotkey_notes
write_readme
open_apps_once
add_esde_to_login_items
print_next_steps
log "Bootstrap completed successfully."
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment